From bc0139f8d1a4348cb90c101eadacec4dbda1042b Mon Sep 17 00:00:00 2001 From: RaghavsBrowserStack Date: Wed, 15 Jul 2026 16:37:03 +0530 Subject: [PATCH 1/4] 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 4/4] 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);