diff --git a/package.json b/package.json index 29c841fb4f1..5b14ad7467b 100644 --- a/package.json +++ b/package.json @@ -32,9 +32,9 @@ "start": "cross-env NODE_ENV=storybook storybook dev -p 9003 --ci -c '.storybook'", "build:storybook": "storybook build -c .storybook -o dist/$(git rev-parse HEAD)/storybook", "start:chromatic": "CHROMATIC=1 NODE_ENV=storybook storybook dev -p 9004 --ci -c '.chromatic'", - "build:chromatic": "CHROMATIC=1 storybook build -c .chromatic -o dist/$(git rev-parse HEAD)/chromatic", + "build:chromatic": "CHROMATIC=1 storybook build -c .chromatic -o dist/$(git rev-parse HEAD)/chromatic --stats-json", "start:chromatic-fc": "CHROMATIC=1 NODE_ENV=storybook storybook dev -p 9005 --ci -c '.chromatic-fc'", - "build:chromatic-fc": "CHROMATIC=1 storybook build -c .chromatic-fc -o dist/$(git rev-parse HEAD)/chromatic-fc", + "build:chromatic-fc": "CHROMATIC=1 storybook build -c .chromatic-fc -o dist/$(git rev-parse HEAD)/chromatic-fc --stats-json", "start:s2": "NODE_ENV=storybook storybook dev -p 6006 --ci -c '.storybook-s2'", "build:storybook-s2": "NODE_ENV=storybook storybook build -c .storybook-s2 -o dist/$(git rev-parse HEAD)/storybook-s2", "build:s2-storybook-docs": "NODE_ENV=storybook storybook build -c .storybook-s2 --docs", @@ -62,8 +62,8 @@ "build:icons": "babel-node --presets @babel/env ./scripts/buildIcons.js", "clean:icons": "babel-node --presets @babel/env ./scripts/cleanIcons.js", "postinstall": "patch-package && yarn build:icons", - "chromatic": "NODE_ENV=production CHROMATIC=1 chromatic --project-token $CHROMATIC_PROJECT_TOKEN --build-script-name 'build:chromatic'", - "chromatic:forced-colors": "NODE_ENV=production CHROMATIC=1 chromatic --project-token $CHROMATIC_FC_PROJECT_TOKEN --build-script-name 'build:chromatic-fc'", + "chromatic": "NODE_ENV=production CHROMATIC=1 chromatic --project-token $CHROMATIC_PROJECT_TOKEN --build-script-name 'build:chromatic' --only-changed --trace-changed --externals './packages/**/style/**/*'", + "chromatic:forced-colors": "NODE_ENV=production CHROMATIC=1 chromatic --project-token $CHROMATIC_FC_PROJECT_TOKEN --build-script-name 'build:chromatic-fc' --only-changed --trace-changed --externals './packages/**/style/**/*'", "merge:css": "babel-node --presets @babel/env ./scripts/merge-spectrum-css.js", "release": "lerna publish from-package --yes", "version:nightly": "yarn workspaces foreach --all --no-private -t version -d 3.0.0-nightly-$(git rev-parse --short HEAD)-$(date +'%y%m%d') && yarn apply-nightly --all", @@ -142,7 +142,7 @@ "babel-plugin-react-remove-properties": "^0.3.0", "babel-plugin-transform-glob-import": "^1.0.1", "chalk": "^4.1.2", - "chromatic": "^15.0.0", + "chromatic": "^17.0.0", "clsx": "^2.0.0", "color-space": "^1.16.0", "concurrently": "^6.0.2", diff --git a/packages/dev/parcel-reporter-turbosnap-stats/StatsReporter.ts b/packages/dev/parcel-reporter-turbosnap-stats/StatsReporter.ts new file mode 100644 index 00000000000..8efb6d57376 --- /dev/null +++ b/packages/dev/parcel-reporter-turbosnap-stats/StatsReporter.ts @@ -0,0 +1,25 @@ +import {addStoryEntries, buildStatsMap, rewriteStoryVirtuals, writeStats} from './helpers'; +import {Reporter} from '@parcel/plugin'; + +const reporter = new Reporter({ + async report({event, options, logger}) { + if (event.type !== 'buildSuccess') return; + + const statsMap = buildStatsMap(event.bundleGraph, options.projectRoot); + rewriteStoryVirtuals(statsMap); + addStoryEntries(statsMap, logger); + + const bundles = event.bundleGraph.getBundles(); + const distDir = bundles[0]?.target.distDir; + if (!distDir) { + throw new Error( + 'parcel-reporter-turbosnap-stats: no bundles were produced; cannot determine output dir.' + ); + } + await writeStats(distDir, statsMap, options.outputFS, logger); + } +}); + +// Parcel's plugin loader expects `module.exports = `, +// not the `.default` wrapper TypeScript would otherwise produce. +module.exports = reporter; diff --git a/packages/dev/parcel-reporter-turbosnap-stats/__tests__/__fixtures__/.parcelrc b/packages/dev/parcel-reporter-turbosnap-stats/__tests__/__fixtures__/.parcelrc new file mode 100644 index 00000000000..47d1b5e3e88 --- /dev/null +++ b/packages/dev/parcel-reporter-turbosnap-stats/__tests__/__fixtures__/.parcelrc @@ -0,0 +1,3 @@ +{ + "extends": "@parcel/config-default" +} diff --git a/packages/dev/parcel-reporter-turbosnap-stats/__tests__/__fixtures__/Button.stories.tsx b/packages/dev/parcel-reporter-turbosnap-stats/__tests__/__fixtures__/Button.stories.tsx new file mode 100644 index 00000000000..86f97a0d8b2 --- /dev/null +++ b/packages/dev/parcel-reporter-turbosnap-stats/__tests__/__fixtures__/Button.stories.tsx @@ -0,0 +1,3 @@ +import {Button} from './Button'; +export {Button}; +export default {title: 'Button'}; diff --git a/packages/dev/parcel-reporter-turbosnap-stats/__tests__/__fixtures__/Button.tsx b/packages/dev/parcel-reporter-turbosnap-stats/__tests__/__fixtures__/Button.tsx new file mode 100644 index 00000000000..0dc01ae4d3d --- /dev/null +++ b/packages/dev/parcel-reporter-turbosnap-stats/__tests__/__fixtures__/Button.tsx @@ -0,0 +1 @@ +export const Button = () => null; diff --git a/packages/dev/parcel-reporter-turbosnap-stats/__tests__/__fixtures__/index.html b/packages/dev/parcel-reporter-turbosnap-stats/__tests__/__fixtures__/index.html new file mode 100644 index 00000000000..e13da3abe26 --- /dev/null +++ b/packages/dev/parcel-reporter-turbosnap-stats/__tests__/__fixtures__/index.html @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/dev/parcel-reporter-turbosnap-stats/__tests__/__fixtures__/preview.js b/packages/dev/parcel-reporter-turbosnap-stats/__tests__/__fixtures__/preview.js new file mode 100644 index 00000000000..e98ee50523f --- /dev/null +++ b/packages/dev/parcel-reporter-turbosnap-stats/__tests__/__fixtures__/preview.js @@ -0,0 +1,2 @@ +import stories from './storybook-builder-parcel/generated-entries/stories.js'; +console.log(stories); diff --git a/packages/dev/parcel-reporter-turbosnap-stats/__tests__/__fixtures__/storybook-builder-parcel/generated-entries/stories.js b/packages/dev/parcel-reporter-turbosnap-stats/__tests__/__fixtures__/storybook-builder-parcel/generated-entries/stories.js new file mode 100644 index 00000000000..6883c2c683a --- /dev/null +++ b/packages/dev/parcel-reporter-turbosnap-stats/__tests__/__fixtures__/storybook-builder-parcel/generated-entries/stories.js @@ -0,0 +1,7 @@ +// Mirrors what parcel-resolver-storybook emits for each `story:` glob: an object +// of `() => import('./Foo.stories.tsx')` async loaders. rewriteStoryVirtuals +// renames this file's STORY_VIRTUAL_RE-matching path to ./storybook-stories.js +// in the emitted stats. +module.exports = { + './Button.stories.tsx': () => import('../../Button.stories.tsx') +}; diff --git a/packages/dev/parcel-reporter-turbosnap-stats/__tests__/helpers.test.ts b/packages/dev/parcel-reporter-turbosnap-stats/__tests__/helpers.test.ts new file mode 100644 index 00000000000..aca7d415387 --- /dev/null +++ b/packages/dev/parcel-reporter-turbosnap-stats/__tests__/helpers.test.ts @@ -0,0 +1,377 @@ +import { + addStoryEntries, + buildStatsMap, + isUserCode, + type Module, + normalize, + rewriteStoryVirtuals, + stripQueryParams, + writeStats +} from '../helpers'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +describe('stripQueryParams', () => { + test('returns input unchanged when no query string', () => { + expect(stripQueryParams('./src/Button.tsx')).toBe('./src/Button.tsx'); + }); + test('strips simple query string', () => { + expect(stripQueryParams('./src/Button.tsx?v=1')).toBe('./src/Button.tsx'); + }); + test('strips query string with multiple params', () => { + expect(stripQueryParams('./src/Button.tsx?v=1&t=2')).toBe('./src/Button.tsx'); + }); +}); + +describe('normalize', () => { + const root = '/repo'; + + test('absolute path inside root → "./relative" POSIX form', () => { + expect(normalize('/repo/src/Button.tsx', root)).toBe('./src/Button.tsx'); + }); + test('strips query params before normalizing', () => { + expect(normalize('/repo/src/Button.tsx?v=42', root)).toBe('./src/Button.tsx'); + }); + test('Windows backslashes converted to forward slashes', () => { + expect(normalize('/repo/src\\nested\\Button.tsx', root)).toMatch( + /^\.\/src\/nested\/Button\.tsx$/ + ); + }); +}); + +describe('isUserCode', () => { + test('user source paths → true', () => { + expect(isUserCode('./packages/foo/Button.tsx')).toBe(true); + }); + test('node_modules paths → true (lockfile-bail prevention)', () => { + expect(isUserCode('./node_modules/react/index.js')).toBe(true); + }); + test('react/jsx-runtime → false (mirrors builder-vite filter)', () => { + expect(isUserCode('./node_modules/react/jsx-runtime.js')).toBe(false); + }); + test('bare @parcel/runtime-* → false', () => { + expect(isUserCode('@parcel/runtime-js/foo.js')).toBe(false); + }); + test('normalized node_modules @parcel/runtime-* → false', () => { + expect(isUserCode('./node_modules/@parcel/runtime-js/lib/runtime-abc.js')).toBe(false); + }); + test('virtual storybook-stories.js → true (it is the CSF-glob anchor)', () => { + expect(isUserCode('./storybook-stories.js')).toBe(true); + }); +}); + +// Minimal stand-in satisfying the BundleGraph methods buildStatsMap uses. +// If Parcel ever renames these methods, this mock breaks first — and the test +// failure tells the maintainer where to look. +function makeMockGraph(opts: { + assets: string[]; + edges?: [string, string][]; + asyncEdges?: [string, string][]; +}) { + const assetById = new Map(); + for (const filePath of opts.assets) { + assetById.set(filePath, {id: filePath, filePath}); + } + const depsBySource = new Map(); + for (const [src, dst] of opts.edges ?? []) { + if (!depsBySource.has(src)) depsBySource.set(src, []); + depsBySource.get(src)!.push({id: `${src}->${dst}`, target: dst}); + } + for (const [src, dst] of opts.asyncEdges ?? []) { + if (!depsBySource.has(src)) depsBySource.set(src, []); + depsBySource.get(src)!.push({id: `${src}->${dst}`, target: dst, isAsync: true}); + } + + return { + getBundles: () => [ + { + traverseAssets: (visit: (a: any) => void) => { + for (const a of assetById.values()) visit(a); + } + } + ], + getDependencies: (asset: {filePath: string}) => depsBySource.get(asset.filePath) ?? [], + getResolvedAsset: (dep: {target: string; isAsync?: boolean}, _bundle: unknown) => + dep.isAsync ? null : (assetById.get(dep.target) ?? null), + resolveAsyncDependency: (dep: {target: string; isAsync?: boolean}, _bundle: unknown) => { + if (!dep.isAsync) return null; + const target = assetById.get(dep.target); + return target ? {type: 'asset', value: target} : null; + }, + getAssetById: (id: string) => assetById.get(id) + } as any; +} + +describe('buildStatsMap', () => { + const root = ''; + + test('inverts a linear chain into reasons', () => { + const g = makeMockGraph({ + assets: ['./Button.tsx', './Button.stories.tsx'], + edges: [['./Button.stories.tsx', './Button.tsx']] + }); + const m = buildStatsMap(g, root); + expect(m.get('./Button.tsx')?.reasons).toEqual([{moduleName: './Button.stories.tsx'}]); + expect(m.get('./Button.stories.tsx')?.reasons).toEqual([]); + }); + + test('accumulates reasons from multiple importers', () => { + const g = makeMockGraph({ + assets: ['./shared.ts', './a.tsx', './b.tsx'], + edges: [ + ['./a.tsx', './shared.ts'], + ['./b.tsx', './shared.ts'] + ] + }); + const m = buildStatsMap(g, root); + const reasons = m + .get('./shared.ts')! + .reasons.map(r => r.moduleName) + .sort(); + expect(reasons).toEqual(['./a.tsx', './b.tsx']); + }); + + test('dedupes repeated edges from the same importer', () => { + const g = makeMockGraph({ + assets: ['./shared.ts', './a.tsx'], + edges: [ + ['./a.tsx', './shared.ts'], + ['./a.tsx', './shared.ts'] + ] + }); + const m = buildStatsMap(g, root); + expect(m.get('./shared.ts')!.reasons).toEqual([{moduleName: './a.tsx'}]); + }); + + test('skips filtered modules (react/jsx-runtime)', () => { + const g = makeMockGraph({ + assets: ['./node_modules/react/jsx-runtime.js', './Button.tsx'], + edges: [['./Button.tsx', './node_modules/react/jsx-runtime.js']] + }); + const m = buildStatsMap(g, root); + expect(m.has('./node_modules/react/jsx-runtime.js')).toBe(false); + expect(m.has('./Button.tsx')).toBe(true); + }); + + test('leaf assets get a record with empty reasons', () => { + const g = makeMockGraph({assets: ['./leaf.ts'], edges: []}); + const m = buildStatsMap(g, root); + expect(m.get('./leaf.ts')).toEqual({id: './leaf.ts', name: './leaf.ts', reasons: []}); + }); + + test('skips self-edges when two assets share a normalized name', () => { + // Simulate Parcel emitting two Asset objects for the same source file + // (different ids, same filePath): one with id='A', one with id='B'. The mock + // graph indexes assets by filePath, so we use a single entry but add a + // self-pointing edge to mimic the dep traversal landing on a sibling asset + // that normalizes to the same name. + const g = makeMockGraph({ + assets: ['./TagGroup.tsx'], + edges: [['./TagGroup.tsx', './TagGroup.tsx']] + }); + const m = buildStatsMap(g, root); + expect(m.get('./TagGroup.tsx')!.reasons).toEqual([]); + }); + + test('resolves async deps past Parcel runtime wrappers to the target asset', () => { + // Real Parcel: getResolvedAsset on an async dep returns the @parcel/runtime-js + // wrapper, not the target. resolveAsyncDependency unwraps to the real asset. + const g = makeMockGraph({ + assets: ['./entry.js', './Foo.stories.tsx'], + asyncEdges: [['./entry.js', './Foo.stories.tsx']] + }); + const m = buildStatsMap(g, root); + expect(m.get('./Foo.stories.tsx')?.reasons).toEqual([{moduleName: './entry.js'}]); + }); +}); + +describe('rewriteStoryVirtuals', () => { + const STORY_VIRTUAL = './packages/dev/storybook-builder-parcel/generated-entries/stories.js'; + const CANONICAL = './storybook-stories.js'; + + test('renames a single stories.js virtual to the canonical name', () => { + const m = new Map([ + [STORY_VIRTUAL, {id: STORY_VIRTUAL, name: STORY_VIRTUAL, reasons: []}] + ]); + rewriteStoryVirtuals(m); + expect(m.has(STORY_VIRTUAL)).toBe(false); + expect(m.get(CANONICAL)).toEqual({id: CANONICAL, name: CANONICAL, reasons: []}); + }); + + test('rewrites any reason pointing at the old virtual', () => { + const m = new Map([ + [STORY_VIRTUAL, {id: STORY_VIRTUAL, name: STORY_VIRTUAL, reasons: []}], + [ + './Button.stories.tsx', + { + id: './Button.stories.tsx', + name: './Button.stories.tsx', + reasons: [{moduleName: STORY_VIRTUAL}] + } + ] + ]); + rewriteStoryVirtuals(m); + expect(m.get('./Button.stories.tsx')!.reasons).toEqual([{moduleName: CANONICAL}]); + }); + + test('collapses multiple stories.js virtuals into one canonical entry', () => { + const PATH_A = './a/storybook-builder-parcel/generated-entries/stories.js'; + const PATH_B = './b/storybook-builder-parcel/generated-entries/stories.js'; + const m = new Map([ + [PATH_A, {id: PATH_A, name: PATH_A, reasons: [{moduleName: './x.tsx'}]}], + [PATH_B, {id: PATH_B, name: PATH_B, reasons: [{moduleName: './y.tsx'}]}] + ]); + rewriteStoryVirtuals(m); + expect(m.size).toBe(1); + const merged = m.get(CANONICAL)!; + const moduleNames = merged.reasons.map(r => r.moduleName).sort(); + expect(moduleNames).toEqual(['./x.tsx', './y.tsx']); + }); + + test('leaves non-matching entries untouched', () => { + const m = new Map([ + ['./Button.tsx', {id: './Button.tsx', name: './Button.tsx', reasons: []}] + ]); + rewriteStoryVirtuals(m); + expect(m.has('./Button.tsx')).toBe(true); + expect(m.has(CANONICAL)).toBe(false); + }); +}); + +const silentLogger = {info: () => {}}; +// Minimal FileSystem stub: writeStats only calls .writeFile, so we adapt node's +// fs.promises.writeFile to match @parcel/types FileSystem's signature. +const nodeFS = { + writeFile: (p: string, c: string | Buffer) => fs.promises.writeFile(p, c) +} as any; + +describe('writeStats — validation', () => { + test('throws when modules map is empty', async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ts-empty-')); + await expect(writeStats(tmp, new Map(), nodeFS, silentLogger)).rejects.toThrow( + /empty modules array/ + ); + }); + + test('throws when no module references ./storybook-stories.js', async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ts-nocsf-')); + const m = new Map([ + ['./Foo.tsx', {id: './Foo.tsx', name: './Foo.tsx', reasons: []}] + ]); + await expect(writeStats(tmp, m, nodeFS, silentLogger)).rejects.toThrow( + /no module references \.\/storybook-stories\.js/ + ); + }); +}); + +describe('writeStats — happy path', () => { + test('writes preview-stats.json to distDir with expected shape', async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ts-write-')); + const m = new Map([ + [ + './storybook-stories.js', + { + id: './storybook-stories.js', + name: './storybook-stories.js', + reasons: [{moduleName: './preview-main.js'}] + } + ], + [ + './Button.stories.tsx', + { + id: './Button.stories.tsx', + name: './Button.stories.tsx', + reasons: [{moduleName: './storybook-stories.js'}] + } + ] + ]); + + let infoLog: string | undefined; + const logger = { + info: (m: {message: string}) => { + infoLog = m.message; + } + }; + await writeStats(tmp, m, nodeFS, logger); + + const written = JSON.parse(fs.readFileSync(path.join(tmp, 'preview-stats.json'), 'utf8')); + expect(Object.keys(written)).toEqual(['modules']); + expect(written.modules).toHaveLength(2); + // writeStats sorts modules by name, so use find() rather than asserting index. + const storiesEntry = written.modules.find((mod: any) => mod.name === './storybook-stories.js'); + expect(storiesEntry).toEqual({ + id: './storybook-stories.js', + name: './storybook-stories.js', + reasons: [{moduleName: './preview-main.js'}] + }); + expect(infoLog).toMatch(/wrote preview-stats\.json \(2 modules\)/); + }); +}); + +describe('addStoryEntries', () => { + const CSF_GLOB = './parcel-csf-glob.js'; + const CANONICAL = './storybook-stories.js'; + + test('rewrites the storybook-stories.js reason on a story file to the synthetic glob', () => { + const m = new Map([ + [ + './Foo.stories.tsx', + { + id: './Foo.stories.tsx', + name: './Foo.stories.tsx', + reasons: [{moduleName: CANONICAL}] + } + ] + ]); + addStoryEntries(m); + expect(m.get('./Foo.stories.tsx')!.reasons).toEqual([{moduleName: CSF_GLOB}]); + }); + + test('inserts the synthetic CSF-glob node with ./storybook-stories.js as its reason', () => { + const m = new Map([ + [ + './Foo.stories.tsx', + { + id: './Foo.stories.tsx', + name: './Foo.stories.tsx', + reasons: [{moduleName: CANONICAL}] + } + ] + ]); + addStoryEntries(m); + expect(m.get(CSF_GLOB)).toEqual({ + id: CSF_GLOB, + name: CSF_GLOB, + reasons: [{moduleName: CANONICAL}] + }); + }); + + test('preserves other reasons besides storybook-stories.js', () => { + const m = new Map([ + [ + './Foo.stories.tsx', + { + id: './Foo.stories.tsx', + name: './Foo.stories.tsx', + reasons: [{moduleName: CANONICAL}, {moduleName: './docs.mdx'}] + } + ] + ]); + addStoryEntries(m); + expect(m.get('./Foo.stories.tsx')!.reasons).toEqual([ + {moduleName: CSF_GLOB}, + {moduleName: './docs.mdx'} + ]); + }); + + test('does nothing and skips synthetic node insertion when no story files match', () => { + const m = new Map([ + ['./Button.tsx', {id: './Button.tsx', name: './Button.tsx', reasons: []}] + ]); + const tagged = addStoryEntries(m); + expect(tagged).toBe(0); + expect(m.get('./Button.tsx')!.reasons).toEqual([]); + expect(m.has(CSF_GLOB)).toBe(false); + }); +}); diff --git a/packages/dev/parcel-reporter-turbosnap-stats/__tests__/integration.test.ts b/packages/dev/parcel-reporter-turbosnap-stats/__tests__/integration.test.ts new file mode 100644 index 00000000000..518cbd50af3 --- /dev/null +++ b/packages/dev/parcel-reporter-turbosnap-stats/__tests__/integration.test.ts @@ -0,0 +1,77 @@ +import fs from 'fs'; +import {FSCache} from '@parcel/cache'; +// @ts-ignore — @parcel/fs has no published types in this version +import {NodeFS} from '@parcel/fs'; +import os from 'os'; +import {Parcel} from '@parcel/core'; +import path from 'path'; + +jest.setTimeout(60_000); + +describe('integration: real Parcel build emits preview-stats.json', () => { + test('reporter runs end-to-end and writes valid preview-stats.json', async () => { + const fixtureDir = path.join(__dirname, '__fixtures__'); + const distDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ts-int-')); + // Use FSCache so Jest's CJS transform doesn't trip over lmdb/native.js + // which uses import.meta.url (ESM-only) causing "URL must be of scheme file". + const cacheDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ts-cache-')); + // @ts-expect-error — published @parcel/cache .d.ts declares FSCache(cacheDir) but the runtime constructor is FSCache(fs, cacheDir) + const cache = new FSCache(new NodeFS(), cacheDir); + + const parcel = new (Parcel as any)({ + entries: path.join(fixtureDir, 'index.html'), + config: path.join(fixtureDir, '.parcelrc'), + mode: 'production', + cache, + // Disable Parcel's persistent cache so it never reads/writes @parcel/watcher + // snapshot files. The BruteForceBackend used on Linux CI containers throws + // "Unable to open snapshot file" on a fresh cache; with shouldDisableCache + // Parcel skips the snapshot read entirely. + shouldDisableCache: true, + additionalReporters: [ + { + // Point Parcel's plugin resolver at the package's local entry rather + // than '@parcel/reporter-turbosnap-stats'. yarn doesn't symlink this + // workspace into node_modules/@parcel/ until a dependent package + // (storybook-builder-parcel) is also installed, so a fresh checkout + // would fail with "Cannot find Parcel plugin". A relative path is + // permitted by Parcel's plugin-name validator (ParcelConfig.schema.js:29). + packageName: '../index.js', + resolveFrom: __filename + } + ], + targets: { + default: { + distDir, + publicUrl: './' + } + } + }); + + // The fixture mirrors the production setup: preview.js imports a stories.js + // entry at storybook-builder-parcel/generated-entries/stories.js, which + // async-imports Button.stories.tsx. After buildStatsMap (with + // resolveAsyncDependency unwrapping the runtime wrapper), rewriteStoryVirtuals + // renames the entry to ./storybook-stories.js, and addStoryEntries rewrites + // the story file's reason to point at the synthetic ./parcel-csf-glob.js. + // Verify the three-level chain chromatic-cli's traversal expects: + // 1. The CSF-glob node exists and has ./storybook-stories.js as a reason + // 2. At least one .stories.* file has the CSF-glob node as a reason + await parcel.run(); + const statsPath = path.join(distDir, 'preview-stats.json'); + expect(fs.existsSync(statsPath)).toBe(true); + const stats = JSON.parse(fs.readFileSync(statsPath, 'utf8')); + + const csfGlobNode = stats.modules.find((m: any) => m.name === './parcel-csf-glob.js'); + expect(csfGlobNode).toBeDefined(); + expect(csfGlobNode.reasons).toEqual([{moduleName: './storybook-stories.js'}]); + + const storyFile = stats.modules.find( + (m: any) => + /\.stories\./.test(m.name) && + m.reasons.some((r: any) => r.moduleName === './parcel-csf-glob.js') + ); + expect(storyFile).toBeDefined(); + expect(stats.modules.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/dev/parcel-reporter-turbosnap-stats/helpers.ts b/packages/dev/parcel-reporter-turbosnap-stats/helpers.ts new file mode 100644 index 00000000000..ddb9d7010c0 --- /dev/null +++ b/packages/dev/parcel-reporter-turbosnap-stats/helpers.ts @@ -0,0 +1,219 @@ +// Helpers for parcel-reporter-turbosnap-stats. See ./StatsReporter.ts for the +// plugin entry; this file holds the pure functions exported for unit testing. + +import type {Asset, BundleGraph, FileSystem} from '@parcel/types'; +import path from 'path'; + +// TurboSnap may still report 0% reuse for reasons outside this reporter's control: +// 1. Lockfile-only diff with no node_modules in stats — we DO include node_modules, +// but filter @parcel/runtime-* and react/jsx-runtime (mirrors builder-vite). If +// a react upgrade fails to propagate, this filter is the suspect. +// 2. Changes under .storybook/ or .chromatic/ — chromatic-cli treats these as +// Storybook-config changes and bails to full snapshot. By design. +// 3. Changes under any configured staticDir — same bail. +// See chromatic-cli node-src/lib/turbosnap/getDependentStoryFiles.ts lines 250-269. + +export interface Reason { + moduleName: string; +} +export interface Module { + id: string; + name: string; + reasons: Reason[]; +} + +export function stripQueryParams(id: string): string { + const idx = id.indexOf('?'); + return idx === -1 ? id : id.slice(0, idx); +} + +export function normalize(filePath: string, projectRoot: string): string { + const stripped = stripQueryParams(filePath); + // Convert backslashes to forward slashes regardless of platform — + // path.sep is '/' on Mac/Linux so .split(path.sep) wouldn't catch literal + // backslashes inside an input string. Universal replace avoids the gap. + const rel = path.relative(projectRoot, stripped).replace(/\\/g, '/'); + return './' + rel; +} + +// Filter Parcel runtime chunks (path may be bare "@parcel/runtime-*" or +// normalized "./node_modules/@parcel/runtime-*"). Also filter the React JSX +// runtime — mirrors builder-vite's filter; means React-version bumps won't +// propagate via stats, but avoids every JSX file having identical noisy reasons. +const FILTER_PATTERNS: RegExp[] = [/@parcel\/runtime-/, /\/react\/jsx-runtime\.js$/]; + +export function isUserCode(name: string): boolean { + for (const re of FILTER_PATTERNS) { + if (re.test(name)) return false; + } + return true; +} + +const STORY_VIRTUAL_RE = /\/storybook-builder-parcel\/generated-entries\/stories\.js$/; +const CANONICAL_CSF_GLOB = './storybook-stories.js'; + +export function rewriteStoryVirtuals(statsMap: Map): void { + for (const [oldName, entry] of [...statsMap]) { + if (!STORY_VIRTUAL_RE.test(oldName)) continue; + statsMap.delete(oldName); + entry.id = CANONICAL_CSF_GLOB; + entry.name = CANONICAL_CSF_GLOB; + const existing = statsMap.get(CANONICAL_CSF_GLOB); + if (existing) { + for (const r of entry.reasons) { + if (existing.reasons.every(x => x.moduleName !== r.moduleName)) { + existing.reasons.push(r); + } + } + } else { + statsMap.set(CANONICAL_CSF_GLOB, entry); + } + } + for (const entry of statsMap.values()) { + for (const reason of entry.reasons) { + if (STORY_VIRTUAL_RE.test(reason.moduleName)) { + reason.moduleName = CANONICAL_CSF_GLOB; + } + } + } +} + +export function buildStatsMap( + bundleGraph: BundleGraph, + projectRoot: string +): Map { + const statsMap = new Map(); + const ensure = (name: string): Module => { + let entry = statsMap.get(name); + if (!entry) { + entry = {id: name, name, reasons: []}; + statsMap.set(name, entry); + } + return entry; + }; + const seen = new Set(); + + for (const bundle of bundleGraph.getBundles()) { + bundle.traverseAssets((asset: Asset) => { + if (seen.has(asset.id)) return; + seen.add(asset.id); + + const assetName = normalize(asset.filePath, projectRoot); + if (!isUserCode(assetName)) return; + ensure(assetName); + + for (const dep of bundleGraph.getDependencies(asset)) { + // resolveAsyncDependency unwraps Parcel's @parcel/runtime-js code-splitting + // wrappers for `() => import('...')` deps so the edge points at the real + // target asset (e.g. ./Foo.stories.tsx) instead of the runtime chunk. + // Returns null for sync deps; fall back to getResolvedAsset there. + const asyncResult = bundleGraph.resolveAsyncDependency(dep, bundle); + let target: Asset | null | undefined; + if (asyncResult) { + target = asyncResult.type === 'asset' + ? asyncResult.value + : bundleGraph.getAssetById(asyncResult.value.entryAssetId); + } else { + target = bundleGraph.getResolvedAsset(dep, bundle); + } + if (!target) continue; + const depName = normalize(target.filePath, projectRoot); + if (!isUserCode(depName)) continue; + // Skip self-edges. Parcel sometimes emits multiple Asset objects for the + // same source file (e.g., a transformer's sibling output, HMR runtime + // injection), giving them distinct asset.id values but identical filePath. + // Without this guard those collapse into "TagGroup.tsx is a reason for + // TagGroup.tsx" entries — harmless (chromatic-cli filters them at + // getDependentStoryFiles.ts:169) but noisy in the emitted JSON. + if (depName === assetName) continue; + const entry = ensure(depName); + if (entry.reasons.every(r => r.moduleName !== assetName)) { + entry.reasons.push({moduleName: assetName}); + } + } + }); + } + return statsMap; +} + +const CSF_GLOB_ENTRY = './parcel-csf-glob.js'; + +// chromatic-cli's getDependentStoryFiles expects this three-level chain: +// +// ./storybook-stories.js ← (CSF entry, imported by preview-main.js) +// ↓ imports +// ./parcel-csf-glob.js ← reasons=[storybook-stories.js] → identified as the CSF glob +// ↓ imports +// ./Foo.stories.tsx ← reasons=[parcel-csf-glob.js] → added to affectedModuleIds +// +// We discover story files structurally: after buildStatsMap (with resolveAsyncDependency) +// and rewriteStoryVirtuals, every story file has './storybook-stories.js' as a reason. +// We rewrite that reason to point at the synthetic ./parcel-csf-glob.js instead. +// +// Pointing story files directly at './storybook-stories.js' would make THEM the +// CSF globs (per getDependentStoryFiles.ts:175-181), causing traceName to bail +// at the story file (line 286) and source files (not story files) to end up +// in affectedModuleIds — which chromatic then can't match to storyIndex entries. +export function addStoryEntries(statsMap: Map, logger?: Logger): number { + let tagged = 0; + for (const entry of statsMap.values()) { + if (entry.name === CSF_GLOB_ENTRY) continue; + let rewritten = false; + for (const reason of entry.reasons) { + if (reason.moduleName === CANONICAL_CSF_GLOB) { + reason.moduleName = CSF_GLOB_ENTRY; + rewritten = true; + } + } + if (rewritten) tagged++; + } + if (tagged > 0 && !statsMap.has(CSF_GLOB_ENTRY)) { + statsMap.set(CSF_GLOB_ENTRY, { + id: CSF_GLOB_ENTRY, + name: CSF_GLOB_ENTRY, + reasons: [{moduleName: CANONICAL_CSF_GLOB}] + }); + } + logger?.info({ + message: `parcel-reporter-turbosnap-stats: tagged ${tagged} story file(s) via synthetic CSF glob` + }); + return tagged; +} + +interface Logger { + info: (m: {message: string}) => void; +} + +export async function writeStats( + distDir: string, + statsMap: Map, + outputFS: FileSystem, + logger: Logger +): Promise { + // Sort modules by name so the emitted JSON is byte-stable across Parcel + // versions even if bundle.traverseAssets order shifts. chromatic-cli doesn't + // care about order; this only helps reproducibility for caching/diff use cases. + const modules = [...statsMap.values()].sort((a, b) => a.name.localeCompare(b.name)); + const stats = {modules}; + + if (stats.modules.length === 0) { + throw new Error( + 'parcel-reporter-turbosnap-stats: empty modules array — nothing was traversed.' + ); + } + const hasCsfGlob = stats.modules.some(m => + m.reasons.some(r => r.moduleName === CANONICAL_CSF_GLOB) + ); + if (!hasCsfGlob) { + throw new Error( + 'parcel-reporter-turbosnap-stats: no module references ./storybook-stories.js as a reason. ' + + 'chromatic-cli will hard-error with "Did not find any CSF globs in preview-stats.json". ' + + 'Check that parcel-resolver-storybook generated a stories.js virtual and STORY_VIRTUAL_RE matches its filePath.' + ); + } + + await outputFS.writeFile(path.join(distDir, 'preview-stats.json'), JSON.stringify(stats), null); + logger.info({ + message: `parcel-reporter-turbosnap-stats: wrote preview-stats.json (${stats.modules.length} modules) to ${distDir}` + }); +} diff --git a/packages/dev/parcel-reporter-turbosnap-stats/index.js b/packages/dev/parcel-reporter-turbosnap-stats/index.js new file mode 100644 index 00000000000..67db41c2448 --- /dev/null +++ b/packages/dev/parcel-reporter-turbosnap-stats/index.js @@ -0,0 +1 @@ +module.exports = require('./StatsReporter.ts'); diff --git a/packages/dev/parcel-reporter-turbosnap-stats/package.json b/packages/dev/parcel-reporter-turbosnap-stats/package.json new file mode 100644 index 00000000000..5dd0816f4c4 --- /dev/null +++ b/packages/dev/parcel-reporter-turbosnap-stats/package.json @@ -0,0 +1,21 @@ +{ + "name": "@parcel/reporter-turbosnap-stats", + "version": "0.0.0", + "private": true, + "source": "StatsReporter.ts", + "main": "dist/StatsReporter.js", + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "rm -rf dist && swc . -d dist --config-file ../../.swcrc", + "clean": "rm -rf dist" + }, + "dependencies": { + "@parcel/plugin": "^2.16.3", + "@parcel/types": "^2.16.3" + }, + "engines": { + "parcel": "^2.8.0" + } +} diff --git a/packages/dev/storybook-builder-parcel/package.json b/packages/dev/storybook-builder-parcel/package.json index 13d01f6bfd4..a39767cd0d8 100644 --- a/packages/dev/storybook-builder-parcel/package.json +++ b/packages/dev/storybook-builder-parcel/package.json @@ -15,6 +15,7 @@ "dependencies": { "@parcel/core": "^2.16.3", "@parcel/reporter-cli": "^2.16.3", + "@parcel/reporter-turbosnap-stats": "0.0.0", "@parcel/utils": "^2.16.3", "http-proxy-middleware": "^2.0.6", "storybook": "^10.0.0" diff --git a/packages/dev/storybook-builder-parcel/preset.mjs b/packages/dev/storybook-builder-parcel/preset.mjs index 62c699baaec..d8fb65dab92 100644 --- a/packages/dev/storybook-builder-parcel/preset.mjs +++ b/packages/dev/storybook-builder-parcel/preset.mjs @@ -123,7 +123,17 @@ async function createParcel(options, isDev = false) { mode: isDev ? 'development' : 'production', serveOptions: isDev ? {port: 3000} : null, hmrOptions: isDev ? {port: 3001} : null, - additionalReporters: [{packageName: '@parcel/reporter-cli', resolveFrom: __filename}], + additionalReporters: [ + {packageName: '@parcel/reporter-cli', resolveFrom: __filename}, + ...(options.statsJson + ? [ + { + packageName: '@parcel/reporter-turbosnap-stats', + resolveFrom: __filename + } + ] + : []) + ], targets: { storybook: { distDir: options.outputDir, diff --git a/yarn.lock b/yarn.lock index c2d9ba44f0a..ff6a85fc43f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5715,6 +5715,15 @@ __metadata: languageName: node linkType: hard +"@parcel/reporter-turbosnap-stats@npm:0.0.0, @parcel/reporter-turbosnap-stats@workspace:packages/dev/parcel-reporter-turbosnap-stats": + version: 0.0.0-use.local + resolution: "@parcel/reporter-turbosnap-stats@workspace:packages/dev/parcel-reporter-turbosnap-stats" + dependencies: + "@parcel/plugin": "npm:^2.16.3" + "@parcel/types": "npm:^2.16.3" + languageName: unknown + linkType: soft + "@parcel/resolver-default@npm:2.16.3, @parcel/resolver-default@npm:^2.16.3": version: 2.16.3 resolution: "@parcel/resolver-default@npm:2.16.3" @@ -6297,7 +6306,7 @@ __metadata: languageName: node linkType: hard -"@parcel/types@npm:2.16.4": +"@parcel/types@npm:2.16.4, @parcel/types@npm:^2.16.3": version: 2.16.4 resolution: "@parcel/types@npm:2.16.4" dependencies: @@ -14053,22 +14062,27 @@ __metadata: languageName: node linkType: hard -"chromatic@npm:^15.0.0": - version: 15.1.0 - resolution: "chromatic@npm:15.1.0" +"chromatic@npm:^17.0.0": + version: 17.0.1 + resolution: "chromatic@npm:17.0.1" + dependencies: + semver: "npm:^7.3.5" peerDependencies: "@chromatic-com/cypress": ^0.*.* || ^1.0.0 "@chromatic-com/playwright": ^0.*.* || ^1.0.0 + "@chromatic-com/vitest": ^0.*.* || ^1.0.0 peerDependenciesMeta: "@chromatic-com/cypress": optional: true "@chromatic-com/playwright": optional: true + "@chromatic-com/vitest": + optional: true bin: - chroma: dist/bin.js - chromatic: dist/bin.js - chromatic-cli: dist/bin.js - checksum: 10c0/aea449b3c07e599e9b4c1cd866ffa57a5fc6b158b7c1ae4c462f74133869927d0932a077191011bdb841ab81a2dde54b0a35370736ef1986b6854453f01086de + chroma: dist/bin.cjs + chromatic: dist/bin.cjs + chromatic-cli: dist/bin.cjs + checksum: 10c0/bd605a11508a293f1bb4f01b99a52f411a8fa56e74b9a10234e93ed196dcc20281609d5277662da3e70e2af75e30abeb4df2e9ef9b337e8c7eabd46a1b4846cf languageName: node linkType: hard @@ -26866,7 +26880,7 @@ __metadata: babel-plugin-react-remove-properties: "npm:^0.3.0" babel-plugin-transform-glob-import: "npm:^1.0.1" chalk: "npm:^4.1.2" - chromatic: "npm:^15.0.0" + chromatic: "npm:^17.0.0" clsx: "npm:^2.0.0" color-space: "npm:^1.16.0" concurrently: "npm:^6.0.2" @@ -29128,6 +29142,7 @@ __metadata: dependencies: "@parcel/core": "npm:^2.16.3" "@parcel/reporter-cli": "npm:^2.16.3" + "@parcel/reporter-turbosnap-stats": "npm:0.0.0" "@parcel/utils": "npm:^2.16.3" http-proxy-middleware: "npm:^2.0.6" react: "npm:*"