From 46f5f6fdcae637b4aac2e7a434de31b335902fc7 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Wed, 27 May 2026 16:03:44 +1000 Subject: [PATCH 01/13] upgrade chromatic cli --- package.json | 2 +- yarn.lock | 21 +++++++++++++-------- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/package.json b/package.json index 29c841fb4f1..ade0154d607 100644 --- a/package.json +++ b/package.json @@ -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/yarn.lock b/yarn.lock index def4ad819e5..4c10e915772 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14053,22 +14053,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 +26871,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" From b9d0c4c97682be986f987361b70d48ac78bb3cf3 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Thu, 28 May 2026 06:29:58 +1000 Subject: [PATCH 02/13] chore: enable turbosnap for chromatic --- .gitignore | 1 + .../2026-05-27-parcel-turbosnap-stats.md | 1176 +++++++++++++++++ ...026-05-27-parcel-turbosnap-stats-design.md | 460 +++++++ package.json | 8 +- .../StatsReporter.ts | 25 + .../__tests__/__fixtures__/.parcelrc | 3 + .../__tests__/__fixtures__/Button.stories.tsx | 3 + .../__tests__/__fixtures__/Button.tsx | 1 + .../__tests__/__fixtures__/index.html | 2 + .../__tests__/__fixtures__/preview.js | 2 + .../__tests__/helpers.test.ts | 289 ++++ .../__tests__/integration.test.ts | 55 + .../helpers.ts | 187 +++ .../parcel-reporter-turbosnap-stats/index.js | 1 + .../package.json | 21 + .../dev/storybook-builder-parcel/package.json | 1 + .../dev/storybook-builder-parcel/preset.mjs | 8 +- packages/react-aria/src/tag/useTag.ts | 1 + yarn.lock | 12 +- 19 files changed, 2250 insertions(+), 6 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-27-parcel-turbosnap-stats.md create mode 100644 docs/superpowers/specs/2026-05-27-parcel-turbosnap-stats-design.md create mode 100644 packages/dev/parcel-reporter-turbosnap-stats/StatsReporter.ts create mode 100644 packages/dev/parcel-reporter-turbosnap-stats/__tests__/__fixtures__/.parcelrc create mode 100644 packages/dev/parcel-reporter-turbosnap-stats/__tests__/__fixtures__/Button.stories.tsx create mode 100644 packages/dev/parcel-reporter-turbosnap-stats/__tests__/__fixtures__/Button.tsx create mode 100644 packages/dev/parcel-reporter-turbosnap-stats/__tests__/__fixtures__/index.html create mode 100644 packages/dev/parcel-reporter-turbosnap-stats/__tests__/__fixtures__/preview.js create mode 100644 packages/dev/parcel-reporter-turbosnap-stats/__tests__/helpers.test.ts create mode 100644 packages/dev/parcel-reporter-turbosnap-stats/__tests__/integration.test.ts create mode 100644 packages/dev/parcel-reporter-turbosnap-stats/helpers.ts create mode 100644 packages/dev/parcel-reporter-turbosnap-stats/index.js create mode 100644 packages/dev/parcel-reporter-turbosnap-stats/package.json diff --git a/.gitignore b/.gitignore index 178fe850797..7a5610e34ed 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ starters/docs/registry starters/tailwind/registry starters/docs/yarn.lock starters/tailwind/yarn.lock +.turbosnap-research/ diff --git a/docs/superpowers/plans/2026-05-27-parcel-turbosnap-stats.md b/docs/superpowers/plans/2026-05-27-parcel-turbosnap-stats.md new file mode 100644 index 00000000000..3518bd278ab --- /dev/null +++ b/docs/superpowers/plans/2026-05-27-parcel-turbosnap-stats.md @@ -0,0 +1,1176 @@ +# Parcel TurboSnap Stats Reporter Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +> **⚠️ NO COMMITS, NO PUSHES.** The user has explicitly forbidden all commits and pushes — including from subagents. Every task ends with a "verify" step instead of "commit." Leave the working tree dirty for the user to review and commit themselves. If you find yourself wanting to `git add` or `git commit`, stop and report status instead. + +**Goal:** Make `yarn chromatic` succeed with `--only-changed` (TurboSnap) on the Parcel-built `.chromatic` and `.chromatic-fc` Storybook configs by emitting a Chromatic-compatible `preview-stats.json` during the build. + +**Architecture:** New Parcel reporter plugin (`@parcel/reporter-turbosnap-stats`) walks Parcel's `BundleGraph` on `buildSuccess`, builds a `Map` matching chromatic-cli's contract, rewrites `parcel-resolver-storybook`'s synthetic `stories.js` virtual to TurboSnap's canonical `./storybook-stories.js` CSF-glob name, validates, and writes the file directly to the build output directory. Registered conditionally in `storybook-builder-parcel` when `options.statsJson` is true. Modeled function-for-function on `@storybook/builder-vite`'s `webpack-stats-plugin.ts` (the upstream proof point at ~140 LOC). Spec: `docs/superpowers/specs/2026-05-27-parcel-turbosnap-stats-design.md`. + +**Tech Stack:** TypeScript, Parcel 2 (`@parcel/plugin`, `@parcel/types`), Jest 29 + `@swc/jest`, Yarn workspaces. + +--- + +## File structure + +``` +packages/dev/parcel-reporter-turbosnap-stats/ +├── package.json NEW +├── index.js NEW (CommonJS bridge to TS source) +├── StatsReporter.ts NEW (plugin instance; imports from ./helpers) +├── helpers.ts NEW (all pure functions, named exports) +└── __tests__/ + ├── helpers.test.ts NEW (unit tests for all helpers) + ├── integration.test.ts NEW (real-Parcel fixture test) + └── __fixtures__/ NEW + ├── .parcelrc NEW + ├── index.html NEW + ├── preview.js NEW + ├── Button.stories.tsx NEW + └── Button.tsx NEW + +packages/dev/storybook-builder-parcel/ +├── package.json MODIFIED (+1 dep) +└── preset.mjs MODIFIED (lines 120-141; conditional reporter) +``` + +**Why two source files (`StatsReporter.ts` + `helpers.ts`):** Parcel's plugin loader expects `module.exports = new Reporter(...)` at the package entry point, which would overwrite TypeScript's named exports. Splitting helpers into a separate file avoids the workaround and gives tests a clean `import {x} from '../helpers'`. + +--- + +## Task 1: Scaffold the new package + +**Files:** +- Create: `packages/dev/parcel-reporter-turbosnap-stats/package.json` +- Create: `packages/dev/parcel-reporter-turbosnap-stats/index.js` +- Create: `packages/dev/parcel-reporter-turbosnap-stats/helpers.ts` (placeholder) +- Create: `packages/dev/parcel-reporter-turbosnap-stats/StatsReporter.ts` (placeholder) + +- [ ] **Step 1: Create `package.json`** (mirrors `packages/dev/parcel-resolver-storybook/package.json`) + +```json +{ + "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" + } +} +``` + +- [ ] **Step 2: Create `index.js`** (one-liner, matches the sibling resolver's pattern at `packages/dev/parcel-resolver-storybook/index.js`) + +```js +module.exports = require('./StatsReporter.ts'); +``` + +- [ ] **Step 3: Create placeholder `helpers.ts`** (will be filled in by subsequent tasks) + +```ts +// Helpers for parcel-reporter-turbosnap-stats. See ./StatsReporter.ts for the +// plugin entry; this file holds the pure functions exported for unit testing. + +// 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[]; } +``` + +- [ ] **Step 4: Create placeholder `StatsReporter.ts`** + +```ts +import {Reporter} from '@parcel/plugin'; + +// Plugin wiring is added in Task 9 once all helpers are implemented. +const reporter = new Reporter({ + async report() { + // intentionally empty until Task 9 + } +}); + +// Parcel's plugin loader expects `module.exports = `, +// not the `.default` wrapper TypeScript would otherwise produce. +module.exports = reporter; +``` + +- [ ] **Step 5: Wire workspace + verify discovery** + +Run: +```bash +yarn install +yarn jest packages/dev/parcel-reporter-turbosnap-stats --listTests +``` + +Expected: `yarn install` completes without errors. The `--listTests` command prints no test files (empty result) — that's correct; we haven't written any yet. + +- [ ] **Step 6: Verify, DO NOT commit** + +Run: +```bash +ls packages/dev/parcel-reporter-turbosnap-stats/ +``` + +Expected output: +``` +StatsReporter.ts +helpers.ts +index.js +package.json +``` + +Stop. Report progress. **Do not run `git add` or `git commit`.** + +--- + +## Task 2: TDD `stripQueryParams` + +**Files:** +- Modify: `packages/dev/parcel-reporter-turbosnap-stats/helpers.ts` +- Create: `packages/dev/parcel-reporter-turbosnap-stats/__tests__/helpers.test.ts` + +- [ ] **Step 1: Write failing test** + +Create `packages/dev/parcel-reporter-turbosnap-stats/__tests__/helpers.test.ts`: + +```ts +import {stripQueryParams} from '../helpers'; + +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'); + }); +}); +``` + +- [ ] **Step 2: Run test, verify it fails** + +Run: +```bash +yarn jest packages/dev/parcel-reporter-turbosnap-stats/__tests__/helpers.test.ts +``` + +Expected: FAIL with `TypeError: ... is not a function` or `(0 , _helpers.stripQueryParams) is not a function` — `stripQueryParams` is not yet exported. + +- [ ] **Step 3: Implement `stripQueryParams`** + +Append to `packages/dev/parcel-reporter-turbosnap-stats/helpers.ts` (after the existing interfaces): + +```ts +export function stripQueryParams(id: string): string { + const idx = id.indexOf('?'); + return idx === -1 ? id : id.slice(0, idx); +} +``` + +- [ ] **Step 4: Run test, verify it passes** + +Run: +```bash +yarn jest packages/dev/parcel-reporter-turbosnap-stats/__tests__/helpers.test.ts +``` + +Expected: PASS, 3 tests pass. + +- [ ] **Step 5: DO NOT commit. Report progress and pause.** + +--- + +## Task 3: TDD `normalize` + +**Files:** +- Modify: `packages/dev/parcel-reporter-turbosnap-stats/helpers.ts` +- Modify: `packages/dev/parcel-reporter-turbosnap-stats/__tests__/helpers.test.ts` + +- [ ] **Step 1: Add failing tests** + +Append to `__tests__/helpers.test.ts`: + +```ts +import {normalize} from '../helpers'; + +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', () => { + // Simulate a Windows-style relative result by passing a backslashy input + expect(normalize('/repo/src\\nested\\Button.tsx', root)).toMatch(/^\.\/src\/nested\/Button\.tsx$/); + }); + test('\\0-prefixed synthetic id gets virtual: leading-slash form', () => { + expect(normalize('\0synthetic/foo.js', root)).toBe('/virtual:/synthetic/foo.js'); + }); +}); +``` + +- [ ] **Step 2: Run tests, verify they fail** + +Run: +```bash +yarn jest packages/dev/parcel-reporter-turbosnap-stats/__tests__/helpers.test.ts -t normalize +``` + +Expected: FAIL — `normalize` is not yet exported. + +- [ ] **Step 3: Implement `normalize`** + +Append to `helpers.ts`: + +```ts +import path from 'path'; + +const VIRTUAL_PREFIX = '\0'; + +export function normalize(filePath: string, projectRoot: string): string { + const stripped = stripQueryParams(filePath); + if (stripped.startsWith(VIRTUAL_PREFIX)) { + // chromatic-cli's getDependentStoryFiles normalizePath short-circuits paths + // starting with /virtual: — see chromatic-cli node-src/lib/turbosnap/getDependentStoryFiles.ts + // line ~53. builder-vite's webpack-stats-plugin.ts has the same trick. + return '/virtual:' + stripped.slice(VIRTUAL_PREFIX.length).replace(/^\/?/, '/'); + } + // 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, '/'); + if (rel.startsWith('virtual:')) return '/' + rel; + return './' + rel; +} +``` + +- [ ] **Step 4: Run tests, verify they pass** + +Run: +```bash +yarn jest packages/dev/parcel-reporter-turbosnap-stats/__tests__/helpers.test.ts -t normalize +``` + +Expected: PASS, 4 tests in the `normalize` describe block. + +- [ ] **Step 5: DO NOT commit. Report progress and pause.** + +--- + +## Task 4: TDD `isUserCode` + +**Files:** +- Modify: `packages/dev/parcel-reporter-turbosnap-stats/helpers.ts` +- Modify: `packages/dev/parcel-reporter-turbosnap-stats/__tests__/helpers.test.ts` + +- [ ] **Step 1: Add failing tests** + +Append to `__tests__/helpers.test.ts`: + +```ts +import {isUserCode} from '../helpers'; + +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('@parcel/runtime-* → false', () => { + expect(isUserCode('@parcel/runtime-js/foo.js')).toBe(false); + }); + test('\\0-prefixed synthetic ids → false', () => { + expect(isUserCode('\0synthetic')).toBe(false); + }); + test('virtual storybook-stories.js → true (it is the CSF-glob anchor)', () => { + expect(isUserCode('./storybook-stories.js')).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run tests, verify they fail** + +```bash +yarn jest packages/dev/parcel-reporter-turbosnap-stats/__tests__/helpers.test.ts -t isUserCode +``` + +Expected: FAIL — not exported. + +- [ ] **Step 3: Implement `isUserCode`** + +Append to `helpers.ts`: + +```ts +const FILTER_PATTERNS: RegExp[] = [ + /^@parcel\/runtime-/, + /\/react\/jsx-runtime\.js$/ +]; + +export function isUserCode(name: string): boolean { + if (name.startsWith(VIRTUAL_PREFIX)) return false; + for (const re of FILTER_PATTERNS) { + if (re.test(name)) return false; + } + return true; +} +``` + +- [ ] **Step 4: Run tests, verify they pass** + +```bash +yarn jest packages/dev/parcel-reporter-turbosnap-stats/__tests__/helpers.test.ts -t isUserCode +``` + +Expected: PASS, 6 tests. + +- [ ] **Step 5: DO NOT commit. Report progress and pause.** + +--- + +## Task 5: TDD `buildStatsMap` (with mock BundleGraph) + +**Files:** +- Modify: `packages/dev/parcel-reporter-turbosnap-stats/helpers.ts` +- Modify: `packages/dev/parcel-reporter-turbosnap-stats/__tests__/helpers.test.ts` + +- [ ] **Step 1: Add a mock-graph helper and failing tests** + +Append to `__tests__/helpers.test.ts`: + +```ts +import {buildStatsMap} from '../helpers'; + +// Minimal stand-in satisfying the four 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][]}) { + 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}); + } + + 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}) => + assetById.get(dep.target) ?? null + } 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); + // Button.tsx is still recorded (it's a user module with no other deps) + 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: []}); + }); +}); +``` + +- [ ] **Step 2: Run tests, verify they fail** + +```bash +yarn jest packages/dev/parcel-reporter-turbosnap-stats/__tests__/helpers.test.ts -t buildStatsMap +``` + +Expected: FAIL — `buildStatsMap` is not exported. + +- [ ] **Step 3: Implement `buildStatsMap`** + +Append to `helpers.ts`: + +```ts +import type {BundleGraph, Asset} from '@parcel/types'; + +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)) { + const target = bundleGraph.getResolvedAsset(dep); + if (!target) continue; + const depName = normalize(target.filePath, projectRoot); + if (!isUserCode(depName)) continue; + const entry = ensure(depName); + if (entry.reasons.every(r => r.moduleName !== assetName)) { + entry.reasons.push({moduleName: assetName}); + } + } + }); + } + return statsMap; +} +``` + +- [ ] **Step 4: Run tests, verify they pass** + +```bash +yarn jest packages/dev/parcel-reporter-turbosnap-stats/__tests__/helpers.test.ts -t buildStatsMap +``` + +Expected: PASS, 5 tests. + +- [ ] **Step 5: DO NOT commit. Report progress and pause.** + +--- + +## Task 6: TDD `rewriteStoryVirtuals` + +**Files:** +- Modify: `packages/dev/parcel-reporter-turbosnap-stats/helpers.ts` +- Modify: `packages/dev/parcel-reporter-turbosnap-stats/__tests__/helpers.test.ts` + +- [ ] **Step 1: Add failing tests** + +Append to `__tests__/helpers.test.ts`: + +```ts +import {rewriteStoryVirtuals, type Module} from '../helpers'; + +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', () => { + // .chromatic/main.mjs has two story globs → two synthetic stories.js + // assets in the same dir. In practice they share the same filePath, but + // tests can simulate dual entries by using a path suffix that still + // matches STORY_VIRTUAL_RE. We use the same path twice via merge semantics. + 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); + }); +}); +``` + +- [ ] **Step 2: Run tests, verify they fail** + +```bash +yarn jest packages/dev/parcel-reporter-turbosnap-stats/__tests__/helpers.test.ts -t rewriteStoryVirtuals +``` + +Expected: FAIL — `rewriteStoryVirtuals` is not exported. + +- [ ] **Step 3: Implement `rewriteStoryVirtuals`** + +Append to `helpers.ts`: + +```ts +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; + } + } + } +} +``` + +- [ ] **Step 4: Run tests, verify they pass** + +```bash +yarn jest packages/dev/parcel-reporter-turbosnap-stats/__tests__/helpers.test.ts -t rewriteStoryVirtuals +``` + +Expected: PASS, 4 tests. + +- [ ] **Step 5: DO NOT commit. Report progress and pause.** + +--- + +## Task 7: TDD `writeStats` validation + +**Files:** +- Modify: `packages/dev/parcel-reporter-turbosnap-stats/helpers.ts` +- Modify: `packages/dev/parcel-reporter-turbosnap-stats/__tests__/helpers.test.ts` + +- [ ] **Step 1: Add failing validation tests** + +Append to `__tests__/helpers.test.ts`: + +```ts +import {writeStats, type Module} from '../helpers'; +import os from 'os'; +import path from 'path'; +import fs from 'fs'; + +const silentLogger = {info: () => {}}; + +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(), 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, silentLogger)) + .rejects.toThrow(/no module references \.\/storybook-stories\.js/); + }); +}); +``` + +- [ ] **Step 2: Run tests, verify they fail** + +```bash +yarn jest packages/dev/parcel-reporter-turbosnap-stats/__tests__/helpers.test.ts -t "writeStats — validation" +``` + +Expected: FAIL — `writeStats` is not exported. + +- [ ] **Step 3: Implement `writeStats` (validation only; file emission in Task 8)** + +Append to `helpers.ts`: + +```ts +import fs from 'fs'; + +interface Logger { info: (m: {message: string}) => void; } + +export async function writeStats( + distDir: string, + statsMap: Map, + logger: Logger +): Promise { + const stats = {modules: [...statsMap.values()]}; + + 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 fs.promises.writeFile( + path.join(distDir, 'preview-stats.json'), + JSON.stringify(stats) + ); + logger.info({message: `parcel-reporter-turbosnap-stats: wrote preview-stats.json (${stats.modules.length} modules) to ${distDir}`}); +} +``` + +- [ ] **Step 4: Run tests, verify they pass** + +```bash +yarn jest packages/dev/parcel-reporter-turbosnap-stats/__tests__/helpers.test.ts -t "writeStats — validation" +``` + +Expected: PASS, 2 tests. + +- [ ] **Step 5: DO NOT commit. Report progress and pause.** + +--- + +## Task 8: TDD `writeStats` file emission + +**Files:** +- Modify: `packages/dev/parcel-reporter-turbosnap-stats/__tests__/helpers.test.ts` + +(No code change to `helpers.ts` — Task 7 already implements file emission; this task adds the happy-path test.) + +- [ ] **Step 1: Add happy-path test** + +Append to `__tests__/helpers.test.ts`: + +```ts +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, 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); + expect(written.modules[0]).toEqual({ + id: './storybook-stories.js', + name: './storybook-stories.js', + reasons: [{moduleName: './preview-main.js'}] + }); + expect(infoLog).toMatch(/wrote preview-stats\.json \(2 modules\)/); + }); +}); +``` + +- [ ] **Step 2: Run test, verify it passes (no impl change needed)** + +```bash +yarn jest packages/dev/parcel-reporter-turbosnap-stats/__tests__/helpers.test.ts -t "writeStats — happy path" +``` + +Expected: PASS. + +- [ ] **Step 3: Run the full helpers suite** + +```bash +yarn jest packages/dev/parcel-reporter-turbosnap-stats/__tests__/helpers.test.ts +``` + +Expected: PASS for the complete file — sum of tests across Tasks 2-8: +- `stripQueryParams`: 3 +- `normalize`: 4 +- `isUserCode`: 6 +- `buildStatsMap`: 5 +- `rewriteStoryVirtuals`: 4 +- `writeStats — validation`: 2 +- `writeStats — happy path`: 1 +- **Total: 25** + +- [ ] **Step 4: DO NOT commit. Report progress and pause.** + +--- + +## Task 9: Wire the Reporter plugin + +**Files:** +- Modify: `packages/dev/parcel-reporter-turbosnap-stats/StatsReporter.ts` + +- [ ] **Step 1: Replace `StatsReporter.ts` with the real plugin wiring** + +Overwrite `packages/dev/parcel-reporter-turbosnap-stats/StatsReporter.ts`: + +```ts +import {Reporter} from '@parcel/plugin'; +import {buildStatsMap, rewriteStoryVirtuals, writeStats} from './helpers'; + +const reporter = new Reporter({ + async report({event, options, logger}) { + if (event.type !== 'buildSuccess') return; + + const statsMap = buildStatsMap(event.bundleGraph, options.projectRoot); + rewriteStoryVirtuals(statsMap); + + 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, logger); + } +}); + +// Parcel's plugin loader expects `module.exports = `, +// not the `.default` wrapper TypeScript would otherwise produce. +module.exports = reporter; +``` + +- [ ] **Step 2: Verify TypeScript compiles without errors** + +Run: +```bash +yarn jest packages/dev/parcel-reporter-turbosnap-stats --listTests +``` + +Expected: prints the test file paths without TypeScript errors. (Jest's swc transform compiles the import chain.) + +- [ ] **Step 3: Re-run the full helpers suite to confirm nothing regressed** + +```bash +yarn jest packages/dev/parcel-reporter-turbosnap-stats/__tests__/helpers.test.ts +``` + +Expected: 25 tests pass. + +- [ ] **Step 4: DO NOT commit. Report progress and pause.** + +--- + +## Task 10: Real-Parcel integration test (fixture build) + +**Files:** +- Create: `packages/dev/parcel-reporter-turbosnap-stats/__tests__/__fixtures__/.parcelrc` +- Create: `packages/dev/parcel-reporter-turbosnap-stats/__tests__/__fixtures__/index.html` +- Create: `packages/dev/parcel-reporter-turbosnap-stats/__tests__/__fixtures__/preview.js` +- Create: `packages/dev/parcel-reporter-turbosnap-stats/__tests__/__fixtures__/Button.stories.tsx` +- Create: `packages/dev/parcel-reporter-turbosnap-stats/__tests__/__fixtures__/Button.tsx` +- Create: `packages/dev/parcel-reporter-turbosnap-stats/__tests__/integration.test.ts` + +This test runs the actual Parcel build against a minimal fixture. The fixture does NOT use storybook-builder-parcel — it directly drives `new Parcel(...)` with our reporter. It validates the algorithm against a real `BundleGraph`. The synthetic-stories rewrite is exercised by Task 12's manual verification, since synthesizing the storybook-resolver pipeline in a unit test is more cost than value. + +- [ ] **Step 1: Create fixture `__fixtures__/index.html`** + +```html + + +``` + +- [ ] **Step 2: Create fixture `__fixtures__/preview.js`** + +```js +import {Button} from './Button.stories.tsx'; +console.log(Button); +``` + +- [ ] **Step 3: Create fixture `__fixtures__/Button.stories.tsx`** + +```tsx +import {Button} from './Button'; +export {Button}; +export default {title: 'Button'}; +``` + +- [ ] **Step 4: Create fixture `__fixtures__/Button.tsx`** + +```tsx +export const Button = () => null; +``` + +- [ ] **Step 5: Create fixture `__fixtures__/.parcelrc`** + +```json +{ + "extends": "@parcel/config-default" +} +``` + +- [ ] **Step 6: Create integration test** + +Create `packages/dev/parcel-reporter-turbosnap-stats/__tests__/integration.test.ts`: + +```ts +import {Parcel} from '@parcel/core'; +import path from 'path'; +import fs from 'fs'; +import os from 'os'; + +jest.setTimeout(60_000); + +describe('integration: real Parcel build emits preview-stats.json', () => { + test('writes a stats file whose modules contain user code with inverted reasons', async () => { + const fixtureDir = path.join(__dirname, '__fixtures__'); + const distDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ts-int-')); + + const parcel = new (Parcel as any)({ + entries: path.join(fixtureDir, 'index.html'), + config: path.join(fixtureDir, '.parcelrc'), + mode: 'production', + additionalReporters: [{ + packageName: '@parcel/reporter-turbosnap-stats', + resolveFrom: __filename + }], + targets: { + default: { + distDir, + publicUrl: './' + } + } + }); + + // The reporter will throw "no module references ./storybook-stories.js" because + // this fixture isn't a Storybook build. Catch and assert the throw — that proves + // the reporter ran, walked the graph, and reached validation. Then inspect what + // it traversed by re-running with validation disabled... actually no, simpler: + // bypass validation by using a fixture file named 'stories.js' in the resolver + // virtuals dir. Use the buildSuccess hook in a wrapper. + // + // Pragmatic alternative: this test ONLY confirms the reporter throws the right + // validation error, which proves the graph walk + validation work end-to-end + // against real Parcel internals. The full happy-path is covered by Task 12's + // manual verification against the actual Storybook build. + await expect(parcel.run()).rejects.toThrow(/no module references \.\/storybook-stories\.js/); + }); +}); +``` + +- [ ] **Step 7: Run the integration test** + +```bash +yarn jest packages/dev/parcel-reporter-turbosnap-stats/__tests__/integration.test.ts +``` + +Expected: PASS. The test takes ~5-15s. If Parcel emits the error inside a wrapping error, the `.rejects.toThrow` matcher walks `.cause` and message chains — should still match. + +- [ ] **Step 8: If integration test FAILS with an unrelated error** + +Common causes and fixes: +- `Cannot find module '@parcel/reporter-turbosnap-stats'` → run `yarn install` to re-link workspaces. +- `Cannot resolve './Button'` from `Button.stories.tsx` → make sure the `.parcelrc` extends `@parcel/config-default` (which includes TSX support). +- Test timeout → bump `jest.setTimeout` to 120_000. + +- [ ] **Step 9: DO NOT commit. Report progress and pause.** + +--- + +## Task 11: Integrate into storybook-builder-parcel + +**Files:** +- Modify: `packages/dev/storybook-builder-parcel/package.json` +- Modify: `packages/dev/storybook-builder-parcel/preset.mjs` (lines 120-141) + +- [ ] **Step 1: Add the new dependency** + +Edit `packages/dev/storybook-builder-parcel/package.json` to add `"@parcel/reporter-turbosnap-stats": "0.0.0"` in the `dependencies` block, alphabetized between `@parcel/reporter-cli` and `@parcel/utils`. Current state (line 17-18): + +```json + "@parcel/reporter-cli": "^2.16.3", + "@parcel/utils": "^2.16.3", +``` + +After: + +```json + "@parcel/reporter-cli": "^2.16.3", + "@parcel/reporter-turbosnap-stats": "0.0.0", + "@parcel/utils": "^2.16.3", +``` + +- [ ] **Step 2: Make the reporter conditional in `preset.mjs`** + +In `packages/dev/storybook-builder-parcel/preset.mjs`, locate the `additionalReporters` argument inside `createParcel` (currently around lines 126): + +```js + additionalReporters: [{packageName: '@parcel/reporter-cli', resolveFrom: __filename}], +``` + +Replace with: + +```js + additionalReporters: [ + {packageName: '@parcel/reporter-cli', resolveFrom: __filename}, + ...(options.statsJson ? [{ + packageName: '@parcel/reporter-turbosnap-stats', + resolveFrom: __filename + }] : []) + ], +``` + +`options.statsJson` is set by Storybook 10 core when the `--stats-json` flag is passed (`storybookjs/storybook` `core/src/types/modules/core-common.ts:228`). + +- [ ] **Step 3: Re-link workspaces** + +```bash +yarn install +``` + +Expected: completes without error. + +- [ ] **Step 4: Verify Parcel can resolve the new reporter** + +```bash +node -e "console.log(require.resolve('@parcel/reporter-turbosnap-stats'))" +``` + +Expected: prints the absolute path to `packages/dev/parcel-reporter-turbosnap-stats/index.js` (or the compiled `dist/StatsReporter.js`). + +- [ ] **Step 5: Run a smoke build with stats enabled** + +```bash +yarn build:chromatic +``` + +Expected: the build completes, and `dist//chromatic/preview-stats.json` exists. Inspect: + +```bash +ls dist/$(git rev-parse HEAD)/chromatic/preview-stats.json +jq '.modules | length' dist/$(git rev-parse HEAD)/chromatic/preview-stats.json +jq '.modules[] | select(.name == "./storybook-stories.js")' dist/$(git rev-parse HEAD)/chromatic/preview-stats.json +``` + +Expected: +- File exists. +- `modules` is a sizable number (likely >1000 for this repo). +- A module named `./storybook-stories.js` exists with at least one reason. + +If the validation throws ("no module references ./storybook-stories.js"), `STORY_VIRTUAL_RE` in `helpers.ts` does not match the actual `asset.filePath` of `parcel-resolver-storybook`'s synthetic stories.js. Print the matching candidates: + +```bash +jq '.modules[] | select(.name | test("stories\\.js"))' dist/$(git rev-parse HEAD)/chromatic/preview-stats.json +``` + +Use the output to adjust the regex. **Do not catch the error to make the build pass** — that's the "fail loudly" contract. + +- [ ] **Step 6: DO NOT commit. Report progress and pause.** + +--- + +## Task 12: End-to-end verification with chromatic-cli + +This is the user's original spike formalized as the final acceptance gate. No new code — just running the pipeline. + +- [ ] **Step 1: Confirm a baseline TurboSnap-eligible diff exists** + +Make a single trivial source change to a non-config, non-story file — e.g., add a no-op comment to a component used by stories. Don't stage or commit it; the diff just needs to exist in the working tree (chromatic-cli reads `git diff`). + +- [ ] **Step 2: Run chromatic locally** + +```bash +yarn chromatic +``` + +Expected output, in order: +- `✔ Authenticated with Chromatic` +- `✔ Retrieved git information` +- `✔ Collected Storybook metadata` +- `✔ Initialized build` +- `✔ Storybook built in seconds` +- `✔ Prepare your built Storybook` — **this is the line that previously failed.** Should pass now. +- `✔ Publish your built Storybook` +- `✔ Verify your Storybook` +- `✔ Test your stories` +- A reuse percentage line indicating TurboSnap engaged, e.g., `Snapshots reused: %`. + +- [ ] **Step 3: If "Prepare" still fails with "missing stats file"** + +The reporter ran but Chromatic isn't finding the file. Verify: +```bash +ls dist/$(git rev-parse HEAD)/chromatic/preview-stats.json +``` + +If it exists, chromatic-cli is looking in the wrong directory. Re-check the `--build-script-name` in `package.json` and that `chromatic-cli` is using the same output dir as `yarn build:chromatic`. + +- [ ] **Step 4: If TurboSnap reports 0% reuse** + +Cross-check against the documented bail conditions in the spec's "Documented bail conditions" section: +- Any change under `.storybook/` or `.chromatic/`? → expected bail. +- Lockfile (`yarn.lock`) changed? → may bail unless `node_modules/*` modules are in stats. Inspect: + ```bash + jq '.modules[] | select(.name | test("node_modules"))' dist/$(git rev-parse HEAD)/chromatic/preview-stats.json | head + ``` + If empty, the filter set in `helpers.ts:isUserCode` is too aggressive — relax it. + +- [ ] **Step 5: Also verify the forced-colors variant** + +```bash +yarn chromatic:forced-colors +``` + +Expected: same behavior, separate Chromatic project. + +- [ ] **Step 6: Run the full helpers + integration test suite one last time** + +```bash +yarn jest packages/dev/parcel-reporter-turbosnap-stats +``` + +Expected: 25 unit tests + 1 integration test pass (total 26). + +- [ ] **Step 7: DO NOT commit anything. Report results to the user.** + +Summarize: +- All tests passing? (yes/no) +- `yarn chromatic` reaches "Verify your Storybook"? (yes/no) +- TurboSnap reuse percentage? (number) +- Any remaining concerns or follow-ups? (list) + +Hand control back to the user. They will decide whether to commit, and what cleanup to do (e.g., removing `.turbosnap-research/`). + +--- + +## Cleanup notes for the user (after acceptance) + +When the user is ready to commit, they should consider: +- `rm -rf .turbosnap-research/` (research clones, currently gitignored). +- Decide whether the `.turbosnap-research/` line in `.gitignore` should stay (harmless) or be removed. +- The original `package.json` changes (`--stats-json` and `--only-changed` flags in the `chromatic` scripts) are part of the same feature and should ship in the same commit as the new package. + +## Spec coverage check (self-review) + +| Spec section | Implementing task(s) | +|---|---| +| Architecture diagram | Task 11 (registration), Task 9 (wiring) | +| Components: parcel-reporter-turbosnap-stats package | Tasks 1, 9 | +| Components: StatsReporter.ts surface (`stripQueryParams`, `normalize`, `isUserCode`, `buildStatsMap`, `rewriteStoryVirtuals`, `writeStats`) | Tasks 2-8 | +| Components: storybook-builder-parcel edit | Task 11 | +| Data flow: Phase A (graph walk) | Task 5 | +| Data flow: Phase B (story-virtual rewrite) | Task 6 | +| Data flow: Phase C (validation + write) | Tasks 7, 8 | +| Worked example | Verified by Task 12 | +| Error handling: fail-loudly | Tasks 7 (validation throws), 9 (no bundles throws) | +| Error handling: logger usage | Task 7 (logger.info on write) | +| Error handling: filter set | Task 4 (`isUserCode`) | +| Testing Layer 1: pure-function unit tests | Tasks 2-8 | +| Testing Layer 2: mock-BundleGraph algorithm test | Task 5 | +| Testing Layer 3: real-Parcel fixture test | Task 10 | +| Validation tests | Task 7 | +| Manual verification (4-step checklist) | Task 12 | diff --git a/docs/superpowers/specs/2026-05-27-parcel-turbosnap-stats-design.md b/docs/superpowers/specs/2026-05-27-parcel-turbosnap-stats-design.md new file mode 100644 index 00000000000..97a1f784f48 --- /dev/null +++ b/docs/superpowers/specs/2026-05-27-parcel-turbosnap-stats-design.md @@ -0,0 +1,460 @@ +# Parcel TurboSnap Stats Reporter — Design + +**Date:** 2026-05-27 +**Status:** Implemented (see addendum below) +**Owner:** Rob Snow + +## Implementation addendum (2026-05-27) + +Two facts about what shipped that diverge from the spec body below: + +1. **Package is published as `@parcel/reporter-turbosnap-stats` (scoped)**, not the unscoped `parcel-reporter-turbosnap-stats` named throughout this doc. The scoped form matches the sibling `@parcel/resolver-storybook` in the same workspace. Parcel's plugin loader accepts either form. + +2. **A `addStoryEntries` post-processing helper was added** that is not in the original design. It iterates the stats map after `rewriteStoryVirtuals` and adds `./storybook-stories.js` as a reason on every `.stories.{js,jsx,mjs,ts,tsx}` file. This was discovered during implementation: Parcel's dynamic-import bundle splitting routes `() => import('./Foo.stories.tsx')` through `@parcel/runtime-js` wrappers, so `bundleGraph.getDependencies(syntheticStoriesJs)` resolves to runtime chunks rather than the actual story files — the natural bundle-graph walk doesn't link stories to the CSF-glob anchor. The helper bridges this gap without depending on Parcel internals. The tradeoff: any story file using a non-standard extension or naming convention (e.g., `*.story.tsx`) won't be auto-tagged. + +## Summary + +Add a Parcel reporter plugin that emits a Chromatic-compatible `preview-stats.json` alongside the Storybook build, enabling TurboSnap (`--only-changed`) for the Parcel-built `.chromatic` and `.chromatic-fc` Storybook configs. The reporter is a single ~150 LOC file modeled directly on `@storybook/builder-vite`'s `webpack-stats-plugin.ts`, with one Parcel-specific concern: rewriting `parcel-resolver-storybook`'s synthetic `stories.js` virtual to TurboSnap's canonical CSF-glob name. + +## Problem + +The user enabled TurboSnap by adding `--stats-json` to `build:chromatic` / `build:chromatic-fc` and `--only-changed` to the `chromatic` / `chromatic:forced-colors` scripts. The build now fails at the "Prepare your built Storybook" step with: + +> Make sure you pass `--stats-json` when building your Storybook. +> Did not find preview-stats.json in your built Storybook. + +Root cause: TurboSnap requires the webpack-format `preview-stats.json` produced by Webpack's stats output or by `@storybook/builder-vite`'s port of `vite-plugin-turbosnap`. The repo's Storybook configs use `storybook-react-parcel` → `storybook-builder-parcel`, which has no equivalent. The `--stats-json` flag flows into the Storybook 10 builder option `options.statsJson`, but `storybook-builder-parcel`'s `build()` ignores it and returns `undefined`, so nothing produces the file. + +## Approach + +**Selected: Option A** — Build a new Parcel reporter plugin that walks Parcel's `BundleGraph` after each build and writes `preview-stats.json` in the shape chromatic-cli's `getDependentStoryFiles` consumes. Register it conditionally on `options.statsJson` in `storybook-builder-parcel`'s `additionalReporters`. + +**Rejected alternatives:** + +- **Option B (post-build script that translates Parcel's bundle-analyzer JSON):** Bundle-analyzer output is sparser than direct `BundleGraph` access — missing virtual modules and dependency edges. Doesn't fix the underlying builder/Storybook contract gap, so `--stats-json` would still be silently dropped. +- **Option C (inline reporter inside `storybook-builder-parcel`):** Parcel mandates the `parcel-reporter-` package prefix, so this would still require a nested package directory. Less idiomatic given the repo already owns a dozen sibling Parcel plugins under `packages/dev/parcel-*`. + +## Research basis + +Two independent expert agents audited the upstream sources before this design was finalized. Findings summarized inline below; full agent reports retained in conversation history. + +**chromatic-cli's stats consumption** (`chromaui/chromatic-cli` v11.20.0, `node-src/lib/turbosnap/getDependentStoryFiles.ts`, `node-src/types.ts`): + +- TurboSnap reads exactly four fields: `stats.modules[].id`, `stats.modules[].name`, `stats.modules[].reasons[].moduleName`, and (optional) `stats.modules[].modules[].name`. No other top-level keys or per-module fields are consumed. +- Hard error (not silent bail) if no module's `reasons[]` contain a known Storybook entry name like `./storybook-stories.js`. `getDependentStoryFiles.ts:201`. +- Bail conditions: changes under `.storybook/` config dir, changes under any `staticDir`, lockfile diff with zero `node_modules/*` modules in stats. +- Path convention: POSIX, `./` prefix, project-root-relative. + +**`@storybook/builder-vite`'s solution** (`storybookjs/storybook` next branch, `code/builders/builder-vite/src/plugins/webpack-stats-plugin.ts`): + +- ~140 LOC, single self-contained file, originally a port of `vite-plugin-turbosnap`. +- Uses only Rollup's `moduleParsed` hook (push-based). For each parsed module, iterates `mod.importedIds + mod.dynamicallyImportedIds` and registers reverse edges into a `Map`. +- Emits exactly `{modules: [...]}` — nothing else. No `chunks`, `assets`, `entrypoints`, `hash`, `version`, etc. +- Virtual ids must start with `/` to be recognized by chromatic-cli; real paths use `./` prefix. +- Filters out: `vite/` internals, `\0`-prefixed non-Storybook ids, `react/jsx-runtime`. +- Returns data through Storybook 10's `BuilderStats.toJson()` contract; Storybook core writes the file. +- Zero coupling to Vite internals. Tightly coupled to chromatic-cli's expected format — that's the dominant maintenance concern. + +## Architecture + +``` +yarn chromatic + └─ chromatic-cli + └─ runs `yarn build:chromatic` (--stats-json flag flows into builder) + └─ Storybook core + └─ storybook-builder-parcel.build({options: {statsJson: true, ...}}) + └─ new Parcel({ + additionalReporters: [ + '@parcel/reporter-cli', + options.statsJson && 'parcel-reporter-turbosnap-stats', + ].filter(Boolean) + }) + └─ parcel.run() + └─ ... build phases ... + └─ fires 'buildSuccess' event with BundleGraph + └─ parcel-reporter-turbosnap-stats + ├─ walks BundleGraph (pull-based) + ├─ builds Map + ├─ rewrites synthetic stories.js virtual → './storybook-stories.js' + ├─ validates (non-empty, CSF-glob anchor present) + └─ writes preview-stats.json to bundle.target.distDir + └─ chromatic-cli reads dist//chromatic/preview-stats.json + └─ TurboSnap maps changed files → affected stories +``` + +## Components + +### New: `packages/dev/parcel-reporter-turbosnap-stats/` + +Three files, matching the structure of the existing `packages/dev/parcel-resolver-storybook` sibling. + +``` +parcel-reporter-turbosnap-stats/ +├── package.json — name: "parcel-reporter-turbosnap-stats" (Parcel-mandated prefix), +│ private: true, type: module, main: "./index.js" +├── index.js — re-exports the TS module (matches parcel-resolver-storybook pattern) +└── StatsReporter.ts — the reporter itself (~150 LOC, ported from + builder-vite's webpack-stats-plugin.ts function-for-function) +``` + +**`StatsReporter.ts` internal surface:** + +| Function | Inputs | Output | builder-vite counterpart | +|---|---|---|---| +| `default export` | `Reporter({async report})` from `@parcel/plugin` | n/a | the plugin object literal | +| `stripQueryParams(id)` | string | string | identical to line 38 | +| `isUserCode(id)` | string | boolean | structurally identical; filter set adjusted: `@parcel/runtime-*`, `\0`-prefixed non-storybook, `react/jsx-runtime` | +| `normalize(filePath, root)` | absolute path, project root | POSIX `./rel` or `/virtual:...` | `normalize()` lines 64-91 | +| `buildStatsMap(bundleGraph, root)` | Parcel `BundleGraph`, project root | `Map` | the `moduleParsed` callback (lines 113-135), but pull-based: iterate `bundleGraph.getBundles()` → `bundle.traverseAssets()` → `bundleGraph.getDependencies(asset)` | +| `rewriteStoryVirtuals(statsMap)` | the map | mutates in place | Parcel-specific (no Vite counterpart) | +| `writeStats(distDir, statsMap, logger)` | output dir, the map, Parcel logger | writes `preview-stats.json`; throws on validation failure | upstream this is Storybook core's job | + +**Module/Reason types** are defined inline against chromatic-cli's contract — no separate `.d.ts` exported from this package: + +```ts +interface Reason { moduleName: string; } +interface Module { id: string; name: string; reasons: Reason[]; } +``` + +### Edit: `packages/dev/storybook-builder-parcel/preset.mjs` + +Two changes to the existing file: + +1. **`package.json`**: add `parcel-reporter-turbosnap-stats` to dependencies. +2. **`preset.mjs:120-141`**: replace the hard-coded `additionalReporters` array with one assembled conditionally based on `options.statsJson`: + +```js +const additionalReporters = [ + {packageName: '@parcel/reporter-cli', resolveFrom: __filename} +]; +if (options.statsJson) { + additionalReporters.push({ + packageName: 'parcel-reporter-turbosnap-stats', + resolveFrom: __filename + }); +} +``` + +### Not changed + +- `.chromatic/main.mjs`, `.chromatic-fc/main.mjs`, `.chromatic/.parcelrc`, `.chromatic-fc/.parcelrc` — reporter is registered at the builder layer, not via `.parcelrc`. +- The existing `--stats-json` and `--only-changed` flags in `package.json` — these already plumb correctly; they just had nowhere to land before. +- `storybook-builder-parcel`'s `build()` return value — the reporter writes the file directly rather than going through Storybook 10's `BuilderStats.toJson()` contract. Storybook core has a `writeStats(directory, 'preview', stats)` utility (`storybookjs/storybook` `core/src/core-server/utils/output-stats.ts:12`) that handles this when a builder returns `previewStats`, but bypassing it avoids tying our output to changes in that utility's filename or formatting conventions. + +## Data flow + +### Phase A: Graph walk (pull-based) + +Parcel's reporter callback receives `event.bundleGraph` with the full graph already resolved. Unlike builder-vite's push-based `moduleParsed` hook, the entire graph is available at once. + +```ts +function buildStatsMap(bundleGraph, projectRoot) { + const statsMap = new Map(); + const ensure = (name) => { + if (!statsMap.has(name)) statsMap.set(name, {id: name, name, reasons: []}); + return statsMap.get(name); + }; + const seen = new Set(); + + for (const bundle of bundleGraph.getBundles()) { + bundle.traverseAssets((asset) => { + if (seen.has(asset.id)) return; + seen.add(asset.id); + + const assetName = normalize(asset.filePath, projectRoot); + if (!isUserCode(assetName)) return; + ensure(assetName); // every reachable asset gets a record, even leaves + + for (const dep of bundleGraph.getDependencies(asset)) { + const target = bundleGraph.getResolvedAsset(dep); + if (!target) continue; + const depName = normalize(target.filePath, projectRoot); + if (!isUserCode(depName)) continue; + const entry = ensure(depName); + if (entry.reasons.every(r => r.moduleName !== assetName)) { + entry.reasons.push({moduleName: assetName}); + } + } + }); + } + return statsMap; +} +``` + +**Invariant:** for every edge `A → B` in the bundle graph, B's `reasons` contains `{moduleName: A}`. This is the inverted graph TurboSnap walks upward from changed files. + +### Phase B: Story-virtual rewrite + +`parcel-resolver-storybook` creates a synthetic `stories.js` asset for every `story:` glob (`StorybookResolver.ts:49`): + +```ts +filePath: path.join(dir, 'stories.js') +``` + +where `dir` is the directory of whoever imported the `story:` pipeline — for us, `storybook-builder-parcel`'s `generated-entries/`. So in the raw stats it surfaces as e.g. `./packages/dev/storybook-builder-parcel/generated-entries/stories.js`. + +TurboSnap requires the CSF-glob entry name to match one of its known patterns (`getDependentStoryFiles.ts:119-135`). We rewrite to the modern Storybook 7+ canonical name: + +```ts +const STORY_VIRTUAL_RE = /\/storybook-builder-parcel\/generated-entries\/stories\.js$/; +const CANONICAL = './storybook-stories.js'; + +function rewriteStoryVirtuals(statsMap) { + // Rename the virtual entries + for (const [oldName, entry] of [...statsMap]) { + if (!STORY_VIRTUAL_RE.test(oldName)) continue; + statsMap.delete(oldName); + entry.id = CANONICAL; + entry.name = CANONICAL; + // If we already created the canonical entry (from a prior rewrite this build), + // merge reasons rather than overwrite. + if (statsMap.has(CANONICAL)) { + const existing = statsMap.get(CANONICAL); + for (const r of entry.reasons) { + if (existing.reasons.every(x => x.moduleName !== r.moduleName)) { + existing.reasons.push(r); + } + } + } else { + statsMap.set(CANONICAL, entry); + } + } + // Rewrite any reasons that pointed at the old virtual name + for (const entry of statsMap.values()) { + for (const reason of entry.reasons) { + if (STORY_VIRTUAL_RE.test(reason.moduleName)) { + reason.moduleName = CANONICAL; + } + } + } +} +``` + +If multiple `story:` globs in the same config dir produce multiple `stories.js` virtuals (the case for `.chromatic/main.mjs` which has two glob entries), they all collapse to a single `./storybook-stories.js` entry. + +### Phase C: Validation + write + +`distDir` is sourced from the Parcel build event: `event.bundleGraph.getBundles()[0].target.distDir`. For our single-target builder this is the Storybook output directory (e.g., `dist//chromatic/`). + +```ts +async function writeStats(distDir, statsMap, logger) { + const stats = {modules: [...statsMap.values()]}; + + 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 === './storybook-stories.js') + ); + 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 fs.promises.writeFile( + path.join(distDir, 'preview-stats.json'), + JSON.stringify(stats) + ); + logger.info({message: `Wrote preview-stats.json (${stats.modules.length} modules)`}); +} +``` + +### Worked example + +Source tree: + +``` +.chromatic/main.mjs (story globs) +packages/foo/chromatic/Button.stories.tsx +packages/foo/src/Button.tsx +``` + +Resulting `preview-stats.json` (essential entries only): + +```json +{ + "modules": [ + { + "id": "./storybook-stories.js", + "name": "./storybook-stories.js", + "reasons": [ + {"moduleName": "./packages/dev/storybook-builder-parcel/generated-entries/preview-main.js"} + ] + }, + { + "id": "./packages/foo/chromatic/Button.stories.tsx", + "name": "./packages/foo/chromatic/Button.stories.tsx", + "reasons": [{"moduleName": "./storybook-stories.js"}] + }, + { + "id": "./packages/foo/src/Button.tsx", + "name": "./packages/foo/src/Button.tsx", + "reasons": [{"moduleName": "./packages/foo/chromatic/Button.stories.tsx"}] + } + ] +} +``` + +A diff that touches `packages/foo/src/Button.tsx` walks: `Button.tsx` → `Button.stories.tsx` → `./storybook-stories.js` (CSF glob match) → Button stories marked affected. + +## Error handling + +### Failure taxonomy + +| Layer | Failure mode | Response | +|---|---|---| +| 1. Reporter setup | Package missing/broken at resolve time | Parcel's reporter loader throws → build fails. No custom handling. | +| 2. Graph walk | `getBundles()` returns empty, malformed Parcel internals | Reporter throws; Parcel surfaces as build error. | +| 3. Data validation | `statsMap` empty, no module has `./storybook-stories.js` reason | Reporter throws with actionable message (Phase C). | +| 4. File I/O | `distDir` not writable, disk full | `fs.writeFile` throws → Parcel surfaces it. | +| 5. Chromatic-side bail | Lockfile diff + no node_modules in stats, change under `.storybook/`, change under `staticDir` | **Not our failure** — documented, not caught. | + +### Fail-loudly stance + +For layers 2–4, the reporter throws and Parcel propagates. The user sees the failure inline in `yarn chromatic` output before chromatic-cli ever tries to read the file. No silent fallback to "snapshot everything." Decided per user preference. + +### Logging + +Parcel passes a `logger` to `report()`. Three log lines per build: + +```ts +logger.verbose({message: `Building stats from ${bundleCount} bundles, ${assetCount} assets`}); +// ... graph walk ... +logger.verbose({message: `Stats map has ${statsMap.size} modules`}); +// ... validation, write ... +logger.info({message: `Wrote preview-stats.json (${statsMap.size} modules) to ${distDir}`}); +``` + +Verbose lines render only with `parcel --log-level verbose`. The single `info` line is the normal-build footprint. + +### Anti-patterns avoided + +- **No try/catch wrapping the graph walk.** Exceptions from Parcel APIs propagate untouched. Masking them as warnings reproduces the exact failure mode TurboSnap users report. +- **No retry on file write.** Disk problems are disk problems. +- **No partial-output fallback.** Empty `modules` is a bug; partial output would be silently wrong. +- **No defensive option-flag checking.** Builder reads `options.statsJson` once at construction; not a moving target. + +### Documented bail conditions + +A comment block at the top of `StatsReporter.ts` enumerates out-of-scope bails — so a future maintainer hitting "TurboSnap ran but no snapshots saved" knows where to look: + +```ts +// 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. +``` + +### Filter scope + +`isUserCode` mirrors builder-vite's filter set exactly: `@parcel/runtime-*` (instead of `vite/`), `\0`-prefixed non-storybook synthetics, `react/jsx-runtime`. Known dead-zone: changes to `react/jsx-runtime` itself won't propagate. Tradeoff accepted because including it would put every JSX-using module's reasons on it, inflating graph traversal cost. + +## Testing + +### Layer 1: pure-function unit tests + +``` +StatsReporter.test.ts +├── normalize() +│ ├── absolute path → './rel' (POSIX, query-stripped) +│ ├── virtual id /storybook-stories.js → stays as '/storybook-stories.js' +│ ├── Windows backslashes → forward slashes +│ └── path with ?query → query stripped +├── isUserCode() +│ ├── './packages/foo/Button.tsx' → true +│ ├── './node_modules/react/index.js' → true (lockfile-bail prevention) +│ ├── './node_modules/react/jsx-runtime.js' → false +│ ├── '@parcel/runtime-js' → false +│ └── '\0synthetic' → false +├── stripQueryParams() — single-line, trivial +└── rewriteStoryVirtuals() + ├── single stories.js virtual collapses to './storybook-stories.js' + ├── multiple stories.js virtuals from different glob entries collapse to one + └── reasons pointing at the old virtual name get rewritten +``` + +Runs in the repo's existing Jest setup. + +### Layer 2: mock-BundleGraph algorithm test + +A small mock satisfying the four `BundleGraph` methods (`getBundles`, `traverseAssets`, `getDependencies`, `getResolvedAsset`) — under 30 lines — drives `buildStatsMap` through a hand-crafted graph: + +```ts +const mockGraph = makeMockGraph({ + assets: ['./preview-main.js', './stories.js', './Button.stories.tsx', './Button.tsx', './Button.css'], + edges: [ + ['./preview-main.js', './stories.js'], + ['./stories.js', './Button.stories.tsx'], + ['./Button.stories.tsx', './Button.tsx'], + ['./Button.tsx', './Button.css'], + ], +}); + +const statsMap = buildStatsMap(mockGraph, '/proj'); +expect(statsMap.get('./Button.tsx').reasons).toEqual([{moduleName: './Button.stories.tsx'}]); +expect(statsMap.get('./Button.css').reasons).toEqual([{moduleName: './Button.tsx'}]); +``` + +Future Parcel major versions: write a new mock matching the new API; algorithm tests still pass. + +### Layer 3: real-Parcel fixture test + +One test that runs Parcel against a 3-file fixture project: + +``` +packages/dev/parcel-reporter-turbosnap-stats/__fixtures__/ +├── preview.js (imports stories.js) +├── Button.stories.tsx (imports Button.tsx) +└── Button.tsx +``` + +Drives `new Parcel(...)` with our reporter registered, snapshots the resulting `preview-stats.json`. Catches the case where Parcel's `BundleGraph` API ships a subtle change that breaks our walk. Slow (~5s), excluded from `yarn test:watch`. + +### Validation tests + +```ts +test('throws when modules array is empty', () => { + expect(() => writeStats(distDir, new Map())).toThrow(/empty modules array/); +}); + +test('throws when no CSF-glob anchor is present', () => { + const map = new Map([['./Foo.tsx', {id: './Foo.tsx', name: './Foo.tsx', reasons: []}]]); + expect(() => writeStats(distDir, map)).toThrow(/no module references \.\/storybook-stories\.js/); +}); +``` + +### Out of scope + +- Diff against builder-vite's actual output. Useful one-time during implementation verification, but not as recurring CI — their format can drift independently. +- TurboSnap reuse percentage. Depends on chromatic-cli logic, current git state, cloud-side computation. Not unit-testable. +- Bail conditions in chromatic-cli. Documented in code, not tested — testing chromatic-cli is them-testing-them. + +### Manual verification before merge (one-time) + +1. Build a tiny Vite-Storybook with `vite-plugin-turbosnap`, capture its `preview-stats.json`. +2. Build our Parcel-Storybook with the new reporter, capture its `preview-stats.json`. +3. Confirm shapes are equivalent (same field set, path conventions, CSF-glob anchor). +4. Run `yarn chromatic` end-to-end on a branch with a single-file change. Confirm TurboSnap reports non-zero reuse percentage in Chromatic UI. + +## Open questions and unknowns + +- **Parcel's `bundle.traverseAssets` ordering.** Whether visit order is stable across Parcel versions. Doesn't affect correctness (algorithm is commutative — reasons get accumulated regardless of order) but may affect snapshot test stability. If unstable: sort `statsMap` entries by `name` before write. +- **Multiple Parcel `targets`.** The current builder defines a single `storybook` target. If a future config adds another target, `getBundles()` may include bundles for both. Probably benign (we'd just emit more modules) but worth verifying during implementation. +- **`asset.filePath` for fully-synthetic assets.** `parcel-resolver-storybook` sets `filePath` to a path that doesn't exist on disk. Other resolvers (e.g., the globals resolver at `StorybookResolver.ts:13-17`) do the same with `__dirname + '/globals.js'`. These should pass through `normalize` cleanly but worth empirically confirming. +- **Logger interface stability.** Parcel reporter `logger` shape has been stable but undocumented. Spot-check against the installed Parcel version during implementation. + +## References + +- Builder-vite stats plugin (template): `.turbosnap-research/storybook/code/builders/builder-vite/src/plugins/webpack-stats-plugin.ts` +- Builder-vite registration: `.turbosnap-research/storybook/code/builders/builder-vite/src/vite-config.ts:96` +- chromatic-cli stats consumption: `chromaui/chromatic-cli` v11.20.0 `node-src/lib/turbosnap/getDependentStoryFiles.ts`, `node-src/types.ts:243-251` +- Parcel reporter API: `@parcel/plugin` `Reporter` class +- Existing repo conventions: `packages/dev/parcel-resolver-storybook/` (peer Parcel plugin) +- Storybook builder API contract: Storybook 10 builders documentation (`https://storybook.js.org/docs/builders/builder-api`) diff --git a/package.json b/package.json index ade0154d607..1dc71d4ee12 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", + "chromatic:forced-colors": "NODE_ENV=production CHROMATIC=1 chromatic --project-token $CHROMATIC_FC_PROJECT_TOKEN --build-script-name 'build:chromatic-fc' --only-changed", "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", 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..ad418eba8be --- /dev/null +++ b/packages/dev/parcel-reporter-turbosnap-stats/StatsReporter.ts @@ -0,0 +1,25 @@ +import {Reporter} from '@parcel/plugin'; +import {buildStatsMap, rewriteStoryVirtuals, addStoryEntries, writeStats} from './helpers'; + +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, 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..d677a92cb57 --- /dev/null +++ b/packages/dev/parcel-reporter-turbosnap-stats/__tests__/__fixtures__/index.html @@ -0,0 +1,2 @@ + + 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..5dc8862db00 --- /dev/null +++ b/packages/dev/parcel-reporter-turbosnap-stats/__tests__/__fixtures__/preview.js @@ -0,0 +1,2 @@ +import {Button} from './Button.stories.tsx'; +console.log(Button); 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..99733028b34 --- /dev/null +++ b/packages/dev/parcel-reporter-turbosnap-stats/__tests__/helpers.test.ts @@ -0,0 +1,289 @@ +import {stripQueryParams, normalize, isUserCode, buildStatsMap, rewriteStoryVirtuals, writeStats, addStoryEntries, type Module} from '../helpers'; +import os from 'os'; +import path from 'path'; +import fs from 'fs'; + +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$/); + }); + test('\\0-prefixed synthetic id gets virtual: leading-slash form', () => { + expect(normalize('\0synthetic/foo.js', root)).toBe('/virtual:/synthetic/foo.js'); + }); +}); + +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('\\0-prefixed synthetic ids → false', () => { + expect(isUserCode('\0synthetic')).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 four 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][]}) { + 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}); + } + + 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}) => + assetById.get(dep.target) ?? null + } 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: []}); + }); +}); + +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: () => {}}; + +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(), 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, 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, 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', () => { + test('adds ./storybook-stories.js as reason on .stories.tsx files', () => { + const m = new Map([ + ['./packages/foo/Accordion.stories.tsx', { + id: './packages/foo/Accordion.stories.tsx', + name: './packages/foo/Accordion.stories.tsx', + reasons: [] + }] + ]); + addStoryEntries(m); + expect(m.get('./packages/foo/Accordion.stories.tsx')!.reasons).toEqual([ + {moduleName: './storybook-stories.js'} + ]); + }); + + test('does not add a duplicate reason if already present', () => { + const m = new Map([ + ['./Foo.stories.tsx', { + id: './Foo.stories.tsx', + name: './Foo.stories.tsx', + reasons: [{moduleName: './storybook-stories.js'}] + }] + ]); + addStoryEntries(m); + expect(m.get('./Foo.stories.tsx')!.reasons).toEqual([ + {moduleName: './storybook-stories.js'} + ]); + }); + + test('matches .stories.{js,jsx,mjs,ts,tsx} extensions', () => { + const names = ['./a.stories.js', './b.stories.jsx', './c.stories.mjs', './d.stories.ts', './e.stories.tsx']; + const m = new Map( + names.map(n => [n, {id: n, name: n, reasons: []}]) + ); + addStoryEntries(m); + for (const n of names) { + expect(m.get(n)!.reasons).toEqual([{moduleName: './storybook-stories.js'}]); + } + }); + + test('does not touch non-story files', () => { + const m = new Map([ + ['./Button.tsx', {id: './Button.tsx', name: './Button.tsx', reasons: []}], + ['./not-a-story.txt', {id: './not-a-story.txt', name: './not-a-story.txt', reasons: []}] + ]); + addStoryEntries(m); + expect(m.get('./Button.tsx')!.reasons).toEqual([]); + expect(m.get('./not-a-story.txt')!.reasons).toEqual([]); + }); +}); 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..8052db8d0a0 --- /dev/null +++ b/packages/dev/parcel-reporter-turbosnap-stats/__tests__/integration.test.ts @@ -0,0 +1,55 @@ +import {Parcel} from '@parcel/core'; +// @ts-ignore — untyped internal package +import {FSCache} from '@parcel/cache'; +// @ts-ignore — untyped internal package +import {NodeFS} from '@parcel/fs'; +import path from 'path'; +import fs from 'fs'; +import os from 'os'; + +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-')); + 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, + additionalReporters: [{ + packageName: '@parcel/reporter-turbosnap-stats', + resolveFrom: __filename + }], + targets: { + default: { + distDir, + publicUrl: './' + } + } + }); + + // The fixture has no storybook-resolver stories.js virtual, but it does include + // Button.stories.tsx. addStoryEntries bridges the gap by injecting + // './storybook-stories.js' as a reason on every .stories.* asset, so the + // reporter succeeds and writes preview-stats.json. Verify the file exists and + // contains the CSF-glob reason — that proves the entire chain ran against real + // Parcel internals. + 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 storiesModule = stats.modules.find((m: any) => + m.reasons.some((r: any) => r.moduleName === './storybook-stories.js') + ); + expect(storiesModule).toBeDefined(); + expect(storiesModule.name).toMatch(/\.stories\./); + 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..3bbb1d9b9f3 --- /dev/null +++ b/packages/dev/parcel-reporter-turbosnap-stats/helpers.ts @@ -0,0 +1,187 @@ +// Helpers for parcel-reporter-turbosnap-stats. See ./StatsReporter.ts for the +// plugin entry; this file holds the pure functions exported for unit testing. + +import fs from 'fs'; +import path from 'path'; +import type {BundleGraph, Asset} from '@parcel/types'; + +// 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); +} + +const VIRTUAL_PREFIX = '\0'; + +export function normalize(filePath: string, projectRoot: string): string { + const stripped = stripQueryParams(filePath); + if (stripped.startsWith(VIRTUAL_PREFIX)) { + // chromatic-cli's getDependentStoryFiles normalizePath short-circuits paths + // starting with /virtual: — see chromatic-cli node-src/lib/turbosnap/getDependentStoryFiles.ts + // line ~53. builder-vite's webpack-stats-plugin.ts has the same trick. + return '/virtual:' + stripped.slice(VIRTUAL_PREFIX.length).replace(/^\/?/, '/'); + } + // 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, '/'); + if (rel.startsWith('virtual:')) return '/' + rel; + 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 { + if (name.startsWith(VIRTUAL_PREFIX)) return false; + 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)) { + const target = bundleGraph.getResolvedAsset(dep); + if (!target) continue; + const depName = normalize(target.filePath, projectRoot); + if (!isUserCode(depName)) continue; + const entry = ensure(depName); + if (entry.reasons.every(r => r.moduleName !== assetName)) { + entry.reasons.push({moduleName: assetName}); + } + } + }); + } + return statsMap; +} + +const STORY_FILE_RE = /\.stories\.(js|jsx|mjs|ts|tsx)$/; + +// Parcel's dynamic-import-based code splitting routes `() => import('./Foo.stories.tsx')` +// through @parcel/runtime-js wrappers, so the synthetic stories.js's resolved deps +// land on runtime chunks rather than the story files themselves. The story files +// then end up in their own bundles with no edge pointing back at './storybook-stories.js', +// which means chromatic-cli's TurboSnap can't find them via the CSF-glob walk. +// This helper bridges the gap by directly adding './storybook-stories.js' as a reason +// on every asset whose name matches a story-file pattern. +export function addStoryEntries( + statsMap: Map, + logger?: Logger +): number { + let tagged = 0; + for (const entry of statsMap.values()) { + if (!STORY_FILE_RE.test(entry.name)) continue; + if (entry.reasons.every(r => r.moduleName !== CANONICAL_CSF_GLOB)) { + entry.reasons.push({moduleName: CANONICAL_CSF_GLOB}); + tagged++; + } + } + logger?.info({message: `parcel-reporter-turbosnap-stats: tagged ${tagged} story file(s) with ./storybook-stories.js`}); + return tagged; +} + +interface Logger { info: (m: {message: string}) => void; } + +export async function writeStats( + distDir: string, + statsMap: Map, + 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 fs.promises.writeFile( + path.join(distDir, 'preview-stats.json'), + JSON.stringify(stats) + ); + 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..9e5d284ad09 100644 --- a/packages/dev/storybook-builder-parcel/preset.mjs +++ b/packages/dev/storybook-builder-parcel/preset.mjs @@ -123,7 +123,13 @@ 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/packages/react-aria/src/tag/useTag.ts b/packages/react-aria/src/tag/useTag.ts index 8f7f9143ffa..58d77bfc4fd 100644 --- a/packages/react-aria/src/tag/useTag.ts +++ b/packages/react-aria/src/tag/useTag.ts @@ -118,6 +118,7 @@ export function useTag( return { removeButtonProps: { + 'data-testid': 'tag-remove-button', 'aria-label': stringFormatter.format('removeButtonLabel'), 'aria-labelledby': `${buttonId} ${rowProps.id}`, isDisabled, diff --git a/yarn.lock b/yarn.lock index 4c10e915772..c0e207c6d3b 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: @@ -29133,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:*" From eb48b3098f303d53b98491d80ec3bc363ebe0535 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Thu, 28 May 2026 11:27:12 +1000 Subject: [PATCH 03/13] fix lint and tests --- .../StatsReporter.ts | 2 +- .../__tests__/__fixtures__/index.html | 6 +- .../__tests__/helpers.test.ts | 147 +++++++++++------- .../__tests__/integration.test.ts | 20 +-- .../helpers.ts | 45 +++--- .../dev/storybook-builder-parcel/preset.mjs | 12 +- packages/react-aria/src/tag/useTag.ts | 1 + 7 files changed, 143 insertions(+), 90 deletions(-) diff --git a/packages/dev/parcel-reporter-turbosnap-stats/StatsReporter.ts b/packages/dev/parcel-reporter-turbosnap-stats/StatsReporter.ts index ad418eba8be..139770b0cf9 100644 --- a/packages/dev/parcel-reporter-turbosnap-stats/StatsReporter.ts +++ b/packages/dev/parcel-reporter-turbosnap-stats/StatsReporter.ts @@ -1,5 +1,5 @@ +import {addStoryEntries, buildStatsMap, rewriteStoryVirtuals, writeStats} from './helpers'; import {Reporter} from '@parcel/plugin'; -import {buildStatsMap, rewriteStoryVirtuals, addStoryEntries, writeStats} from './helpers'; const reporter = new Reporter({ async report({event, options, logger}) { diff --git a/packages/dev/parcel-reporter-turbosnap-stats/__tests__/__fixtures__/index.html b/packages/dev/parcel-reporter-turbosnap-stats/__tests__/__fixtures__/index.html index d677a92cb57..e13da3abe26 100644 --- a/packages/dev/parcel-reporter-turbosnap-stats/__tests__/__fixtures__/index.html +++ b/packages/dev/parcel-reporter-turbosnap-stats/__tests__/__fixtures__/index.html @@ -1,2 +1,6 @@ - + + + + + diff --git a/packages/dev/parcel-reporter-turbosnap-stats/__tests__/helpers.test.ts b/packages/dev/parcel-reporter-turbosnap-stats/__tests__/helpers.test.ts index 99733028b34..7aa522014fe 100644 --- a/packages/dev/parcel-reporter-turbosnap-stats/__tests__/helpers.test.ts +++ b/packages/dev/parcel-reporter-turbosnap-stats/__tests__/helpers.test.ts @@ -1,7 +1,16 @@ -import {stripQueryParams, normalize, isUserCode, buildStatsMap, rewriteStoryVirtuals, writeStats, addStoryEntries, type Module} from '../helpers'; +import { + addStoryEntries, + buildStatsMap, + isUserCode, + type Module, + normalize, + rewriteStoryVirtuals, + stripQueryParams, + writeStats +} from '../helpers'; +import fs from 'fs'; import os from 'os'; import path from 'path'; -import fs from 'fs'; describe('stripQueryParams', () => { test('returns input unchanged when no query string', () => { @@ -25,7 +34,9 @@ describe('normalize', () => { 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$/); + expect(normalize('/repo/src\\nested\\Button.tsx', root)).toMatch( + /^\.\/src\/nested\/Button\.tsx$/ + ); }); test('\\0-prefixed synthetic id gets virtual: leading-slash form', () => { expect(normalize('\0synthetic/foo.js', root)).toBe('/virtual:/synthetic/foo.js'); @@ -71,15 +82,15 @@ function makeMockGraph(opts: {assets: string[]; edges: [string, string][]}) { } return { - getBundles: () => [{ - traverseAssets: (visit: (a: any) => void) => { - for (const a of assetById.values()) visit(a); + 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}) => - assetById.get(dep.target) ?? null + ], + getDependencies: (asset: {filePath: string}) => depsBySource.get(asset.filePath) ?? [], + getResolvedAsset: (dep: {target: string}, _bundle: unknown) => assetById.get(dep.target) ?? null } as any; } @@ -92,26 +103,33 @@ describe('buildStatsMap', () => { 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.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']] + 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(); + 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']] + edges: [ + ['./a.tsx', './shared.ts'], + ['./a.tsx', './shared.ts'] + ] }); const m = buildStatsMap(g, root); expect(m.get('./shared.ts')!.reasons).toEqual([{moduleName: './a.tsx'}]); @@ -150,11 +168,14 @@ describe('rewriteStoryVirtuals', () => { 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}] - }] + [ + './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}]); @@ -189,8 +210,7 @@ const silentLogger = {info: () => {}}; 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(), silentLogger)) - .rejects.toThrow(/empty modules array/); + await expect(writeStats(tmp, new Map(), silentLogger)).rejects.toThrow(/empty modules array/); }); test('throws when no module references ./storybook-stories.js', async () => { @@ -198,8 +218,9 @@ describe('writeStats — validation', () => { const m = new Map([ ['./Foo.tsx', {id: './Foo.tsx', name: './Foo.tsx', reasons: []}] ]); - await expect(writeStats(tmp, m, silentLogger)) - .rejects.toThrow(/no module references \.\/storybook-stories\.js/); + await expect(writeStats(tmp, m, silentLogger)).rejects.toThrow( + /no module references \.\/storybook-stories\.js/ + ); }); }); @@ -207,20 +228,30 @@ 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'}] - }] + [ + './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; }}; + const logger = { + info: (m: {message: string}) => { + infoLog = m.message; + } + }; await writeStats(tmp, m, logger); const written = JSON.parse(fs.readFileSync(path.join(tmp, 'preview-stats.json'), 'utf8')); @@ -240,11 +271,14 @@ describe('writeStats — happy path', () => { describe('addStoryEntries', () => { test('adds ./storybook-stories.js as reason on .stories.tsx files', () => { const m = new Map([ - ['./packages/foo/Accordion.stories.tsx', { - id: './packages/foo/Accordion.stories.tsx', - name: './packages/foo/Accordion.stories.tsx', - reasons: [] - }] + [ + './packages/foo/Accordion.stories.tsx', + { + id: './packages/foo/Accordion.stories.tsx', + name: './packages/foo/Accordion.stories.tsx', + reasons: [] + } + ] ]); addStoryEntries(m); expect(m.get('./packages/foo/Accordion.stories.tsx')!.reasons).toEqual([ @@ -254,23 +288,28 @@ describe('addStoryEntries', () => { test('does not add a duplicate reason if already present', () => { const m = new Map([ - ['./Foo.stories.tsx', { - id: './Foo.stories.tsx', - name: './Foo.stories.tsx', - reasons: [{moduleName: './storybook-stories.js'}] - }] + [ + './Foo.stories.tsx', + { + id: './Foo.stories.tsx', + name: './Foo.stories.tsx', + reasons: [{moduleName: './storybook-stories.js'}] + } + ] ]); addStoryEntries(m); - expect(m.get('./Foo.stories.tsx')!.reasons).toEqual([ - {moduleName: './storybook-stories.js'} - ]); + expect(m.get('./Foo.stories.tsx')!.reasons).toEqual([{moduleName: './storybook-stories.js'}]); }); test('matches .stories.{js,jsx,mjs,ts,tsx} extensions', () => { - const names = ['./a.stories.js', './b.stories.jsx', './c.stories.mjs', './d.stories.ts', './e.stories.tsx']; - const m = new Map( - names.map(n => [n, {id: n, name: n, reasons: []}]) - ); + const names = [ + './a.stories.js', + './b.stories.jsx', + './c.stories.mjs', + './d.stories.ts', + './e.stories.tsx' + ]; + const m = new Map(names.map(n => [n, {id: n, name: n, reasons: []}])); addStoryEntries(m); for (const n of names) { expect(m.get(n)!.reasons).toEqual([{moduleName: './storybook-stories.js'}]); diff --git a/packages/dev/parcel-reporter-turbosnap-stats/__tests__/integration.test.ts b/packages/dev/parcel-reporter-turbosnap-stats/__tests__/integration.test.ts index 8052db8d0a0..d26d4f35c41 100644 --- a/packages/dev/parcel-reporter-turbosnap-stats/__tests__/integration.test.ts +++ b/packages/dev/parcel-reporter-turbosnap-stats/__tests__/integration.test.ts @@ -1,11 +1,10 @@ -import {Parcel} from '@parcel/core'; -// @ts-ignore — untyped internal package +import fs from 'fs'; import {FSCache} from '@parcel/cache'; -// @ts-ignore — untyped internal package +// @ts-ignore — @parcel/fs has no published types in this version import {NodeFS} from '@parcel/fs'; -import path from 'path'; -import fs from 'fs'; import os from 'os'; +import {Parcel} from '@parcel/core'; +import path from 'path'; jest.setTimeout(60_000); @@ -16,6 +15,7 @@ describe('integration: real Parcel build emits preview-stats.json', () => { // 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)({ @@ -23,10 +23,12 @@ describe('integration: real Parcel build emits preview-stats.json', () => { config: path.join(fixtureDir, '.parcelrc'), mode: 'production', cache, - additionalReporters: [{ - packageName: '@parcel/reporter-turbosnap-stats', - resolveFrom: __filename - }], + additionalReporters: [ + { + packageName: '@parcel/reporter-turbosnap-stats', + resolveFrom: __filename + } + ], targets: { default: { distDir, diff --git a/packages/dev/parcel-reporter-turbosnap-stats/helpers.ts b/packages/dev/parcel-reporter-turbosnap-stats/helpers.ts index 3bbb1d9b9f3..38b2534da69 100644 --- a/packages/dev/parcel-reporter-turbosnap-stats/helpers.ts +++ b/packages/dev/parcel-reporter-turbosnap-stats/helpers.ts @@ -1,9 +1,9 @@ // 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} from '@parcel/types'; import fs from 'fs'; import path from 'path'; -import type {BundleGraph, Asset} from '@parcel/types'; // 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, @@ -14,8 +14,14 @@ import type {BundleGraph, Asset} from '@parcel/types'; // 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 interface Reason { + moduleName: string; +} +export interface Module { + id: string; + name: string; + reasons: Reason[]; +} export function stripQueryParams(id: string): string { const idx = id.indexOf('?'); @@ -44,10 +50,7 @@ export function normalize(filePath: string, projectRoot: string): string { // 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$/ -]; +const FILTER_PATTERNS: RegExp[] = [/@parcel\/runtime-/, /\/react\/jsx-runtime\.js$/]; export function isUserCode(name: string): boolean { if (name.startsWith(VIRTUAL_PREFIX)) return false; @@ -111,7 +114,7 @@ export function buildStatsMap( ensure(assetName); for (const dep of bundleGraph.getDependencies(asset)) { - const target = bundleGraph.getResolvedAsset(dep); + const target = bundleGraph.getResolvedAsset(dep, bundle); if (!target) continue; const depName = normalize(target.filePath, projectRoot); if (!isUserCode(depName)) continue; @@ -134,10 +137,7 @@ const STORY_FILE_RE = /\.stories\.(js|jsx|mjs|ts|tsx)$/; // which means chromatic-cli's TurboSnap can't find them via the CSF-glob walk. // This helper bridges the gap by directly adding './storybook-stories.js' as a reason // on every asset whose name matches a story-file pattern. -export function addStoryEntries( - statsMap: Map, - logger?: Logger -): number { +export function addStoryEntries(statsMap: Map, logger?: Logger): number { let tagged = 0; for (const entry of statsMap.values()) { if (!STORY_FILE_RE.test(entry.name)) continue; @@ -146,11 +146,15 @@ export function addStoryEntries( tagged++; } } - logger?.info({message: `parcel-reporter-turbosnap-stats: tagged ${tagged} story file(s) with ./storybook-stories.js`}); + logger?.info({ + message: `parcel-reporter-turbosnap-stats: tagged ${tagged} story file(s) with ./storybook-stories.js` + }); return tagged; } -interface Logger { info: (m: {message: string}) => void; } +interface Logger { + info: (m: {message: string}) => void; +} export async function writeStats( distDir: string, @@ -174,14 +178,13 @@ export async function writeStats( 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.' + '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 fs.promises.writeFile( - path.join(distDir, 'preview-stats.json'), - JSON.stringify(stats) - ); - logger.info({message: `parcel-reporter-turbosnap-stats: wrote preview-stats.json (${stats.modules.length} modules) to ${distDir}`}); + await fs.promises.writeFile(path.join(distDir, 'preview-stats.json'), JSON.stringify(stats)); + logger.info({ + message: `parcel-reporter-turbosnap-stats: wrote preview-stats.json (${stats.modules.length} modules) to ${distDir}` + }); } diff --git a/packages/dev/storybook-builder-parcel/preset.mjs b/packages/dev/storybook-builder-parcel/preset.mjs index 9e5d284ad09..d8fb65dab92 100644 --- a/packages/dev/storybook-builder-parcel/preset.mjs +++ b/packages/dev/storybook-builder-parcel/preset.mjs @@ -125,10 +125,14 @@ async function createParcel(options, isDev = false) { hmrOptions: isDev ? {port: 3001} : null, additionalReporters: [ {packageName: '@parcel/reporter-cli', resolveFrom: __filename}, - ...(options.statsJson ? [{ - packageName: '@parcel/reporter-turbosnap-stats', - resolveFrom: __filename - }] : []) + ...(options.statsJson + ? [ + { + packageName: '@parcel/reporter-turbosnap-stats', + resolveFrom: __filename + } + ] + : []) ], targets: { storybook: { diff --git a/packages/react-aria/src/tag/useTag.ts b/packages/react-aria/src/tag/useTag.ts index 58d77bfc4fd..17054135d4f 100644 --- a/packages/react-aria/src/tag/useTag.ts +++ b/packages/react-aria/src/tag/useTag.ts @@ -118,6 +118,7 @@ export function useTag( return { removeButtonProps: { + // @ts-ignore 'data-testid': 'tag-remove-button', 'aria-label': stringFormatter.format('removeButtonLabel'), 'aria-labelledby': `${buttonId} ${rowProps.id}`, From 56fceff9a5d1f3e492a8d806ccb41925e704775c Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Thu, 28 May 2026 12:13:54 +1000 Subject: [PATCH 04/13] testing more --- package.json | 2 +- packages/react-aria/src/tag/useTag.ts | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 1dc71d4ee12..49a61f4bd30 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,7 @@ "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' --only-changed", + "chromatic": "NODE_ENV=production CHROMATIC=1 chromatic --project-token $CHROMATIC_PROJECT_TOKEN --build-script-name 'build:chromatic' --only-changed --trace-changed --dry-run", "chromatic:forced-colors": "NODE_ENV=production CHROMATIC=1 chromatic --project-token $CHROMATIC_FC_PROJECT_TOKEN --build-script-name 'build:chromatic-fc' --only-changed", "merge:css": "babel-node --presets @babel/env ./scripts/merge-spectrum-css.js", "release": "lerna publish from-package --yes", diff --git a/packages/react-aria/src/tag/useTag.ts b/packages/react-aria/src/tag/useTag.ts index 17054135d4f..b8583130df4 100644 --- a/packages/react-aria/src/tag/useTag.ts +++ b/packages/react-aria/src/tag/useTag.ts @@ -127,6 +127,8 @@ export function useTag( onPress: () => (onRemove ? onRemove(new Set([item.key])) : null) }, rowProps: mergeProps(focusableProps, rowProps, domProps, linkProps, { + // @ts-ignore + 'data-testid': 'tag-row', tabIndex, onKeyDown: onRemove ? onKeyDown : undefined, 'aria-describedby': descProps['aria-describedby'] From 4c08aa825c415e36eadd2714a17a3f8c0ee42a1d Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Thu, 28 May 2026 12:29:42 +1000 Subject: [PATCH 05/13] finish debugging, new test that should result in a visual difference --- package.json | 2 +- packages/@react-spectrum/s2/src/TagGroup.tsx | 2 +- packages/react-aria/src/tag/useTag.ts | 4 ---- 3 files changed, 2 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 49a61f4bd30..1dc71d4ee12 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,7 @@ "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' --only-changed --trace-changed --dry-run", + "chromatic": "NODE_ENV=production CHROMATIC=1 chromatic --project-token $CHROMATIC_PROJECT_TOKEN --build-script-name 'build:chromatic' --only-changed", "chromatic:forced-colors": "NODE_ENV=production CHROMATIC=1 chromatic --project-token $CHROMATIC_FC_PROJECT_TOKEN --build-script-name 'build:chromatic-fc' --only-changed", "merge:css": "babel-node --presets @babel/env ./scripts/merge-spectrum-css.js", "release": "lerna publish from-package --yes", diff --git a/packages/@react-spectrum/s2/src/TagGroup.tsx b/packages/@react-spectrum/s2/src/TagGroup.tsx index 8e92a58cb8a..aedc63af0e7 100644 --- a/packages/@react-spectrum/s2/src/TagGroup.tsx +++ b/packages/@react-spectrum/s2/src/TagGroup.tsx @@ -521,7 +521,7 @@ const tagStyles = style< transition: 'default', // maxWidth: 'calc(self(height) * 7)', // s2 designs show a max width on tags but we pushed back on this in v3 backgroundColor: { - default: 'gray-100', + default: 'blue-100', isHovered: { default: 'gray-200' }, diff --git a/packages/react-aria/src/tag/useTag.ts b/packages/react-aria/src/tag/useTag.ts index b8583130df4..8f7f9143ffa 100644 --- a/packages/react-aria/src/tag/useTag.ts +++ b/packages/react-aria/src/tag/useTag.ts @@ -118,8 +118,6 @@ export function useTag( return { removeButtonProps: { - // @ts-ignore - 'data-testid': 'tag-remove-button', 'aria-label': stringFormatter.format('removeButtonLabel'), 'aria-labelledby': `${buttonId} ${rowProps.id}`, isDisabled, @@ -127,8 +125,6 @@ export function useTag( onPress: () => (onRemove ? onRemove(new Set([item.key])) : null) }, rowProps: mergeProps(focusableProps, rowProps, domProps, linkProps, { - // @ts-ignore - 'data-testid': 'tag-row', tabIndex, onKeyDown: onRemove ? onKeyDown : undefined, 'aria-describedby': descProps['aria-describedby'] From f068554f0c9cbaf342c85df902b51f2ce74f5195 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Thu, 28 May 2026 12:47:43 +1000 Subject: [PATCH 06/13] revert and fixes --- package.json | 2 +- packages/@react-spectrum/s2/src/TagGroup.tsx | 2 +- .../__tests__/helpers.test.ts | 24 +++++++++++--- .../__tests__/integration.test.ts | 24 ++++++++------ .../helpers.ts | 32 ++++++++++++++++--- 5 files changed, 63 insertions(+), 21 deletions(-) diff --git a/package.json b/package.json index 1dc71d4ee12..49a61f4bd30 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,7 @@ "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' --only-changed", + "chromatic": "NODE_ENV=production CHROMATIC=1 chromatic --project-token $CHROMATIC_PROJECT_TOKEN --build-script-name 'build:chromatic' --only-changed --trace-changed --dry-run", "chromatic:forced-colors": "NODE_ENV=production CHROMATIC=1 chromatic --project-token $CHROMATIC_FC_PROJECT_TOKEN --build-script-name 'build:chromatic-fc' --only-changed", "merge:css": "babel-node --presets @babel/env ./scripts/merge-spectrum-css.js", "release": "lerna publish from-package --yes", diff --git a/packages/@react-spectrum/s2/src/TagGroup.tsx b/packages/@react-spectrum/s2/src/TagGroup.tsx index aedc63af0e7..8e92a58cb8a 100644 --- a/packages/@react-spectrum/s2/src/TagGroup.tsx +++ b/packages/@react-spectrum/s2/src/TagGroup.tsx @@ -521,7 +521,7 @@ const tagStyles = style< transition: 'default', // maxWidth: 'calc(self(height) * 7)', // s2 designs show a max width on tags but we pushed back on this in v3 backgroundColor: { - default: 'blue-100', + default: 'gray-100', isHovered: { default: 'gray-200' }, diff --git a/packages/dev/parcel-reporter-turbosnap-stats/__tests__/helpers.test.ts b/packages/dev/parcel-reporter-turbosnap-stats/__tests__/helpers.test.ts index 7aa522014fe..d749cc7c3c5 100644 --- a/packages/dev/parcel-reporter-turbosnap-stats/__tests__/helpers.test.ts +++ b/packages/dev/parcel-reporter-turbosnap-stats/__tests__/helpers.test.ts @@ -269,7 +269,9 @@ describe('writeStats — happy path', () => { }); describe('addStoryEntries', () => { - test('adds ./storybook-stories.js as reason on .stories.tsx files', () => { + const CSF_GLOB = './parcel-csf-glob.js'; + + test('tags .stories.tsx files with the synthetic CSF-glob entry', () => { const m = new Map([ [ './packages/foo/Accordion.stories.tsx', @@ -282,8 +284,20 @@ describe('addStoryEntries', () => { ]); addStoryEntries(m); expect(m.get('./packages/foo/Accordion.stories.tsx')!.reasons).toEqual([ - {moduleName: './storybook-stories.js'} + {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: []}] ]); + addStoryEntries(m); + expect(m.get(CSF_GLOB)).toEqual({ + id: CSF_GLOB, + name: CSF_GLOB, + reasons: [{moduleName: './storybook-stories.js'}] + }); }); test('does not add a duplicate reason if already present', () => { @@ -293,12 +307,12 @@ describe('addStoryEntries', () => { { id: './Foo.stories.tsx', name: './Foo.stories.tsx', - reasons: [{moduleName: './storybook-stories.js'}] + reasons: [{moduleName: CSF_GLOB}] } ] ]); addStoryEntries(m); - expect(m.get('./Foo.stories.tsx')!.reasons).toEqual([{moduleName: './storybook-stories.js'}]); + expect(m.get('./Foo.stories.tsx')!.reasons).toEqual([{moduleName: CSF_GLOB}]); }); test('matches .stories.{js,jsx,mjs,ts,tsx} extensions', () => { @@ -312,7 +326,7 @@ describe('addStoryEntries', () => { const m = new Map(names.map(n => [n, {id: n, name: n, reasons: []}])); addStoryEntries(m); for (const n of names) { - expect(m.get(n)!.reasons).toEqual([{moduleName: './storybook-stories.js'}]); + expect(m.get(n)!.reasons).toEqual([{moduleName: CSF_GLOB}]); } }); diff --git a/packages/dev/parcel-reporter-turbosnap-stats/__tests__/integration.test.ts b/packages/dev/parcel-reporter-turbosnap-stats/__tests__/integration.test.ts index d26d4f35c41..dcf294ff62d 100644 --- a/packages/dev/parcel-reporter-turbosnap-stats/__tests__/integration.test.ts +++ b/packages/dev/parcel-reporter-turbosnap-stats/__tests__/integration.test.ts @@ -38,20 +38,26 @@ describe('integration: real Parcel build emits preview-stats.json', () => { }); // The fixture has no storybook-resolver stories.js virtual, but it does include - // Button.stories.tsx. addStoryEntries bridges the gap by injecting - // './storybook-stories.js' as a reason on every .stories.* asset, so the - // reporter succeeds and writes preview-stats.json. Verify the file exists and - // contains the CSF-glob reason — that proves the entire chain ran against real - // Parcel internals. + // Button.stories.tsx. addStoryEntries bridges the gap by inserting a synthetic + // CSF-glob node and tagging .stories.* assets with it. Verify: + // 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 + // This is the three-level chain chromatic-cli's traversal expects. 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 storiesModule = stats.modules.find((m: any) => - m.reasons.some((r: any) => r.moduleName === './storybook-stories.js') + + 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(storiesModule).toBeDefined(); - expect(storiesModule.name).toMatch(/\.stories\./); + 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 index 38b2534da69..ec3fcb9992f 100644 --- a/packages/dev/parcel-reporter-turbosnap-stats/helpers.ts +++ b/packages/dev/parcel-reporter-turbosnap-stats/helpers.ts @@ -129,25 +129,47 @@ export function buildStatsMap( } const STORY_FILE_RE = /\.stories\.(js|jsx|mjs|ts|tsx)$/; +const CSF_GLOB_ENTRY = './parcel-csf-glob.js'; // Parcel's dynamic-import-based code splitting routes `() => import('./Foo.stories.tsx')` // through @parcel/runtime-js wrappers, so the synthetic stories.js's resolved deps // land on runtime chunks rather than the story files themselves. The story files // then end up in their own bundles with no edge pointing back at './storybook-stories.js', // which means chromatic-cli's TurboSnap can't find them via the CSF-glob walk. -// This helper bridges the gap by directly adding './storybook-stories.js' as a reason -// on every asset whose name matches a story-file pattern. +// +// This helper bridges the gap by inserting a synthetic CSF-glob node between +// './storybook-stories.js' and the actual story files. The three-level chain +// chromatic-cli's getDependentStoryFiles expects is: +// +// ./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 +// +// Pointing story files directly at './storybook-stories.js' would make THEM the +// CSF globs (per getDependentStoryFiles.ts:174-181), causing traceName to bail +// at the story file (line 287) 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 (!STORY_FILE_RE.test(entry.name)) continue; - if (entry.reasons.every(r => r.moduleName !== CANONICAL_CSF_GLOB)) { - entry.reasons.push({moduleName: CANONICAL_CSF_GLOB}); + if (entry.reasons.every(r => r.moduleName !== CSF_GLOB_ENTRY)) { + entry.reasons.push({moduleName: CSF_GLOB_ENTRY}); tagged++; } } + // Insert the synthetic CSF-glob node itself with CANONICAL_CSF_GLOB as its only reason. + 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) with ./storybook-stories.js` + message: `parcel-reporter-turbosnap-stats: tagged ${tagged} story file(s) via synthetic CSF glob` }); return tagged; } From 77c6921d3991555a8af532aef00389f2e1dfc469 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Thu, 28 May 2026 12:49:49 +1000 Subject: [PATCH 07/13] try again --- packages/@react-spectrum/s2/src/TagGroup.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@react-spectrum/s2/src/TagGroup.tsx b/packages/@react-spectrum/s2/src/TagGroup.tsx index 8e92a58cb8a..aedc63af0e7 100644 --- a/packages/@react-spectrum/s2/src/TagGroup.tsx +++ b/packages/@react-spectrum/s2/src/TagGroup.tsx @@ -521,7 +521,7 @@ const tagStyles = style< transition: 'default', // maxWidth: 'calc(self(height) * 7)', // s2 designs show a max width on tags but we pushed back on this in v3 backgroundColor: { - default: 'gray-100', + default: 'blue-100', isHovered: { default: 'gray-200' }, From 503cb14f3fe0cfb97e9b7ffdca3d1dcf1d7b2c3a Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Thu, 28 May 2026 13:22:02 +1000 Subject: [PATCH 08/13] create a different change --- package.json | 4 ++-- packages/@react-spectrum/s2/src/TagGroup.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 49a61f4bd30..8bb0ece60c7 100644 --- a/package.json +++ b/package.json @@ -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' --only-changed --trace-changed --dry-run", - "chromatic:forced-colors": "NODE_ENV=production CHROMATIC=1 chromatic --project-token $CHROMATIC_FC_PROJECT_TOKEN --build-script-name 'build:chromatic-fc' --only-changed", + "chromatic": "NODE_ENV=production CHROMATIC=1 chromatic --project-token $CHROMATIC_PROJECT_TOKEN --build-script-name 'build:chromatic' --only-changed --trace-changed", + "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", "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", diff --git a/packages/@react-spectrum/s2/src/TagGroup.tsx b/packages/@react-spectrum/s2/src/TagGroup.tsx index aedc63af0e7..042835fc21c 100644 --- a/packages/@react-spectrum/s2/src/TagGroup.tsx +++ b/packages/@react-spectrum/s2/src/TagGroup.tsx @@ -521,7 +521,7 @@ const tagStyles = style< transition: 'default', // maxWidth: 'calc(self(height) * 7)', // s2 designs show a max width on tags but we pushed back on this in v3 backgroundColor: { - default: 'blue-100', + default: 'green-100', isHovered: { default: 'gray-200' }, From b800e63fde86fa84e827ab1468bd26027383fab6 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Thu, 28 May 2026 13:29:14 +1000 Subject: [PATCH 09/13] fix the color back to normal --- packages/@react-spectrum/s2/src/TagGroup.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@react-spectrum/s2/src/TagGroup.tsx b/packages/@react-spectrum/s2/src/TagGroup.tsx index 042835fc21c..8e92a58cb8a 100644 --- a/packages/@react-spectrum/s2/src/TagGroup.tsx +++ b/packages/@react-spectrum/s2/src/TagGroup.tsx @@ -521,7 +521,7 @@ const tagStyles = style< transition: 'default', // maxWidth: 'calc(self(height) * 7)', // s2 designs show a max width on tags but we pushed back on this in v3 backgroundColor: { - default: 'green-100', + default: 'gray-100', isHovered: { default: 'gray-200' }, From ad663fdf6efcc305553caf1c67adda2217e1a8ef Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Thu, 28 May 2026 14:32:00 +1000 Subject: [PATCH 10/13] cleanup --- .gitignore | 1 - .../2026-05-27-parcel-turbosnap-stats.md | 1176 ----------------- ...026-05-27-parcel-turbosnap-stats-design.md | 460 ------- .../__tests__/helpers.test.ts | 14 + .../__tests__/integration.test.ts | 4 +- .../helpers.ts | 7 + 6 files changed, 23 insertions(+), 1639 deletions(-) delete mode 100644 docs/superpowers/plans/2026-05-27-parcel-turbosnap-stats.md delete mode 100644 docs/superpowers/specs/2026-05-27-parcel-turbosnap-stats-design.md diff --git a/.gitignore b/.gitignore index 7a5610e34ed..178fe850797 100644 --- a/.gitignore +++ b/.gitignore @@ -21,4 +21,3 @@ starters/docs/registry starters/tailwind/registry starters/docs/yarn.lock starters/tailwind/yarn.lock -.turbosnap-research/ diff --git a/docs/superpowers/plans/2026-05-27-parcel-turbosnap-stats.md b/docs/superpowers/plans/2026-05-27-parcel-turbosnap-stats.md deleted file mode 100644 index 3518bd278ab..00000000000 --- a/docs/superpowers/plans/2026-05-27-parcel-turbosnap-stats.md +++ /dev/null @@ -1,1176 +0,0 @@ -# Parcel TurboSnap Stats Reporter Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -> **⚠️ NO COMMITS, NO PUSHES.** The user has explicitly forbidden all commits and pushes — including from subagents. Every task ends with a "verify" step instead of "commit." Leave the working tree dirty for the user to review and commit themselves. If you find yourself wanting to `git add` or `git commit`, stop and report status instead. - -**Goal:** Make `yarn chromatic` succeed with `--only-changed` (TurboSnap) on the Parcel-built `.chromatic` and `.chromatic-fc` Storybook configs by emitting a Chromatic-compatible `preview-stats.json` during the build. - -**Architecture:** New Parcel reporter plugin (`@parcel/reporter-turbosnap-stats`) walks Parcel's `BundleGraph` on `buildSuccess`, builds a `Map` matching chromatic-cli's contract, rewrites `parcel-resolver-storybook`'s synthetic `stories.js` virtual to TurboSnap's canonical `./storybook-stories.js` CSF-glob name, validates, and writes the file directly to the build output directory. Registered conditionally in `storybook-builder-parcel` when `options.statsJson` is true. Modeled function-for-function on `@storybook/builder-vite`'s `webpack-stats-plugin.ts` (the upstream proof point at ~140 LOC). Spec: `docs/superpowers/specs/2026-05-27-parcel-turbosnap-stats-design.md`. - -**Tech Stack:** TypeScript, Parcel 2 (`@parcel/plugin`, `@parcel/types`), Jest 29 + `@swc/jest`, Yarn workspaces. - ---- - -## File structure - -``` -packages/dev/parcel-reporter-turbosnap-stats/ -├── package.json NEW -├── index.js NEW (CommonJS bridge to TS source) -├── StatsReporter.ts NEW (plugin instance; imports from ./helpers) -├── helpers.ts NEW (all pure functions, named exports) -└── __tests__/ - ├── helpers.test.ts NEW (unit tests for all helpers) - ├── integration.test.ts NEW (real-Parcel fixture test) - └── __fixtures__/ NEW - ├── .parcelrc NEW - ├── index.html NEW - ├── preview.js NEW - ├── Button.stories.tsx NEW - └── Button.tsx NEW - -packages/dev/storybook-builder-parcel/ -├── package.json MODIFIED (+1 dep) -└── preset.mjs MODIFIED (lines 120-141; conditional reporter) -``` - -**Why two source files (`StatsReporter.ts` + `helpers.ts`):** Parcel's plugin loader expects `module.exports = new Reporter(...)` at the package entry point, which would overwrite TypeScript's named exports. Splitting helpers into a separate file avoids the workaround and gives tests a clean `import {x} from '../helpers'`. - ---- - -## Task 1: Scaffold the new package - -**Files:** -- Create: `packages/dev/parcel-reporter-turbosnap-stats/package.json` -- Create: `packages/dev/parcel-reporter-turbosnap-stats/index.js` -- Create: `packages/dev/parcel-reporter-turbosnap-stats/helpers.ts` (placeholder) -- Create: `packages/dev/parcel-reporter-turbosnap-stats/StatsReporter.ts` (placeholder) - -- [ ] **Step 1: Create `package.json`** (mirrors `packages/dev/parcel-resolver-storybook/package.json`) - -```json -{ - "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" - } -} -``` - -- [ ] **Step 2: Create `index.js`** (one-liner, matches the sibling resolver's pattern at `packages/dev/parcel-resolver-storybook/index.js`) - -```js -module.exports = require('./StatsReporter.ts'); -``` - -- [ ] **Step 3: Create placeholder `helpers.ts`** (will be filled in by subsequent tasks) - -```ts -// Helpers for parcel-reporter-turbosnap-stats. See ./StatsReporter.ts for the -// plugin entry; this file holds the pure functions exported for unit testing. - -// 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[]; } -``` - -- [ ] **Step 4: Create placeholder `StatsReporter.ts`** - -```ts -import {Reporter} from '@parcel/plugin'; - -// Plugin wiring is added in Task 9 once all helpers are implemented. -const reporter = new Reporter({ - async report() { - // intentionally empty until Task 9 - } -}); - -// Parcel's plugin loader expects `module.exports = `, -// not the `.default` wrapper TypeScript would otherwise produce. -module.exports = reporter; -``` - -- [ ] **Step 5: Wire workspace + verify discovery** - -Run: -```bash -yarn install -yarn jest packages/dev/parcel-reporter-turbosnap-stats --listTests -``` - -Expected: `yarn install` completes without errors. The `--listTests` command prints no test files (empty result) — that's correct; we haven't written any yet. - -- [ ] **Step 6: Verify, DO NOT commit** - -Run: -```bash -ls packages/dev/parcel-reporter-turbosnap-stats/ -``` - -Expected output: -``` -StatsReporter.ts -helpers.ts -index.js -package.json -``` - -Stop. Report progress. **Do not run `git add` or `git commit`.** - ---- - -## Task 2: TDD `stripQueryParams` - -**Files:** -- Modify: `packages/dev/parcel-reporter-turbosnap-stats/helpers.ts` -- Create: `packages/dev/parcel-reporter-turbosnap-stats/__tests__/helpers.test.ts` - -- [ ] **Step 1: Write failing test** - -Create `packages/dev/parcel-reporter-turbosnap-stats/__tests__/helpers.test.ts`: - -```ts -import {stripQueryParams} from '../helpers'; - -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'); - }); -}); -``` - -- [ ] **Step 2: Run test, verify it fails** - -Run: -```bash -yarn jest packages/dev/parcel-reporter-turbosnap-stats/__tests__/helpers.test.ts -``` - -Expected: FAIL with `TypeError: ... is not a function` or `(0 , _helpers.stripQueryParams) is not a function` — `stripQueryParams` is not yet exported. - -- [ ] **Step 3: Implement `stripQueryParams`** - -Append to `packages/dev/parcel-reporter-turbosnap-stats/helpers.ts` (after the existing interfaces): - -```ts -export function stripQueryParams(id: string): string { - const idx = id.indexOf('?'); - return idx === -1 ? id : id.slice(0, idx); -} -``` - -- [ ] **Step 4: Run test, verify it passes** - -Run: -```bash -yarn jest packages/dev/parcel-reporter-turbosnap-stats/__tests__/helpers.test.ts -``` - -Expected: PASS, 3 tests pass. - -- [ ] **Step 5: DO NOT commit. Report progress and pause.** - ---- - -## Task 3: TDD `normalize` - -**Files:** -- Modify: `packages/dev/parcel-reporter-turbosnap-stats/helpers.ts` -- Modify: `packages/dev/parcel-reporter-turbosnap-stats/__tests__/helpers.test.ts` - -- [ ] **Step 1: Add failing tests** - -Append to `__tests__/helpers.test.ts`: - -```ts -import {normalize} from '../helpers'; - -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', () => { - // Simulate a Windows-style relative result by passing a backslashy input - expect(normalize('/repo/src\\nested\\Button.tsx', root)).toMatch(/^\.\/src\/nested\/Button\.tsx$/); - }); - test('\\0-prefixed synthetic id gets virtual: leading-slash form', () => { - expect(normalize('\0synthetic/foo.js', root)).toBe('/virtual:/synthetic/foo.js'); - }); -}); -``` - -- [ ] **Step 2: Run tests, verify they fail** - -Run: -```bash -yarn jest packages/dev/parcel-reporter-turbosnap-stats/__tests__/helpers.test.ts -t normalize -``` - -Expected: FAIL — `normalize` is not yet exported. - -- [ ] **Step 3: Implement `normalize`** - -Append to `helpers.ts`: - -```ts -import path from 'path'; - -const VIRTUAL_PREFIX = '\0'; - -export function normalize(filePath: string, projectRoot: string): string { - const stripped = stripQueryParams(filePath); - if (stripped.startsWith(VIRTUAL_PREFIX)) { - // chromatic-cli's getDependentStoryFiles normalizePath short-circuits paths - // starting with /virtual: — see chromatic-cli node-src/lib/turbosnap/getDependentStoryFiles.ts - // line ~53. builder-vite's webpack-stats-plugin.ts has the same trick. - return '/virtual:' + stripped.slice(VIRTUAL_PREFIX.length).replace(/^\/?/, '/'); - } - // 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, '/'); - if (rel.startsWith('virtual:')) return '/' + rel; - return './' + rel; -} -``` - -- [ ] **Step 4: Run tests, verify they pass** - -Run: -```bash -yarn jest packages/dev/parcel-reporter-turbosnap-stats/__tests__/helpers.test.ts -t normalize -``` - -Expected: PASS, 4 tests in the `normalize` describe block. - -- [ ] **Step 5: DO NOT commit. Report progress and pause.** - ---- - -## Task 4: TDD `isUserCode` - -**Files:** -- Modify: `packages/dev/parcel-reporter-turbosnap-stats/helpers.ts` -- Modify: `packages/dev/parcel-reporter-turbosnap-stats/__tests__/helpers.test.ts` - -- [ ] **Step 1: Add failing tests** - -Append to `__tests__/helpers.test.ts`: - -```ts -import {isUserCode} from '../helpers'; - -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('@parcel/runtime-* → false', () => { - expect(isUserCode('@parcel/runtime-js/foo.js')).toBe(false); - }); - test('\\0-prefixed synthetic ids → false', () => { - expect(isUserCode('\0synthetic')).toBe(false); - }); - test('virtual storybook-stories.js → true (it is the CSF-glob anchor)', () => { - expect(isUserCode('./storybook-stories.js')).toBe(true); - }); -}); -``` - -- [ ] **Step 2: Run tests, verify they fail** - -```bash -yarn jest packages/dev/parcel-reporter-turbosnap-stats/__tests__/helpers.test.ts -t isUserCode -``` - -Expected: FAIL — not exported. - -- [ ] **Step 3: Implement `isUserCode`** - -Append to `helpers.ts`: - -```ts -const FILTER_PATTERNS: RegExp[] = [ - /^@parcel\/runtime-/, - /\/react\/jsx-runtime\.js$/ -]; - -export function isUserCode(name: string): boolean { - if (name.startsWith(VIRTUAL_PREFIX)) return false; - for (const re of FILTER_PATTERNS) { - if (re.test(name)) return false; - } - return true; -} -``` - -- [ ] **Step 4: Run tests, verify they pass** - -```bash -yarn jest packages/dev/parcel-reporter-turbosnap-stats/__tests__/helpers.test.ts -t isUserCode -``` - -Expected: PASS, 6 tests. - -- [ ] **Step 5: DO NOT commit. Report progress and pause.** - ---- - -## Task 5: TDD `buildStatsMap` (with mock BundleGraph) - -**Files:** -- Modify: `packages/dev/parcel-reporter-turbosnap-stats/helpers.ts` -- Modify: `packages/dev/parcel-reporter-turbosnap-stats/__tests__/helpers.test.ts` - -- [ ] **Step 1: Add a mock-graph helper and failing tests** - -Append to `__tests__/helpers.test.ts`: - -```ts -import {buildStatsMap} from '../helpers'; - -// Minimal stand-in satisfying the four 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][]}) { - 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}); - } - - 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}) => - assetById.get(dep.target) ?? null - } 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); - // Button.tsx is still recorded (it's a user module with no other deps) - 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: []}); - }); -}); -``` - -- [ ] **Step 2: Run tests, verify they fail** - -```bash -yarn jest packages/dev/parcel-reporter-turbosnap-stats/__tests__/helpers.test.ts -t buildStatsMap -``` - -Expected: FAIL — `buildStatsMap` is not exported. - -- [ ] **Step 3: Implement `buildStatsMap`** - -Append to `helpers.ts`: - -```ts -import type {BundleGraph, Asset} from '@parcel/types'; - -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)) { - const target = bundleGraph.getResolvedAsset(dep); - if (!target) continue; - const depName = normalize(target.filePath, projectRoot); - if (!isUserCode(depName)) continue; - const entry = ensure(depName); - if (entry.reasons.every(r => r.moduleName !== assetName)) { - entry.reasons.push({moduleName: assetName}); - } - } - }); - } - return statsMap; -} -``` - -- [ ] **Step 4: Run tests, verify they pass** - -```bash -yarn jest packages/dev/parcel-reporter-turbosnap-stats/__tests__/helpers.test.ts -t buildStatsMap -``` - -Expected: PASS, 5 tests. - -- [ ] **Step 5: DO NOT commit. Report progress and pause.** - ---- - -## Task 6: TDD `rewriteStoryVirtuals` - -**Files:** -- Modify: `packages/dev/parcel-reporter-turbosnap-stats/helpers.ts` -- Modify: `packages/dev/parcel-reporter-turbosnap-stats/__tests__/helpers.test.ts` - -- [ ] **Step 1: Add failing tests** - -Append to `__tests__/helpers.test.ts`: - -```ts -import {rewriteStoryVirtuals, type Module} from '../helpers'; - -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', () => { - // .chromatic/main.mjs has two story globs → two synthetic stories.js - // assets in the same dir. In practice they share the same filePath, but - // tests can simulate dual entries by using a path suffix that still - // matches STORY_VIRTUAL_RE. We use the same path twice via merge semantics. - 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); - }); -}); -``` - -- [ ] **Step 2: Run tests, verify they fail** - -```bash -yarn jest packages/dev/parcel-reporter-turbosnap-stats/__tests__/helpers.test.ts -t rewriteStoryVirtuals -``` - -Expected: FAIL — `rewriteStoryVirtuals` is not exported. - -- [ ] **Step 3: Implement `rewriteStoryVirtuals`** - -Append to `helpers.ts`: - -```ts -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; - } - } - } -} -``` - -- [ ] **Step 4: Run tests, verify they pass** - -```bash -yarn jest packages/dev/parcel-reporter-turbosnap-stats/__tests__/helpers.test.ts -t rewriteStoryVirtuals -``` - -Expected: PASS, 4 tests. - -- [ ] **Step 5: DO NOT commit. Report progress and pause.** - ---- - -## Task 7: TDD `writeStats` validation - -**Files:** -- Modify: `packages/dev/parcel-reporter-turbosnap-stats/helpers.ts` -- Modify: `packages/dev/parcel-reporter-turbosnap-stats/__tests__/helpers.test.ts` - -- [ ] **Step 1: Add failing validation tests** - -Append to `__tests__/helpers.test.ts`: - -```ts -import {writeStats, type Module} from '../helpers'; -import os from 'os'; -import path from 'path'; -import fs from 'fs'; - -const silentLogger = {info: () => {}}; - -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(), 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, silentLogger)) - .rejects.toThrow(/no module references \.\/storybook-stories\.js/); - }); -}); -``` - -- [ ] **Step 2: Run tests, verify they fail** - -```bash -yarn jest packages/dev/parcel-reporter-turbosnap-stats/__tests__/helpers.test.ts -t "writeStats — validation" -``` - -Expected: FAIL — `writeStats` is not exported. - -- [ ] **Step 3: Implement `writeStats` (validation only; file emission in Task 8)** - -Append to `helpers.ts`: - -```ts -import fs from 'fs'; - -interface Logger { info: (m: {message: string}) => void; } - -export async function writeStats( - distDir: string, - statsMap: Map, - logger: Logger -): Promise { - const stats = {modules: [...statsMap.values()]}; - - 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 fs.promises.writeFile( - path.join(distDir, 'preview-stats.json'), - JSON.stringify(stats) - ); - logger.info({message: `parcel-reporter-turbosnap-stats: wrote preview-stats.json (${stats.modules.length} modules) to ${distDir}`}); -} -``` - -- [ ] **Step 4: Run tests, verify they pass** - -```bash -yarn jest packages/dev/parcel-reporter-turbosnap-stats/__tests__/helpers.test.ts -t "writeStats — validation" -``` - -Expected: PASS, 2 tests. - -- [ ] **Step 5: DO NOT commit. Report progress and pause.** - ---- - -## Task 8: TDD `writeStats` file emission - -**Files:** -- Modify: `packages/dev/parcel-reporter-turbosnap-stats/__tests__/helpers.test.ts` - -(No code change to `helpers.ts` — Task 7 already implements file emission; this task adds the happy-path test.) - -- [ ] **Step 1: Add happy-path test** - -Append to `__tests__/helpers.test.ts`: - -```ts -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, 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); - expect(written.modules[0]).toEqual({ - id: './storybook-stories.js', - name: './storybook-stories.js', - reasons: [{moduleName: './preview-main.js'}] - }); - expect(infoLog).toMatch(/wrote preview-stats\.json \(2 modules\)/); - }); -}); -``` - -- [ ] **Step 2: Run test, verify it passes (no impl change needed)** - -```bash -yarn jest packages/dev/parcel-reporter-turbosnap-stats/__tests__/helpers.test.ts -t "writeStats — happy path" -``` - -Expected: PASS. - -- [ ] **Step 3: Run the full helpers suite** - -```bash -yarn jest packages/dev/parcel-reporter-turbosnap-stats/__tests__/helpers.test.ts -``` - -Expected: PASS for the complete file — sum of tests across Tasks 2-8: -- `stripQueryParams`: 3 -- `normalize`: 4 -- `isUserCode`: 6 -- `buildStatsMap`: 5 -- `rewriteStoryVirtuals`: 4 -- `writeStats — validation`: 2 -- `writeStats — happy path`: 1 -- **Total: 25** - -- [ ] **Step 4: DO NOT commit. Report progress and pause.** - ---- - -## Task 9: Wire the Reporter plugin - -**Files:** -- Modify: `packages/dev/parcel-reporter-turbosnap-stats/StatsReporter.ts` - -- [ ] **Step 1: Replace `StatsReporter.ts` with the real plugin wiring** - -Overwrite `packages/dev/parcel-reporter-turbosnap-stats/StatsReporter.ts`: - -```ts -import {Reporter} from '@parcel/plugin'; -import {buildStatsMap, rewriteStoryVirtuals, writeStats} from './helpers'; - -const reporter = new Reporter({ - async report({event, options, logger}) { - if (event.type !== 'buildSuccess') return; - - const statsMap = buildStatsMap(event.bundleGraph, options.projectRoot); - rewriteStoryVirtuals(statsMap); - - 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, logger); - } -}); - -// Parcel's plugin loader expects `module.exports = `, -// not the `.default` wrapper TypeScript would otherwise produce. -module.exports = reporter; -``` - -- [ ] **Step 2: Verify TypeScript compiles without errors** - -Run: -```bash -yarn jest packages/dev/parcel-reporter-turbosnap-stats --listTests -``` - -Expected: prints the test file paths without TypeScript errors. (Jest's swc transform compiles the import chain.) - -- [ ] **Step 3: Re-run the full helpers suite to confirm nothing regressed** - -```bash -yarn jest packages/dev/parcel-reporter-turbosnap-stats/__tests__/helpers.test.ts -``` - -Expected: 25 tests pass. - -- [ ] **Step 4: DO NOT commit. Report progress and pause.** - ---- - -## Task 10: Real-Parcel integration test (fixture build) - -**Files:** -- Create: `packages/dev/parcel-reporter-turbosnap-stats/__tests__/__fixtures__/.parcelrc` -- Create: `packages/dev/parcel-reporter-turbosnap-stats/__tests__/__fixtures__/index.html` -- Create: `packages/dev/parcel-reporter-turbosnap-stats/__tests__/__fixtures__/preview.js` -- Create: `packages/dev/parcel-reporter-turbosnap-stats/__tests__/__fixtures__/Button.stories.tsx` -- Create: `packages/dev/parcel-reporter-turbosnap-stats/__tests__/__fixtures__/Button.tsx` -- Create: `packages/dev/parcel-reporter-turbosnap-stats/__tests__/integration.test.ts` - -This test runs the actual Parcel build against a minimal fixture. The fixture does NOT use storybook-builder-parcel — it directly drives `new Parcel(...)` with our reporter. It validates the algorithm against a real `BundleGraph`. The synthetic-stories rewrite is exercised by Task 12's manual verification, since synthesizing the storybook-resolver pipeline in a unit test is more cost than value. - -- [ ] **Step 1: Create fixture `__fixtures__/index.html`** - -```html - - -``` - -- [ ] **Step 2: Create fixture `__fixtures__/preview.js`** - -```js -import {Button} from './Button.stories.tsx'; -console.log(Button); -``` - -- [ ] **Step 3: Create fixture `__fixtures__/Button.stories.tsx`** - -```tsx -import {Button} from './Button'; -export {Button}; -export default {title: 'Button'}; -``` - -- [ ] **Step 4: Create fixture `__fixtures__/Button.tsx`** - -```tsx -export const Button = () => null; -``` - -- [ ] **Step 5: Create fixture `__fixtures__/.parcelrc`** - -```json -{ - "extends": "@parcel/config-default" -} -``` - -- [ ] **Step 6: Create integration test** - -Create `packages/dev/parcel-reporter-turbosnap-stats/__tests__/integration.test.ts`: - -```ts -import {Parcel} from '@parcel/core'; -import path from 'path'; -import fs from 'fs'; -import os from 'os'; - -jest.setTimeout(60_000); - -describe('integration: real Parcel build emits preview-stats.json', () => { - test('writes a stats file whose modules contain user code with inverted reasons', async () => { - const fixtureDir = path.join(__dirname, '__fixtures__'); - const distDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ts-int-')); - - const parcel = new (Parcel as any)({ - entries: path.join(fixtureDir, 'index.html'), - config: path.join(fixtureDir, '.parcelrc'), - mode: 'production', - additionalReporters: [{ - packageName: '@parcel/reporter-turbosnap-stats', - resolveFrom: __filename - }], - targets: { - default: { - distDir, - publicUrl: './' - } - } - }); - - // The reporter will throw "no module references ./storybook-stories.js" because - // this fixture isn't a Storybook build. Catch and assert the throw — that proves - // the reporter ran, walked the graph, and reached validation. Then inspect what - // it traversed by re-running with validation disabled... actually no, simpler: - // bypass validation by using a fixture file named 'stories.js' in the resolver - // virtuals dir. Use the buildSuccess hook in a wrapper. - // - // Pragmatic alternative: this test ONLY confirms the reporter throws the right - // validation error, which proves the graph walk + validation work end-to-end - // against real Parcel internals. The full happy-path is covered by Task 12's - // manual verification against the actual Storybook build. - await expect(parcel.run()).rejects.toThrow(/no module references \.\/storybook-stories\.js/); - }); -}); -``` - -- [ ] **Step 7: Run the integration test** - -```bash -yarn jest packages/dev/parcel-reporter-turbosnap-stats/__tests__/integration.test.ts -``` - -Expected: PASS. The test takes ~5-15s. If Parcel emits the error inside a wrapping error, the `.rejects.toThrow` matcher walks `.cause` and message chains — should still match. - -- [ ] **Step 8: If integration test FAILS with an unrelated error** - -Common causes and fixes: -- `Cannot find module '@parcel/reporter-turbosnap-stats'` → run `yarn install` to re-link workspaces. -- `Cannot resolve './Button'` from `Button.stories.tsx` → make sure the `.parcelrc` extends `@parcel/config-default` (which includes TSX support). -- Test timeout → bump `jest.setTimeout` to 120_000. - -- [ ] **Step 9: DO NOT commit. Report progress and pause.** - ---- - -## Task 11: Integrate into storybook-builder-parcel - -**Files:** -- Modify: `packages/dev/storybook-builder-parcel/package.json` -- Modify: `packages/dev/storybook-builder-parcel/preset.mjs` (lines 120-141) - -- [ ] **Step 1: Add the new dependency** - -Edit `packages/dev/storybook-builder-parcel/package.json` to add `"@parcel/reporter-turbosnap-stats": "0.0.0"` in the `dependencies` block, alphabetized between `@parcel/reporter-cli` and `@parcel/utils`. Current state (line 17-18): - -```json - "@parcel/reporter-cli": "^2.16.3", - "@parcel/utils": "^2.16.3", -``` - -After: - -```json - "@parcel/reporter-cli": "^2.16.3", - "@parcel/reporter-turbosnap-stats": "0.0.0", - "@parcel/utils": "^2.16.3", -``` - -- [ ] **Step 2: Make the reporter conditional in `preset.mjs`** - -In `packages/dev/storybook-builder-parcel/preset.mjs`, locate the `additionalReporters` argument inside `createParcel` (currently around lines 126): - -```js - additionalReporters: [{packageName: '@parcel/reporter-cli', resolveFrom: __filename}], -``` - -Replace with: - -```js - additionalReporters: [ - {packageName: '@parcel/reporter-cli', resolveFrom: __filename}, - ...(options.statsJson ? [{ - packageName: '@parcel/reporter-turbosnap-stats', - resolveFrom: __filename - }] : []) - ], -``` - -`options.statsJson` is set by Storybook 10 core when the `--stats-json` flag is passed (`storybookjs/storybook` `core/src/types/modules/core-common.ts:228`). - -- [ ] **Step 3: Re-link workspaces** - -```bash -yarn install -``` - -Expected: completes without error. - -- [ ] **Step 4: Verify Parcel can resolve the new reporter** - -```bash -node -e "console.log(require.resolve('@parcel/reporter-turbosnap-stats'))" -``` - -Expected: prints the absolute path to `packages/dev/parcel-reporter-turbosnap-stats/index.js` (or the compiled `dist/StatsReporter.js`). - -- [ ] **Step 5: Run a smoke build with stats enabled** - -```bash -yarn build:chromatic -``` - -Expected: the build completes, and `dist//chromatic/preview-stats.json` exists. Inspect: - -```bash -ls dist/$(git rev-parse HEAD)/chromatic/preview-stats.json -jq '.modules | length' dist/$(git rev-parse HEAD)/chromatic/preview-stats.json -jq '.modules[] | select(.name == "./storybook-stories.js")' dist/$(git rev-parse HEAD)/chromatic/preview-stats.json -``` - -Expected: -- File exists. -- `modules` is a sizable number (likely >1000 for this repo). -- A module named `./storybook-stories.js` exists with at least one reason. - -If the validation throws ("no module references ./storybook-stories.js"), `STORY_VIRTUAL_RE` in `helpers.ts` does not match the actual `asset.filePath` of `parcel-resolver-storybook`'s synthetic stories.js. Print the matching candidates: - -```bash -jq '.modules[] | select(.name | test("stories\\.js"))' dist/$(git rev-parse HEAD)/chromatic/preview-stats.json -``` - -Use the output to adjust the regex. **Do not catch the error to make the build pass** — that's the "fail loudly" contract. - -- [ ] **Step 6: DO NOT commit. Report progress and pause.** - ---- - -## Task 12: End-to-end verification with chromatic-cli - -This is the user's original spike formalized as the final acceptance gate. No new code — just running the pipeline. - -- [ ] **Step 1: Confirm a baseline TurboSnap-eligible diff exists** - -Make a single trivial source change to a non-config, non-story file — e.g., add a no-op comment to a component used by stories. Don't stage or commit it; the diff just needs to exist in the working tree (chromatic-cli reads `git diff`). - -- [ ] **Step 2: Run chromatic locally** - -```bash -yarn chromatic -``` - -Expected output, in order: -- `✔ Authenticated with Chromatic` -- `✔ Retrieved git information` -- `✔ Collected Storybook metadata` -- `✔ Initialized build` -- `✔ Storybook built in seconds` -- `✔ Prepare your built Storybook` — **this is the line that previously failed.** Should pass now. -- `✔ Publish your built Storybook` -- `✔ Verify your Storybook` -- `✔ Test your stories` -- A reuse percentage line indicating TurboSnap engaged, e.g., `Snapshots reused: %`. - -- [ ] **Step 3: If "Prepare" still fails with "missing stats file"** - -The reporter ran but Chromatic isn't finding the file. Verify: -```bash -ls dist/$(git rev-parse HEAD)/chromatic/preview-stats.json -``` - -If it exists, chromatic-cli is looking in the wrong directory. Re-check the `--build-script-name` in `package.json` and that `chromatic-cli` is using the same output dir as `yarn build:chromatic`. - -- [ ] **Step 4: If TurboSnap reports 0% reuse** - -Cross-check against the documented bail conditions in the spec's "Documented bail conditions" section: -- Any change under `.storybook/` or `.chromatic/`? → expected bail. -- Lockfile (`yarn.lock`) changed? → may bail unless `node_modules/*` modules are in stats. Inspect: - ```bash - jq '.modules[] | select(.name | test("node_modules"))' dist/$(git rev-parse HEAD)/chromatic/preview-stats.json | head - ``` - If empty, the filter set in `helpers.ts:isUserCode` is too aggressive — relax it. - -- [ ] **Step 5: Also verify the forced-colors variant** - -```bash -yarn chromatic:forced-colors -``` - -Expected: same behavior, separate Chromatic project. - -- [ ] **Step 6: Run the full helpers + integration test suite one last time** - -```bash -yarn jest packages/dev/parcel-reporter-turbosnap-stats -``` - -Expected: 25 unit tests + 1 integration test pass (total 26). - -- [ ] **Step 7: DO NOT commit anything. Report results to the user.** - -Summarize: -- All tests passing? (yes/no) -- `yarn chromatic` reaches "Verify your Storybook"? (yes/no) -- TurboSnap reuse percentage? (number) -- Any remaining concerns or follow-ups? (list) - -Hand control back to the user. They will decide whether to commit, and what cleanup to do (e.g., removing `.turbosnap-research/`). - ---- - -## Cleanup notes for the user (after acceptance) - -When the user is ready to commit, they should consider: -- `rm -rf .turbosnap-research/` (research clones, currently gitignored). -- Decide whether the `.turbosnap-research/` line in `.gitignore` should stay (harmless) or be removed. -- The original `package.json` changes (`--stats-json` and `--only-changed` flags in the `chromatic` scripts) are part of the same feature and should ship in the same commit as the new package. - -## Spec coverage check (self-review) - -| Spec section | Implementing task(s) | -|---|---| -| Architecture diagram | Task 11 (registration), Task 9 (wiring) | -| Components: parcel-reporter-turbosnap-stats package | Tasks 1, 9 | -| Components: StatsReporter.ts surface (`stripQueryParams`, `normalize`, `isUserCode`, `buildStatsMap`, `rewriteStoryVirtuals`, `writeStats`) | Tasks 2-8 | -| Components: storybook-builder-parcel edit | Task 11 | -| Data flow: Phase A (graph walk) | Task 5 | -| Data flow: Phase B (story-virtual rewrite) | Task 6 | -| Data flow: Phase C (validation + write) | Tasks 7, 8 | -| Worked example | Verified by Task 12 | -| Error handling: fail-loudly | Tasks 7 (validation throws), 9 (no bundles throws) | -| Error handling: logger usage | Task 7 (logger.info on write) | -| Error handling: filter set | Task 4 (`isUserCode`) | -| Testing Layer 1: pure-function unit tests | Tasks 2-8 | -| Testing Layer 2: mock-BundleGraph algorithm test | Task 5 | -| Testing Layer 3: real-Parcel fixture test | Task 10 | -| Validation tests | Task 7 | -| Manual verification (4-step checklist) | Task 12 | diff --git a/docs/superpowers/specs/2026-05-27-parcel-turbosnap-stats-design.md b/docs/superpowers/specs/2026-05-27-parcel-turbosnap-stats-design.md deleted file mode 100644 index 97a1f784f48..00000000000 --- a/docs/superpowers/specs/2026-05-27-parcel-turbosnap-stats-design.md +++ /dev/null @@ -1,460 +0,0 @@ -# Parcel TurboSnap Stats Reporter — Design - -**Date:** 2026-05-27 -**Status:** Implemented (see addendum below) -**Owner:** Rob Snow - -## Implementation addendum (2026-05-27) - -Two facts about what shipped that diverge from the spec body below: - -1. **Package is published as `@parcel/reporter-turbosnap-stats` (scoped)**, not the unscoped `parcel-reporter-turbosnap-stats` named throughout this doc. The scoped form matches the sibling `@parcel/resolver-storybook` in the same workspace. Parcel's plugin loader accepts either form. - -2. **A `addStoryEntries` post-processing helper was added** that is not in the original design. It iterates the stats map after `rewriteStoryVirtuals` and adds `./storybook-stories.js` as a reason on every `.stories.{js,jsx,mjs,ts,tsx}` file. This was discovered during implementation: Parcel's dynamic-import bundle splitting routes `() => import('./Foo.stories.tsx')` through `@parcel/runtime-js` wrappers, so `bundleGraph.getDependencies(syntheticStoriesJs)` resolves to runtime chunks rather than the actual story files — the natural bundle-graph walk doesn't link stories to the CSF-glob anchor. The helper bridges this gap without depending on Parcel internals. The tradeoff: any story file using a non-standard extension or naming convention (e.g., `*.story.tsx`) won't be auto-tagged. - -## Summary - -Add a Parcel reporter plugin that emits a Chromatic-compatible `preview-stats.json` alongside the Storybook build, enabling TurboSnap (`--only-changed`) for the Parcel-built `.chromatic` and `.chromatic-fc` Storybook configs. The reporter is a single ~150 LOC file modeled directly on `@storybook/builder-vite`'s `webpack-stats-plugin.ts`, with one Parcel-specific concern: rewriting `parcel-resolver-storybook`'s synthetic `stories.js` virtual to TurboSnap's canonical CSF-glob name. - -## Problem - -The user enabled TurboSnap by adding `--stats-json` to `build:chromatic` / `build:chromatic-fc` and `--only-changed` to the `chromatic` / `chromatic:forced-colors` scripts. The build now fails at the "Prepare your built Storybook" step with: - -> Make sure you pass `--stats-json` when building your Storybook. -> Did not find preview-stats.json in your built Storybook. - -Root cause: TurboSnap requires the webpack-format `preview-stats.json` produced by Webpack's stats output or by `@storybook/builder-vite`'s port of `vite-plugin-turbosnap`. The repo's Storybook configs use `storybook-react-parcel` → `storybook-builder-parcel`, which has no equivalent. The `--stats-json` flag flows into the Storybook 10 builder option `options.statsJson`, but `storybook-builder-parcel`'s `build()` ignores it and returns `undefined`, so nothing produces the file. - -## Approach - -**Selected: Option A** — Build a new Parcel reporter plugin that walks Parcel's `BundleGraph` after each build and writes `preview-stats.json` in the shape chromatic-cli's `getDependentStoryFiles` consumes. Register it conditionally on `options.statsJson` in `storybook-builder-parcel`'s `additionalReporters`. - -**Rejected alternatives:** - -- **Option B (post-build script that translates Parcel's bundle-analyzer JSON):** Bundle-analyzer output is sparser than direct `BundleGraph` access — missing virtual modules and dependency edges. Doesn't fix the underlying builder/Storybook contract gap, so `--stats-json` would still be silently dropped. -- **Option C (inline reporter inside `storybook-builder-parcel`):** Parcel mandates the `parcel-reporter-` package prefix, so this would still require a nested package directory. Less idiomatic given the repo already owns a dozen sibling Parcel plugins under `packages/dev/parcel-*`. - -## Research basis - -Two independent expert agents audited the upstream sources before this design was finalized. Findings summarized inline below; full agent reports retained in conversation history. - -**chromatic-cli's stats consumption** (`chromaui/chromatic-cli` v11.20.0, `node-src/lib/turbosnap/getDependentStoryFiles.ts`, `node-src/types.ts`): - -- TurboSnap reads exactly four fields: `stats.modules[].id`, `stats.modules[].name`, `stats.modules[].reasons[].moduleName`, and (optional) `stats.modules[].modules[].name`. No other top-level keys or per-module fields are consumed. -- Hard error (not silent bail) if no module's `reasons[]` contain a known Storybook entry name like `./storybook-stories.js`. `getDependentStoryFiles.ts:201`. -- Bail conditions: changes under `.storybook/` config dir, changes under any `staticDir`, lockfile diff with zero `node_modules/*` modules in stats. -- Path convention: POSIX, `./` prefix, project-root-relative. - -**`@storybook/builder-vite`'s solution** (`storybookjs/storybook` next branch, `code/builders/builder-vite/src/plugins/webpack-stats-plugin.ts`): - -- ~140 LOC, single self-contained file, originally a port of `vite-plugin-turbosnap`. -- Uses only Rollup's `moduleParsed` hook (push-based). For each parsed module, iterates `mod.importedIds + mod.dynamicallyImportedIds` and registers reverse edges into a `Map`. -- Emits exactly `{modules: [...]}` — nothing else. No `chunks`, `assets`, `entrypoints`, `hash`, `version`, etc. -- Virtual ids must start with `/` to be recognized by chromatic-cli; real paths use `./` prefix. -- Filters out: `vite/` internals, `\0`-prefixed non-Storybook ids, `react/jsx-runtime`. -- Returns data through Storybook 10's `BuilderStats.toJson()` contract; Storybook core writes the file. -- Zero coupling to Vite internals. Tightly coupled to chromatic-cli's expected format — that's the dominant maintenance concern. - -## Architecture - -``` -yarn chromatic - └─ chromatic-cli - └─ runs `yarn build:chromatic` (--stats-json flag flows into builder) - └─ Storybook core - └─ storybook-builder-parcel.build({options: {statsJson: true, ...}}) - └─ new Parcel({ - additionalReporters: [ - '@parcel/reporter-cli', - options.statsJson && 'parcel-reporter-turbosnap-stats', - ].filter(Boolean) - }) - └─ parcel.run() - └─ ... build phases ... - └─ fires 'buildSuccess' event with BundleGraph - └─ parcel-reporter-turbosnap-stats - ├─ walks BundleGraph (pull-based) - ├─ builds Map - ├─ rewrites synthetic stories.js virtual → './storybook-stories.js' - ├─ validates (non-empty, CSF-glob anchor present) - └─ writes preview-stats.json to bundle.target.distDir - └─ chromatic-cli reads dist//chromatic/preview-stats.json - └─ TurboSnap maps changed files → affected stories -``` - -## Components - -### New: `packages/dev/parcel-reporter-turbosnap-stats/` - -Three files, matching the structure of the existing `packages/dev/parcel-resolver-storybook` sibling. - -``` -parcel-reporter-turbosnap-stats/ -├── package.json — name: "parcel-reporter-turbosnap-stats" (Parcel-mandated prefix), -│ private: true, type: module, main: "./index.js" -├── index.js — re-exports the TS module (matches parcel-resolver-storybook pattern) -└── StatsReporter.ts — the reporter itself (~150 LOC, ported from - builder-vite's webpack-stats-plugin.ts function-for-function) -``` - -**`StatsReporter.ts` internal surface:** - -| Function | Inputs | Output | builder-vite counterpart | -|---|---|---|---| -| `default export` | `Reporter({async report})` from `@parcel/plugin` | n/a | the plugin object literal | -| `stripQueryParams(id)` | string | string | identical to line 38 | -| `isUserCode(id)` | string | boolean | structurally identical; filter set adjusted: `@parcel/runtime-*`, `\0`-prefixed non-storybook, `react/jsx-runtime` | -| `normalize(filePath, root)` | absolute path, project root | POSIX `./rel` or `/virtual:...` | `normalize()` lines 64-91 | -| `buildStatsMap(bundleGraph, root)` | Parcel `BundleGraph`, project root | `Map` | the `moduleParsed` callback (lines 113-135), but pull-based: iterate `bundleGraph.getBundles()` → `bundle.traverseAssets()` → `bundleGraph.getDependencies(asset)` | -| `rewriteStoryVirtuals(statsMap)` | the map | mutates in place | Parcel-specific (no Vite counterpart) | -| `writeStats(distDir, statsMap, logger)` | output dir, the map, Parcel logger | writes `preview-stats.json`; throws on validation failure | upstream this is Storybook core's job | - -**Module/Reason types** are defined inline against chromatic-cli's contract — no separate `.d.ts` exported from this package: - -```ts -interface Reason { moduleName: string; } -interface Module { id: string; name: string; reasons: Reason[]; } -``` - -### Edit: `packages/dev/storybook-builder-parcel/preset.mjs` - -Two changes to the existing file: - -1. **`package.json`**: add `parcel-reporter-turbosnap-stats` to dependencies. -2. **`preset.mjs:120-141`**: replace the hard-coded `additionalReporters` array with one assembled conditionally based on `options.statsJson`: - -```js -const additionalReporters = [ - {packageName: '@parcel/reporter-cli', resolveFrom: __filename} -]; -if (options.statsJson) { - additionalReporters.push({ - packageName: 'parcel-reporter-turbosnap-stats', - resolveFrom: __filename - }); -} -``` - -### Not changed - -- `.chromatic/main.mjs`, `.chromatic-fc/main.mjs`, `.chromatic/.parcelrc`, `.chromatic-fc/.parcelrc` — reporter is registered at the builder layer, not via `.parcelrc`. -- The existing `--stats-json` and `--only-changed` flags in `package.json` — these already plumb correctly; they just had nowhere to land before. -- `storybook-builder-parcel`'s `build()` return value — the reporter writes the file directly rather than going through Storybook 10's `BuilderStats.toJson()` contract. Storybook core has a `writeStats(directory, 'preview', stats)` utility (`storybookjs/storybook` `core/src/core-server/utils/output-stats.ts:12`) that handles this when a builder returns `previewStats`, but bypassing it avoids tying our output to changes in that utility's filename or formatting conventions. - -## Data flow - -### Phase A: Graph walk (pull-based) - -Parcel's reporter callback receives `event.bundleGraph` with the full graph already resolved. Unlike builder-vite's push-based `moduleParsed` hook, the entire graph is available at once. - -```ts -function buildStatsMap(bundleGraph, projectRoot) { - const statsMap = new Map(); - const ensure = (name) => { - if (!statsMap.has(name)) statsMap.set(name, {id: name, name, reasons: []}); - return statsMap.get(name); - }; - const seen = new Set(); - - for (const bundle of bundleGraph.getBundles()) { - bundle.traverseAssets((asset) => { - if (seen.has(asset.id)) return; - seen.add(asset.id); - - const assetName = normalize(asset.filePath, projectRoot); - if (!isUserCode(assetName)) return; - ensure(assetName); // every reachable asset gets a record, even leaves - - for (const dep of bundleGraph.getDependencies(asset)) { - const target = bundleGraph.getResolvedAsset(dep); - if (!target) continue; - const depName = normalize(target.filePath, projectRoot); - if (!isUserCode(depName)) continue; - const entry = ensure(depName); - if (entry.reasons.every(r => r.moduleName !== assetName)) { - entry.reasons.push({moduleName: assetName}); - } - } - }); - } - return statsMap; -} -``` - -**Invariant:** for every edge `A → B` in the bundle graph, B's `reasons` contains `{moduleName: A}`. This is the inverted graph TurboSnap walks upward from changed files. - -### Phase B: Story-virtual rewrite - -`parcel-resolver-storybook` creates a synthetic `stories.js` asset for every `story:` glob (`StorybookResolver.ts:49`): - -```ts -filePath: path.join(dir, 'stories.js') -``` - -where `dir` is the directory of whoever imported the `story:` pipeline — for us, `storybook-builder-parcel`'s `generated-entries/`. So in the raw stats it surfaces as e.g. `./packages/dev/storybook-builder-parcel/generated-entries/stories.js`. - -TurboSnap requires the CSF-glob entry name to match one of its known patterns (`getDependentStoryFiles.ts:119-135`). We rewrite to the modern Storybook 7+ canonical name: - -```ts -const STORY_VIRTUAL_RE = /\/storybook-builder-parcel\/generated-entries\/stories\.js$/; -const CANONICAL = './storybook-stories.js'; - -function rewriteStoryVirtuals(statsMap) { - // Rename the virtual entries - for (const [oldName, entry] of [...statsMap]) { - if (!STORY_VIRTUAL_RE.test(oldName)) continue; - statsMap.delete(oldName); - entry.id = CANONICAL; - entry.name = CANONICAL; - // If we already created the canonical entry (from a prior rewrite this build), - // merge reasons rather than overwrite. - if (statsMap.has(CANONICAL)) { - const existing = statsMap.get(CANONICAL); - for (const r of entry.reasons) { - if (existing.reasons.every(x => x.moduleName !== r.moduleName)) { - existing.reasons.push(r); - } - } - } else { - statsMap.set(CANONICAL, entry); - } - } - // Rewrite any reasons that pointed at the old virtual name - for (const entry of statsMap.values()) { - for (const reason of entry.reasons) { - if (STORY_VIRTUAL_RE.test(reason.moduleName)) { - reason.moduleName = CANONICAL; - } - } - } -} -``` - -If multiple `story:` globs in the same config dir produce multiple `stories.js` virtuals (the case for `.chromatic/main.mjs` which has two glob entries), they all collapse to a single `./storybook-stories.js` entry. - -### Phase C: Validation + write - -`distDir` is sourced from the Parcel build event: `event.bundleGraph.getBundles()[0].target.distDir`. For our single-target builder this is the Storybook output directory (e.g., `dist//chromatic/`). - -```ts -async function writeStats(distDir, statsMap, logger) { - const stats = {modules: [...statsMap.values()]}; - - 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 === './storybook-stories.js') - ); - 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 fs.promises.writeFile( - path.join(distDir, 'preview-stats.json'), - JSON.stringify(stats) - ); - logger.info({message: `Wrote preview-stats.json (${stats.modules.length} modules)`}); -} -``` - -### Worked example - -Source tree: - -``` -.chromatic/main.mjs (story globs) -packages/foo/chromatic/Button.stories.tsx -packages/foo/src/Button.tsx -``` - -Resulting `preview-stats.json` (essential entries only): - -```json -{ - "modules": [ - { - "id": "./storybook-stories.js", - "name": "./storybook-stories.js", - "reasons": [ - {"moduleName": "./packages/dev/storybook-builder-parcel/generated-entries/preview-main.js"} - ] - }, - { - "id": "./packages/foo/chromatic/Button.stories.tsx", - "name": "./packages/foo/chromatic/Button.stories.tsx", - "reasons": [{"moduleName": "./storybook-stories.js"}] - }, - { - "id": "./packages/foo/src/Button.tsx", - "name": "./packages/foo/src/Button.tsx", - "reasons": [{"moduleName": "./packages/foo/chromatic/Button.stories.tsx"}] - } - ] -} -``` - -A diff that touches `packages/foo/src/Button.tsx` walks: `Button.tsx` → `Button.stories.tsx` → `./storybook-stories.js` (CSF glob match) → Button stories marked affected. - -## Error handling - -### Failure taxonomy - -| Layer | Failure mode | Response | -|---|---|---| -| 1. Reporter setup | Package missing/broken at resolve time | Parcel's reporter loader throws → build fails. No custom handling. | -| 2. Graph walk | `getBundles()` returns empty, malformed Parcel internals | Reporter throws; Parcel surfaces as build error. | -| 3. Data validation | `statsMap` empty, no module has `./storybook-stories.js` reason | Reporter throws with actionable message (Phase C). | -| 4. File I/O | `distDir` not writable, disk full | `fs.writeFile` throws → Parcel surfaces it. | -| 5. Chromatic-side bail | Lockfile diff + no node_modules in stats, change under `.storybook/`, change under `staticDir` | **Not our failure** — documented, not caught. | - -### Fail-loudly stance - -For layers 2–4, the reporter throws and Parcel propagates. The user sees the failure inline in `yarn chromatic` output before chromatic-cli ever tries to read the file. No silent fallback to "snapshot everything." Decided per user preference. - -### Logging - -Parcel passes a `logger` to `report()`. Three log lines per build: - -```ts -logger.verbose({message: `Building stats from ${bundleCount} bundles, ${assetCount} assets`}); -// ... graph walk ... -logger.verbose({message: `Stats map has ${statsMap.size} modules`}); -// ... validation, write ... -logger.info({message: `Wrote preview-stats.json (${statsMap.size} modules) to ${distDir}`}); -``` - -Verbose lines render only with `parcel --log-level verbose`. The single `info` line is the normal-build footprint. - -### Anti-patterns avoided - -- **No try/catch wrapping the graph walk.** Exceptions from Parcel APIs propagate untouched. Masking them as warnings reproduces the exact failure mode TurboSnap users report. -- **No retry on file write.** Disk problems are disk problems. -- **No partial-output fallback.** Empty `modules` is a bug; partial output would be silently wrong. -- **No defensive option-flag checking.** Builder reads `options.statsJson` once at construction; not a moving target. - -### Documented bail conditions - -A comment block at the top of `StatsReporter.ts` enumerates out-of-scope bails — so a future maintainer hitting "TurboSnap ran but no snapshots saved" knows where to look: - -```ts -// 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. -``` - -### Filter scope - -`isUserCode` mirrors builder-vite's filter set exactly: `@parcel/runtime-*` (instead of `vite/`), `\0`-prefixed non-storybook synthetics, `react/jsx-runtime`. Known dead-zone: changes to `react/jsx-runtime` itself won't propagate. Tradeoff accepted because including it would put every JSX-using module's reasons on it, inflating graph traversal cost. - -## Testing - -### Layer 1: pure-function unit tests - -``` -StatsReporter.test.ts -├── normalize() -│ ├── absolute path → './rel' (POSIX, query-stripped) -│ ├── virtual id /storybook-stories.js → stays as '/storybook-stories.js' -│ ├── Windows backslashes → forward slashes -│ └── path with ?query → query stripped -├── isUserCode() -│ ├── './packages/foo/Button.tsx' → true -│ ├── './node_modules/react/index.js' → true (lockfile-bail prevention) -│ ├── './node_modules/react/jsx-runtime.js' → false -│ ├── '@parcel/runtime-js' → false -│ └── '\0synthetic' → false -├── stripQueryParams() — single-line, trivial -└── rewriteStoryVirtuals() - ├── single stories.js virtual collapses to './storybook-stories.js' - ├── multiple stories.js virtuals from different glob entries collapse to one - └── reasons pointing at the old virtual name get rewritten -``` - -Runs in the repo's existing Jest setup. - -### Layer 2: mock-BundleGraph algorithm test - -A small mock satisfying the four `BundleGraph` methods (`getBundles`, `traverseAssets`, `getDependencies`, `getResolvedAsset`) — under 30 lines — drives `buildStatsMap` through a hand-crafted graph: - -```ts -const mockGraph = makeMockGraph({ - assets: ['./preview-main.js', './stories.js', './Button.stories.tsx', './Button.tsx', './Button.css'], - edges: [ - ['./preview-main.js', './stories.js'], - ['./stories.js', './Button.stories.tsx'], - ['./Button.stories.tsx', './Button.tsx'], - ['./Button.tsx', './Button.css'], - ], -}); - -const statsMap = buildStatsMap(mockGraph, '/proj'); -expect(statsMap.get('./Button.tsx').reasons).toEqual([{moduleName: './Button.stories.tsx'}]); -expect(statsMap.get('./Button.css').reasons).toEqual([{moduleName: './Button.tsx'}]); -``` - -Future Parcel major versions: write a new mock matching the new API; algorithm tests still pass. - -### Layer 3: real-Parcel fixture test - -One test that runs Parcel against a 3-file fixture project: - -``` -packages/dev/parcel-reporter-turbosnap-stats/__fixtures__/ -├── preview.js (imports stories.js) -├── Button.stories.tsx (imports Button.tsx) -└── Button.tsx -``` - -Drives `new Parcel(...)` with our reporter registered, snapshots the resulting `preview-stats.json`. Catches the case where Parcel's `BundleGraph` API ships a subtle change that breaks our walk. Slow (~5s), excluded from `yarn test:watch`. - -### Validation tests - -```ts -test('throws when modules array is empty', () => { - expect(() => writeStats(distDir, new Map())).toThrow(/empty modules array/); -}); - -test('throws when no CSF-glob anchor is present', () => { - const map = new Map([['./Foo.tsx', {id: './Foo.tsx', name: './Foo.tsx', reasons: []}]]); - expect(() => writeStats(distDir, map)).toThrow(/no module references \.\/storybook-stories\.js/); -}); -``` - -### Out of scope - -- Diff against builder-vite's actual output. Useful one-time during implementation verification, but not as recurring CI — their format can drift independently. -- TurboSnap reuse percentage. Depends on chromatic-cli logic, current git state, cloud-side computation. Not unit-testable. -- Bail conditions in chromatic-cli. Documented in code, not tested — testing chromatic-cli is them-testing-them. - -### Manual verification before merge (one-time) - -1. Build a tiny Vite-Storybook with `vite-plugin-turbosnap`, capture its `preview-stats.json`. -2. Build our Parcel-Storybook with the new reporter, capture its `preview-stats.json`. -3. Confirm shapes are equivalent (same field set, path conventions, CSF-glob anchor). -4. Run `yarn chromatic` end-to-end on a branch with a single-file change. Confirm TurboSnap reports non-zero reuse percentage in Chromatic UI. - -## Open questions and unknowns - -- **Parcel's `bundle.traverseAssets` ordering.** Whether visit order is stable across Parcel versions. Doesn't affect correctness (algorithm is commutative — reasons get accumulated regardless of order) but may affect snapshot test stability. If unstable: sort `statsMap` entries by `name` before write. -- **Multiple Parcel `targets`.** The current builder defines a single `storybook` target. If a future config adds another target, `getBundles()` may include bundles for both. Probably benign (we'd just emit more modules) but worth verifying during implementation. -- **`asset.filePath` for fully-synthetic assets.** `parcel-resolver-storybook` sets `filePath` to a path that doesn't exist on disk. Other resolvers (e.g., the globals resolver at `StorybookResolver.ts:13-17`) do the same with `__dirname + '/globals.js'`. These should pass through `normalize` cleanly but worth empirically confirming. -- **Logger interface stability.** Parcel reporter `logger` shape has been stable but undocumented. Spot-check against the installed Parcel version during implementation. - -## References - -- Builder-vite stats plugin (template): `.turbosnap-research/storybook/code/builders/builder-vite/src/plugins/webpack-stats-plugin.ts` -- Builder-vite registration: `.turbosnap-research/storybook/code/builders/builder-vite/src/vite-config.ts:96` -- chromatic-cli stats consumption: `chromaui/chromatic-cli` v11.20.0 `node-src/lib/turbosnap/getDependentStoryFiles.ts`, `node-src/types.ts:243-251` -- Parcel reporter API: `@parcel/plugin` `Reporter` class -- Existing repo conventions: `packages/dev/parcel-resolver-storybook/` (peer Parcel plugin) -- Storybook builder API contract: Storybook 10 builders documentation (`https://storybook.js.org/docs/builders/builder-api`) diff --git a/packages/dev/parcel-reporter-turbosnap-stats/__tests__/helpers.test.ts b/packages/dev/parcel-reporter-turbosnap-stats/__tests__/helpers.test.ts index d749cc7c3c5..a03fedfae6c 100644 --- a/packages/dev/parcel-reporter-turbosnap-stats/__tests__/helpers.test.ts +++ b/packages/dev/parcel-reporter-turbosnap-stats/__tests__/helpers.test.ts @@ -150,6 +150,20 @@ describe('buildStatsMap', () => { 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([]); + }); }); describe('rewriteStoryVirtuals', () => { diff --git a/packages/dev/parcel-reporter-turbosnap-stats/__tests__/integration.test.ts b/packages/dev/parcel-reporter-turbosnap-stats/__tests__/integration.test.ts index dcf294ff62d..4e50582b9fe 100644 --- a/packages/dev/parcel-reporter-turbosnap-stats/__tests__/integration.test.ts +++ b/packages/dev/parcel-reporter-turbosnap-stats/__tests__/integration.test.ts @@ -54,8 +54,8 @@ describe('integration: real Parcel build emits preview-stats.json', () => { const storyFile = stats.modules.find( (m: any) => - /\.stories\./.test(m.name) - && m.reasons.some((r: any) => r.moduleName === './parcel-csf-glob.js') + /\.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 index ec3fcb9992f..116c2e7801b 100644 --- a/packages/dev/parcel-reporter-turbosnap-stats/helpers.ts +++ b/packages/dev/parcel-reporter-turbosnap-stats/helpers.ts @@ -118,6 +118,13 @@ export function buildStatsMap( 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}); From f3419e8e1b938569faaf3875f65e505fa50565c6 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Thu, 28 May 2026 14:38:02 +1000 Subject: [PATCH 11/13] fix test --- .../__tests__/integration.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/dev/parcel-reporter-turbosnap-stats/__tests__/integration.test.ts b/packages/dev/parcel-reporter-turbosnap-stats/__tests__/integration.test.ts index 4e50582b9fe..ccd32ecccbb 100644 --- a/packages/dev/parcel-reporter-turbosnap-stats/__tests__/integration.test.ts +++ b/packages/dev/parcel-reporter-turbosnap-stats/__tests__/integration.test.ts @@ -23,6 +23,11 @@ describe('integration: real Parcel build emits preview-stats.json', () => { 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: [ { packageName: '@parcel/reporter-turbosnap-stats', From 0f416250047a19e61f287b94da90f7bc438aad8b Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Fri, 29 May 2026 10:07:29 +1000 Subject: [PATCH 12/13] change file writing and add externals --- package.json | 4 ++-- .../StatsReporter.ts | 2 +- .../__tests__/helpers.test.ts | 13 ++++++++++--- .../__tests__/integration.test.ts | 8 +++++++- .../dev/parcel-reporter-turbosnap-stats/helpers.ts | 6 +++--- 5 files changed, 23 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index 8bb0ece60c7..5b14ad7467b 100644 --- a/package.json +++ b/package.json @@ -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' --only-changed --trace-changed", - "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", + "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", diff --git a/packages/dev/parcel-reporter-turbosnap-stats/StatsReporter.ts b/packages/dev/parcel-reporter-turbosnap-stats/StatsReporter.ts index 139770b0cf9..8efb6d57376 100644 --- a/packages/dev/parcel-reporter-turbosnap-stats/StatsReporter.ts +++ b/packages/dev/parcel-reporter-turbosnap-stats/StatsReporter.ts @@ -16,7 +16,7 @@ const reporter = new Reporter({ 'parcel-reporter-turbosnap-stats: no bundles were produced; cannot determine output dir.' ); } - await writeStats(distDir, statsMap, logger); + await writeStats(distDir, statsMap, options.outputFS, logger); } }); diff --git a/packages/dev/parcel-reporter-turbosnap-stats/__tests__/helpers.test.ts b/packages/dev/parcel-reporter-turbosnap-stats/__tests__/helpers.test.ts index a03fedfae6c..4cd3ec21215 100644 --- a/packages/dev/parcel-reporter-turbosnap-stats/__tests__/helpers.test.ts +++ b/packages/dev/parcel-reporter-turbosnap-stats/__tests__/helpers.test.ts @@ -220,11 +220,18 @@ describe('rewriteStoryVirtuals', () => { }); 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(), silentLogger)).rejects.toThrow(/empty modules array/); + await expect(writeStats(tmp, new Map(), nodeFS, silentLogger)).rejects.toThrow( + /empty modules array/ + ); }); test('throws when no module references ./storybook-stories.js', async () => { @@ -232,7 +239,7 @@ describe('writeStats — validation', () => { const m = new Map([ ['./Foo.tsx', {id: './Foo.tsx', name: './Foo.tsx', reasons: []}] ]); - await expect(writeStats(tmp, m, silentLogger)).rejects.toThrow( + await expect(writeStats(tmp, m, nodeFS, silentLogger)).rejects.toThrow( /no module references \.\/storybook-stories\.js/ ); }); @@ -266,7 +273,7 @@ describe('writeStats — happy path', () => { infoLog = m.message; } }; - await writeStats(tmp, m, logger); + 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']); diff --git a/packages/dev/parcel-reporter-turbosnap-stats/__tests__/integration.test.ts b/packages/dev/parcel-reporter-turbosnap-stats/__tests__/integration.test.ts index ccd32ecccbb..7015869fb06 100644 --- a/packages/dev/parcel-reporter-turbosnap-stats/__tests__/integration.test.ts +++ b/packages/dev/parcel-reporter-turbosnap-stats/__tests__/integration.test.ts @@ -30,7 +30,13 @@ describe('integration: real Parcel build emits preview-stats.json', () => { shouldDisableCache: true, additionalReporters: [ { - packageName: '@parcel/reporter-turbosnap-stats', + // 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 } ], diff --git a/packages/dev/parcel-reporter-turbosnap-stats/helpers.ts b/packages/dev/parcel-reporter-turbosnap-stats/helpers.ts index 116c2e7801b..c23d32b8478 100644 --- a/packages/dev/parcel-reporter-turbosnap-stats/helpers.ts +++ b/packages/dev/parcel-reporter-turbosnap-stats/helpers.ts @@ -1,8 +1,7 @@ // 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} from '@parcel/types'; -import fs from 'fs'; +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: @@ -188,6 +187,7 @@ interface Logger { 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 @@ -212,7 +212,7 @@ export async function writeStats( ); } - await fs.promises.writeFile(path.join(distDir, 'preview-stats.json'), JSON.stringify(stats)); + 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}` }); From b7b31d4eb5de987cf87f61c2f6c3a7ddc346b33c Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Fri, 29 May 2026 18:17:33 +1000 Subject: [PATCH 13/13] update from code review --- .../__tests__/__fixtures__/preview.js | 4 +- .../generated-entries/stories.js | 7 ++ .../__tests__/helpers.test.ts | 102 ++++++++++-------- .../__tests__/integration.test.ts | 11 +- .../helpers.ts | 56 +++++----- 5 files changed, 102 insertions(+), 78 deletions(-) create mode 100644 packages/dev/parcel-reporter-turbosnap-stats/__tests__/__fixtures__/storybook-builder-parcel/generated-entries/stories.js diff --git a/packages/dev/parcel-reporter-turbosnap-stats/__tests__/__fixtures__/preview.js b/packages/dev/parcel-reporter-turbosnap-stats/__tests__/__fixtures__/preview.js index 5dc8862db00..e98ee50523f 100644 --- a/packages/dev/parcel-reporter-turbosnap-stats/__tests__/__fixtures__/preview.js +++ b/packages/dev/parcel-reporter-turbosnap-stats/__tests__/__fixtures__/preview.js @@ -1,2 +1,2 @@ -import {Button} from './Button.stories.tsx'; -console.log(Button); +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 index 4cd3ec21215..aca7d415387 100644 --- a/packages/dev/parcel-reporter-turbosnap-stats/__tests__/helpers.test.ts +++ b/packages/dev/parcel-reporter-turbosnap-stats/__tests__/helpers.test.ts @@ -38,9 +38,6 @@ describe('normalize', () => { /^\.\/src\/nested\/Button\.tsx$/ ); }); - test('\\0-prefixed synthetic id gets virtual: leading-slash form', () => { - expect(normalize('\0synthetic/foo.js', root)).toBe('/virtual:/synthetic/foo.js'); - }); }); describe('isUserCode', () => { @@ -59,27 +56,32 @@ describe('isUserCode', () => { test('normalized node_modules @parcel/runtime-* → false', () => { expect(isUserCode('./node_modules/@parcel/runtime-js/lib/runtime-abc.js')).toBe(false); }); - test('\\0-prefixed synthetic ids → false', () => { - expect(isUserCode('\0synthetic')).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 four BundleGraph methods buildStatsMap uses. +// 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][]}) { +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) { + 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: () => [ @@ -90,7 +92,14 @@ function makeMockGraph(opts: {assets: string[]; edges: [string, string][]}) { } ], getDependencies: (asset: {filePath: string}) => depsBySource.get(asset.filePath) ?? [], - getResolvedAsset: (dep: {target: string}, _bundle: unknown) => assetById.get(dep.target) ?? null + 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; } @@ -164,6 +173,17 @@ describe('buildStatsMap', () => { 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', () => { @@ -291,73 +311,67 @@ describe('writeStats — happy path', () => { describe('addStoryEntries', () => { const CSF_GLOB = './parcel-csf-glob.js'; + const CANONICAL = './storybook-stories.js'; - test('tags .stories.tsx files with the synthetic CSF-glob entry', () => { + test('rewrites the storybook-stories.js reason on a story file to the synthetic glob', () => { const m = new Map([ [ - './packages/foo/Accordion.stories.tsx', + './Foo.stories.tsx', { - id: './packages/foo/Accordion.stories.tsx', - name: './packages/foo/Accordion.stories.tsx', - reasons: [] + id: './Foo.stories.tsx', + name: './Foo.stories.tsx', + reasons: [{moduleName: CANONICAL}] } ] ]); addStoryEntries(m); - expect(m.get('./packages/foo/Accordion.stories.tsx')!.reasons).toEqual([ - {moduleName: CSF_GLOB} - ]); + 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: []}] + [ + './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: './storybook-stories.js'}] + reasons: [{moduleName: CANONICAL}] }); }); - test('does not add a duplicate reason if already present', () => { + 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: CSF_GLOB}] + reasons: [{moduleName: CANONICAL}, {moduleName: './docs.mdx'}] } ] ]); addStoryEntries(m); - expect(m.get('./Foo.stories.tsx')!.reasons).toEqual([{moduleName: CSF_GLOB}]); - }); - - test('matches .stories.{js,jsx,mjs,ts,tsx} extensions', () => { - const names = [ - './a.stories.js', - './b.stories.jsx', - './c.stories.mjs', - './d.stories.ts', - './e.stories.tsx' - ]; - const m = new Map(names.map(n => [n, {id: n, name: n, reasons: []}])); - addStoryEntries(m); - for (const n of names) { - expect(m.get(n)!.reasons).toEqual([{moduleName: CSF_GLOB}]); - } + expect(m.get('./Foo.stories.tsx')!.reasons).toEqual([ + {moduleName: CSF_GLOB}, + {moduleName: './docs.mdx'} + ]); }); - test('does not touch non-story files', () => { + 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: []}], - ['./not-a-story.txt', {id: './not-a-story.txt', name: './not-a-story.txt', reasons: []}] + ['./Button.tsx', {id: './Button.tsx', name: './Button.tsx', reasons: []}] ]); - addStoryEntries(m); + const tagged = addStoryEntries(m); + expect(tagged).toBe(0); expect(m.get('./Button.tsx')!.reasons).toEqual([]); - expect(m.get('./not-a-story.txt')!.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 index 7015869fb06..518cbd50af3 100644 --- a/packages/dev/parcel-reporter-turbosnap-stats/__tests__/integration.test.ts +++ b/packages/dev/parcel-reporter-turbosnap-stats/__tests__/integration.test.ts @@ -48,12 +48,15 @@ describe('integration: real Parcel build emits preview-stats.json', () => { } }); - // The fixture has no storybook-resolver stories.js virtual, but it does include - // Button.stories.tsx. addStoryEntries bridges the gap by inserting a synthetic - // CSF-glob node and tagging .stories.* assets with it. Verify: + // 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 - // This is the three-level chain chromatic-cli's traversal expects. await parcel.run(); const statsPath = path.join(distDir, 'preview-stats.json'); expect(fs.existsSync(statsPath)).toBe(true); diff --git a/packages/dev/parcel-reporter-turbosnap-stats/helpers.ts b/packages/dev/parcel-reporter-turbosnap-stats/helpers.ts index c23d32b8478..ddb9d7010c0 100644 --- a/packages/dev/parcel-reporter-turbosnap-stats/helpers.ts +++ b/packages/dev/parcel-reporter-turbosnap-stats/helpers.ts @@ -27,21 +27,12 @@ export function stripQueryParams(id: string): string { return idx === -1 ? id : id.slice(0, idx); } -const VIRTUAL_PREFIX = '\0'; - export function normalize(filePath: string, projectRoot: string): string { const stripped = stripQueryParams(filePath); - if (stripped.startsWith(VIRTUAL_PREFIX)) { - // chromatic-cli's getDependentStoryFiles normalizePath short-circuits paths - // starting with /virtual: — see chromatic-cli node-src/lib/turbosnap/getDependentStoryFiles.ts - // line ~53. builder-vite's webpack-stats-plugin.ts has the same trick. - return '/virtual:' + stripped.slice(VIRTUAL_PREFIX.length).replace(/^\/?/, '/'); - } // 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, '/'); - if (rel.startsWith('virtual:')) return '/' + rel; return './' + rel; } @@ -52,7 +43,6 @@ export function normalize(filePath: string, projectRoot: string): string { const FILTER_PATTERNS: RegExp[] = [/@parcel\/runtime-/, /\/react\/jsx-runtime\.js$/]; export function isUserCode(name: string): boolean { - if (name.startsWith(VIRTUAL_PREFIX)) return false; for (const re of FILTER_PATTERNS) { if (re.test(name)) return false; } @@ -113,7 +103,19 @@ export function buildStatsMap( ensure(assetName); for (const dep of bundleGraph.getDependencies(asset)) { - const target = bundleGraph.getResolvedAsset(dep, bundle); + // 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; @@ -134,18 +136,9 @@ export function buildStatsMap( return statsMap; } -const STORY_FILE_RE = /\.stories\.(js|jsx|mjs|ts|tsx)$/; const CSF_GLOB_ENTRY = './parcel-csf-glob.js'; -// Parcel's dynamic-import-based code splitting routes `() => import('./Foo.stories.tsx')` -// through @parcel/runtime-js wrappers, so the synthetic stories.js's resolved deps -// land on runtime chunks rather than the story files themselves. The story files -// then end up in their own bundles with no edge pointing back at './storybook-stories.js', -// which means chromatic-cli's TurboSnap can't find them via the CSF-glob walk. -// -// This helper bridges the gap by inserting a synthetic CSF-glob node between -// './storybook-stories.js' and the actual story files. The three-level chain -// chromatic-cli's getDependentStoryFiles expects is: +// chromatic-cli's getDependentStoryFiles expects this three-level chain: // // ./storybook-stories.js ← (CSF entry, imported by preview-main.js) // ↓ imports @@ -153,20 +146,27 @@ const CSF_GLOB_ENTRY = './parcel-csf-glob.js'; // ↓ 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:174-181), causing traceName to bail -// at the story file (line 287) and source files (not story files) to end up +// 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 (!STORY_FILE_RE.test(entry.name)) continue; - if (entry.reasons.every(r => r.moduleName !== CSF_GLOB_ENTRY)) { - entry.reasons.push({moduleName: CSF_GLOB_ENTRY}); - tagged++; + 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++; } - // Insert the synthetic CSF-glob node itself with CANONICAL_CSF_GLOB as its only reason. if (tagged > 0 && !statsMap.has(CSF_GLOB_ENTRY)) { statsMap.set(CSF_GLOB_ENTRY, { id: CSF_GLOB_ENTRY,