From bc0139f8d1a4348cb90c101eadacec4dbda1042b Mon Sep 17 00:00:00 2001 From: RaghavsBrowserStack Date: Wed, 15 Jul 2026 16:37:03 +0530 Subject: [PATCH 01/14] feat(cli-command,client): add IntelliStory storybook affected-story filtering Introduces IntelliStory: given a Storybook build, it diffs against a baseline (explicit or API-predicted) and filters the snapshot set down to the stories a change actually affects, bailing to a full run whenever it can't reason about the change. - cli-command: intelliStory.js (applyIntelliStory, IntelliStoryBailError), lockfileDiff.js and graphTrace.js, exported via the ./intelliStory subpath; adds glob-to-regexp, stream-json and optional snyk-nodejs-lockfile-parser. - client: getStatus() accepts the intelli_story_graph job type, plus getIntelliStorySnapshotNameToCommit() and generateIntelliStoryGraph() hitting the /intelli_story endpoints. Binds createRequire to cjsRequire (not `require`) in intelliStory.js and lockfileDiff.js so the CommonJS-transpiled packaged binary doesn't crash with "_require is not a function", and adds noRequireBinding.test.js as a static regression guard (with the matching .semgrepignore rationale). --- .semgrepignore | 13 + packages/cli-command/package.json | 8 +- packages/cli-command/src/graphTrace.js | 154 ++++ .../cli-command/src/graphTraceTemplate.html | 349 ++++++++ packages/cli-command/src/index.js | 1 + packages/cli-command/src/intelliStory.js | 658 ++++++++++++++++ packages/cli-command/src/lockfileDiff.js | 172 ++++ packages/cli-command/test/graphTrace.test.js | 247 ++++++ packages/cli-command/test/index.test.js | 17 + .../cli-command/test/intelliStory.test.js | 743 ++++++++++++++++++ .../cli-command/test/lockfileDiff.test.js | 115 +++ .../cli-command/test/noRequireBinding.test.js | 99 +++ packages/client/src/client.js | 54 +- packages/client/test/client.test.js | 121 +++ yarn.lock | 647 ++++++++++++++- 15 files changed, 3391 insertions(+), 7 deletions(-) create mode 100644 packages/cli-command/src/graphTrace.js create mode 100644 packages/cli-command/src/graphTraceTemplate.html create mode 100644 packages/cli-command/src/intelliStory.js create mode 100644 packages/cli-command/src/lockfileDiff.js create mode 100644 packages/cli-command/test/graphTrace.test.js create mode 100644 packages/cli-command/test/index.test.js create mode 100644 packages/cli-command/test/intelliStory.test.js create mode 100644 packages/cli-command/test/lockfileDiff.test.js create mode 100644 packages/cli-command/test/noRequireBinding.test.js diff --git a/.semgrepignore b/.semgrepignore index 854bd41f2..63365813c 100644 --- a/.semgrepignore +++ b/.semgrepignore @@ -38,3 +38,16 @@ packages/core/src/api.js # in the file-load helper anyway. No user input flows here. packages/core/test/unit/maestro-hierarchy.test.js packages/core/test/unit/maestro-hierarchy.parity.test.js + +# Regression guard for the packaged-binary crash (PER-9666): it statically +# scans the repo's own source tree for the `require = createRequire` footgun. +# To walk that tree it builds paths with path.join()/path.resolve() inside its +# findRepoRoot() and collectSourceFiles() helpers, which trips semgrep's +# javascript.lang.security.audit.path-traversal.path-join-resolve-traversal +# rule. No user input flows into those joins — every path is derived from +# process.cwd() and fs.readdir() of the repo's own packages/ directory, with a +# fixed allowlist of source extensions and a skip-list of build/dep dirs. It is +# a build-time test, never shipped or exposed to external input. The rule +# cannot follow that the inputs are filesystem-internal, so it false-positives +# on every join. Suppress at the file level with this rationale. +packages/cli-command/test/noRequireBinding.test.js diff --git a/packages/cli-command/package.json b/packages/cli-command/package.json index ba0216129..a9cdb9bd3 100644 --- a/packages/cli-command/package.json +++ b/packages/cli-command/package.json @@ -27,6 +27,7 @@ ".": "./dist/index.js", "./flags": "./dist/flags.js", "./utils": "./dist/utils.js", + "./intelliStory": "./dist/intelliStory.js", "./test/helpers": "./test/helpers.js" }, "scripts": { @@ -38,6 +39,11 @@ "dependencies": { "@percy/config": "1.32.4", "@percy/core": "1.32.4", - "@percy/logger": "1.32.4" + "@percy/logger": "1.32.4", + "glob-to-regexp": "^0.4.1", + "stream-json": "^1.8.0" + }, + "optionalDependencies": { + "snyk-nodejs-lockfile-parser": "2.7.1" } } diff --git a/packages/cli-command/src/graphTrace.js b/packages/cli-command/src/graphTrace.js new file mode 100644 index 000000000..f869286c8 --- /dev/null +++ b/packages/cli-command/src/graphTrace.js @@ -0,0 +1,154 @@ +import fs from 'fs'; +import path from 'path'; +import url from 'url'; + +// Template resolution mirrors core/utils.js's secretPatterns.yml lookup: +// resolves relative to this file's URL so it works under src/ (dev) and +// dist/ (installed) without bundler help. The .html file is copied alongside +// by babel's copyFiles when cli-command is built. +const TEMPLATE_PATH = path.resolve(url.fileURLToPath(import.meta.url), '../graphTraceTemplate.html'); + +// Maps a (raw kind, changed) pair to the kind value the template expects: +// 'package' | 'component' | 'story' | 'is_relevant'. `changed: true` wins +// over the underlying kind so any node touched in the diff renders purple. +function templateKindOf(v) { + if (v.changed) return 'is_relevant'; + switch (v.kind) { + case 'dependency': return 'package'; + case 'component': return 'component'; + case 'story': return 'story'; + default: return 'component'; + } +} + +// Sort order within a column: packages left, components middle, stories right. +// `is_relevant` shares rank with components so a changed node doesn't jump +// out of its own group — it just recolors. +const KIND_RANK = { package: 0, component: 1, is_relevant: 1, story: 2 }; + +// Layout algorithm (ported from the original Ruby renderer): +// 1. col = longest-path depth reaching the vertex (read from the +// transitive-closure triples the API sends), with dependencies pinned +// to col 0. +// 2. Propagate over edges so col[target] > col[source]. Bounded loop +// guards against degenerate inputs. +// 3. Stories pushed past the rightmost non-story column. +// 4. Within each column, sort by (kind-rank, name) and assign row. +function computeLayout(rawVertices, edges, transitiveClosure) { + const n = rawVertices.length; + const vertices = rawVertices.map((v, i) => ({ + index: i, + name: v.file_path, + kind: v.kind, + changed: !!v.changed, + row: 0, + col: 0 + })); + + // 1. Seed col from incoming transitive-closure lengths. + const incomingMax = new Array(n).fill(0); + for (const triple of transitiveClosure) { + const [u, v, val] = triple; + if (u === v || val <= 0) continue; + if (v < 0 || v >= n) continue; + if (val > incomingMax[v]) incomingMax[v] = val; + } + for (let i = 0; i < n; i++) { + vertices[i].col = vertices[i].kind === 'dependency' ? 0 : incomingMax[i] + 1; + } + + // 2. Propagate edge constraint. n+2 iterations is enough for any DAG + // and bounds the work on accidentally-cyclic input. + const iterations = n + 2; + for (let iter = 0; iter < iterations; iter++) { + let changed = false; + for (const [s, t] of edges) { + if (s < 0 || s >= n || t < 0 || t >= n) continue; + if (vertices[s].col < vertices[t].col) continue; + vertices[t].col = vertices[s].col + 1; + changed = true; + } + if (!changed) break; + } + + // 3. Stories rightmost. Two passes: max across non-stories first, then + // push every story past that boundary. Folding into one loop would let + // stories visited before the last non-story keep a stale max. + let furthestNonStory = 0; + for (const v of vertices) { + if (v.kind === 'story') continue; + if (v.col > furthestNonStory) furthestNonStory = v.col; + } + for (const v of vertices) { + if (v.kind !== 'story') continue; + if (v.col < furthestNonStory + 1) v.col = furthestNonStory + 1; + } + + // 4. Group by column, sort by (kind-rank, name), assign row. + const groups = new Map(); + for (const v of vertices) { + let list = groups.get(v.col); + if (!list) groups.set(v.col, list = []); + list.push(v); + } + const rankOf = v => { + const r = KIND_RANK[templateKindOf(v)]; + /* istanbul ignore next: templateKindOf always returns a kind present in + KIND_RANK, so the `=== undefined` fallback is defensive */ + return r === undefined ? 99 : r; + }; + for (const list of groups.values()) { + list.sort((a, b) => { + const ra = rankOf(a); + const rb = rankOf(b); + if (ra !== rb) return ra - rb; + // Byte-wise compare on name to match Ruby's String#<=> behaviour. + if (a.name < b.name) return -1; + if (a.name > b.name) return 1; + return 0; + }); + list.forEach((v, row) => { v.row = row; }); + } + + // 5. Final shape the template consumes: drop `changed`, fold it into kind. + return vertices.map(v => ({ + index: v.index, + name: v.name, + row: v.row, + col: v.col, + kind: templateKindOf(v) + })); +} + +// Escapes characters that have meaning inside a `; `` cover HTML comment confusion; U+2028 +// and U+2029 are valid JSON but illegal in JS string literals pre-ES2019 and +// have historically been XSS sinks. +const LS = String.fromCharCode(0x2028); +const PS = String.fromCharCode(0x2029); +function safeJson(obj) { + return JSON.stringify(obj) + .replace(/<\//g, '<\\/') + .replace(/`; + + function hostileLine() { + return embeddedJson(renderGraphTraceHtml({ + vertices: [{ kind: 'component', file_path: hostile }], + edges: [], + transitiveClosureMatrixSparse: [] + }), 'vertices'); + } + + it('escapes " { + let line = hostileLine(); + expect(line).not.toContain(''); + expect(line).toContain('<\\/script>'); + }); + + it('escapes HTML comment open and close markers', () => { + let line = hostileLine(); + expect(line).toContain('<\\!--'); + expect(line).toContain('--\\>'); + }); + + it('escapes U+2028 and U+2029 line/paragraph separators', () => { + let line = hostileLine(); + expect(line).not.toContain(LS); + expect(line).not.toContain(PS); + expect(line).toContain('\\u2028'); + expect(line).toContain('\\u2029'); + }); + + it('escapes only the dangerous sequences, leaving the payload intact', () => { + // The output is embedded in a `; `` cover HTML comment confusion; U+2028 -// and U+2029 are valid JSON but illegal in JS string literals pre-ES2019 and -// have historically been XSS sinks. const LS = String.fromCharCode(0x2028); const PS = String.fromCharCode(0x2029); function safeJson(obj) { @@ -136,10 +103,6 @@ function safeJson(obj) { .split(PS).join('\\u2029'); } -// Populates the trace template with the three JSON payloads the page needs. -// Input shape matches the API's graph data: `vertices` carries `kind`, -// `file_path`, `changed`; `edges` and `transitive_closure_matrix_sparse` -// are arrays of integer tuples. export function renderGraphTraceHtml({ vertices, edges, transitiveClosureMatrixSparse }) { const laidOutVertices = computeLayout( vertices || [], diff --git a/packages/cli-command/src/graphTraceTemplate.html b/packages/cli-command/src/graphTraceTemplate.html index 0b1e48ecb..dee9c25a4 100644 --- a/packages/cli-command/src/graphTraceTemplate.html +++ b/packages/cli-command/src/graphTraceTemplate.html @@ -115,23 +115,11 @@
`; @@ -234,10 +223,7 @@ describe('graphTrace', () => { }); it('escapes only the dangerous sequences, leaving the payload intact', () => { - // The output is embedded in a `; @@ -223,7 +216,6 @@ describe('graphTrace', () => { }); it('escapes only the dangerous sequences, leaving the payload intact', () => { - let restored = hostileLine() .split('<\\!--').join(''); diff --git a/packages/cli-command/test/intelliStory.test.js b/packages/cli-command/test/intelliStory.test.js index 78d6deb71..c71832892 100644 --- a/packages/cli-command/test/intelliStory.test.js +++ b/packages/cli-command/test/intelliStory.test.js @@ -113,7 +113,6 @@ describe('intelliStory', () => { }); it('anchors a traversal-prefixed statsFile inside the build dir via basename', async () => { - await mockfs({ '/build/foo.json': JSON.stringify({ buildId: 'b', modules: [] }) }); let res = await validateAndReadStats('/build', '../../etc/foo.json', '/root', log); expect(res.buildId).toEqual('b'); @@ -249,7 +248,6 @@ describe('intelliStory', () => { }); it('treats an over-long glob as non-matching instead of throwing', () => { - expect(() => assertNoBailOnChanges(['yarn.lock'], ['*'.repeat(600)])).not.toThrow(); }); }); @@ -533,10 +531,8 @@ describe('intelliStory', () => { } if (NODE_MAJOR >= 18) { - expect(res).toBeDefined(); } else { - expect(res).toBeInstanceOf(IntelliStoryBailError); expect(res.message).toContain('snyk-nodejs-lockfile-parser is not available'); } @@ -658,14 +654,12 @@ describe('intelliStory', () => { const STATS = JSON.stringify({ buildId: 'bld-1', modules: [] }); it('bails when no build directory is provided', async () => { - await expectBail( () => applyIntelliStory({ client: {} }, [], undefined, undefined), 'requires the Storybook build directory'); }); it('bails when nothing is affected after filtering', async () => { - let { dir } = setup({ 'sb/enriched-stats.json': STATS, 'src/A.stories.jsx': 'v1' }); await expectBail( () => applyIntelliStory({ client: {} }, [{ name: 'A', importPath: 'src/A.stories.jsx' }], diff --git a/packages/cli-command/test/lockfileDiff.test.js b/packages/cli-command/test/lockfileDiff.test.js index 1c8155512..c5a4c5dd0 100644 --- a/packages/cli-command/test/lockfileDiff.test.js +++ b/packages/cli-command/test/lockfileDiff.test.js @@ -38,7 +38,6 @@ describe('lockfileDiff', () => { const diff = opts => diffLockfileDeps({ lockfileType: 'package-lock.json', ...opts }); it('flags a top-level dependency whose resolved version changed', async () => { - await expectAsync(diff({ oldPackageJson: packageJson({ dependencies: { 'left-pad': '^1.0.0' } }), packageJson: packageJson({ dependencies: { 'left-pad': '^1.0.0' } }), @@ -66,7 +65,6 @@ describe('lockfileDiff', () => { }); it('flags a range-only bump even when the resolved version is identical', async () => { - await expectAsync(diff({ oldPackageJson: packageJson({ dependencies: { 'left-pad': '^1.0.0' } }), packageJson: packageJson({ dependencies: { 'left-pad': '^1.2.0' } }), @@ -85,7 +83,6 @@ describe('lockfileDiff', () => { }); it('ignores changes to devDependencies', async () => { - await expectAsync(diff({ oldPackageJson: packageJson({ devDependencies: { 'left-pad': '^1.0.0' } }), packageJson: packageJson({ devDependencies: { 'left-pad': '^1.0.0' } }), diff --git a/packages/cli-command/test/noRequireBinding.test.js b/packages/cli-command/test/noRequireBinding.test.js index 5ed5201a7..e1988b670 100644 --- a/packages/cli-command/test/noRequireBinding.test.js +++ b/packages/cli-command/test/noRequireBinding.test.js @@ -44,7 +44,6 @@ describe('source: no `require = createRequire` binding', () => { const files = collectSourceFiles(root); it('scans a non-trivial number of source files', () => { - expect(files.length).toBeGreaterThan(20); }); From 893cd7efd316d235026407e31830038de8f2a3cb Mon Sep 17 00:00:00 2001 From: RaghavsBrowserStack Date: Fri, 17 Jul 2026 06:25:23 +0530 Subject: [PATCH 04/14] Update CLI for new API --- packages/cli-command/src/index.js | 2 +- packages/cli-command/src/intelliStory.js | 102 +++-- .../cli-command/test/intelliStory.test.js | 125 +++--- packages/cli/bin/run.js | 398 ++++++++++++++++++ packages/client/src/client.js | 34 +- packages/client/test/client.test.js | 20 +- .../Archived Snapshot-0ba6aa12.json | 1 + packages/core/src/config.js | 5 + packages/core/src/percy.js | 15 +- packages/core/src/snapshot.js | 6 +- 10 files changed, 591 insertions(+), 117 deletions(-) create mode 100755 packages/cli/bin/run.js create mode 100644 packages/core/percy-archive/Archived Snapshot-0ba6aa12.json diff --git a/packages/cli-command/src/index.js b/packages/cli-command/src/index.js index ced7a380d..7be1b9f9d 100644 --- a/packages/cli-command/src/index.js +++ b/packages/cli-command/src/index.js @@ -1,6 +1,6 @@ export { default, command, _resetShutdownForTest } from './command.js'; export { legacyCommand, legacyFlags as flags } from './legacy.js'; -export { applyIntelliStory, IntelliStoryBailError } from './intelliStory.js'; +export { applyIntelliStory, writeIntelliStoryTrace, IntelliStoryBailError } from './intelliStory.js'; // export common packages to avoid dependency resolution issues export { default as PercyConfig } from '@percy/config'; export { default as logger } from '@percy/logger'; diff --git a/packages/cli-command/src/intelliStory.js b/packages/cli-command/src/intelliStory.js index 250e1cc02..c2ebdd72f 100644 --- a/packages/cli-command/src/intelliStory.js +++ b/packages/cli-command/src/intelliStory.js @@ -189,13 +189,12 @@ export async function validateAndReadStats(buildDir, statsFile, projectRoot, log } log.debug(`IntelliStory: parsing stats file ${resolvedStatsPath}`); - const { files, modules, buildId } = await readStats(resolvedStatsPath, projectRoot); + // The graph is now keyed by the Percy build id, not the stats-file `buildId`, + // so a missing `buildId` in the stats file is no longer fatal. We only need + // the module graph (`files`/`modules`) from here. + const { files, modules } = await readStats(resolvedStatsPath, projectRoot); - if (typeof buildId !== 'string' || !buildId) { - throw new IntelliStoryBailError(`IntelliStory: stats file at ${resolvedStatsPath} is missing a top-level "buildId" — running full snapshot set`); - } - - return { files, modules, buildId }; + return { files, modules }; } export async function getBaselineAndAffectedNodes(percy, baseline, log) { @@ -326,13 +325,10 @@ export async function runGraphGeneration(percy, buildId, payload, log) { files, modules, storybookPaths, affectedNodes, affectedFileLocations }); - const { status, data } = await pollGraphStatus(percy, buildId, log); + const { status } = await pollGraphStatus(percy, buildId, log); if (status !== 'done') { throw new IntelliStoryBailError(`IntelliStory: graph generation did not complete (status: ${status ?? 'timed out'}); running full snapshot set`); } - - log.debug(`IntelliStory: affected stories result ${JSON.stringify(data?.affected_stories)}`); - return data; } export function maybeWriteTrace(trace, data, log) { @@ -352,45 +348,30 @@ export function maybeWriteTrace(trace, data, log) { } } -export function selectAffectedSnapshots(snapshots, data, baseline, baselineSnapshots, normalizeImportPath, log) { - const affected = new Set(data?.affected_stories || []); - - const FORCE_RESNAPSHOT_STATES = new Set(['failed', 'rejected']); - const needsBaselineRefresh = name => { - if (baseline) return false; - const state = baselineSnapshots[name]; - return state === undefined || FORCE_RESNAPSHOT_STATES.has(state); - }; - - let forced = 0; - let affectedKept = 0; - const filtered = snapshots.filter(s => { - if (needsBaselineRefresh(s.name)) { - forced += 1; - return true; - } - const p = normalizeImportPath(s.importPath); - if (p && affected.has(p)) { - affectedKept += 1; - return true; - } - return false; - }); - log.info(`IntelliStory: ${filtered.length} of ${snapshots.length} snapshots kept (${affectedKept} via affected-graph, ${forced} via missing/failed/rejected baseline)`); - return filtered; -} +// Baseline states that must always be re-snapshotted: a snapshot with no +// baseline yet, or whose baseline failed/was rejected, cannot be safely skipped +// by server-side selection. +const FORCE_RESNAPSHOT_STATES = new Set(['failed', 'rejected']); export async function applyIntelliStory(percy, snapshots, intelliStoryConfig, buildDir) { const log = logger('storybook:intelliStory'); - const { baseline, untraced, trace, bailOnChanges, statsFile } = intelliStoryConfig || {}; + const { baseline, untraced, bailOnChanges, statsFile } = intelliStoryConfig || {}; if (!buildDir) { throw new IntelliStoryBailError('IntelliStory requires the Storybook build directory (e.g. `percy storybook ./storybook-static`); URL and `start` modes are not supported. Running full snapshot set'); } + // The graph is keyed by the real Percy build id. The build is created up + // front for IntelliStory runs (see @percy/storybook); if it is not present + // (e.g. a dry run, or build creation failed) there is nothing to key on. + const buildId = percy.build?.id; + if (!buildId) { + throw new IntelliStoryBailError('IntelliStory: Percy build was not created (dry run or build creation failed); running full snapshot set'); + } + const projectRoot = gitProjectRoot(); - const { files, modules, buildId } = await validateAndReadStats(buildDir, statsFile, projectRoot, log); + const { files, modules } = await validateAndReadStats(buildDir, statsFile, projectRoot, log); let { baseRef, affectedNodes, baselineSnapshots } = await getBaselineAndAffectedNodes(percy, baseline, log); @@ -428,9 +409,46 @@ export async function applyIntelliStory(percy, snapshots, intelliStoryConfig, bu const affectedFileLocations = getAffectedFileLocations(baseRef, files); - const data = await runGraphGeneration(percy, buildId, { files, modules, storybookPaths, affectedNodes, affectedFileLocations }, log); + // Enqueue the affected-story graph against the Percy build. Snapshot + // selection now happens server-side (when snapshots are posted), so we no + // longer read affected_stories back here or write the trace — we only kick + // off generation and surface a failure by bailing to the full set. + await runGraphGeneration(percy, buildId, { files, modules, storybookPaths, affectedNodes, affectedFileLocations }, log); - maybeWriteTrace(trace, data, log); + // A snapshot that must be force re-snapshotted (no baseline yet, or a + // failed/rejected baseline, when no explicit baseline is set) has IntelliStory + // disabled so the API never selects it out — it is always captured. + const needsBaselineRefresh = name => { + if (baseline) return false; + const state = baselineSnapshots?.[name]; + return state === undefined || FORCE_RESNAPSHOT_STATES.has(state); + }; + + // Tag every snapshot with `intelliStory` and its normalized `storybookPath` + // so the API can perform affected-story selection when each is posted. + return snapshots.map(s => ({ + ...s, + intelliStory: !needsBaselineRefresh(s.name), + storybookPath: normalizeImportPath(s.importPath) + })); +} - return selectAffectedSnapshots(snapshots, data, baseline, baselineSnapshots, normalizeImportPath, log); +// Called after the build has been finalized. At that point the graph job's +// data (vertices/edges/transitive closure) is available from job status, so we +// fetch it once more and write the trace when `trace` is enabled. +export async function writeIntelliStoryTrace(percy, intelliStoryConfig, log = logger('storybook:intelliStory')) { + const { trace } = intelliStoryConfig || {}; + if (!trace) return; + + const buildId = percy.build?.id; + if (!buildId) return; + + log.debug(`IntelliStory: fetching finalized graph data for build ${buildId} to write trace`); + const { status, data } = await pollGraphStatus(percy, buildId, log); + if (status !== 'done') { + log.debug(`IntelliStory: graph status "${status ?? 'timed out'}" after finalize; skipping trace`); + return; + } + + maybeWriteTrace(trace, data, log); } diff --git a/packages/cli-command/test/intelliStory.test.js b/packages/cli-command/test/intelliStory.test.js index c71832892..48c800156 100644 --- a/packages/cli-command/test/intelliStory.test.js +++ b/packages/cli-command/test/intelliStory.test.js @@ -15,7 +15,6 @@ import { extractStorybookPaths, runGraphGeneration, maybeWriteTrace, - selectAffectedSnapshots, applyIntelliStory } from '../src/intelliStory.js'; @@ -99,23 +98,16 @@ describe('intelliStory', () => { 'is not a regular file'); }); - it('bails when the stats file has no top-level buildId', async () => { + it('reads files and modules from a valid stats file (buildId no longer required)', async () => { await mockfs({ '/build/enriched-stats.json': JSON.stringify({ modules: [] }) }); - await expectBail( - () => validateAndReadStats('/build', undefined, '/root', log), - 'missing a top-level "buildId"'); - }); - - it('reads files, modules and buildId from a valid stats file', async () => { - await mockfs({ '/build/enriched-stats.json': JSON.stringify({ buildId: 'bld-1', modules: [] }) }); let res = await validateAndReadStats('/build', undefined, '/root', log); - expect(res).toEqual({ files: [], modules: [], buildId: 'bld-1' }); + expect(res).toEqual({ files: [], modules: [] }); }); it('anchors a traversal-prefixed statsFile inside the build dir via basename', async () => { - await mockfs({ '/build/foo.json': JSON.stringify({ buildId: 'b', modules: [] }) }); + await mockfs({ '/build/foo.json': JSON.stringify({ modules: [] }) }); let res = await validateAndReadStats('/build', '../../etc/foo.json', '/root', log); - expect(res.buildId).toEqual('b'); + expect(res).toEqual({ files: [], modules: [] }); }); it('streams modules: indexes src refs, leaves module refs, drops node_modules/string-id and id-less entries', async () => { @@ -142,8 +134,6 @@ describe('intelliStory', () => { let res = await validateAndReadStats('/build', undefined, '/root', log); - expect(res.buildId).toEqual('b'); - expect(res.files).toEqual([path.join('src', 'A.js'), path.join('src', 'B.js'), path.join('src', 'C.js')]); expect(res.modules.length).toEqual(2); expect(res.modules[0].id).toEqual(0); @@ -410,51 +400,6 @@ describe('intelliStory', () => { }); }); - describe('selectAffectedSnapshots()', () => { - it('keeps only affected snapshots when an explicit baseline is set', () => { - let log = mockLog(); - let snapshots = [ - { name: 'A', importPath: 'src/A.stories.js' }, - { name: 'B', importPath: 'src/B.stories.js' } - ]; - let data = { affected_stories: ['src/A.stories.js'] }; - - let filtered = selectAffectedSnapshots(snapshots, data, 'main', null, identity, log); - - expect(filtered.map(s => s.name)).toEqual(['A']); - }); - - it('forces re-snapshot for missing, failed and rejected baselines', () => { - let log = mockLog(); - let snapshots = [ - { name: 'A', importPath: 'src/A.stories.js' }, - { name: 'B', importPath: 'src/B.stories.js' }, - { name: 'C', importPath: 'src/C.stories.js' }, - { name: 'D', importPath: 'src/D.stories.js' } - ]; - let baselineSnapshots = { A: 'approved', B: 'failed', D: 'approved' }; - let data = { affected_stories: ['src/A.stories.js'] }; - - let filtered = selectAffectedSnapshots(snapshots, data, undefined, baselineSnapshots, identity, log); - - expect(filtered.map(s => s.name)).toEqual(['A', 'B', 'C']); - }); - - it('keeps nothing when the graph reports no affected stories and a baseline is set', () => { - let log = mockLog(); - let snapshots = [{ name: 'A', importPath: 'src/A.stories.js' }]; - let filtered = selectAffectedSnapshots(snapshots, { affected_stories: [] }, 'main', null, identity, log); - expect(filtered).toEqual([]); - }); - - it('treats a payload with no affected_stories field as none affected', () => { - let log = mockLog(); - let snapshots = [{ name: 'A', importPath: 'src/A.stories.js' }]; - - expect(selectAffectedSnapshots(snapshots, {}, 'main', null, identity, log)).toEqual([]); - }); - }); - describe('runGraphGeneration() polling', () => { beforeEach(() => jasmine.clock().install()); afterEach(() => jasmine.clock().uninstall()); @@ -659,25 +604,34 @@ describe('intelliStory', () => { 'requires the Storybook build directory'); }); - it('bails when nothing is affected after filtering', async () => { + it('bails when the Percy build has not been created', async () => { let { dir } = setup({ 'sb/enriched-stats.json': STATS, 'src/A.stories.jsx': 'v1' }); await expectBail( () => applyIntelliStory({ client: {} }, [{ name: 'A', importPath: 'src/A.stories.jsx' }], { baseline: 'HEAD' }, path.join(dir, 'sb')), + 'Percy build was not created'); + }); + + it('bails when nothing is affected after filtering', async () => { + let { dir } = setup({ 'sb/enriched-stats.json': STATS, 'src/A.stories.jsx': 'v1' }); + await expectBail( + () => applyIntelliStory({ client: {}, build: { id: '123' } }, [{ name: 'A', importPath: 'src/A.stories.jsx' }], + { baseline: 'HEAD' }, path.join(dir, 'sb')), 'no affected files or packages detected'); }); - itPosix('keeps only the snapshots the affected-graph reports', async () => { + itPosix('tags every snapshot for server-side selection and enqueues graph generation against the Percy build id', async () => { let { dir, baseSha } = setup( { 'sb/enriched-stats.json': STATS, 'src/A.stories.jsx': 'v1' }, { 'src/A.stories.jsx': 'v2' }); - let data = { affected_stories: [path.join('src', 'A.stories.jsx'), path.join('src', 'Dot.stories.jsx')] }; let generate = jasmine.createSpy('generateIntelliStoryGraph'); let percy = { + build: { id: '456' }, client: { generateIntelliStoryGraph: generate, - getStatus: async () => ({ status: 'done', data }) + // job status no longer returns affected_stories during the run + getStatus: async () => ({ status: 'done', data: {} }) } }; let snapshots = [ @@ -689,8 +643,49 @@ describe('intelliStory', () => { let result = await applyIntelliStory(percy, snapshots, { baseline: baseSha, trace: false }, path.join(dir, 'sb')); - expect(result.map(s => s.name).sort()).toEqual(['A', 'Dot']); - expect(generate).toHaveBeenCalled(); + // all snapshots are returned (the API performs selection when they post) + expect(result.map(s => s.name).sort()).toEqual(['A', 'Dot', 'Empty', 'NoPath']); + // each is tagged for IntelliStory with its normalized storybook path + expect(result.every(s => s.intelliStory === true)).toBe(true); + expect(result.find(s => s.name === 'A').storybookPath).toEqual(path.join('src', 'A.stories.jsx')); + expect(result.find(s => s.name === 'Dot').storybookPath).toEqual(path.join('src', 'Dot.stories.jsx')); + // the graph is enqueued against the real Percy build id, not the stats UUID + expect(generate).toHaveBeenCalledWith('456', jasmine.any(Object)); + }); + + itPosix('disables IntelliStory for snapshots with a missing/failed/rejected baseline so they are always captured', async () => { + let { dir, baseSha } = setup( + { 'sb/enriched-stats.json': STATS, 'src/A.stories.jsx': 'v1' }, + { 'src/A.stories.jsx': 'v2' }); + + let percy = { + build: { id: '789' }, + client: { + generateIntelliStoryGraph: jasmine.createSpy('generateIntelliStoryGraph'), + getStatus: async () => ({ status: 'done', data: {} }), + // no explicit baseline: base commit + per-snapshot states come from the API + getIntelliStorySnapshotNameToCommit: async () => ({ + base_build_commit_sha: baseSha, + snapshots: { Approved: 'approved', Failed: 'failed', Rejected: 'rejected' } + }) + } + }; + let snapshots = [ + { name: 'Approved', importPath: 'src/A.stories.jsx' }, + { name: 'Failed', importPath: 'src/A.stories.jsx' }, + { name: 'Rejected', importPath: 'src/A.stories.jsx' }, + { name: 'Missing', importPath: 'src/A.stories.jsx' } + ]; + + let result = await applyIntelliStory(percy, snapshots, { trace: false }, path.join(dir, 'sb')); + let byName = Object.fromEntries(result.map(s => [s.name, s.intelliStory])); + + // approved baseline => IntelliStory selection applies + expect(byName.Approved).toBe(true); + // failed / rejected / missing baselines => always captured + expect(byName.Failed).toBe(false); + expect(byName.Rejected).toBe(false); + expect(byName.Missing).toBe(false); }); }); }); diff --git a/packages/cli/bin/run.js b/packages/cli/bin/run.js new file mode 100755 index 000000000..6978fbcaf --- /dev/null +++ b/packages/cli/bin/run.js @@ -0,0 +1,398 @@ +#!/usr/bin/env node + +// DO NOT REMOVE: Update NODE_ENV for executable +"use strict"; + +function _typeof(o) { + "@babel/helpers - typeof"; + + return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function (o) { + return typeof o; + } : function (o) { + return o && "function" == typeof Symbol && o.constructor === Symbol && o !== Symbol.prototype ? "symbol" : typeof o; + }, _typeof(o); +} +function _regeneratorRuntime() { + "use strict"; + + /*! regenerator-runtime -- Copyright (c) 2014-present, Facebook, Inc. -- license (MIT): https://github.com/facebook/regenerator/blob/main/LICENSE */ + _regeneratorRuntime = function _regeneratorRuntime() { + return e; + }; + var t, + e = {}, + r = Object.prototype, + n = r.hasOwnProperty, + o = Object.defineProperty || function (t, e, r) { + t[e] = r.value; + }, + i = "function" == typeof Symbol ? Symbol : {}, + a = i.iterator || "@@iterator", + c = i.asyncIterator || "@@asyncIterator", + u = i.toStringTag || "@@toStringTag"; + function define(t, e, r) { + return Object.defineProperty(t, e, { + value: r, + enumerable: !0, + configurable: !0, + writable: !0 + }), t[e]; + } + try { + define({}, ""); + } catch (t) { + define = function define(t, e, r) { + return t[e] = r; + }; + } + function wrap(t, e, r, n) { + var i = e && e.prototype instanceof Generator ? e : Generator, + a = Object.create(i.prototype), + c = new Context(n || []); + return o(a, "_invoke", { + value: makeInvokeMethod(t, r, c) + }), a; + } + function tryCatch(t, e, r) { + try { + return { + type: "normal", + arg: t.call(e, r) + }; + } catch (t) { + return { + type: "throw", + arg: t + }; + } + } + e.wrap = wrap; + var h = "suspendedStart", + l = "suspendedYield", + f = "executing", + s = "completed", + y = {}; + function Generator() {} + function GeneratorFunction() {} + function GeneratorFunctionPrototype() {} + var p = {}; + define(p, a, function () { + return this; + }); + var d = Object.getPrototypeOf, + v = d && d(d(values([]))); + v && v !== r && n.call(v, a) && (p = v); + var g = GeneratorFunctionPrototype.prototype = Generator.prototype = Object.create(p); + function defineIteratorMethods(t) { + ["next", "throw", "return"].forEach(function (e) { + define(t, e, function (t) { + return this._invoke(e, t); + }); + }); + } + function AsyncIterator(t, e) { + function invoke(r, o, i, a) { + var c = tryCatch(t[r], t, o); + if ("throw" !== c.type) { + var u = c.arg, + h = u.value; + return h && "object" == _typeof(h) && n.call(h, "__await") ? e.resolve(h.__await).then(function (t) { + invoke("next", t, i, a); + }, function (t) { + invoke("throw", t, i, a); + }) : e.resolve(h).then(function (t) { + u.value = t, i(u); + }, function (t) { + return invoke("throw", t, i, a); + }); + } + a(c.arg); + } + var r; + o(this, "_invoke", { + value: function value(t, n) { + function callInvokeWithMethodAndArg() { + return new e(function (e, r) { + invoke(t, n, e, r); + }); + } + return r = r ? r.then(callInvokeWithMethodAndArg, callInvokeWithMethodAndArg) : callInvokeWithMethodAndArg(); + } + }); + } + function makeInvokeMethod(e, r, n) { + var o = h; + return function (i, a) { + if (o === f) throw Error("Generator is already running"); + if (o === s) { + if ("throw" === i) throw a; + return { + value: t, + done: !0 + }; + } + for (n.method = i, n.arg = a;;) { + var c = n.delegate; + if (c) { + var u = maybeInvokeDelegate(c, n); + if (u) { + if (u === y) continue; + return u; + } + } + if ("next" === n.method) n.sent = n._sent = n.arg;else if ("throw" === n.method) { + if (o === h) throw o = s, n.arg; + n.dispatchException(n.arg); + } else "return" === n.method && n.abrupt("return", n.arg); + o = f; + var p = tryCatch(e, r, n); + if ("normal" === p.type) { + if (o = n.done ? s : l, p.arg === y) continue; + return { + value: p.arg, + done: n.done + }; + } + "throw" === p.type && (o = s, n.method = "throw", n.arg = p.arg); + } + }; + } + function maybeInvokeDelegate(e, r) { + var n = r.method, + o = e.iterator[n]; + if (o === t) return r.delegate = null, "throw" === n && e.iterator["return"] && (r.method = "return", r.arg = t, maybeInvokeDelegate(e, r), "throw" === r.method) || "return" !== n && (r.method = "throw", r.arg = new TypeError("The iterator does not provide a '" + n + "' method")), y; + var i = tryCatch(o, e.iterator, r.arg); + if ("throw" === i.type) return r.method = "throw", r.arg = i.arg, r.delegate = null, y; + var a = i.arg; + return a ? a.done ? (r[e.resultName] = a.value, r.next = e.nextLoc, "return" !== r.method && (r.method = "next", r.arg = t), r.delegate = null, y) : a : (r.method = "throw", r.arg = new TypeError("iterator result is not an object"), r.delegate = null, y); + } + function pushTryEntry(t) { + var e = { + tryLoc: t[0] + }; + 1 in t && (e.catchLoc = t[1]), 2 in t && (e.finallyLoc = t[2], e.afterLoc = t[3]), this.tryEntries.push(e); + } + function resetTryEntry(t) { + var e = t.completion || {}; + e.type = "normal", delete e.arg, t.completion = e; + } + function Context(t) { + this.tryEntries = [{ + tryLoc: "root" + }], t.forEach(pushTryEntry, this), this.reset(!0); + } + function values(e) { + if (e || "" === e) { + var r = e[a]; + if (r) return r.call(e); + if ("function" == typeof e.next) return e; + if (!isNaN(e.length)) { + var o = -1, + i = function next() { + for (; ++o < e.length;) if (n.call(e, o)) return next.value = e[o], next.done = !1, next; + return next.value = t, next.done = !0, next; + }; + return i.next = i; + } + } + throw new TypeError(_typeof(e) + " is not iterable"); + } + return GeneratorFunction.prototype = GeneratorFunctionPrototype, o(g, "constructor", { + value: GeneratorFunctionPrototype, + configurable: !0 + }), o(GeneratorFunctionPrototype, "constructor", { + value: GeneratorFunction, + configurable: !0 + }), GeneratorFunction.displayName = define(GeneratorFunctionPrototype, u, "GeneratorFunction"), e.isGeneratorFunction = function (t) { + var e = "function" == typeof t && t.constructor; + return !!e && (e === GeneratorFunction || "GeneratorFunction" === (e.displayName || e.name)); + }, e.mark = function (t) { + return Object.setPrototypeOf ? Object.setPrototypeOf(t, GeneratorFunctionPrototype) : (t.__proto__ = GeneratorFunctionPrototype, define(t, u, "GeneratorFunction")), t.prototype = Object.create(g), t; + }, e.awrap = function (t) { + return { + __await: t + }; + }, defineIteratorMethods(AsyncIterator.prototype), define(AsyncIterator.prototype, c, function () { + return this; + }), e.AsyncIterator = AsyncIterator, e.async = function (t, r, n, o, i) { + void 0 === i && (i = Promise); + var a = new AsyncIterator(wrap(t, r, n, o), i); + return e.isGeneratorFunction(r) ? a : a.next().then(function (t) { + return t.done ? t.value : a.next(); + }); + }, defineIteratorMethods(g), define(g, u, "Generator"), define(g, a, function () { + return this; + }), define(g, "toString", function () { + return "[object Generator]"; + }), e.keys = function (t) { + var e = Object(t), + r = []; + for (var n in e) r.push(n); + return r.reverse(), function next() { + for (; r.length;) { + var t = r.pop(); + if (t in e) return next.value = t, next.done = !1, next; + } + return next.done = !0, next; + }; + }, e.values = values, Context.prototype = { + constructor: Context, + reset: function reset(e) { + if (this.prev = 0, this.next = 0, this.sent = this._sent = t, this.done = !1, this.delegate = null, this.method = "next", this.arg = t, this.tryEntries.forEach(resetTryEntry), !e) for (var r in this) "t" === r.charAt(0) && n.call(this, r) && !isNaN(+r.slice(1)) && (this[r] = t); + }, + stop: function stop() { + this.done = !0; + var t = this.tryEntries[0].completion; + if ("throw" === t.type) throw t.arg; + return this.rval; + }, + dispatchException: function dispatchException(e) { + if (this.done) throw e; + var r = this; + function handle(n, o) { + return a.type = "throw", a.arg = e, r.next = n, o && (r.method = "next", r.arg = t), !!o; + } + for (var o = this.tryEntries.length - 1; o >= 0; --o) { + var i = this.tryEntries[o], + a = i.completion; + if ("root" === i.tryLoc) return handle("end"); + if (i.tryLoc <= this.prev) { + var c = n.call(i, "catchLoc"), + u = n.call(i, "finallyLoc"); + if (c && u) { + if (this.prev < i.catchLoc) return handle(i.catchLoc, !0); + if (this.prev < i.finallyLoc) return handle(i.finallyLoc); + } else if (c) { + if (this.prev < i.catchLoc) return handle(i.catchLoc, !0); + } else { + if (!u) throw Error("try statement without catch or finally"); + if (this.prev < i.finallyLoc) return handle(i.finallyLoc); + } + } + } + }, + abrupt: function abrupt(t, e) { + for (var r = this.tryEntries.length - 1; r >= 0; --r) { + var o = this.tryEntries[r]; + if (o.tryLoc <= this.prev && n.call(o, "finallyLoc") && this.prev < o.finallyLoc) { + var i = o; + break; + } + } + i && ("break" === t || "continue" === t) && i.tryLoc <= e && e <= i.finallyLoc && (i = null); + var a = i ? i.completion : {}; + return a.type = t, a.arg = e, i ? (this.method = "next", this.next = i.finallyLoc, y) : this.complete(a); + }, + complete: function complete(t, e) { + if ("throw" === t.type) throw t.arg; + return "break" === t.type || "continue" === t.type ? this.next = t.arg : "return" === t.type ? (this.rval = this.arg = t.arg, this.method = "return", this.next = "end") : "normal" === t.type && e && (this.next = e), y; + }, + finish: function finish(t) { + for (var e = this.tryEntries.length - 1; e >= 0; --e) { + var r = this.tryEntries[e]; + if (r.finallyLoc === t) return this.complete(r.completion, r.afterLoc), resetTryEntry(r), y; + } + }, + "catch": function _catch(t) { + for (var e = this.tryEntries.length - 1; e >= 0; --e) { + var r = this.tryEntries[e]; + if (r.tryLoc === t) { + var n = r.completion; + if ("throw" === n.type) { + var o = n.arg; + resetTryEntry(r); + } + return o; + } + } + throw Error("illegal catch attempt"); + }, + delegateYield: function delegateYield(e, r, n) { + return this.delegate = { + iterator: values(e), + resultName: r, + nextLoc: n + }, "next" === this.method && (this.arg = t), y; + } + }, e; +} +function asyncGeneratorStep(n, t, e, r, o, a, c) { + try { + var i = n[a](c), + u = i.value; + } catch (n) { + return void e(n); + } + i.done ? t(u) : Promise.resolve(u).then(r, o); +} +function _asyncToGenerator(n) { + return function () { + var t = this, + e = arguments; + return new Promise(function (r, o) { + var a = n.apply(t, e); + function _next(n) { + asyncGeneratorStep(a, r, o, _next, _throw, "next", n); + } + function _throw(n) { + asyncGeneratorStep(a, r, o, _next, _throw, "throw", n); + } + _next(void 0); + }); + }; +} +function _getRequireWildcardCache(e) { + if ("function" != typeof WeakMap) return null; + var r = new WeakMap(), + t = new WeakMap(); + return (_getRequireWildcardCache = function _getRequireWildcardCache(e) { + return e ? t : r; + })(e); +} +function _interopRequireWildcard(e, r) { + if (!r && e && e.__esModule) return e; + if (null === e || "object" != _typeof(e) && "function" != typeof e) return { + "default": e + }; + var t = _getRequireWildcardCache(r); + if (t && t.has(e)) return t.get(e); + var n = { + __proto__: null + }, + a = Object.defineProperty && Object.getOwnPropertyDescriptor; + for (var u in e) if ("default" !== u && {}.hasOwnProperty.call(e, u)) { + var i = a ? Object.getOwnPropertyDescriptor(e, u) : null; + i && (i.get || i.set) ? Object.defineProperty(n, u, i) : n[u] = e[u]; + } + return n["default"] = e, t && t.set(e, n), n; +} +process.env.NODE_ENV = "executable"; +// ensure that we're running within a supported node version +if (parseInt(process.version.split('.')[0].substring(1), 10) < 14) { + console.error("Node ".concat(process.version, " is not supported. Percy only ") + 'supports current LTS versions of Node. Please upgrade to Node 14+'); + process.exit(1); +} +Promise.resolve().then(function () { + return _interopRequireWildcard(require('../dist/index.js')); +}).then(/*#__PURE__*/function () { + var _ref2 = _asyncToGenerator(/*#__PURE__*/_regeneratorRuntime().mark(function _callee(_ref) { + var percy, checkForUpdate; + return _regeneratorRuntime().wrap(function _callee$(_context) { + while (1) switch (_context.prev = _context.next) { + case 0: + percy = _ref.percy, checkForUpdate = _ref.checkForUpdate; + _context.next = 3; + return checkForUpdate(); + case 3: + _context.next = 5; + return percy(process.argv.slice(2)); + case 5: + case "end": + return _context.stop(); + } + }, _callee); + })); + return function (_x) { + return _ref2.apply(this, arguments); + }; +}()); \ No newline at end of file diff --git a/packages/client/src/client.js b/packages/client/src/client.js index 07e49d83b..8d4caf7b5 100644 --- a/packages/client/src/client.js +++ b/packages/client/src/client.js @@ -617,6 +617,8 @@ export class PercyClient { regions, algorithm, algorithmConfiguration, + intelliStory, + storybookPath, resources = [], meta } = {}) { @@ -655,7 +657,11 @@ export class PercyClient { 'enable-javascript': enableJavaScript || null, 'enable-layout': enableLayout || false, 'th-test-case-execution-id': thTestCaseExecutionId || null, - browsers: normalizeBrowsers(browsers) || null + browsers: normalizeBrowsers(browsers) || null, + // IntelliStory: when enabled, the API selects affected snapshots + // server-side using the story's source path. + 'intelli-story': intelliStory || null, + 'storybook-path': storybookPath || null }, relationships: { resources: { @@ -687,6 +693,32 @@ export class PercyClient { async sendSnapshot(buildId, options) { let { meta = {} } = options; let snapshot = await this.createSnapshot(buildId, options); + + // Response code tells us the IntelliStory outcome: a kept snapshot returns + // `201 Created` with the snapshot object; a snapshot skipped by server-side + // selection returns `204 No Content` (header only, no snapshot id). Tally + // the outcome so the storybook flow can print an IntelliStory summary. + let created = !!snapshot?.data?.id; + if (typeof options.intelliStory === 'boolean') { + this.intelliStoryStats ??= { graphKept: 0, forcedKept: 0, skipped: 0 }; + if (!options.intelliStory) { + // IntelliStory disabled for this snapshot (missing/failed/rejected + // baseline) — always captured server-side. + this.intelliStoryStats.forcedKept += 1; + } else if (created) { + this.intelliStoryStats.graphKept += 1; + } else { + this.intelliStoryStats.skipped += 1; + } + } + + // With IntelliStory, snapshot selection happens server-side: the API may + // accept the request without creating a snapshot (204 No Content). There is + // nothing to upload or finalize in that case. + if (!created) { + this.log.debug(`Snapshot not created server-side, skipping upload: ${options.name}...`, meta); + return snapshot; + } meta.snapshotId = snapshot.data.id; let missing = snapshot.data.relationships?.['missing-resources']?.data; diff --git a/packages/client/test/client.test.js b/packages/client/test/client.test.js index 32085c8fe..540911bf3 100644 --- a/packages/client/test/client.test.js +++ b/packages/client/test/client.test.js @@ -1331,7 +1331,9 @@ describe('PercyClient', () => { 'enable-javascript': true, 'enable-layout': true, 'th-test-case-execution-id': 'random-uuid', - browsers: null + browsers: null, + 'intelli-story': null, + 'storybook-path': null }, relationships: { resources: { @@ -1423,7 +1425,9 @@ describe('PercyClient', () => { 'enable-javascript': true, 'enable-layout': true, 'th-test-case-execution-id': 'random-uuid', - browsers: ['chrome', 'firefox', 'safari_on_iphone'] + browsers: ['chrome', 'firefox', 'safari_on_iphone'], + 'intelli-story': null, + 'storybook-path': null }, relationships: { resources: { @@ -1474,7 +1478,9 @@ describe('PercyClient', () => { 'enable-layout': false, regions: null, 'th-test-case-execution-id': null, - browsers: null + browsers: null, + 'intelli-story': null, + 'storybook-path': null }, relationships: { resources: { @@ -1547,7 +1553,9 @@ describe('PercyClient', () => { regions: null, 'enable-layout': false, 'th-test-case-execution-id': null, - browsers: null + browsers: null, + 'intelli-story': null, + 'storybook-path': null }, relationships: { resources: { @@ -2325,7 +2333,9 @@ describe('PercyClient', () => { regions: null, 'enable-layout': false, 'th-test-case-execution-id': null, - browsers: null + browsers: null, + 'intelli-story': null, + 'storybook-path': null }, relationships: { resources: { diff --git a/packages/core/percy-archive/Archived Snapshot-0ba6aa12.json b/packages/core/percy-archive/Archived Snapshot-0ba6aa12.json new file mode 100644 index 000000000..056e6f761 --- /dev/null +++ b/packages/core/percy-archive/Archived Snapshot-0ba6aa12.json @@ -0,0 +1 @@ +{"version":1,"snapshot":{"widths":[1000],"discovery":{"allowedHostnames":["localhost"],"networkIdleTimeout":100,"captureMockedServiceWorker":false,"retry":true,"scrollToBottom":false,"autoConfigureAllowedHostnames":true},"meta":{"snapshot":{"name":"Archived Snapshot"}},"minHeight":1024,"percyCSS":"","enableJavaScript":false,"cliEnableJavaScript":true,"disableShadowDOM":false,"forceShadowAsLightDOM":false,"responsiveSnapshotCapture":false,"ignoreCanvasSerializationErrors":false,"ignoreStyleSheetSerializationErrors":false,"name":"Archived Snapshot","url":"http://localhost:8000/","_ctrl":{"signal":{"_events":{},"_eventsCount":0,"reason":{"name":"AbortError"},"aborted":true}}},"resources":[{"root":true,"sha":"b633a587c652d02386c4f16f8c6f6aab7352d97f16367c3c40576214372dd628","mimetype":"text/html","content":"PGh0bWw+PC9odG1sPg==","url":"http://localhost:8000/"},{"log":true,"sha":"5cd0e93d03a2bff856d27185c03e2e2197feed3823dc7ef78506b539f9ebcc89","mimetype":"text/plain","content":"W3siZGVidWciOiJjb3JlOnNuYXBzaG90IiwibGV2ZWwiOiJkZWJ1ZyIsIm1lc3NhZ2UiOiItLS0tLS0tLS0iLCJtZXRhIjp7InNuYXBzaG90Ijp7Im5hbWUiOiJBcmNoaXZlZCBTbmFwc2hvdCJ9fSwidGltZXN0YW1wIjoxNzg0MjQwMzQ5Nzg2LCJlcnJvciI6ZmFsc2V9LHsiZGVidWciOiJjb3JlOnNuYXBzaG90IiwibGV2ZWwiOiJkZWJ1ZyIsIm1lc3NhZ2UiOiJSZWNlaXZlZCBzbmFwc2hvdDogQXJjaGl2ZWQgU25hcHNob3QiLCJtZXRhIjp7InNuYXBzaG90Ijp7Im5hbWUiOiJBcmNoaXZlZCBTbmFwc2hvdCJ9fSwidGltZXN0YW1wIjoxNzg0MjQwMzQ5Nzg2LCJlcnJvciI6ZmFsc2V9LHsiZGVidWciOiJjb3JlOnNuYXBzaG90IiwibGV2ZWwiOiJkZWJ1ZyIsIm1lc3NhZ2UiOiItIHVybDogaHR0cDovL2xvY2FsaG9zdDo4MDAwLyIsIm1ldGEiOnsic25hcHNob3QiOnsibmFtZSI6IkFyY2hpdmVkIFNuYXBzaG90In19LCJ0aW1lc3RhbXAiOjE3ODQyNDAzNDk3ODYsImVycm9yIjpmYWxzZX0seyJkZWJ1ZyI6ImNvcmU6c25hcHNob3QiLCJsZXZlbCI6ImRlYnVnIiwibWVzc2FnZSI6Ii0gd2lkdGhzOiAxMDAwcHgiLCJtZXRhIjp7InNuYXBzaG90Ijp7Im5hbWUiOiJBcmNoaXZlZCBTbmFwc2hvdCJ9fSwidGltZXN0YW1wIjoxNzg0MjQwMzQ5Nzg2LCJlcnJvciI6ZmFsc2V9LHsiZGVidWciOiJjb3JlOnNuYXBzaG90IiwibGV2ZWwiOiJkZWJ1ZyIsIm1lc3NhZ2UiOiItIG1pbkhlaWdodDogMTAyNHB4IiwibWV0YSI6eyJzbmFwc2hvdCI6eyJuYW1lIjoiQXJjaGl2ZWQgU25hcHNob3QifX0sInRpbWVzdGFtcCI6MTc4NDI0MDM0OTc4NiwiZXJyb3IiOmZhbHNlfSx7ImRlYnVnIjoiY29yZTpzbmFwc2hvdCIsImxldmVsIjoiZGVidWciLCJtZXNzYWdlIjoiLSBlbmFibGVKYXZhU2NyaXB0OiBmYWxzZSIsIm1ldGEiOnsic25hcHNob3QiOnsibmFtZSI6IkFyY2hpdmVkIFNuYXBzaG90In19LCJ0aW1lc3RhbXAiOjE3ODQyNDAzNDk3ODYsImVycm9yIjpmYWxzZX0seyJkZWJ1ZyI6ImNvcmU6c25hcHNob3QiLCJsZXZlbCI6ImRlYnVnIiwibWVzc2FnZSI6Ii0gY2xpRW5hYmxlSmF2YVNjcmlwdDogdHJ1ZSIsIm1ldGEiOnsic25hcHNob3QiOnsibmFtZSI6IkFyY2hpdmVkIFNuYXBzaG90In19LCJ0aW1lc3RhbXAiOjE3ODQyNDAzNDk3ODYsImVycm9yIjpmYWxzZX0seyJkZWJ1ZyI6ImNvcmU6c25hcHNob3QiLCJsZXZlbCI6ImRlYnVnIiwibWVzc2FnZSI6Ii0gZGlzYWJsZVNoYWRvd0RPTTogZmFsc2UiLCJtZXRhIjp7InNuYXBzaG90Ijp7Im5hbWUiOiJBcmNoaXZlZCBTbmFwc2hvdCJ9fSwidGltZXN0YW1wIjoxNzg0MjQwMzQ5Nzg2LCJlcnJvciI6ZmFsc2V9LHsiZGVidWciOiJjb3JlOnNuYXBzaG90IiwibGV2ZWwiOiJkZWJ1ZyIsIm1lc3NhZ2UiOiItIGZvcmNlU2hhZG93QXNMaWdodERPTTogZmFsc2UiLCJtZXRhIjp7InNuYXBzaG90Ijp7Im5hbWUiOiJBcmNoaXZlZCBTbmFwc2hvdCJ9fSwidGltZXN0YW1wIjoxNzg0MjQwMzQ5Nzg2LCJlcnJvciI6ZmFsc2V9LHsiZGVidWciOiJjb3JlOnNuYXBzaG90IiwibGV2ZWwiOiJkZWJ1ZyIsIm1lc3NhZ2UiOiItIGRpc2NvdmVyeS5hbGxvd2VkSG9zdG5hbWVzOiBsb2NhbGhvc3QiLCJtZXRhIjp7InNuYXBzaG90Ijp7Im5hbWUiOiJBcmNoaXZlZCBTbmFwc2hvdCJ9fSwidGltZXN0YW1wIjoxNzg0MjQwMzQ5Nzg2LCJlcnJvciI6ZmFsc2V9LHsiZGVidWciOiJjb3JlOnNuYXBzaG90IiwibGV2ZWwiOiJkZWJ1ZyIsIm1lc3NhZ2UiOiItIGRpc2NvdmVyeS5jYXB0dXJlTW9ja2VkU2VydmljZVdvcmtlcjogZmFsc2UiLCJtZXRhIjp7InNuYXBzaG90Ijp7Im5hbWUiOiJBcmNoaXZlZCBTbmFwc2hvdCJ9fSwidGltZXN0YW1wIjoxNzg0MjQwMzQ5Nzg2LCJlcnJvciI6ZmFsc2V9LHsiZGVidWciOiJjb3JlOnNuYXBzaG90IiwibGV2ZWwiOiJkZWJ1ZyIsIm1lc3NhZ2UiOiItIGRvbVNuYXBzaG90OiB0cnVlIiwibWV0YSI6eyJzbmFwc2hvdCI6eyJuYW1lIjoiQXJjaGl2ZWQgU25hcHNob3QifX0sInRpbWVzdGFtcCI6MTc4NDI0MDM0OTc4NiwiZXJyb3IiOmZhbHNlfSx7ImRlYnVnIjoiY29yZTpzbmFwc2hvdCIsImxldmVsIjoiZGVidWciLCJtZXNzYWdlIjoiLSBkaXNjb3Zlcnkuc2Nyb2xsVG9Cb3R0b206IGZhbHNlIiwibWV0YSI6eyJzbmFwc2hvdCI6eyJuYW1lIjoiQXJjaGl2ZWQgU25hcHNob3QifX0sInRpbWVzdGFtcCI6MTc4NDI0MDM0OTc4NiwiZXJyb3IiOmZhbHNlfSx7ImRlYnVnIjoiY29yZTpzbmFwc2hvdCIsImxldmVsIjoiZGVidWciLCJtZXNzYWdlIjoiLSBpZ25vcmVDYW52YXNTZXJpYWxpemF0aW9uRXJyb3JzOiBmYWxzZSIsIm1ldGEiOnsic25hcHNob3QiOnsibmFtZSI6IkFyY2hpdmVkIFNuYXBzaG90In19LCJ0aW1lc3RhbXAiOjE3ODQyNDAzNDk3ODYsImVycm9yIjpmYWxzZX0seyJkZWJ1ZyI6ImNvcmU6c25hcHNob3QiLCJsZXZlbCI6ImRlYnVnIiwibWVzc2FnZSI6Ii0gaWdub3JlU3R5bGVTaGVldFNlcmlhbGl6YXRpb25FcnJvcnM6IGZhbHNlIiwibWV0YSI6eyJzbmFwc2hvdCI6eyJuYW1lIjoiQXJjaGl2ZWQgU25hcHNob3QifX0sInRpbWVzdGFtcCI6MTc4NDI0MDM0OTc4NiwiZXJyb3IiOmZhbHNlfSx7ImRlYnVnIjoiY29yZTpzbmFwc2hvdCIsImxldmVsIjoiZGVidWciLCJtZXNzYWdlIjoiLSBkaXNjb3ZlcnkuYXV0b0NvbmZpZ3VyZUFsbG93ZWRIb3N0bmFtZXM6IHRydWUiLCJtZXRhIjp7InNuYXBzaG90Ijp7Im5hbWUiOiJBcmNoaXZlZCBTbmFwc2hvdCJ9fSwidGltZXN0YW1wIjoxNzg0MjQwMzQ5Nzg2LCJlcnJvciI6ZmFsc2V9XQ==","url":"/percy.1784240349786.log"}]} \ No newline at end of file diff --git a/packages/core/src/config.js b/packages/core/src/config.js index 167d028ba..0fcf5abf9 100644 --- a/packages/core/src/config.js +++ b/packages/core/src/config.js @@ -557,6 +557,11 @@ export const snapshotSchema = { testCase: { $ref: '/config/snapshot#/properties/testCase' }, labels: { $ref: '/config/snapshot#/properties/labels' }, thTestCaseExecutionId: { $ref: '/config/snapshot#/properties/thTestCaseExecutionId' }, + // IntelliStory: injected per-snapshot by @percy/storybook so the API can + // do affected-story selection server-side. `storybookPath` is the + // project-relative path of the story's source module. + intelliStory: { type: 'boolean' }, + storybookPath: { type: 'string' }, browsers: { $ref: '/config/snapshot#/properties/browsers' }, reshuffleInvalidTags: { $ref: '/config/snapshot#/properties/reshuffleInvalidTags' }, regions: { $ref: '/config/snapshot#/properties/regions' }, diff --git a/packages/core/src/percy.js b/packages/core/src/percy.js index 1bab43d7a..2124a92dc 100644 --- a/packages/core/src/percy.js +++ b/packages/core/src/percy.js @@ -156,7 +156,7 @@ export class Percy { }; // generator methods are wrapped to autorun and return promises - for (let m of ['start', 'stop', 'flush', 'idle', 'snapshot', 'upload', 'replaySnapshot']) { + for (let m of ['start', 'stop', 'flush', 'idle', 'snapshot', 'upload', 'replaySnapshot', 'startBuild']) { // the original generator can be referenced with percy.yield. let method = (this.yield ||= {})[m] = this[m].bind(this); this[m] = (...args) => generatePromise(method(...args)); @@ -354,6 +354,19 @@ export class Percy { this._lockHandle = null; } + // Forces the snapshots queue to start, which creates the Percy build up + // front and populates `percy.build.id`. Normally, when uploads are delayed + // or deferred, the build is created lazily on the first flush. IntelliStory + // needs the real build id before any snapshots are taken so it can enqueue + // the affected-story graph against it. Safe to call more than once — the + // queue memoizes its start task, so the build is only created once. + async *startBuild() { + if (!this.readyState) return this.build; + if (this.build?.id || this.build?.error) return this.build; + yield this.#snapshots.start(); + return this.build; + } + // Resolves once snapshot and upload queues are idle async *idle() { yield* this.#discovery.idle(); diff --git a/packages/core/src/snapshot.js b/packages/core/src/snapshot.js index 8d23e9352..31b92f1f1 100644 --- a/packages/core/src/snapshot.js +++ b/packages/core/src/snapshot.js @@ -493,8 +493,10 @@ export function createSnapshotsQueue(percy) { if (percy.deferUploads) percy.log.info(`Snapshot uploaded: ${name}`, meta); // Pushing to syncQueue, that will check for - // snapshot processing status, and will resolve once done - if (snapshot.sync) { + // snapshot processing status, and will resolve once done. + // With IntelliStory the API may accept the request without creating a + // snapshot (server-side selection), so there is no id to wait on. + if (snapshot.sync && response?.data?.id) { percy.log.info(`Waiting for snapshot '${name}' to be completed`, meta); const data = new JobData(response.data.id, null, snapshot.resolve, snapshot.reject); percy.syncQueue.push(data); From 8dcdcde467c8e9530031cba3a5fa783b7912d9bb Mon Sep 17 00:00:00 2001 From: RaghavsBrowserStack Date: Fri, 17 Jul 2026 13:30:11 +0530 Subject: [PATCH 05/14] chore: remove accidentally committed build artifact and snapshot archive --- packages/cli/bin/run.js | 398 ------------------ .../Archived Snapshot-0ba6aa12.json | 1 - 2 files changed, 399 deletions(-) delete mode 100755 packages/cli/bin/run.js delete mode 100644 packages/core/percy-archive/Archived Snapshot-0ba6aa12.json diff --git a/packages/cli/bin/run.js b/packages/cli/bin/run.js deleted file mode 100755 index 6978fbcaf..000000000 --- a/packages/cli/bin/run.js +++ /dev/null @@ -1,398 +0,0 @@ -#!/usr/bin/env node - -// DO NOT REMOVE: Update NODE_ENV for executable -"use strict"; - -function _typeof(o) { - "@babel/helpers - typeof"; - - return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function (o) { - return typeof o; - } : function (o) { - return o && "function" == typeof Symbol && o.constructor === Symbol && o !== Symbol.prototype ? "symbol" : typeof o; - }, _typeof(o); -} -function _regeneratorRuntime() { - "use strict"; - - /*! regenerator-runtime -- Copyright (c) 2014-present, Facebook, Inc. -- license (MIT): https://github.com/facebook/regenerator/blob/main/LICENSE */ - _regeneratorRuntime = function _regeneratorRuntime() { - return e; - }; - var t, - e = {}, - r = Object.prototype, - n = r.hasOwnProperty, - o = Object.defineProperty || function (t, e, r) { - t[e] = r.value; - }, - i = "function" == typeof Symbol ? Symbol : {}, - a = i.iterator || "@@iterator", - c = i.asyncIterator || "@@asyncIterator", - u = i.toStringTag || "@@toStringTag"; - function define(t, e, r) { - return Object.defineProperty(t, e, { - value: r, - enumerable: !0, - configurable: !0, - writable: !0 - }), t[e]; - } - try { - define({}, ""); - } catch (t) { - define = function define(t, e, r) { - return t[e] = r; - }; - } - function wrap(t, e, r, n) { - var i = e && e.prototype instanceof Generator ? e : Generator, - a = Object.create(i.prototype), - c = new Context(n || []); - return o(a, "_invoke", { - value: makeInvokeMethod(t, r, c) - }), a; - } - function tryCatch(t, e, r) { - try { - return { - type: "normal", - arg: t.call(e, r) - }; - } catch (t) { - return { - type: "throw", - arg: t - }; - } - } - e.wrap = wrap; - var h = "suspendedStart", - l = "suspendedYield", - f = "executing", - s = "completed", - y = {}; - function Generator() {} - function GeneratorFunction() {} - function GeneratorFunctionPrototype() {} - var p = {}; - define(p, a, function () { - return this; - }); - var d = Object.getPrototypeOf, - v = d && d(d(values([]))); - v && v !== r && n.call(v, a) && (p = v); - var g = GeneratorFunctionPrototype.prototype = Generator.prototype = Object.create(p); - function defineIteratorMethods(t) { - ["next", "throw", "return"].forEach(function (e) { - define(t, e, function (t) { - return this._invoke(e, t); - }); - }); - } - function AsyncIterator(t, e) { - function invoke(r, o, i, a) { - var c = tryCatch(t[r], t, o); - if ("throw" !== c.type) { - var u = c.arg, - h = u.value; - return h && "object" == _typeof(h) && n.call(h, "__await") ? e.resolve(h.__await).then(function (t) { - invoke("next", t, i, a); - }, function (t) { - invoke("throw", t, i, a); - }) : e.resolve(h).then(function (t) { - u.value = t, i(u); - }, function (t) { - return invoke("throw", t, i, a); - }); - } - a(c.arg); - } - var r; - o(this, "_invoke", { - value: function value(t, n) { - function callInvokeWithMethodAndArg() { - return new e(function (e, r) { - invoke(t, n, e, r); - }); - } - return r = r ? r.then(callInvokeWithMethodAndArg, callInvokeWithMethodAndArg) : callInvokeWithMethodAndArg(); - } - }); - } - function makeInvokeMethod(e, r, n) { - var o = h; - return function (i, a) { - if (o === f) throw Error("Generator is already running"); - if (o === s) { - if ("throw" === i) throw a; - return { - value: t, - done: !0 - }; - } - for (n.method = i, n.arg = a;;) { - var c = n.delegate; - if (c) { - var u = maybeInvokeDelegate(c, n); - if (u) { - if (u === y) continue; - return u; - } - } - if ("next" === n.method) n.sent = n._sent = n.arg;else if ("throw" === n.method) { - if (o === h) throw o = s, n.arg; - n.dispatchException(n.arg); - } else "return" === n.method && n.abrupt("return", n.arg); - o = f; - var p = tryCatch(e, r, n); - if ("normal" === p.type) { - if (o = n.done ? s : l, p.arg === y) continue; - return { - value: p.arg, - done: n.done - }; - } - "throw" === p.type && (o = s, n.method = "throw", n.arg = p.arg); - } - }; - } - function maybeInvokeDelegate(e, r) { - var n = r.method, - o = e.iterator[n]; - if (o === t) return r.delegate = null, "throw" === n && e.iterator["return"] && (r.method = "return", r.arg = t, maybeInvokeDelegate(e, r), "throw" === r.method) || "return" !== n && (r.method = "throw", r.arg = new TypeError("The iterator does not provide a '" + n + "' method")), y; - var i = tryCatch(o, e.iterator, r.arg); - if ("throw" === i.type) return r.method = "throw", r.arg = i.arg, r.delegate = null, y; - var a = i.arg; - return a ? a.done ? (r[e.resultName] = a.value, r.next = e.nextLoc, "return" !== r.method && (r.method = "next", r.arg = t), r.delegate = null, y) : a : (r.method = "throw", r.arg = new TypeError("iterator result is not an object"), r.delegate = null, y); - } - function pushTryEntry(t) { - var e = { - tryLoc: t[0] - }; - 1 in t && (e.catchLoc = t[1]), 2 in t && (e.finallyLoc = t[2], e.afterLoc = t[3]), this.tryEntries.push(e); - } - function resetTryEntry(t) { - var e = t.completion || {}; - e.type = "normal", delete e.arg, t.completion = e; - } - function Context(t) { - this.tryEntries = [{ - tryLoc: "root" - }], t.forEach(pushTryEntry, this), this.reset(!0); - } - function values(e) { - if (e || "" === e) { - var r = e[a]; - if (r) return r.call(e); - if ("function" == typeof e.next) return e; - if (!isNaN(e.length)) { - var o = -1, - i = function next() { - for (; ++o < e.length;) if (n.call(e, o)) return next.value = e[o], next.done = !1, next; - return next.value = t, next.done = !0, next; - }; - return i.next = i; - } - } - throw new TypeError(_typeof(e) + " is not iterable"); - } - return GeneratorFunction.prototype = GeneratorFunctionPrototype, o(g, "constructor", { - value: GeneratorFunctionPrototype, - configurable: !0 - }), o(GeneratorFunctionPrototype, "constructor", { - value: GeneratorFunction, - configurable: !0 - }), GeneratorFunction.displayName = define(GeneratorFunctionPrototype, u, "GeneratorFunction"), e.isGeneratorFunction = function (t) { - var e = "function" == typeof t && t.constructor; - return !!e && (e === GeneratorFunction || "GeneratorFunction" === (e.displayName || e.name)); - }, e.mark = function (t) { - return Object.setPrototypeOf ? Object.setPrototypeOf(t, GeneratorFunctionPrototype) : (t.__proto__ = GeneratorFunctionPrototype, define(t, u, "GeneratorFunction")), t.prototype = Object.create(g), t; - }, e.awrap = function (t) { - return { - __await: t - }; - }, defineIteratorMethods(AsyncIterator.prototype), define(AsyncIterator.prototype, c, function () { - return this; - }), e.AsyncIterator = AsyncIterator, e.async = function (t, r, n, o, i) { - void 0 === i && (i = Promise); - var a = new AsyncIterator(wrap(t, r, n, o), i); - return e.isGeneratorFunction(r) ? a : a.next().then(function (t) { - return t.done ? t.value : a.next(); - }); - }, defineIteratorMethods(g), define(g, u, "Generator"), define(g, a, function () { - return this; - }), define(g, "toString", function () { - return "[object Generator]"; - }), e.keys = function (t) { - var e = Object(t), - r = []; - for (var n in e) r.push(n); - return r.reverse(), function next() { - for (; r.length;) { - var t = r.pop(); - if (t in e) return next.value = t, next.done = !1, next; - } - return next.done = !0, next; - }; - }, e.values = values, Context.prototype = { - constructor: Context, - reset: function reset(e) { - if (this.prev = 0, this.next = 0, this.sent = this._sent = t, this.done = !1, this.delegate = null, this.method = "next", this.arg = t, this.tryEntries.forEach(resetTryEntry), !e) for (var r in this) "t" === r.charAt(0) && n.call(this, r) && !isNaN(+r.slice(1)) && (this[r] = t); - }, - stop: function stop() { - this.done = !0; - var t = this.tryEntries[0].completion; - if ("throw" === t.type) throw t.arg; - return this.rval; - }, - dispatchException: function dispatchException(e) { - if (this.done) throw e; - var r = this; - function handle(n, o) { - return a.type = "throw", a.arg = e, r.next = n, o && (r.method = "next", r.arg = t), !!o; - } - for (var o = this.tryEntries.length - 1; o >= 0; --o) { - var i = this.tryEntries[o], - a = i.completion; - if ("root" === i.tryLoc) return handle("end"); - if (i.tryLoc <= this.prev) { - var c = n.call(i, "catchLoc"), - u = n.call(i, "finallyLoc"); - if (c && u) { - if (this.prev < i.catchLoc) return handle(i.catchLoc, !0); - if (this.prev < i.finallyLoc) return handle(i.finallyLoc); - } else if (c) { - if (this.prev < i.catchLoc) return handle(i.catchLoc, !0); - } else { - if (!u) throw Error("try statement without catch or finally"); - if (this.prev < i.finallyLoc) return handle(i.finallyLoc); - } - } - } - }, - abrupt: function abrupt(t, e) { - for (var r = this.tryEntries.length - 1; r >= 0; --r) { - var o = this.tryEntries[r]; - if (o.tryLoc <= this.prev && n.call(o, "finallyLoc") && this.prev < o.finallyLoc) { - var i = o; - break; - } - } - i && ("break" === t || "continue" === t) && i.tryLoc <= e && e <= i.finallyLoc && (i = null); - var a = i ? i.completion : {}; - return a.type = t, a.arg = e, i ? (this.method = "next", this.next = i.finallyLoc, y) : this.complete(a); - }, - complete: function complete(t, e) { - if ("throw" === t.type) throw t.arg; - return "break" === t.type || "continue" === t.type ? this.next = t.arg : "return" === t.type ? (this.rval = this.arg = t.arg, this.method = "return", this.next = "end") : "normal" === t.type && e && (this.next = e), y; - }, - finish: function finish(t) { - for (var e = this.tryEntries.length - 1; e >= 0; --e) { - var r = this.tryEntries[e]; - if (r.finallyLoc === t) return this.complete(r.completion, r.afterLoc), resetTryEntry(r), y; - } - }, - "catch": function _catch(t) { - for (var e = this.tryEntries.length - 1; e >= 0; --e) { - var r = this.tryEntries[e]; - if (r.tryLoc === t) { - var n = r.completion; - if ("throw" === n.type) { - var o = n.arg; - resetTryEntry(r); - } - return o; - } - } - throw Error("illegal catch attempt"); - }, - delegateYield: function delegateYield(e, r, n) { - return this.delegate = { - iterator: values(e), - resultName: r, - nextLoc: n - }, "next" === this.method && (this.arg = t), y; - } - }, e; -} -function asyncGeneratorStep(n, t, e, r, o, a, c) { - try { - var i = n[a](c), - u = i.value; - } catch (n) { - return void e(n); - } - i.done ? t(u) : Promise.resolve(u).then(r, o); -} -function _asyncToGenerator(n) { - return function () { - var t = this, - e = arguments; - return new Promise(function (r, o) { - var a = n.apply(t, e); - function _next(n) { - asyncGeneratorStep(a, r, o, _next, _throw, "next", n); - } - function _throw(n) { - asyncGeneratorStep(a, r, o, _next, _throw, "throw", n); - } - _next(void 0); - }); - }; -} -function _getRequireWildcardCache(e) { - if ("function" != typeof WeakMap) return null; - var r = new WeakMap(), - t = new WeakMap(); - return (_getRequireWildcardCache = function _getRequireWildcardCache(e) { - return e ? t : r; - })(e); -} -function _interopRequireWildcard(e, r) { - if (!r && e && e.__esModule) return e; - if (null === e || "object" != _typeof(e) && "function" != typeof e) return { - "default": e - }; - var t = _getRequireWildcardCache(r); - if (t && t.has(e)) return t.get(e); - var n = { - __proto__: null - }, - a = Object.defineProperty && Object.getOwnPropertyDescriptor; - for (var u in e) if ("default" !== u && {}.hasOwnProperty.call(e, u)) { - var i = a ? Object.getOwnPropertyDescriptor(e, u) : null; - i && (i.get || i.set) ? Object.defineProperty(n, u, i) : n[u] = e[u]; - } - return n["default"] = e, t && t.set(e, n), n; -} -process.env.NODE_ENV = "executable"; -// ensure that we're running within a supported node version -if (parseInt(process.version.split('.')[0].substring(1), 10) < 14) { - console.error("Node ".concat(process.version, " is not supported. Percy only ") + 'supports current LTS versions of Node. Please upgrade to Node 14+'); - process.exit(1); -} -Promise.resolve().then(function () { - return _interopRequireWildcard(require('../dist/index.js')); -}).then(/*#__PURE__*/function () { - var _ref2 = _asyncToGenerator(/*#__PURE__*/_regeneratorRuntime().mark(function _callee(_ref) { - var percy, checkForUpdate; - return _regeneratorRuntime().wrap(function _callee$(_context) { - while (1) switch (_context.prev = _context.next) { - case 0: - percy = _ref.percy, checkForUpdate = _ref.checkForUpdate; - _context.next = 3; - return checkForUpdate(); - case 3: - _context.next = 5; - return percy(process.argv.slice(2)); - case 5: - case "end": - return _context.stop(); - } - }, _callee); - })); - return function (_x) { - return _ref2.apply(this, arguments); - }; -}()); \ No newline at end of file diff --git a/packages/core/percy-archive/Archived Snapshot-0ba6aa12.json b/packages/core/percy-archive/Archived Snapshot-0ba6aa12.json deleted file mode 100644 index 056e6f761..000000000 --- a/packages/core/percy-archive/Archived Snapshot-0ba6aa12.json +++ /dev/null @@ -1 +0,0 @@ -{"version":1,"snapshot":{"widths":[1000],"discovery":{"allowedHostnames":["localhost"],"networkIdleTimeout":100,"captureMockedServiceWorker":false,"retry":true,"scrollToBottom":false,"autoConfigureAllowedHostnames":true},"meta":{"snapshot":{"name":"Archived Snapshot"}},"minHeight":1024,"percyCSS":"","enableJavaScript":false,"cliEnableJavaScript":true,"disableShadowDOM":false,"forceShadowAsLightDOM":false,"responsiveSnapshotCapture":false,"ignoreCanvasSerializationErrors":false,"ignoreStyleSheetSerializationErrors":false,"name":"Archived Snapshot","url":"http://localhost:8000/","_ctrl":{"signal":{"_events":{},"_eventsCount":0,"reason":{"name":"AbortError"},"aborted":true}}},"resources":[{"root":true,"sha":"b633a587c652d02386c4f16f8c6f6aab7352d97f16367c3c40576214372dd628","mimetype":"text/html","content":"PGh0bWw+PC9odG1sPg==","url":"http://localhost:8000/"},{"log":true,"sha":"5cd0e93d03a2bff856d27185c03e2e2197feed3823dc7ef78506b539f9ebcc89","mimetype":"text/plain","content":"W3siZGVidWciOiJjb3JlOnNuYXBzaG90IiwibGV2ZWwiOiJkZWJ1ZyIsIm1lc3NhZ2UiOiItLS0tLS0tLS0iLCJtZXRhIjp7InNuYXBzaG90Ijp7Im5hbWUiOiJBcmNoaXZlZCBTbmFwc2hvdCJ9fSwidGltZXN0YW1wIjoxNzg0MjQwMzQ5Nzg2LCJlcnJvciI6ZmFsc2V9LHsiZGVidWciOiJjb3JlOnNuYXBzaG90IiwibGV2ZWwiOiJkZWJ1ZyIsIm1lc3NhZ2UiOiJSZWNlaXZlZCBzbmFwc2hvdDogQXJjaGl2ZWQgU25hcHNob3QiLCJtZXRhIjp7InNuYXBzaG90Ijp7Im5hbWUiOiJBcmNoaXZlZCBTbmFwc2hvdCJ9fSwidGltZXN0YW1wIjoxNzg0MjQwMzQ5Nzg2LCJlcnJvciI6ZmFsc2V9LHsiZGVidWciOiJjb3JlOnNuYXBzaG90IiwibGV2ZWwiOiJkZWJ1ZyIsIm1lc3NhZ2UiOiItIHVybDogaHR0cDovL2xvY2FsaG9zdDo4MDAwLyIsIm1ldGEiOnsic25hcHNob3QiOnsibmFtZSI6IkFyY2hpdmVkIFNuYXBzaG90In19LCJ0aW1lc3RhbXAiOjE3ODQyNDAzNDk3ODYsImVycm9yIjpmYWxzZX0seyJkZWJ1ZyI6ImNvcmU6c25hcHNob3QiLCJsZXZlbCI6ImRlYnVnIiwibWVzc2FnZSI6Ii0gd2lkdGhzOiAxMDAwcHgiLCJtZXRhIjp7InNuYXBzaG90Ijp7Im5hbWUiOiJBcmNoaXZlZCBTbmFwc2hvdCJ9fSwidGltZXN0YW1wIjoxNzg0MjQwMzQ5Nzg2LCJlcnJvciI6ZmFsc2V9LHsiZGVidWciOiJjb3JlOnNuYXBzaG90IiwibGV2ZWwiOiJkZWJ1ZyIsIm1lc3NhZ2UiOiItIG1pbkhlaWdodDogMTAyNHB4IiwibWV0YSI6eyJzbmFwc2hvdCI6eyJuYW1lIjoiQXJjaGl2ZWQgU25hcHNob3QifX0sInRpbWVzdGFtcCI6MTc4NDI0MDM0OTc4NiwiZXJyb3IiOmZhbHNlfSx7ImRlYnVnIjoiY29yZTpzbmFwc2hvdCIsImxldmVsIjoiZGVidWciLCJtZXNzYWdlIjoiLSBlbmFibGVKYXZhU2NyaXB0OiBmYWxzZSIsIm1ldGEiOnsic25hcHNob3QiOnsibmFtZSI6IkFyY2hpdmVkIFNuYXBzaG90In19LCJ0aW1lc3RhbXAiOjE3ODQyNDAzNDk3ODYsImVycm9yIjpmYWxzZX0seyJkZWJ1ZyI6ImNvcmU6c25hcHNob3QiLCJsZXZlbCI6ImRlYnVnIiwibWVzc2FnZSI6Ii0gY2xpRW5hYmxlSmF2YVNjcmlwdDogdHJ1ZSIsIm1ldGEiOnsic25hcHNob3QiOnsibmFtZSI6IkFyY2hpdmVkIFNuYXBzaG90In19LCJ0aW1lc3RhbXAiOjE3ODQyNDAzNDk3ODYsImVycm9yIjpmYWxzZX0seyJkZWJ1ZyI6ImNvcmU6c25hcHNob3QiLCJsZXZlbCI6ImRlYnVnIiwibWVzc2FnZSI6Ii0gZGlzYWJsZVNoYWRvd0RPTTogZmFsc2UiLCJtZXRhIjp7InNuYXBzaG90Ijp7Im5hbWUiOiJBcmNoaXZlZCBTbmFwc2hvdCJ9fSwidGltZXN0YW1wIjoxNzg0MjQwMzQ5Nzg2LCJlcnJvciI6ZmFsc2V9LHsiZGVidWciOiJjb3JlOnNuYXBzaG90IiwibGV2ZWwiOiJkZWJ1ZyIsIm1lc3NhZ2UiOiItIGZvcmNlU2hhZG93QXNMaWdodERPTTogZmFsc2UiLCJtZXRhIjp7InNuYXBzaG90Ijp7Im5hbWUiOiJBcmNoaXZlZCBTbmFwc2hvdCJ9fSwidGltZXN0YW1wIjoxNzg0MjQwMzQ5Nzg2LCJlcnJvciI6ZmFsc2V9LHsiZGVidWciOiJjb3JlOnNuYXBzaG90IiwibGV2ZWwiOiJkZWJ1ZyIsIm1lc3NhZ2UiOiItIGRpc2NvdmVyeS5hbGxvd2VkSG9zdG5hbWVzOiBsb2NhbGhvc3QiLCJtZXRhIjp7InNuYXBzaG90Ijp7Im5hbWUiOiJBcmNoaXZlZCBTbmFwc2hvdCJ9fSwidGltZXN0YW1wIjoxNzg0MjQwMzQ5Nzg2LCJlcnJvciI6ZmFsc2V9LHsiZGVidWciOiJjb3JlOnNuYXBzaG90IiwibGV2ZWwiOiJkZWJ1ZyIsIm1lc3NhZ2UiOiItIGRpc2NvdmVyeS5jYXB0dXJlTW9ja2VkU2VydmljZVdvcmtlcjogZmFsc2UiLCJtZXRhIjp7InNuYXBzaG90Ijp7Im5hbWUiOiJBcmNoaXZlZCBTbmFwc2hvdCJ9fSwidGltZXN0YW1wIjoxNzg0MjQwMzQ5Nzg2LCJlcnJvciI6ZmFsc2V9LHsiZGVidWciOiJjb3JlOnNuYXBzaG90IiwibGV2ZWwiOiJkZWJ1ZyIsIm1lc3NhZ2UiOiItIGRvbVNuYXBzaG90OiB0cnVlIiwibWV0YSI6eyJzbmFwc2hvdCI6eyJuYW1lIjoiQXJjaGl2ZWQgU25hcHNob3QifX0sInRpbWVzdGFtcCI6MTc4NDI0MDM0OTc4NiwiZXJyb3IiOmZhbHNlfSx7ImRlYnVnIjoiY29yZTpzbmFwc2hvdCIsImxldmVsIjoiZGVidWciLCJtZXNzYWdlIjoiLSBkaXNjb3Zlcnkuc2Nyb2xsVG9Cb3R0b206IGZhbHNlIiwibWV0YSI6eyJzbmFwc2hvdCI6eyJuYW1lIjoiQXJjaGl2ZWQgU25hcHNob3QifX0sInRpbWVzdGFtcCI6MTc4NDI0MDM0OTc4NiwiZXJyb3IiOmZhbHNlfSx7ImRlYnVnIjoiY29yZTpzbmFwc2hvdCIsImxldmVsIjoiZGVidWciLCJtZXNzYWdlIjoiLSBpZ25vcmVDYW52YXNTZXJpYWxpemF0aW9uRXJyb3JzOiBmYWxzZSIsIm1ldGEiOnsic25hcHNob3QiOnsibmFtZSI6IkFyY2hpdmVkIFNuYXBzaG90In19LCJ0aW1lc3RhbXAiOjE3ODQyNDAzNDk3ODYsImVycm9yIjpmYWxzZX0seyJkZWJ1ZyI6ImNvcmU6c25hcHNob3QiLCJsZXZlbCI6ImRlYnVnIiwibWVzc2FnZSI6Ii0gaWdub3JlU3R5bGVTaGVldFNlcmlhbGl6YXRpb25FcnJvcnM6IGZhbHNlIiwibWV0YSI6eyJzbmFwc2hvdCI6eyJuYW1lIjoiQXJjaGl2ZWQgU25hcHNob3QifX0sInRpbWVzdGFtcCI6MTc4NDI0MDM0OTc4NiwiZXJyb3IiOmZhbHNlfSx7ImRlYnVnIjoiY29yZTpzbmFwc2hvdCIsImxldmVsIjoiZGVidWciLCJtZXNzYWdlIjoiLSBkaXNjb3ZlcnkuYXV0b0NvbmZpZ3VyZUFsbG93ZWRIb3N0bmFtZXM6IHRydWUiLCJtZXRhIjp7InNuYXBzaG90Ijp7Im5hbWUiOiJBcmNoaXZlZCBTbmFwc2hvdCJ9fSwidGltZXN0YW1wIjoxNzg0MjQwMzQ5Nzg2LCJlcnJvciI6ZmFsc2V9XQ==","url":"/percy.1784240349786.log"}]} \ No newline at end of file From b61044f897282225ad57e2cd6035ff24a2837eaf Mon Sep 17 00:00:00 2001 From: RaghavsBrowserStack Date: Fri, 17 Jul 2026 13:43:56 +0530 Subject: [PATCH 06/14] update intelliStory test --- packages/cli-command/test/intelliStory.test.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/cli-command/test/intelliStory.test.js b/packages/cli-command/test/intelliStory.test.js index 48c800156..da49d88d1 100644 --- a/packages/cli-command/test/intelliStory.test.js +++ b/packages/cli-command/test/intelliStory.test.js @@ -318,7 +318,7 @@ describe('intelliStory', () => { }); describe('runGraphGeneration()', () => { - it('starts the job and returns the graph payload on done', async () => { + it('starts the job and resolves once the graph is done', async () => { let log = mockLog(); let generate = jasmine.createSpy('generateIntelliStoryGraph'); let data = { affected_stories: ['src/A.stories.js'] }; @@ -337,9 +337,10 @@ describe('intelliStory', () => { affectedFileLocations: { 0: [[3, 3], [6, 7]] } }; - let res = await runGraphGeneration(percy, 'bld-1', payload, log); + // selection is server-side now, so nothing is returned — it just + // enqueues generation and resolves once the job reaches `done`. + await runGraphGeneration(percy, 'bld-1', payload, log); - expect(res).toBe(data); expect(generate).toHaveBeenCalledWith('bld-1', payload); }); @@ -429,7 +430,7 @@ describe('intelliStory', () => { }; let p = runGraphGeneration(percy, 'bld-1', { files: [], modules: [], storybookPaths: [], affectedNodes: [] }, log); - await expectAsync(drainPolls(p)).toBeResolvedTo(data); + await expectAsync(drainPolls(p)).toBeResolved(); }); it('bails after the poll loop times out without reaching done', async () => { From 00a16796ff6f21638e7afe0c02834db0e02ca98d Mon Sep 17 00:00:00 2001 From: RaghavsBrowserStack Date: Fri, 17 Jul 2026 14:24:40 +0530 Subject: [PATCH 07/14] add more test coverage --- .../cli-command/test/intelliStory.test.js | 64 ++++++++++++++++++- 1 file changed, 63 insertions(+), 1 deletion(-) diff --git a/packages/cli-command/test/intelliStory.test.js b/packages/cli-command/test/intelliStory.test.js index da49d88d1..619066cfc 100644 --- a/packages/cli-command/test/intelliStory.test.js +++ b/packages/cli-command/test/intelliStory.test.js @@ -15,7 +15,8 @@ import { extractStorybookPaths, runGraphGeneration, maybeWriteTrace, - applyIntelliStory + applyIntelliStory, + writeIntelliStoryTrace } from '../src/intelliStory.js'; const NODE_MAJOR = parseInt(process.versions.node.split('.')[0], 10); @@ -401,6 +402,67 @@ describe('intelliStory', () => { }); }); + describe('writeIntelliStoryTrace()', () => { + beforeEach(() => jasmine.clock().install()); + afterEach(() => jasmine.clock().uninstall()); + + const fullData = { + affected_stories: [], + vertices: [{ kind: 'component', file_path: 'A.jsx' }], + edges: [], + transitive_closure_matrix_sparse: [] + }; + + // Flush microtasks between clock ticks so the poll loop advances. + async function drainPolls(promise, rounds = 20) { + for (let i = 0; i < rounds; i++) { + await Promise.resolve(); + await Promise.resolve(); + jasmine.clock().tick(5000); + } + return promise; + } + + it('is a no-op when trace is disabled (defaults its logger and config)', async () => { + let getStatus = jasmine.createSpy('getStatus'); + // no config and no log arg — exercises `intelliStoryConfig || {}` and the default logger param + await writeIntelliStoryTrace({ build: { id: '1' }, client: { getStatus } }); + expect(getStatus).not.toHaveBeenCalled(); + }); + + it('is a no-op when the Percy build was never created', async () => { + let log = mockLog(); + let getStatus = jasmine.createSpy('getStatus'); + await writeIntelliStoryTrace({ client: { getStatus } }, { trace: true }, log); + expect(getStatus).not.toHaveBeenCalled(); + }); + + it('skips the trace when the graph reports failed', async () => { + let log = mockLog(); + let write = spyOn(fs, 'writeFileSync'); + let percy = { build: { id: '1' }, client: { getStatus: async () => ({ status: 'failed' }) } }; + await writeIntelliStoryTrace(percy, { trace: true }, log); + expect(write).not.toHaveBeenCalled(); + expect(log.debug).toHaveBeenCalled(); + }); + + it('skips the trace when polling times out', async () => { + let log = mockLog(); + let write = spyOn(fs, 'writeFileSync'); + let percy = { build: { id: '1' }, client: { getStatus: async () => ({ status: 'in_progress' }) } }; + await drainPolls(writeIntelliStoryTrace(percy, { trace: true }, log)); + expect(write).not.toHaveBeenCalled(); + }); + + it('fetches the finalized graph data and writes the trace when done', async () => { + let log = mockLog(); + let write = spyOn(fs, 'writeFileSync'); + let percy = { build: { id: '1' }, client: { getStatus: async () => ({ status: 'done', data: fullData }) } }; + await writeIntelliStoryTrace(percy, { trace: true }, log); + expect(write).toHaveBeenCalledTimes(1); + }); + }); + describe('runGraphGeneration() polling', () => { beforeEach(() => jasmine.clock().install()); afterEach(() => jasmine.clock().uninstall()); From 3e67e738ea345fb4d3dda8c025550c503a252ba1 Mon Sep 17 00:00:00 2001 From: RaghavsBrowserStack Date: Mon, 20 Jul 2026 15:01:00 +0530 Subject: [PATCH 08/14] fix test --- packages/cli-upload/test/upload.test.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/cli-upload/test/upload.test.js b/packages/cli-upload/test/upload.test.js index 503796e7b..c66af5c92 100644 --- a/packages/cli-upload/test/upload.test.js +++ b/packages/cli-upload/test/upload.test.js @@ -97,7 +97,9 @@ describe('percy upload', () => { regions: null, 'enable-layout': false, 'th-test-case-execution-id': null, - browsers: null + browsers: null, + 'intelli-story': null, + 'storybook-path': null }, relationships: { resources: { From d85f8f0358c430475baa20dbb0b3bc8994a154ab Mon Sep 17 00:00:00 2001 From: RaghavsBrowserStack Date: Mon, 20 Jul 2026 17:33:32 +0530 Subject: [PATCH 09/14] update smartsnap skip checkg --- packages/client/src/client.js | 29 +++++++++------------------ packages/client/test/client.test.js | 31 +++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 20 deletions(-) diff --git a/packages/client/src/client.js b/packages/client/src/client.js index c4af66828..b8e4e76d9 100644 --- a/packages/client/src/client.js +++ b/packages/client/src/client.js @@ -701,29 +701,18 @@ export class PercyClient { let { meta = {} } = options; let snapshot = await this.createSnapshot(buildId, options); - // Response code tells us the IntelliStory outcome: a kept snapshot returns - // `201 Created` with the snapshot object; a snapshot skipped by server-side - // selection returns `204 No Content` (header only, no snapshot id). Tally - // the outcome so the storybook flow can print an IntelliStory summary. - let created = !!snapshot?.data?.id; + // The API always creates the snapshot record now; when server-side SmartSnap + // selection skips it, the response carries `skipped-via-smartsnap: true` and + // there is nothing to upload or finalize. Tally kept vs skipped so the + // storybook flow can print an IntelliStory summary. + let skipped = !!snapshot?.data?.attributes?.['skipped-via-smartsnap']; if (typeof options.intelliStory === 'boolean') { - this.intelliStoryStats ??= { graphKept: 0, forcedKept: 0, skipped: 0 }; - if (!options.intelliStory) { - // IntelliStory disabled for this snapshot (missing/failed/rejected - // baseline) — always captured server-side. - this.intelliStoryStats.forcedKept += 1; - } else if (created) { - this.intelliStoryStats.graphKept += 1; - } else { - this.intelliStoryStats.skipped += 1; - } + this.intelliStoryStats ??= { kept: 0, skipped: 0 }; + this.intelliStoryStats[skipped ? 'skipped' : 'kept'] += 1; } - // With IntelliStory, snapshot selection happens server-side: the API may - // accept the request without creating a snapshot (204 No Content). There is - // nothing to upload or finalize in that case. - if (!created) { - this.log.debug(`Snapshot not created server-side, skipping upload: ${options.name}...`, meta); + if (skipped) { + this.log.debug(`Snapshot skipped via SmartSnap, skipping upload: ${options.name}...`, meta); return snapshot; } meta.snapshotId = snapshot.data.id; diff --git a/packages/client/test/client.test.js b/packages/client/test/client.test.js index 736958163..20c61fa00 100644 --- a/packages/client/test/client.test.js +++ b/packages/client/test/client.test.js @@ -1619,6 +1619,37 @@ describe('PercyClient', () => { await expectAsync(client.sendSnapshot(123, { name: 'test snapshot name' })).toBeResolved(); expect(api.requests['/snapshots/4567/finalize']).toBeDefined(); }); + + it('tallies IntelliStory kept snapshots and still finalizes them', async () => { + await expectAsync( + client.sendSnapshot(123, { name: 'kept one', intelliStory: true }) + ).toBeResolved(); + await expectAsync( + client.sendSnapshot(123, { name: 'kept two', intelliStory: false }) + ).toBeResolved(); + + expect(api.requests['/snapshots/4567/finalize']).toBeDefined(); + expect(client.intelliStoryStats).toEqual({ kept: 2, skipped: 0 }); + }); + + it('tallies IntelliStory skipped snapshots and does not upload or finalize', async () => { + api.reply('/builds/123/snapshots', () => [201, { + data: { id: '4567', attributes: { 'skipped-via-smartsnap': true } } + }]); + + await expectAsync( + client.sendSnapshot(123, { name: 'skipped one', intelliStory: true }) + ).toBeResolved(); + + expect(api.requests['/builds/123/resources']).toBeUndefined(); + expect(api.requests['/snapshots/4567/finalize']).toBeUndefined(); + expect(client.intelliStoryStats).toEqual({ kept: 0, skipped: 1 }); + }); + + it('does not tally when intelliStory is not set', async () => { + await expectAsync(client.sendSnapshot(123, { name: 'plain' })).toBeResolved(); + expect(client.intelliStoryStats).toBeUndefined(); + }); }); describe('#createComparison()', () => { From 735ecfc8d1e34ce76dd3b8be32cf7d832cbd26aa Mon Sep 17 00:00:00 2001 From: RaghavsBrowserStack Date: Mon, 20 Jul 2026 20:47:40 +0530 Subject: [PATCH 10/14] account for browser upgrade bail --- packages/cli-command/src/intelliStory.js | 11 ++++- .../cli-command/test/intelliStory.test.js | 41 ++++++++++++++++--- 2 files changed, 44 insertions(+), 8 deletions(-) diff --git a/packages/cli-command/src/intelliStory.js b/packages/cli-command/src/intelliStory.js index c2ebdd72f..695787d56 100644 --- a/packages/cli-command/src/intelliStory.js +++ b/packages/cli-command/src/intelliStory.js @@ -201,13 +201,20 @@ export async function getBaselineAndAffectedNodes(percy, baseline, log) { let baseRef; let baselineSnapshots; + // Always look up the base build: its `browser_upgrade` flag forces a full + // snapshot run regardless of whether an explicit baseline was configured. + const baseLookup = await percy.client.getIntelliStorySnapshotNameToCommit(); + log.debug(`IntelliStory: base lookup ${JSON.stringify(baseLookup)}`); + + if (baseLookup?.browser_upgrade) { + throw new IntelliStoryBailError('IntelliStory: This build has to take all snapshots by fallback because this build corresponds to a browser upgrade'); + } + if (baseline) { log.debug(`IntelliStory: diffing against explicit baseline "${baseline}"`); baseRef = baseline; baselineSnapshots = null; } else { - const baseLookup = await percy.client.getIntelliStorySnapshotNameToCommit(); - log.debug(`IntelliStory: base lookup ${JSON.stringify(baseLookup)}`); if (!baseLookup?.base_build_commit_sha) { throw new IntelliStoryBailError('IntelliStory: API could not predict a base build commit and no explicit baseline was set; running full snapshot set'); } diff --git a/packages/cli-command/test/intelliStory.test.js b/packages/cli-command/test/intelliStory.test.js index 619066cfc..fec4c4d3e 100644 --- a/packages/cli-command/test/intelliStory.test.js +++ b/packages/cli-command/test/intelliStory.test.js @@ -153,8 +153,9 @@ describe('intelliStory', () => { describe('getBaselineAndAffectedNodes()', () => { const log = mockLog(); - it('uses an explicit baseline and skips the API base lookup', async () => { - let lookup = jasmine.createSpy('getIntelliStorySnapshotNameToCommit'); + it('uses an explicit baseline but still calls the API to check for a browser upgrade', async () => { + let lookup = jasmine.createSpy('getIntelliStorySnapshotNameToCommit') + .and.resolveTo({ browser_upgrade: false }); let percy = { client: { getIntelliStorySnapshotNameToCommit: lookup } }; let res = await getBaselineAndAffectedNodes(percy, 'HEAD', log); @@ -162,7 +163,30 @@ describe('intelliStory', () => { expect(res.baseRef).toEqual('HEAD'); expect(res.baselineSnapshots).toBeNull(); expect(res.affectedNodes).toEqual([]); - expect(lookup).not.toHaveBeenCalled(); + expect(lookup).toHaveBeenCalled(); + }); + + it('tolerates the API returning no base lookup when an explicit baseline is set', async () => { + let percy = { client: { getIntelliStorySnapshotNameToCommit: async () => undefined } }; + + let res = await getBaselineAndAffectedNodes(percy, 'HEAD', log); + + expect(res.baseRef).toEqual('HEAD'); + expect(res.baselineSnapshots).toBeNull(); + }); + + it('bails when the base lookup reports a browser upgrade, even with an explicit baseline', async () => { + let percy = { + client: { + getIntelliStorySnapshotNameToCommit: async () => ({ + browser_upgrade: true, + base_build_commit_sha: 'HEAD' + }) + } + }; + await expectBail( + () => getBaselineAndAffectedNodes(percy, 'HEAD', log), + 'this build corresponds to a browser upgrade'); }); it('falls back to the predicted base build commit when no baseline is set', async () => { @@ -197,7 +221,7 @@ describe('intelliStory', () => { }); it('bails on an unsafe baseline ref before shelling out to git', async () => { - let percy = { client: {} }; + let percy = { client: { getIntelliStorySnapshotNameToCommit: async () => ({}) } }; await expectBail( () => getBaselineAndAffectedNodes(percy, '--upload-pack=evil', log), 'unsafe baseline ref'); @@ -678,7 +702,9 @@ describe('intelliStory', () => { it('bails when nothing is affected after filtering', async () => { let { dir } = setup({ 'sb/enriched-stats.json': STATS, 'src/A.stories.jsx': 'v1' }); await expectBail( - () => applyIntelliStory({ client: {}, build: { id: '123' } }, [{ name: 'A', importPath: 'src/A.stories.jsx' }], + () => applyIntelliStory( + { client: { getIntelliStorySnapshotNameToCommit: async () => ({}) }, build: { id: '123' } }, + [{ name: 'A', importPath: 'src/A.stories.jsx' }], { baseline: 'HEAD' }, path.join(dir, 'sb')), 'no affected files or packages detected'); }); @@ -694,7 +720,10 @@ describe('intelliStory', () => { client: { generateIntelliStoryGraph: generate, // job status no longer returns affected_stories during the run - getStatus: async () => ({ status: 'done', data: {} }) + getStatus: async () => ({ status: 'done', data: {} }), + // an explicit baseline is set, but the base lookup is always called + // now (to surface browser_upgrade) + getIntelliStorySnapshotNameToCommit: async () => ({}) } }; let snapshots = [ From 425630f837dd29ea760f9ded33c5c91b6619bf03 Mon Sep 17 00:00:00 2001 From: RaghavsBrowserStack Date: Tue, 21 Jul 2026 02:19:17 +0530 Subject: [PATCH 11/14] send build id with snapshot-name endopint as well --- packages/cli-command/src/intelliStory.js | 2 +- packages/client/src/client.js | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/cli-command/src/intelliStory.js b/packages/cli-command/src/intelliStory.js index 695787d56..6e8205182 100644 --- a/packages/cli-command/src/intelliStory.js +++ b/packages/cli-command/src/intelliStory.js @@ -203,7 +203,7 @@ export async function getBaselineAndAffectedNodes(percy, baseline, log) { // Always look up the base build: its `browser_upgrade` flag forces a full // snapshot run regardless of whether an explicit baseline was configured. - const baseLookup = await percy.client.getIntelliStorySnapshotNameToCommit(); + const baseLookup = await percy.client.getIntelliStorySnapshotNameToCommit(percy.build?.id); log.debug(`IntelliStory: base lookup ${JSON.stringify(baseLookup)}`); if (baseLookup?.browser_upgrade) { diff --git a/packages/client/src/client.js b/packages/client/src/client.js index b8e4e76d9..6dbb78389 100644 --- a/packages/client/src/client.js +++ b/packages/client/src/client.js @@ -426,10 +426,11 @@ export class PercyClient { return this.get(`job_status?sync=true&type=${type}&id=${ids.join()}`); } - async getIntelliStorySnapshotNameToCommit() { + async getIntelliStorySnapshotNameToCommit(buildId) { this.log.debug('IntelliStory: looking up baselines...'); const qs = new URLSearchParams(); + if (buildId) qs.append('build_id', buildId); if (this.env.git?.branch) qs.append('branch', this.env.git.branch); if (this.env.target?.branch) qs.append('target_branch', this.env.target.branch); if (this.env.git?.sha) qs.append('commit_sha', this.env.git.sha); From 4d683ad7d1f79749563980a5184a574fc78b15fc Mon Sep 17 00:00:00 2001 From: RaghavsBrowserStack Date: Tue, 21 Jul 2026 02:56:32 +0530 Subject: [PATCH 12/14] update clien tests --- packages/client/test/client.test.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/client/test/client.test.js b/packages/client/test/client.test.js index 20c61fa00..e4b10adc8 100644 --- a/packages/client/test/client.test.js +++ b/packages/client/test/client.test.js @@ -907,6 +907,18 @@ describe('PercyClient', () => { expect(api.requests[expectedPath]).toBeDefined(); }); + + it('includes the build_id when provided', async () => { + const expectedPath = '/intelli_story/snapshot-name-to-commit?build_id=bld-123'; + api.reply(expectedPath, () => [200, { data: { b: 'sha-b' } }]); + + await expectAsync( + client.getIntelliStorySnapshotNameToCommit('bld-123') + ).toBeResolvedTo({ data: { b: 'sha-b' } }); + + expect(api.requests[expectedPath]).toBeDefined(); + expect(api.requests[expectedPath][0].method).toBe('GET'); + }); }); describe('#generateIntelliStoryGraph()', () => { From faf80edd5cb4de6b6e4a18760619a18dc1dc3350 Mon Sep 17 00:00:00 2001 From: RaghavsBrowserStack Date: Tue, 21 Jul 2026 10:29:34 +0530 Subject: [PATCH 13/14] test smartsnap in percy --- packages/core/test/percy.test.js | 49 ++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/packages/core/test/percy.test.js b/packages/core/test/percy.test.js index 2001b65a1..0b93c1be3 100644 --- a/packages/core/test/percy.test.js +++ b/packages/core/test/percy.test.js @@ -841,6 +841,55 @@ describe('Percy', () => { }); }); + describe('#startBuild()', () => { + it('returns the current build without creating one when percy has not started', async () => { + // readyState is null before start() + let result = await generatePromise(percy.yield.startBuild()); + expect(result).toBe(percy.build); + expect(api.requests['/builds']).toBeUndefined(); + }); + + it('returns the existing build without re-creating it when one already exists', async () => { + spyOn(percy.browser, 'launch'); + await percy.start(); + expect(percy.build.id).toBeDefined(); + let buildCount = api.requests['/builds'].length; + + let result = await generatePromise(percy.yield.startBuild()); + + expect(result).toBe(percy.build); + // the memoized queue start means no additional build is created + expect(api.requests['/builds'].length).toEqual(buildCount); + }); + + it('returns the build without re-creating it when a prior creation errored', async () => { + percy.readyState = 1; + percy.build = { error: 'build creation failed' }; + + let result = await generatePromise(percy.yield.startBuild()); + + expect(result).toEqual({ error: 'build creation failed' }); + expect(api.requests['/builds']).toBeUndefined(); + + // neutralize afterEach stop() for this hand-set state + percy.readyState = null; + }); + + it('creates the build up front when uploads are deferred', async () => { + percy = new Percy({ token: 'PERCY_TOKEN', snapshot: { widths: [1000] }, deferUploads: true }); + spyOn(percy.browser, 'launch'); + await percy.start(); + // deferred: the build is not created during start() + expect(percy.build?.id).toBeUndefined(); + + let result = await generatePromise(percy.yield.startBuild()); + + expect(percy.build.id).toBeDefined(); + expect(result).toBe(percy.build); + expect(api.requests['/builds']).toBeDefined(); + }); + }); + describe('#stop()', () => { // stop the previously started instance and clear requests async function reset(options) { From 5657256ce03ef79fd47cb8734d3076409e5386f2 Mon Sep 17 00:00:00 2001 From: RaghavsBrowserStack Date: Tue, 21 Jul 2026 12:58:38 +0530 Subject: [PATCH 14/14] apply suggested fixed --- .github/workflows/test.yml | 97 ++++++++++++++++++++++++ packages/cli-command/src/intelliStory.js | 33 ++++++-- 2 files changed, 123 insertions(+), 7 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ad7389b22..2f6bcca03 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -154,6 +154,103 @@ jobs: if: steps.retry0.outcome=='failure' run: echo "::warning title=Flaky tests::${{ matrix.package }} flaked on the first attempt and was recovered by a spec-level retry (tracked in PER-9011)." + # The Snyk-backed lockfile-diff path (resolveAffectedDeps in + # packages/cli-command/src/lockfileDiff.js) and its IntelliStory callers + # require snyk-nodejs-lockfile-parser, which needs Node >=18. On the Node 14 + # matrix above those tests are xdescribe'd / bail, so this leg runs the + # @percy/cli-command suite on Node 20 to exercise that code in CI. See PPLT-5844. + test-node20: + name: Test ${{ matrix.package }} (Node ${{ matrix.node }}) + # Skip only for the automated release PR (see the build job for rationale). + if: >- + ${{ !(github.event_name == 'pull_request' + && github.event.pull_request.user.login == 'github-actions[bot]' + && github.event.pull_request.head.repo.full_name == github.repository + && startsWith(github.head_ref, 'release/')) }} + needs: [build] + strategy: + matrix: + os: [ubuntu-latest] + node: [20] + package: + - '@percy/cli-command' + runs-on: ${{ matrix.os }} + # Collect failed node-test spec names so retries re-run only the specs that + # flaked instead of the whole suite. Non-PERCY_ name so it doesn't trip + # cli-doctor's env-audit tests. See PER-9011. + env: + CLI_TEST_FAILURES_FILE: ${{ github.workspace }}/.cli-test-failures.json + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/setup-node@3235b876344d2a9aa001b8d1453c930bba69e610 # v3.9.1 + with: + node-version: ${{ matrix.node }} + - uses: actions/cache@f4b3439a656ba812b8cb417d2d49f9c810103092 # v3.4.0 + with: + path: | + node_modules + packages/*/node_modules + packages/core/.local-chromium + key: > + ${{ runner.os }}/node-${{ matrix.node }}/ + ${{ hashFiles('.github/.cache-key') }}/ + ${{ hashFiles('**/yarn.lock') }} + restore-keys: > + ${{ runner.os }}/node-${{ matrix.node }}/ + ${{ hashFiles('.github/.cache-key') }}/ + - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 + with: + name: dist + path: packages + - run: yarn + - name: Install browser dependencies + run: | + sudo apt-get update + sudo apt-get install -y --fix-missing libgbm-dev + if: ${{ matrix.os == 'ubuntu-latest' }} + # First attempt runs the full suite WITH coverage (enforces the 100% + # gate) and records any failed specs. + - name: Run tests + continue-on-error: true + id: retry0 + run: yarn workspace ${{ matrix.package }} test:coverage --colors + # Retries re-run ONLY the specs that failed in the previous attempt, and + # WITHOUT coverage (a subset can't hit the 100% threshold). If retry0 + # failed with no recorded spec failures (e.g. a real coverage drop), the + # runner preserves that failure instead of masking it. See PER-9011. + - name: Run tests Retry (1/4) + continue-on-error: true + id: retry1 + if: steps.retry0.outcome=='failure' + env: + CLI_TEST_ONLY_FAILED: '1' + run: yarn workspace ${{ matrix.package }} test --colors + - name: Run tests Retry (2/4) + continue-on-error: true + id: retry2 + if: steps.retry1.outcome=='failure' + env: + CLI_TEST_ONLY_FAILED: '1' + run: yarn workspace ${{ matrix.package }} test --colors + - name: Run tests Retry (3/4) + continue-on-error: true + id: retry3 + if: steps.retry2.outcome=='failure' + env: + CLI_TEST_ONLY_FAILED: '1' + run: yarn workspace ${{ matrix.package }} test --colors + - name: Run tests Retry (4/4) + id: retry4 + if: steps.retry3.outcome=='failure' + env: + CLI_TEST_ONLY_FAILED: '1' + run: yarn workspace ${{ matrix.package }} test --colors + # Keep "green via retry" honest: surface a warning whenever the first + # attempt failed and a retry recovered it, so flakiness stays visible. + - name: Flag flaky tests + if: steps.retry0.outcome=='failure' + run: echo "::warning title=Flaky tests::${{ matrix.package }} flaked on the first attempt and was recovered by a spec-level retry (tracked in PER-9011)." + regression: name: Regression # Skip only for the automated release PR (see the build job for rationale). diff --git a/packages/cli-command/src/intelliStory.js b/packages/cli-command/src/intelliStory.js index 6e8205182..74b1bcff0 100644 --- a/packages/cli-command/src/intelliStory.js +++ b/packages/cli-command/src/intelliStory.js @@ -65,7 +65,7 @@ function assertSafeRef(ref) { function gitDiffNames(ref) { assertSafeRef(ref); - return git(['diff', '--name-only', ref, 'HEAD', '--']).split('\n').filter(Boolean); + return git(['-c', 'core.quotepath=false', 'diff', '--name-only', ref, 'HEAD', '--']).split('\n').filter(Boolean); } function gitProjectRoot() { @@ -74,7 +74,7 @@ function gitProjectRoot() { export function getAffectedFileLocations(baseRef, files) { assertSafeRef(baseRef); - const diff = git(['diff', '--unified=0', '--no-color', '--no-renames', baseRef, 'HEAD', '--']); + const diff = git(['-c', 'core.quotepath=false', 'diff', '--unified=0', '--no-color', '--no-renames', baseRef, 'HEAD', '--']); const toPosix = p => p.split(path.sep).join('/'); const indexByPath = new Map(files.map((f, i) => [toPosix(f), i])); @@ -143,16 +143,24 @@ function transformModule(m, fileIndex, projectRoot) { return out; } -function readStats(statsFile, projectRoot) { +function readStats(statsFile, projectRoot, log) { const fileIndex = new Map(); const modules = []; - const stats = JSON.parse(fs.readFileSync(statsFile, 'utf8')); + let stats; + try { + stats = JSON.parse(fs.readFileSync(statsFile, 'utf8')); + } catch (e) { + throw new IntelliStoryBailError(`IntelliStory: failed to parse stats file ${statsFile}: ${e.message}; running full snapshot set`); + } /* istanbul ignore next */ const rawModules = stats.modules || []; + let droppedModules = 0; for (const m of rawModules) { const t = transformModule(m, fileIndex, projectRoot); if (t) modules.push(t); + else droppedModules++; } + if (droppedModules) log?.debug(`IntelliStory: dropped ${droppedModules} module(s) with unresolved (non-absolute) ids`); const files = [...fileIndex.entries()] .sort((a, b) => a[1] - b[1]) @@ -192,7 +200,7 @@ export async function validateAndReadStats(buildDir, statsFile, projectRoot, log // The graph is now keyed by the Percy build id, not the stats-file `buildId`, // so a missing `buildId` in the stats file is no longer fatal. We only need // the module graph (`files`/`modules`) from here. - const { files, modules } = await readStats(resolvedStatsPath, projectRoot); + const { files, modules } = await readStats(resolvedStatsPath, projectRoot, log); return { files, modules }; } @@ -252,6 +260,7 @@ export function enforceUntraced(affectedNodes, untraced) { } export async function getAffectedPackages(affectedNodes, baseRef, projectRoot, log) { + assertSafeRef(baseRef); const MANIFEST_PATHS = new Set(['package.json', 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml']); const manifestHits = affectedNodes.filter(p => MANIFEST_PATHS.has(path.basename(p))); @@ -282,11 +291,21 @@ export async function getAffectedPackages(affectedNodes, baseRef, projectRoot, l throw new IntelliStoryBailError(`IntelliStory: lockfile "${lockfileRepoPath}" not present at base ref ${baseRef}; running full snapshot set`); } - const newLockfile = fs.readFileSync(path.join(absManifestDir, lockfileName), 'utf8'); // nosemgrep + let newLockfile; + try { + newLockfile = fs.readFileSync(path.join(absManifestDir, lockfileName), 'utf8'); // nosemgrep + } catch { + throw new IntelliStoryBailError(`IntelliStory: failed to read lockfile "${lockfileName}" in "${manifestDir}"; running full snapshot set`); + } if (oldLockfile === newLockfile) return []; - const packageJson = fs.readFileSync(path.join(absManifestDir, 'package.json'), 'utf8'); // nosemgrep + let packageJson; + try { + packageJson = fs.readFileSync(path.join(absManifestDir, 'package.json'), 'utf8'); // nosemgrep + } catch { + throw new IntelliStoryBailError(`IntelliStory: failed to read "package.json" in "${manifestDir}"; running full snapshot set`); + } const packageJsonRepoPath = manifestDir === '.' ? 'package.json' : `${manifestDir}/package.json`; const oldPackageJson = git(['show', `${baseRef}:${packageJsonRepoPath}`]); try {