diff --git a/.agents/skills/handle-pr-comments/SKILL.md b/.agents/skills/handle-pr-comments/SKILL.md new file mode 100644 index 000000000000..9df27fa7e6d0 --- /dev/null +++ b/.agents/skills/handle-pr-comments/SKILL.md @@ -0,0 +1,22 @@ +--- +name: handle-pr-comments +description: Triage and resolve GitHub PR review comments one by one, interactively. Use when the user asks to handle, address, respond to, or resolve PR review comments, or mentions reviewer feedback on a pull request. +--- + +# Handle PR Comments + +Evaluate review comments on the PR (from the current branch, or a PR number/URL if given). For each unresolved comment: summarize the feedback, suggest a resolution, ask the user how to resolve it, apply their choice, commit if needed, and resolve the thread. + +1. **Find the PR.** Use the given number/URL, else derive it from the current branch (`gh pr view`). Stop if no PR exists. + +2. **Fetch unresolved review threads** via the GraphQL `reviewThreads` field on the pull request — request each thread's `id`, `isResolved`, `isOutdated`, `path`, `line`, and comments. Filter to `isResolved == false`. (Use GraphQL, not REST: only it exposes thread `id`s and resolution state.) + +3. **Loop one comment at a time.** For each: (a) summarize the feedback + file/line, reading surrounding code; (b) suggest a concrete fix; (c) ask via AskQuestion with options — apply suggested fix / apply a different fix / reply only / skip; (d) apply the choice; (e) if applicable, summarize what was changed and ask whether to commit; (f) resolve the thread via the GraphQL `resolveReviewThread` mutation. + +4. **Report** an overview of what was changed, committed, replied to, skipped, and resolved. Include links. + +## Notes + +- Handle real-user comments before addressing AI agents' (Copilot, CodeRabbit). +- Group duplicate comments; when the same/similar issue is mentioned multiple times, handle them all at once. +- Make a separate commit for each change. \ No newline at end of file diff --git a/.agents/skills/open-pr/SKILL.md b/.agents/skills/open-pr/SKILL.md new file mode 100644 index 000000000000..8cf1abafbc70 --- /dev/null +++ b/.agents/skills/open-pr/SKILL.md @@ -0,0 +1,77 @@ +--- +name: open-pr +description: Opens a pull request from the current branch using the PR template. Use when the user asks to open a PR, create a pull request, or invokes /open-pr. +allowed-tools: Bash, Read, AskQuestion +--- + +# Open Pull Request + +Opens a draft PR from the current branch following Storybook conventions. + +## Workflow + +### 1. Gather context + +Run in parallel: `git status`, `git diff`, `git log --oneline ...HEAD` (after step 2), `git branch -vv`. + +Push first if needed: `git push -u origin HEAD`. + +### 2. Detect base branch + +```bash +git fetch origin +bash .agents/skills/open-pr/scripts/detect-base-branch.sh +``` + +Detects base in order: tracked upstream → reflog checkout source → closest `origin/*` ancestor (fewest commits from branch tip to HEAD). Supports telescoped/stacked PRs off feature branches, not only `next`/`main`. Skips branches at the same commit as HEAD. Tie-break: feature branch over trunk, then `next` over `main`. Falls back to `next`. Tell the user the result. + +### 3. Ask for labels + +Use **AskQuestion** for three questions. Options from `.github/PULL_REQUEST_TEMPLATE.md`: + +| Question | Options | +| -------- | ------- | +| CI label | `ci:normal`, `ci:merged`, `ci:daily` | +| QA label | `qa:needed`, `qa:skip` | +| Type label | `bug`, `maintenance`, `dependencies`, `build`, `cleanup`, `documentation`, `feature request`, `BREAKING CHANGE`, `other` | + +Verify the available labels with the PR template. + +### 4. Draft title and body + +**Title:** `[Area]: [Description]` — see the `pr` skill for format and examples. + +**Body:** Read `.github/PULL_REQUEST_TEMPLATE.md`. Copy it **exactly** (keep all HTML comments). Fill in: + +- `Closes #` when an issue is linked +- **What I did** +- Testing checkboxes and mandatory manual-testing steps (or state why none is needed) +- Documentation checkboxes when applicable +- Leave maintainer checklist items unchecked + +### 5. Create the PR + +```bash +gh pr create \ + --draft \ + --base "" \ + --title ": " \ + --body "$(cat <<'EOF' + +EOF +)" \ + --assignee @me \ + --label ",," +``` + +### 6. Report and offer canary + +Share the PR URL. Then **AskQuestion**: "Do you want to create a canary release for this PR?" + +- **Yes** — run `/canary ` and report workflow status +- **No** — stop + +## Notes + +- Always draft; always assign `@me`. +- For canary details after publishing, see the `canary` skill. diff --git a/.agents/skills/open-pr/scripts/detect-base-branch.sh b/.agents/skills/open-pr/scripts/detect-base-branch.sh new file mode 100755 index 000000000000..b77a92c982f9 --- /dev/null +++ b/.agents/skills/open-pr/scripts/detect-base-branch.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +# Pick the remote branch the current branch most likely forked from. +# Supports telescoped/stacked PRs by scanning all origin/* ancestors. +set -euo pipefail + +current_branch=$(git branch --show-current) +head_tip=$(git rev-parse HEAD) +best="" +min_commits=999999 +best_is_feature=0 + +is_trunk() { + case "$1" in + main | next) return 0 ;; + *) return 1 ;; + esac +} + +normalize_branch_name() { + local name=$1 + name=${name#origin/} + name=${name#remotes/origin/} + echo "${name}" +} + +branch_exists_on_origin() { + git rev-parse --verify "origin/$1" >/dev/null 2>&1 +} + +is_valid_base() { + local branch=$1 + local ref="origin/${branch}" + + [ "${branch}" != "${current_branch}" ] || return 1 + branch_exists_on_origin "${branch}" || return 1 + + local branch_tip + branch_tip=$(git rev-parse "${ref}") + [ "${branch_tip}" != "${head_tip}" ] || return 1 + git merge-base --is-ancestor "${ref}" HEAD 2>/dev/null +} + +commit_count_since() { + git rev-list --count "origin/$1..HEAD" +} + +consider() { + local branch=$1 + local commit_count=$2 + + if ! is_valid_base "${branch}"; then + return + fi + + local is_feature=0 + is_trunk "${branch}" || is_feature=1 + + if [ -z "${best}" ]; then + best=${branch} + min_commits=${commit_count} + best_is_feature=${is_feature} + return + fi + + if [ "${commit_count}" -lt "${min_commits}" ]; then + best=${branch} + min_commits=${commit_count} + best_is_feature=${is_feature} + return + fi + + if [ "${commit_count}" -gt "${min_commits}" ]; then + return + fi + + # Tie: prefer telescoped parent over trunk, then next over main. + if [ "${best_is_feature}" -eq 0 ] && [ "${is_feature}" -eq 1 ]; then + best=${branch} + best_is_feature=${is_feature} + return + fi + + if [ "${best_is_feature}" -eq 1 ] && [ "${is_feature}" -eq 0 ]; then + return + fi + + if [ "${branch}" = "next" ]; then + best=${branch} + best_is_feature=${is_feature} + fi +} + +try_explicit_base() { + local branch=$1 + branch=$(normalize_branch_name "${branch}") + consider "${branch}" "$(commit_count_since "${branch}")" +} + +# 1. Tracked upstream (set when branching with -u or --track) +if upstream=$(git rev-parse --abbrev-ref '@{upstream}' 2>/dev/null); then + try_explicit_base "${upstream}" +fi + +# 2. Reflog: most recent branch we checked out from +if [ -z "${best}" ]; then + while IFS= read -r line; do + if [[ ${line} =~ checkout:\ moving\ from\ (.+)\ to\ ${current_branch} ]]; then + try_explicit_base "${BASH_REMATCH[1]}" + break + fi + if [[ ${line} =~ branch:\ Created\ from\ (.+) ]]; then + try_explicit_base "${BASH_REMATCH[1]}" + break + fi + done < <(git reflog show --format='%gs' "${current_branch}" 2>/dev/null || true) +fi + +# 3. Scan all origin/* branches — closest strict ancestor wins +while IFS= read -r ref; do + branch=${ref#origin/} + + if [ "${branch}" = "HEAD" ] || [ "${branch}" = "${current_branch}" ]; then + continue + fi + + if ! is_valid_base "${branch}"; then + continue + fi + + consider "${branch}" "$(commit_count_since "${branch}")" +done < <(git for-each-ref --format='%(refname:short)' refs/remotes/origin/) + +if [ -z "${best}" ]; then + best="next" +fi + +echo "${best}" diff --git a/.agents/skills/pr/SKILL.md b/.agents/skills/pr/SKILL.md index ba97bec26cd9..b5795b21b9d2 100644 --- a/.agents/skills/pr/SKILL.md +++ b/.agents/skills/pr/SKILL.md @@ -41,16 +41,54 @@ Add these labels to the PR: - `ci:daily` - daily sandbox set; use this when changes affect prerelease sandboxes or sandboxes pinned to a framework or React version other than latest - `ci:docs` - documentation-only changes (use with `documentation` category) +**QA (required, pick one):** + +Tells the release team whether manual QA is needed before the next minor release. + +- `qa:needed` — a human must manually verify this PR at release time +- `qa:skip` — no per-PR manual QA needed at release time + +Heuristics: + +- User explicitly asked for manual QA before release → `qa:needed` +- Part of a larger project that gets holistic QA (not per-PR) → `qa:skip` +- Touches paths, filesystem, or anything that could break on Windows → `qa:needed` +- Complex change spanning multiple areas that must work together → `qa:needed` +- Small change in central/shared code with high side-effect risk (e.g. layout CSS in shared UI) → `qa:needed` +- Simple, straightforward change → `qa:skip` +- Unsure → ask the user whether manual QA before minor release is necessary + ## PR body Read `.github/PULL_REQUEST_TEMPLATE.md` from the repository root. Copy that template **EXACTLY**, including all HTML comments (``). Fill in the relevant sections based on the changes, but keep all comments intact. +### Manual testing (required) + +The **Manual testing** section is mandatory — never leave it empty. Write steps for a separate maintainer, not a log of how you tested. + +Each step should be: + +- Clear and easy to follow +- Copy-pasteable shell commands where applicable +- Explicit about what behavior to inspect (expected outcome, not just "check it works") +- Linked to specific stories for UI changes, or to build artifacts when relevant +- Lists areas most likely to regress and worth extra scrutiny + +**Verify your own steps first** — run through them locally before opening the PR. + +When useful, link to published Chromatic Storybooks (CI must finish first; links won't work immediately after opening the PR): + +- Internal UI: `https://--635781f3500dd2c49e189caf.chromatic.com/?path=/story/` +- React Vite TS: `https://--630511d655df72125520f051.chromatic.com/?path=/story/` + +Replace `` with Chromatic's normalized slug (special chars → dashes, e.g. `feature/foo` → `feature-foo`) and `` with the story path (e.g. `example-button--primary`). + ## Command Always create PRs in draft mode: ```bash -gh pr create --draft --title ": " --body "" --label "," +gh pr create --draft --title ": " --body "" --label ",," ``` diff --git a/.agents/skills/rebuild-restart-storybook/SKILL.md b/.agents/skills/rebuild-restart-storybook/SKILL.md new file mode 100644 index 000000000000..1679cd7ad7f1 --- /dev/null +++ b/.agents/skills/rebuild-restart-storybook/SKILL.md @@ -0,0 +1,49 @@ +--- +name: rebuild-restart-storybook +description: Rebuild and restart the internal Storybook UI after changes to internal Storybook code (core, addons, frameworks, renderers, libs, etc.), then optionally display a UI review. Use after editing any package in the Storybook monorepo's code/ directory, or when the user asks to rebuild and/or restart Storybook. +allowed-tools: Bash, Read +--- + +# Rebuild and Restart Storybook + +After making changes to internal Storybook code (core, addons or otherwise), follow this workflow. + +## Step 1: Ask whether to rebuild and restart (in case of model invocation) + +Unless the user explicitly ran `/rebuild-restart-storybook`, ask the user whether they want to rebuild and restart Storybook. If they decline, stop here. + +## Step 2: Determine modified packages + +Build a space-separated list of the monorepo packages that were modified in the conversation. For each modified package, find its Nx project name in the `project.json` file in that package's directory (the `name` field), not the `package.json` name. + +For example, if `code/addons/review` and `code/addons/vitest` were modified, the list is `addon-review addon-vitest`. + +## Step 3: Run the build and start commands in sequence + +Run these in the `code` directory, in order. Substitute `` with the list from Step 2. + +```bash +rm -rf node_modules/.cache +yarn +yarn build storybook +``` + +Then, run in the background: + +```bash +NODE_OPTIONS="--preserve-symlinks" yarn storybook:ui --no-open +``` + +### Handle an occupied port + +A Storybook may already be running. If the port is occupied, cancel the start, kill the old process to free the port, then start Storybook again. + +## Step 4: Provide the URL and ask about a review + +Once Storybook has started, give the user the URL. Then ask whether they want to display a review. + +## Step 5: Display the review + +If the user wants a review, use Storybook's `display-review` MCP tool to create a UI review of relevant stories. Pick a set of stories related to the entirety of the conversation so far. + +If there are no relevant stories because no UI elements were touched, ask the user what they would like to see a review for. diff --git a/.agents/skills/storybook-startup-benchmark/SKILL.md b/.agents/skills/storybook-startup-benchmark/SKILL.md new file mode 100644 index 000000000000..6cd23e6922d8 --- /dev/null +++ b/.agents/skills/storybook-startup-benchmark/SKILL.md @@ -0,0 +1,154 @@ +--- +name: storybook-startup-benchmark +description: Measure Storybook startup time from spawning `storybook dev` until the first story renders in the browser. Use when the user asks about Storybook boot time, server-ready timing, first story render timing, startup regressions, benchmarking with repeat runs, or comparing Storybook versions or feature flags. +allowed-tools: Bash, Read +--- + +# Storybook Startup Benchmark + +## Purpose + +Use this skill to build or explain a repeatable benchmark for Storybook startup: + +- `server`: process spawn -> Storybook server responds +- `browser`: server responds -> first story rendered +- `total`: process spawn -> first story rendered + +## Quick Start + +When asked to benchmark Storybook startup: + +1. Confirm the boundary: + - Start timing immediately before spawning `storybook dev` + - Open Storybook at `/`, not `iframe.html` + - Stop timing on first story mount plus one `requestAnimationFrame()` +2. Check whether the repo already has a benchmark script. Reuse it if possible. +3. If not, create a Node script that: + - starts Storybook with `--no-open` + - waits for the server to respond + - launches a browser it controls + - waits for a preview-side render signal + - prints JSON results +4. Add `--repeat ` support with grouped summary stats. + +## Measurement Rules + +Use these defaults unless the user asks otherwise: + +- Start URL: `/` +- Browser path: normal manager page, let Storybook load the preview iframe itself +- Render signal: first preview story mount + one `requestAnimationFrame()` +- Browser launch: external harness launches the browser, not Storybook +- Auto-open: disable Storybook browser auto-open with `storybook dev --no-open` + +Do not measure only CLI output. Server listening is not the same as first story rendered. + +## Recommended Implementation + +### 1. Preview-side signal + +Add a small global preview decorator or component that runs once on the first story mount: + +- wait one `requestAnimationFrame()` +- set a global value like `window.__sbStartupBenchmark` +- also mirror that value to `window.top` when same-origin +- optionally call `performance.mark('sb:first-story-rendered')` + +Preferred payload: + +```ts +{ + firstStoryRenderedAt: performance.now(), + storyId: id +} +``` + +### 2. Harness behavior + +The benchmark script should: + +1. Fail fast if the target Storybook port is already in use +2. Spawn Storybook with `--no-open` +3. Start timing immediately before spawn +4. Wait for HTTP readiness on the Storybook URL +5. Launch a controlled browser to `/` +6. Wait for the preview-side signal +7. Print JSON results +8. Kill the full spawned process group during cleanup + +### 3. Repeated runs + +For `--repeat N`, print: + +- per-run results +- summary stats grouped by `server`, `browser`, and `total` +- human-readable durations like `5.2s` or `2m15s`, not raw millisecond field names + +Recommended summary fields: + +```json +{ + "server": { + "average": "5.2s", + "min": "4.8s", + "max": "6.1s", + "p95": "6.0s" + }, + "browser": { + "average": "1.9s", + "min": "1.6s", + "max": "2.4s", + "p95": "2.3s" + }, + "total": { + "average": "7.1s", + "min": "6.6s", + "max": "8.2s", + "p95": "8.1s" + } +} +``` + +## Interpretation Guidance + +Use these heuristics when explaining results: + +- If `server` grows, the regression is likely on the server/build side. +- If `browser` grows, the regression is likely in manager boot, preview boot, or first-story render. +- If averages are close but `p95` grows a lot, the feature likely increases variability or tail latency. +- If newer Storybook without a feature is much faster than older Storybook, but enabling the feature returns timings to older levels, the feature likely erases the newer startup gains. + +## Common Pitfalls + +- Existing Storybook already running on the benchmark port +- Storybook auto-opening a separate browser window +- Measuring direct `iframe.html` loads instead of normal `/` +- Leaving child Storybook processes alive between runs +- Treating warm repeated runs as cold-start data + +If a repeated benchmark reports unrealistically low `server.average`, first check for a stale Storybook server on the same port. + +## Tooling Notes + +- Prefer a plain Node script for portability. +- For Chrome-family browsers, remote debugging is a practical control path. +- If Storybook CLI flags or behavior are in question, fetch current Storybook docs first. + +## Output Style + +When reporting results: + +- clearly separate `server`, `browser`, and `total` +- call out averages and p95 first, with min/max as supporting bounds +- highlight whether the regression affects common-case latency, tail latency, or both +- avoid claiming root cause unless the benchmark isolates server vs render behavior + +## Example Triggers + +Use this skill when the user says things like: + +- "How can I test Storybook startup time?" +- "Measure from `storybook dev` to first story render" +- "Compare Storybook startup with and without this feature flag" +- "Run 20 startup measurements and summarize averages" +- "Is this startup regression server-side or render-side?" diff --git a/.agents/skills/update-pr-description/SKILL.md b/.agents/skills/update-pr-description/SKILL.md new file mode 100644 index 000000000000..6ef85183a1cc --- /dev/null +++ b/.agents/skills/update-pr-description/SKILL.md @@ -0,0 +1,26 @@ +--- +name: update-pr-description +description: Evaluate a PR's title and description against its actual implementation, then iteratively suggest and apply updates. Use when the user asks to check, fix, or update a PR title or description. +--- + +# Update PR Description + +## Workflow + +1. **Resolve the PR.** Use the number/URL the user provided. If none, look up the PR for the current branch with `gh pr view`. +2. **Gather evidence:** + - `gh pr view --json title,body` + - `gh pr view --json commits` (commit messages) + - `gh pr diff ` (full diff against base) +3. **Evaluate divergence.** Compare the stated title/description against what the commits and diff actually do. Only flag *meaningful* divergence (wrong scope, missing major changes, stale claims, inaccurate summary). Ignore trivial wording. +4. **Report.** Tell the user whether the title and/or description meaningfully diverges. If not, stop here. +5. **Suggest iteratively.** Propose concrete updated title/description. Ask the user one change at a time whether to apply, accept edits, and refine. +6. **Apply.** Once agreed, update on the user's behalf with `gh pr edit --title ... --body ...`. + +## Notes + +- Match the repository's existing PR template/style if the body uses one. +- Don't rewrite a description that's already accurate. +- Update the state of checkboxes where appropriate. +- Remove section placeholders/reminders when filling out a section. +- Do not remove the canary release section. \ No newline at end of file diff --git a/.circleci/config.yml b/.circleci/config.yml index d2798c319911..72a40b1c00b1 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -17,6 +17,10 @@ parameters: default: '' description: The PR number type: string + ghTrustedAuthor: + default: 'false' + description: Whether the PR author is a trusted author who should be allowed to persist to shared caches + type: string workflow: default: skipped description: Which workflow to run @@ -30,7 +34,7 @@ parameters: jobs: generate-and-run-config: - executor: + executor: name: node/default resource_class: large steps: @@ -44,7 +48,9 @@ jobs: - run: name: Generate config command: | - yarn dlx jiti ./scripts/ci/main.ts --workflow=<< pipeline.parameters.workflow >> + yarn dlx jiti ./scripts/ci/main.ts \ + --workflow=<< pipeline.parameters.workflow >> \ + --gh-trusted-author=<< pipeline.parameters.ghTrustedAuthor >> - continuation/continue: configuration_path: .circleci/config.generated.yml workflows: diff --git a/.claude/skills/docs-review/SKILL.md b/.claude/skills/docs-review/SKILL.md new file mode 100644 index 000000000000..4d9467c75f91 --- /dev/null +++ b/.claude/skills/docs-review/SKILL.md @@ -0,0 +1 @@ +@../../../.agents/skills/docs-review/SKILL.md diff --git a/.claude/skills/fix-linting-types-on-pr/SKILL.md b/.claude/skills/fix-linting-types-on-pr/SKILL.md new file mode 100644 index 000000000000..58bb71fcb235 --- /dev/null +++ b/.claude/skills/fix-linting-types-on-pr/SKILL.md @@ -0,0 +1 @@ +@../../../.agents/skills/fix-linting-types-on-pr/SKILL.md diff --git a/.claude/skills/handle-pr-comments/SKILL.md b/.claude/skills/handle-pr-comments/SKILL.md new file mode 100644 index 000000000000..bf62f83d35ad --- /dev/null +++ b/.claude/skills/handle-pr-comments/SKILL.md @@ -0,0 +1 @@ +@../../../.agents/skills/handle-pr-comments/SKILL.md diff --git a/.claude/skills/minor-release/SKILL.md b/.claude/skills/minor-release/SKILL.md new file mode 100644 index 000000000000..22985b96089e --- /dev/null +++ b/.claude/skills/minor-release/SKILL.md @@ -0,0 +1 @@ +@../../../.agents/skills/minor-release/SKILL.md diff --git a/.claude/skills/open-pr/SKILL.md b/.claude/skills/open-pr/SKILL.md new file mode 100644 index 000000000000..6b15c5fb612d --- /dev/null +++ b/.claude/skills/open-pr/SKILL.md @@ -0,0 +1 @@ +@../../../.agents/skills/open-pr/SKILL.md diff --git a/.claude/skills/rebuild-restart-storybook/SKILL.md b/.claude/skills/rebuild-restart-storybook/SKILL.md new file mode 100644 index 000000000000..69cbf65ac824 --- /dev/null +++ b/.claude/skills/rebuild-restart-storybook/SKILL.md @@ -0,0 +1 @@ +@../../../.agents/skills/rebuild-restart-storybook/SKILL.md diff --git a/.claude/skills/storybook-startup-benchmark/SKILL.md b/.claude/skills/storybook-startup-benchmark/SKILL.md new file mode 100644 index 000000000000..6c2f8f01e515 --- /dev/null +++ b/.claude/skills/storybook-startup-benchmark/SKILL.md @@ -0,0 +1 @@ +@../../../.agents/skills/storybook-startup-benchmark/SKILL.md diff --git a/.claude/skills/update-pr-description/SKILL.md b/.claude/skills/update-pr-description/SKILL.md new file mode 100644 index 000000000000..dda9474e5153 --- /dev/null +++ b/.claude/skills/update-pr-description/SKILL.md @@ -0,0 +1 @@ +@../../../.agents/skills/update-pr-description/SKILL.md diff --git a/.codex/environments/environment.toml b/.codex/environments/environment.toml new file mode 100644 index 000000000000..94d072a22f2d --- /dev/null +++ b/.codex/environments/environment.toml @@ -0,0 +1,30 @@ +version = 1 +name = "storybook" + +[setup] +script = "yarn install && yarn nx run-many -t compile" + +[[actions]] +name = "Compile" +icon = "tool" +command = "yarn nx run-many -t compile" + +[[actions]] +name = "Check" +icon = "tool" +command = "yarn nx run-many -t check" + +[[actions]] +name = "Unit Tests" +icon = "test" +command = "yarn test" + +[[actions]] +name = "Internal UI" +icon = "run" +command = "yarn --cwd code storybook:ui" + +[[actions]] +name = "Format" +icon = "tool" +command = "yarn --cwd code fmt:write" diff --git a/.external/addon-svelte-csf b/.external/addon-svelte-csf deleted file mode 160000 index 0ff845efcd43..000000000000 --- a/.external/addon-svelte-csf +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 0ff845efcd43373e5418b70ee83fb7325d33e940 diff --git a/.external/addon-webpack5-compiler-babel b/.external/addon-webpack5-compiler-babel deleted file mode 160000 index ae95d5f16854..000000000000 --- a/.external/addon-webpack5-compiler-babel +++ /dev/null @@ -1 +0,0 @@ -Subproject commit ae95d5f168548237319071fe67e6cf51bd3981cf diff --git a/.external/addon-webpack5-compiler-swc b/.external/addon-webpack5-compiler-swc deleted file mode 160000 index 3d64e2bf92ea..000000000000 --- a/.external/addon-webpack5-compiler-swc +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 3d64e2bf92eae064f528ce5e45bdd4ad159ee6a4 diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index a57d93ef584e..e50fbad8495c 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -30,12 +30,14 @@ Thank you for contributing to Storybook! Please submit all PRs to the `next` bra > [!CAUTION] > This section is mandatory for all contributions. If you believe no manual test is necessary, please state so explicitly. Thanks! - ### Documentation @@ -49,6 +51,7 @@ Thank you for contributing to Storybook! Please submit all PRs to the `next` bra ## Checklist for Maintainers - [ ] When this PR is ready for testing, make sure to add `ci:normal`, `ci:merged` or `ci:daily` GH label to it to run a specific set of sandboxes. The particular set of sandboxes can be found in `code/lib/cli-storybook/src/sandbox-templates.ts` +- [ ] Declare whether manual QA will be needed for this PR during the next release, through `qa:needed` or `qa:skip` - [ ] Make sure this PR contains **one** of the labels below:
Available labels diff --git a/.github/actions/setup-node-and-install/action.yml b/.github/actions/setup-node-and-install/action.yml index d882d1c57068..fb7fd0a25248 100644 --- a/.github/actions/setup-node-and-install/action.yml +++ b/.github/actions/setup-node-and-install/action.yml @@ -11,7 +11,7 @@ runs: using: 'composite' steps: - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version-file: '.nvmrc' @@ -19,8 +19,11 @@ runs: shell: bash run: npm install -g npm@latest - - name: Cache dependencies - uses: actions/cache@v4 + # Restore only — save is gated below. actions/cache's post-job save step uses a + # runner-internal token that bypasses `permissions:`, so splitting is the only + # reliable way to prevent pull_request_target runs from poisoning the shared cache. + - name: Restore cached dependencies + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: | ~/.yarn/berry/cache @@ -39,3 +42,11 @@ runs: shell: bash working-directory: code run: yarn install + + - name: Save cached dependencies + if: github.event_name != 'pull_request_target' + uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: | + ~/.yarn/berry/cache + key: yarn-v1-${{ hashFiles('yarn.lock') }} diff --git a/.github/scripts/agent-scan-label-pr.mjs b/.github/scripts/agent-scan-label-pr.mjs index 91b4182b36f2..b7908b51f44a 100644 --- a/.github/scripts/agent-scan-label-pr.mjs +++ b/.github/scripts/agent-scan-label-pr.mjs @@ -2,33 +2,29 @@ import * as core from '@actions/core'; import * as github from '@actions/github'; /** - * agent scan classification rename + * agent scan classification name * - organic -> human - * - automated - * - mixed */ const CLASSIFICATION_MAP = { organic: 'human', - automation: 'automated', }; async function main() { const classification = core.getInput('classification', { required: true }); const token = core.getInput('token', { required: true }); - const isCommunityFlagged = core.getInput('community-flagged') === 'true'; const octokit = github.getOctokit(token); const prNumber = github.context.payload.pull_request.number; - const labels = [`agent-scan:${CLASSIFICATION_MAP[classification] ?? classification}`]; - if (isCommunityFlagged) { - labels.push('agent-scan:community-flagged'); + if (classification === 'organic') { + const labels = [`agent-scan:${CLASSIFICATION_MAP[classification] ?? classification}`]; + + await octokit.rest.issues.addLabels({ + ...github.context.repo, + issue_number: prNumber, + labels: labels, + }); } - await octokit.rest.issues.addLabels({ - ...github.context.repo, - issue_number: prNumber, - labels: labels, - }); } main().catch((error) => { diff --git a/.github/workflows/agent-scan.yml b/.github/workflows/agent-scan.yml index badbb0da6a48..d9a44a6b4d86 100644 --- a/.github/workflows/agent-scan.yml +++ b/.github/workflows/agent-scan.yml @@ -1,6 +1,44 @@ +################################################################################################### +# # +# ██ # +# ██░░██ # +# ░░ ░░ ██░░░░░░██ ░░░░ # +# ██░░░░░░░░░░██ # +# ██░░░░░░░░░░██ # +# ██░░░░░░░░░░░░░░██ # +# ██░░░░░░██████░░░░░░██ # +# ██░░░░░░██████░░░░░░██ # +# ██░░░░░░░░██████░░░░░░░░██ # +# ██░░░░░░░░██████░░░░░░░░██ # +# ██░░░░░░░░░░██████░░░░░░░░░░██ # +# ██░░░░░░░░░░░░██████░░░░░░░░░░░░██ # +# ██░░░░░░░░░░░░██████░░░░░░░░░░░░██ # +# ██░░░░░░░░░░░░░░██████░░░░░░░░░░░░░░██ # +# ██░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░██ # +# ██░░░░░░░░░░░░░░░░██████░░░░░░░░░░░░░░░░██ # +# ██░░░░░░░░░░░░░░░░██████░░░░░░░░░░░░░░░░██ # +# ██░░░░░░░░░░░░░░░░░░██████░░░░░░░░░░░░░░░░░░██ # +# ░░ ██░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░██ # +# ██████████████████████████████████████████ # +# # +# # +# SECURITY WARNING: Ensure your `pull_request_target` job respects the following rules: # +# # +# - Never write to GitHub Actions cache, as it would allow cache poisoning attacks # +# - Only call third-party systems that are aware the code passed to them could be untrustworthy # +# - Always set explicit permissions on your PR to limit the capabilities of secrets.GITHUB_TOKEN # +# # +################################################################################################### + name: agent-scan +# Start with empty permissions on `pull_request_target`, then set permissions per job as needed. +permissions: {} + on: + # Use `pull_request_target` so we can run this workflow on PRs from forks, as its goal is to assess + # if PR authors are trustworthy. Only reasons on the PR author and does not check out the fork code. + # zizmor: ignore[dangerous-triggers] # required for fork PRs; no fork code is checked out pull_request_target: types: - opened @@ -25,10 +63,17 @@ jobs: runs-on: ubuntu-latest permissions: pull-requests: write + contents: read steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + - name: Checkout code from `next`/`main` branch (trusted code, not PR author code) + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event.pull_request.base.sha }} + persist-credentials: false + - name: Install script dependencies run: npm install --prefix .github/scripts + - name: Check author org membership id: membership env: @@ -36,24 +81,31 @@ jobs: INPUT_ORG: ${{ github.repository_owner }} INPUT_USERNAME: ${{ github.event.pull_request.user.login }} run: node .github/scripts/agent-scan-check-org-membership.mjs + - name: Cache AgentScan analysis if: steps.membership.outputs.should-scan == 'true' - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: .agentscan-cache + # Safe because the cache is prefixed and only used here, and does not include + # user-controlled content (can't spoof another actor's identity). key: agentscan-cache-${{ github.actor }} - restore-keys: agentscan-cache- + - name: AgentScan if: steps.membership.outputs.should-scan == 'true' id: agentscan - uses: MatteoGabriele/agentscan-action@a584774dd15cabe6df4c6ab45fc43514a3b56b2d + uses: MatteoGabriele/agentscan-action@f41545309db947a68e22ed2643f182e754f4d41a # v1.8.0 with: github-token: ${{ secrets.GITHUB_TOKEN }} agent-scan-comment: false cache-path: .agentscan-cache + label-community-flagged: 'agent-scan:community-flagged' + label-mixed: 'agent-scan:mixed' + label-automation: 'agent-scan:automated' + - name: Label PR with classification if: steps.membership.outputs.should-scan == 'true' && steps.agentscan.outputs.classification env: INPUT_TOKEN: ${{ secrets.GITHUB_TOKEN }} INPUT_CLASSIFICATION: ${{ steps.agentscan.outputs.classification }} - run: node .github/scripts/agent-scan-label-pr.mjs \ No newline at end of file + run: node .github/scripts/agent-scan-label-pr.mjs diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml new file mode 100644 index 000000000000..db6aa408223a --- /dev/null +++ b/.github/workflows/claude-code-review.yml @@ -0,0 +1,35 @@ +name: Claude Code Review + +on: + pull_request: + types: [opened, synchronize, ready_for_review, reopened] + +jobs: + claude-review: + # Claude Code reviews are estimated to cost $15-25. use only when necessary and only internally. + if: | + (github.event.pull_request.author_association == 'MEMBER' || github.event.pull_request.author_association == 'OWNER') && + contains(github.event.pull_request.labels.*.name, 'claude-review') + + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 1 + persist-credentials: false + + - name: Run Claude Code Review + id: claude-review + uses: anthropics/claude-code-action@a92e7c70a4da9793dc164451d829089dc057a464 # v1.0.159 + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + plugin_marketplaces: 'https://github.com/anthropics/claude-code.git' + plugins: 'code-review@claude-code-plugins' + prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}' diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml new file mode 100644 index 000000000000..7921c3a3c925 --- /dev/null +++ b/.github/workflows/claude.yml @@ -0,0 +1,68 @@ +name: Claude Code + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + issues: + types: [opened, assigned] + pull_request_review: + types: [submitted] + +jobs: + claude: + # Only org OWNERs and MEMBERs may trigger Claude. author_association is set by + # GitHub on the comment/review/issue payload; everything else (COLLABORATOR, + # CONTRIBUTOR, FIRST_TIME_CONTRIBUTOR, NONE) is rejected. + if: | + (github.event_name == 'issue_comment' && + contains(github.event.comment.body, '@claude') && + contains(fromJSON('["OWNER", "MEMBER"]'), github.event.comment.author_association)) || + (github.event_name == 'pull_request_review_comment' && + contains(github.event.comment.body, '@claude') && + contains(fromJSON('["OWNER", "MEMBER"]'), github.event.comment.author_association)) || + (github.event_name == 'pull_request_review' && + contains(github.event.review.body, '@claude') && + contains(fromJSON('["OWNER", "MEMBER"]'), github.event.review.author_association)) || + (github.event_name == 'issues' && ( + (github.event.action == 'opened' && + contains(fromJSON('["OWNER", "MEMBER"]'), github.event.issue.author_association) && + (contains(github.event.issue.body, '`@claude`') || contains(github.event.issue.title, '`@claude`'))) || + (github.event.action == 'assigned' && github.event.assignee.login == 'claude') + )) + + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + actions: read # Required for Claude to read CI results on PRs + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 1 + persist-credentials: false + + - name: Run Claude Code + id: claude + uses: anthropics/claude-code-action@a92e7c70a4da9793dc164451d829089dc057a464 # v1.0.159 + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + assignee_trigger: 'claude' + base_branch: 'next' + prompt: 'Respect the PR description template and repository guidelines in AGENTS.md.' + # Allows Claude to read CI results on PRs + additional_permissions: | + actions: read + + # Tool permissions Claude needs to follow AGENTS.md (install, compile, + # typecheck, lint, format, test). Bash is NOT allowed by default, so + # without this Claude cannot run any of the repo's build/test commands. + # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md + # or https://code.claude.com/docs/en/cli-reference for available options + claude_args: | + --allowed-tools "Bash(yarn:*),Bash(cd:*),Bash(node:*),Bash(git status:*),Bash(git diff:*),Bash(git log:*),Bash(git show:*)" + --disallowed-tools "Bash(yarn start),Bash(yarn task dev)" diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index 73a7eaad10a9..613f96d5999b 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -25,7 +25,9 @@ jobs: # If you do not check out your code, Copilot will do this for you. steps: - name: Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Setup Node.js and Install Dependencies uses: ./.github/actions/setup-node-and-install diff --git a/.github/workflows/cron-weekly.yml b/.github/workflows/cron-weekly.yml deleted file mode 100644 index 26269d89f3ba..000000000000 --- a/.github/workflows/cron-weekly.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: Markdown Links Check -# runs every monday at 9 am -on: - schedule: - - cron: "0 9 * * 1" - -permissions: - contents: read # to fetch repository files for markdown link checks - -jobs: - check-links: - if: github.repository_owner == 'storybookjs' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: gaurav-nelson/github-action-markdown-link-check@v1 - # checks all markdown files from important folders including all subfolders - with: - # only show errors that occur instead of successful links + errors - use-quiet-mode: "yes" - # output full HTTP info for broken links - use-verbose-mode: "yes" - config-file: ".github/workflows/markdown-link-check-config.json" - # Notify to Discord channel on failure - - name: Send Discord Notification - if: failure() # Only run this step if previous steps failed - run: | - curl -H "Content-Type: application/json" -X POST -d '{"content":"The Markdown Links Check workflow has failed in the repository: [storybook]"}' ${{ secrets.DISCORD_MONITORING_URL }} diff --git a/.github/workflows/danger-js.yml b/.github/workflows/danger-js.yml index b2f6c6decbe8..2f4f4a6a46d6 100644 --- a/.github/workflows/danger-js.yml +++ b/.github/workflows/danger-js.yml @@ -1,5 +1,41 @@ +################################################################################################### +# # +# ██ # +# ██░░██ # +# ░░ ░░ ██░░░░░░██ ░░░░ # +# ██░░░░░░░░░░██ # +# ██░░░░░░░░░░██ # +# ██░░░░░░░░░░░░░░██ # +# ██░░░░░░██████░░░░░░██ # +# ██░░░░░░██████░░░░░░██ # +# ██░░░░░░░░██████░░░░░░░░██ # +# ██░░░░░░░░██████░░░░░░░░██ # +# ██░░░░░░░░░░██████░░░░░░░░░░██ # +# ██░░░░░░░░░░░░██████░░░░░░░░░░░░██ # +# ██░░░░░░░░░░░░██████░░░░░░░░░░░░██ # +# ██░░░░░░░░░░░░░░██████░░░░░░░░░░░░░░██ # +# ██░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░██ # +# ██░░░░░░░░░░░░░░░░██████░░░░░░░░░░░░░░░░██ # +# ██░░░░░░░░░░░░░░░░██████░░░░░░░░░░░░░░░░██ # +# ██░░░░░░░░░░░░░░░░░░██████░░░░░░░░░░░░░░░░░░██ # +# ░░ ██░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░██ # +# ██████████████████████████████████████████ # +# # +# # +# SECURITY WARNING: Ensure your `pull_request_target` job respects the following rules: # +# # +# - Never write to GitHub Actions cache, as it would allow cache poisoning attacks # +# - Only call third-party systems that are aware the code passed to them could be untrustworthy # +# - Always set explicit permissions on your PR to limit the capabilities of secrets.GITHUB_TOKEN # +# # +################################################################################################### + +name: Danger JS + on: - pull_request: + # We need `pull_request_target` to check external contributor PRs. + # zizmor: ignore[dangerous-triggers] # job checks out base.sha (trusted code), not the PR head; see security warning above + pull_request_target: types: - opened - synchronize @@ -8,22 +44,32 @@ on: - unlabeled - edited branches: - - "**" + - '**' concurrency: group: ${{ github.workflow }}-${{ github.event.number }} cancel-in-progress: true -name: Danger JS +permissions: {} + jobs: dangerJS: name: Danger JS runs-on: ubuntu-latest + permissions: + contents: read + issues: read + pull-requests: write + statuses: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + # Checkout our own base code, NOT the untrusted PR code. + ref: ${{ github.event.pull_request.base.sha }} + persist-credentials: false - name: Danger JS - uses: docker://ghcr.io/danger/danger-js:13.0.5 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + uses: docker://ghcr.io/danger/danger-js@sha256:66d5394557aef115c9b8086e9886fc24ee32d82ad222efb83778468538a91ef0 # 13.0.5 with: args: --dangerfile scripts/dangerfile.js + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/fork-checks.yml b/.github/workflows/fork-checks.yml index 7a7bc9b4dcfe..69f658fad599 100644 --- a/.github/workflows/fork-checks.yml +++ b/.github/workflows/fork-checks.yml @@ -7,15 +7,20 @@ on: env: NODE_OPTIONS: '--max_old_space_size=4096' +permissions: {} + jobs: check: name: Core Type Checking if: github.repository_owner != 'storybookjs' runs-on: ubuntu-latest + permissions: + contents: read steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 2 + persist-credentials: false - name: Setup Node.js and Install Dependencies uses: ./.github/actions/setup-node-and-install @@ -29,10 +34,13 @@ jobs: name: Core Formatting if: github.repository_owner != 'storybookjs' runs-on: ubuntu-latest + permissions: + contents: read steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 2 + persist-credentials: false - name: Setup Node.js and Install Dependencies uses: ./.github/actions/setup-node-and-install @@ -40,7 +48,7 @@ jobs: install-code-deps: true - name: oxfmt - run: cd code && yarn lint:fmt + run: cd code && yarn fmt:check test: strategy: @@ -49,10 +57,13 @@ jobs: runs-on: ${{ matrix.os }} name: Core Unit Tests, ${{ matrix.os }} if: github.repository_owner != 'storybookjs' + permissions: + contents: read steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 2 + persist-credentials: false - name: Setup Node.js and Install Dependencies uses: ./.github/actions/setup-node-and-install diff --git a/.github/workflows/generate-sandboxes.yml b/.github/workflows/generate-sandboxes.yml index 7548cbada8cc..431edb241922 100644 --- a/.github/workflows/generate-sandboxes.yml +++ b/.github/workflows/generate-sandboxes.yml @@ -14,16 +14,18 @@ env: CLEANUP_SANDBOX_NODE_MODULES: 'true' NX_CLOUD_ACCESS_TOKEN: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }} +permissions: {} + defaults: run: working-directory: ./code - jobs: set-branches: name: Resolve target branches if: github.repository_owner == 'storybookjs' runs-on: ubuntu-latest + permissions: {} outputs: branches: ${{ steps.set.outputs.branches }} steps: @@ -45,6 +47,8 @@ jobs: needs: set-branches if: github.repository_owner == 'storybookjs' runs-on: ubuntu-latest + permissions: + contents: read strategy: fail-fast: false matrix: @@ -67,11 +71,12 @@ jobs: /usr/share/dotnet \ /usr/share/swift - - uses: actions/checkout@v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ matrix.branch }} + persist-credentials: false - - uses: actions/setup-node@v4 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version-file: '.nvmrc' @@ -104,13 +109,16 @@ jobs: # publish sandboxes even if the generation fails, as some sandboxes might have been generated successfully # when triggered manually, always publish to the `next` branch on the sandboxes repo if: ${{ !cancelled() }} - run: yarn publish-sandboxes --remote=https://storybook-bot:${{ secrets.PAT_STORYBOOK_BOT }}@github.com/storybookjs/sandboxes.git --push --branch=${{ github.event_name == 'workflow_dispatch' && 'next' || matrix.branch }} + env: + PAT: ${{ secrets.PAT_STORYBOOK_BOT }} + BRANCH: ${{ github.event_name == 'workflow_dispatch' && 'next' || matrix.branch }} + run: yarn publish-sandboxes --remote="https://storybook-bot:${PAT}@github.com/storybookjs/sandboxes.git" --push --branch="$BRANCH" - name: Report failure to Discord if: failure() env: DISCORD_WEBHOOK: ${{ secrets.DISCORD_MONITORING_URL }} - uses: Ilshidur/action-discord@d2594079a10f1d6739ee50a2471f0ca57418b554 + uses: Ilshidur/action-discord@d2594079a10f1d6739ee50a2471f0ca57418b554 # v0.4.0 with: args: | The generation of some or all sandboxes on the **${{ matrix.branch }}** branch has failed. diff --git a/.github/workflows/handle-release-branches.yml b/.github/workflows/handle-release-branches.yml index 021ed04934ff..c39996d4d1de 100644 --- a/.github/workflows/handle-release-branches.yml +++ b/.github/workflows/handle-release-branches.yml @@ -3,28 +3,38 @@ name: Handle Release Branches on: push: +permissions: {} + jobs: branch-checks: if: github.repository_owner == 'storybookjs' runs-on: ubuntu-latest + permissions: {} steps: - id: get-branch + env: + REF: ${{ github.ref }} run: | - BRANCH=($(echo ${{ github.ref }} | sed -E 's/refs\/heads\///')) - echo "branch=$BRANCH" >> $GITHUB_ENV + BRANCH="${REF#refs/heads/}" + echo "branch=$BRANCH" >> "$GITHUB_OUTPUT" outputs: - branch: ${{ env.branch }} - is-latest-branch: ${{ env.branch == 'main' }} - is-next-branch: ${{ env.branch == 'next' }} - is-release-branch: ${{ startsWith(env.branch, 'release-') }} - is-actionable-branch: ${{ env.branch == 'main' || env.branch == 'next' || startsWith(env.branch, 'release-') }} + branch: ${{ steps.get-branch.outputs.branch }} + is-latest-branch: ${{ steps.get-branch.outputs.branch == 'main' }} + is-next-branch: ${{ steps.get-branch.outputs.branch == 'next' }} + is-release-branch: ${{ startsWith(steps.get-branch.outputs.branch, 'release-') }} + is-actionable-branch: ${{ steps.get-branch.outputs.branch == 'main' || steps.get-branch.outputs.branch == 'next' || startsWith(steps.get-branch.outputs.branch, 'release-') }} handle-latest: needs: branch-checks if: ${{ needs.branch-checks.outputs.is-latest-branch == 'true' }} runs-on: ubuntu-latest + permissions: + contents: read steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: main + persist-credentials: false - run: curl -X POST "https://api.netlify.com/build_hooks/${{ secrets.FRONTPAGE_HOOK }}" @@ -32,39 +42,44 @@ jobs: needs: branch-checks if: ${{ needs.branch-checks.outputs.is-next-branch == 'true' || needs.branch-checks.outputs.is-release-branch == 'true' }} runs-on: ubuntu-latest + permissions: + contents: read steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: next path: next + persist-credentials: false - - id: next-version - uses: notiz-dev/github-action-json-property@release - with: - path: ${{ github.workspace }}/next/code/package.json - prop_path: version - - - run: | - NEXT_RELEASE_BRANCH=($(echo ${{ steps.next-version.outputs.prop }} | sed -E 's/([0-9]+)\.([0-9]+).*/release-\1-\2/')) - echo "next-release-branch=$NEXT_RELEASE_BRANCH" >> $GITHUB_ENV + - id: next-release-branch + run: | + NEXT_RELEASE_BRANCH=$(jq -r '.version | capture("^(?[0-9]+)\\.(?[0-9]+)") | "release-\(.maj)-\(.min)"' next/code/package.json) + echo "branch=$NEXT_RELEASE_BRANCH" >> $GITHUB_OUTPUT outputs: - branch: ${{ env.next-release-branch }} + branch: ${{ steps.next-release-branch.outputs.branch }} create-next-release-branch: needs: [branch-checks, get-next-release-branch] if: ${{ needs.branch-checks.outputs.is-next-branch == 'true' }} runs-on: ubuntu-latest + permissions: + contents: write steps: - - uses: actions/checkout@v4 + - name: Checkout (with creds for later git push) + # zizmor: ignore[artipacked] # git push origin requires persisted credentials + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 - - run: | + - env: + TARGET_BRANCH: ${{ needs.get-next-release-branch.outputs.branch }} + SOURCE_BRANCH: ${{ needs.branch-checks.outputs.branch }} + run: | set +e - REMOTE_BRANCH=$(git branch -r | grep origin/${{ needs.get-next-release-branch.outputs.branch }}) - if [[ ! -z $REMOTE_BRANCH ]]; then git push origin --delete ${{ needs.get-next-release-branch.outputs.branch }}; fi - echo 'Pushing branch ${{ needs.get-next-release-branch.outputs.branch }}...' - git push -f origin ${{ needs.branch-checks.outputs.branch }}:${{ needs.get-next-release-branch.outputs.branch }} + REMOTE_BRANCH=$(git branch -r | grep "origin/${TARGET_BRANCH}") + if [[ -n "$REMOTE_BRANCH" ]]; then git push origin --delete "$TARGET_BRANCH"; fi + echo "Pushing branch ${TARGET_BRANCH}..." + git push -f origin "${SOURCE_BRANCH}:${TARGET_BRANCH}" outputs: branch: ${{ needs.get-next-release-branch.outputs.branch }} @@ -72,30 +87,40 @@ jobs: if: ${{ always() && github.repository_owner == 'storybookjs' }} needs: [branch-checks, get-next-release-branch] runs-on: ubuntu-latest + permissions: {} steps: - - run: | + - id: is-next-release-branch + run: | IS_NEXT_RELEASE_BRANCH=${{ needs.branch-checks.outputs.branch == needs.get-next-release-branch.outputs.branch }} - echo "is-next-release-branch=$IS_NEXT_RELEASE_BRANCH" >> $GITHUB_ENV + echo "result=$IS_NEXT_RELEASE_BRANCH" >> $GITHUB_OUTPUT - - if: ${{ env.is-next-release-branch == 'true' }} - run: echo "relevant-base-branch=next" >> $GITHUB_ENV + - id: relevant-base-branch + if: ${{ steps.is-next-release-branch.outputs.result == 'true' }} + run: echo "relevant-base-branch=next" >> $GITHUB_OUTPUT - - if: ${{ env.is-next-release-branch == 'true' }} + - if: ${{ steps.is-next-release-branch.outputs.result == 'true' }} run: | - echo 'WARNING: Do not push directly to the `${{ needs.branch-checks.outputs.branch }}` branch. This branch is created and force-pushed over after pushing to the `${{ env.relevant-base-branch }}` branch and the changes you just pushed will be lost.' + echo 'WARNING: Do not push directly to the `${{ needs.branch-checks.outputs.branch }}` branch. This branch is created and force-pushed over after pushing to the `${{ steps.relevant-base-branch.outputs.relevant-base-branch }}` branch and the changes you just pushed will be lost.' exit 1 outputs: - check: ${{ env.is-next-release-branch }} + check: ${{ steps.is-next-release-branch.outputs.result }} request-create-frontpage-branch: if: ${{ always() && github.repository_owner == 'storybookjs' }} needs: [branch-checks, next-release-branch-check, create-next-release-branch] runs-on: ubuntu-latest + permissions: + contents: read steps: - if: ${{ needs.branch-checks.outputs.is-actionable-branch == 'true' && needs.branch-checks.outputs.is-latest-branch == 'false' && needs.next-release-branch-check.outputs.check == 'false' }} + env: + BRANCH: ${{ needs.create-next-release-branch.outputs.branch || needs.branch-checks.outputs.branch }} + FRONTPAGE_TOKEN: ${{ secrets.FRONTPAGE_ACCESS_TOKEN }} run: | + DISPATCH_PAYLOAD=$(jq -n --arg branch "$BRANCH" \ + '{event_type: "request-create-frontpage-branch", client_payload: {branch: $branch}}') curl -X POST https://api.github.com/repos/storybookjs/frontpage/dispatches \ - -H 'Accept: application/vnd.github.v3+json' \ - -u ${{ secrets.FRONTPAGE_ACCESS_TOKEN }} \ - --data '{"event_type": "request-create-frontpage-branch", "client_payload": { "branch": "${{ needs.create-next-release-branch.outputs.branch || needs.branch-checks.outputs.branch }}" }}' + -H 'Accept: application/vnd.github.v3+json' \ + -u "$FRONTPAGE_TOKEN" \ + --data "$DISPATCH_PAYLOAD" diff --git a/.github/workflows/markdown-link-check-config.json b/.github/workflows/markdown-link-check-config.json deleted file mode 100644 index 6cdc0a785121..000000000000 --- a/.github/workflows/markdown-link-check-config.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "replacementPatterns": [ - { - "pattern": "^/", - "replacement": "./" - } - ], - "ignorePatterns": [ - { - "pattern": "localhost" - }, - { - "pattern": "https://github.com/storybookjs/storybook/pull/*" - }, - { - "pattern": "https://stackblitz.com/*" - }, - { - "pattern": "https://*.chromatic.com" - }, - { - "pattern": "https://www.chromatic.com/build?*" - }, - { - "pattern": "http://*.nodeca.com" - }, - { - "pattern": "http://definitelytyped.org/*" - }, - { - "pattern": "https://yoursite.com/*" - }, - { - "pattern": "https://my-specific-domain.com" - } - ], - "aliveStatusCodes": [429, 200] -} diff --git a/.github/workflows/nx.yml b/.github/workflows/nx.yml index 8afa8c14b353..58c4f15ee567 100644 --- a/.github/workflows/nx.yml +++ b/.github/workflows/nx.yml @@ -30,45 +30,48 @@ jobs: env: ALL_TASKS: compile,check,knip,test,lint,fmt,sandbox,build,e2e-tests,e2e-tests-dev,test-runner,vitest-integration,check-sandbox,e2e-ui,jest,vitest,playwright-ct steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: filter: tree:0 fetch-depth: 0 + persist-credentials: false - name: Set Nx tag(s) id: tag + env: + EVENT_NAME: ${{ github.event_name }} + IS_MERGED: ${{ contains(github.event.pull_request.labels.*.name, 'ci:merged') }} + IS_DAILY: ${{ contains(github.event.pull_request.labels.*.name, 'ci:daily') }} + REF: ${{ github.ref }} run: | tags="normal" - if [[ "${{ github.event_name }}" == "pull_request" ]]; then - if [[ "${{ contains(github.event.pull_request.labels.*.name, 'ci:merged') }}" == "true" ]]; then - tags="merged" - fi - if [[ "${{ contains(github.event.pull_request.labels.*.name, 'ci:daily') }}" == "true" ]]; then - tags="daily" - fi + if [[ "$EVENT_NAME" == "pull_request" ]]; then + if [[ "$IS_MERGED" == "true" ]]; then tags="merged"; fi + if [[ "$IS_DAILY" == "true" ]]; then tags="daily"; fi fi - - if [[ "${{ github.event_name }}" == "push" && "${{ github.ref }}" == "refs/heads/next" ]]; then + if [[ "$EVENT_NAME" == "push" && "$REF" == "refs/heads/next" ]]; then tags="merged" fi - - if [[ "${{ github.event_name }}" == "schedule" ]]; then + if [[ "$EVENT_NAME" == "schedule" ]]; then tags="daily" fi - echo "tag=$tags" >> "$GITHUB_OUTPUT" - name: Select distribution config id: dist + env: + TAG: ${{ steps.tag.outputs.tag }} run: | - if [[ "${{ steps.tag.outputs.tag }}" == "daily" ]]; then + if [[ "$TAG" == "daily" ]]; then echo "config=./.nx/workflows/distribution-config-daily.yaml" >> "$GITHUB_OUTPUT" else echo "config=./.nx/workflows/distribution-config.yaml" >> "$GITHUB_OUTPUT" fi - - run: npx nx-cloud@latest start-ci-run --distribute-on="${{ steps.dist.outputs.config }}" --stop-agents-after="$ALL_TASKS" + - env: + DIST_CONFIG: ${{ steps.dist.outputs.config }} + run: npx nx-cloud@19.1.3 start-ci-run --distribute-on="$DIST_CONFIG" --stop-agents-after="$ALL_TASKS" - name: Create Nx Cloud Status (pending) - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 with: - script: | + script: | # zizmor: ignore[template-injection] safe toJson context expansion in github-script const tag = ${{ toJson(steps.tag.outputs.tag) }} || 'normal'; await github.rest.repos.createCommitStatus({ @@ -82,49 +85,51 @@ jobs: description: 'NX Cloud is running your tests', context: `nx: ${tag}`, }); - - uses: actions/setup-node@v4 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: 22 + node-version-file: '.nvmrc' cache: 'yarn' - run: yarn install --immutable - - uses: nrwl/nx-set-shas@v4 + - uses: nrwl/nx-set-shas@afb73a62d26e41464e9254689e1fd6122ee683c1 # v5.0.1 - id: nx name: 'Run nx' + env: + TAG: ${{ steps.tag.outputs.tag }} run: | echo 'nx_output<> "$GITHUB_OUTPUT" - yarn nx run-many -t $ALL_TASKS -c production -p="tag:library,tag:ci:${{ steps.tag.outputs.tag }}" | tee -a "$GITHUB_OUTPUT" + yarn nx run-many -t $ALL_TASKS -c production -p="tag:library,tag:ci:${TAG}" | tee -a "$GITHUB_OUTPUT" status=${PIPESTATUS[0]} echo 'EOF' >> "$GITHUB_OUTPUT" exit $status - name: Create per-task Nx statuses if: always() - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 with: github-token: ${{ secrets.GITHUB_TOKEN }} - script: | + script: | # zizmor: ignore[template-injection] safe toJson context expansion in github-script const raw = ${{ toJson(steps.nx.outputs.nx_output) }} || ''; const tag = ${{ toJson(steps.tag.outputs.tag) }} || ''; const lines = raw.split('\n'); const failures = []; - + for (const [i, line] of lines.entries()) { if (!line.includes('✖')) continue; - + const task = line.match(/✖\s+([^│]+?)\s{2,}/)?.[1].trim() || 'Unknown Nx task'; - + const url = lines .slice(i + 1, i + 6) .find(l => l.includes('Task logs:')) ?.match(/Task logs:\s*(https:\/\/cloud\.nx\.app\/logs\/\S+)/)?.[1]; - + failures.push({ task, url }); } - + const sha = context.payload.pull_request?.head?.sha ?? context.sha; - + // Per-task statuses (max 5) for (const { task, url } of failures.slice(0, 5)) { await github.rest.repos.createCommitStatus({ @@ -137,7 +142,7 @@ jobs: description: 'Your test failed on NX Cloud', }); } - + const runMatches = raw.match(/https:\/\/cloud\.nx\.app\/runs\/\S+/g); const nxCloudUrl = runMatches ? runMatches[runMatches.length - 1] : undefined; @@ -153,4 +158,4 @@ jobs: ? `Nx Cloud run failed (${failedCount} tasks failed)` : 'Nx Cloud run finished successfully', context: `nx: ${tag}`, - }); \ No newline at end of file + }); diff --git a/.github/workflows/prepare-non-patch-release.yml b/.github/workflows/prepare-non-patch-release.yml index 0ea4d5ee51d7..a54599891b58 100644 --- a/.github/workflows/prepare-non-patch-release.yml +++ b/.github/workflows/prepare-non-patch-release.yml @@ -33,18 +33,25 @@ concurrency: group: ${{ github.workflow }} cancel-in-progress: true +permissions: {} + jobs: prepare-non-patch-pull-request: name: Prepare non-patch pull request if: github.repository_owner == 'storybookjs' runs-on: ubuntu-latest environment: Release + permissions: + contents: write + pull-requests: write + actions: write defaults: run: working-directory: scripts steps: - name: Checkout next - uses: actions/checkout@v4 + # zizmor: ignore[artipacked] # git push --force origin uses persisted GH_TOKEN credentials + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: next # this needs to be set to a high enough number that it will contain the last version tag @@ -66,10 +73,11 @@ jobs: if: steps.check-frozen.outputs.frozen == 'true' && github.event_name != 'workflow_dispatch' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RUN_ID: ${{ github.run_id }} # From https://stackoverflow.com/a/75809743 run: | - gh run cancel ${{ github.run_id }} - gh run watch ${{ github.run_id }} + gh run cancel "$RUN_ID" + gh run watch "$RUN_ID" # tags are needed to get changes and changelog generation - name: Fetch git tags @@ -86,63 +94,91 @@ jobs: if: steps.unreleased-changes.outputs.has-changes-to-release == 'false' && github.event_name != 'workflow_dispatch' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RUN_ID: ${{ github.run_id }} # From https://stackoverflow.com/a/75809743 run: | - gh run cancel ${{ github.run_id }} - gh run watch ${{ github.run_id }} + gh run cancel "$RUN_ID" + gh run watch "$RUN_ID" - name: Bump version deferred id: bump-version + env: + RELEASE_TYPE: ${{ inputs.release-type || 'prerelease' }} + PRE_ID: ${{ inputs.pre-id }} run: | - yarn release:version --deferred --release-type ${{ inputs.release-type || 'prerelease' }} ${{ inputs.pre-id && format('{0} {1}', '--pre-id', inputs.pre-id) || '' }} --verbose + ARGS=(--deferred --release-type "$RELEASE_TYPE") + if [[ -n "$PRE_ID" ]]; then + ARGS+=(--pre-id "$PRE_ID") + fi + yarn release:version "${ARGS[@]}" --verbose - name: Write changelog env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NEXT_VERSION: ${{ steps.bump-version.outputs.next-version }} run: | - yarn release:write-changelog ${{ steps.bump-version.outputs.next-version }} --verbose + yarn release:write-changelog "$NEXT_VERSION" --verbose - name: 'Commit changes to branch: version-non-patch-from-${{ steps.bump-version.outputs.current-version }}' working-directory: . + env: + CURRENT_VERSION: ${{ steps.bump-version.outputs.current-version }} + NEXT_VERSION: ${{ steps.bump-version.outputs.next-version }} run: | git config --global user.name 'storybook-bot' git config --global user.email '32066757+storybook-bot@users.noreply.github.com' - git checkout -b version-non-patch-from-${{ steps.bump-version.outputs.current-version }} + git checkout -b "version-non-patch-from-${CURRENT_VERSION}" git add . - git commit --allow-empty --no-verify -m "Write changelog for ${{ steps.bump-version.outputs.next-version }} [skip ci]" - git push --force origin version-non-patch-from-${{ steps.bump-version.outputs.current-version }} + git commit --allow-empty --no-verify -m "Write changelog for ${NEXT_VERSION} [skip ci]" + git push --force origin "version-non-patch-from-${CURRENT_VERSION}" - name: Generate PR description id: description env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: yarn release:generate-pr-description --current-version ${{ steps.bump-version.outputs.current-version }} --next-version ${{ steps.bump-version.outputs.next-version }} --verbose + CURRENT_VERSION: ${{ steps.bump-version.outputs.current-version }} + NEXT_VERSION: ${{ steps.bump-version.outputs.next-version }} + run: | + yarn release:generate-pr-description \ + --current-version "$CURRENT_VERSION" \ + --next-version "$NEXT_VERSION" \ + --verbose - name: Create or update pull request env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + RELEASE_TYPE: ${{ inputs.release-type || 'prerelease' }} + PRE_ID: ${{ inputs.pre-id }} + NEXT_VERSION: ${{ steps.bump-version.outputs.next-version }} + CURRENT_VERSION: ${{ steps.bump-version.outputs.current-version }} + DESCRIPTION: ${{ steps.description.outputs.description }} run: | - RELEASE_TYPE=${{ inputs.release-type || 'prerelease' }} CAPITALIZED_RELEASE_TYPE=${RELEASE_TYPE^} + TITLE_SUFFIX="" + if [[ -n "$PRE_ID" ]]; then + TITLE_SUFFIX="${PRE_ID} " + fi + TITLE="Release: ${CAPITALIZED_RELEASE_TYPE} ${TITLE_SUFFIX}${NEXT_VERSION}" if PR_STATE=$(gh pr view --json state --jq .state 2>/dev/null) && [[ -n "$PR_STATE" && "$PR_STATE" == *"OPEN"* ]]; then gh pr edit \ - --repo "${{github.repository }}" \ - --title "Release: $CAPITALIZED_RELEASE_TYPE ${{ inputs.pre-id && format('{0} ', inputs.pre-id) }}${{ steps.bump-version.outputs.next-version }}" \ - --body "${{ steps.description.outputs.description }}" + --repo "$REPO" \ + --title "$TITLE" \ + --body "$DESCRIPTION" else gh pr create \ - --repo "${{github.repository }}"\ - --title "Release: $CAPITALIZED_RELEASE_TYPE ${{ inputs.pre-id && format('{0} ', inputs.pre-id) }}${{ steps.bump-version.outputs.next-version }}" \ + --repo "$REPO" \ + --title "$TITLE" \ --label "release" \ --base next-release \ - --head version-non-patch-from-${{ steps.bump-version.outputs.current-version }} \ - --body "${{ steps.description.outputs.description }}" + --head "version-non-patch-from-${CURRENT_VERSION}" \ + --body "$DESCRIPTION" fi - name: Report job failure to Discord if: failure() env: DISCORD_WEBHOOK: ${{ secrets.DISCORD_MONITORING_URL }} - uses: Ilshidur/action-discord@d2594079a10f1d6739ee50a2471f0ca57418b554 + uses: Ilshidur/action-discord@d2594079a10f1d6739ee50a2471f0ca57418b554 # v0.4.0 with: args: 'The GitHub Action for preparing the release pull request bumping from v${{ steps.bump-version.outputs.current-version }} to v${{ steps.bump-version.outputs.next-version }} (triggered by ${{ github.triggering_actor }}) failed! See run at: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}' diff --git a/.github/workflows/prepare-patch-release.yml b/.github/workflows/prepare-patch-release.yml index fb7eaa96288f..7f6c0ea045c8 100644 --- a/.github/workflows/prepare-patch-release.yml +++ b/.github/workflows/prepare-patch-release.yml @@ -15,18 +15,25 @@ concurrency: group: ${{ github.workflow }} cancel-in-progress: true +permissions: {} + jobs: prepare-patch-pull-request: name: Prepare patch pull request if: github.repository_owner == 'storybookjs' runs-on: ubuntu-latest environment: Release + permissions: + contents: write + pull-requests: write + actions: write defaults: run: working-directory: scripts steps: - name: Checkout main - uses: actions/checkout@v4 + # zizmor: ignore[artipacked] # git push --force origin uses persisted GH_TOKEN credentials + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: main token: ${{ secrets.GH_TOKEN }} @@ -45,10 +52,11 @@ jobs: if: steps.check-frozen.outputs.frozen == 'true' && github.event_name != 'workflow_dispatch' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RUN_ID: ${{ github.run_id }} # From https://stackoverflow.com/a/75809743 run: | - gh run cancel ${{ github.run_id }} - gh run watch ${{ github.run_id }} + gh run cancel "$RUN_ID" + gh run watch "$RUN_ID" - name: Check for unreleased changes id: unreleased-changes @@ -75,10 +83,11 @@ jobs: if: steps.pick-patches.outputs.no-patch-prs == 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RUN_ID: ${{ github.run_id }} # From https://stackoverflow.com/a/75809743 run: | - gh run cancel ${{ github.run_id }} - gh run watch ${{ github.run_id }} + gh run cancel "$RUN_ID" + gh run watch "$RUN_ID" - name: Bump version deferred id: bump-version @@ -95,77 +104,101 @@ jobs: - name: Set version output id: versions + env: + BUMP_CURRENT: ${{ steps.bump-version.outputs.current-version }} + BUMP_NEXT: ${{ steps.bump-version.outputs.next-version }} + CUR_CURRENT: ${{ steps.current-version.outputs.current-version }} run: | - echo "current=${{ steps.bump-version.outputs.current-version || steps.current-version.outputs.current-version }}" >> "$GITHUB_OUTPUT" - echo "next=${{ steps.bump-version.outputs.next-version || steps.current-version.outputs.current-version }}" >> "$GITHUB_OUTPUT" + echo "current=${BUMP_CURRENT:-$CUR_CURRENT}" >> "$GITHUB_OUTPUT" + echo "next=${BUMP_NEXT:-$CUR_CURRENT}" >> "$GITHUB_OUTPUT" - name: Write changelog if: steps.unreleased-changes.outputs.has-changes-to-release == 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NEXT_VERSION: ${{ steps.versions.outputs.next }} run: | - yarn release:write-changelog ${{ steps.versions.outputs.next }} --unpicked-patches --verbose + yarn release:write-changelog "$NEXT_VERSION" --unpicked-patches --verbose - name: 'Commit changes to branch: version-patch-from-${{ steps.versions.outputs.current }}' working-directory: . + env: + CURRENT_VERSION: ${{ steps.versions.outputs.current }} + NEXT_VERSION: ${{ steps.versions.outputs.next }} run: | git config --global user.name 'storybook-bot' git config --global user.email '32066757+storybook-bot@users.noreply.github.com' - git checkout -b version-patch-from-${{ steps.versions.outputs.current }} + git checkout -b "version-patch-from-${CURRENT_VERSION}" git add . - git commit --allow-empty --no-verify -m "Write changelog for ${{ steps.versions.outputs.next }} [skip ci]" - git push --force origin version-patch-from-${{ steps.versions.outputs.current }} + git commit --allow-empty --no-verify -m "Write changelog for ${NEXT_VERSION} [skip ci]" + git push --force origin "version-patch-from-${CURRENT_VERSION}" - name: Generate PR description id: description env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: yarn release:generate-pr-description --unpicked-patches --manual-cherry-picks='${{ steps.pick-patches.outputs.failed-cherry-picks }}' ${{ steps.unreleased-changes.outputs.has-changes-to-release == 'true' && format('{0}={1} {2}={3}', '--current-version', steps.versions.outputs.current, '--next-version', steps.versions.outputs.next) || '' }} --verbose + HAS_CHANGES: ${{ steps.unreleased-changes.outputs.has-changes-to-release }} + CURRENT_VERSION: ${{ steps.versions.outputs.current }} + NEXT_VERSION: ${{ steps.versions.outputs.next }} + FAILED_PICKS: ${{ steps.pick-patches.outputs.failed-cherry-picks }} + run: | + ARGS=(--unpicked-patches --manual-cherry-picks="$FAILED_PICKS") + if [[ "$HAS_CHANGES" == "true" ]]; then + ARGS+=(--current-version "$CURRENT_VERSION" --next-version "$NEXT_VERSION") + fi + yarn release:generate-pr-description "${ARGS[@]}" --verbose - name: Create or update pull request with release if: steps.unreleased-changes.outputs.has-changes-to-release == 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + NEXT_VERSION: ${{ steps.versions.outputs.next }} + CURRENT_VERSION: ${{ steps.versions.outputs.current }} + DESCRIPTION: ${{ steps.description.outputs.description }} run: | if PR_STATE=$(gh pr view --json state --jq .state 2>/dev/null) && [[ -n "$PR_STATE" && "$PR_STATE" == *"OPEN"* ]]; then gh pr edit \ - --repo "${{github.repository }}" \ - --title "Release: Patch ${{ steps.versions.outputs.next }}" \ - --body "${{ steps.description.outputs.description }}" + --repo "$REPO" \ + --title "Release: Patch ${NEXT_VERSION}" \ + --body "$DESCRIPTION" else gh pr create \ - --repo "${{github.repository }}" \ - --title "Release: Patch ${{ steps.versions.outputs.next }}" \ + --repo "$REPO" \ + --title "Release: Patch ${NEXT_VERSION}" \ --label "release" \ --base latest-release \ - --head version-patch-from-${{ steps.versions.outputs.current }} \ - --body "${{ steps.description.outputs.description }}" + --head "version-patch-from-${CURRENT_VERSION}" \ + --body "$DESCRIPTION" fi - name: Create or update pull request without release if: steps.unreleased-changes.outputs.has-changes-to-release == 'false' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + CURRENT_VERSION: ${{ steps.versions.outputs.current }} + DESCRIPTION: ${{ steps.description.outputs.description }} run: | if PR_STATE=$(gh pr view --json state --jq .state 2>/dev/null) && [[ -n "$PR_STATE" && "$PR_STATE" == *"OPEN"* ]]; then gh pr edit \ - --repo "${{github.repository }}"\ + --repo "$REPO" \ --title "Release: Merge patches to \`main\` (without version bump)" \ - --body "${{ steps.description.outputs.description }}" + --body "$DESCRIPTION" else gh pr create \ - --repo "${{github.repository }}"\ + --repo "$REPO" \ --title "Release: Merge patches to \`main\` (without version bump)" \ --label "release" \ --base latest-release \ - --head version-patch-from-${{ steps.versions.outputs.current }} \ - --body "${{ steps.description.outputs.description }}" + --head "version-patch-from-${CURRENT_VERSION}" \ + --body "$DESCRIPTION" fi - name: Report job failure to Discord if: failure() env: DISCORD_WEBHOOK: ${{ secrets.DISCORD_MONITORING_URL }} - uses: Ilshidur/action-discord@d2594079a10f1d6739ee50a2471f0ca57418b554 + uses: Ilshidur/action-discord@d2594079a10f1d6739ee50a2471f0ca57418b554 # v0.4.0 with: args: 'The GitHub Action for preparing the release pull request bumping from v${{ steps.versions.outputs.current }} to v${{ steps.versions.outputs.next }} (triggered by ${{ github.triggering_actor }}) failed! See run at: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}' diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 853fc99af401..f588fe4c6eaa 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -11,28 +11,24 @@ on: # Manual canary releases on PRs inputs: pr: - description: "⚠️ CANARY RELEASES ONLY - Enter the pull request number to create a canary release for" + description: '⚠️ CANARY RELEASES ONLY - Enter the pull request number to create a canary release for' required: true type: number - pull_request: - # Automated canary releases on PRs with the "with-canary-release"-suffix in the branch name - types: [opened, synchronize, reopened] env: PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: 1 -permissions: - id-token: write - contents: write - pull-requests: write +permissions: {} concurrency: - # Group concurrent runs based on the event type: - # - For workflow_dispatch and pull_request: group by PR number to allow only one canary release per PR - # - For push events: group by branch name to prevent multiple releases on the same branch - group: ${{ github.event_name == 'workflow_dispatch' && format('{0}-{1}', github.workflow, inputs.pr) || github.event_name == 'pull_request' && format('{0}-{1}', github.workflow, github.event.pull_request.number) || format('{0}-{1}', github.workflow, github.ref_name) }} - cancel-in-progress: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request' }} + # Group concurrent runs to control cancellation per release type: + # - Canary releases (manual `workflow_dispatch`) are grouped by PR number, + # so a newer canary supersedes an older one for the same PR. Only runs + # that actually publish a canary join this group. + # - Pushes to release branches are grouped by branch name and never cancelled. + group: ${{ github.event_name == 'workflow_dispatch' && format('{0}-{1}', github.workflow, inputs.pr) || format('{0}-{1}', github.workflow, github.ref_name) }} + cancel-in-progress: ${{ github.event_name == 'workflow_dispatch'}} jobs: publish-normal: @@ -44,12 +40,18 @@ jobs: (github.ref_name == 'latest-release' || github.ref_name == 'next-release') && contains(github.event.head_commit.message, '[skip ci]') != true environment: Release + permissions: + contents: write + issues: write + actions: write # required for yarn release:cancel-preparation-runs + id-token: write # required for npm provenance via yarn release:publish defaults: run: working-directory: scripts steps: - name: Checkout ${{ github.ref_name }} - uses: actions/checkout@v4 + # zizmor: ignore[artipacked] # git push origin runs at multiple steps using persisted GH_TOKEN + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 100 token: ${{ secrets.GH_TOKEN }} @@ -186,7 +188,7 @@ jobs: - name: Create Sentry release if: steps.publish-needed.outputs.published == 'false' - uses: getsentry/action-release@v3 + uses: getsentry/action-release@5657c9e888b4e2cc85f4d29143ea4131fde4a73a # v3.6.0 env: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_ORG: ${{ secrets.SENTRY_ORG }} @@ -199,24 +201,25 @@ jobs: if: failure() env: DISCORD_WEBHOOK: ${{ secrets.DISCORD_MONITORING_URL }} - uses: Ilshidur/action-discord@d2594079a10f1d6739ee50a2471f0ca57418b554 + uses: Ilshidur/action-discord@d2594079a10f1d6739ee50a2471f0ca57418b554 # v0.4.0 with: - args: "The GitHub Action for publishing version ${{ steps.version.outputs.current-version }} (triggered by ${{ github.triggering_actor }}) failed! See run at: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" + args: 'The GitHub Action for publishing version ${{ steps.version.outputs.current-version }} (triggered by ${{ github.triggering_actor }}) failed! See run at: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}' publish-canary: name: Publish canary version runs-on: ubuntu-latest if: | github.repository_owner == 'storybookjs' && - ( - github.event_name == 'workflow_dispatch' || - (github.event_name == 'pull_request' && endsWith(github.head_ref, 'with-canary-release')) - ) && + github.event_name == 'workflow_dispatch' && contains(github.event.head_commit.message, '[skip ci]') != true environment: Release + permissions: + contents: read + pull-requests: write + id-token: write # required for npm provenance via yarn release:publish steps: - name: Fail if triggering actor is not administrator - uses: prince-chrismc/check-actor-permissions-action@87c6d9b36c730377858fd9719fbbac1b58fa678d + uses: prince-chrismc/check-actor-permissions-action@87c6d9b36c730377858fd9719fbbac1b58fa678d # no version attached, ahead of last release with: permission: admin @@ -224,7 +227,7 @@ jobs: id: info env: GH_TOKEN: ${{ secrets.GH_TOKEN }} - PR_NUMBER: ${{ github.event_name == 'workflow_dispatch' && inputs.pr || github.event.pull_request.number }} + PR_NUMBER: ${{ github.event_name == 'workflow_dispatch' && inputs.pr }} REPOSITORY: ${{ github.repository }} run: | PR_INFO=$(gh pr view "$PR_NUMBER" --repo "$REPOSITORY" --json isCrossRepository,headRefOid,headRefName,headRepository,headRepositoryOwner --jq '{isFork: .isCrossRepository, owner: .headRepositoryOwner.login, repoName: .headRepository.name, branch: .headRefName, sha: .headRefOid}') @@ -240,11 +243,12 @@ jobs: echo "timestamp=$(date +%s)" >> $GITHUB_OUTPUT - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: repository: ${{ steps.info.outputs.isFork == 'true' && steps.info.outputs.repository || null }} ref: ${{ steps.info.outputs.sha }} token: ${{ secrets.GH_TOKEN }} + persist-credentials: false - name: Setup Node.js and Install Dependencies uses: ./.github/actions/setup-node-and-install @@ -255,7 +259,7 @@ jobs: id: version working-directory: scripts env: - PR_NUMBER: ${{ github.event_name == 'workflow_dispatch' && inputs.pr || github.event.pull_request.number }} + PR_NUMBER: ${{ github.event_name == 'workflow_dispatch' && inputs.pr }} SHORT_SHA: ${{ steps.info.outputs.shortSha }} run: | yarn release:version --exact "0.0.0-pr-$PR_NUMBER-sha-$SHORT_SHA" --verbose @@ -267,11 +271,11 @@ jobs: run: yarn release:publish --tag canary --verbose - name: Replace Pull Request Body - uses: ivangabriele/find-and-replace-pull-request-body@042438c6cbfbacf6a4701d6042f59b1f73db2fd8 + uses: ivangabriele/find-and-replace-pull-request-body@042438c6cbfbacf6a4701d6042f59b1f73db2fd8 # no version attached, ahead of last release with: githubToken: ${{ secrets.GH_TOKEN }} prNumber: ${{ github.event_name == 'workflow_dispatch' && inputs.pr || '' }} - find: "CANARY_RELEASE_SECTION" + find: 'CANARY_RELEASE_SECTION' isHtmlCommentTag: true replace: | This pull request has been released as version `${{ steps.version.outputs.next-version }}`. Try it out in a new sandbox by running `npx storybook@${{ steps.version.outputs.next-version }} sandbox` or in an existing project with `npx storybook@${{ steps.version.outputs.next-version }} upgrade`. @@ -290,14 +294,14 @@ jobs: To request a new release of this pull request, mention the `@storybookjs/core` team. - _core team members can create a new canary release [here](https://github.com/storybookjs/storybook/actions/workflows/publish.yml) or locally with `gh workflow run --repo storybookjs/storybook publish.yml --field pr=${{ github.event_name == 'workflow_dispatch' && inputs.pr || github.event.pull_request.number }}`_ + _core team members can create a new canary release [here](https://github.com/storybookjs/storybook/actions/workflows/publish.yml) or locally with `gh workflow run --repo storybookjs/storybook publish.yml --field pr=${{ github.event_name == 'workflow_dispatch' && inputs.pr }}`_
- name: Create failing comment on PR if: failure() env: GH_TOKEN: ${{ secrets.GH_TOKEN }} - PR_NUMBER: ${{ github.event_name == 'workflow_dispatch' && inputs.pr || github.event.pull_request.number }} + PR_NUMBER: ${{ github.event_name == 'workflow_dispatch' && inputs.pr }} REPOSITORY: ${{ github.repository }} TRIGGERING_ACTOR: ${{ github.triggering_actor }} RUN_ID: ${{ github.run_id }} diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 55a49c5f04d2..0e3b950f25b4 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -1,7 +1,7 @@ -name: "Close stale issues that need reproduction or more info from OP" +name: 'Close stale issues that need reproduction or more info from OP' on: schedule: - - cron: "30 1 * * *" + - cron: '30 1 * * *' permissions: issues: write # to close and label issues (actions/stale) @@ -12,13 +12,13 @@ jobs: runs-on: ubuntu-latest if: github.repository_owner == 'storybookjs' steps: - - uses: actions/stale@v9 + - uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0 with: stale-issue-message: "Hi there! Thank you for opening this issue, but it has been marked as `stale` because we need more information to move forward. Could you please provide us with the requested reproduction or additional information that could help us better understand the problem? We'd love to resolve this issue, but we can't do it without your help!" close-issue-message: "I'm afraid we need to close this issue for now, since we can't take any action without the requested reproduction or additional information. But please don't hesitate to open a new issue if the problem persists – we're always happy to help. Thanks so much for your understanding." - any-of-issue-labels: "needs reproduction,needs more info" - exempt-issue-labels: "needs triage" - labels-to-add-when-unstale: "needs triage" + any-of-issue-labels: 'needs reproduction,needs more info' + exempt-issue-labels: 'needs triage' + labels-to-add-when-unstale: 'needs triage' days-before-issue-close: 7 days-before-issue-stale: 21 days-before-pr-close: -1 diff --git a/.github/workflows/triage.yml b/.github/workflows/triage.yml index af45b109da06..25642fd6a4e6 100644 --- a/.github/workflows/triage.yml +++ b/.github/workflows/triage.yml @@ -18,14 +18,14 @@ jobs: if: github.repository_owner == 'storybookjs' runs-on: ubuntu-latest steps: - - uses: balazsorban44/nissuer@1.10.0 + - uses: balazsorban44/nissuer@92ef22afd6a75e5e588f5d689a1fd3433f596f82 # v1.10.0 with: label-comments: | { "good first issue": ".github/comments/good-first-issue.md" } - reproduction-comment: ".github/comments/invalid-link.md" - reproduction-hosts: "github.com,codesandbox.io,stackblitz.com" - reproduction-link-section: "### Reproduction link(.*)### Reproduction steps" - reproduction-invalid-label: "needs reproduction" - reproduction-issue-labels: "bug,needs triage" + reproduction-comment: '.github/comments/invalid-link.md' + reproduction-hosts: 'github.com,codesandbox.io,stackblitz.com' + reproduction-link-section: '### Reproduction link(.*)### Reproduction steps' + reproduction-invalid-label: 'needs reproduction' + reproduction-issue-labels: 'bug,needs triage' diff --git a/.github/workflows/trigger-circle-ci-workflow.yml b/.github/workflows/trigger-circle-ci-workflow.yml index 7b1cedda19f1..d2f16bf0ec28 100644 --- a/.github/workflows/trigger-circle-ci-workflow.yml +++ b/.github/workflows/trigger-circle-ci-workflow.yml @@ -1,8 +1,42 @@ +################################################################################################### +# # +# ██ # +# ██░░██ # +# ░░ ░░ ██░░░░░░██ ░░░░ # +# ██░░░░░░░░░░██ # +# ██░░░░░░░░░░██ # +# ██░░░░░░░░░░░░░░██ # +# ██░░░░░░██████░░░░░░██ # +# ██░░░░░░██████░░░░░░██ # +# ██░░░░░░░░██████░░░░░░░░██ # +# ██░░░░░░░░██████░░░░░░░░██ # +# ██░░░░░░░░░░██████░░░░░░░░░░██ # +# ██░░░░░░░░░░░░██████░░░░░░░░░░░░██ # +# ██░░░░░░░░░░░░██████░░░░░░░░░░░░██ # +# ██░░░░░░░░░░░░░░██████░░░░░░░░░░░░░░██ # +# ██░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░██ # +# ██░░░░░░░░░░░░░░░░██████░░░░░░░░░░░░░░░░██ # +# ██░░░░░░░░░░░░░░░░██████░░░░░░░░░░░░░░░░██ # +# ██░░░░░░░░░░░░░░░░░░██████░░░░░░░░░░░░░░░░░░██ # +# ░░ ██░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░██ # +# ██████████████████████████████████████████ # +# # +# # +# SECURITY WARNING: Ensure your `pull_request_target` job respects the following rules: # +# # +# - Never write to GitHub Actions cache, as it would allow cache poisoning attacks # +# - Only call third-party systems that are aware the code passed to them could be untrustworthy # +# - Always set explicit permissions on your PR to limit the capabilities of secrets.GITHUB_TOKEN # +# # +################################################################################################### + name: Trigger CircleCI workflow +# Start with empty permissions on `pull_request_target`, then set permissions per job as needed. +permissions: {} + on: - # Use pull_request_target, as we don't need to check out the actual code of the fork in this script. - # And this is the only way to trigger the Circle CI API on forks as well. + # zizmor: ignore[dangerous-triggers] # required for fork PRs; no fork code is checked out — only the Circle CI API is called pull_request_target: types: [opened, synchronize, labeled, reopened] push: @@ -10,59 +44,124 @@ on: - next - main +# For PR events, group per-PR (github.ref is refs/pull//merge) so redundant +# re-triggers on the same PR cancel each other. For pushes to a shared branch +# (next/main), key on the commit SHA instead: otherwise two merges landing close +# together share the refs/heads/ group and the earlier merge's trigger is +# cancelled before it can POST to CircleCI, silently skipping its `merged` workflow. concurrency: - group: ${{ github.workflow }}-${{ github.ref }} + group: ${{ github.workflow }}-${{ github.event_name == 'push' && github.sha || github.event.pull_request.number }} cancel-in-progress: true jobs: get-branch: if: github.repository_owner == 'storybookjs' runs-on: ubuntu-latest + permissions: {} steps: - id: get-branch env: - # Stored as environment variable to prevent script injection REF_NAME: ${{ github.ref_name }} PR_REF_NAME: ${{ github.event.pull_request.head.ref }} + PR_NUMBER: ${{ github.event.pull_request.number }} + IS_FORK: ${{ github.event.pull_request.head.repo.fork }} + EVENT_NAME: ${{ github.event_name }} run: | - if [ "${{ github.event.pull_request.head.repo.fork }}" = "true" ]; then - export BRANCH=pull/${{ github.event.pull_request.number }}/head - elif [ "${{ github.event_name }}" = "push" ]; then - export BRANCH="$REF_NAME" - else - export BRANCH="$PR_REF_NAME" + if [ "$IS_FORK" = "true" ]; then + BRANCH="pull/${PR_NUMBER}/head" + elif [ "$EVENT_NAME" = "push" ]; then + BRANCH="$REF_NAME" + else + BRANCH="$PR_REF_NAME" fi echo "$BRANCH" - echo "branch=$BRANCH" >> $GITHUB_ENV + echo "branch=$BRANCH" >> "$GITHUB_OUTPUT" outputs: - branch: ${{ env.branch }} + branch: ${{ steps.get-branch.outputs.branch }} get-parameters: if: github.repository_owner == 'storybookjs' runs-on: ubuntu-latest + permissions: {} steps: - - if: github.event_name == 'pull_request_target' && (contains(github.event.pull_request.labels.*.name, 'ci:normal')) - run: echo "workflow=normal" >> $GITHUB_ENV - - if: github.event_name == 'pull_request_target' && (contains(github.event.pull_request.labels.*.name, 'ci:docs')) - run: echo "workflow=docs" >> $GITHUB_ENV - - if: github.event_name == 'push' || contains(github.event.pull_request.labels.*.name, 'ci:merged') - run: echo "workflow=merged" >> $GITHUB_ENV - - if: github.event_name == 'pull_request_target' && (contains(github.event.pull_request.labels.*.name, 'ci:daily')) - run: echo "workflow=daily" >> $GITHUB_ENV + - id: normal + if: github.event_name == 'pull_request_target' && (contains(github.event.pull_request.labels.*.name, 'ci:normal')) + run: echo "workflow=normal" >> "$GITHUB_OUTPUT" + - id: docs + if: github.event_name == 'pull_request_target' && (contains(github.event.pull_request.labels.*.name, 'ci:docs')) + run: echo "workflow=docs" >> "$GITHUB_OUTPUT" + - id: merged + if: github.event_name == 'push' || contains(github.event.pull_request.labels.*.name, 'ci:merged') + run: echo "workflow=merged" >> "$GITHUB_OUTPUT" + - id: daily + if: github.event_name == 'pull_request_target' && (contains(github.event.pull_request.labels.*.name, 'ci:daily')) + run: echo "workflow=daily" >> "$GITHUB_OUTPUT" + - id: trusted-author + env: + EVENT_NAME: ${{ github.event_name }} + USER_TYPE: ${{ github.event.pull_request.user.type }} + USER_LOGIN: ${{ github.event.pull_request.user.login }} + GITHUB_API_TOKEN: ${{ secrets.STORYBOOKJS_ORG_MEMBERSHIP_TOKEN }} + run: | + # You can only push to `main` and `next` as a core team member, so the content is trustworthy. + if [ "$EVENT_NAME" = "push" ]; then + echo "result=true" >> "$GITHUB_OUTPUT" + # These commits are made by the release actions, which are gated to core team members. + elif [ "$USER_LOGIN" = "github-actions[bot]" ] && [ "$USER_TYPE" = "Bot" ]; then + echo "result=true" >> "$GITHUB_OUTPUT" + else + # Explicitly trust only members of specific Storybook teams. + # Team slugs for: Maintainers, Core, Developer Experience. + TRUSTED_TEAMS="maintainers core developer-experience" + IS_TRUSTED=false + + for TEAM in $TRUSTED_TEAMS; do + STATE=$(curl -fsSL \ + -H "Authorization: Bearer $GITHUB_API_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "https://api.github.com/orgs/storybookjs/teams/$TEAM/memberships/$USER_LOGIN" \ + | jq -r '.state' 2>/dev/null || true) + + if [ "$STATE" = "active" ]; then + IS_TRUSTED=true + break + fi + done + + echo "result=$IS_TRUSTED" >> "$GITHUB_OUTPUT" + fi outputs: - workflow: ${{ env.workflow }} + workflow: ${{ steps.normal.outputs.workflow || steps.docs.outputs.workflow || steps.merged.outputs.workflow || steps.daily.outputs.workflow }} ghBaseBranch: ${{ github.event.pull_request.base.ref }} ghPrNumber: ${{ github.event.pull_request.number }} + ghTrustedAuthor: ${{ steps.trusted-author.outputs.result }} trigger-circle-ci-workflow: runs-on: ubuntu-latest needs: [get-branch, get-parameters] if: github.repository_owner == 'storybookjs' && needs.get-parameters.outputs.workflow != '' + permissions: {} steps: - - name: Trigger Normal tests - uses: fjogeleit/http-request-action@v1 - with: - url: 'https://circleci.com/api/v2/project/gh/storybookjs/storybook/pipeline' - method: 'POST' - customHeaders: '{"Content-Type": "application/json", "Circle-Token": "${{ secrets.CIRCLE_CI_TOKEN }}"}' - data: '{ "branch": "${{needs.get-branch.outputs.branch}}", "parameters": ${{toJson(needs.get-parameters.outputs)}} }' + - name: Trigger CircleCI pipeline + env: + CIRCLE_CI_TOKEN: ${{ secrets.CIRCLE_CI_TOKEN }} + BRANCH: ${{ needs.get-branch.outputs.branch }} + WORKFLOW: ${{ needs.get-parameters.outputs.workflow }} + GH_BASE_BRANCH: ${{ needs.get-parameters.outputs.ghBaseBranch }} + GH_PR_NUMBER: ${{ needs.get-parameters.outputs.ghPrNumber }} + GH_TRUSTED_AUTHOR: ${{ needs.get-parameters.outputs.ghTrustedAuthor }} + run: | + PARAMETERS=$(jq -nc \ + --arg workflow "$WORKFLOW" \ + --arg ghBaseBranch "$GH_BASE_BRANCH" \ + --arg ghPrNumber "$GH_PR_NUMBER" \ + --arg ghTrustedAuthor "$GH_TRUSTED_AUTHOR" \ + '{workflow: $workflow, ghBaseBranch: $ghBaseBranch, ghPrNumber: $ghPrNumber, ghTrustedAuthor: $ghTrustedAuthor}') + PAYLOAD=$(jq -nc --arg branch "$BRANCH" --argjson parameters "$PARAMETERS" \ + '{branch: $branch, parameters: $parameters}') + curl -sS --fail-with-body -X POST \ + -H "Content-Type: application/json" \ + -H "Circle-Token: $CIRCLE_CI_TOKEN" \ + -d "$PAYLOAD" \ + "https://circleci.com/api/v2/project/gh/storybookjs/storybook/pipeline" diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml new file mode 100644 index 000000000000..aa6e37c7b2d7 --- /dev/null +++ b/.github/workflows/zizmor.yml @@ -0,0 +1,36 @@ +name: GitHub Actions Security Analysis with zizmor 🌈 + +on: + push: + branches: ['main', 'next', 'next-release', 'latest-release', 'release-*'] + pull_request: + branches: ['**'] + +permissions: + contents: read + security-events: write # Required for upload-sarif (used by zizmor-action) to upload SARIF files. + +jobs: + zizmor: + name: zizmor latest via PyPI + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Install the latest version of uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + enable-cache: false + + - name: Run zizmor 🌈 + run: uvx zizmor --format=sarif . > results.sarif + + - name: Upload SARIF file + uses: github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 + if: github.repository_owner == 'storybookjs' + with: + sarif_file: results.sarif + category: zizmor diff --git a/.gitignore b/.gitignore index c8417670f741..a289b7e25180 100644 --- a/.gitignore +++ b/.gitignore @@ -75,7 +75,9 @@ code/core/report .junie CLAUDE.local.md -.claude +.claude/* +!.claude/skills/ +!.claude/skills/** .cursor/mcp.json .vscode/mcp.json .mcp.json diff --git a/.husky/post-checkout b/.husky/post-checkout new file mode 100644 index 000000000000..967bc20ecb99 --- /dev/null +++ b/.husky/post-checkout @@ -0,0 +1,3 @@ +if [ -z "$SKIP_STORYBOOK_GIT_HOOKS" ] && [ "$STORYBOOK_COMPILE_ON_CHECKOUT" = "true" ]; then + yarn && yarn task compile -s compile +fi diff --git a/.nvmrc b/.nvmrc index ddeb00c1678b..cde04c9af5a2 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1,2 +1,2 @@ -22.22.1 +22.22.3 diff --git a/.rollout-repos/addon-coverage b/.rollout-repos/addon-coverage deleted file mode 160000 index 0279cd63a671..000000000000 --- a/.rollout-repos/addon-coverage +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 0279cd63a671d1c35e3c45b4ae35d2f6e0a39ab6 diff --git a/.rollout-repos/addon-designs b/.rollout-repos/addon-designs deleted file mode 160000 index 50824a3e12c5..000000000000 --- a/.rollout-repos/addon-designs +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 50824a3e12c5004443695f3d58955c884876458e diff --git a/.rollout-repos/addon-kit b/.rollout-repos/addon-kit deleted file mode 160000 index e17a0f15cab7..000000000000 --- a/.rollout-repos/addon-kit +++ /dev/null @@ -1 +0,0 @@ -Subproject commit e17a0f15cab7c0ea6fd9e9c5bc0cd6a8f24941ca diff --git a/.rollout-repos/addon-styling-webpack b/.rollout-repos/addon-styling-webpack deleted file mode 160000 index 7df09eb6d759..000000000000 --- a/.rollout-repos/addon-styling-webpack +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 7df09eb6d7596f03d07aefda1252c707ba488d65 diff --git a/.rollout-repos/addon-visual-tests b/.rollout-repos/addon-visual-tests deleted file mode 160000 index be1829a2403b..000000000000 --- a/.rollout-repos/addon-visual-tests +++ /dev/null @@ -1 +0,0 @@ -Subproject commit be1829a2403b2bd55ddc694db00fd0f6ade3007d diff --git a/.rollout-repos/addon-webpack5-compiler-babel b/.rollout-repos/addon-webpack5-compiler-babel deleted file mode 160000 index 5d9ade95a91d..000000000000 --- a/.rollout-repos/addon-webpack5-compiler-babel +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 5d9ade95a91dd90e023bc471ce82c1eba2c19988 diff --git a/.rollout-repos/addon-webpack5-compiler-swc b/.rollout-repos/addon-webpack5-compiler-swc deleted file mode 160000 index 5af31673f84b..000000000000 --- a/.rollout-repos/addon-webpack5-compiler-swc +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 5af31673f84b656f19de0ee799ce691ec1ee2d49 diff --git a/.rollout-repos/icons b/.rollout-repos/icons deleted file mode 160000 index 70f13df022f8..000000000000 --- a/.rollout-repos/icons +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 70f13df022f8a0a8ccc15ad358df967ddc2c2e5d diff --git a/.rollout-repos/telejson b/.rollout-repos/telejson deleted file mode 160000 index 78136d94add2..000000000000 --- a/.rollout-repos/telejson +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 78136d94add2f441c2321cebd95ff9a793893828 diff --git a/.rollout-repos/test-runner b/.rollout-repos/test-runner deleted file mode 160000 index c1be8e0ebcb2..000000000000 --- a/.rollout-repos/test-runner +++ /dev/null @@ -1 +0,0 @@ -Subproject commit c1be8e0ebcb2ba1e3e7374f14a2e89c120339fb2 diff --git a/.rollout-repos/vite-plugin-storybook-nextjs b/.rollout-repos/vite-plugin-storybook-nextjs deleted file mode 160000 index bbbb87a985dd..000000000000 --- a/.rollout-repos/vite-plugin-storybook-nextjs +++ /dev/null @@ -1 +0,0 @@ -Subproject commit bbbb87a985dd72f5c1f5a18dc3b3e73f650e8b6a diff --git a/.vscode/settings.json b/.vscode/settings.json index ac32fd688694..a4b94099da83 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -11,9 +11,6 @@ "[javascriptreact]": { "editor.defaultFormatter": "oxc.oxc-vscode" }, - "[typescript]": { - "editor.defaultFormatter": "oxc.oxc-vscode" - }, "[typescriptreact]": { "editor.defaultFormatter": "oxc.oxc-vscode" }, @@ -29,7 +26,9 @@ }, "editor.codeActionsOnSave": { "source.fixAll.eslint": "explicit", - "source.fixAll.oxc": "explicit" + "source.fixAll.oxc": "explicit", + "source.organizeImports": "never", + "source.sortImports": "explicit" }, "editor.tabSize": 2, "eslint.options": { @@ -61,8 +60,6 @@ "typescript.preferences.quoteStyle": "single", "typescript.preferGoToSourceDefinition": true, "typescript.tsdk": "./node_modules/typescript/lib", - "vitest.workspaceConfig": "./code/vitest.workspace.ts", - "vitest.rootConfig": "./code/vitest.workspace.ts", "oxc.fmt.configPath": ".oxfmtrc.json", "oxc.enable.oxlint": false } diff --git a/.yarn/patches/@7rulnik-react-element-to-jsx-string-npm-15.0.1-e53b67c4b3.patch b/.yarn/patches/@7rulnik-react-element-to-jsx-string-npm-15.0.1-e53b67c4b3.patch new file mode 100644 index 000000000000..4c53827bc11d --- /dev/null +++ b/.yarn/patches/@7rulnik-react-element-to-jsx-string-npm-15.0.1-e53b67c4b3.patch @@ -0,0 +1,34 @@ +diff --git a/dist/cjs/index.js b/dist/cjs/index.js +index 6d1961830f978c0f3d90cc864003b0c50eb98f0d..9d48f1990ef440431e0e12ebf7560912893cd725 100644 +--- a/dist/cjs/index.js ++++ b/dist/cjs/index.js +@@ -373,11 +373,7 @@ var formatProp = (function (name, hasValue, value, hasDefaultValue, defaultValue + var attributeFormattedInline = ' '; + var attributeFormattedMultiline = "\n".concat(spacer(lvl + 1, tabStop)); + var isMultilineAttribute = formattedPropValue.includes('\n'); +- if (useBooleanShorthandSyntax && formattedPropValue === '{false}' && !hasDefaultValue) { +- // If a boolean is false and not different from it's default, we do not render the attribute +- attributeFormattedInline = ''; +- attributeFormattedMultiline = ''; +- } else if (useBooleanShorthandSyntax && formattedPropValue === '{true}') { ++ if (useBooleanShorthandSyntax && formattedPropValue === '{true}') { + attributeFormattedInline += "".concat(name); + attributeFormattedMultiline += "".concat(name); + } else { +diff --git a/dist/esm/index.js b/dist/esm/index.js +index 1c23d38bc15c76c6f9277c39b362f01e99e561ae..4e2de641a3ddb5851f7afa18c74d5a0f22d1c364 100644 +--- a/dist/esm/index.js ++++ b/dist/esm/index.js +@@ -347,11 +347,7 @@ var formatProp = (function (name, hasValue, value, hasDefaultValue, defaultValue + var attributeFormattedInline = ' '; + var attributeFormattedMultiline = "\n".concat(spacer(lvl + 1, tabStop)); + var isMultilineAttribute = formattedPropValue.includes('\n'); +- if (useBooleanShorthandSyntax && formattedPropValue === '{false}' && !hasDefaultValue) { +- // If a boolean is false and not different from it's default, we do not render the attribute +- attributeFormattedInline = ''; +- attributeFormattedMultiline = ''; +- } else if (useBooleanShorthandSyntax && formattedPropValue === '{true}') { ++ if (useBooleanShorthandSyntax && formattedPropValue === '{true}') { + attributeFormattedInline += "".concat(name); + attributeFormattedMultiline += "".concat(name); + } else { diff --git a/.yarn/patches/react-aria-npm-3.48.0-0945840d84.patch b/.yarn/patches/react-aria-npm-3.48.0-0945840d84.patch new file mode 100644 index 000000000000..2855ae658e01 --- /dev/null +++ b/.yarn/patches/react-aria-npm-3.48.0-0945840d84.patch @@ -0,0 +1,78 @@ +diff --git a/dist/private/overlays/calculatePosition.cjs b/dist/private/overlays/calculatePosition.cjs +index 379d9a4c18e222ca6320b70af39cd22c0caf48f3..633da26c8799a89b528918c7f10c0bca7167e119 100644 +--- a/dist/private/overlays/calculatePosition.cjs ++++ b/dist/private/overlays/calculatePosition.cjs +@@ -356,7 +356,7 @@ function $43b053bcc899501d$export$4b834cebd9e5cebe(node, ignoreScale) { + let { top: top, left: left, width: width, height: height } = node.getBoundingClientRect(); + // Use offsetWidth and offsetHeight if this is an HTML element, so that + // the size is not affected by scale transforms. +- if (ignoreScale && node instanceof node.ownerDocument.defaultView.HTMLElement) { ++ if (ignoreScale && node.ownerDocument.defaultView && node instanceof node.ownerDocument.defaultView.HTMLElement) { + width = node.offsetWidth; + height = node.offsetHeight; + } +diff --git a/dist/private/overlays/calculatePosition.js b/dist/private/overlays/calculatePosition.js +index aa585640a389c7a82312fa2a42e0f9cccd488c5f..074cac3333c8e8f8df81cc0db0218ef197dc8828 100644 +--- a/dist/private/overlays/calculatePosition.js ++++ b/dist/private/overlays/calculatePosition.js +@@ -365,7 +365,7 @@ function $ba2991ee81d3897a$export$4b834cebd9e5cebe(node, ignoreScale) { + let { top: top, left: left, width: width, height: height } = node.getBoundingClientRect(); + // Use offsetWidth and offsetHeight if this is an HTML element, so that + // the size is not affected by scale transforms. +- if (ignoreScale && node instanceof node.ownerDocument.defaultView.HTMLElement) { ++ if (ignoreScale && node.ownerDocument.defaultView && node instanceof node.ownerDocument.defaultView.HTMLElement) { + width = node.offsetWidth; + height = node.offsetHeight; + } +diff --git a/dist/private/overlays/calculatePosition.mjs b/dist/private/overlays/calculatePosition.mjs +index ff3d7c3d7ba24eeed43ab6a9b1b98533ea7e381c..d0acfa9672a11df4ad135d68bb3c726be57eefed 100644 +--- a/dist/private/overlays/calculatePosition.mjs ++++ b/dist/private/overlays/calculatePosition.mjs +@@ -349,7 +349,7 @@ function $954926fb6168ae2a$export$4b834cebd9e5cebe(node, ignoreScale) { + let { top: top, left: left, width: width, height: height } = node.getBoundingClientRect(); + // Use offsetWidth and offsetHeight if this is an HTML element, so that + // the size is not affected by scale transforms. +- if (ignoreScale && node instanceof node.ownerDocument.defaultView.HTMLElement) { ++ if (ignoreScale && node.ownerDocument.defaultView && node instanceof node.ownerDocument.defaultView.HTMLElement) { + width = node.offsetWidth; + height = node.offsetHeight; + } +diff --git a/dist/private/utils/isFocusable.cjs b/dist/private/utils/isFocusable.cjs +index 429761d9841dfebe7b7e49267ff7fa34a1b009b9..cb6d7448e3c725c858d7ad63d257276ffb3c0633 100644 +--- a/dist/private/utils/isFocusable.cjs ++++ b/dist/private/utils/isFocusable.cjs +@@ -46,7 +46,7 @@ function $48f566b6becd50da$export$bebd5a1431fec25d(element) { + function $48f566b6becd50da$var$isInert(element) { + let node = element; + while(node != null){ +- if (node instanceof node.ownerDocument.defaultView.HTMLElement && node.inert) return true; ++ if (node.ownerDocument.defaultView && node instanceof node.ownerDocument.defaultView.HTMLElement && node.inert) return true; + node = node.parentElement; + } + return false; +diff --git a/dist/private/utils/isFocusable.js b/dist/private/utils/isFocusable.js +index de779817e244ab97ec267d297089555d10a85511..0de6cb0af8254988cecf66a0871047f23100ba14 100644 +--- a/dist/private/utils/isFocusable.js ++++ b/dist/private/utils/isFocusable.js +@@ -39,7 +39,7 @@ function $ee5e22534121197a$export$bebd5a1431fec25d(element) { + function $ee5e22534121197a$var$isInert(element) { + let node = element; + while(node != null){ +- if (node instanceof node.ownerDocument.defaultView.HTMLElement && node.inert) return true; ++ if (node.ownerDocument.defaultView && node instanceof node.ownerDocument.defaultView.HTMLElement && node.inert) return true; + node = node.parentElement; + } + return false; +diff --git a/dist/private/utils/isFocusable.mjs b/dist/private/utils/isFocusable.mjs +index d29c257f391589e5c06d8279f54b483864cb2dbc..f36a7ee287be99705b28d2f79073b97e78e28acc 100644 +--- a/dist/private/utils/isFocusable.mjs ++++ b/dist/private/utils/isFocusable.mjs +@@ -39,7 +39,7 @@ function $3b8b240c1bf84ab9$export$bebd5a1431fec25d(element) { + function $3b8b240c1bf84ab9$var$isInert(element) { + let node = element; + while(node != null){ +- if (node instanceof node.ownerDocument.defaultView.HTMLElement && node.inert) return true; ++ if (node.ownerDocument.defaultView && node instanceof node.ownerDocument.defaultView.HTMLElement && node.inert) return true; + node = node.parentElement; + } + return false; diff --git a/AGENTS.md b/AGENTS.md index 55f3c0752f22..693c451c8628 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,7 @@ This file is the canonical instruction source for coding agents. Files like `CLA Storybook is a large TypeScript monorepo. The git root is the repo root, the main code lives in `code/`, and build tooling lives in `scripts/`. The default branch is `next`. - **Base branch**: `next` (all PRs should target `next`, not `main`) -- **Node.js**: `22.22.1` (see `.nvmrc`) — supports `.ts` natively via type stripping (no loader needed) +- **Node.js**: `22.22.3` (see `.nvmrc`) — supports `.ts` natively via type stripping (no loader needed) - **Package Manager**: Yarn Berry - **Task orchestration**: NX plus the custom `yarn task` runner - **CI environment**: Linux and Windows @@ -242,12 +242,38 @@ When writing unit tests (utilities, hooks, non-React modules): - Test real behavior, not just syntax patterns - Use coverage when useful: `yarn vitest run --coverage ` - Mock external dependencies like file system access and loggers +- Use Node's path.resolve to wrap expected FS paths when writing path-related tests, so they work on Windows + +### Filesystem tests with `memfs` + +For unit tests that touch `node:fs` / `node:fs/promises`, use [`memfs`](https://github.com/streamich/memfs) instead of real temp directories or wholesale `node:fs` mocks: + +- Import `vol` from `memfs` and call `vol.reset()` in `beforeEach` +- Seed virtual files with `vol.fromNestedJSON({ '/absolute/path/file.json': '...' })` or memfs `writeFile` after redirecting spies +- Use `vi.mock('node:fs/promises', { spy: true })` and, in `beforeEach`, point `mkdir` / `writeFile` / `readFile` at `memfs.fs.promises` (see `code/core/src/shared/open-service/server.test.ts`) +- Assert disk state with `vol.toJSON()` when helpful + +Do **not** use `/tmp` paths or replace `node:fs/promises` with a full async factory mock unless a test file already standardizes on the spy redirect pattern above. + +### Globals in tests: never assign `globalThis.*` directly + +> [!IMPORTANT] +> Under no circumstances may a test mutate a global by assigning it directly (e.g. `globalThis.FEATURES = {...}`, `globalThis.window = ...`, `global.fetch = ...`). Direct assignment leaks across tests and files — Vitest does not restore it — so it silently changes behavior in unrelated tests and creates order-dependent flakiness. + +Use Vitest's global stubbing instead, which is tracked and restorable: + +- Set a global with `vi.stubGlobal('FEATURES', { experimentalDocgenServer: true })`. +- Restore in `afterEach(() => vi.unstubAllGlobals())` (or enable `unstubGlobals: true` in the Vitest config so it resets before each test automatically). +- For a value used by every test in a file, stub it in `beforeEach` and unstub in `afterEach`; for a one-off override, call `vi.stubGlobal` inside that single test. +- Never capture-and-restore by hand (`const original = globalThis.X; ... globalThis.X = original`); `vi.stubGlobal` + `vi.unstubAllGlobals()` does this correctly, including deleting keys that did not previously exist. + +This applies to all ambient globals, not just `FEATURES` (e.g. `window`, `document`, `navigator`, `fetch`, `IS_REACT_ACT_ENVIRONMENT`). ## Quality and Logging After changing files: -1. Format with `yarn fmt:write` (run from the repo root) +1. **Always** format with `yarn fmt:write`, run from the `code/` directory (`cd code && yarn fmt:write`), once you are done editing. The repo uses `oxfmt`, so hand-written formatting will frequently be wrong — do not skip this step. 2. Lint with `yarn --cwd code lint:js:cmd --fix` or `cd code && yarn lint:js:cmd ` 3. Run relevant tests before submitting a PR @@ -273,12 +299,12 @@ Avoid `console.log`, `console.warn`, and `console.error` unless the file is isol ## Environment Variables -| Variable | Purpose | -| ----------------------------- | --------------------------- | -| `IN_STORYBOOK_SANDBOX` | Set during sandbox creation | -| `STORYBOOK_DISABLE_TELEMETRY` | Disable telemetry | -| `STORYBOOK_TELEMETRY_DEBUG` | Log telemetry events | -| `DEBUG` | Enable debug logging | +| Variable | Purpose | +| ----------------------------- | ----------------------------------------------- | +| `IN_STORYBOOK_SANDBOX` | Set during sandbox creation | +| `STORYBOOK_DISABLE_TELEMETRY` | Disable telemetry | +| `STORYBOOK_TELEMETRY_DEBUG` | Log telemetry events | +| `DEBUG` | Enable debug logging | | `FIX_ON_COMMIT` | Force autofix for fmt & lint in pre-commit hook | ## Commands To Avoid @@ -288,6 +314,19 @@ Avoid `console.log`, `console.warn`, and `console.error` unless the file is isol These usually start long-running development servers and are the wrong default for agents. +## Code Authoring Principles + +These are recurring failure modes in agent-authored changes to this repo. Apply them when writing or reviewing code, not just when asked. + +- **Comments are maintenance docs, not an investigation transcript.** Explain *why* for the next maintainer. Do not commit internal ticket / acceptance-criteria codes (`AC-X2`, `Probe B`, `R6`), the narrative of how you figured something out, "verified byte-identical" provenance prose, or cross-file line references (`L125→L131`) — they are noise and they rot. One or two sentences of rationale beats a paragraph of evidence. +- **Verify environment assumptions empirically before encoding them.** If a design rests on "the bundler strips X" or "this metadata is empty here", prove it with a throwaway probe before building on it (and before writing it into a comment as fact). A 10-line experiment is cheaper than a wrong architecture. +- **Encode assumptions with static checks first.** If an assumption is expected to always hold, prefer making it impossible via TypeScript types and existing lint rules. When static checks are not practical, add a cheap runtime assertion close to the boundary so violations fail loudly at the source. +- **Avoid redundant tests already covered elsewhere.** Do not add tests for code patterns already guaranteed by TypeScript or linting, and do not duplicate coverage that already exists in Storybook `play` functions or Playwright tests. +- **Test contracts (including side effects), not private implementation details.** It is valid to assert side effects when they are part of the public contract. Avoid assertions about internals that are not part of an exported contract, user-visible DOM output, or externally observable behavior. +- **Bias toward broader coverage for security and migrations.** For security-sensitive code paths and legacy data migration logic, prefer handling more edge cases and documenting evidence for the chosen safeguards. Migration compatibility code should be explicitly version-scoped so it can be removed once the support window ends. +- **Prefer deletion and simplicity over speculative generality.** No abstraction, fallback, or "flexibility" for a consumer or scenario that does not exist in this codebase today. If a change adds many lines, check whether the right change removes them. +- **Don't commit accidental overrides to generated code.** Files like `code/core/src/manager/globals/exports.ts` are auto-generated, as stated in their JSDoc header. Only commit changes if they match changes you made on your PR, otherwise leave them untouched and flag flaky generated files in the PR description. + ## Maintenance Rules For Agents - Use this file as the canonical instruction source diff --git a/CHANGELOG.md b/CHANGELOG.md index 884e765a3d1a..e3c92b3fc7eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,150 @@ +## 10.4.6 + +- CSF: Allow partial globals overrides in story and meta annotations - [#34985](https://github.com/storybookjs/storybook/pull/34985), thanks @TheSeydiCharyyev! +- Dependencies: Upgrade esbuild - [#35157](https://github.com/storybookjs/storybook/pull/35157), thanks @Kakadus! + +## 10.4.5 + +- Core: Rework AI checklist feature gate - [#35053](https://github.com/storybookjs/storybook/pull/35053), thanks @Sidnioulz! +- Preview: Stop mixed CSF3+4 stories getting core annotations injected twice - [#35094](https://github.com/storybookjs/storybook/pull/35094), thanks @JReinhold! + +## 10.4.4 + +- Telemetry: Add timeout to event-log POST to prevent build hang - [#35085](https://github.com/storybookjs/storybook/pull/35085), thanks @badams! + +## 10.4.3 + +- Addon Docs: Fix Primary and Controls blocks not rendering in custom MDX pages - [#34496](https://github.com/storybookjs/storybook/pull/34496), thanks @NYCU-Chung! +- Core: Respect !dev tag on MDX docs in sidebar - [#35031](https://github.com/storybookjs/storybook/pull/35031), thanks @JReinhold! +- React: Add support for resolving subcomponents attached as properties of a parent component - [#34967](https://github.com/storybookjs/storybook/pull/34967), thanks @yatishgoel! +- UI: Prevent docs page scroll reset on HMR re-render - [#35021](https://github.com/storybookjs/storybook/pull/35021), thanks @LongTangGithub! + +## 10.4.2 + +- Bug: Fix Windows command resolution for non-Node package managers - [#33534](https://github.com/storybookjs/storybook/pull/33534), thanks @copilot-swe-agent! +- Build: Upgrade type-fest to latest version 5.6.0 - [#34791](https://github.com/storybookjs/storybook/pull/34791), thanks @tobiasdiez! +- CSF: Fix parsing of string literal export names - [#34901](https://github.com/storybookjs/storybook/pull/34901), thanks @shilman! +- Publish: Add npm provenance attestations - [#34936](https://github.com/storybookjs/storybook/pull/34936), thanks @copilot-swe-agent! + +## 10.4.1 + +- Angular: Detect model() signal outputs (type inference + compodoc autodocs + runtime binding) - [#34833](https://github.com/storybookjs/storybook/pull/34833), thanks @valentinpalkovic! +- Build: Upgrade type-fest to latest version 5.6.0 - [#34791](https://github.com/storybookjs/storybook/pull/34791), thanks @tobiasdiez! +- CLI: Run `npx expo install --fix` after init for Expo projects - [#34803](https://github.com/storybookjs/storybook/pull/34803), thanks @ndelangen! +- CLI: Support `peerDependencies` in framework detection for component libraries - [#34516](https://github.com/storybookjs/storybook/pull/34516), thanks @zhyd1997! +- Next.js: Add useLinkStatus mock to next/link export mock - [#34593](https://github.com/storybookjs/storybook/pull/34593), thanks @philwolstenholme! +- Vue3: Specify a specific version for non-dev dependency - [#34794](https://github.com/storybookjs/storybook/pull/34794), thanks @ScopeyNZ! + +## 10.4.0 + +> _AI-assisted setup, change-aware review, and stronger framework support_ + +Storybook 10.4 contains hundreds of fixes and improvements including: + +- 🤖 Agentic Setup: New CLI workflow for AI-assisted Storybook setup and onboarding +- 🔍 Change review: Sidebar filtering to highlight new, modified, and related stories based on git changes +- 🧭 Sidebar review tools: Status filtering, URL-persisted filters, and clearer review signals in the sidebar +- ⚛️ TanStack React: New `@storybook/tanstack-react` framework with routing and server function support +- 🧩 React MCP: Faster, more accurate component docgen powered by the TypeScript Language Server +- 📱 React Native: Zero config RN project initialization +- 🤝 Sharing: Easily publish and share your local Storybook with teammates, powered by Chromatic + +
+List of all updates + +- A11y: Add aria-live announcements via @react-aria/live-announcer - [#33970](https://github.com/storybookjs/storybook/pull/33970), thanks @copilot-swe-agent! +- A11y: Improve boolean control contrast in forced colors mode - [#34204](https://github.com/storybookjs/storybook/pull/34204), thanks @anchmelev! +- Actions: Fix state mutation and keep newest actions when limit reached - [#34286](https://github.com/storybookjs/storybook/pull/34286), thanks @Sidnioulz! +- Addon-Docs: Add Reset story button to re-render stories in docs - [#34086](https://github.com/storybookjs/storybook/pull/34086), thanks @6810779s! +- Addon-Docs: Avoid rerendering static Source blocks - [#34206](https://github.com/storybookjs/storybook/pull/34206), thanks @anchmelev! +- Addon-Vitest: Use Vitest's provide-API for injecting values - [#34518](https://github.com/storybookjs/storybook/pull/34518), thanks @JReinhold! +- Agentic Setup: Add --extensive for an extra prompt - [#34730](https://github.com/storybookjs/storybook/pull/34730), thanks @Sidnioulz! +- Agentic Setup: Allow failed stories to persist - [#34717](https://github.com/storybookjs/storybook/pull/34717), thanks @Sidnioulz! +- Agentic Setup: Keep sample content if users want onboarding - [#34704](https://github.com/storybookjs/storybook/pull/34704), thanks @Sidnioulz! +- Agentic Setup: Rework ai-init-opt-in logic - [#34739](https://github.com/storybookjs/storybook/pull/34739), thanks @Sidnioulz! +- Angular: Use Story ID for renderer IDs (including standalone stories) - [#33982](https://github.com/storybookjs/storybook/pull/33982), thanks @ValentinFunk! +- Automigration: Move RN on-device addons to `deviceAddons` - [#34659](https://github.com/storybookjs/storybook/pull/34659), thanks @ndelangen! +- Builder-Vite: Add onModuleGraphChange method - [#34323](https://github.com/storybookjs/storybook/pull/34323), thanks @ghengeveld! +- CLI: Add automigrate check for 'storybook' package name conflict - [#34290](https://github.com/storybookjs/storybook/pull/34290), thanks @whdjh! +- CLI: Add react-vite to tanstack-react automigration - [#34718](https://github.com/storybookjs/storybook/pull/34718), thanks @huang-julien! +- CLI: Change mock event detection - [#34586](https://github.com/storybookjs/storybook/pull/34586), thanks @yannbf! +- CLI: Explicitly tell whether smoke tests passed or failed - [#34419](https://github.com/storybookjs/storybook/pull/34419), thanks @Sidnioulz! +- CLI: Fix Next.js Vite automigration corrupting configs already using `@storybook/nextjs-vite` - [#34249](https://github.com/storybookjs/storybook/pull/34249), thanks @nathanjessen! +- CLI: Fix agentic check - [#34678](https://github.com/storybookjs/storybook/pull/34678), thanks @yannbf! +- CLI: Handle minimumReleaseAge conflicts across package managers - [#34769](https://github.com/storybookjs/storybook/pull/34769), thanks @JReinhold! +- CLI: Improve package incompatibility detection and warning - [#34559](https://github.com/storybookjs/storybook/pull/34559), thanks @copilot-swe-agent! +- CLI: Improve self-healing scoring observability - [#34699](https://github.com/storybookjs/storybook/pull/34699), thanks @yannbf! +- CLI: Introduce Agentic Setup workflow - [#34297](https://github.com/storybookjs/storybook/pull/34297), thanks @yannbf! +- CLI: Remove extensive prompt option - [#34740](https://github.com/storybookjs/storybook/pull/34740), thanks @yannbf! +- CLI: Streamline Node.js version detection code - [#34440](https://github.com/storybookjs/storybook/pull/34440), thanks @Sidnioulz! +- Change-Detection: Set GIT_OPTIONAL_LOCKS=0 to avoid blocking commits - [#34726](https://github.com/storybookjs/storybook/pull/34726), thanks @valentinpalkovic! +- Cli: Set ai prompt to yes if yes flag for react-vite to tanstack migration - [#34743](https://github.com/storybookjs/storybook/pull/34743), thanks @huang-julien! +- Code: Fix inline code blocks inside links removing link affordance - [#33903](https://github.com/storybookjs/storybook/pull/33903), thanks @yatishgoel! +- Controls: Add maxPresetColors option to ColorControl - [#33998](https://github.com/storybookjs/storybook/pull/33998), thanks @mixelburg! +- Core: Add `ChangeDetectionService` and wire up builder-vite - [#34369](https://github.com/storybookjs/storybook/pull/34369), thanks @ghengeveld! +- Core: Add changeDetection feature flag - [#34314](https://github.com/storybookjs/storybook/pull/34314), thanks @valentinpalkovic! +- Core: Barrel-aware named import resolution for change detection - [#34675](https://github.com/storybookjs/storybook/pull/34675), thanks @valentinpalkovic! +- Core: Ensure process termination on SIGINT when telemetry is disabled - [#34585](https://github.com/storybookjs/storybook/pull/34585), thanks @ghengeveld! +- Core: Fix "Open In Editor" support for VSCode - [#34747](https://github.com/storybookjs/storybook/pull/34747), thanks @JReinhold! +- Core: Fix telemetry not handling canceling of prompts - [#34680](https://github.com/storybookjs/storybook/pull/34680), thanks @JReinhold! +- Core: Implement Git change detection - [#34420](https://github.com/storybookjs/storybook/pull/34420), thanks @ghengeveld! +- Core: Improve startup performance by deferring change detection initialization - [#34498](https://github.com/storybookjs/storybook/pull/34498), thanks @ghengeveld! +- Core: Normalize file paths in ChangeDetectionService and trace-changed for Windows support - [#34445](https://github.com/storybookjs/storybook/pull/34445), thanks @ghengeveld! +- Core: Quiet change-detection regex warning and swap clear icon - [#34758](https://github.com/storybookjs/storybook/pull/34758), thanks @valentinpalkovic! +- Core: Rename preview.ts to preview.tsx in generated projects - [#34396](https://github.com/storybookjs/storybook/pull/34396), thanks @Sidnioulz! +- Core: Show "new" status on newly added individual stories - [#34504](https://github.com/storybookjs/storybook/pull/34504), thanks @ghengeveld! +- Dependencies: Update `vite-plugin-storybook-nextjs` to ^3.2.4 - [#34280](https://github.com/storybookjs/storybook/pull/34280), thanks @k35o! +- Docs: Ensure unique control id attributes across multiple Controls blocks - [#34021](https://github.com/storybookjs/storybook/pull/34021), thanks @TheSeydiCharyyev! +- Fix ArgsTable borders not visible in Windows High Contrast Mode - [#34264](https://github.com/storybookjs/storybook/pull/34264), thanks @TheSeydiCharyyev! +- Fix: Add vite-plus vendored libraries version detection - [#34509](https://github.com/storybookjs/storybook/pull/34509), thanks @huang-julien! +- MDX: Replace `@storybook/docs-mdx` with inline implementation - [#34611](https://github.com/storybookjs/storybook/pull/34611), thanks @copilot-swe-agent! +- Maintenance: Add assertions outside step incorrectly nested in interactions panel - [#34296](https://github.com/storybookjs/storybook/pull/34296), thanks @majiayu000! +- Maintenance: Enhance ghost stories internal tests - [#34707](https://github.com/storybookjs/storybook/pull/34707), thanks @yannbf! +- Maintenance: Extract getBuilderOptions helper across framewo… - [#34260](https://github.com/storybookjs/storybook/pull/34260), thanks @alex-js-ltd! +- Maintenance: Extract parseFilterParam shared helper from tags and statuses modules - [#34436](https://github.com/storybookjs/storybook/pull/34436), thanks @mixelburg! +- Maintenance: Fix self healing payload - [#34782](https://github.com/storybookjs/storybook/pull/34782), thanks @yannbf! +- Maintenance: Remove dead-code copy of wrap-getAbsolutePath-utils - [#34168](https://github.com/storybookjs/storybook/pull/34168), thanks @mixelburg! +- Maintenance: Use errorToErrorLike in boot-test-runner for consistent stack deduplication - [#34385](https://github.com/storybookjs/storybook/pull/34385), thanks @mixelburg! +- Manager: URL-based tag filter state + filter-aware initial story selection - [#34283](https://github.com/storybookjs/storybook/pull/34283), thanks @valentinpalkovic! +- Nextjs: Handle node builtin webpack imports - [#34494](https://github.com/storybookjs/storybook/pull/34494), thanks @JReinhold! +- Onboarding: Fix checklist MDX instructions - [#33193](https://github.com/storybookjs/storybook/pull/33193), thanks @kylegach! +- Prompt: Run vitest fewer times, improve play functions - [#34651](https://github.com/storybookjs/storybook/pull/34651), thanks @yannbf! +- React-Docgen: Add tsconfig fallback chain and warning for monorepos - [#34353](https://github.com/storybookjs/storybook/pull/34353), thanks @viditkbhatnagar! +- React: Add component metadata extraction via Volar-style LanguageService - [#33914](https://github.com/storybookjs/storybook/pull/33914), thanks @kasperpeulen! +- React: Add subcomponents to component manifests - [#34428](https://github.com/storybookjs/storybook/pull/34428), thanks @kasperpeulen! +- ReactNative: Add Metro config AST codemod for init - [#34660](https://github.com/storybookjs/storybook/pull/34660), thanks @ndelangen! +- ReactNative: Add true entrypoint generation - [#34663](https://github.com/storybookjs/storybook/pull/34663), thanks @ndelangen! +- ReactNative: AppRegistry component name in template - [#34742](https://github.com/storybookjs/storybook/pull/34742), thanks @ndelangen! +- ReactNative: New init setup - [#34665](https://github.com/storybookjs/storybook/pull/34665), thanks @ndelangen! +- Refactor: Extract shared `PseudoStateGrid` component in pseudo-states stories - [#34334](https://github.com/storybookjs/storybook/pull/34334), thanks @copilot-swe-agent! +- Security: Makes sure `serialize-javascript` is at latest version - [#34034](https://github.com/storybookjs/storybook/pull/34034), thanks @50bbx! +- Sidebar: Add dual-slot status icons for change detection and test results - [#34346](https://github.com/storybookjs/storybook/pull/34346), thanks @valentinpalkovic! +- Sidebar: Add status-based filtering with refactored status architecture - [#34339](https://github.com/storybookjs/storybook/pull/34339), thanks @valentinpalkovic! +- Sidebar: Fix clear filter button not refreshing story list - [#34737](https://github.com/storybookjs/storybook/pull/34737), thanks @valentinpalkovic! +- Sidebar: Fix clear status button to only clear test statuses - [#34478](https://github.com/storybookjs/storybook/pull/34478), thanks @valentinpalkovic! +- Sidebar: Show same status icon at story and group level - [#34702](https://github.com/storybookjs/storybook/pull/34702), thanks @valentinpalkovic! +- Sidebar: Soften change-detection signals + add Review CTA - [#34701](https://github.com/storybookjs/storybook/pull/34701), thanks @valentinpalkovic! +- StatusValue: Add 'status-value:' - [#34305](https://github.com/storybookjs/storybook/pull/34305), thanks @valentinpalkovic! +- Svelte: Fix Vite 8 + Vitest breaking rolldown deps scanner - [#34783](https://github.com/storybookjs/storybook/pull/34783), thanks @JReinhold! +- Tanstack: Add `@storybook/tanstack-react` package - [#34403](https://github.com/storybookjs/storybook/pull/34403), thanks @huang-julien! +- Tanstack: Optimize tanstack react-store - [#34731](https://github.com/storybookjs/storybook/pull/34731), thanks @huang-julien! +- Tanstack: Treeshake top-level unused functions - [#34760](https://github.com/storybookjs/storybook/pull/34760), thanks @huang-julien! +- Telemetry: Add sidebar filter telemetry for change detection - [#34533](https://github.com/storybookjs/storybook/pull/34533), thanks @valentinpalkovic! +- Telemetry: Centralize disable logic with module-level flag - [#34485](https://github.com/storybookjs/storybook/pull/34485), thanks @valentinpalkovic! +- Telemetry: Fix delayed init events - [#34670](https://github.com/storybookjs/storybook/pull/34670), thanks @JReinhold! +- Telemetry: Refactor init tracking - [#34629](https://github.com/storybookjs/storybook/pull/34629), thanks @Programer1804! +- UI: Add Share section to onboarding checklist and redesign share tool - [#34413](https://github.com/storybookjs/storybook/pull/34413), thanks @valentinpalkovic! +- UI: Ensure Controls panel can scroll horizontally for now - [#34248](https://github.com/storybookjs/storybook/pull/34248), thanks @Sidnioulz! +- UI: Fix global shortcuts not showing region focus indicator - [#34201](https://github.com/storybookjs/storybook/pull/34201), thanks @Sidnioulz! +- UI: Fix mobile navigation when renderLabel returns a React node - [#34262](https://github.com/storybookjs/storybook/pull/34262), thanks @Nathan54Villaume! +- UI: Fix showing and hiding copy prompt in the correct scenarios - [#34706](https://github.com/storybookjs/storybook/pull/34706), thanks @yannbf! +- UI: Improve interactions panel accessibility - [#34110](https://github.com/storybookjs/storybook/pull/34110), thanks @anchmelev! +- Vite: Use vite hook filter for performance improvements - [#34022](https://github.com/storybookjs/storybook/pull/34022), thanks @huang-julien! +- Vitest: Fix agent detection breaking runs - [#34681](https://github.com/storybookjs/storybook/pull/34681), thanks @JReinhold! +- Vue3: Clear stale args/globals when nextArgs is empty in updateArgs - [#34409](https://github.com/storybookjs/storybook/pull/34409), thanks @whdjh! + +
+ ## 10.3.6 - ESLint: Update deprecated @storybook/test reference to storybook/test - [#34430](https://github.com/storybookjs/storybook/pull/34430), thanks @venkat22022202! diff --git a/CHANGELOG.prerelease.md b/CHANGELOG.prerelease.md index 0b0cc2349d60..ac8d75733c9a 100644 --- a/CHANGELOG.prerelease.md +++ b/CHANGELOG.prerelease.md @@ -1,3 +1,208 @@ +## 10.5.0-beta.1 + +- Core: Add experimentalReview feature flag and make the features type augmentable - [#35379](https://github.com/storybookjs/storybook/pull/35379), thanks @yannbf! + +## 10.5.0-beta.0 + + +## 10.5.0-alpha.11 + +- Addon-vitest: Add an initialGlobals option to pin a project's globals - [#35226](https://github.com/storybookjs/storybook/pull/35226), thanks @lifeiscontent! +- Core: Add ai-review observability - [#35300](https://github.com/storybookjs/storybook/pull/35300), thanks @yannbf! +- Core: Allow vite-plus 0.2.x in the optional peer range - [#35221](https://github.com/storybookjs/storybook/pull/35221), thanks @lifeiscontent! +- Manager: Restructure review feature state, layout integration, and thumbnail pipeline - [#35351](https://github.com/storybookjs/storybook/pull/35351), thanks @ghengeveld! +- Preview: Restore iframe scrolling after resizing the layout - [#35310](https://github.com/storybookjs/storybook/pull/35310), thanks @yatishgoel! + +## 10.5.0-alpha.10 + +- A11y: Handle lang attribute throughout preview - [#35321](https://github.com/storybookjs/storybook/pull/35321), thanks @Sidnioulz! +- A11y: Surface required args and keyboard-reachable Setup controls in ArgsTable - [#35306](https://github.com/storybookjs/storybook/pull/35306), thanks @Sidnioulz! +- Addon-Vitest: Subscribe for run completion before triggering it - [#35291](https://github.com/storybookjs/storybook/pull/35291), thanks @tsushanth! +- Core: Compose core annotations before preview beforeAll - [#35323](https://github.com/storybookjs/storybook/pull/35323), thanks @JReinhold! +- Docgen: Run experimentalDocgenServer extraction in a worker thread - [#35324](https://github.com/storybookjs/storybook/pull/35324), thanks @JReinhold! +- DocgenServer: Fix OOM by recycling the shared TS program - [#35305](https://github.com/storybookjs/storybook/pull/35305), thanks @ndelangen! +- Next: Call link onClick before preventing default - [#35311](https://github.com/storybookjs/storybook/pull/35311), thanks @yatishgoel! +- Review: Dynamic thumbnail scaling based on content dimensions - [#35282](https://github.com/storybookjs/storybook/pull/35282), thanks @ghengeveld! +- Review: Improve review thumbnail scaling and loading UX - [#35335](https://github.com/storybookjs/storybook/pull/35335), thanks @ghengeveld! +- Review: Notify on review arrival instead of auto-navigating - [#35276](https://github.com/storybookjs/storybook/pull/35276), thanks @ghengeveld! +- Review: Simplify layout handling - [#35322](https://github.com/storybookjs/storybook/pull/35322), thanks @ghengeveld! +- UI: Hide onboarding menu with feature flag - [#35299](https://github.com/storybookjs/storybook/pull/35299), thanks @Sidnioulz! +- Vue: Fix Unicode Default Values In Docs - [#34909](https://github.com/storybookjs/storybook/pull/34909), thanks @Arunsiva003! + +## 10.5.0-alpha.9 + +- Addon Vitest: Avoid erroring out on benign Win process exits - [#35287](https://github.com/storybookjs/storybook/pull/35287), thanks @Sidnioulz! +- Core: Introduce Agentic Review feature - [#34837](https://github.com/storybookjs/storybook/pull/34837), thanks @yannbf! +- Core: Recognize addon-mcp registered via getAbsolutePath() in runtime instance registry - [#35286](https://github.com/storybookjs/storybook/pull/35286), thanks @yannbf! +- ESLint Plugin: Avoid ESLint Unstable API Load - [#35269](https://github.com/storybookjs/storybook/pull/35269), thanks @pupuking723! +- Index: Remove `hasActiveFilters` check - [#35151](https://github.com/storybookjs/storybook/pull/35151), thanks @mrginglymus! + +## 10.5.0-alpha.8 + +- Angular: Add versioned `@types/node` to packages installed during `storybook init` - [#34192](https://github.com/storybookjs/storybook/pull/34192), thanks @copilot-swe-agent! +- Angular: Introduce @storybook/angular-vite package - [#34202](https://github.com/storybookjs/storybook/pull/34202), thanks @brandonroberts! +- Angular: Use future-proof API for component reflection - [#35228](https://github.com/storybookjs/storybook/pull/35228), thanks @Sidnioulz! +- Babel: Remove bugfixes from preset-env in v8 - [#35266](https://github.com/storybookjs/storybook/pull/35266), thanks @Sidnioulz! +- Builder-Vite: Fix empty external globals imports - [#34348](https://github.com/storybookjs/storybook/pull/34348), thanks @raashish1601! +- CLI: Exit process after successful build - [#34735](https://github.com/storybookjs/storybook/pull/34735), thanks @torleifhalseth! +- CLI: Fix upgrade crash on Storybook latest version check - [#35156](https://github.com/storybookjs/storybook/pull/35156), thanks @yatishgoel! +- CLI: Load Storybook AI help from preset metadata - [#35212](https://github.com/storybookjs/storybook/pull/35212), thanks @kasperpeulen! +- CLI: Prefer agent-matched Storybook instances - [#35235](https://github.com/storybookjs/storybook/pull/35235), thanks @kasperpeulen! +- CLI: Show MCP workflow instructions in AI help - [#35164](https://github.com/storybookjs/storybook/pull/35164), thanks @kasperpeulen! +- CLI: Support Claude preview autoPort - [#35259](https://github.com/storybookjs/storybook/pull/35259), thanks @kasperpeulen! +- Cli: Install MCP when upgrade is ran by agent - [#35215](https://github.com/storybookjs/storybook/pull/35215), thanks @huang-julien! +- Controls: Prevent AbortError when rapidly resetting - [#34412](https://github.com/storybookjs/storybook/pull/34412), thanks @whdjh! +- Controls: Prevent the save bar from covering the last control - [#35136](https://github.com/storybookjs/storybook/pull/35136), thanks @TheSeydiCharyyev! +- Core: Fix npm dependency detection - [#35083](https://github.com/storybookjs/storybook/pull/35083), thanks @fallintoplace! +- Core: Flush preview-api hook effects in portable stories - [#35224](https://github.com/storybookjs/storybook/pull/35224), thanks @lifeiscontent! +- Core: Include chromatic packages in ecosystem identifier - [#35170](https://github.com/storybookjs/storybook/pull/35170), thanks @yannbf! +- Core: Use UndoIcon for Review-changes clear button - [#34767](https://github.com/storybookjs/storybook/pull/34767), thanks @valentinpalkovic! +- Docgen: Fix inferred argTypes from initial args overriding docgen and custom argTypes in experimentalDocgenServer mode - [#35196](https://github.com/storybookjs/storybook/pull/35196), thanks @JReinhold! +- Docs: Add shallow MDX ref manifests for experimentalDocgenServer - [#35189](https://github.com/storybookjs/storybook/pull/35189), thanks @JReinhold! +- Docs: Improve DocsParameters types - [#35190](https://github.com/storybookjs/storybook/pull/35190), thanks @mrginglymus! +- Docs: Restore React 16/17 support in useServiceDocgen - [#35179](https://github.com/storybookjs/storybook/pull/35179), thanks @JReinhold! +- Manager: Fix crash when toggling addon panel on mobile (LandmarkManager TypeError) - [#34790](https://github.com/storybookjs/storybook/pull/34790), thanks @valentinpalkovic! +- Manager: Fix race condition in `experimental_setFilter` - [#35194](https://github.com/storybookjs/storybook/pull/35194), thanks @mrginglymus! +- Measure & Outline: Honor the `disable` parameter - [#35150](https://github.com/storybookjs/storybook/pull/35150), thanks @yatishgoel! +- ModuleGraph: Stop bumping the graph revision on story-index invalidation - [#35178](https://github.com/storybookjs/storybook/pull/35178), thanks @JReinhold! +- NextJS: Support `as` prop in next/link mock - [#35148](https://github.com/storybookjs/storybook/pull/35148), thanks @yatishgoel! +- Open Service: Split story docs from docgen service - [#35169](https://github.com/storybookjs/storybook/pull/35169), thanks @JReinhold! +- Open-Service: Introduce query states (loading/error/success) - [#35214](https://github.com/storybookjs/storybook/pull/35214), thanks @JReinhold! +- Open-Service: Type getService for core services per runtime - [#35242](https://github.com/storybookjs/storybook/pull/35242), thanks @JReinhold! +- Preview: Preserve primitive args for non-ReactNode other types - [#35088](https://github.com/storybookjs/storybook/pull/35088), thanks @TheSeydiCharyyev! +- PseudoStates: Preserve URL globals on story change - [#35211](https://github.com/storybookjs/storybook/pull/35211), thanks @Sidnioulz! +- React Native: Remove babel deps from RN setup (not needed) - [#35243](https://github.com/storybookjs/storybook/pull/35243), thanks @dannyhw! +- React: Align react-docgen versions - [#35271](https://github.com/storybookjs/storybook/pull/35271), thanks @Sidnioulz! +- UI: Fix args not preserved in isolation mode - [#35055](https://github.com/storybookjs/storybook/pull/35055), thanks @sijie-Z! +- Vue: Use AST to inject __docgenInfo correctly - [#35201](https://github.com/storybookjs/storybook/pull/35201), thanks @Sidnioulz! + +## 10.5.0-alpha.7 + +- CLI: Add `storybook ai ` MCP passthrough behind `STORYBOOK_FEATURE_AI_CLI` - [#35125](https://github.com/storybookjs/storybook/pull/35125), thanks @kasperpeulen! +- CLI: Add telemetry for the `storybook ai ` passthrough - [#35138](https://github.com/storybookjs/storybook/pull/35138), thanks @kasperpeulen! +- CLI: Bundle the `ai` command in core so it never downloads `@storybook/cli` - [#35147](https://github.com/storybookjs/storybook/pull/35147), thanks @kasperpeulen! +- CSF: Allow partial globals overrides in story and meta annotations - [#34985](https://github.com/storybookjs/storybook/pull/34985), thanks @TheSeydiCharyyev! +- Dependencies: Upgrade esbuild - [#35157](https://github.com/storybookjs/storybook/pull/35157), thanks @Kakadus! +- Docgen: Register service runtime, payload argTypes - [#35108](https://github.com/storybookjs/storybook/pull/35108), thanks @JReinhold! +- Docs: Route ArgTypes and Controls through docgen service when flag enabled - [#35109](https://github.com/storybookjs/storybook/pull/35109), thanks @JReinhold! +- Open Service: Fix docgen hot update controls refresh - [#35158](https://github.com/storybookjs/storybook/pull/35158), thanks @JReinhold! +- Open Service: Load static query snapshots in browser builds - [#35154](https://github.com/storybookjs/storybook/pull/35154), thanks @JReinhold! +- Open Service: Mark module-graph engine commands as internal - [#35144](https://github.com/storybookjs/storybook/pull/35144), thanks @JReinhold! +- Telemetry: Preserve state machine when the module loads more than once - [#35140](https://github.com/storybookjs/storybook/pull/35140), thanks @kasperpeulen! +- Viewport: Warn when legacy `defaultViewport` parameter is used - [#35087](https://github.com/storybookjs/storybook/pull/35087), thanks @yatishgoel! + +## 10.5.0-alpha.6 + +- Controls: Guard normalizeOptions against array labels and prototype chain lookups - [#34664](https://github.com/storybookjs/storybook/pull/34664), thanks @creazyfrog! +- Core: Add ref-based components manifest index backed by docgen open service - [#35063](https://github.com/storybookjs/storybook/pull/35063), thanks @JReinhold! +- Core: Fix resolveImport TSX fallback - [#34815](https://github.com/storybookjs/storybook/pull/34815), thanks @cyphercodes! +- Core: Rework AI checklist feature gate - [#35053](https://github.com/storybookjs/storybook/pull/35053), thanks @Sidnioulz! +- Docs: Deprecate ExternalDocs - [#35074](https://github.com/storybookjs/storybook/pull/35074), thanks @Sidnioulz! +- Interactions: Prevent debugger controls from stealing focus - [#35073](https://github.com/storybookjs/storybook/pull/35073), thanks @BrenoSI03! +- Manager: Keep local preview iframe on host URL when a ref story loads first - [#35078](https://github.com/storybookjs/storybook/pull/35078), thanks @TheSeydiCharyyev! +- Module Graph: Refactor from StoryDependencyService to open service-based ModuleGraphService - [#35048](https://github.com/storybookjs/storybook/pull/35048), thanks @JReinhold! +- Open Service: Add `internal` property to control visibility - [#35057](https://github.com/storybookjs/storybook/pull/35057), thanks @JReinhold! +- Open Service: Add sync between server, manager and preview - [#35017](https://github.com/storybookjs/storybook/pull/35017), thanks @ndelangen! +- Open Service: Call remote commands in load functions - [#35106](https://github.com/storybookjs/storybook/pull/35106), thanks @JReinhold! +- Preview: Stop mixed CSF3+4 stories getting core annotations injected twice - [#35094](https://github.com/storybookjs/storybook/pull/35094), thanks @JReinhold! +- React: Fix subcomponent prop extraction without JSX usage - [#35107](https://github.com/storybookjs/storybook/pull/35107), thanks @JReinhold! +- Telemetry: Add timeout to event-log POST to prevent build hang - [#35085](https://github.com/storybookjs/storybook/pull/35085), thanks @badams! +- Viewport: Highlight toolbar button when custom viewport is active - [#35075](https://github.com/storybookjs/storybook/pull/35075), thanks @derinbarutcu17! +- Webpack: Gate lazy-compilation import pipeline on webpack version - [#34931](https://github.com/storybookjs/storybook/pull/34931), thanks @copilot-swe-agent! + +## 10.5.0-alpha.5 + +- Addon Docs: DocsContent not filling available width when TOC is enabled - [#35043](https://github.com/storybookjs/storybook/pull/35043), thanks @k-utsumi! +- Addon Docs: Fix Primary and Controls blocks not rendering in custom MDX pages - [#34496](https://github.com/storybookjs/storybook/pull/34496), thanks @NYCU-Chung! +- CSF: Fix Canvas and userEvent types under Yarn PnP - [#34986](https://github.com/storybookjs/storybook/pull/34986), thanks @yatishgoel! +- Controls: Load controls for stories from a composed ref - [#35050](https://github.com/storybookjs/storybook/pull/35050), thanks @TheSeydiCharyyev! +- Core: Expose backgrounds types from public subpath - [#35044](https://github.com/storybookjs/storybook/pull/35044), thanks @ShaharAviram1! +- Core: Respect !dev tag on MDX docs in sidebar - [#35031](https://github.com/storybookjs/storybook/pull/35031), thanks @JReinhold! +- Docgen: Add mock open service - [#34954](https://github.com/storybookjs/storybook/pull/34954), thanks @JReinhold! +- Docgen: Wire up react-component-meta with DocgenService - [#34963](https://github.com/storybookjs/storybook/pull/34963), thanks @JReinhold! +- Docs: Fix Controls losing focus when subcomponents are defined - [#35018](https://github.com/storybookjs/storybook/pull/35018), thanks @cssinate! +- Open Service: Fix reactivity on deep signals, fire subscribers on load dependencies - [#34979](https://github.com/storybookjs/storybook/pull/34979), thanks @JReinhold! +- Preview: Use width/height instead of min-* in CanvasWrap - [#35056](https://github.com/storybookjs/storybook/pull/35056), thanks @DevLikhith5! +- Pseudo States: Keep combinator broad pseudo selectors valid - [#35060](https://github.com/storybookjs/storybook/pull/35060), thanks @mturac! +- React: Add support for resolving subcomponents attached as properties of a parent component - [#34967](https://github.com/storybookjs/storybook/pull/34967), thanks @yatishgoel! +- TanStack: Normalize route-group handling in createRoute mock - [#34948](https://github.com/storybookjs/storybook/pull/34948), thanks @copilot-swe-agent! +- UI: Prevent docs page scroll reset on HMR re-render - [#35021](https://github.com/storybookjs/storybook/pull/35021), thanks @LongTangGithub! + +## 10.5.0-alpha.4 + +- Addon Vitest: Fix dynamic import failure with Vitest 3 - [#34927](https://github.com/storybookjs/storybook/pull/34927), thanks @Sidnioulz! +- Bug: Fix Windows command resolution for non-Node package managers - [#33534](https://github.com/storybookjs/storybook/pull/33534), thanks @copilot-swe-agent! +- CSF Next: Propagate skip tags to .test children - [#34964](https://github.com/storybookjs/storybook/pull/34964), thanks @kwonoj! +- Controls: Improve ArgsTable empty state guidance - [#34857](https://github.com/storybookjs/storybook/pull/34857), thanks @Aniketiitk21! +- Core: Add missing export to globals - [#34929](https://github.com/storybookjs/storybook/pull/34929), thanks @Sidnioulz! +- Core: Clean stale runtime instance records - [#35023](https://github.com/storybookjs/storybook/pull/35023), thanks @kasperpeulen! +- Core: Extract StoryDependencyGraphService from ChangeDetectionService - [#35009](https://github.com/storybookjs/storybook/pull/35009), thanks @valentinpalkovic! +- Docs: Prevent heading anchor cutoff on docs pages - [#34945](https://github.com/storybookjs/storybook/pull/34945), thanks @copilot-swe-agent! +- Manager: Fix layout.showPanel config - [#34777](https://github.com/storybookjs/storybook/pull/34777), thanks @kalinco-glitch! +- Open Service: Sync queries, load/loaded() API, strict reader handlers - [#34932](https://github.com/storybookjs/storybook/pull/34932), thanks @JReinhold! +- Open-Service: Add schema-driven service runtime - [#34860](https://github.com/storybookjs/storybook/pull/34860), thanks @JReinhold! +- Open-service: Implement service registration on the server (attempt 2) - [#34961](https://github.com/storybookjs/storybook/pull/34961), thanks @JReinhold! +- Open-service: Implement service registration on the server - [#34875](https://github.com/storybookjs/storybook/pull/34875), thanks @JReinhold! +- Preview: Preserve @ts-expect-error in web-component and vue preview - [#34839](https://github.com/storybookjs/storybook/pull/34839), thanks @brentswisher! +- Publish: Add npm provenance attestations - [#34936](https://github.com/storybookjs/storybook/pull/34936), thanks @copilot-swe-agent! +- React: Render boolean props set to false in source snippets - [#34968](https://github.com/storybookjs/storybook/pull/34968), thanks @valentinpalkovic! +- Tanstack: Export `TanStackPreview` from `@storybook/tanstack-react` to unblock CSF Next typing - [#34949](https://github.com/storybookjs/storybook/pull/34949), thanks @copilot-swe-agent! +- Tanstack: Remove Outlet mock - [#35010](https://github.com/storybookjs/storybook/pull/35010), thanks @huang-julien! +- Tanstack: Supply id OR path when using RouteOptions for route mock - [#34950](https://github.com/storybookjs/storybook/pull/34950), thanks @copilot-swe-agent! +- Vue: Ensure vue-component-meta runs in post - [#34976](https://github.com/storybookjs/storybook/pull/34976), thanks @Sidnioulz! + +## 10.5.0-alpha.3 + +- A11y-Addon: Preserve disabled a11y rules with runOnly - [#34649](https://github.com/storybookjs/storybook/pull/34649), thanks @cyphercodes! +- Add an optional TypeScript peer to react-vite - [#34627](https://github.com/storybookjs/storybook/pull/34627), thanks @wojtekmaj! +- Addon-Docs: Resolve CSF4 module exports without a default export - [#34834](https://github.com/storybookjs/storybook/pull/34834), thanks @TheSeydiCharyyev! +- Addon-Docs: Resolve providerImportSource to a path instead of a file:// URL - [#34841](https://github.com/storybookjs/storybook/pull/34841), thanks @TheSeydiCharyyev! +- Build: Upgrade type-fest to latest version 5.6.0 - [#34791](https://github.com/storybookjs/storybook/pull/34791), thanks @tobiasdiez! +- CLI: Respect BROWSER and BROWSER_ARGS - [#34513](https://github.com/storybookjs/storybook/pull/34513), thanks @ianzone! +- CSF-Next: Add tags type support for - [#34819](https://github.com/storybookjs/storybook/pull/34819), thanks @unional! +- CSF: Fix parsing of string literal export names - [#34901](https://github.com/storybookjs/storybook/pull/34901), thanks @shilman! +- Controls: Add label to Object JSON control - [#34766](https://github.com/storybookjs/storybook/pull/34766), thanks @Jaksenc! +- Core: Add runtime instance registry - [#34863](https://github.com/storybookjs/storybook/pull/34863), thanks @kasperpeulen! +- Core: Categorize UniversalStore follower timeout error - [#34592](https://github.com/storybookjs/storybook/pull/34592), thanks @justismailmemon! +- Core: Fix EEXIST race condition in static file copying during build - [#34499](https://github.com/storybookjs/storybook/pull/34499), thanks @flt3150sk! +- Core: Improve ActionBar focus indicator in high contrast mode - [#34779](https://github.com/storybookjs/storybook/pull/34779), thanks @TheSeydiCharyyev! +- Core: Incorrect package json handling - [#34515](https://github.com/storybookjs/storybook/pull/34515), thanks @lino-levan! +- Maintenance: Centralize supported file extension lists - [#34844](https://github.com/storybookjs/storybook/pull/34844), thanks @valentinpalkovic! +- UI: Allow manager-head favicon override - [#34809](https://github.com/storybookjs/storybook/pull/34809), thanks @MukundaKatta! + +## 10.5.0-alpha.2 + +- A11y: Fix MDX heading anchors not keyboard accessible - [#34368](https://github.com/storybookjs/storybook/pull/34368), thanks @TheSeydiCharyyev! +- Angular: Detect model() signal outputs (type inference + compodoc autodocs + runtime binding) - [#34833](https://github.com/storybookjs/storybook/pull/34833), thanks @valentinpalkovic! +- CLI: Support `peerDependencies` in framework detection for component libraries - [#34516](https://github.com/storybookjs/storybook/pull/34516), thanks @zhyd1997! +- Docs: Add ariaLabel support to ActionItem interface - [#34749](https://github.com/storybookjs/storybook/pull/34749), thanks @TheSeydiCharyyev! +- Docs: Scope control input ids to each block instance - [#34793](https://github.com/storybookjs/storybook/pull/34793), thanks @TheSeydiCharyyev! +- Docs: Support explicit id prop on for standalone MDX - [#34808](https://github.com/storybookjs/storybook/pull/34808), thanks @TheSeydiCharyyev! +- Maintenance: Replace `resolve` and `resolve.exports` with `oxc-resolver` - [#34692](https://github.com/storybookjs/storybook/pull/34692), thanks @valentinpalkovic! +- Next.js-Vite: Bump vite-plugin-storybook-nextjs to ^3.3.0 - [#34838](https://github.com/storybookjs/storybook/pull/34838), thanks @yatishgoel! +- Vitest: Reset playwright cursor position to avoid hover bug - [#34765](https://github.com/storybookjs/storybook/pull/34765), thanks @Sidnioulz! +- Vue3: Specify a specific version for non-dev dependency - [#34794](https://github.com/storybookjs/storybook/pull/34794), thanks @ScopeyNZ! + +## 10.5.0-alpha.1 + +- Angular: Fix custom paths for stats.json on angular - [#34551](https://github.com/storybookjs/storybook/pull/34551), thanks @mrginglymus! +- Builder-Vite: Support configLoader via builder options - [#34080](https://github.com/storybookjs/storybook/pull/34080), thanks @holvi-sebastian! +- CLI: Run `npx expo install --fix` after init for Expo projects - [#34803](https://github.com/storybookjs/storybook/pull/34803), thanks @ndelangen! +- Core: Ignore story-like directories in indexer - [#34806](https://github.com/storybookjs/storybook/pull/34806), thanks @MukundaKatta! +- Next.js Vite: Add Link mock with useLinkStatus to nextjs-vite framework - [#34736](https://github.com/storybookjs/storybook/pull/34736), thanks @yatishgoel! +- Next.js: Add useLinkStatus mock to next/link export mock - [#34593](https://github.com/storybookjs/storybook/pull/34593), thanks @philwolstenholme! +- Test: Move @testing-library/dom to dependencies - [#34604](https://github.com/storybookjs/storybook/pull/34604), thanks @XionWCFM! + +## 10.5.0-alpha.0 + + +## 10.4.0-beta.0 + +- CLI: Handle minimumReleaseAge conflicts across package managers - [#34769](https://github.com/storybookjs/storybook/pull/34769), thanks @JReinhold! +- Core: Fix telemetry not handling canceling of prompts - [#34680](https://github.com/storybookjs/storybook/pull/34680), thanks @JReinhold! +- Maintenance: Fix self healing payload - [#34782](https://github.com/storybookjs/storybook/pull/34782), thanks @yannbf! +- Svelte: Fix Vite 8 + Vitest breaking rolldown deps scanner - [#34783](https://github.com/storybookjs/storybook/pull/34783), thanks @JReinhold! + ## 10.4.0-alpha.19 - Agentic Setup: Add --extensive for an extra prompt - [#34730](https://github.com/storybookjs/storybook/pull/34730), thanks @Sidnioulz! diff --git a/CODEOWNERS b/CODEOWNERS index 5ed2da00b6f4..4ed754fbc2e5 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -62,4 +62,4 @@ # /code/renderers/web-components/ @kasperpeulen @JReinhold # E2E -# /code/e2e-tests/ @yannbf @valentinpalkovic +# /code/e2e-sandbox/ @yannbf @valentinpalkovic diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fd2886782808..593cb8d23b31 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -90,7 +90,8 @@ Here's a highlight of notable directories and files: │ ├── builders │ ├── chromatic.config.json │ ├── core # Core package for UI and API of Storybook -│ ├── e2e-tests +│ ├── e2e-internal # Playwright e2e tests for the internal Storybook UI +│ ├── e2e-sandbox # Playwright e2e tests for generated sandboxes │ ├── frameworks # Different framework-bundler versions of Storybook │ ├── lib # CLI and plugins │ ├── node_modules diff --git a/MIGRATION.md b/MIGRATION.md index 6dffa77bd1f3..4f746e55026c 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1,8 +1,10 @@

Migration

+- [From version 10.4.0 to 10.5.0](#from-version-1040-to-1050) + - [ExternalDocs and ExternalDocsContainer are deprecated](#externaldocs-and-externaldocscontainer-are-deprecated) - [From version 10.3.0 to 10.4.0](#from-version-1030-to-1040) - [React Native: on-device addons moved to `deviceAddons`](#react-native-on-device-addons-moved-to-deviceaddons) - - [TanStack React: migrate from `@storybook/react-vite` to `@storybook/tanstack-react`](#tanstack-router-projects-migrate-from-storybookreact-vite-to-storybooktanstack-react) + - [TanStack Router projects: migrate from `@storybook/react-vite` to `@storybook/tanstack-react`](#tanstack-router-projects-migrate-from-storybookreact-vite-to-storybooktanstack-react) - [From version 10.0.0 to 10.1.0](#from-version-1000-to-1010) - [API and Component Changes](#api-and-component-changes) - [Button Component API Changes](#button-component-api-changes) @@ -523,6 +525,14 @@ - [Packages renaming](#packages-renaming) - [Deprecated embedded addons](#deprecated-embedded-addons) +## From version 10.4.0 to 10.5.0 + +### ExternalDocs and ExternalDocsContainer are deprecated + +The `ExternalDocs` and `ExternalDocsContainer` components from `@storybook/addon-docs` are deprecated and will be removed in Storybook 11. These components allowed rendering Storybook docs pages outside of the Storybook UI, but the approach was never stabilized and is redundant with other embedding options. + +If you are currently using `ExternalDocs` or `ExternalDocsContainer`, please open an issue describing your use case so the team can consider alternative solutions. + ## From version 10.3.0 to 10.4.0 ### React Native: on-device addons moved to `deviceAddons` diff --git a/code/.eslintrc.js b/code/.eslintrc.js index 03e449456d27..5900708a2b05 100644 --- a/code/.eslintrc.js +++ b/code/.eslintrc.js @@ -201,7 +201,7 @@ module.exports = { }, }, { - files: ['./e2e-tests/*.ts'], + files: ['./e2e-sandbox/*.ts', './e2e-internal/*.ts'], extends: ['plugin:playwright/recommended'], rules: { 'playwright/no-skipped-test': [ diff --git a/code/.storybook/main.ts b/code/.storybook/main.ts index c1e5d725acdc..89ac7e4a1a08 100644 --- a/code/.storybook/main.ts +++ b/code/.storybook/main.ts @@ -2,8 +2,10 @@ import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { defineMain } from '@storybook/react-vite/node'; +import type { Options } from 'storybook/internal/types'; import react from '@vitejs/plugin-react'; +import type { InlineConfig } from 'vite'; import { BROWSER_TARGETS } from '../core/src/shared/constants/environments-support.ts'; @@ -12,6 +14,7 @@ const currentDirPath = dirname(currentFilePath); const componentsPath = join(currentDirPath, '../core/src/components/index.ts'); const managerApiPath = join(currentDirPath, '../core/src/manager-api/index.mock.ts'); +const previewApiPath = join(currentDirPath, '../core/src/preview-api/index.ts'); const themingCreatePath = join(currentDirPath, '../core/src/theming/create.ts'); const themingPath = join(currentDirPath, '../core/src/theming/index.ts'); const imageContextPath = join(currentDirPath, '../frameworks/nextjs/src/image-context.ts'); @@ -35,6 +38,10 @@ const config = defineMain({ directory: '../core/src/preview', titlePrefix: 'preview', }, + { + directory: '../core/src/shared', + titlePrefix: 'core/shared', + }, { directory: '../core/src/components/brand', titlePrefix: 'brand', @@ -118,6 +125,7 @@ const config = defineMain({ '@storybook/addon-mcp', 'storybook-addon-pseudo-states', '@chromatic-com/storybook', + './services-preset.ts', ], previewAnnotations: [ './core/template/stories/preview.ts', @@ -149,10 +157,15 @@ const config = defineMain({ features: { developmentModeForBuild: true, experimentalTestSyntax: true, + // Disabled by default for production builds; the internal dev server enables it via + // STORYBOOK_EXPERIMENTAL_DOCGEN_SERVER so hot-update e2e covers the open-service path. + experimentalDocgenServer: process.env.STORYBOOK_EXPERIMENTAL_DOCGEN_SERVER === 'true', + experimentalReactComponentMeta: true, changeDetection: true, + experimentalReview: true, }, staticDirs: [{ from: './bench/bundle-analyzer', to: '/bundle-analyzer' }], - viteFinal: async (viteConfig, { configType }) => { + viteFinal: async (viteConfig: InlineConfig, { configType }: Options) => { const { mergeConfig } = await import('vite'); return mergeConfig(viteConfig, { @@ -162,12 +175,14 @@ const config = defineMain({ ? { 'storybook/internal/components': componentsPath, 'storybook/manager-api': managerApiPath, + 'storybook/preview-api': previewApiPath, 'storybook/theming/create': themingCreatePath, 'storybook/theming': themingPath, 'sb-original/image-context': imageContextPath, } : { 'storybook/manager-api': managerApiPath, + 'storybook/preview-api': previewApiPath, }, }, plugins: [react()], @@ -179,12 +194,17 @@ const config = defineMain({ server: { watch: { // Something odd happens with tsconfig and nx which causes Storybook to keep reloading, so we ignore them - ignored: ['**/.nx/cache/**', '**/tsconfig.json'], + ignored: [ + '**/.nx/cache/**', + '**/tsconfig.json', + // Internal e2e writes traces under code/playwright-results while the dev server is running. + '**/playwright-results/**', + '**/playwright-report/**', + ], }, }, } satisfies typeof viteConfig); }, - // logLevel: 'debug', }); export default config; diff --git a/code/.storybook/manager.tsx b/code/.storybook/manager.tsx index 890e86b6e394..b2436c5b3dd8 100644 --- a/code/.storybook/manager.tsx +++ b/code/.storybook/manager.tsx @@ -7,3 +7,5 @@ addons.setConfig({ renderLabel: ({ name, type }) => (type === 'story' ? name : startCase(name)), }, }); + +import '../core/src/shared/open-service/sync-test/manager.tsx'; diff --git a/code/.storybook/open-service-debug-service.ts b/code/.storybook/open-service-debug-service.ts new file mode 100644 index 000000000000..af01c2af0265 --- /dev/null +++ b/code/.storybook/open-service-debug-service.ts @@ -0,0 +1,189 @@ +import * as v from 'valibot'; + +import { logger } from 'storybook/internal/node-logger'; +import type { StoryIndexGenerator } from '../core/src/core-server/utils/StoryIndexGenerator.ts'; + +import { defineService } from 'storybook/open-service'; +import { describeService, registerService } from '../core/src/shared/open-service/server.ts'; + +const DEBUG_SERVICE_ID = 'storybook/internal/open-service-debug'; + +type DebugServiceState = { + activity: string[]; + preloadedByEntryId: Record; + lastObservedValue: string | null; + storyIndexEntryCount: number; + storyIndexSampleIds: string[]; +}; + +const messageInputSchema = v.object({ message: v.string() }); +const entryInputSchema = v.object({ entryId: v.string() }); +const activityQueryInputSchema = v.object({ limit: v.number() }); +const preloadVisitInputSchema = v.object({ + entryId: v.string(), + source: v.string(), +}); +const storyIndexSummaryInputSchema = v.object({ includeSampleIds: v.boolean() }); +const storyIndexSummaryOutputSchema = v.object({ + entryCount: v.number(), + sampleIds: v.array(v.string()), +}); +const syncStoryIndexInputSchema = v.object({ reason: v.string() }); + +function createDebugServiceDef(storyIndexGeneratorPromise: Promise) { + return defineService({ + id: DEBUG_SERVICE_ID, + description: + 'Exercises Storybook open-service registration, queries, commands, loads, subscriptions, static builds, and story-index integration inside the internal Storybook.', + initialState: { + activity: [], + preloadedByEntryId: {}, + lastObservedValue: null, + storyIndexEntryCount: 0, + storyIndexSampleIds: [], + } as DebugServiceState, + queries: { + activity: { + description: 'Returns the latest activity entries for the debug service.', + input: activityQueryInputSchema, + output: v.array(v.string()), + handler: (input, ctx) => { + logger.warn('[open-service debug] query activity'); + return ctx.self.state.activity.slice(-input.limit); + }, + }, + storyIndexSummary: { + description: 'Returns story-index-derived summary data captured by the debug service.', + input: storyIndexSummaryInputSchema, + output: storyIndexSummaryOutputSchema, + handler: (input, ctx) => { + logger.warn('[open-service debug] query storyIndexSummary'); + return { + entryCount: ctx.self.state.storyIndexEntryCount, + sampleIds: input.includeSampleIds ? ctx.self.state.storyIndexSampleIds : [], + }; + }, + }, + preloadedValue: { + description: + 'Returns a preloaded value for one entry id and participates in static builds.', + input: entryInputSchema, + output: v.nullable(v.string()), + load: async (input, ctx) => { + logger.warn(`[open-service debug] load preloadedValue(${input.entryId})`); + if (ctx.self.state.preloadedByEntryId[input.entryId] !== undefined) { + return; + } + + await ctx.self.commands.recordPreloadVisit({ + entryId: input.entryId, + source: 'load', + }); + }, + staticPath: (input) => `${input.entryId}.json`, + staticInputs: async () => [{ entryId: 'static-a' }, { entryId: 'static-b' }], + handler: (input, ctx) => { + const value = ctx.self.state.preloadedByEntryId[input.entryId] ?? null; + + logger.warn(`[open-service debug] query preloadedValue(${input.entryId}) => ${value}`); + return value; + }, + }, + }, + commands: { + addActivity: { + description: 'Appends one entry to the debug activity log.', + input: messageInputSchema, + output: v.undefined(), + handler: async (input, ctx) => { + logger.warn(`[open-service debug] command addActivity(${input.message})`); + ctx.self.setState((state) => { + state.activity.push(input.message); + }); + + return undefined; + }, + }, + syncStoryIndex: { + description: 'Reads the current story index and stores a compact summary in service state.', + input: syncStoryIndexInputSchema, + output: v.undefined(), + handler: async (input, ctx) => { + const storyIndex = await (await storyIndexGeneratorPromise).getIndex(); + const sampleIds = Object.keys(storyIndex.entries).slice(0, 5); + + logger.warn( + `[open-service debug] command syncStoryIndex(${input.reason}) => ${Object.keys(storyIndex.entries).length} entries` + ); + ctx.self.setState((state) => { + state.storyIndexEntryCount = Object.keys(storyIndex.entries).length; + state.storyIndexSampleIds = sampleIds; + state.activity.push(`syncStoryIndex:${input.reason}:${sampleIds.length}`); + }); + + return undefined; + }, + }, + recordPreloadVisit: { + description: 'Stores a generated value for one entry id and records the visit.', + input: preloadVisitInputSchema, + output: v.undefined(), + handler: async (input, ctx) => { + const summary = ctx.self.queries.storyIndexSummary.get({ + includeSampleIds: false, + }); + const value = `${input.source}:${input.entryId}:${summary.entryCount}`; + + logger.warn( + `[open-service debug] command recordPreloadVisit(${input.entryId}, ${input.source}) => ${value}` + ); + ctx.self.setState((state) => { + state.preloadedByEntryId[input.entryId] = value; + state.lastObservedValue = value; + state.activity.push(`recordPreloadVisit:${input.entryId}:${input.source}`); + }); + + return undefined; + }, + }, + }, + }); +} + +/** + * Registers the internal Storybook debug service that exercises the server-side open-service + * features in one place. + * + * The service self-demonstrates queries, commands, loads, subscriptions, static snapshot + * generation, and story-index integration inside the internal Storybook. It is gated behind the + * `STORYBOOK_OPEN_SERVICE_DEBUG=true` env flag in `code/.storybook/services-preset.ts`. + */ +export async function registerOpenServiceDebugService( + storyIndexGeneratorPromise: Promise +): Promise { + const service = registerService(createDebugServiceDef(storyIndexGeneratorPromise)); + const descriptor = await describeService(DEBUG_SERVICE_ID); + + logger.warn('[open-service debug] registered service descriptor'); + logger.warn(JSON.stringify(descriptor, null, 2)); + + const unsubscribe = service.queries.preloadedValue.subscribe( + { entryId: 'startup' }, + ({ data }) => { + logger.warn(`[open-service debug] subscription preloadedValue(startup) => ${data}`); + } + ); + + try { + // Trigger the main runtime behaviors once during registration so debug logs immediately show + // the command, query, load, and subscription paths without extra manual setup. + await service.commands.syncStoryIndex({ reason: 'services-preset' }); + await service.commands.addActivity({ message: 'registered via services preset' }); + service.queries.activity.get({ limit: 10 }); + service.queries.storyIndexSummary.get({ includeSampleIds: true }); + await service.queries.preloadedValue.loaded({ entryId: 'startup' }); + await new Promise((resolve) => queueMicrotask(resolve)); + } finally { + unsubscribe(); + } +} diff --git a/code/.storybook/preview.tsx b/code/.storybook/preview.tsx index ec3fb2a8fd75..e6258be49aaf 100644 --- a/code/.storybook/preview.tsx +++ b/code/.storybook/preview.tsx @@ -51,8 +51,23 @@ sb.mock(import('lodash-es/sum')); sb.mock(import('uuid')); /* eslint-enable depend/ban-dependencies */ +import '../core/src/shared/open-service/sync-test/preview.ts'; + const { document } = global; -globalThis.CONFIG_TYPE = 'DEVELOPMENT'; + +// The internal Storybook's manager stories (sidebar context menu, onboarding guide, …) demonstrate +// DEVELOPMENT-only UI, so they must render as if in development even though this Storybook is itself +// built and served statically (`yarn storybook:ui`, Chromatic, Vitest portable stories). In Vitest the +// preview builder never injects CONFIG_TYPE (it is undefined); in Chromatic it is injected as PRODUCTION. +// The one exception is the open-service static-load E2E, which serves a plain production build to +// exercise the real static-loading path — there CONFIG_TYPE is PRODUCTION and Chromatic is not driving +// the browser, so we leave the injected value untouched. +// TODO(open-service): #29743 added an unconditional `CONFIG_TYPE = 'DEVELOPMENT'` here with no rationale. +// Ask Norbert why the internal Storybook needs to force development mode rather than the stories opting +// in per-story; if it can be removed, this gate should go with it. +if (isChromatic() || globalThis.CONFIG_TYPE !== 'PRODUCTION') { + globalThis.CONFIG_TYPE = 'DEVELOPMENT'; +} const ThemeBlock = styled.div<{ side: 'left' | 'right'; layout: string }>( { @@ -341,7 +356,11 @@ const decorators = [ ] satisfies Decorator[]; const parameters = { + // Redundant but useful for testing lang parameter propagation in stories. + htmlLang: 'en-CA', docs: { + // Redundant but useful for testing lang parameter propagation in docs. + lang: 'en-US', theme: themes.light, codePanel: true, source: { diff --git a/code/.storybook/services-preset.ts b/code/.storybook/services-preset.ts new file mode 100644 index 000000000000..c15cbe621937 --- /dev/null +++ b/code/.storybook/services-preset.ts @@ -0,0 +1,21 @@ +import type { Options, StorybookConfigRaw } from 'storybook/internal/types'; + +import { registerOpenServiceSyncDemos } from '../core/src/shared/open-service/sync-test/server.ts'; +import { registerOpenServiceDebugService } from './open-service-debug-service.ts'; + +/** + * Preset hook that registers internal open-service examples and the opt-in debug service. + * + * Set `STORYBOOK_OPEN_SERVICE_DEBUG=true` to additionally register the debug service. + */ +export const services = async (_value: void, options: Options): Promise => { + registerOpenServiceSyncDemos(); + + if (process.env.STORYBOOK_OPEN_SERVICE_DEBUG === 'true') { + await registerOpenServiceDebugService( + options.presets.apply>( + 'storyIndexGenerator' + ) + ); + } +}; diff --git a/code/.storybook/storybook.setup.ts b/code/.storybook/storybook.setup.ts index 875d71242c63..8c87819e80d7 100644 --- a/code/.storybook/storybook.setup.ts +++ b/code/.storybook/storybook.setup.ts @@ -5,6 +5,7 @@ import { setProjectAnnotations } from '@storybook/react'; import { userEvent as storybookEvent, expect as storybookExpect } from 'storybook/test'; import '../core/src/shared/utils/toHaveLiveRegion.ts'; + import preview from './preview.tsx'; vi.spyOn(console, 'warn').mockImplementation((...args) => console.log(...args)); diff --git a/code/.storybook/tsconfig.json b/code/.storybook/tsconfig.json new file mode 100644 index 000000000000..a32dd4f239d6 --- /dev/null +++ b/code/.storybook/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.json", + "include": ["**/*"] +} diff --git a/code/addons/a11y/package.json b/code/addons/a11y/package.json index c3a3c124b2e6..e2c9ac6ecbae 100644 --- a/code/addons/a11y/package.json +++ b/code/addons/a11y/package.json @@ -1,6 +1,6 @@ { "name": "@storybook/addon-a11y", - "version": "10.4.0-alpha.19", + "version": "10.5.0-beta.1", "description": "Storybook Addon A11y: Test UI component compliance with WCAG web accessibility standards", "keywords": [ "a11y", diff --git a/code/addons/a11y/src/a11yRunner.test.ts b/code/addons/a11y/src/a11yRunner.test.ts index 9018e7417c6e..3edeaa1977e7 100644 --- a/code/addons/a11y/src/a11yRunner.test.ts +++ b/code/addons/a11y/src/a11yRunner.test.ts @@ -1,3 +1,4 @@ +import type { AxeResults } from 'axe-core'; import type { Mock } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -5,17 +6,57 @@ import { addons } from 'storybook/preview-api'; import { EVENTS } from './constants.ts'; +const { axeMock, documentMock } = vi.hoisted(() => { + const documentMock = { + body: {}, + getElementById: vi.fn(), + location: { pathname: '/iframe.html' }, + }; + + return { + documentMock, + axeMock: { + reset: vi.fn(), + configure: vi.fn(), + run: vi.fn(), + }, + }; +}); + +vi.mock('@storybook/global', () => ({ + global: { + document: documentMock, + }, +})); + +vi.mock('axe-core', () => ({ + default: axeMock, +})); + vi.mock('storybook/preview-api'); const mockedAddons = vi.mocked(addons); +const axeResults = { + violations: [], + passes: [], + incomplete: [], + inapplicable: [], +} as Partial as AxeResults; + describe('a11yRunner', () => { let mockChannel: { on: Mock; emit?: Mock }; beforeEach(() => { - mockedAddons.getChannel.mockReset(); + vi.resetModules(); + vi.clearAllMocks(); + + documentMock.getElementById.mockReturnValue(null); + axeMock.run.mockResolvedValue(axeResults); mockChannel = { on: vi.fn(), emit: vi.fn() }; - mockedAddons.getChannel.mockReturnValue(mockChannel as any); + mockedAddons.getChannel.mockReturnValue( + mockChannel as unknown as ReturnType + ); }); it('should listen to events', async () => { @@ -24,4 +65,67 @@ describe('a11yRunner', () => { expect(mockedAddons.getChannel).toHaveBeenCalled(); expect(mockChannel.on).toHaveBeenCalledWith(EVENTS.MANUAL, expect.any(Function)); }); + + it('passes disabled configured rules to axe.run when runOnly is present', async () => { + const { run } = await import('./a11yRunner.ts'); + const input = { + config: { + rules: [ + { id: 'target-size', enabled: false }, + { id: 'color-contrast', enabled: true }, + ], + }, + options: { + runOnly: ['wcag2a'], + rules: { + 'button-name': { enabled: false }, + }, + }, + }; + + await run(input, 'example-story'); + + expect(axeMock.configure).toHaveBeenCalledWith({ + rules: [ + { id: 'region', enabled: false }, + { id: 'target-size', enabled: false }, + { id: 'color-contrast', enabled: true }, + ], + }); + expect(axeMock.run).toHaveBeenCalledWith(expect.any(Object), { + runOnly: ['wcag2a'], + rules: { + region: { enabled: false }, + 'target-size': { enabled: false }, + 'button-name': { enabled: false }, + }, + }); + expect(axeMock.run.mock.calls[0][1]).not.toBe(input.options); + expect(input.options).toEqual({ + runOnly: ['wcag2a'], + rules: { + 'button-name': { enabled: false }, + }, + }); + }); + + it('respects configured rule overrides when collecting disabled rules', async () => { + const { run } = await import('./a11yRunner.ts'); + + await run( + { + config: { + rules: [{ id: 'region', enabled: true }], + }, + options: { + runOnly: ['wcag2a'], + }, + }, + 'example-story' + ); + + expect(axeMock.run).toHaveBeenCalledWith(expect.any(Object), { + runOnly: ['wcag2a'], + }); + }); }); diff --git a/code/addons/a11y/src/a11yRunner.ts b/code/addons/a11y/src/a11yRunner.ts index 00fb4540f8a7..b5aa244c6a84 100644 --- a/code/addons/a11y/src/a11yRunner.ts +++ b/code/addons/a11y/src/a11yRunner.ts @@ -2,7 +2,7 @@ import { ElementA11yParameterError } from 'storybook/internal/preview-errors'; import { global } from '@storybook/global'; -import type { AxeResults, ContextProp, ContextSpec } from 'axe-core'; +import type { AxeResults, ContextProp, ContextSpec, RunOptions, Spec } from 'axe-core'; import { addons, waitForAnimations } from 'storybook/preview-api'; import { withLinkPaths } from './a11yRunnerUtils.ts'; @@ -21,6 +21,47 @@ const DISABLED_RULES = [ 'region', ] as const; +const getDisabledRules = (rules: Spec['rules'] = []) => { + const disabledRules: NonNullable = {}; + + // Rules are applied in order, so a later entry overrides an earlier one for the same id. + for (const { id, enabled } of rules) { + if (!id || typeof enabled !== 'boolean') { + continue; + } + + if (enabled) { + delete disabledRules[id]; + } else { + disabledRules[id] = { enabled: false }; + } + } + + return disabledRules; +}; + +const mergeDisabledRulesIntoRunOptions = (options: RunOptions, config: Spec): RunOptions => { + // axe.run({ runOnly }) can re-enable tagged rules, so mirror configured disables into + // the same run options object without mutating the user's parameters. + if (!options.runOnly) { + return options; + } + + const disabledRules = getDisabledRules(config.rules); + + if (Object.keys(disabledRules).length === 0) { + return options; + } + + return { + ...options, + rules: { + ...disabledRules, + ...options.rules, + }, + }; +}; + // A simple queue to run axe-core in sequence // This is necessary because axe-core is not designed to run in parallel const queue: (() => Promise)[] = []; @@ -92,6 +133,8 @@ export const run = async (input: A11yParameters = DEFAULT_PARAMETERS, storyId: s axe.configure(configWithDefault); + const optionsWithDisabledRules = mergeDisabledRulesIntoRunOptions(options, configWithDefault); + return new Promise((resolve, reject) => { const highlightsRoot = document?.getElementById('storybook-highlights-root'); if (highlightsRoot) { @@ -100,7 +143,7 @@ export const run = async (input: A11yParameters = DEFAULT_PARAMETERS, storyId: s const task = async () => { try { - const result = await axe.run(context, options); + const result = await axe.run(context, optionsWithDisabledRules); const resultWithLinks = withLinkPaths(result, storyId); resolve(resultWithLinks); } catch (error) { diff --git a/code/addons/docs/package.json b/code/addons/docs/package.json index c835dc762e42..9cdc1608d058 100644 --- a/code/addons/docs/package.json +++ b/code/addons/docs/package.json @@ -1,6 +1,6 @@ { "name": "@storybook/addon-docs", - "version": "10.4.0-alpha.19", + "version": "10.5.0-beta.1", "description": "Storybook Docs: Document UI components automatically with stories and MDX", "keywords": [ "docs", @@ -106,6 +106,7 @@ "memoizerific": "^1.11.3", "polished": "^4.2.2", "react": "^18.2.0", + "react-aria": "patch:react-aria@npm%3A3.48.0#~/.yarn/patches/react-aria-npm-3.48.0-0945840d84.patch", "react-colorful": "^5.1.2", "react-dom": "^18.2.0", "rehype-external-links": "^3.0.0", diff --git a/code/addons/docs/src/blocks/blocks/ArgTypes.mdx b/code/addons/docs/src/blocks/blocks/ArgTypes.mdx new file mode 100644 index 000000000000..cee0bd2720af --- /dev/null +++ b/code/addons/docs/src/blocks/blocks/ArgTypes.mdx @@ -0,0 +1,73 @@ +import { Meta } from '@storybook/addon-docs/blocks'; + +import * as ArgTypesStories from './ArgTypes.stories.tsx'; +import * as ExampleStories from '../examples/ArgTypesParameters.stories'; +import * as SubcomponentsExampleStories from '../examples/ArgTypesWithSubcomponentsParameters.stories'; + +import { ArgTypes } from './ArgTypes'; + + + +# ArgTypes block (MDX) + +Each section below uses the `ArgTypes` block the same way as the matching story in `ArgTypes.stories.tsx`. + +## OfComponent + + + +## OfMeta + + + +## OfStory + + + +## IncludeProp + + + +## IncludeParameter + + + +## ExcludeProp + + + +## ExcludeParameter + + + +## SortProp + + + +## SortParameter + + + +## Categories + + + +## SubcomponentsOfMeta + + + +## SubcomponentsOfStory + + + +## SubcomponentsIncludeProp + + + +## SubcomponentsExcludeProp + + + +## SubcomponentsSortProp + + diff --git a/code/addons/docs/src/blocks/blocks/ArgTypes.stories.tsx b/code/addons/docs/src/blocks/blocks/ArgTypes.stories.tsx index 63f493e39313..fb9d13bc8f11 100644 --- a/code/addons/docs/src/blocks/blocks/ArgTypes.stories.tsx +++ b/code/addons/docs/src/blocks/blocks/ArgTypes.stories.tsx @@ -1,16 +1,13 @@ -import React from 'react'; - +/** Custom docs page: {@link ./ArgTypes.mdx} (attached via ``). */ import type { PlayFunctionContext } from 'storybook/internal/csf'; import type { Meta, StoryObj } from '@storybook/react-vite'; -import { within } from 'storybook/test'; - import * as ExampleStories from '../examples/ArgTypesParameters.stories'; import * as SubcomponentsExampleStories from '../examples/ArgTypesWithSubcomponentsParameters.stories'; import { ArgTypes } from './ArgTypes'; -const meta: Meta = { +const meta = { title: 'Blocks/ArgTypes', component: ArgTypes, parameters: { @@ -21,7 +18,8 @@ const meta: Meta = { ], docsStyles: true, }, -}; +} satisfies Meta; + export default meta; type Story = StoryObj; @@ -108,7 +106,7 @@ export const Categories: Story = { }; const findSubcomponentTabs = async ( - canvas: ReturnType, + canvas: Parameters>[0]['canvas'], step: PlayFunctionContext['step'] ) => { let subcomponentATab: HTMLElement | null = null; @@ -124,17 +122,18 @@ export const SubcomponentsOfMeta: Story = { args: { of: SubcomponentsExampleStories.default, }, - play: async ({ canvasElement, step }) => { - const canvas = within(canvasElement); + play: async ({ canvas, step }) => { await findSubcomponentTabs(canvas, step); }, }; export const SubcomponentsOfStory: Story = { - ...SubcomponentsOfMeta, args: { of: SubcomponentsExampleStories.NoParameters, }, + play: async ({ canvas, step }) => { + await findSubcomponentTabs(canvas, step); + }, }; export const SubcomponentsIncludeProp: Story = { @@ -142,8 +141,7 @@ export const SubcomponentsIncludeProp: Story = { of: SubcomponentsExampleStories.NoParameters, include: ['a', 'f'], }, - play: async ({ canvasElement, step }) => { - const canvas = within(canvasElement); + play: async ({ canvas, step }) => { const { subcomponentBTab } = await findSubcomponentTabs(canvas, step); if (subcomponentBTab) { await (subcomponentBTab as HTMLElement & { click: () => Promise }).click(); diff --git a/code/addons/docs/src/blocks/blocks/ArgTypes.tsx b/code/addons/docs/src/blocks/blocks/ArgTypes.tsx index 3d0d343c553a..93b5b409d739 100644 --- a/code/addons/docs/src/blocks/blocks/ArgTypes.tsx +++ b/code/addons/docs/src/blocks/blocks/ArgTypes.tsx @@ -1,9 +1,7 @@ -/* eslint-disable react/destructuring-assignment */ import type { FC } from 'react'; -import React from 'react'; +import React, { useContext } from 'react'; -import type { Parameters, Renderer, StrictArgTypes } from 'storybook/internal/csf'; -import type { ArgTypesExtractor } from 'storybook/internal/docs-tools'; +import type { Args, Parameters, Renderer, StrictArgTypes } from 'storybook/internal/csf'; import { InvalidBlockOfPropError } from 'storybook/internal/preview-errors'; import type { ModuleExports } from 'storybook/internal/types'; @@ -11,10 +9,16 @@ import type { PropDescriptor } from 'storybook/preview-api'; import { filterArgTypes } from 'storybook/preview-api'; import type { SortType } from '../components'; -import { ArgsTableError, ArgsTable as PureArgsTable, TabbedArgsTable } from '../components'; -import { useOf } from './useOf'; -import { getComponentName } from './utils'; -import { withMdxComponentOverride } from './with-mdx-component-override'; +import { ArgsTable as PureArgsTable, TabbedArgsTable } from '../components'; +import { + extractComponentArgTypes, + extractSubcomponentArgTypes, + useDocgenServiceRows, +} from './argTypesShared'; +import { DocsContext } from './DocsContext.ts'; +import { useOf } from './useOf.ts'; +import { getComponentName } from './utils.ts'; +import { withMdxComponentOverride } from './with-mdx-component-override.tsx'; type ArgTypesParameters = { include?: PropDescriptor; @@ -25,85 +29,166 @@ type ArgTypesParameters = { type ArgTypesProps = ArgTypesParameters & { of?: Renderer['component'] | ModuleExports; }; -function extractComponentArgTypes( - component: Renderer['component'], - parameters: Parameters -): StrictArgTypes { - const { extractArgTypes }: { extractArgTypes: ArgTypesExtractor } = parameters.docs || {}; - if (!extractArgTypes) { - throw new Error(ArgsTableError.ARGS_UNSUPPORTED); + +type ResolvedArgTypes = { + parameters: Parameters; + componentId: string; + storyId?: string; + initialArgs?: Args; + argTypes?: StrictArgTypes; + component?: Renderer['component']; + subcomponents?: Record; + filterProps: ArgTypesParameters; +}; + +function useResolveArgTypes(props: ArgTypesProps): ResolvedArgTypes { + const { of } = props; + if ('of' in props && of === undefined) { + throw new InvalidBlockOfPropError(); } - return extractArgTypes(component) as StrictArgTypes; -} + const context = useContext(DocsContext); + const resolved = useOf(of || 'meta'); -function getArgTypesFromResolved(resolved: ReturnType) { + let resolvedArgTypes: Omit; if (resolved.type === 'component') { const { component, projectAnnotations: { parameters }, } = resolved; - return { + resolvedArgTypes = { + parameters: parameters as Parameters, + // Bare `of={Component}` has no story/meta annotations; the docgen service is addressed by + // component id, recovered from the CSF file that declares this component. + componentId: context.getComponentId(component)!, argTypes: extractComponentArgTypes(component, parameters as Parameters), + component, + }; + } else if (resolved.type === 'meta') { + const { id, argTypes, parameters, initialArgs, component, subcomponents } = + resolved.preparedMeta; + resolvedArgTypes = { parameters, + componentId: id.split('--')[0], + initialArgs, + argTypes, component, + subcomponents, + }; + } else { + const { id, argTypes, parameters, initialArgs, component, subcomponents } = resolved.story; + resolvedArgTypes = { + parameters, + componentId: id.split('--')[0], + storyId: id, + initialArgs, + argTypes, + component, + subcomponents, }; } - if (resolved.type === 'meta') { - const { - preparedMeta: { argTypes, parameters, component, subcomponents }, - } = resolved; - return { argTypes, parameters, component, subcomponents }; + const argTypesParameters = + resolvedArgTypes.parameters?.docs?.argTypes || ({} as ArgTypesParameters); + + return { + ...resolvedArgTypes, + filterProps: { + include: props.include ?? argTypesParameters.include, + exclude: props.exclude ?? argTypesParameters.exclude, + sort: props.sort ?? argTypesParameters.sort, + }, + }; +} + +function renderArgTypesTables({ + mainName = 'Main', + mainRows, + subcomponentRows, + include, + exclude, + sort, + docsLang, +}: { + mainName?: string; + mainRows: StrictArgTypes; + subcomponentRows: Record; + include?: PropDescriptor; + exclude?: PropDescriptor; + sort?: SortType; + docsLang?: string; +}) { + const filteredMainRows = filterArgTypes(mainRows, include, exclude); + + if (Object.keys(subcomponentRows).length === 0) { + return ; } - // In the case of the story, the enhanceArgs argTypeEnhancer has already added the extracted - // arg types from the component to the prepared story. - const { - story: { argTypes, parameters, component, subcomponents }, - } = resolved; - return { argTypes, parameters, component, subcomponents }; + const tabs = { + [mainName]: { rows: filteredMainRows, sort }, + ...Object.fromEntries( + Object.entries(subcomponentRows).map(([key, rows]) => [ + key, + { + rows: filterArgTypes(rows, include, exclude), + sort, + }, + ]) + ), + }; + + return ; } -const ArgTypesImpl: FC = (props) => { - const { of } = props; - if ('of' in props && of === undefined) { - throw new InvalidBlockOfPropError(); +const LegacyArgTypes: FC = (props) => { + const { argTypes, parameters, component, subcomponents, filterProps } = useResolveArgTypes(props); + + if (!argTypes) { + return null; } - const resolved = useOf(of || 'meta'); - const { argTypes, parameters, component, subcomponents } = getArgTypesFromResolved(resolved); - const argTypesParameters = parameters?.docs?.argTypes || ({} as ArgTypesParameters); - const include = props.include ?? argTypesParameters.include; - const exclude = props.exclude ?? argTypesParameters.exclude; - const sort = props.sort ?? argTypesParameters.sort; + return renderArgTypesTables({ + mainName: getComponentName(component), + mainRows: argTypes, + subcomponentRows: extractSubcomponentArgTypes(subcomponents, parameters), + docsLang: parameters?.docs?.lang, + ...filterProps, + }); +}; - const filteredArgTypes = filterArgTypes(argTypes, include, exclude); +const DocgenServiceArgTypes: FC = (props) => { + const { argTypes, parameters, componentId, storyId, initialArgs, filterProps, component } = + useResolveArgTypes(props); + const { rows: serviceRows, isInitialLoading } = useDocgenServiceRows({ + componentId, + storyId, + parameters, + initialArgs, + customArgTypes: argTypes, + }); - const hasSubcomponents = Boolean(subcomponents) && Object.keys(subcomponents || {}).length > 0; + if (isInitialLoading) { + return ; + } - if (!hasSubcomponents) { - return ; + if (!serviceRows) { + return null; } - const mainComponentName = getComponentName(component) || 'Main'; - const subcomponentTabs = Object.fromEntries( - Object.entries(subcomponents || {}).map(([key, comp]) => [ - key, - { - rows: filterArgTypes( - extractComponentArgTypes(comp, parameters as Parameters), - include, - exclude - ), - sort, - }, - ]) + return renderArgTypesTables({ + mainName: getComponentName(component) ?? serviceRows.serviceComponentName, + mainRows: serviceRows.mainRows, + subcomponentRows: serviceRows.subcomponentRows, + docsLang: parameters?.docs?.lang, + ...filterProps, + }); +}; + +const ArgTypesImpl: FC = (props) => { + return globalThis.FEATURES?.experimentalDocgenServer ? ( + + ) : ( + ); - const tabs = { - [mainComponentName]: { rows: filteredArgTypes, sort }, - ...subcomponentTabs, - }; - return ; }; export const ArgTypes = withMdxComponentOverride('ArgTypes', ArgTypesImpl); diff --git a/code/addons/docs/src/blocks/blocks/Controls.mdx b/code/addons/docs/src/blocks/blocks/Controls.mdx new file mode 100644 index 000000000000..fec7bba1c16e --- /dev/null +++ b/code/addons/docs/src/blocks/blocks/Controls.mdx @@ -0,0 +1,76 @@ +import { Meta } from '@storybook/addon-docs/blocks'; + +import * as ControlsStories from './Controls.stories.tsx'; +import * as ExampleStories from '../examples/ControlsParameters.stories'; +import * as SubcomponentsExampleStories from '../examples/ControlsWithSubcomponentsParameters.stories'; +import * as EmptyArgTypesStories from '../examples/EmptyArgTypes.stories'; + +import { Controls } from './Controls'; + + + +# Controls block (MDX) + +Each section below uses the `Controls` block the same way as the matching story in `Controls.stories.tsx`. + +## OfStory + + + +## IncludeProp + + + +## IncludeParameter + + + +## ExcludeProp + + + +## ExcludeParameter + + + +## SortProp + + + +## SortParameter + + + +## Categories + + + +## SubcomponentsOfStory + + + +## SubcomponentsIncludeProp + + + +## SubcomponentsExcludeProp + + + +## SubcomponentsSortProp + + + +## EmptyArgTypes + + + +## MultipleControlsOnSamePage + + + + +## MultipleControlsForSameStoryOnSamePage + + + diff --git a/code/addons/docs/src/blocks/blocks/Controls.stories.tsx b/code/addons/docs/src/blocks/blocks/Controls.stories.tsx index 0a312c1e2f55..4c917d5dded4 100644 --- a/code/addons/docs/src/blocks/blocks/Controls.stories.tsx +++ b/code/addons/docs/src/blocks/blocks/Controls.stories.tsx @@ -1,10 +1,11 @@ +/** Custom docs page: {@link ./Controls.mdx} (attached via ``). */ import React from 'react'; import type { PlayFunctionContext } from 'storybook/internal/csf'; import type { Meta, StoryObj } from '@storybook/react-vite'; -import { expect, within } from 'storybook/test'; +import { expect, userEvent, waitFor, within } from 'storybook/test'; import * as ExampleStories from '../examples/ControlsParameters.stories'; import * as SubcomponentsExampleStories from '../examples/ControlsWithSubcomponentsParameters.stories'; @@ -120,6 +121,37 @@ export const SubcomponentsOfStory: Story = { }, }; +/** + * When a component declares subcomponents, editing a control on the main component tab should not + * remount the input and drop focus. This verifies the fix for + * https://github.com/storybookjs/storybook/issues/29028 + */ +export const SubcomponentsRetainControlFocus: Story = { + args: { + of: SubcomponentsExampleStories.NoParameters, + }, + beforeEach: async ({ canvasElement }) => { + return async () => { + const canvas = within(canvasElement); + const input = canvas.queryByDisplayValue('bx') ?? canvas.queryByDisplayValue('b'); + if (!input) { + return; + } + await userEvent.clear(input); + await userEvent.type(input, 'b'); + }; + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const input = await canvas.findByDisplayValue('b'); + await userEvent.click(input); + await userEvent.type(input, 'x'); + await waitFor(() => { + expect(document.activeElement).toBe(input); + }); + }, +}; + export const SubcomponentsIncludeProp: Story = { args: { of: SubcomponentsExampleStories.NoParameters, @@ -181,3 +213,25 @@ export const MultipleControlsOnSamePage: Story = { await expect(uniqueIds.size).toBe(allIds.length); }, }; + +/** + * When multiple Controls blocks for the SAME story are on the same docs page, each control should + * still have a unique id (and unique name across blocks, so that radio button groups remain + * independent). This verifies the fix for https://github.com/storybookjs/storybook/issues/29295. + */ +export const MultipleControlsForSameStoryOnSamePage: Story = { + render: () => ( + <> + + + + ), + play: async ({ canvasElement }) => { + const allIds = Array.from(canvasElement.querySelectorAll('[id^="control-"]')).map( + (el) => el.id + ); + const uniqueIds = new Set(allIds); + await expect(allIds.length).toBeGreaterThan(0); + await expect(uniqueIds.size).toBe(allIds.length); + }, +}; diff --git a/code/addons/docs/src/blocks/blocks/Controls.tsx b/code/addons/docs/src/blocks/blocks/Controls.tsx index 5110ba6687c0..4d69ec8cde17 100644 --- a/code/addons/docs/src/blocks/blocks/Controls.tsx +++ b/code/addons/docs/src/blocks/blocks/Controls.tsx @@ -1,16 +1,17 @@ -/* eslint-disable react/destructuring-assignment */ import type { FC } from 'react'; import React, { useContext } from 'react'; -import type { Parameters, Renderer, StrictArgTypes } from 'storybook/internal/csf'; -import type { ArgTypesExtractor } from 'storybook/internal/docs-tools'; -import type { ModuleExports } from 'storybook/internal/types'; +import { useId } from 'react-aria/useId'; + +import type { Args, Globals, Renderer, StrictArgTypes } from 'storybook/internal/csf'; +import type { DocsContextProps, ModuleExports, PreparedStory } from 'storybook/internal/types'; -import { filterArgTypes } from 'storybook/preview-api'; import type { PropDescriptor } from 'storybook/preview-api'; +import { filterArgTypes } from 'storybook/preview-api'; import type { SortType } from '../components'; -import { ArgsTableError, ArgsTable as PureArgsTable, TabbedArgsTable } from '../components'; +import { ArgsTable as PureArgsTable, TabbedArgsTable } from '../components'; +import { extractSubcomponentArgTypes, useDocgenServiceRows } from './argTypesShared'; import { DocsContext } from './DocsContext'; import { useArgs } from './useArgs'; import { useGlobals } from './useGlobals'; @@ -28,73 +29,104 @@ type ControlsProps = ControlsParameters & { of?: Renderer['component'] | ModuleExports; }; -function extractComponentArgTypes( - component: Renderer['component'], - parameters: Parameters -): StrictArgTypes { - const { extractArgTypes }: { extractArgTypes: ArgTypesExtractor } = parameters.docs || {}; - if (!extractArgTypes) { - throw new Error(ArgsTableError.ARGS_UNSUPPORTED); - } - return extractArgTypes(component) as StrictArgTypes; -} - -const ControlsImpl: FC = (props) => { - const { of } = props; - const context = useContext(DocsContext); - const primaryStory = usePrimaryStory(); +type ControlsStoryProps = ControlsProps & { + story: PreparedStory; + context: DocsContextProps; +}; - const story = of ? context.resolveOf(of, ['story']).story : primaryStory; +type ControlsInteractiveState = { + controlsId: string; + args: Args; + globals: Globals; + updateArgs: ReturnType[1]; + resetArgs: ReturnType[2]; +}; - if (!story) { - return null; - } +type ControlsTablesProps = ControlsInteractiveState & { + mainName?: string; + mainRows: StrictArgTypes; + subcomponentRows: Record; + include?: PropDescriptor; + exclude?: PropDescriptor; + sort?: SortType; + storyId: string; + docsLang?: string; +}; - const { parameters, argTypes, component, subcomponents } = story; - const controlsParameters = parameters.docs?.controls || ({} as ControlsParameters); +function getControlsFilterProps(story: PreparedStory, props: ControlsProps): ControlsParameters { + const controlsParameters = story.parameters.docs?.controls || ({} as ControlsParameters); - const include = props.include ?? controlsParameters.include; - const exclude = props.exclude ?? controlsParameters.exclude; - const sort = props.sort ?? controlsParameters.sort; + return { + include: props.include ?? controlsParameters.include, + exclude: props.exclude ?? controlsParameters.exclude, + sort: props.sort ?? controlsParameters.sort, + }; +} +function useControlsInteractiveState( + story: PreparedStory, + context: DocsContextProps +): ControlsInteractiveState { + // Disambiguate multiple blocks rendered for the same story on a single page. + // React Aria's useId gives a stable id per component instance, with a polyfill for + // React versions that lack the built-in useId. + const controlsId = useId(); const [args, updateArgs, resetArgs] = useArgs(story, context); const [globals] = useGlobals(story, context); - const filteredArgTypes = filterArgTypes(argTypes, include, exclude); - - const hasSubcomponents = Boolean(subcomponents) && Object.keys(subcomponents || {}).length > 0; + return { controlsId, args, globals, updateArgs, resetArgs }; +} - if (!hasSubcomponents) { - if (!(Object.keys(filteredArgTypes).length > 0 || Object.keys(args).length > 0)) { +const ControlsTables: FC = ({ + mainName = 'Story', + mainRows, + subcomponentRows, + include, + exclude, + sort, + storyId, + controlsId, + args, + globals, + updateArgs, + resetArgs, + docsLang, +}) => { + const filteredMainRows = filterArgTypes(mainRows, include, exclude); + + if (Object.keys(subcomponentRows).length === 0) { + if (!(Object.keys(filteredMainRows).length > 0 || Object.keys(args).length > 0)) { return null; } + return ( ); } - const mainComponentName = getComponentName(component) || 'Story'; - const subcomponentTabs = Object.fromEntries( - Object.entries(subcomponents || {}).map(([key, comp]) => [ - key, - { - rows: filterArgTypes(extractComponentArgTypes(comp, parameters), include, exclude), - sort, - }, - ]) - ); const tabs = { - [mainComponentName]: { rows: filteredArgTypes, sort }, - ...subcomponentTabs, + [mainName]: { rows: filteredMainRows, sort }, + ...Object.fromEntries( + Object.entries(subcomponentRows).map(([key, rows]) => [ + key, + { + rows: filterArgTypes(rows, include, exclude), + sort, + }, + ]) + ), }; + return ( = (props) => { globals={globals} updateArgs={updateArgs} resetArgs={resetArgs} + storyId={storyId} + controlsId={controlsId} + docsLang={docsLang} + /> + ); +}; + +const LegacyControls: FC = ({ story, context, ...props }) => { + const { parameters, argTypes, component, subcomponents } = story; + const filterProps = getControlsFilterProps(story, props); + const interactiveState = useControlsInteractiveState(story, context); + + if (!argTypes) { + return null; + } + + return ( + + ); +}; + +const DocgenServiceControls: FC = ({ story, context, ...props }) => { + const { parameters, argTypes, component } = story; + const filterProps = getControlsFilterProps(story, props); + const interactiveState = useControlsInteractiveState(story, context); + const { rows: serviceRows, isInitialLoading } = useDocgenServiceRows({ + componentId: story.id.split('--')[0], + storyId: story.id, + parameters, + initialArgs: story.initialArgs, + customArgTypes: argTypes, + }); + + if (isInitialLoading) { + return ; + } + + if (!serviceRows) { + return null; + } + + return ( + ); }; +const ControlsImpl: FC = (props) => { + const { of } = props; + const context = useContext(DocsContext); + const primaryStory = usePrimaryStory(); + + const story = of ? context.resolveOf(of, ['story']).story : primaryStory; + + if (!story) { + return null; + } + + const storyProps = { ...props, story, context }; + + return globalThis.FEATURES?.experimentalDocgenServer ? ( + + ) : ( + + ); +}; + export const Controls = withMdxComponentOverride('Controls', ControlsImpl); diff --git a/code/addons/docs/src/blocks/blocks/Description.stories.tsx b/code/addons/docs/src/blocks/blocks/Description.stories.tsx index 2e78949e756b..6d8611f37cf9 100644 --- a/code/addons/docs/src/blocks/blocks/Description.stories.tsx +++ b/code/addons/docs/src/blocks/blocks/Description.stories.tsx @@ -1,13 +1,90 @@ -import React from 'react'; +import type { ModuleExport, StoryContext, StoryDocsPayload } from 'storybook/internal/types'; +import { registerService } from 'storybook/preview-api'; import type { Meta, StoryObj } from '@storybook/react-vite'; +import { expect } from 'storybook/test'; +import invariant from 'tiny-invariant'; + import { Button as ButtonComponent } from '../examples/Button'; import * as DefaultButtonStories from '../examples/Button.stories'; import * as ButtonStoriesWithMetaDescriptionAsBoth from '../examples/ButtonWithMetaDescriptionAsBoth.stories'; import * as ButtonStoriesWithMetaDescriptionAsComment from '../examples/ButtonWithMetaDescriptionAsComment.stories'; import * as ButtonStoriesWithMetaDescriptionAsParameter from '../examples/ButtonWithMetaDescriptionAsParameter.stories'; +import * as ParametersStories from '../examples/SourceParameters.stories.tsx'; import { Description } from './Description'; +import type { DocsContextProps } from './DocsContext.ts'; +import { storyDocsServiceDef } from '../../../../../core/src/shared/open-service/services/story-docs/definition.ts'; +import { unregisterService } from '../../../../../core/src/shared/open-service/service-registry.ts'; + +type StoryDocsMockData = { + description?: string; +}; + +function createStoryDocsPayload( + docsContext: DocsContextProps, + of: ModuleExport, + data: StoryDocsMockData +): StoryDocsPayload { + const { story } = docsContext.resolveOf(of, ['story']); + const componentId = story.id.split('--')[0]!; + + return { + id: componentId, + name: componentId, + path: './example.stories.tsx', + stories: { + [story.id]: { + id: story.id, + name: story.name, + ...(data.description ? { description: data.description } : {}), + }, + }, + }; +} + +function storyDocsServiceStoryBeforeEach(of: ModuleExport, data: StoryDocsMockData) { + return async (context: StoryContext) => { + const docsContext = context.loaded?.docsContext as DocsContextProps | undefined; + invariant(docsContext, 'docsContext is required to mock story-docs for docs block stories'); + + // The docs blocks only consume the story-docs service when this feature is enabled, but it is + // disabled by default in production builds (e.g. Chromatic). Enable it here so the service-backed + // story renders the mocked data instead of falling back to the non-service path. + const previousFeatures = globalThis.FEATURES; + globalThis.FEATURES = { ...previousFeatures, experimentalDocgenServer: true }; + + const payload = createStoryDocsPayload(docsContext, of, data); + unregisterService('core/story-docs'); + const service = registerService(storyDocsServiceDef, { + commands: { + extractStoryDocs: { + handler: (input, ctx) => { + ctx.self.setState((state) => { + state.components[input.id] = payload; + }); + return payload; + }, + }, + extractAllStoryDocs: { + handler: (_input, ctx) => { + ctx.self.setState((state) => { + state.components[payload.id] = payload; + }); + }, + }, + }, + }); + await service.commands.extractStoryDocs({ id: payload.id }); + + return () => { + unregisterService('core/story-docs'); + globalThis.FEATURES = previousFeatures; + }; + }; +} + +const SERVICE_STORY_DESCRIPTION = 'Description from the story-docs service'; const meta: Meta = { component: Description, @@ -110,6 +187,20 @@ export const OfStoryAsStoryCommentAndParameter: Story = { }, parameters: { relativeCsfPaths: ['../examples/Button.stories'] }, }; + +export const OfStoryFromStoryDocsService: Story = { + args: { + of: ParametersStories.NoParameters, + }, + parameters: { relativeCsfPaths: ['../examples/SourceParameters.stories'] }, + beforeEach: storyDocsServiceStoryBeforeEach(ParametersStories.NoParameters, { + description: SERVICE_STORY_DESCRIPTION, + }), + play: async ({ canvasElement }) => { + await expect(canvasElement).toHaveTextContent(SERVICE_STORY_DESCRIPTION); + }, +}; + export const DefaultAttached: Story = { parameters: { relativeCsfPaths: ['../examples/Button.stories'], attached: true }, }; diff --git a/code/addons/docs/src/blocks/blocks/Description.tsx b/code/addons/docs/src/blocks/blocks/Description.tsx index 29f3394e8485..f9ef0d6bd1eb 100644 --- a/code/addons/docs/src/blocks/blocks/Description.tsx +++ b/code/addons/docs/src/blocks/blocks/Description.tsx @@ -1,9 +1,12 @@ import type { FC } from 'react'; -import React from 'react'; +import React, { useContext } from 'react'; import { InvalidBlockOfPropError } from 'storybook/internal/preview-errors'; +import { DocsContext } from './DocsContext'; import { Markdown } from './Markdown'; +import { useServiceDocgen } from './use-service-docgen.ts'; +import { useServiceStoryDoc } from './use-service-story-docs.ts'; import type { Of } from './useOf'; import { useOf } from './useOf'; import { withMdxComponentOverride } from './with-mdx-component-override'; @@ -23,10 +26,15 @@ interface DescriptionProps { of?: Of; } -const getDescriptionFromResolvedOf = (resolvedOf: ReturnType): string | null => { +const getDescriptionFromResolvedOf = ( + resolvedOf: ReturnType, + serviceComponentDescription?: string, + storyDocsDescription?: string | null +): string | null => { switch (resolvedOf.type) { case 'story': { - return resolvedOf.story.parameters.docs?.description?.story || null; + const storyDescription = resolvedOf.story.parameters.docs?.description?.story; + return storyDescription !== undefined ? storyDescription : (storyDocsDescription ?? null); } case 'meta': { const { parameters, component } = resolvedOf.preparedMeta; @@ -38,7 +46,9 @@ const getDescriptionFromResolvedOf = (resolvedOf: ReturnType): str parameters.docs?.extractComponentDescription?.(component, { component, parameters, - }) || null + }) || + serviceComponentDescription || + null ); } case 'component': { @@ -50,7 +60,9 @@ const getDescriptionFromResolvedOf = (resolvedOf: ReturnType): str parameters?.docs?.extractComponentDescription?.(component, { component, parameters, - }) || null + }) || + serviceComponentDescription || + null ); } default: { @@ -61,16 +73,91 @@ const getDescriptionFromResolvedOf = (resolvedOf: ReturnType): str } }; +type ResolvedOf = ReturnType; + +const DescriptionBody: FC<{ + resolvedOf: ResolvedOf; + serviceComponentDescription?: string; + lang?: string; + storyDocsDescription?: string | null; +}> = ({ resolvedOf, serviceComponentDescription, lang, storyDocsDescription }) => { + const markdown = getDescriptionFromResolvedOf( + resolvedOf, + serviceComponentDescription, + storyDocsDescription + ); + + return markdown ? {markdown} : null; +}; + +const DescriptionStoryWithServices: FC<{ + resolvedOf: Extract; +}> = ({ resolvedOf }) => { + const storyDocsDescription = useServiceStoryDoc(resolvedOf.story.id).data?.description; + const lang = resolvedOf.story.parameters.docs?.lang || 'en'; + return ( + + ); +}; + +const DescriptionComponentWithServices: FC<{ + resolvedOf: Extract; + componentId: string; +}> = ({ resolvedOf, componentId }) => { + const serviceComponentDescription = useServiceDocgen(componentId).data?.description || undefined; + const lang = + resolvedOf.type === 'meta' + ? resolvedOf.preparedMeta.parameters.docs?.lang || 'en' + : resolvedOf.projectAnnotations.parameters?.docs?.lang || 'en'; + return ( + + ); +}; + const DescriptionImpl: FC = (props) => { const { of } = props; + const context = useContext(DocsContext); if ('of' in props && of === undefined) { throw new InvalidBlockOfPropError(); } const resolvedOf = useOf(of || 'meta'); - const markdown = getDescriptionFromResolvedOf(resolvedOf); - return markdown ? {markdown} : null; + // The docgen service contributes a fallback description, but its two sources need different hooks + // (story-docs by story id, docgen by component id), so each lives in its own child component to + // keep the hook call unconditional. When the feature is off — or a bare `of={Component}` has no + // resolvable component id — render without a service fallback. + if (globalThis.FEATURES?.experimentalDocgenServer) { + if (resolvedOf.type === 'story') { + return ; + } + + const componentId = + resolvedOf.type === 'meta' + ? resolvedOf.preparedMeta.componentId + : context.getComponentId(resolvedOf.component); + + if (componentId) { + return ; + } + } + + const parameters = + resolvedOf.type === 'story' + ? resolvedOf.story.parameters + : resolvedOf.type === 'meta' + ? resolvedOf.preparedMeta.parameters + : resolvedOf.projectAnnotations.parameters; + const lang = parameters?.docs?.lang || 'en'; + return ; }; export const Description = withMdxComponentOverride('Description', DescriptionImpl); diff --git a/code/addons/docs/src/blocks/blocks/DocsContainer.tsx b/code/addons/docs/src/blocks/blocks/DocsContainer.tsx index 3cae103afe80..8567c7afb7a2 100644 --- a/code/addons/docs/src/blocks/blocks/DocsContainer.tsx +++ b/code/addons/docs/src/blocks/blocks/DocsContainer.tsx @@ -4,7 +4,7 @@ import React, { useEffect, useMemo } from 'react'; import type { Renderer } from 'storybook/internal/types'; import type { ThemeVars } from 'storybook/theming'; -import { ThemeProvider, ensure as ensureTheme } from 'storybook/theming'; +import { ensure as ensureTheme, ThemeProvider } from 'storybook/theming'; import { DocsPageWrapper } from '../components'; import { TableOfContents } from '../components/TableOfContents'; @@ -28,13 +28,18 @@ export const DocsContainer: FC> = ({ }) => { const slugger = useMemo(() => createDocsSlugger(), []); let toc; + // Language of docs prose (descriptions, ArgTypes description cells, free MDX prose). + let lang; try { const meta = context.resolveOf('meta', ['meta']); - toc = meta.preparedMeta.parameters?.docs?.toc; + const metaParameters = meta.preparedMeta.parameters; + toc = metaParameters?.docs?.toc; + lang = metaParameters?.docs?.lang || 'en'; } catch (err) { // No meta, falling back to project annotations toc = context?.projectAnnotations?.parameters?.docs?.toc; + lang = context?.projectAnnotations?.parameters?.docs?.lang || 'en'; } useEffect(() => { @@ -61,6 +66,7 @@ export const DocsContainer: FC> = ({ { + const docsContext = context.loaded?.docsContext as DocsContextProps | undefined; + invariant(docsContext, 'docsContext is required to mock story-docs for docs block stories'); + + // The docs blocks only consume the story-docs service when this feature is enabled, but it is + // disabled by default in production builds (e.g. Chromatic). Enable it here so the service-backed + // story renders the mocked data instead of falling back to the non-service path. + const previousFeatures = globalThis.FEATURES; + globalThis.FEATURES = { ...previousFeatures, experimentalDocgenServer: true }; + + const payload = createStoryDocsPayload(docsContext, of, data); + unregisterService('core/story-docs'); + const service = registerService(storyDocsServiceDef, { + commands: { + extractStoryDocs: { + handler: (input, ctx) => { + ctx.self.setState((state) => { + state.components[input.id] = payload; + }); + return payload; + }, + }, + extractAllStoryDocs: { + handler: (_input, ctx) => { + ctx.self.setState((state) => { + state.components[payload.id] = payload; + }); + }, + }, + }, + }); + await service.commands.extractStoryDocs({ id: payload.id }); + + return () => { + unregisterService('core/story-docs'); + globalThis.FEATURES = previousFeatures; + }; + }; +} + +const SERVICE_IMPORT = "import { EmptyExample } from './EmptyExample';"; +const SERVICE_SNIPPET = ''; const meta: Meta = { component: Source, @@ -214,6 +292,24 @@ export const Of: Story = { }, }; +export const OfStorySnippetFromStoryDocsService: Story = { + args: { + of: ParametersStories.NoParameters, + }, + beforeEach: storyDocsServiceStoryBeforeEach(ParametersStories.NoParameters, { + import: SERVICE_IMPORT, + snippet: SERVICE_SNIPPET, + }), + play: async ({ canvasElement }) => { + await waitFor(() => { + const text = canvasElement.textContent ?? ''; + expect(text).toContain('EmptyExample'); + expect(text).toContain('something='); + expect(text).not.toContain("const emitted = 'source';"); + }); + }, +}; + export const OfUndefined: Story = { args: { // @ts-expect-error this is supposed to be undefined diff --git a/code/addons/docs/src/blocks/blocks/Source.tsx b/code/addons/docs/src/blocks/blocks/Source.tsx index 2ee7af35c816..37d725e05d52 100644 --- a/code/addons/docs/src/blocks/blocks/Source.tsx +++ b/code/addons/docs/src/blocks/blocks/Source.tsx @@ -1,4 +1,4 @@ -import type { ComponentProps } from 'react'; +import type { ComponentProps, FC } from 'react'; import React, { useContext, useMemo } from 'react'; import { SourceType } from 'storybook/internal/docs-tools'; @@ -11,6 +11,7 @@ import type { DocsContextProps } from './DocsContext'; import { DocsContext } from './DocsContext'; import type { SourceContextProps, SourceItem } from './SourceContainer'; import { SourceContext, UNKNOWN_ARGS_HASH, argsHash } from './SourceContainer'; +import { useServiceStorySnippet } from './use-service-story-docs.ts'; import { useTransformCode } from './useTransformCode'; import { withMdxComponentOverride } from './with-mdx-component-override'; @@ -65,11 +66,13 @@ const getStorySource = ( const useCode = ({ snippet, + serviceSnippet, storyContext, typeFromProps, transformFromProps, }: { snippet: string; + serviceSnippet: string; storyContext: ReturnType; typeFromProps: SourceType; transformFromProps?: SourceProps['transform']; @@ -80,13 +83,14 @@ const useCode = ({ const type = typeFromProps || sourceParameters.type || SourceType.AUTO; + const staticSnippet = serviceSnippet || snippet; const useSnippet = // if user has explicitly set this as dynamic, use snippet type === SourceType.DYNAMIC || // if this is an args story and there's a snippet - (type === SourceType.AUTO && snippet && isArgsStory); + (type === SourceType.AUTO && staticSnippet && isArgsStory); - const code = useSnippet ? snippet : sourceParameters.originalSource || ''; + const code = useSnippet ? staticSnippet : sourceParameters.originalSource || ''; const transformer = transformFromProps ?? sourceParameters.transform; const transformedCode = transformer ? useTransformCode(code, transformer, storyContext) : code; @@ -104,7 +108,8 @@ type PureSourceProps = ComponentProps; export const useSourceProps = ( props: SourceProps, docsContext: DocsContextProps, - sourceContext: SourceContextProps + sourceContext: SourceContextProps, + serviceSnippet = '' ): PureSourceProps => { const { of } = props; @@ -132,6 +137,7 @@ export const useSourceProps = ( const transformedCode = useCode({ snippet: source ? source.code : '', + serviceSnippet, storyContext: { ...storyContext, args: argsForSource }, typeFromProps: props.type as SourceType, transformFromProps: props.transform, @@ -170,15 +176,51 @@ export const useSourceProps = ( }; }; +const SourceWithStoryDocsSnippet: FC< + SourceProps & { + docsContext: DocsContextProps; + sourceContext: SourceContextProps; + storyId: string; + } +> = ({ storyId, docsContext, sourceContext, ...props }) => { + const serviceSnippet = useServiceStorySnippet(storyId).data ?? ''; + const sourceProps = useSourceProps(props, docsContext, sourceContext, serviceSnippet); + return ; +}; + /** * Story source doc block renders source code if provided, or the source for a story if `storyId` is * provided, or the source for the current story if nothing is provided. */ const SourceWithStorySnippet = (props: SourceProps) => { + const { of } = props; const sourceContext = useContext(SourceContext); const docsContext = useContext(DocsContext); - const sourceProps = useSourceProps(props, docsContext, sourceContext); + const story = useMemo(() => { + if (of) { + const resolved = docsContext.resolveOf(of, ['story']); + return resolved.story; + } + try { + return docsContext.storyById(); + } catch { + // You are allowed to use and unattached. + } + }, [docsContext, of]); + + if (globalThis.FEATURES?.experimentalDocgenServer && story?.id) { + return ( + + ); + } + + const sourceProps = useSourceProps(props, docsContext, sourceContext); return ; }; diff --git a/code/addons/docs/src/blocks/blocks/Stories.tsx b/code/addons/docs/src/blocks/blocks/Stories.tsx index 555163f539e6..bd2b9910582c 100644 --- a/code/addons/docs/src/blocks/blocks/Stories.tsx +++ b/code/addons/docs/src/blocks/blocks/Stories.tsx @@ -9,6 +9,7 @@ import { DocsContext } from './DocsContext'; import { DocsStory } from './DocsStory'; import { Heading } from './Heading'; import { withMdxComponentOverride } from './with-mdx-component-override'; +import type { DocsParameters } from '../../types.ts'; interface StoriesProps { title?: ReactElement | string; @@ -35,7 +36,8 @@ const StoriesImpl: FC = ({ title = 'Stories', includePrimary = tru const { componentStories, projectAnnotations, getStoryContext } = useContext(DocsContext); let stories = componentStories(); - const { stories: { filter } = { filter: undefined } } = projectAnnotations.parameters?.docs || {}; + const filter = (projectAnnotations.parameters as DocsParameters | undefined)?.docs?.stories + ?.filter; if (filter) { stories = stories.filter((story) => filter(story, getStoryContext(story))); } diff --git a/code/addons/docs/src/blocks/blocks/Story.stories.tsx b/code/addons/docs/src/blocks/blocks/Story.stories.tsx index 4df1e7f9878b..afbdb2dbbbd3 100644 --- a/code/addons/docs/src/blocks/blocks/Story.stories.tsx +++ b/code/addons/docs/src/blocks/blocks/Story.stories.tsx @@ -66,6 +66,20 @@ export const Inline: Story = { }, }; +export const InlineHtmlLang: Story = { + ...Inline, + args: { + of: StoryParametersStories.HtmlLang, + inline: true, + }, + play: async ({ canvasElement }) => { + await waitFor(() => { + const inner = canvasElement.querySelector('[id$="-inner"]'); + expect(inner).toHaveAttribute('lang', 'fr'); + }); + }, +}; + export const InlineWithHeightProps: Story = { ...Inline, args: { diff --git a/code/addons/docs/src/blocks/blocks/argTypesShared.ts b/code/addons/docs/src/blocks/blocks/argTypesShared.ts new file mode 100644 index 000000000000..79a1301392e2 --- /dev/null +++ b/code/addons/docs/src/blocks/blocks/argTypesShared.ts @@ -0,0 +1,86 @@ +import type { Args, Parameters, Renderer, StrictArgTypes } from 'storybook/internal/csf'; +import type { ArgTypesExtractor } from 'storybook/internal/docs-tools'; +import { + getServiceSubcomponentArgTypes, + mergeServiceArgTypes, +} from 'storybook/internal/docs-tools'; + +import { ArgsTableError } from '../components'; +import { useServiceDocgen } from './use-service-docgen.ts'; + +/** Runs the renderer's docgen extractor against a component, throwing when it is unavailable. */ +export function extractComponentArgTypes( + component: Renderer['component'], + parameters: Parameters +): StrictArgTypes { + const { extractArgTypes }: { extractArgTypes: ArgTypesExtractor } = parameters.docs || {}; + if (!extractArgTypes) { + throw new Error(ArgsTableError.ARGS_UNSUPPORTED); + } + return extractArgTypes(component) as StrictArgTypes; +} + +/** Extracts argTypes for each declared subcomponent via the renderer's docgen extractor. */ +export function extractSubcomponentArgTypes( + subcomponents: Record | undefined, + parameters: Parameters +): Record { + return Object.fromEntries( + Object.entries(subcomponents || {}).map(([key, comp]) => [ + key, + extractComponentArgTypes(comp, parameters), + ]) + ); +} + +export type DocgenServiceRows = { + /** The component name reported by the service, used as a fallback table title. */ + serviceComponentName: string; + mainRows: StrictArgTypes; + subcomponentRows: Record; +}; + +/** + * Shared docgen-service recipe for the ArgTypes and Controls blocks behind + * `experimentalDocgenServer`. + * + * Subscribes to the `core/docgen` service for the component's server-extracted argTypes and merges + * them with the locally-prepared `customArgTypes` (the service only carries extracted component + * docgen, so the block works regardless of whether the story rendered). Surfaces the query's + * `isInitialLoading` flag alongside the rows so callers can render a loading skeleton while docgen is + * still resolving (instead of rendering nothing); `rows` stays `null` until a payload arrives. + */ +export function useDocgenServiceRows({ + componentId, + storyId, + parameters, + initialArgs, + customArgTypes, +}: { + componentId: string; + storyId?: string; + parameters?: Parameters; + initialArgs?: Args; + customArgTypes?: StrictArgTypes; +}): { rows: DocgenServiceRows | null; isInitialLoading: boolean } { + const { data: servicePayload, isInitialLoading } = useServiceDocgen(componentId); + + if (!servicePayload) { + return { rows: null, isInitialLoading }; + } + + return { + rows: { + serviceComponentName: servicePayload.name, + mainRows: mergeServiceArgTypes({ + payload: servicePayload, + storyId: storyId ?? componentId, + parameters, + initialArgs, + customArgTypes, + }), + subcomponentRows: getServiceSubcomponentArgTypes(servicePayload), + }, + isInitialLoading, + }; +} diff --git a/code/addons/docs/src/blocks/blocks/external/ExternalDocs.tsx b/code/addons/docs/src/blocks/blocks/external/ExternalDocs.tsx index 0e8f4cab2f7e..350feb6c8825 100644 --- a/code/addons/docs/src/blocks/blocks/external/ExternalDocs.tsx +++ b/code/addons/docs/src/blocks/blocks/external/ExternalDocs.tsx @@ -1,6 +1,7 @@ import type { PropsWithChildren } from 'react'; import React, { useRef } from 'react'; +import { deprecate } from 'storybook/internal/client-logger'; import type { ProjectAnnotations, Renderer } from 'storybook/internal/types'; import { composeConfigs } from 'storybook/preview-api'; @@ -27,6 +28,7 @@ export function ExternalDocs({ projectAnnotationsList, children, }: PropsWithChildren>) { + deprecate(`ExternalDocs is deprecated and will be removed in Storybook 11.`); const projectAnnotations = composeConfigs(projectAnnotationsList); const preview = usePreview(projectAnnotations); const docsParameter = { diff --git a/code/addons/docs/src/blocks/blocks/external/ExternalDocsContainer.tsx b/code/addons/docs/src/blocks/blocks/external/ExternalDocsContainer.tsx index a153e72968f8..4101689817bd 100644 --- a/code/addons/docs/src/blocks/blocks/external/ExternalDocsContainer.tsx +++ b/code/addons/docs/src/blocks/blocks/external/ExternalDocsContainer.tsx @@ -1,5 +1,6 @@ import React from 'react'; +import { deprecate } from 'storybook/internal/client-logger'; import type { Renderer } from 'storybook/internal/types'; import { ThemeProvider, ensure, themes } from 'storybook/theming'; @@ -12,6 +13,7 @@ let preview: ExternalPreview; export const ExternalDocsContainer: React.FC< React.PropsWithChildren<{ projectAnnotations: any }> > = ({ projectAnnotations, children }) => { + deprecate(`ExternalDocsContainer is deprecated and will be removed in Storybook 11.`); if (!preview) { preview = new ExternalPreview(projectAnnotations); } diff --git a/code/addons/docs/src/blocks/blocks/external/ExternalDocsContext.ts b/code/addons/docs/src/blocks/blocks/external/ExternalDocsContext.ts index 63e02b4acc08..436d28a86f84 100644 --- a/code/addons/docs/src/blocks/blocks/external/ExternalDocsContext.ts +++ b/code/addons/docs/src/blocks/blocks/external/ExternalDocsContext.ts @@ -16,6 +16,11 @@ export class ExternalDocsContext extends DocsContext renderStoryToElement as DocsContextProps['renderStoryToElement'], [] ); + + // External docs are always an author-supplied page (`` passes its children as the + // docs page), so `` / `` should respect the author's story selection + // rather than filtering to `autodocs`-tagged stories. + this.filterByAutodocs = false; } referenceMeta = (metaExports: ModuleExports, attach: boolean) => { diff --git a/code/addons/docs/src/blocks/blocks/mdx.tsx b/code/addons/docs/src/blocks/blocks/mdx.tsx index c54fc07ced62..4339ea8a24e6 100644 --- a/code/addons/docs/src/blocks/blocks/mdx.tsx +++ b/code/addons/docs/src/blocks/blocks/mdx.tsx @@ -2,7 +2,7 @@ import type { FC, MouseEvent, PropsWithChildren, SyntheticEvent } from 'react'; import React, { useContext } from 'react'; import type { SupportedLanguage } from 'storybook/internal/components'; -import { Code, components, nameSpaceClassNames } from 'storybook/internal/components'; +import { Button, Code, components, nameSpaceClassNames } from 'storybook/internal/components'; import { NAVIGATE_URL } from 'storybook/internal/core-events'; import { LinkIcon } from '@storybook/icons'; @@ -143,31 +143,47 @@ const SUPPORTED_MDX_HEADERS = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] as const; const OcticonHeaders = SUPPORTED_MDX_HEADERS.reduce( (acc, headerType) => ({ ...acc, - [headerType]: styled(headerType)({ - '& svg': { - position: 'relative', - top: '-0.1em', - visibility: 'hidden', - }, - '&:hover svg': { - visibility: 'visible', - }, - }), + [headerType]: styled(headerType)({}), }), {} ); -const OcticonAnchor = styled.a( - () => - ({ - float: 'left', - lineHeight: 'inherit', - paddingRight: '10px', - marginLeft: '-24px', - // Allow the theme's text color to override the default link color. - color: 'inherit', - }) as const -); +const OcticonAlignmentWrapper = styled.span({ + display: 'block', + position: 'relative', + '& svg': { + visibility: 'hidden', + }, + '&:hover svg, &:focus-within svg': { + visibility: 'visible', + }, +}); + +const OcticonAnchorWrapper = styled.span({ + // Position the anchor in the heading's left gutter instead of floating it, so the + // Button's dimensions never shift the heading text. The parent header is relatively + // positioned to anchor this. + position: 'absolute', + top: '50%', + right: '100%', + lineHeight: 'inherit', + paddingRight: '8px', + // Increase specificity to avoid being overridden by DocsPage based on + // CSS block load order. + '&&': { + marginTop: -14, // Half the Button's height to center it vertically + }, + // Allow the theme's text color to override the default link color. + color: 'inherit', + '& a': { + color: 'inherit', + textDecoration: 'none', + }, +}); + +const HeaderTitle = styled.span({ + // marginInlineStart: -40, +}); interface HeaderWithOcticonAnchorProps { as: string; @@ -188,21 +204,32 @@ const HeaderWithOcticonAnchor: FC - - {children} + + + + + {children} + ); }; diff --git a/code/addons/docs/src/blocks/blocks/use-query-subscription.ts b/code/addons/docs/src/blocks/blocks/use-query-subscription.ts new file mode 100644 index 000000000000..034c70574377 --- /dev/null +++ b/code/addons/docs/src/blocks/blocks/use-query-subscription.ts @@ -0,0 +1,82 @@ +import { useCallback, useRef } from 'react'; + +import { type Query, type QueryState, seedQueryState } from 'storybook/open-service'; + +import { useSyncExternalStoreShim } from './useSyncExternalStoreShim.ts'; + +/** + * React 16/17-safe subscription to a single open-service query, shared by the preview-side docs + * hooks. + * + * Deliberately does NOT reuse the manager-side `useServiceQuery`: that hook is built on React's + * `useSyncExternalStore`, which only exists in React 18+, while the preview docs blocks must keep + * working on React 16/17. It is instead built on {@link useSyncExternalStoreShim} (the React 16-safe + * port of that hook), adding the open-service specifics: + * + * - the current {@link QueryState} is seeded synchronously during render (via `seedQueryState`, a + * pure `.get()` read wrapped in a synthetic `pending` state) so switching subjects never lags a + * frame, and + * - the subscription re-renders whenever the query emits a new {@link QueryState}. + * + * `cacheKey` must uniquely identify the `input` (a stringifiable identity for the value the query is + * read with); it gates the synchronous re-seed and re-subscription. `input` itself is read through a + * ref so an inline object literal that is structurally stable under one `cacheKey` does not force a + * re-subscribe every render. A `selector` cannot be folded into a string key, so it is tracked by + * reference as a real dependency: changing it re-seeds and re-subscribes so the selected slice can + * never lag behind the current selector. + */ +export function useQuerySubscription( + cacheKey: string, + query: Query, + input: TInput +): QueryState; +export function useQuerySubscription( + cacheKey: string, + query: Query, + input: TInput, + selector: (value: TOutput) => TSelected +): QueryState; +export function useQuerySubscription( + cacheKey: string, + query: Query, + input: TInput, + selector?: (value: TOutput) => TSelected +): QueryState { + const cache = useRef<{ + key: string; + selector: ((value: TOutput) => TSelected) | undefined; + state: QueryState; + }>(undefined); + + if (cache.current?.key !== cacheKey || cache.current.selector !== selector) { + cache.current = { + key: cacheKey, + selector, + state: selector ? seedQueryState(query, input, selector) : seedQueryState(query, input), + }; + } + + const inputRef = useRef(input); + inputRef.current = input; + + // Re-subscribe only when the query, input identity (`cacheKey`), or `selector` changes; `input` + // stays in a ref so its per-render object identity does not re-subscribe under a stable key. + const subscribe = useCallback( + (listener: () => void): (() => void) => { + const onQueryState = (state: QueryState) => { + cache.current = { key: cacheKey, selector, state }; + listener(); + }; + return selector + ? query.subscribe(inputRef.current, selector, onQueryState) + : query.subscribe(inputRef.current, onQueryState); + }, + [query, cacheKey, selector] + ); + + // Pure ref read: the shim calls this during render and to detect emitted changes, so it must not + // recompute the seed (which would re-run the query handler). + const getSnapshot = useCallback((): QueryState => cache.current!.state, []); + + return useSyncExternalStoreShim(subscribe, getSnapshot); +} diff --git a/code/addons/docs/src/blocks/blocks/use-service-docgen.ts b/code/addons/docs/src/blocks/blocks/use-service-docgen.ts new file mode 100644 index 000000000000..b37f9572aa38 --- /dev/null +++ b/code/addons/docs/src/blocks/blocks/use-service-docgen.ts @@ -0,0 +1,24 @@ +import type { DocgenPayload } from 'storybook/internal/types'; + +import { type QueryState } from 'storybook/open-service'; +import { getService } from 'storybook/preview-api'; + +import { useQuerySubscription } from './use-query-subscription.ts'; + +/** + * Subscribes docs blocks to the preview's local `core/docgen` runtime. + * + * Returns the full {@link QueryState} — `data` plus the load lifecycle (`isInitialLoading`, + * `isError`, etc.) — so blocks can show a loading skeleton or error state instead of rendering + * nothing while docgen resolves. `data` mirrors the query's output (a {@link DocgenPayload}, or + * `undefined` when nothing has been extracted for the id yet). + * + * Requires a concrete component id and a registered `core/docgen` service. Callers whose service may + * be absent (e.g. behind `experimentalDocgenServer`) must guard at a parent and conditionally render + * a child that calls this hook. The React 16/17-safe subscription mechanics live in + * {@link useQuerySubscription}. + */ +export function useServiceDocgen(id: string): QueryState { + const service = getService('core/docgen'); + return useQuerySubscription(id, service.queries.docgen, { id }); +} diff --git a/code/addons/docs/src/blocks/blocks/use-service-story-docs.ts b/code/addons/docs/src/blocks/blocks/use-service-story-docs.ts new file mode 100644 index 000000000000..fff7e070abb2 --- /dev/null +++ b/code/addons/docs/src/blocks/blocks/use-service-story-docs.ts @@ -0,0 +1,54 @@ +import { useCallback } from 'react'; + +import type { StoryDoc, StoryDocsPayload } from 'storybook/internal/types'; + +import { type QueryState, selectSnippetForStory, selectStoryDoc } from 'storybook/open-service'; +import { getService } from 'storybook/preview-api'; + +import { useQuerySubscription } from './use-query-subscription.ts'; + +/** + * Subscribes docs blocks to one story in the preview's local `core/story-docs` runtime. + * + * Derives the component id from the story id and uses the query's selector subscription so the + * callback only fires when the selected story slice changes — not when a sibling story in the same + * CSF file updates. Returns the full {@link QueryState} — the selected `data` plus the load lifecycle + * (`isInitialLoading`, `isError`, etc.) — so blocks can react to loading and error states. + * + * Requires a concrete story id and a registered `core/story-docs` service. Callers whose service may + * be absent must guard at a parent and conditionally render a child that calls this hook. The React + * 16/17-safe subscription mechanics live in {@link useQuerySubscription}. + */ +export function useServiceStory( + storyId: string, + selector: (payload: StoryDocsPayload | undefined, storyId: string) => TSelected +): QueryState { + const componentId = storyId.split('--')[0]!; + const service = getService('core/story-docs'); + + // Kept stable (selectors are compared by reference on the subscription) so we don't re-subscribe + // every render. + const boundSelector = useCallback( + (payload: StoryDocsPayload | undefined) => selector(payload, storyId), + [selector, storyId] + ); + + // Keyed on `storyId` (not `componentId`): the selected slice is per-story, so switching between + // sibling stories in the same component must re-seed and re-subscribe with the new selector. + return useQuerySubscription( + storyId, + service.queries.storyDocs, + { id: componentId }, + boundSelector + ); +} + +/** Convenience hook for the common case: the story-docs entry for one story. */ +export function useServiceStoryDoc(storyId: string): QueryState { + return useServiceStory(storyId, selectStoryDoc); +} + +/** Convenience hook returning one story's display snippet (with its CSF import block prepended). */ +export function useServiceStorySnippet(storyId: string): QueryState { + return useServiceStory(storyId, selectSnippetForStory); +} diff --git a/code/addons/docs/src/blocks/blocks/usePrimaryStory.test.tsx b/code/addons/docs/src/blocks/blocks/usePrimaryStory.test.tsx index f657c2f3dc63..c98caaa5b65b 100644 --- a/code/addons/docs/src/blocks/blocks/usePrimaryStory.test.tsx +++ b/code/addons/docs/src/blocks/blocks/usePrimaryStory.test.tsx @@ -19,8 +19,13 @@ const stories: Record> = { story4: { name: 'Story Four', tags: [] }, }; -const createMockContext = (storyList: PreparedStory[]) => ({ +const createMockContext = ( + storyList: PreparedStory[], + overrides: Partial = {} +) => ({ componentStories: vi.fn(() => storyList), + filterByAutodocs: true, + ...overrides, }); const Wrapper: FC }>> = ({ @@ -28,7 +33,7 @@ const Wrapper: FC }>> = ( context, }) => {children}; -describe('usePrimaryStory', () => { +describe('usePrimaryStory — autodocs page (filterByAutodocs: true)', () => { it('ignores !autodocs stories', () => { const mockContext = createMockContext([ stories.story1, @@ -49,7 +54,7 @@ describe('usePrimaryStory', () => { expect(result.current?.name).toBe('Story Two'); }); - it('returns undefined if no story has "autodocs" tag', () => { + it('returns undefined when no story has the autodocs tag', () => { const mockContext = createMockContext([stories.story1, stories.story4] as PreparedStory[]); const { result } = renderHook(() => usePrimaryStory(), { wrapper: ({ children }) => {children}, @@ -65,3 +70,16 @@ describe('usePrimaryStory', () => { expect(result.current).toBeUndefined(); }); }); + +describe('usePrimaryStory — MDX / custom page (filterByAutodocs: false)', () => { + it('returns the first story regardless of autodocs tag', () => { + const mockContext = createMockContext( + [stories.story1, stories.story2, stories.story3] as PreparedStory[], + { filterByAutodocs: false } + ); + const { result } = renderHook(() => usePrimaryStory(), { + wrapper: ({ children }) => {children}, + }); + expect(result.current?.name).toBe('Story One'); + }); +}); diff --git a/code/addons/docs/src/blocks/blocks/usePrimaryStory.ts b/code/addons/docs/src/blocks/blocks/usePrimaryStory.ts index 75d085a69b17..d2b0491c5b58 100644 --- a/code/addons/docs/src/blocks/blocks/usePrimaryStory.ts +++ b/code/addons/docs/src/blocks/blocks/usePrimaryStory.ts @@ -6,11 +6,15 @@ import type { PreparedStory } from 'storybook/internal/types'; import { DocsContext } from './DocsContext'; /** - * A hook to get the primary story for the current component's doc page. It defines the primary - * story as the first story that includes the 'autodocs' tag + * Returns the primary story for the current docs page. Autodocs pages pick the first story tagged + * `autodocs`; MDX pages pick the first story regardless of tag (driven by + * `DocsContext.filterByAutodocs`, set by the docs render based on the entry type). */ export const usePrimaryStory = (): PreparedStory | undefined => { const context = useContext(DocsContext); const stories = context.componentStories(); + if (context.filterByAutodocs === false) { + return stories[0]; + } return stories.find((story) => story.tags.includes(Tag.AUTODOCS)); }; diff --git a/code/addons/docs/src/blocks/blocks/useSyncExternalStoreShim.ts b/code/addons/docs/src/blocks/blocks/useSyncExternalStoreShim.ts new file mode 100644 index 000000000000..0964ca9705b9 --- /dev/null +++ b/code/addons/docs/src/blocks/blocks/useSyncExternalStoreShim.ts @@ -0,0 +1,67 @@ +import { useEffect, useLayoutEffect, useState } from 'react'; + +/** + * Inlined fallback for React's `useSyncExternalStore`. + * + * `addon-docs` still supports React 16.8 and 17 (see the `react` range in `package.json`), and + * `useSyncExternalStore` only exists from React 18 onwards. This is a faithful port of the official + * `use-sync-external-store/shim` fallback, which is safe on React 16/17 because those versions render + * synchronously and therefore can't tear (the tearing problem the real hook guards against only + * occurs with concurrent rendering, which doesn't exist before React 18). + * + * The snapshot is recomputed on every render and returned directly, so the value is always current + * during render; the `useState` updater is used solely as a force-re-render mechanism when the store + * emits a change. The layout effect re-checks the snapshot to catch a store mutation that happened + * between render and commit. + * + * TODO: Delete this shim and go back to importing `useSyncExternalStore` from `react` once we drop + * support for React < 18. + */ +export function useSyncExternalStoreShim( + subscribe: (onStoreChange: () => void) => () => void, + getSnapshot: () => T +): T { + const value = getSnapshot(); + // `inst` is a stable mutable container that is never reassigned, so it is intentionally omitted + // from the effect dependency arrays below (matching React's upstream shim). The dependency arrays + // are deliberately `[subscribe, value, getSnapshot]` and `[subscribe]` to preserve the exact + // re-subscription semantics of `useSyncExternalStore`. + const [{ inst }, forceUpdate] = useState({ inst: { value, getSnapshot } }); + + useLayoutEffect(() => { + inst.value = value; + inst.getSnapshot = getSnapshot; + + if (didSnapshotChange(inst)) { + forceUpdate({ inst }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [subscribe, value, getSnapshot]); + + useEffect(() => { + // Re-check on subscribe in case the store changed before the subscription was set up. + if (didSnapshotChange(inst)) { + forceUpdate({ inst }); + } + + return subscribe(() => { + if (didSnapshotChange(inst)) { + forceUpdate({ inst }); + } + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [subscribe]); + + return value; +} + +function didSnapshotChange(inst: { value: T; getSnapshot: () => T }): boolean { + const latestGetSnapshot = inst.getSnapshot; + const prevValue = inst.value; + try { + const nextValue = latestGetSnapshot(); + return !Object.is(prevValue, nextValue); + } catch { + return true; + } +} diff --git a/code/addons/docs/src/blocks/components/ArgsTable/ArgControl.tsx b/code/addons/docs/src/blocks/components/ArgsTable/ArgControl.tsx index 97d3013b4bd8..1cc3b2e697e1 100644 --- a/code/addons/docs/src/blocks/components/ArgsTable/ArgControl.tsx +++ b/code/addons/docs/src/blocks/components/ArgsTable/ArgControl.tsx @@ -20,8 +20,9 @@ export interface ArgControlProps { row: ArgType; arg: any; updateArgs: (args: Args) => void; - isHovered: boolean; + isRequired: boolean; storyId?: string; + controlsId?: string; } const Controls: Record> = { @@ -44,7 +45,14 @@ const Controls: Record> = { const NoControl = () => <>-; -export const ArgControl: FC = ({ row, arg, updateArgs, isHovered, storyId }) => { +export const ArgControl: FC = ({ + row, + arg, + updateArgs, + isRequired, + storyId, + controlsId, +}) => { const { key, control } = row; const [isFocused, setFocused] = useState(false); @@ -71,16 +79,26 @@ export const ArgControl: FC = ({ row, arg, updateArgs, isHovere if (!control || control.disable) { const canBeSetup = control?.disable !== true && row?.type?.name !== 'function'; - return isHovered && canBeSetup ? ( - - Setup controls - - ) : ( - + if (!canBeSetup) { + return ; + } + // Both nodes are always rendered; the parent row toggles their visibility with CSS on + // :hover and :focus-within, so the link stays reachable for keyboard users. + return ( + <> + + + Setup controls + + + + + + ); } // row.name is a display name and not a suitable DOM input id or name - i might contain whitespace etc. @@ -88,8 +106,10 @@ export const ArgControl: FC = ({ row, arg, updateArgs, isHovere const props = { name: key, storyId, + controlsId, argType: row, value: boxedValue.value, + required: isRequired, onChange, onBlur, onFocus, diff --git a/code/addons/docs/src/blocks/components/ArgsTable/ArgRow.tsx b/code/addons/docs/src/blocks/components/ArgsTable/ArgRow.tsx index 83ad20c476ac..d21e05930587 100644 --- a/code/addons/docs/src/blocks/components/ArgsTable/ArgRow.tsx +++ b/code/addons/docs/src/blocks/components/ArgsTable/ArgRow.tsx @@ -1,12 +1,12 @@ import type { FC } from 'react'; -import React, { useState } from 'react'; +import React from 'react'; import { codeCommon } from 'storybook/internal/components'; import Markdown from 'markdown-to-jsx'; import { transparentize } from 'polished'; import type { CSSObject } from 'storybook/theming'; -import { styled } from 'storybook/theming'; +import { srOnlyStyles, srOnlyUnsetStyles, styled } from 'storybook/theming'; import type { ArgControlProps } from './ArgControl'; import { ArgControl } from './ArgControl'; @@ -22,6 +22,8 @@ interface ArgRowProps { expandable?: boolean; initialExpandedArgs?: boolean; storyId?: string; + controlsId?: string; + docsLang?: string; } const Name = styled.span({ fontWeight: 'bold' }); @@ -79,6 +81,22 @@ const StyledTd = styled.td<{ expandable: boolean }>(({ expandable }) => ({ paddingLeft: expandable ? '40px !important' : '20px !important', })); +// When a row has no usable control, the "Setup controls" link is hidden and a placeholder dash is +// shown. Hovering or focusing within the row swaps them, so keyboard users reach the link too. +const StyledTr = styled.tr({ + [`span.sbdocs-argcontrol-setup`]: { + ...srOnlyStyles, + }, + '&:hover, &:focus-within': { + [`span.sbdocs-argcontrol-setup`]: { + ...srOnlyUnsetStyles, + }, + [`span.sbdocs-argcontrol-placeholder`]: { + ...srOnlyStyles, + }, + }, +}); + const toSummary = (value: any) => { if (!value) { return value; @@ -88,8 +106,7 @@ const toSummary = (value: any) => { }; export const ArgRow: FC = (props) => { - const [isHovered, setIsHovered] = useState(false); - const { row, updateArgs, compact, expandable, initialExpandedArgs } = props; + const { row, updateArgs, compact, expandable, initialExpandedArgs, docsLang } = props; const { name, description } = row; const table = (row.table || {}) as TableAnnotation; const type = table.type || toSummary(row.type); @@ -98,15 +115,19 @@ export const ArgRow: FC = (props) => { const hasDescription = description != null && description !== ''; return ( - setIsHovered(true)} onMouseLeave={() => setIsHovered(false)}> + {name} - {required ? * : null} + {required ? ( + + * + + ) : null} {compact ? null : ( {hasDescription && ( - + {description} )} @@ -131,9 +152,9 @@ export const ArgRow: FC = (props) => { )} {updateArgs ? ( - + ) : null} - + ); }; diff --git a/code/addons/docs/src/blocks/components/ArgsTable/ArgsTable.lang.test.tsx b/code/addons/docs/src/blocks/components/ArgsTable/ArgsTable.lang.test.tsx new file mode 100644 index 000000000000..71bacd19bbf1 --- /dev/null +++ b/code/addons/docs/src/blocks/components/ArgsTable/ArgsTable.lang.test.tsx @@ -0,0 +1,42 @@ +// @vitest-environment happy-dom +import { cleanup, render } from '@testing-library/react'; +import { afterEach, describe, expect, it } from 'vitest'; + +import React from 'react'; + +import { ThemeProvider, convert, themes } from 'storybook/theming'; + +import * as ArgRow from './ArgRow.stories'; +import { ArgsTable } from './ArgsTable'; + +const renderTable = (docsLang?: string) => + render( + + {/* Wrap in a non-en region to prove chrome re-asserts English */} +
+ +
+
+ ); + +describe('ArgsTable chrome stays English', () => { + afterEach(() => cleanup()); + + it('declares English on the table so the chrome stays English under a non-en region', () => { + const { container } = renderTable(); + expect(container.querySelector('.docblock-argstable')?.getAttribute('lang')).toBe('en'); + }); + + it('keeps the arg-name cell announced in English', () => { + const { container } = renderTable(); + const nameCell = container.querySelector('tbody tr td:first-of-type'); + // The cell inherits English from the table wrapper rather than declaring its own lang. + expect(nameCell?.closest('[lang]')?.getAttribute('lang')).toBe('en'); + }); + + it('languages argument descriptions with docs.lang', () => { + const { container } = renderTable('de'); + const description = container.querySelector('tbody tr td:nth-of-type(2) > div'); + expect(description?.getAttribute('lang')).toBe('de'); + }); +}); diff --git a/code/addons/docs/src/blocks/components/ArgsTable/ArgsTable.stories.tsx b/code/addons/docs/src/blocks/components/ArgsTable/ArgsTable.stories.tsx index e176cb564742..9986aabb1220 100644 --- a/code/addons/docs/src/blocks/components/ArgsTable/ArgsTable.stories.tsx +++ b/code/addons/docs/src/blocks/components/ArgsTable/ArgsTable.stories.tsx @@ -3,6 +3,7 @@ import React from 'react'; import type { Meta, StoryObj } from '@storybook/react-vite'; import { action } from 'storybook/actions'; +import { expect, within } from 'storybook/test'; import { styled } from 'storybook/theming'; import * as ArgRow from './ArgRow.stories'; @@ -156,11 +157,34 @@ export const Error = { }, }; -export const Empty = { - args: {}, +const expectEmptyState = async (canvasElement: HTMLElement) => { + const canvas = within(canvasElement); + + await expect(await canvas.findByText('This story has no controls')).toBeVisible(); + await expect( + await canvas.findByText(/Storybook couldn't find or generate any controls for this story/i) + ).toBeVisible(); + const learnMoreLink = await canvas.findByRole('link', { + name: /Read docs/i, + }); + + await expect(learnMoreLink).toBeVisible(); + await expect(learnMoreLink).toHaveAttribute( + 'href', + 'https://storybook.js.org/docs/essentials/controls?ref=ui' + ); +}; + +export const Empty: Story = { + args: { + rows: {}, + }, parameters: { layout: 'centered', }, + play: async ({ canvasElement }) => { + await expectEmptyState(canvasElement); + }, }; export const EmptyInsideAddonPanel: Story = { @@ -171,6 +195,9 @@ export const EmptyInsideAddonPanel: Story = { parameters: { layout: 'centered', }, + play: async ({ canvasElement }) => { + await expectEmptyState(canvasElement); + }, }; export const WithDefaultExpandedArgs = { diff --git a/code/addons/docs/src/blocks/components/ArgsTable/ArgsTable.tsx b/code/addons/docs/src/blocks/components/ArgsTable/ArgsTable.tsx index 11e0325d6460..6b142951e62d 100644 --- a/code/addons/docs/src/blocks/components/ArgsTable/ArgsTable.tsx +++ b/code/addons/docs/src/blocks/components/ArgsTable/ArgsTable.tsx @@ -1,5 +1,5 @@ import type { FC } from 'react'; -import React from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { once } from 'storybook/internal/client-logger'; import { Button, Link, ResetWrapper } from 'storybook/internal/components'; @@ -216,6 +216,8 @@ export interface ArgsTableOptionProps { isLoading?: boolean; sort?: SortType; storyId?: string; + controlsId?: string; + docsLang?: string; } interface ArgsTableDataProps { rows: ArgTypes; @@ -340,8 +342,29 @@ export const ArgsTable: FC = (props) => { sort = 'none', isLoading, storyId, + controlsId, + docsLang, } = props; + const { rows, args, globals } = + 'rows' in props ? props : { rows: undefined, args: undefined, globals: undefined }; + + const isResettingRef = useRef(false); + const [isResetting, setIsResetting] = useState(false); + + useEffect(() => { + isResettingRef.current = false; + setIsResetting(false); + }, [args]); + + const handleResetClick = useCallback(() => { + if (!isResettingRef.current && resetArgs) { + isResettingRef.current = true; + setIsResetting(true); + resetArgs(); + } + }, [resetArgs]); + if ('error' in props) { const { error } = props; return ( @@ -362,9 +385,6 @@ export const ArgsTable: FC = (props) => { if (isLoading) { return ; } - - const { rows, args, globals } = - 'rows' in props ? props : { rows: undefined, args: undefined, globals: undefined }; const groups: Sections = groupRows( pickBy( rows || {}, @@ -393,7 +413,14 @@ export const ArgsTable: FC = (props) => { } const expandable = Object.keys(groups.sections).length > 0; - const common = { updateArgs, compact, inAddonPanel, initialExpandedArgs, storyId }; + const common = { + updateArgs, + compact, + inAddonPanel, + initialExpandedArgs, + storyId, + controlsId, + }; return ( @@ -403,8 +430,10 @@ export const ArgsTable: FC = (props) => { resetArgs()} - ariaLabel="Reset controls" + onClick={handleResetClick} + disabled={isResetting} + ariaLabel={isResetting ? 'Resetting controls...' : 'Reset controls'} + lang="en" > @@ -414,6 +443,7 @@ export const ArgsTable: FC = (props) => { @@ -439,7 +469,13 @@ export const ArgsTable: FC = (props) => { {groups.ungrouped.map((row) => ( - + ))} {Object.entries(groups.ungroupedSubsections).map(([subcategory, subsection]) => ( diff --git a/code/addons/docs/src/blocks/components/ArgsTable/Empty.tsx b/code/addons/docs/src/blocks/components/ArgsTable/Empty.tsx index b8e0b8fbd11e..a8b6df56f4b0 100644 --- a/code/addons/docs/src/blocks/components/ArgsTable/Empty.tsx +++ b/code/addons/docs/src/blocks/components/ArgsTable/Empty.tsx @@ -51,39 +51,23 @@ export const Empty: FC = ({ inAddonPanel }) => { return ( - Controls give you an easy to use interface to test your components. Set your story args - and you'll see controls appearing here automatically. + Storybook couldn't find or generate any controls for this story. Define{' '} + args or argTypes, or configure docgen to let Storybook + generate controls automatically. } footer={ - {inAddonPanel && ( - <> - - Read docs - - - )} - {!inAddonPanel && ( - - Learn how to set that up - - )} + + Read docs + } /> diff --git a/code/addons/docs/src/blocks/components/ArgsTable/TabbedArgsTable.stories.tsx b/code/addons/docs/src/blocks/components/ArgsTable/TabbedArgsTable.stories.tsx index 2b8fb49dc55d..3305688a5e6c 100644 --- a/code/addons/docs/src/blocks/components/ArgsTable/TabbedArgsTable.stories.tsx +++ b/code/addons/docs/src/blocks/components/ArgsTable/TabbedArgsTable.stories.tsx @@ -1,10 +1,17 @@ +import React, { useState } from 'react'; + import { TabsView } from 'storybook/internal/components'; import type { Meta, StoryObj } from '@storybook/react-vite'; +import { expect, userEvent, within } from 'storybook/test'; + +import * as ArgRow from './ArgRow.stories'; import { ArgsTable } from './ArgsTable'; import { Compact, Normal, Sections } from './ArgsTable.stories'; +import type { TabbedArgsTableProps } from './TabbedArgsTable'; import { TabbedArgsTable } from './TabbedArgsTable'; +import type { Args } from './types'; const meta = { component: TabbedArgsTable, @@ -57,3 +64,53 @@ export const WithContentAround: StoryObj = { ), }; + +/** + * When multiple tabs are shown, editing a control on the first tab should not remount the input and + * drop focus. This verifies the fix for https://github.com/storybookjs/storybook/issues/29028 + */ +export const RetainControlFocusWithTabs: StoryObj = { + args: { + tabs: { + Main: { + rows: { + someString: ArgRow.String.args.row, + }, + }, + Other: { + rows: { + numberType: ArgRow.Number.args.row, + }, + }, + }, + args: { someString: 'hello' }, + }, + render: function Render(props) { + const [storyArgs, setStoryArgs] = useState({ someString: 'hello' }); + return ( + setStoryArgs((prev) => ({ ...prev, ...updated }))} + /> + ); + }, + beforeEach: async ({ canvasElement }) => { + return async () => { + const canvas = within(canvasElement); + const input = canvas.queryByDisplayValue('hellox') ?? canvas.queryByDisplayValue('hello'); + if (!input) { + return; + } + await userEvent.clear(input); + await userEvent.type(input, 'hello'); + }; + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const input = await canvas.findByDisplayValue('hello'); + await userEvent.click(input); + await userEvent.type(input, 'x'); + await expect(document.activeElement).toBe(input); + }, +}; diff --git a/code/addons/docs/src/blocks/components/ArgsTable/TabbedArgsTable.tsx b/code/addons/docs/src/blocks/components/ArgsTable/TabbedArgsTable.tsx index 3fdc43484256..7d75120d7b83 100644 --- a/code/addons/docs/src/blocks/components/ArgsTable/TabbedArgsTable.tsx +++ b/code/addons/docs/src/blocks/components/ArgsTable/TabbedArgsTable.tsx @@ -25,20 +25,19 @@ export const TabbedArgsTable: FC = ({ tabs, ...props }) => return ; } - const tabsFromEntries = entries.map(([label, table], index) => ({ - id: `prop_table_div_${label}`, - title: label, - children: () => { - /** - * The first tab is the main component, controllable if in the Controls block All other tabs - * are subcomponents, never controllable, so we filter out the props indicating - * controllability Essentially all subcomponents always behave like ArgTypes, never Controls - */ - const argsTableProps = index === 0 ? props : { sort: props.sort }; - - return ; - }, - })); + const tabsFromEntries = entries.map(([label, table], index) => { + // The first tab is the main component, controllable if in the Controls block. All other tabs + // are subcomponents, never controllable, so we filter out the props indicating + // controllability. Pass a stable element (not an inline function component) so React + // reconciles the tab panel instead of remounting it on every args update. + const argsTableProps = index === 0 ? props : { sort: props.sort, docsLang: props.docsLang }; + + return { + id: `prop_table_div_${label}`, + title: label, + children: , + }; + }); return ; }; diff --git a/code/addons/docs/src/blocks/components/DocsPage.stories.tsx b/code/addons/docs/src/blocks/components/DocsPage.stories.tsx index f46cb44b5bb0..cef070b0ff88 100644 --- a/code/addons/docs/src/blocks/components/DocsPage.stories.tsx +++ b/code/addons/docs/src/blocks/components/DocsPage.stories.tsx @@ -1,6 +1,7 @@ /* eslint-disable jsx-a11y/anchor-is-valid */ import React from 'react'; +import { expect, within } from 'storybook/test'; import { Global, css } from 'storybook/theming'; import { ArgsTable, Source } from '.'; @@ -90,6 +91,20 @@ export const Markdown = () => (
); +export const ForwardsLang = { + name: 'Forwards lang', + render: () => ( + + Sprache + + + ), + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + const content = within(canvasElement).getByText('Sprache').closest('.sbdocs-content'); + await expect(content).toHaveAttribute('lang', 'de'); + }, +}; + export const Html = { name: 'HTML', render: () => ( diff --git a/code/addons/docs/src/blocks/components/DocsPage.tsx b/code/addons/docs/src/blocks/components/DocsPage.tsx index ac5576ed7d58..24a00590cfbc 100644 --- a/code/addons/docs/src/blocks/components/DocsPage.tsx +++ b/code/addons/docs/src/blocks/components/DocsPage.tsx @@ -110,6 +110,7 @@ export const DocsContent = styled.div(({ theme }) => { maxWidth: 1000, width: '100%', minWidth: 0, + flex: 1, [toGlobalSelector('a')]: { ...reset, fontSize: 'inherit', @@ -440,7 +441,7 @@ export const DocsWrapper = styled.div(({ theme }) => ({ display: 'flex', flexDirection: 'row-reverse', justifyContent: 'center', - padding: '4rem 20px', + padding: '4rem 40px', minHeight: '100vh', boxSizing: 'border-box', gap: '3rem', @@ -451,11 +452,14 @@ export const DocsWrapper = styled.div(({ theme }) => ({ interface DocsPageWrapperProps { children?: React.ReactNode; toc?: React.ReactNode; + lang?: string; } -export const DocsPageWrapper: FC = ({ children, toc }) => ( +export const DocsPageWrapper: FC = ({ children, toc, lang = 'en' }) => ( {toc} - {children} + + {children} + ); diff --git a/code/addons/docs/src/blocks/components/Preview.tsx b/code/addons/docs/src/blocks/components/Preview.tsx index c26c374fcff3..9263da5de080 100644 --- a/code/addons/docs/src/blocks/components/Preview.tsx +++ b/code/addons/docs/src/blocks/components/Preview.tsx @@ -2,8 +2,8 @@ import type { FC, PropsWithChildren, ReactElement, ReactNode } from 'react'; import React, { Children, useCallback, useContext, useMemo, useState } from 'react'; import { logger } from 'storybook/internal/client-logger'; -import { Bar, Button, ToggleButton, Zoom } from 'storybook/internal/components'; import type { ActionItem } from 'storybook/internal/components'; +import { Bar, Button, ToggleButton, Zoom } from 'storybook/internal/components'; import { CopyIcon, MarkupIcon } from '@storybook/icons'; @@ -232,6 +232,7 @@ export const Preview: FC = ({ {hasSourceError && ( )} - {additionalActionItems.map(({ title, className, onClick, disabled }, index: number) => ( - - ))} + {additionalActionItems.map( + ({ title, ariaLabel, className, onClick, disabled }, index: number) => ( + + ) + )} )} diff --git a/code/addons/docs/src/blocks/components/Story.tsx b/code/addons/docs/src/blocks/components/Story.tsx index 144d14445fba..bdb153d4162b 100644 --- a/code/addons/docs/src/blocks/components/Story.tsx +++ b/code/addons/docs/src/blocks/components/Story.tsx @@ -82,7 +82,12 @@ const InlineStory: FunctionComponent = (props) => { )} { min-height: ${height}; transform: translateZ(0); overflow: auto }`} ) : null} {showLoader && } -
+
); }; diff --git a/code/addons/docs/src/blocks/components/TableOfContents.tsx b/code/addons/docs/src/blocks/components/TableOfContents.tsx index 75ff1364f242..9c5cd0102ef7 100644 --- a/code/addons/docs/src/blocks/components/TableOfContents.tsx +++ b/code/addons/docs/src/blocks/components/TableOfContents.tsx @@ -1,5 +1,5 @@ -import React, { useEffect } from 'react'; import type { FC, ReactElement } from 'react'; +import React, { useEffect } from 'react'; import type { Channel } from 'storybook/internal/channels'; import { NAVIGATE_URL } from 'storybook/internal/core-events'; @@ -145,7 +145,12 @@ const Title: FC<{ // General case. if (typeof title === 'string' || !title) { return ( - + {title || 'Table of contents'} ); diff --git a/code/addons/docs/src/blocks/components/Toolbar.tsx b/code/addons/docs/src/blocks/components/Toolbar.tsx index a9faf0be1280..68470d7f4f55 100644 --- a/code/addons/docs/src/blocks/components/Toolbar.tsx +++ b/code/addons/docs/src/blocks/components/Toolbar.tsx @@ -65,7 +65,11 @@ export const Toolbar: FC = ({ onReloadStory, ...rest }) => ( - + {isLoading ? ( [1, 2, 3].map((key) => ) diff --git a/code/addons/docs/src/blocks/controls/Boolean.tsx b/code/addons/docs/src/blocks/controls/Boolean.tsx index ee89ab44d8cf..861d955d62bd 100644 --- a/code/addons/docs/src/blocks/controls/Boolean.tsx +++ b/code/addons/docs/src/blocks/controls/Boolean.tsx @@ -123,11 +123,13 @@ export type BooleanProps = ControlProps & BooleanConfig; export const BooleanControl: FC = ({ name, storyId, + controlsId, value, onChange, onBlur, onFocus, argType, + required, }) => { const onSetFalse = useCallback(() => onChange(false), [onChange]); const readonly = !!argType?.table?.readonly; @@ -137,7 +139,7 @@ export const BooleanControl: FC = ({ ariaLabel={false} variant="outline" size="medium" - id={getControlSetterButtonId(name, storyId)} + id={getControlSetterButtonId(name, storyId, controlsId)} onClick={onSetFalse} disabled={readonly} > @@ -145,7 +147,7 @@ export const BooleanControl: FC = ({ ); } - const controlId = getControlId(name, storyId); + const controlId = getControlId(name, storyId, controlsId); const parsedValue = typeof value === 'string' ? parse(value) : value; @@ -158,6 +160,7 @@ export const BooleanControl: FC = ({ checked={parsedValue} role="switch" disabled={readonly} + aria-required={required || undefined} {...{ name, onBlur, onFocus }} /> diff --git a/code/addons/docs/src/blocks/controls/Color.tsx b/code/addons/docs/src/blocks/controls/Color.tsx index b109004c2e63..8702bb2072b1 100644 --- a/code/addons/docs/src/blocks/controls/Color.tsx +++ b/code/addons/docs/src/blocks/controls/Color.tsx @@ -367,6 +367,7 @@ export type ColorControlProps = ControlProps & ColorConfig; export const ColorControl: FC = ({ name, storyId, + controlsId, value: initialValue, onChange, onFocus, @@ -375,6 +376,7 @@ export const ColorControl: FC = ({ maxPresetColors, startOpen = false, argType, + required, }) => { const debouncedOnChange = useCallback(debounce(onChange, 200), [onChange]); const { value, realValue, updateValue, color, colorSpace, cycleColorSpace } = useColorInput( @@ -385,7 +387,7 @@ export const ColorControl: FC = ({ const Picker = ColorPicker[colorSpace]; const readOnly = !!argType?.table?.readonly; - const controlId = getControlId(name, storyId); + const controlId = getControlId(name, storyId, controlsId); return ( @@ -398,6 +400,7 @@ export const ColorControl: FC = ({ onChange={(e: ChangeEvent) => updateValue(e.target.value)} onFocus={(e: FocusEvent) => e.target.select()} readOnly={readOnly} + aria-required={required || undefined} placeholder="Choose color..." /> & DateConfig; export const DateControl: FC = ({ name, storyId, + controlsId, value, onChange, onFocus, onBlur, argType, + required, }) => { const [valid, setValid] = useState(true); const dateRef = useRef(); @@ -122,7 +124,7 @@ export const DateControl: FC = ({ setValid(!!time); }; - const controlId = getControlId(name, storyId); + const controlId = getControlId(name, storyId, controlsId); return ( @@ -137,6 +139,7 @@ export const DateControl: FC = ({ id={`${controlId}-date`} name={`${controlId}-date`} readOnly={readonly} + aria-required={required || undefined} onChange={onDateChange} {...{ onFocus, onBlur }} /> @@ -150,6 +153,7 @@ export const DateControl: FC = ({ ref={timeRef as RefObject} onChange={onTimeChange} readOnly={readonly} + aria-required={required || undefined} {...{ onFocus, onBlur }} /> {!valid ?
invalid
: null} diff --git a/code/addons/docs/src/blocks/controls/Files.tsx b/code/addons/docs/src/blocks/controls/Files.tsx index 85f2d839701a..d0a6970dd060 100644 --- a/code/addons/docs/src/blocks/controls/Files.tsx +++ b/code/addons/docs/src/blocks/controls/Files.tsx @@ -41,9 +41,11 @@ export const FilesControl: FC = ({ onChange, name, storyId, + controlsId, accept = 'image/*', value, argType, + required, }) => { const inputElement = useRef(null); const readonly = argType?.control?.readOnly; @@ -64,7 +66,7 @@ export const FilesControl: FC = ({ } }, [value, name]); - const controlId = getControlId(name, storyId); + const controlId = getControlId(name, storyId, controlsId); return ( <> @@ -80,6 +82,7 @@ export const FilesControl: FC = ({ disabled={readonly} onChange={handleFileChange} accept={accept} + aria-required={required || undefined} size="flex" /> diff --git a/code/addons/docs/src/blocks/controls/Number.tsx b/code/addons/docs/src/blocks/controls/Number.tsx index a3f33db74de7..2705dcccd61e 100644 --- a/code/addons/docs/src/blocks/controls/Number.tsx +++ b/code/addons/docs/src/blocks/controls/Number.tsx @@ -28,6 +28,7 @@ const FormInput = styled(Form.Input)(({ theme }) => ({ export const NumberControl: FC = ({ name, storyId, + controlsId, value, onChange, min, @@ -36,6 +37,7 @@ export const NumberControl: FC = ({ onBlur, onFocus, argType, + required, }) => { const [inputValue, setInputValue] = useState(typeof value === 'number' ? value : ''); const [forceVisible, setForceVisible] = useState(false); @@ -103,7 +105,7 @@ export const NumberControl: FC = ({ ariaLabel={false} variant="outline" size="medium" - id={getControlSetterButtonId(name, storyId)} + id={getControlSetterButtonId(name, storyId, controlsId)} onClick={onForceVisible} disabled={readonly} > @@ -116,7 +118,7 @@ export const NumberControl: FC = ({ = ({ value={inputValue} valid={parseError ? 'error' : undefined} autoFocus={forceVisible} + aria-required={required || undefined} readOnly={readonly} {...{ name, min, max, step, onFocus, onBlur }} /> diff --git a/code/addons/docs/src/blocks/controls/Object.stories.tsx b/code/addons/docs/src/blocks/controls/Object.stories.tsx index 50146d080f31..2476f334e35f 100644 --- a/code/addons/docs/src/blocks/controls/Object.stories.tsx +++ b/code/addons/docs/src/blocks/controls/Object.stories.tsx @@ -1,6 +1,8 @@ +import React, { useEffect, useState } from 'react'; + import type { Meta, StoryObj } from '@storybook/react-vite'; -import { fn } from 'storybook/test'; +import { expect, fn, waitFor } from 'storybook/test'; import { ObjectControl } from './Object'; @@ -64,6 +66,34 @@ export const Undefined: Story = { }, }; +export const DelayedObject: Story = { + render: (args) => { + const [value, setValue] = useState(undefined); + + useEffect(() => { + setTimeout(() => { + setValue({ + name: 'Michael', + nested: { someBool: true, someNumber: 22 }, + }); + }, 1_000); + }, []); + + return ; + }, + parameters: { + withRawArg: false, + }, + play: async ({ canvas }) => { + await canvas.findByText('"Michael"'); + await waitFor(() => { + expect( + canvas.queryByRole('textbox', { name: 'Edit object as JSON' }) + ).not.toBeInTheDocument(); + }); + }, +}; + class Person { constructor( public firstName: string, diff --git a/code/addons/docs/src/blocks/controls/Object.tsx b/code/addons/docs/src/blocks/controls/Object.tsx index a724dfb9d0a7..98dcedabf08c 100644 --- a/code/addons/docs/src/blocks/controls/Object.tsx +++ b/code/addons/docs/src/blocks/controls/Object.tsx @@ -6,7 +6,7 @@ import { Button, Form, ToggleButton } from 'storybook/internal/components'; import { AddIcon, EditIcon, SubtractIcon } from '@storybook/icons'; import { cloneDeep } from 'es-toolkit/object'; -import { styled, useTheme } from 'storybook/theming'; +import { styled } from 'storybook/theming'; import { getControlId, getControlSetterButtonId } from './helpers'; import { JsonTree } from './react-editable-json-tree'; @@ -163,11 +163,19 @@ const selectValue = (event: SyntheticEvent) => { export type ObjectProps = ControlProps & ObjectConfig; -export const ObjectControl: FC = ({ name, storyId, value, onChange, argType }) => { - const theme = useTheme(); +export const ObjectControl: FC = ({ + name, + storyId, + controlsId, + value, + onChange, + argType, + required, +}) => { const data = useMemo(() => value && cloneDeep(value), [value]); const hasData = data !== null && data !== undefined; const [showRaw, setShowRaw] = useState(!hasData); + const hadDataRef = useRef(hasData); const [parseError, setParseError] = useState(null); const readonly = !!argType?.table?.readonly; @@ -191,6 +199,13 @@ export const ObjectControl: FC = ({ name, storyId, value, onChange, setForceVisible(true); }, [onChange, setForceVisible]); + useEffect(() => { + if (!hadDataRef.current && hasData && showRaw && !forceVisible) { + setShowRaw(false); + } + hadDataRef.current = hasData; + }, [forceVisible, hasData, showRaw]); + const htmlElRef = useRef(null); useEffect(() => { if (forceVisible && htmlElRef.current) { @@ -208,7 +223,7 @@ export const ObjectControl: FC = ({ name, storyId, value, onChange, + +`; + expect(extractDocsSummary(content)).toBe( + '# Button Component The Button component is used for user interactions. Click me' + ); + }); + + it('should handle empty content', () => { + expect(extractDocsSummary('')).toBeUndefined(); + }); + + it('should handle content with only imports', () => { + const content = `import { Button } from './Button'; +import { Meta } from '@storybook/blocks';`; + expect(extractDocsSummary(content)).toBeUndefined(); + }); + }); + + describe('truncation', () => { + it('should truncate content longer than 90 characters', () => { + const content = + 'This is a very long description that exceeds the maximum summary length limit and should be truncated with an ellipsis.'; + expect(extractDocsSummary(content)).toBe( + 'This is a very long description that exceeds the maximum summary length limit and should b...' + ); + }); + + it('should not truncate content at exactly 90 characters', () => { + const content = 'A'.repeat(90); + expect(extractDocsSummary(content)).toBe(content); + }); + }); + + describe('whitespace handling', () => { + it('should collapse newlines into spaces', () => { + const content = `Line one. +Line two. +Line three.`; + expect(extractDocsSummary(content)).toBe('Line one. Line two. Line three.'); + }); + }); +}); diff --git a/code/addons/docs/src/extract-docs-summary.ts b/code/addons/docs/src/extract-docs-summary.ts new file mode 100644 index 000000000000..d5e63708540e --- /dev/null +++ b/code/addons/docs/src/extract-docs-summary.ts @@ -0,0 +1,41 @@ +/** + * Ported from Storybook MCP's manifest formatter so ref-based docs manifests expose summaries in + * the same shape MCP previously derived itself. + * + * Source: + * https://github.com/storybookjs/mcp/blob/main/packages/mcp/src/utils/manifest-formatter/extract-docs-summary.ts + */ +export const MAX_SUMMARY_LENGTH = 90; + +export function extractDocsSummary(content: string): string | undefined { + let result = content; + + result = result.replace(/^\s*import\s+(?:[\s\S]*?from\s+)?['"][^'"]+['"];?\s*$/gm, ''); + + let prevResult = ''; + while (prevResult !== result) { + prevResult = result; + result = result.replace(/\{[^{}]*\}/g, ''); + } + + result = result.replace(/<[^>]+\/>/g, ''); + + prevResult = ''; + while (prevResult !== result) { + prevResult = result; + result = result.replace(/<(\w+)[^>]*>([\s\S]*?)<\/\1>/g, '$2'); + } + + result = result.replace(/<[^>]+>/g, ''); + result = result.replace(/\s+/g, ' ').trim(); + + if (!result) { + return undefined; + } + + if (result.length > MAX_SUMMARY_LENGTH) { + return `${result.slice(0, MAX_SUMMARY_LENGTH)}...`; + } + + return result; +} diff --git a/code/addons/docs/src/manager.tsx b/code/addons/docs/src/manager.tsx index 49217e0c401a..6b498502a6eb 100644 --- a/code/addons/docs/src/manager.tsx +++ b/code/addons/docs/src/manager.tsx @@ -9,26 +9,41 @@ import { ADDON_ID, PANEL_ID, PARAM_KEY, + type StoryDocsCodePanelParameters, + shouldWaitForServiceSnippet, SNIPPET_RENDERED, -} from '../../../core/src/docs-tools/shared'; +} from 'storybook/internal/docs-tools'; +import type { StoryId } from 'storybook/internal/types'; import type { SourceParameters } from './blocks/blocks'; import { Source } from './blocks/components/Source'; +/** Payload emitted on the `SNIPPET_RENDERED` channel event (see `emitTransformCode`). */ +type SnippetRenderedEvent = { + id?: StoryId; + source?: string; + format?: SyntaxHighlighterFormatTypes; +}; + const CodePanel = ({ active, lastEvent, currentStoryId, + storyParameters, + storyPrepared, }: { active: boolean | undefined; - lastEvent: any | undefined; + lastEvent: SnippetRenderedEvent | undefined; currentStoryId: string | undefined; + storyParameters: StoryDocsCodePanelParameters | undefined; + storyPrepared: boolean | undefined; }) => { + const lastEventMatchesCurrentStory = lastEvent?.id === currentStoryId; const [codeSnippet, setSourceCode] = useState<{ source: string | undefined; format: SyntaxHighlighterFormatTypes | undefined; }>({ - source: lastEvent?.source, - format: lastEvent?.format ?? undefined, + source: lastEventMatchesCurrentStory ? lastEvent?.source : undefined, + format: lastEventMatchesCurrentStory ? (lastEvent?.format ?? undefined) : undefined, }); const parameter = useParameter(PARAM_KEY, { @@ -43,24 +58,35 @@ const CodePanel = ({ }); }, [currentStoryId]); - useChannel({ - [SNIPPET_RENDERED]: ({ source, format }) => { - setSourceCode({ source, format }); + useChannel( + { + [SNIPPET_RENDERED]: ({ id, source, format }) => { + // Ignore snippets emitted for other stories: a slow extraction for the previously selected + // story can resolve after navigation and would otherwise overwrite the current panel. + // `useChannel` captures this handler per `deps`, so it must list `currentStoryId` to compare + // against the currently selected story rather than the one selected on mount. + if (id !== undefined && id !== currentStoryId) { + return; + } + setSourceCode({ source, format }); + }, }, - }); + [currentStoryId] + ); const theme = useTheme(); const isDark = theme.base !== 'light'; + const awaitingServiceSnippet = shouldWaitForServiceSnippet(storyParameters, storyPrepared); + const code = + parameter.source?.code || + codeSnippet.source || + (awaitingServiceSnippet ? '' : parameter.source?.originalSource); + return ( - + ); @@ -92,7 +118,15 @@ addons.register(ADDON_ID, (api) => { const lastEvent = channel?.last(SNIPPET_RENDERED)?.[0]; - return ; + return ( + + ); }, }); }); diff --git a/code/addons/docs/src/manifest.test.ts b/code/addons/docs/src/manifest.test.ts index 41fa36c17403..ca60abbe50f3 100644 --- a/code/addons/docs/src/manifest.test.ts +++ b/code/addons/docs/src/manifest.test.ts @@ -204,6 +204,101 @@ describe('experimental_manifests', () => { ); }); + it('should emit shallow MDX refs when experimentalDocgenServer is enabled', async () => { + const manifestEntries: IndexEntry[] = [ + { + id: 'example--docs', + name: 'docs', + title: 'Example', + type: 'docs', + importPath: './Example.mdx', + tags: [Tag.MANIFEST, Tag.ATTACHED_MDX], + storiesImports: ['./Example.stories.tsx'], + } satisfies DocsIndexEntry, + { + id: 'standalone--docs', + name: 'docs', + title: 'Standalone', + type: 'docs', + importPath: './Standalone.mdx', + tags: [Tag.MANIFEST, Tag.UNATTACHED_MDX], + storiesImports: [], + } satisfies DocsIndexEntry, + ]; + + const result = (await manifests( + { + components: { + v: 0, + components: {}, + meta: { docgen: 'react-component-meta', durationMs: 0 }, + }, + }, + { + manifestEntries, + presets: { + apply: vi.fn().mockResolvedValue({ + experimentalDocgenServer: true, + componentsManifest: true, + }), + }, + } as any + )) as ManifestResult; + + // Shallow refs are structural only; summaries are layered into the static index by core from + // the MDX service snapshots, so the preset never reads files in docgen-server mode. + expect(result.docs?.docs['standalone--docs']).toEqual({ + id: 'standalone--docs', + name: 'docs', + mdx: { + $ref: '../services/addon-docs/mdx/standalone--docs.json#/components/standalone--docs/docs/standalone--docs', + }, + }); + expect(result.docs?.v).toBe(1); + expect(result.components?.components.example.docs?.['example--docs']).toEqual({ + id: 'example--docs', + name: 'docs', + mdx: { + $ref: '../services/addon-docs/mdx/example.json#/components/example/docs/example--docs', + }, + }); + expect(result.docs?.docs['standalone--docs'].content).toBeUndefined(); + expect(result.components?.components.example.docs?.['example--docs'].content).toBeUndefined(); + }); + + it('builds shallow MDX refs without reading the source file', async () => { + const manifestEntries: IndexEntry[] = [ + { + id: 'missing--docs', + name: 'docs', + title: 'Missing', + type: 'docs', + importPath: './DoesNotExist.mdx', + tags: [Tag.MANIFEST, Tag.UNATTACHED_MDX], + storiesImports: [], + } satisfies DocsIndexEntry, + ]; + + const result = (await manifests(undefined, { + manifestEntries, + presets: { + apply: vi.fn().mockResolvedValue({ + experimentalDocgenServer: true, + componentsManifest: true, + }), + }, + } as any)) as ManifestResult; + + // No `error` despite the missing file: ref mode is purely structural. + expect(result.docs?.docs['missing--docs']).toEqual({ + id: 'missing--docs', + name: 'docs', + mdx: { + $ref: '../services/addon-docs/mdx/missing--docs.json#/components/missing--docs/docs/missing--docs', + }, + }); + }); + it('should handle both attached and unattached docs entries separately', async () => { const existingManifests = { components: { @@ -429,7 +524,7 @@ describe('experimental_manifests', () => { ); }); - it('should not include summary property when analyze returns no summary', async () => { + it('should derive a fallback summary from content when analyze returns no summary', async () => { vol.fromJSON( { './NoSummary.mdx': '# No Summary\n\nThis content has no summary.', @@ -455,6 +550,8 @@ describe('experimental_manifests', () => { expect(result.docs?.docs['nosummary--docs'].content).toBe( '# No Summary\n\nThis content has no summary.' ); - expect(result.docs?.docs['nosummary--docs'].summary).toBeUndefined(); + expect(result.docs?.docs['nosummary--docs'].summary).toBe( + '# No Summary This content has no summary.' + ); }); }); diff --git a/code/addons/docs/src/manifest.ts b/code/addons/docs/src/manifest.ts index d99881f07447..6f31224aae45 100644 --- a/code/addons/docs/src/manifest.ts +++ b/code/addons/docs/src/manifest.ts @@ -1,50 +1,76 @@ import * as fs from 'node:fs/promises'; import * as path from 'node:path'; -import { groupBy } from 'storybook/internal/common'; -import { Tag, analyzeMdx } from 'storybook/internal/core-server'; +import { getComponentIdFromEntry, groupBy } from 'storybook/internal/common'; +import { + type DocsManifestEntry, + type DocsManifestRefEntry, + type MdxDocPayload, + Tag, + analyzeMdx, + mdxManifestRef, +} from 'storybook/internal/core-server'; import { logger } from 'storybook/internal/node-logger'; import type { ComponentManifest, + ComponentsManifest, DocsIndexEntry, IndexEntry, Manifests, - Path, PresetPropertyFn, StorybookConfigRaw, } from 'storybook/internal/types'; -export interface DocsManifestEntry { - id: string; - name: string; - path: Path; - title: string; - content?: string; - summary?: string; - error?: { name: string; message: string }; -} +import { extractDocsSummary } from './extract-docs-summary.ts'; + +export type { DocsManifestEntry } from 'storybook/internal/core-server'; interface DocsManifest { v: number; docs: Record; } -interface ComponentManifestWithDocs extends ComponentManifest { +/** + * A component row carrying docs. In legacy mode this is a full {@link ComponentManifest}; in + * `experimentalDocgenServer` mode core builds the real component rows from docgen, so attached docs + * may be synthesized onto a minimal `{ id, name }` shell — hence the `Partial`. + */ +type ComponentManifestWithDocs = Partial & { + id: string; + name: string; docs?: Record; -} +}; interface ComponentsManifestWithDocs { v: number; components: Record; + meta?: ComponentsManifest['meta']; } -interface ManifestsWithDocs extends Manifests { +/** + * `Manifests` view that also exposes the docs-augmented `docs`/`components` shapes. Uses + * `Omit` so every other manifest keeps its `Manifests` typing while + * `components` is specialized to carry docs. + */ +interface ManifestsWithDocs extends Omit { docs?: DocsManifest; components?: ComponentsManifestWithDocs; } -/** Converts a DocsIndexEntry to a DocsManifestEntry by reading its file content. */ -export async function createDocsManifestEntry(entry: DocsIndexEntry): Promise { +/** Builds the manifest entry for one docs index entry, owning the single component-id context. */ +type DocBuilder = ( + entry: DocsIndexEntry, + componentId: string +) => Promise | DocsManifestEntry; + +/** + * Reads one MDX file into a full payload (content + derived summary). + * + * `summary` is derived once here — an explicit `Meta summary` when present, falling back to text + * extracted from the content — so the service, the legacy inline manifest, and the ref index all + * share one summary. It is omitted (like `content`) when the file cannot be read. + */ +export async function createDocsManifestEntry(entry: DocsIndexEntry): Promise { const absolutePath = path.join(process.cwd(), entry.importPath); try { const content = await fs.readFile(absolutePath, 'utf-8'); @@ -55,6 +81,7 @@ export async function createDocsManifestEntry(entry: DocsIndexEntry): Promise> { - if (entries.length === 0) { - return {}; - } - const entriesWithContent = await Promise.all(entries.map(createDocsManifestEntry)); - return Object.fromEntries(entriesWithContent.map((entry) => [entry.id, entry])); + const docs = await Promise.all( + entries.map(async (entry) => [entry.id, await build(entry, entry.id)] as const) + ); + + return Object.fromEntries(docs); } /** - * Extracts attached docs entries by adding them to their corresponding component manifests. - * - * Returns the updated components manifest with docs added to each component. + * Adds attached docs to their owning component, immutably synthesizing a minimal component row when + * one does not exist yet (the docgen-server components manifest starts empty). */ -export async function extractAttachedDocsEntries( +async function applyAttachedDocs( entries: DocsIndexEntry[], - existingComponents: ComponentsManifestWithDocs | undefined + existingComponents: ComponentsManifestWithDocs | undefined, + build: DocBuilder ): Promise { if (!existingComponents || entries.length === 0) { return existingComponents; } - const entriesWithContent = await Promise.all(entries.map(createDocsManifestEntry)); - - // Add docs to their corresponding components based on the entry id prefix - for (const docsEntry of entriesWithContent) { - const componentId = docsEntry.id.split('--')[0]; + const docs = await Promise.all( + entries.map(async (entry) => { + const componentId = getComponentIdFromEntry(entry); + return { componentId, doc: await build(entry, componentId) }; + }) + ); - const component = existingComponents.components[componentId]; - if (component) { - if (!component.docs) { - component.docs = {}; - } - component.docs[docsEntry.id] = docsEntry; - } + const components = { ...existingComponents.components }; + for (const { componentId, doc } of docs) { + const component = components[componentId] ?? { id: componentId, name: componentId }; + components[componentId] = { + ...component, + docs: { ...component.docs, [doc.id]: doc }, + }; } - return existingComponents; + return { ...existingComponents, components }; } /** @@ -131,13 +172,19 @@ export async function extractAttachedDocsEntries( * - Attached MDX entries (with 'attached-mdx' tag) are added to their corresponding component * manifests under a `docs` property. * - Docs entries without either tag are ignored. + * + * In `experimentalDocgenServer` mode docs become shallow `$ref` rows (resolved from the MDX service + * snapshots); otherwise the full content is inlined. */ export const manifests: PresetPropertyFn< 'experimental_manifests', StorybookConfigRaw, { manifestEntries: IndexEntry[] } -> = async (existingManifests = {}, { manifestEntries }) => { +> = async (existingManifests = {}, { manifestEntries, presets }) => { const startPerformance = performance.now(); + const features = await presets?.apply?.('features'); + const useMdxService = + features?.experimentalDocgenServer === true && features?.componentsManifest === true; const docsEntries = manifestEntries.filter( (entry): entry is DocsIndexEntry => entry.type === 'docs' @@ -163,10 +210,11 @@ export const manifests: PresetPropertyFn< } const existingManifestsWithDocs = existingManifests as ManifestsWithDocs; + const build: DocBuilder = useMdxService ? createDocsManifestRefEntry : createDocsManifestEntry; const [unattachedDocs, updatedComponents] = await Promise.all([ - extractUnattachedDocsEntries(unattachedEntries), - extractAttachedDocsEntries(attachedEntries, existingManifestsWithDocs.components), + buildUnattachedDocs(unattachedEntries, build), + applyAttachedDocs(attachedEntries, existingManifestsWithDocs.components, build), ]); const processedCount = unattachedEntries.length + attachedEntries.length; @@ -174,20 +222,20 @@ export const manifests: PresetPropertyFn< `Docs manifest generation took ${performance.now() - startPerformance}ms for ${processedCount} entries (${unattachedEntries.length} unattached, ${attachedEntries.length} attached)` ); - const result = { ...existingManifestsWithDocs }; + const result: ManifestsWithDocs = { ...existingManifestsWithDocs }; - // Add unattached docs to the docs manifest if (Object.keys(unattachedDocs).length > 0) { result.docs = { - v: 0, + v: useMdxService ? 1 : 0, docs: unattachedDocs, }; } - // Update the components manifest with attached docs if (updatedComponents) { result.components = updatedComponents; } - return result; + // Augmented with docs but structurally a `Manifests` at runtime; component rows may be the + // minimal `{ id, name }` shells synthesized for attached docs in docgen-server mode. + return result as Manifests; }; diff --git a/code/addons/docs/src/mdx-plugin.ts b/code/addons/docs/src/mdx-plugin.ts index 3873da8bae31..49f8a0969038 100644 --- a/code/addons/docs/src/mdx-plugin.ts +++ b/code/addons/docs/src/mdx-plugin.ts @@ -1,3 +1,5 @@ +import { fileURLToPath } from 'node:url'; + import type { Options } from 'storybook/internal/types'; import { createFilter } from '@rollup/pluginutils'; @@ -36,7 +38,9 @@ export async function mdxPlugin(options: Options): Promise { const mdxLoaderOptions: CompileOptions = await presets.apply('mdxLoaderOptions', { ...mdxPluginOptions, mdxCompileOptions: { - providerImportSource: import.meta.resolve('@storybook/addon-docs/mdx-react-shim'), + providerImportSource: fileURLToPath( + import.meta.resolve('@storybook/addon-docs/mdx-react-shim') + ), ...mdxPluginOptions?.mdxCompileOptions, rehypePlugins: [ ...(mdxPluginOptions?.mdxCompileOptions?.rehypePlugins ?? []), diff --git a/code/addons/docs/src/mdx-service/definition.ts b/code/addons/docs/src/mdx-service/definition.ts new file mode 100644 index 000000000000..ffcd343b544d --- /dev/null +++ b/code/addons/docs/src/mdx-service/definition.ts @@ -0,0 +1,75 @@ +import { + MDX_SERVICE_ID, + type MdxPayload, + mdxQueryStaticPath, +} from 'storybook/internal/core-server'; + +import * as v from 'valibot'; + +import { defineService } from 'storybook/open-service'; + +const mdxInputSchema = v.object({ id: v.string() }); + +const mdxErrorSchema = v.object({ + name: v.string(), + message: v.string(), +}); + +const mdxDocPayloadSchema = v.object({ + id: v.string(), + name: v.string(), + path: v.string(), + title: v.string(), + content: v.optional(v.string()), + summary: v.optional(v.string()), + error: v.optional(mdxErrorSchema), +}); + +const mdxPayloadSchema = v.object({ + id: v.string(), + name: v.string(), + docs: v.record(v.string(), mdxDocPayloadSchema), +}); + +const mdxOutputSchema = v.optional(mdxPayloadSchema); + +export type MdxServiceState = { + components: Record; +}; + +export const mdxServiceDef = defineService({ + id: MDX_SERVICE_ID, + internal: true, // this service really only ensures that the MDX docs are available in manifests, so there is no need to expose it to the public API + initialState: { components: {} } as MdxServiceState, + queries: { + mdxForComponent: { + input: mdxInputSchema, + output: mdxOutputSchema, + handler: (input, ctx) => ctx.self.state.components[input.id], + load: async (input, ctx) => { + await ctx.self.commands._extractMdxForComponent(input); + }, + staticPath: (input) => mdxQueryStaticPath(input.id), + }, + mdxForAllComponents: { + input: v.void(), + output: v.record(v.string(), mdxPayloadSchema), + handler: (_input, ctx) => ctx.self.state.components, + load: async (_input, ctx) => { + await ctx.self.commands._extractAllMdx(undefined); + }, + }, + }, + commands: { + _extractMdxForComponent: { + internal: true, + input: mdxInputSchema, + output: mdxOutputSchema, + }, + _extractAllMdx: { + internal: true, + input: v.undefined(), + output: v.void(), + }, + }, +}); diff --git a/code/addons/docs/src/mdx-service/server.test.ts b/code/addons/docs/src/mdx-service/server.test.ts new file mode 100644 index 000000000000..6861532b7a4b --- /dev/null +++ b/code/addons/docs/src/mdx-service/server.test.ts @@ -0,0 +1,135 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { Tag } from 'storybook/internal/core-server'; +import type { DocsIndexEntry, IndexEntry, StoryIndex } from 'storybook/internal/types'; + +import { + buildStaticFiles, + clearRegistry, +} from '../../../../core/src/shared/open-service/server.ts'; +import { registerMdxService } from './server.ts'; +import type { MdxProvider } from './types.ts'; + +afterEach(() => { + clearRegistry(); +}); + +function makeGetIndex(entries: IndexEntry[]) { + const index: StoryIndex = { + v: 5, + entries: Object.fromEntries(entries.map((entry) => [entry.id, entry])), + }; + return () => Promise.resolve(index); +} + +function makeDocsEntry(id: string, tags: string[], storiesImports: string[] = []): DocsIndexEntry { + return { + id, + name: 'Docs', + title: id.split('--')[0], + type: 'docs', + importPath: `./${id}.mdx`, + storiesImports, + tags, + }; +} + +const provider: MdxProvider = async ({ componentId, entries }) => ({ + id: componentId, + name: componentId, + docs: Object.fromEntries( + entries.map((entry) => [ + entry.id, + { + id: entry.id, + name: entry.name, + path: entry.importPath, + title: entry.title, + content: `# ${entry.title}`, + }, + ]) + ), +}); + +describe('mdx open service', () => { + it('extracts attached MDX by component id and unattached MDX by docs id', async () => { + const attachedDocs = makeDocsEntry( + 'button--docs', + [Tag.MANIFEST, Tag.ATTACHED_MDX], + ['./button.stories.tsx'] + ); + const unattachedDocs = makeDocsEntry('intro--docs', [Tag.MANIFEST, Tag.UNATTACHED_MDX]); + const mockProvider = vi.fn(provider); + + const service = registerMdxService({ + getIndex: makeGetIndex([attachedDocs, unattachedDocs]), + provider: mockProvider, + }); + + await expect(service.queries.mdxForComponent.loaded({ id: 'button' })).resolves.toMatchObject({ + id: 'button', + docs: { 'button--docs': { content: '# button' } }, + }); + await expect( + service.queries.mdxForComponent.loaded({ id: 'intro--docs' }) + ).resolves.toMatchObject({ + id: 'intro--docs', + docs: { 'intro--docs': { content: '# intro' } }, + }); + }); + + it('does not extract anything until a query load is requested', async () => { + const mockProvider = vi.fn(provider); + + registerMdxService({ + getIndex: makeGetIndex([ + makeDocsEntry('button--docs', [Tag.MANIFEST, Tag.ATTACHED_MDX], ['./button.stories.tsx']), + ]), + provider: mockProvider, + }); + + expect(mockProvider).not.toHaveBeenCalled(); + }); + + it('builds one static JSON file per MDX component id', async () => { + const mockProvider = vi.fn(provider); + + registerMdxService({ + getIndex: makeGetIndex([ + makeDocsEntry('button--docs', [Tag.MANIFEST, Tag.ATTACHED_MDX], ['./button.stories.tsx']), + makeDocsEntry('intro--docs', [Tag.MANIFEST, Tag.UNATTACHED_MDX]), + makeDocsEntry('ignored--docs', [Tag.ATTACHED_MDX], ['./ignored.stories.tsx']), + ]), + provider: mockProvider, + }); + + const store = await buildStaticFiles(); + + expect(Object.keys(store).sort()).toEqual([ + 'addon-docs/mdx/button.json', + 'addon-docs/mdx/ignored.json', + 'addon-docs/mdx/intro--docs.json', + ]); + expect(store['addon-docs/mdx/button.json']).toMatchObject({ + components: { + button: { + docs: { 'button--docs': { id: 'button--docs' } }, + }, + }, + }); + expect(store['addon-docs/mdx/ignored.json']).toMatchObject({ + components: { + ignored: { + docs: { 'ignored--docs': { id: 'ignored--docs' } }, + }, + }, + }); + expect(store['addon-docs/mdx/intro--docs.json']).toMatchObject({ + components: { + 'intro--docs': { + docs: { 'intro--docs': { id: 'intro--docs' } }, + }, + }, + }); + }); +}); diff --git a/code/addons/docs/src/mdx-service/server.ts b/code/addons/docs/src/mdx-service/server.ts new file mode 100644 index 000000000000..f2ff15239d2d --- /dev/null +++ b/code/addons/docs/src/mdx-service/server.ts @@ -0,0 +1,106 @@ +import { getComponentIdFromEntry, groupBy, registerService } from 'storybook/internal/common'; +import { type MdxPayload, Tag } from 'storybook/internal/core-server'; +import type { DocsIndexEntry, StoryIndex } from 'storybook/internal/types'; + +import { createDocsManifestEntry } from '../manifest.ts'; +import { mdxServiceDef } from './definition.ts'; +import type { MdxProvider } from './types.ts'; + +export type RegisterMdxServiceOptions = { + getIndex: () => Promise; + provider?: MdxProvider; +}; + +function groupMdxEntriesByComponent(index: StoryIndex): Record { + return groupBy( + Object.values(index.entries).filter( + (entry): entry is DocsIndexEntry => + entry.type === 'docs' && + ((entry.tags?.includes(Tag.ATTACHED_MDX) ?? false) || + (entry.tags?.includes(Tag.UNATTACHED_MDX) ?? false)) + ), + (entry) => (entry.tags?.includes(Tag.ATTACHED_MDX) ? getComponentIdFromEntry(entry) : entry.id) + ); +} + +const defaultMdxProvider: MdxProvider = async ({ componentId, entries }) => { + if (entries.length === 0) { + return undefined; + } + + const docs = await Promise.all(entries.map(createDocsManifestEntry)); + + return { + id: componentId, + name: componentId, + docs: Object.fromEntries(docs.map((doc) => [doc.id, doc])), + } satisfies MdxPayload; +}; + +export function registerMdxService({ + getIndex, + provider = defaultMdxProvider, +}: RegisterMdxServiceOptions) { + return registerService(mdxServiceDef, { + queries: { + mdxForComponent: { + staticInputs: async () => { + const index = await getIndex(); + const grouped = groupMdxEntriesByComponent(index); + + return Object.keys(grouped).map((id) => ({ id })); + }, + }, + }, + commands: { + _extractMdxForComponent: { + handler: async (input, ctx) => { + const index = await getIndex(); + const grouped = groupMdxEntriesByComponent(index); + const entries = grouped[input.id] ?? []; + + if (entries.length === 0) { + return undefined; + } + + const payload = await provider({ + componentId: input.id, + entries, + }); + + if (!payload) { + return undefined; + } + + ctx.self.setState((state) => { + state.components[input.id] = payload; + }); + return payload; + }, + }, + _extractAllMdx: { + handler: async (_input, ctx) => { + const index = await getIndex(); + const grouped = groupMdxEntriesByComponent(index); + + const extracted = await Promise.all( + Object.entries(grouped).map(async ([id, entries]) => { + const payload = await provider({ componentId: id, entries }); + return payload ? ([id, payload] as const) : undefined; + }) + ); + + ctx.self.setState((state) => { + for (const result of extracted) { + if (!result) { + continue; + } + const [id, payload] = result; + state.components[id] = payload; + } + }); + }, + }, + }, + }); +} diff --git a/code/addons/docs/src/mdx-service/types.ts b/code/addons/docs/src/mdx-service/types.ts new file mode 100644 index 000000000000..66c4e2120546 --- /dev/null +++ b/code/addons/docs/src/mdx-service/types.ts @@ -0,0 +1,9 @@ +import type { MdxPayload } from 'storybook/internal/core-server'; +import type { DocsIndexEntry } from 'storybook/internal/types'; + +export interface MdxProviderInput { + componentId: string; + entries: DocsIndexEntry[]; +} + +export type MdxProvider = (input: MdxProviderInput) => Promise; diff --git a/code/addons/docs/src/preset.ts b/code/addons/docs/src/preset.ts index f65bf06b228a..d8f6d6a5521a 100644 --- a/code/addons/docs/src/preset.ts +++ b/code/addons/docs/src/preset.ts @@ -1,6 +1,7 @@ import { isAbsolute } from 'node:path'; import { fileURLToPath } from 'node:url'; +import type { StoryIndexGenerator } from 'storybook/internal/core-server'; import { logger } from 'storybook/internal/node-logger'; import type { Options, PresetProperty, StorybookConfigRaw } from 'storybook/internal/types'; @@ -8,6 +9,7 @@ import type { CsfPluginOptions } from '@storybook/csf-plugin'; import { resolvePackageDir } from '../../../core/src/shared/utils/module'; import type { CompileOptions } from './compiler'; +import { registerMdxService } from './mdx-service/server.ts'; /** * Get the resolvedReact preset, which points either to the user's react dependencies or the react @@ -49,7 +51,9 @@ async function webpack( const mdxLoaderOptions: CompileOptions = await options.presets.apply('mdxLoaderOptions', { ...mdxPluginOptions, mdxCompileOptions: { - providerImportSource: import.meta.resolve('@storybook/addon-docs/mdx-react-shim'), + providerImportSource: fileURLToPath( + import.meta.resolve('@storybook/addon-docs/mdx-react-shim') + ), ...mdxPluginOptions.mdxCompileOptions, rehypePlugins: [ ...(mdxPluginOptions?.mdxCompileOptions?.rehypePlugins ?? []), @@ -211,6 +215,22 @@ export const resolvedReact = async (existing: any) => ({ mdx: existing?.mdx ?? fileURLToPath(import.meta.resolve('@mdx-js/react')), }); +export const services = async (_value: void, options: Options): Promise => { + const features = await options.presets.apply('features'); + + if ( + features?.experimentalDocgenServer && + features?.componentsManifest && + !options.ignorePreview + ) { + const generator = await options.presets.apply('storyIndexGenerator'); + + registerMdxService({ + getIndex: () => generator.getIndex(), + }); + } +}; + const optimizeViteDeps = [ '@storybook/addon-docs', '@storybook/addon-docs/blocks', diff --git a/code/addons/docs/src/types.ts b/code/addons/docs/src/types.ts index 71768a3e34b4..6d47018f6a18 100644 --- a/code/addons/docs/src/types.ts +++ b/code/addons/docs/src/types.ts @@ -1,6 +1,13 @@ import type { ComponentType } from 'react'; -import type { ModuleExport, ModuleExports } from 'storybook/internal/types'; +import type { + Args, + DocsContextProps, + ModuleExport, + ModuleExports, + PreparedStory, + Renderer, +} from 'storybook/internal/types'; import type { ThemeVars } from 'storybook/theming'; @@ -31,6 +38,16 @@ type StoryBlockParameters = { of: ModuleExport; }; +type StoriesBlockParameters = { + /** + * Custom filter function for determining which stories to include in a block + */ + filter?: ( + Story: PreparedStory, + Context: ReturnType + ) => boolean; +}; + type ControlsBlockParameters = { /** Exclude specific properties from the Controls panel */ exclude?: string[] | RegExp; @@ -69,6 +86,7 @@ type CanvasBlockParameters = { * buttons that do anything you specify in the onClick function. */ additionalActions?: { + ariaLabel?: string; className?: string; disabled?: boolean; onClick: () => void; @@ -215,12 +233,19 @@ export interface DocsParameters { source?: Partial; /** - * Story configuration + * Story block configuration * * @see https://storybook.js.org/docs/api/doc-blocks/doc-block-story */ story?: Partial; + /** + * Stories block configuration + * + * @see https://storybook.js.org/docs/api/doc-blocks/doc-block-stories + */ + stories?: Partial; + /** * The subtitle displayed when shown in docs page * @@ -228,6 +253,17 @@ export interface DocsParameters { */ subtitle?: string; + /** + * The BCP-47 language tag applied to Storybook-rendered docs prose (descriptions, titles, + * ArgTypes description cells, free MDX prose). + * + * Storybook's own chrome stays English regardless. Inherited project → meta (page-level) or + * project → meta → story (per-story prose). + * + * @default 'en' + */ + lang?: string; + /** * Override the default theme * diff --git a/code/addons/docs/template/stories/codePanel/index.stories.tsx b/code/addons/docs/template/stories/codePanel/index.stories.tsx index 87daa4c6ed46..f22c02814dbb 100644 --- a/code/addons/docs/template/stories/codePanel/index.stories.tsx +++ b/code/addons/docs/template/stories/codePanel/index.stories.tsx @@ -9,7 +9,17 @@ export default { }, }; -export const Default = { args: { label: 'Default' } }; +/** Default story for the Code panel demo and story-docs e2e (`code/e2e-internal/story-docs.spec.ts`). */ +export const Default = { + args: { label: 'e2eStoryDocsBefore' }, + parameters: { + docs: { + canvas: { + sourceState: 'shown', + }, + }, + }, +}; export const CustomCode = { args: { label: 'Custom code' }, diff --git a/code/addons/docs/template/stories/docs2/UtfSymbolScroll.mdx b/code/addons/docs/template/stories/docs2/UtfSymbolScroll.mdx index d8aa64b1bf1c..4c67a5aa7b02 100644 --- a/code/addons/docs/template/stories/docs2/UtfSymbolScroll.mdx +++ b/code/addons/docs/template/stories/docs2/UtfSymbolScroll.mdx @@ -6,9 +6,11 @@ import { Meta } from '@storybook/addon-docs/blocks'; > Instruction below works only in iframe.html. Unknown code in normal mode (with manager) removes hash from url. -Click on [link](#anchor-with-utf-symbols-абвг). That will jump scroll to anchor after green block below. Then reload page and -it should smooth-scroll to that anchor. +Click on [link](#anchor-with-utf-symbols-абвг). That will jump scroll to anchor after green block below. Then reload page and +it should smooth-scroll to that anchor. -
Space for scroll test
+
+ Space for scroll test +
-## Anchor with utf symbols (абвг) \ No newline at end of file +## Anchor with utf symbols (абвг) diff --git a/code/addons/docs/template/stories/docs2/no-dev-attached.mdx b/code/addons/docs/template/stories/docs2/no-dev-attached.mdx new file mode 100644 index 000000000000..2e6acc52c9f1 --- /dev/null +++ b/code/addons/docs/template/stories/docs2/no-dev-attached.mdx @@ -0,0 +1,10 @@ +import { Meta } from '@storybook/addon-docs/blocks'; +import * as ButtonStories from './button.stories.ts'; + + + +# Attached docs with `!dev` + +Attached MDX (`Meta of={…}`) with `tags={['!dev', 'manifest']}`. + +Should be hidden from the sidebar, same as `no-dev-unattached.mdx`. diff --git a/code/addons/docs/template/stories/docs2/no-dev-unattached.mdx b/code/addons/docs/template/stories/docs2/no-dev-unattached.mdx new file mode 100644 index 000000000000..1dc77288d46d --- /dev/null +++ b/code/addons/docs/template/stories/docs2/no-dev-unattached.mdx @@ -0,0 +1,7 @@ +import { Meta } from '@storybook/addon-docs/blocks'; + + + +Unattached docs with the tags `!dev` and `manifest` + +Should be hidden from the sidebar (unattached MDX; compare with `no-dev-attached.mdx`). diff --git a/code/addons/links/package.json b/code/addons/links/package.json index 4257245ef03a..6f00d46d2e2d 100644 --- a/code/addons/links/package.json +++ b/code/addons/links/package.json @@ -1,6 +1,6 @@ { "name": "@storybook/addon-links", - "version": "10.4.0-alpha.19", + "version": "10.5.0-beta.1", "description": "Storybook Links: Link stories together to build demos and prototypes with your UI components", "keywords": [ "storybook", diff --git a/code/addons/onboarding/README.md b/code/addons/onboarding/README.md index cd97fda30cf6..3f915f004720 100644 --- a/code/addons/onboarding/README.md +++ b/code/addons/onboarding/README.md @@ -6,8 +6,7 @@ This addon provides a guided tour in some of Storybook's features, helping you g ## Triggering the onboarding -This addon comes installed by default in Storybook projects and should trigger automatically. -If you want to retrigger the addon, you should make sure that your Storybook still contains the example stories that come when initializing Storybook, and you can then navigate to http://localhost:6006/?path=/onboarding after running Storybook. +If you're setting up Storybook for the first time, you will be prompted to set up the onboarding addon. If you choose to skip it, you can always install it manually later if needed. To manually trigger the addon, ensure that your Storybook still contains the example stories added by default and navigate to http://localhost:6006/?path=/onboarding in your browser. ## Uninstalling diff --git a/code/addons/onboarding/package.json b/code/addons/onboarding/package.json index 94c27fa92fc0..6743e67626ca 100644 --- a/code/addons/onboarding/package.json +++ b/code/addons/onboarding/package.json @@ -1,6 +1,6 @@ { "name": "@storybook/addon-onboarding", - "version": "10.4.0-alpha.19", + "version": "10.5.0-beta.1", "description": "Storybook Onboarding: Help new users learn how to write stories", "keywords": [ "storybook", diff --git a/code/addons/pseudo-states/package.json b/code/addons/pseudo-states/package.json index 8d36287e3b23..0ca5d12c8110 100644 --- a/code/addons/pseudo-states/package.json +++ b/code/addons/pseudo-states/package.json @@ -1,6 +1,6 @@ { "name": "storybook-addon-pseudo-states", - "version": "10.4.0-alpha.19", + "version": "10.5.0-beta.1", "description": "Storybook Pseudo-states addon: Manipulate CSS pseudo states", "keywords": [ "storybook", diff --git a/code/addons/pseudo-states/src/preview/rewriteStyleSheet.test.ts b/code/addons/pseudo-states/src/preview/rewriteStyleSheet.test.ts index 11e38589de82..ec23424809a2 100644 --- a/code/addons/pseudo-states/src/preview/rewriteStyleSheet.test.ts +++ b/code/addons/pseudo-states/src/preview/rewriteStyleSheet.test.ts @@ -390,6 +390,22 @@ describe('rewriteStyleSheet', () => { ); }); + it('keeps child-combinator pseudo-state selectors valid', () => { + const sheet = new Sheet('.ds-card > :focus-visible { outline: none }'); + rewriteStyleSheet(sheet as any); + expect(sheet.cssRules[0].cssText).toEqual( + '.ds-card > :focus-visible, .ds-card > .pseudo-focus-visible, .pseudo-focus-visible-all .ds-card > * { outline: none }' + ); + }); + + it('keeps pseudo-state selectors valid inside ":has" child combinators', () => { + const sheet = new Sheet('.ds-card:has(> :focus-visible) { outline: 4px solid blue }'); + rewriteStyleSheet(sheet as any); + expect(sheet.cssRules[0].cssText).toEqual( + '.ds-card:has(> :focus-visible), .ds-card:has(> .pseudo-focus-visible), .pseudo-focus-visible-all .ds-card:has(> *) { outline: 4px solid blue }' + ); + }); + it('supports ":has" inside and outside of ":not"', () => { const sheet = new Sheet(':has(:not(:hover, :has(:focus), :has(:active))) { color: red }'); rewriteStyleSheet(sheet as any); diff --git a/code/addons/pseudo-states/src/preview/rewriteStyleSheet.ts b/code/addons/pseudo-states/src/preview/rewriteStyleSheet.ts index 901c40a802c7..2761c2744f7b 100644 --- a/code/addons/pseudo-states/src/preview/rewriteStyleSheet.ts +++ b/code/addons/pseudo-states/src/preview/rewriteStyleSheet.ts @@ -69,6 +69,9 @@ const extractPseudoStates = (selector: string) => { // If removing pseudo-state selectors from inside a functional selector left it empty (thus invalid), must fix it by adding '*'. // The negative lookbehind ensures we don't replace :is() with :is(*). .replaceAll(/(?+~])\s*(?=$|[,)])/g, '$1 *') // If a selector list was left with blank items (e.g. ", foo, , bar, "), remove the extra commas/spaces. .replace(/(?<=[\s(]),\s+|(,\s+)+(?=\))/g, '') || '*'; diff --git a/code/addons/pseudo-states/src/preview/withPseudoState.ts b/code/addons/pseudo-states/src/preview/withPseudoState.ts index 3533a8833f3b..c8749356f3b5 100644 --- a/code/addons/pseudo-states/src/preview/withPseudoState.ts +++ b/code/addons/pseudo-states/src/preview/withPseudoState.ts @@ -148,10 +148,16 @@ export const withPseudoState: DecoratorFunction = ( const globalsRef = useRef(globals); // Sync parameter to globals, used by the toolbar (only in canvas as this - // doesn't make sense for docs because many stories are displayed at once) + // doesn't make sense for docs because many stories are displayed at once). + // Skip when the parameter has no pseudo states to sync; otherwise we'd wipe + // globals set via URL or toolbar on stories that don't declare pseudo states. useEffect(() => { const config = pseudoConfig(parameter); - if (viewMode === 'story' && !equals(config, globalsRef.current)) { + if ( + viewMode === 'story' && + Object.keys(config).length > 0 && + !equals(config, globalsRef.current) + ) { channel.emit(UPDATE_GLOBALS, { globals: { pseudo: config }, }); diff --git a/code/addons/themes/package.json b/code/addons/themes/package.json index eb1828e9ec49..578fa9a83687 100644 --- a/code/addons/themes/package.json +++ b/code/addons/themes/package.json @@ -1,6 +1,6 @@ { "name": "@storybook/addon-themes", - "version": "10.4.0-alpha.19", + "version": "10.5.0-beta.1", "description": "Storybook Themes addon: Switch between themes from the toolbar", "keywords": [ "css", diff --git a/code/addons/vitest/build-config.ts b/code/addons/vitest/build-config.ts index c324af90450d..df54c1e1dfd1 100644 --- a/code/addons/vitest/build-config.ts +++ b/code/addons/vitest/build-config.ts @@ -17,6 +17,16 @@ const config: BuildEntries = { entryPoint: './src/vitest-plugin/setup-file.ts', dts: false, }, + { + exportEntries: ['./internal/setup-file.browser.3'], + entryPoint: './src/vitest-plugin/setup-file.browser.3.ts', + dts: false, + }, + { + exportEntries: ['./internal/setup-file.browser.4'], + entryPoint: './src/vitest-plugin/setup-file.browser.4.ts', + dts: false, + }, { exportEntries: ['./internal/setup-file-with-project-annotations'], entryPoint: './src/vitest-plugin/setup-file-with-project-annotations.ts', diff --git a/code/addons/vitest/package.json b/code/addons/vitest/package.json index 51da4ccd2336..1826e2cee558 100644 --- a/code/addons/vitest/package.json +++ b/code/addons/vitest/package.json @@ -1,6 +1,6 @@ { "name": "@storybook/addon-vitest", - "version": "10.4.0-alpha.19", + "version": "10.5.0-beta.1", "description": "Storybook Vitest addon: Blazing fast component testing using stories", "keywords": [ "storybook", @@ -52,6 +52,8 @@ "./internal/global-setup": "./dist/vitest-plugin/global-setup.js", "./internal/setup-file": "./dist/vitest-plugin/setup-file.js", "./internal/setup-file-with-project-annotations": "./dist/vitest-plugin/setup-file-with-project-annotations.js", + "./internal/setup-file.browser.3": "./dist/vitest-plugin/setup-file.browser.3.js", + "./internal/setup-file.browser.4": "./dist/vitest-plugin/setup-file.browser.4.js", "./internal/test-utils": "./dist/vitest-plugin/test-utils.js", "./manager": "./dist/manager.js", "./package.json": "./package.json", @@ -81,6 +83,7 @@ }, "devDependencies": { "@storybook/addon-a11y": "workspace:*", + "@storybook/builder-vite": "workspace:*", "@types/istanbul-lib-report": "^3.0.3", "@types/micromatch": "^4.0.0", "@types/node": "^22.19.1", diff --git a/code/addons/vitest/src/angular-vitest-postinstall.test.ts b/code/addons/vitest/src/angular-vitest-postinstall.test.ts new file mode 100644 index 000000000000..4d451fdb66f7 --- /dev/null +++ b/code/addons/vitest/src/angular-vitest-postinstall.test.ts @@ -0,0 +1,235 @@ +import { describe, expect, it } from 'vitest'; + +import type { types as t } from 'storybook/internal/babel'; +import { babelParse, generate, traverse } from 'storybook/internal/babel'; + +import { + ANGULAR_VITEST_IMPORT_SOURCE, + ANGULAR_VITEST_PLUGIN_CALL, + collectStorybookTestLocalNames, + injectAngularVitestIntoAst, + injectAngularVitestIntoConfig, + isAngularVitestAlreadyWired, +} from './angular-vitest-postinstall.ts'; + +/** Returns the names of the elements (call callees / spread) inside the plugins array, in order. */ +function pluginCalleesInSameArray(code: string, locatorName = 'storybookTest'): string[] | null { + const ast = babelParse(code); + let elements: string[] | null = null; + traverse(ast, { + CallExpression(path) { + if (elements) { + path.stop(); + return; + } + const { callee } = path.node; + if ( + callee.type === 'Identifier' && + callee.name === locatorName && + path.parentPath.isArrayExpression() + ) { + const array = path.parentPath.node as t.ArrayExpression; + elements = array.elements.map((el) => { + if (el?.type === 'CallExpression' && el.callee.type === 'Identifier') { + return el.callee.name; + } + if (el?.type === 'SpreadElement') { + return 'spread'; + } + return el?.type ?? 'null'; + }); + path.stop(); + } + }, + }); + return elements; +} + +function countOccurrences(haystack: string, needle: string): number { + return haystack.split(needle).length - 1; +} + +const FRESH_V4 = ` +import path from 'node:path'; +import { defineConfig } from 'vitest/config'; +import { storybookTest } from '@storybook/addon-vitest/vitest-plugin'; + +export default defineConfig({ + test: { + projects: [ + { + extends: true, + plugins: [ + // The plugin will run tests for the stories defined in your Storybook config + storybookTest({ configDir: path.join(__dirname, '.storybook') }), + ], + test: { name: 'storybook' }, + }, + ], + }, +}); +`; + +describe('isAngularVitestAlreadyWired', () => { + it('is false on a plain storybookTest config', () => { + expect(isAngularVitestAlreadyWired(FRESH_V4)).toBe(false); + }); + + it('is true when the import source is present', () => { + expect( + isAngularVitestAlreadyWired( + `import { storybookAngularVitest } from '${ANGULAR_VITEST_IMPORT_SOURCE}';` + ) + ).toBe(true); + }); + + it('is true when only the call is present (partial wiring)', () => { + expect( + isAngularVitestAlreadyWired('plugins: [storybookAngularVitest({}), storybookTest({})]') + ).toBe(true); + }); +}); + +describe('collectStorybookTestLocalNames', () => { + it('always seeds the bare storybookTest name', () => { + const names = collectStorybookTestLocalNames(babelParse('export default {};')); + expect(names.has('storybookTest')).toBe(true); + }); + + it('collects aliased import names', () => { + const names = collectStorybookTestLocalNames( + babelParse(`import { storybookTest as sbTest } from '@storybook/addon-vitest/vitest-plugin';`) + ); + expect(names.has('sbTest')).toBe(true); + expect(names.has('storybookTest')).toBe(true); + }); + + it('ignores imports from other sources', () => { + const names = collectStorybookTestLocalNames( + babelParse(`import { storybookTest as other } from 'somewhere-else';`) + ); + expect(names.has('other')).toBe(false); + }); +}); + +describe('injectAngularVitestIntoConfig', () => { + it('co-locates the bridge in the same plugins array as storybookTest (case 1)', () => { + const out = injectAngularVitestIntoConfig(FRESH_V4); + expect(out).not.toBeNull(); + const callees = pluginCalleesInSameArray(out!); + expect(callees).toEqual([ANGULAR_VITEST_PLUGIN_CALL, 'storybookTest']); + // Quote style is normalized later by prettier in the real flow; assert on the structural import. + expect(out).toContain(`{ ${ANGULAR_VITEST_PLUGIN_CALL} }`); + expect(out).toContain(ANGULAR_VITEST_IMPORT_SOURCE); + }); + + it('adds the scaffold comment (case 20: comments preserved through generate)', () => { + const out = injectAngularVitestIntoConfig(FRESH_V4)!; + expect(out).toContain('Forwards Angular build options'); + // The template's own comment must also survive. + expect(out).toContain('The plugin will run tests for the stories'); + }); + + it('is alias-aware: co-locates next to an aliased storybookTest (case 2)', () => { + const aliased = FRESH_V4.replace( + `import { storybookTest } from '@storybook/addon-vitest/vitest-plugin';`, + `import { storybookTest as sbTest } from '@storybook/addon-vitest/vitest-plugin';` + ).replace('storybookTest({ configDir', 'sbTest({ configDir'); + + const out = injectAngularVitestIntoConfig(aliased); + expect(out).not.toBeNull(); + const callees = pluginCalleesInSameArray(out!, 'sbTest'); + expect(callees).toEqual([ANGULAR_VITEST_PLUGIN_CALL, 'sbTest']); + }); + + it('returns null on spread storybookTest with no locatable array (case 3)', () => { + const spread = ` + import { storybookTest } from '@storybook/addon-vitest/vitest-plugin'; + export default { test: { projects: [{ plugins: [...storybookTest()] }] } }; + `; + expect(injectAngularVitestIntoConfig(spread)).toBeNull(); + }); + + it('returns null when storybookTest is not in any array (exotic)', () => { + const exotic = ` + import { storybookTest } from '@storybook/addon-vitest/vitest-plugin'; + const p = storybookTest(); + export default { test: { projects: [{ plugins: p }] } }; + `; + expect(injectAngularVitestIntoConfig(exotic)).toBeNull(); + }); + + it('returns null on unparsable content', () => { + expect(injectAngularVitestIntoConfig('this is ::: not valid <<< ts')).toBeNull(); + }); + + it('is idempotent: a second pass adds no duplicate import or call (case 8)', () => { + const once = injectAngularVitestIntoConfig(FRESH_V4)!; + const twice = injectAngularVitestIntoConfig(once); + // Already wired by the import source — the string path short-circuits via callers, but the + // injector itself must also not duplicate when re-run on already-injected content. + expect(twice).not.toBeNull(); + expect(countOccurrences(twice!, ANGULAR_VITEST_IMPORT_SOURCE)).toBe(1); + expect(countOccurrences(twice!, `${ANGULAR_VITEST_PLUGIN_CALL}(`)).toBe(1); + }); + + it('partial wiring: call already present, adds import without duplicating the call (case 10a)', () => { + const callOnly = FRESH_V4.replace( + 'plugins: [', + 'plugins: [\n storybookAngularVitest({}),' + ); + const out = injectAngularVitestIntoConfig(callOnly)!; + expect(countOccurrences(out, `${ANGULAR_VITEST_PLUGIN_CALL}(`)).toBe(1); + expect(countOccurrences(out, ANGULAR_VITEST_IMPORT_SOURCE)).toBe(1); + }); + + it('partial wiring: import already present, adds call without duplicating the import (case 10b)', () => { + const importOnly = `import { storybookAngularVitest } from '${ANGULAR_VITEST_IMPORT_SOURCE}';\n${FRESH_V4}`; + const out = injectAngularVitestIntoConfig(importOnly)!; + expect(countOccurrences(out, ANGULAR_VITEST_IMPORT_SOURCE)).toBe(1); + expect(countOccurrences(out, `${ANGULAR_VITEST_PLUGIN_CALL}(`)).toBe(1); + const callees = pluginCalleesInSameArray(out); + expect(callees).toEqual([ANGULAR_VITEST_PLUGIN_CALL, 'storybookTest']); + }); +}); + +describe('injectAngularVitestIntoAst', () => { + it('mutates the AST in place and returns true', () => { + const ast = babelParse(FRESH_V4); + expect(injectAngularVitestIntoAst(ast)).toBe(true); + const callees = pluginCalleesInSameArray(generate(ast).code); + expect(callees).toEqual([ANGULAR_VITEST_PLUGIN_CALL, 'storybookTest']); + }); + + it('returns false for non-locatable arrays', () => { + const ast = babelParse('export default { test: {} };'); + expect(injectAngularVitestIntoAst(ast)).toBe(false); + }); + + it('co-locates in a workspace (defineWorkspace) element plugins array (case 6)', () => { + const workspace = ` + import { defineWorkspace } from 'vitest/config'; + import { storybookTest } from '@storybook/addon-vitest/vitest-plugin'; + export default defineWorkspace([ + './vite.config.ts', + { + extends: './vite.config.ts', + plugins: [storybookTest({ configDir: '.storybook' })], + test: { name: 'storybook' }, + }, + ]); + `; + const out = injectAngularVitestIntoConfig(workspace); + expect(out).not.toBeNull(); + const callees = pluginCalleesInSameArray(out!); + expect(callees).toEqual([ANGULAR_VITEST_PLUGIN_CALL, 'storybookTest']); + }); + + it('returns null for a workspace that only references external configs (case 7)', () => { + const referenced = ` + import { defineWorkspace } from 'vitest/config'; + export default defineWorkspace(['./vite.config.ts', './vitest.storybook.config.ts']); + `; + expect(injectAngularVitestIntoConfig(referenced)).toBeNull(); + }); +}); diff --git a/code/addons/vitest/src/angular-vitest-postinstall.ts b/code/addons/vitest/src/angular-vitest-postinstall.ts new file mode 100644 index 000000000000..a9aae06ee8a7 --- /dev/null +++ b/code/addons/vitest/src/angular-vitest-postinstall.ts @@ -0,0 +1,191 @@ +import { + type BabelFile, + babelParse, + generate, + traverse, + types as t, +} from 'storybook/internal/babel'; + +/** + * Auto-wiring for the `@storybook/angular-vite` standalone-vitest options bridge. + * + * `storybookAngularVitest()` must live in the SAME nested `plugins` array as the addon's + * `storybookTest()` call (`test.projects[].plugins` in the config templates, or a workspace entry's + * `plugins`) — never as a top-level sibling — so that the env var it sets synchronously is in place + * before `storybookTest`'s inline `presets.apply('viteFinal')` reads it. The generic + * `updateConfigFile` merge only matches top-level keys, so this module performs a targeted AST + * injection instead. + * + * `@storybook/angular-vite/vitest` is referenced as a string literal only — there is no build-time + * import edge from addon-vitest to the framework package. + */ + +export const ANGULAR_VITEST_IMPORT_SOURCE = '@storybook/angular-vite/vitest'; +export const ANGULAR_VITEST_PLUGIN_CALL = 'storybookAngularVitest'; + +const STORYBOOK_TEST_PLUGIN_SOURCE = '@storybook/addon-vitest/vitest-plugin'; +const STORYBOOK_TEST_PLUGIN_CALL = 'storybookTest'; + +/** + * True if the config already references the Angular bridge — either via its import source or a call + * to `storybookAngularVitest(`. Used as an idempotency gate independent of `isConfigAlreadySetup`. + */ +export function isAngularVitestAlreadyWired(content: string): boolean { + return ( + content.includes(ANGULAR_VITEST_IMPORT_SOURCE) || + content.includes(`${ANGULAR_VITEST_PLUGIN_CALL}(`) + ); +} + +/** + * Collects the local identifier names that `storybookTest` is imported as, so the locator can find + * the call even when it was aliased (`import { storybookTest as sbTest }`). Always seeded with the + * bare `storybookTest`. Mirrors the alias-collection precedent in `postinstall.ts`'s + * `isConfigAlreadySetup`. + */ +export function collectStorybookTestLocalNames(ast: BabelFile['ast']): Set { + const names = new Set([STORYBOOK_TEST_PLUGIN_CALL]); + + traverse(ast, { + ImportDeclaration(path) { + if (path.node.source.value !== STORYBOOK_TEST_PLUGIN_SOURCE) { + return; + } + path.node.specifiers.forEach((specifier) => { + if ('local' in specifier && specifier.local?.name) { + names.add(specifier.local.name); + } + }); + }, + }); + + return names; +} + +/** Ensures the storybookAngularVitest named import (from the angular-vite/vitest subpath) exists once. */ +function ensureImport(ast: BabelFile['ast']): void { + let hasImport = false; + + traverse(ast, { + ImportDeclaration(path) { + if (path.node.source.value !== ANGULAR_VITEST_IMPORT_SOURCE) { + return; + } + const alreadyImports = path.node.specifiers.some( + (specifier) => + specifier.type === 'ImportSpecifier' && + specifier.imported.type === 'Identifier' && + specifier.imported.name === ANGULAR_VITEST_PLUGIN_CALL + ); + if (alreadyImports) { + hasImport = true; + path.stop(); + } + }, + }); + + if (hasImport) { + return; + } + + const importDecl = t.importDeclaration( + [ + t.importSpecifier( + t.identifier(ANGULAR_VITEST_PLUGIN_CALL), + t.identifier(ANGULAR_VITEST_PLUGIN_CALL) + ), + ], + t.stringLiteral(ANGULAR_VITEST_IMPORT_SOURCE) + ); + + const lastImportIndex = ast.program.body.findLastIndex((n) => n.type === 'ImportDeclaration'); + ast.program.body.splice(lastImportIndex + 1, 0, importDecl); +} + +/** Builds the `storybookAngularVitest({})` call with a leading scaffold comment. */ +function buildAngularVitestCall(): t.CallExpression { + const call = t.callExpression(t.identifier(ANGULAR_VITEST_PLUGIN_CALL), [t.objectExpression([])]); + t.addComment( + call, + 'leading', + ' Forwards Angular build options (styles, assets, zoneless, …) into standalone vitest runs', + true + ); + return call; +} + +/** True if the array already contains a `storybookAngularVitest(...)` call. */ +function arrayHasAngularVitest(array: t.ArrayExpression): boolean { + return array.elements.some( + (el) => + el?.type === 'CallExpression' && + el.callee.type === 'Identifier' && + el.callee.name === ANGULAR_VITEST_PLUGIN_CALL + ); +} + +/** + * Locates the `plugins` ArrayExpression that contains a (possibly aliased) `storybookTest()` call + * and, if it is not already present, unshifts `storybookAngularVitest({})` so the bridge runs before + * the addon's plugin. Also ensures the import. Operates on the AST in place so callers can inject + * into an already-merged target before a single `generate`. + * + * Returns `false` when no such locatable array exists (e.g. `...storybookTest()` spread or other + * exotic shapes) — the caller then falls back to the manual-setup guidance. + */ +export function injectAngularVitestIntoAst(ast: BabelFile['ast']): boolean { + const localNames = collectStorybookTestLocalNames(ast); + + let pluginsArray: t.ArrayExpression | null = null; + + traverse(ast, { + CallExpression(path) { + if (pluginsArray) { + path.stop(); + return; + } + const { callee } = path.node; + if ( + callee.type === 'Identifier' && + localNames.has(callee.name) && + path.parentPath.isArrayExpression() + ) { + pluginsArray = path.parentPath.node as t.ArrayExpression; + path.stop(); + } + }, + }); + + if (!pluginsArray) { + return false; + } + + // Cast away the narrowing TS applies inside the visitor closure above. + const array = pluginsArray as t.ArrayExpression; + if (!arrayHasAngularVitest(array)) { + array.elements.unshift(buildAngularVitestCall()); + } + + ensureImport(ast); + return true; +} + +/** + * String-in / string-out convenience over {@link injectAngularVitestIntoAst} for the fresh-create + * and re-read paths. Returns `null` when the content cannot be parsed or no locatable plugins array + * is found. + */ +export function injectAngularVitestIntoConfig(content: string): string | null { + let ast: BabelFile['ast']; + try { + ast = babelParse(content); + } catch { + return null; + } + + if (!injectAngularVitestIntoAst(ast)) { + return null; + } + + return generate(ast).code; +} diff --git a/code/addons/vitest/src/components/InitialGlobals.stories.tsx b/code/addons/vitest/src/components/InitialGlobals.stories.tsx new file mode 100644 index 000000000000..be38b842134f --- /dev/null +++ b/code/addons/vitest/src/components/InitialGlobals.stories.tsx @@ -0,0 +1,31 @@ +import React from 'react'; + +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { expect } from 'storybook/test'; + +// The `storybook-ui` Vitest project pins `initialGlobals: { initialGlobalsWork: true }` in +// vitest.config.storybook.ts. This story renders that global and asserts it arrived, exercising the +// `initialGlobals` plugin option end to end (config -> provide/inject -> composed story globals). +const meta = { + title: 'InitialGlobals', + render: (_args, { globals }) => ( +
{JSON.stringify(globals.initialGlobalsWork)}
+ ), + // The pinned global only exists in the `storybook-ui` Vitest project, so this story is meaningful + // only there. Drop the `test` tag so the Chromatic/test-runner build (which has no such global) + // does not run the play, and skip the snapshot since there is nothing visual to capture. It keeps + // the `vitest` tag from the preview, so the `tests-stories` Vitest project still runs it. + tags: ['!test'], + parameters: { chromatic: { disableSnapshot: true } }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const FromVitestConfig: Story = { + play: async ({ canvas }) => { + await expect(canvas.getByTestId('initial-globals')).toHaveTextContent('true'); + }, +}; diff --git a/code/addons/vitest/src/constants.ts b/code/addons/vitest/src/constants.ts index 96e06983f104..b1fbfdf1ece4 100644 --- a/code/addons/vitest/src/constants.ts +++ b/code/addons/vitest/src/constants.ts @@ -70,6 +70,7 @@ export const STATUS_TYPE_ID_A11Y = 'storybook/a11y'; export const STORYBOOK_TEST_PROVIDE_KEY = 'storybook/test-provided'; export const STORYBOOK_CORE_GHOST_STORIES_PROVIDE_KEY = 'storybook/core-ghost-stories'; export const STORYBOOK_CORE_RENDER_ANALYSIS_PROVIDE_KEY = 'storybook/core-render-analysis'; +export const STORYBOOK_TEST_INITIAL_GLOBALS_PROVIDE_KEY = 'storybook/test-initial-globals'; // Channel event names for programmatic test triggering export const TRIGGER_TEST_RUN_REQUEST = `${ADDON_ID}/trigger-test-run-request`; diff --git a/code/addons/vitest/src/node/boot-test-runner.ts b/code/addons/vitest/src/node/boot-test-runner.ts index d08b496302b5..3236c52de56b 100644 --- a/code/addons/vitest/src/node/boot-test-runner.ts +++ b/code/addons/vitest/src/node/boot-test-runner.ts @@ -9,6 +9,8 @@ import { } from 'storybook/internal/core-server'; import type { EventInfo, Options } from 'storybook/internal/types'; +import type { BuilderOptions } from '@storybook/builder-vite'; + import { normalize } from 'pathe'; import { importMetaResolve } from '../../../../core/src/shared/utils/module.ts'; @@ -49,10 +51,12 @@ const bootTestRunner = async ({ channel, store, options, + configLoader, }: { channel: Channel; store: Store; options: Options; + configLoader?: BuilderOptions['configLoader']; }) => { let stderr: string[] = []; const killChild = () => { @@ -86,6 +90,7 @@ const bootTestRunner = async ({ VITEST_CHILD_PROCESS: 'true', NODE_ENV: process.env.NODE_ENV ?? 'test', STORYBOOK_CONFIG_DIR: normalize(options.configDir), + STORYBOOK_CONFIG_LOADER: configLoader, }, extendEnv: true, }, @@ -159,19 +164,21 @@ export const runTestRunner = async ({ initEvent, initArgs, options, + configLoader, }: { channel: Channel; store: Store; initEvent?: string; initArgs?: any[]; options: Options; + configLoader?: BuilderOptions['configLoader']; }) => { if (!ready && initEvent) { eventQueue.push({ type: initEvent, args: initArgs }); } if (!child) { ready = false; - await bootTestRunner({ channel, store, options }); + await bootTestRunner({ channel, store, options, configLoader }); ready = true; } }; diff --git a/code/addons/vitest/src/node/test-manager.ts b/code/addons/vitest/src/node/test-manager.ts index 83d945b780ec..2080ac2a8f33 100644 --- a/code/addons/vitest/src/node/test-manager.ts +++ b/code/addons/vitest/src/node/test-manager.ts @@ -8,6 +8,8 @@ import type { TestProviderStoreById, } from 'storybook/internal/types'; +import type { BuilderOptions } from '@storybook/builder-vite'; + import { throttle } from 'es-toolkit/function'; import type { Report } from 'storybook/preview-api'; @@ -26,6 +28,7 @@ import { VitestManager } from './vitest-manager.ts'; export type TestManagerOptions = { storybookOptions: Options; + configLoader?: BuilderOptions['configLoader']; store: experimental_UniversalStore; componentTestStatusStore: StatusStoreByTypeId; a11yStatusStore: StatusStoreByTypeId; @@ -57,6 +60,8 @@ export class TestManager { public storybookOptions: Options; + public readonly configLoader?: TestManagerOptions['configLoader']; + private batchedTestCaseResults: { storyId: string; testResult: TestResult; @@ -70,6 +75,7 @@ export class TestManager { this.testProviderStore = options.testProviderStore; this.onReady = options.onReady; this.storybookOptions = options.storybookOptions; + this.configLoader = options.configLoader; this.vitestManager = new VitestManager(this); diff --git a/code/addons/vitest/src/node/vitest-manager.ts b/code/addons/vitest/src/node/vitest-manager.ts index edc1115a1d9b..100b7bd01da5 100644 --- a/code/addons/vitest/src/node/vitest-manager.ts +++ b/code/addons/vitest/src/node/vitest-manager.ts @@ -127,6 +127,7 @@ export class VitestManager { try { this.vitest = await createVitest('test', { root: vitestWorkspaceConfig ?? vitestConfigFallbackLocation, + configLoader: this.testManager.configLoader, watch: true, passWithNoTests: false, project: [projectName], diff --git a/code/addons/vitest/src/node/vitest.ts b/code/addons/vitest/src/node/vitest.ts index 6d855168ba80..51e292cb1dd0 100644 --- a/code/addons/vitest/src/node/vitest.ts +++ b/code/addons/vitest/src/node/vitest.ts @@ -7,6 +7,8 @@ import { experimental_getTestProviderStore, } from 'storybook/internal/core-server'; +import type { BuilderOptions } from '@storybook/builder-vite'; + import { ADDON_ID, STATUS_TYPE_ID_A11Y, @@ -48,6 +50,7 @@ new TestManager({ storybookOptions: { configDir: process.env.STORYBOOK_CONFIG_DIR || '', } as any, + configLoader: process.env.STORYBOOK_CONFIG_LOADER as BuilderOptions['configLoader'], }); const exit = (code = 0) => { diff --git a/code/addons/vitest/src/postinstall.test.ts b/code/addons/vitest/src/postinstall.test.ts index f7082efee9a3..c3612fa6822a 100644 --- a/code/addons/vitest/src/postinstall.test.ts +++ b/code/addons/vitest/src/postinstall.test.ts @@ -1,6 +1,71 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; -import { isConfigAlreadySetup } from './postinstall.ts'; +import type { types as t } from 'storybook/internal/babel'; +import { babelParse, generate, traverse } from 'storybook/internal/babel'; + +import { + ANGULAR_VITEST_PLUGIN_CALL, + injectAngularVitestIntoAst, + injectAngularVitestIntoConfig, +} from './angular-vitest-postinstall.ts'; +import { getTemplateConfigDir, isConfigAlreadySetup } from './postinstall.ts'; +import { loadTemplate, updateConfigFile } from './updateVitestFile.ts'; + +vi.mock('storybook/internal/node-logger', () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +/** + * Returns the callee names of the plugins array that holds the (possibly aliased) storybookTest + * call, in source order. Asserts co-location: the Angular bridge must sit in the SAME array. + */ +function pluginCalleesInSameArray(code: string, locatorName = 'storybookTest'): string[] | null { + const ast = babelParse(code); + let elements: string[] | null = null; + traverse(ast, { + CallExpression(path) { + if (elements) { + path.stop(); + return; + } + const { callee } = path.node; + if ( + callee.type === 'Identifier' && + callee.name === locatorName && + path.parentPath.isArrayExpression() + ) { + elements = (path.parentPath.node as t.ArrayExpression).elements.map((el) => + el?.type === 'CallExpression' && el.callee.type === 'Identifier' + ? el.callee.name + : 'other' + ); + path.stop(); + } + }, + }); + return elements; +} + +describe('getTemplateConfigDir', () => { + it('returns the config dir relative to the generated config file directory', () => { + // Both inputs are cwd-relative, so the result is independent of the test cwd. + expect(getTemplateConfigDir('vitest.config.ts', '.storybook')).toBe('.storybook'); + }); + + it('does not double the path when the config file lives in a monorepo subproject', () => { + // `storybook add --config-dir apps/x/.storybook` run from the repo root, with the + // new vitest.config.ts created inside apps/x. + expect(getTemplateConfigDir('apps/x/vitest.config.ts', 'apps/x/.storybook')).toBe('.storybook'); + }); + + it('keeps a relative climb when the config dir is above the config file', () => { + expect(getTemplateConfigDir('apps/x/vitest.config.ts', '.storybook')).toBe('../../.storybook'); + }); + + it('supports a custom config dir name', () => { + expect(getTemplateConfigDir('apps/x/vitest.config.ts', 'apps/x/sb-config')).toBe('sb-config'); + }); +}); describe('postinstall helpers', () => { it('detects a fully configured Vitest config with addon plugin', () => { @@ -41,3 +106,95 @@ describe('postinstall helpers', () => { expect(isConfigAlreadySetup('/project/vitest.config.ts', config)).toBe(false); }); }); + +describe('Angular bridge wiring (postinstall integration)', () => { + it('fresh-create v4: co-locates the bridge in the template plugins array (case 5)', async () => { + const template = await loadTemplate('vitest.config.4.template', { CONFIG_DIR: '.storybook' }); + const out = injectAngularVitestIntoConfig(template); + expect(out).not.toBeNull(); + expect(pluginCalleesInSameArray(out!)).toEqual([ANGULAR_VITEST_PLUGIN_CALL, 'storybookTest']); + // Template comment survives the reprint (probe-locked path). + expect(out).toContain('The plugin will run tests for the stories'); + }); + + it('fresh-create v3.2: co-locates the bridge in the template plugins array (case 5)', async () => { + const template = await loadTemplate('vitest.config.3.2.template', { CONFIG_DIR: '.storybook' }); + const out = injectAngularVitestIntoConfig(template); + expect(out).not.toBeNull(); + expect(pluginCalleesInSameArray(out!)).toEqual([ANGULAR_VITEST_PLUGIN_CALL, 'storybookTest']); + }); + + it('fresh-create base: co-locates the bridge in the template plugins array (case 5)', async () => { + const template = await loadTemplate('vitest.config.template', { CONFIG_DIR: '.storybook' }); + const out = injectAngularVitestIntoConfig(template); + expect(out).not.toBeNull(); + expect(pluginCalleesInSameArray(out!)).toEqual([ANGULAR_VITEST_PLUGIN_CALL, 'storybookTest']); + }); + + it('existing-config sequencing: merge storybookTest, then co-locate the bridge (case 4)', async () => { + // Mirrors postinstall's existing-config branch: updateConfigFile merges the template into the + // user's vite config, then injectAngularVitestIntoAst runs on the SAME (merged) target. + const source = babelParse( + await loadTemplate('vitest.config.4.template', { CONFIG_DIR: '.storybook' }) + ); + const target = babelParse(` + import { defineConfig } from 'vite' + import react from '@vitejs/plugin-react' + export default defineConfig({ + plugins: [react()], + test: { globals: true }, + }) + `); + + expect(updateConfigFile(source, target)).toBe(true); + expect(injectAngularVitestIntoAst(target)).toBe(true); + + const after = generate(target).code; + expect(pluginCalleesInSameArray(after)).toEqual([ANGULAR_VITEST_PLUGIN_CALL, 'storybookTest']); + // The bridge must NOT be deposited as a top-level plugins sibling (react stays alone). + expect(pluginCalleesInSameArray(after, 'react')).toEqual(['react']); + }); + + it('arrow-function config: merges storybookTest and co-locates the bridge (case 4)', async () => { + // Function-notation configs (e.g. `defineConfig(() => ({ ... }))`) are now + // supported by updateConfigFile, so the merge succeeds and the Angular bridge + // co-locates with storybookTest in the same plugins array. + const source = babelParse( + await loadTemplate('vitest.config.4.template', { CONFIG_DIR: '.storybook' }) + ); + const target = babelParse(` + import { defineConfig } from 'vite' + export default defineConfig(() => ({ test: { globals: true } })) + `); + expect(updateConfigFile(source, target)).toBe(true); + expect(injectAngularVitestIntoAst(target)).toBe(true); + expect(pluginCalleesInSameArray(generate(target).code)).toEqual([ + ANGULAR_VITEST_PLUGIN_CALL, + 'storybookTest', + ]); + }); + + it('early-return-safe: storybookTest present but bridge absent still injects (case 9)', () => { + // Simulates the alreadyConfigured fall-through: storybookTest is wired, Angular is not. + const existing = ` + import { storybookTest } from '@storybook/addon-vitest/vitest-plugin'; + export default { + test: { projects: [{ extends: true, plugins: [storybookTest({ configDir: '.storybook' })] }] }, + }; + `; + const out = injectAngularVitestIntoConfig(existing); + expect(out).not.toBeNull(); + expect(pluginCalleesInSameArray(out!)).toEqual([ANGULAR_VITEST_PLUGIN_CALL, 'storybookTest']); + }); + + it('non-Angular is unaffected: injector is never invoked, config has no bridge (case 11)', () => { + // The postinstall gate (info.framework === ANGULAR_VITE) is what skips the injector for + // react-vite; the injector itself only acts when explicitly called. This documents that a + // react-vite fresh-create config contains no Angular references. + const reactConfig = ` + import { storybookTest } from '@storybook/addon-vitest/vitest-plugin'; + export default { test: { projects: [{ plugins: [storybookTest({})] }] } }; + `; + expect(reactConfig).not.toContain(ANGULAR_VITEST_PLUGIN_CALL); + }); +}); diff --git a/code/addons/vitest/src/postinstall.ts b/code/addons/vitest/src/postinstall.ts index 33d75788a37d..ff0d9de5950c 100644 --- a/code/addons/vitest/src/postinstall.ts +++ b/code/addons/vitest/src/postinstall.ts @@ -28,6 +28,11 @@ import { coerce, satisfies } from 'semver'; import { dedent } from 'ts-dedent'; import { type PostinstallOptions } from '../../../lib/cli-storybook/src/add.ts'; +import { + injectAngularVitestIntoAst, + injectAngularVitestIntoConfig, + isAngularVitestAlreadyWired, +} from './angular-vitest-postinstall.ts'; import { DOCUMENTATION_LINK } from './constants.ts'; import { loadTemplate, updateConfigFile, updateWorkspaceFile } from './updateVitestFile.ts'; @@ -37,6 +42,18 @@ const STORYBOOK_TEST_PLUGIN_SOURCE = `${ADDON_NAME}/vitest-plugin`; const addonA11yName = '@storybook/addon-a11y'; +/** + * The Vitest config templates resolve the Storybook config dir against the + * generated config file's own directory (`path.join(dirname, CONFIG_DIR)`). + * So CONFIG_DIR must be the path FROM that file's directory TO the config dir, + * not the cwd-relative `--config-dir` value. Otherwise, in a monorepo where the + * config file is created inside the project (e.g. `apps/x/vitest.config.ts`) but + * `--config-dir apps/x/.storybook` is passed from the repo root, the path gets + * doubled to `apps/x/apps/x/.storybook`. + */ +export const getTemplateConfigDir = (configFilePath: string, configDir: string): string => + relative(dirname(configFilePath), configDir); + export default async function postInstall(options: PostinstallOptions) { const errors: InstanceType[] = []; const { logger, prompt } = options; @@ -77,7 +94,10 @@ export default async function postInstall(options: PostinstallOptions) { ? satisfies(vitestVersionSpecifier, '>=4.0.0') : true; - const info = await getStorybookInfo(options.configDir); + // Skip the module cache: an automigration (e.g. angular-to-angular-vite) may have rewritten the + // main config earlier in this same process, and the cached version would still report the old + // framework/builder — causing the prerequisite check below to fail incorrectly. + const info = await getStorybookInfo(options.configDir, undefined, { skipCache: true }); // only install these dependencies if they are not already installed const addonVitestService = new AddonVitestService(packageManager); @@ -213,6 +233,45 @@ export default async function postInstall(options: PostinstallOptions) { return 'vitest.config.template'; }; + const isAngularVite = info.framework === SupportedFramework.ANGULAR_VITE; + + /** + * For Angular projects, co-locate `storybookAngularVitest()` in the same nested plugins array as + * `storybookTest()` so standalone `vitest` runs receive the Angular build options. Reads the file + * back from disk so it always operates on the just-written content, formats, and writes once. On a + * non-locatable plugins array the injector returns `null` and we emit the manual-setup error. + */ + const maybeWireAngular = async ( + filePath: string, + content: string, + ErrorClass: + | typeof AddonVitestPostinstallConfigUpdateError + | typeof AddonVitestPostinstallWorkspaceUpdateError = AddonVitestPostinstallConfigUpdateError + ) => { + if (!isAngularVite || isAngularVitestAlreadyWired(content)) { + return; + } + + const injected = injectAngularVitestIntoConfig(content); + if (injected === null) { + logger.error(dedent` + We configured @storybook/addon-vitest, but could not automatically add the + @storybook/angular-vite standalone-vitest bridge to: + ${filePath} + + Please add storybookAngularVitest({}) to the plugins array next to storybookTest() + and import it from "@storybook/angular-vite/vitest". See: + https://storybook.js.org/docs/next/${DOCUMENTATION_LINK}#manual-setup-advanced + `); + errors.push(new ErrorClass({ filePath })); + return; + } + + const formattedContent = await formatFileContent(filePath, injected); + await writeFile(filePath, formattedContent); + logger.step('Added the @storybook/angular-vite standalone-vitest bridge.'); + }; + // If there's an existing workspace file, we update that file to include the Storybook Addon Vitest plugin. // We assume the existing workspaces include the Vite(st) config, so we won't add it. if (vitestWorkspaceFile) { @@ -220,6 +279,13 @@ export default async function postInstall(options: PostinstallOptions) { const alreadyConfigured = isConfigAlreadySetup(vitestWorkspaceFile, workspaceFileContent); if (alreadyConfigured) { + // storybookTest is already wired, but the Angular bridge may still be missing (e.g. + // addon-vitest was set up before angular-vite). Wire it before the early return. + await maybeWireAngular( + vitestWorkspaceFile, + workspaceFileContent, + AddonVitestPostinstallWorkspaceUpdateError + ); logger.step( CLI_COLORS.success('Vitest for Storybook is already properly configured. Skipping setup.') ); @@ -230,7 +296,7 @@ export default async function postInstall(options: PostinstallOptions) { EXTENDS_WORKSPACE: viteConfigFile ? relative(dirname(vitestWorkspaceFile), viteConfigFile) : '', - CONFIG_DIR: options.configDir, + CONFIG_DIR: getTemplateConfigDir(vitestWorkspaceFile, options.configDir), }).then((t) => t.replace(`\n 'ROOT_CONFIG',`, '').replace(/\s+extends: '',/, '')); const source = babelParse(workspaceTemplate); const target = babelParse(workspaceFileContent); @@ -241,6 +307,28 @@ export default async function postInstall(options: PostinstallOptions) { logger.log(`${vitestWorkspaceFile}`); + // The workspace template adds a storybookTest plugins array inline, so the Angular bridge can + // be co-located in the merged target. If the workspace only references external config files + // (no inline plugins array), the injector returns false and we degrade to manual setup. + if (isAngularVite && !isAngularVitestAlreadyWired(workspaceFileContent)) { + if (injectAngularVitestIntoAst(target)) { + logger.step('Added the @storybook/angular-vite standalone-vitest bridge.'); + } else { + logger.error(dedent` + We configured @storybook/addon-vitest, but could not automatically add the + @storybook/angular-vite standalone-vitest bridge to your workspace file: + ${vitestWorkspaceFile} + + Please add storybookAngularVitest({}) to the plugins array next to storybookTest() + in the referenced config and import it from "@storybook/angular-vite/vitest". See: + https://storybook.js.org/docs/next/${DOCUMENTATION_LINK}#manual-setup-advanced + `); + errors.push( + new AddonVitestPostinstallWorkspaceUpdateError({ filePath: vitestWorkspaceFile }) + ); + } + } + const formattedContent = await formatFileContent(vitestWorkspaceFile, generate(target).code); await writeFile(vitestWorkspaceFile, formattedContent); } else { @@ -275,7 +363,7 @@ export default async function postInstall(options: PostinstallOptions) { if (templateName && !alreadyConfigured) { const configTemplate = await loadTemplate(templateName, { - CONFIG_DIR: options.configDir, + CONFIG_DIR: getTemplateConfigDir(rootConfig, options.configDir), }); const source = babelParse(configTemplate); @@ -284,6 +372,9 @@ export default async function postInstall(options: PostinstallOptions) { } if (alreadyConfigured) { + // storybookTest is already wired, but the Angular bridge may still be missing. Operate on the + // current on-disk content (no merge happened in this branch). + await maybeWireAngular(rootConfig, configFile); logger.step( CLI_COLORS.success('Vitest for Storybook is already properly configured. Skipping setup.') ); @@ -291,6 +382,27 @@ export default async function postInstall(options: PostinstallOptions) { logger.step(`Updating your ${vitestConfigFile ? 'Vitest' : 'Vite'} config file:`); logger.log(` ${rootConfig}`); + // Inject the Angular bridge into the already-merged target so it co-locates with the freshly + // added storybookTest call, before the single generate/format/write below. Arrow-function + // configs are rejected by updateConfigFile (updated is falsy), so they never reach here and + // defer to the manual-setup error in the else branch. + if (isAngularVite && !isAngularVitestAlreadyWired(configFile)) { + if (injectAngularVitestIntoAst(target)) { + logger.step('Added the @storybook/angular-vite standalone-vitest bridge.'); + } else { + logger.error(dedent` + We configured @storybook/addon-vitest, but could not automatically add the + @storybook/angular-vite standalone-vitest bridge to: + ${rootConfig} + + Please add storybookAngularVitest({}) to the plugins array next to storybookTest() + and import it from "@storybook/angular-vite/vitest". See: + https://storybook.js.org/docs/next/${DOCUMENTATION_LINK}#manual-setup-advanced + `); + errors.push(new AddonVitestPostinstallConfigUpdateError({ filePath: rootConfig })); + } + } + const formattedContent = await formatFileContent(rootConfig, generate(target).code); // Only add triple slash reference to vite.config files, not vitest.config files // vitest.config files already have the vitest/config types available @@ -317,13 +429,24 @@ export default async function postInstall(options: PostinstallOptions) { const newConfigFile = resolve(parentDir, `vitest.config.${fileExtension}`); const configTemplate = await loadTemplate(getTemplateName(), { - CONFIG_DIR: options.configDir, + CONFIG_DIR: getTemplateConfigDir(newConfigFile, options.configDir), }); logger.step(`Creating a Vitest config file:`); logger.log(`${newConfigFile}`); - const formattedContent = await formatFileContent(newConfigFile, configTemplate); + // For Angular, co-locate the standalone-vitest bridge in the template's storybookTest plugins + // array. The template always has a locatable array, so the injector never returns null here. + let configContent = configTemplate; + if (isAngularVite) { + const injected = injectAngularVitestIntoConfig(configTemplate); + if (injected !== null) { + configContent = injected; + logger.step('Added the @storybook/angular-vite standalone-vitest bridge.'); + } + } + + const formattedContent = await formatFileContent(newConfigFile, configContent); await writeFile(newConfigFile, formattedContent); } diff --git a/code/addons/vitest/src/preset.ts b/code/addons/vitest/src/preset.ts index c10349d63f00..0b17ab781ddc 100644 --- a/code/addons/vitest/src/preset.ts +++ b/code/addons/vitest/src/preset.ts @@ -21,6 +21,8 @@ import type { StoryId, } from 'storybook/internal/types'; +import type { BuilderOptions } from '@storybook/builder-vite'; + import { isEqual } from 'es-toolkit/predicate'; import picocolors from 'picocolors'; import { dedent } from 'ts-dedent'; @@ -82,6 +84,10 @@ export const experimental_serverChannel = async (channel: Channel, options: Opti return channel; } + const configLoader = + typeof core.builder !== 'string' && + (core.builder?.options?.configLoader as BuilderOptions['configLoader']); + const storyIndexGenerator = await options.presets.apply>('storyIndexGenerator'); @@ -136,6 +142,7 @@ export const experimental_serverChannel = async (channel: Channel, options: Opti initEvent: STORE_CHANNEL_EVENT_NAME, initArgs: [{ event, eventInfo }], options, + configLoader: configLoader || undefined, }); }); store.subscribe('TOGGLE_WATCHING', (event, eventInfo) => { @@ -157,6 +164,7 @@ export const experimental_serverChannel = async (channel: Channel, options: Opti initEvent: STORE_CHANNEL_EVENT_NAME, initArgs: [{ event, eventInfo }], options, + configLoader: configLoader || undefined, }); } }); @@ -225,17 +233,6 @@ export const experimental_serverChannel = async (channel: Channel, options: Opti return; } - store.send({ - type: 'TRIGGER_RUN', - payload: { - storyIds, - triggeredBy: `external:${actor}`, - ...(configOverride && { - configOverride: { ...config, ...configOverride }, - }), - }, - }); - const unsubscribe = store.subscribe((event) => { switch (event.type) { case 'TEST_RUN_COMPLETED': { @@ -255,6 +252,17 @@ export const experimental_serverChannel = async (channel: Channel, options: Opti } } }); + + store.send({ + type: 'TRIGGER_RUN', + payload: { + storyIds, + triggeredBy: `external:${actor}`, + ...(configOverride && { + configOverride: { ...config, ...configOverride }, + }), + }, + }); }); const enableCrashReports = core?.enableCrashReports || options.enableCrashReports; diff --git a/code/addons/vitest/src/updateVitestFile.config.3.2.test.ts b/code/addons/vitest/src/updateVitestFile.config.3.2.test.ts index 28ddb27aa149..e75ca3a2a76e 100644 --- a/code/addons/vitest/src/updateVitestFile.config.3.2.test.ts +++ b/code/addons/vitest/src/updateVitestFile.config.3.2.test.ts @@ -181,7 +181,7 @@ describe('updateConfigFile', () => { `); }); - it('does not support function notation', async () => { + it('does not support complex function notation', async () => { const source = babel.babelParse( await loadTemplate('vitest.config.3.2.template', { CONFIG_DIR: '.storybook', @@ -194,13 +194,25 @@ describe('updateConfigFile', () => { import react from '@vitejs/plugin-react' // https://vite.dev/config/ - export default defineConfig(() => ({ - plugins: [react()], - test: { - globals: true, - projects: ['packages/*'] - }, - })) + export default defineConfig(({ mode }) => { + if (mode === 'production') { + return { + plugins: [react()], + test: { + globals: true, + projects: ['packages/*'] + }, + } + } + + return { + plugins: [react()], + test: { + globals: false, + projects: ['packages/*'] + }, + } + }) `); const before = babel.generate(target).code; diff --git a/code/addons/vitest/src/updateVitestFile.config.4.test.ts b/code/addons/vitest/src/updateVitestFile.config.4.test.ts index f0d17a06c0df..81b391e725f9 100644 --- a/code/addons/vitest/src/updateVitestFile.config.4.test.ts +++ b/code/addons/vitest/src/updateVitestFile.config.4.test.ts @@ -182,7 +182,121 @@ describe('updateConfigFile', () => { `); }); - it('does not support function notation', async () => { + it('supports function notation when defineConfig callback returns an object literal', async () => { + const source = babel.babelParse( + await loadTemplate('vitest.config.4.template', { + CONFIG_DIR: '.storybook', + BROWSER_CONFIG: "{ provider: 'playwright' }", + SETUP_FILE: '../.storybook/vitest.setup.ts', + }) + ); + const target = babel.babelParse(` + /// + + import angular from '@analogjs/vite-plugin-angular'; + import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; + import { defineConfig } from 'vite'; + + // https://vitejs.dev/config/ + export default defineConfig(({ mode }) => { + return { + plugins: [angular(), nxViteTsPaths()], + test: { + globals: true, + environment: 'jsdom', + setupFiles: ['src/test-setup.ts'], + include: ['**/*.spec.ts'], + reporters: ['default'], + hideSkippedTests: true, + passWithNoTests: true, + }, + define: { + 'import.meta.vitest': mode !== 'production', + }, + }; + }); + `); + + const before = babel.generate(target).code; + const updated = updateConfigFile(source, target); + expect(updated).toBe(true); + + const after = babel.generate(target).code; + expect(after).not.toBe(before); + expect(getDiff(before, after)).toMatchInlineSnapshot(` + " ... + import { defineConfig } from 'vite'; + + // https://vitejs.dev/config/ + + + import path from 'node:path'; + + import { fileURLToPath } from 'node:url'; + + import { storybookTest } from '@storybook/addon-vitest/vitest-plugin'; + + import { playwright } from '@vitest/browser-playwright'; + + const dirname = typeof __dirname !== 'undefined' ? __dirname : path.dirname(fileURLToPath(import.meta.url)); + + + + // More info at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon + + + export default defineConfig(({ + mode + }) => { + return { + plugins: [angular(), nxViteTsPaths()], + + - test: { + - globals: true, + - environment: 'jsdom', + - setupFiles: ['src/test-setup.ts'], + - include: ['**/*.spec.ts'], + - reporters: ['default'], + - hideSkippedTests: true, + - passWithNoTests: true + - }, + - + define: { + 'import.meta.vitest': mode !== 'production' + + + }, + + test: { + + reporters: ['default'], + + passWithNoTests: true, + + projects: [{ + + extends: true, + + test: { + + globals: true, + + environment: 'jsdom', + + setupFiles: ['src/test-setup.ts'], + + include: ['**/*.spec.ts'], + + hideSkippedTests: true + + } + + }, { + + extends: true, + + plugins: [ + + // The plugin will run tests for the stories defined in your Storybook config + + // See options at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon#storybooktest + + storybookTest({ + + configDir: path.join(dirname, '.storybook') + + })], + + test: { + + name: 'storybook', + + browser: { + + enabled: true, + + headless: true, + + provider: playwright({}), + + instances: [{ + + browser: 'chromium' + + }] + + } + + } + + }] + + + } + }; + });" + `); + }); + + it('does not support complex function notation', async () => { const source = babel.babelParse( await loadTemplate('vitest.config.4.template', { CONFIG_DIR: '.storybook', @@ -195,13 +309,25 @@ describe('updateConfigFile', () => { import react from '@vitejs/plugin-react' // https://vite.dev/config/ - export default defineConfig(() => ({ - plugins: [react()], - test: { - globals: true, - projects: ['packages/*'] - }, - })) + export default defineConfig(({ mode }) => { + if (mode === 'production') { + return { + plugins: [react()], + test: { + globals: true, + projects: ['packages/*'] + }, + } + } + + return { + plugins: [react()], + test: { + globals: false, + projects: ['packages/*'] + }, + } + }) `); const before = babel.generate(target).code; diff --git a/code/addons/vitest/src/updateVitestFile.config.test.ts b/code/addons/vitest/src/updateVitestFile.config.test.ts index 685cc866db54..d3aa3b030910 100644 --- a/code/addons/vitest/src/updateVitestFile.config.test.ts +++ b/code/addons/vitest/src/updateVitestFile.config.test.ts @@ -178,7 +178,7 @@ describe('updateConfigFile', () => { `); }); - it('does not support function notation', async () => { + it('does not support complex function notation', async () => { const source = babel.babelParse( await loadTemplate('vitest.config.template', { CONFIG_DIR: '.storybook', @@ -191,13 +191,25 @@ describe('updateConfigFile', () => { import react from '@vitejs/plugin-react' // https://vite.dev/config/ - export default defineConfig(() => ({ - plugins: [react()], - test: { - globals: true, - workspace: ['packages/*'] - }, - })) + export default defineConfig(({ mode }) => { + if (mode === 'production') { + return { + plugins: [react()], + test: { + globals: true, + workspace: ['packages/*'] + }, + } + } + + return { + plugins: [react()], + test: { + globals: false, + workspace: ['packages/*'] + }, + } + }) `); const before = babel.generate(target).code; diff --git a/code/addons/vitest/src/updateVitestFile.ts b/code/addons/vitest/src/updateVitestFile.ts index 157aacad84df..e1b1f88291f0 100644 --- a/code/addons/vitest/src/updateVitestFile.ts +++ b/code/addons/vitest/src/updateVitestFile.ts @@ -323,6 +323,51 @@ const mergeTemplateIntoConfigObject = ( mergeProperties(properties, targetConfigObject.properties); }; +/** + * Finds the writable target config object in `export default ...`, including + * callback-based defineConfig patterns like `defineConfig(({ mode }) => ({ ... }))` + * and `defineConfig(() => { return { ... }; })`. + */ +const getWritableTargetConfigObject = ( + target: BabelFile['ast'], + exportDefault: t.ExportDefaultDeclaration +): t.ObjectExpression | null => { + const directConfigObject = getTargetConfigObject(target, exportDefault); + if (directConfigObject) { + return directConfigObject; + } + + const resolvedDecl = resolveExpression(exportDefault.declaration, target); + if (resolvedDecl?.type !== 'CallExpression' || !isDefineConfigLike(resolvedDecl, target)) { + return null; + } + + const callbackArg = resolvedDecl.arguments[0]; + if ( + !callbackArg || + (callbackArg.type !== 'ArrowFunctionExpression' && callbackArg.type !== 'FunctionExpression') + ) { + return null; + } + + if (callbackArg.body.type === 'ObjectExpression') { + return callbackArg.body; + } + + if ( + callbackArg.body.type === 'BlockStatement' && + callbackArg.body.body.length === 1 && + callbackArg.body.body[0]?.type === 'ReturnStatement' + ) { + const returnedExpr = resolveExpression(callbackArg.body.body[0].argument, target); + if (returnedExpr?.type === 'ObjectExpression') { + return returnedExpr; + } + } + + return null; +}; + /** * Merges a source Vitest configuration AST into a target configuration AST. * @@ -366,22 +411,10 @@ export const updateConfigFile = (source: BabelFile['ast'], target: BabelFile['as return false; } - // Check if this is a function notation that we don't support (defineConfig(() => ({}))) - // Resolve through TS type wrappers and variable references before checking. - const effectiveDecl = resolveExpression(targetExportDefault.declaration, target); - if ( - effectiveDecl?.type === 'CallExpression' && - isDefineConfigLike(effectiveDecl, target) && - effectiveDecl.arguments.length > 0 && - effectiveDecl.arguments[0].type === 'ArrowFunctionExpression' - ) { - return false; - } - // Check if we can handle the config pattern (direct object, defineConfig/defineProject, // mergeConfig, or any of these wrapped in TS type annotations / variable references) let canHandleConfig = false; - if (getTargetConfigObject(target, targetExportDefault) !== null) { + if (getWritableTargetConfigObject(target, targetExportDefault) !== null) { canHandleConfig = true; } else if (getEffectiveMergeConfigCall(targetExportDefault.declaration, target) !== null) { canHandleConfig = true; @@ -431,7 +464,7 @@ export const updateConfigFile = (source: BabelFile['ast'], target: BabelFile['as sourceNode.declaration.arguments[0].type === 'ObjectExpression' ) { const { properties } = sourceNode.declaration.arguments[0]; - const targetConfigObject = getTargetConfigObject(target, exportDefault); + const targetConfigObject = getWritableTargetConfigObject(target, exportDefault); if (targetConfigObject !== null) { mergeTemplateIntoConfigObject(targetConfigObject, properties, target); updated = true; diff --git a/code/addons/vitest/src/vitest-plugin/compose-initial-globals.test.ts b/code/addons/vitest/src/vitest-plugin/compose-initial-globals.test.ts new file mode 100644 index 000000000000..f6f8e22a2b18 --- /dev/null +++ b/code/addons/vitest/src/vitest-plugin/compose-initial-globals.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; + +import { STORYBOOK_TEST_PROVIDE_KEY } from '../constants.ts'; +import { composeInitialGlobals } from './compose-initial-globals.ts'; + +describe('composeInitialGlobals', () => { + const base = { + runConfig: { a11y: true }, + ghostStoriesEnabled: false, + renderAnalysisEnabled: false, + shouldRunA11yTests: true, + }; + + it('applies the project initialGlobals', () => { + const globals = composeInitialGlobals({ ...base, userInitialGlobals: { theme: 'dark' } }); + expect(globals.theme).toBe('dark'); + }); + + it("keeps Storybook's run-control globals overriding user values", () => { + const globals = composeInitialGlobals({ + ...base, + shouldRunA11yTests: false, + userInitialGlobals: { + a11y: { manual: false, config: { rules: [] } }, + ghostStories: { enabled: true }, + [STORYBOOK_TEST_PROVIDE_KEY]: { hijacked: true }, + }, + }); + // `manual` is derived from the run, but other a11y fields the project set are kept + expect(globals.a11y).toEqual({ manual: true, config: { rules: [] } }); + // the test-provided run config can't be clobbered by a user global + expect(globals[STORYBOOK_TEST_PROVIDE_KEY]).toEqual({ a11y: true }); + // a project can't inject the internal ghost-stories flag when the feature is off + expect(globals.ghostStories).toBeUndefined(); + }); + + it('adds ghost-stories and render-analysis flags when enabled', () => { + const globals = composeInitialGlobals({ + ...base, + userInitialGlobals: {}, + ghostStoriesEnabled: true, + renderAnalysisEnabled: true, + }); + expect(globals.ghostStories).toEqual({ enabled: true }); + expect(globals.renderAnalysis).toEqual({ enabled: true }); + }); +}); diff --git a/code/addons/vitest/src/vitest-plugin/compose-initial-globals.ts b/code/addons/vitest/src/vitest-plugin/compose-initial-globals.ts new file mode 100644 index 000000000000..a709d22a53ca --- /dev/null +++ b/code/addons/vitest/src/vitest-plugin/compose-initial-globals.ts @@ -0,0 +1,48 @@ +import { STORYBOOK_TEST_PROVIDE_KEY } from '../constants.ts'; + +/** + * Build the globals each story runs under: the project's `initialGlobals` (set via + * `storybookTest({ initialGlobals })`) merged BENEATH Storybook's own run-control globals, so those + * always win and a consumer can't clobber them (`a11y.manual`, the test-provided run config, and the + * ghost-stories / render-analysis flags). + */ +export const composeInitialGlobals = ({ + userInitialGlobals, + runConfig, + ghostStoriesEnabled, + renderAnalysisEnabled, + shouldRunA11yTests, +}: { + userInitialGlobals: Record; + runConfig: Record; + ghostStoriesEnabled: boolean; + renderAnalysisEnabled: boolean; + shouldRunA11yTests: boolean; +}): Record => { + // Start from the project's globals, then force Storybook's run-control globals on top so a + // project's `initialGlobals` can never override them (whatever value it set for these keys). + const globals: Record = { ...userInitialGlobals }; + + globals[STORYBOOK_TEST_PROVIDE_KEY] = runConfig; + + // Match the original behaviour: the key is present only when the feature is enabled, so a + // project-supplied value is dropped when it isn't. + if (ghostStoriesEnabled) { + globals.ghostStories = { enabled: true }; + } else { + delete globals.ghostStories; + } + if (renderAnalysisEnabled) { + globals.renderAnalysis = { enabled: true }; + } else { + delete globals.renderAnalysis; + } + + // Keep any other a11y globals the project set; only `manual` is owned by the run. + globals.a11y = { + ...(globals.a11y as Record | undefined), + manual: !shouldRunA11yTests, + }; + + return globals; +}; diff --git a/code/addons/vitest/src/vitest-plugin/global-setup.ts b/code/addons/vitest/src/vitest-plugin/global-setup.ts index 2086b3b15f89..b601b999f81c 100644 --- a/code/addons/vitest/src/vitest-plugin/global-setup.ts +++ b/code/addons/vitest/src/vitest-plugin/global-setup.ts @@ -82,8 +82,19 @@ export const teardown = async () => { await new Promise((resolve, reject) => { // Storybook starts multiple child processes, so we need to kill the whole tree if (storybookProcess?.pid) { - treeKill(storybookProcess.pid, 'SIGTERM', (error) => { + const pid = storybookProcess.pid; + treeKill(pid, 'SIGTERM', (error) => { if (error) { + // On Windows, tree-kill shells out to `taskkill`, which exits with code 128 when the + // target PID no longer exists (e.g. Storybook already exited or was stopped by the user). + // On POSIX, tree-kill already swallows the equivalent ESRCH internally. + if (process.platform === 'win32' && 'code' in error && error.code === 128) { + logger.verbose( + `Storybook process (pid ${pid}) already exited; treating teardown as successful (code ${error.code}: ${error.message})` + ); + resolve(); + return; + } logger.error('Failed to stop Storybook process:'); reject(error); return; diff --git a/code/addons/vitest/src/vitest-plugin/index.ts b/code/addons/vitest/src/vitest-plugin/index.ts index fce687c60e50..f915ea9ee97e 100644 --- a/code/addons/vitest/src/vitest-plugin/index.ts +++ b/code/addons/vitest/src/vitest-plugin/index.ts @@ -3,6 +3,7 @@ import { fileURLToPath } from 'node:url'; import type { Plugin } from 'vitest/config'; import { mergeConfig } from 'vitest/config'; import type { ViteUserConfig } from 'vitest/config'; +import type {} from '@vitest/browser-playwright'; import { DEFAULT_FILES_PATTERN, @@ -43,6 +44,7 @@ import { withoutVitePlugins } from '../../../../builders/builder-vite/src/utils/ import { STORYBOOK_CORE_GHOST_STORIES_PROVIDE_KEY, STORYBOOK_CORE_RENDER_ANALYSIS_PROVIDE_KEY, + STORYBOOK_TEST_INITIAL_GLOBALS_PROVIDE_KEY, } from '../constants.ts'; import type { InternalOptions, UserOptions } from './types.ts'; import { requiresProjectAnnotations } from './utils.ts'; @@ -55,6 +57,7 @@ const defaultOptions = { configDir: resolve(join(WORKING_DIR, '.storybook')), storybookUrl: 'http://localhost:6006', disableAddonDocs: true, + initialGlobals: {}, } satisfies UserOptions; const extractTagsFromPreview = async (configDir: string) => { @@ -323,13 +326,11 @@ export const storybookTest = async (options?: UserOptions): Promise => finalOptions ); - const internalSetupFiles = ( - [ - '@storybook/addon-vitest/internal/setup-file', - areProjectAnnotationRequired && - '@storybook/addon-vitest/internal/setup-file-with-project-annotations', - ].filter(Boolean) as string[] - ).map((filePath) => fileURLToPath(import.meta.resolve(filePath))); + const internalSetupFiles = [ + '@storybook/addon-vitest/internal/setup-file', + areProjectAnnotationRequired && + '@storybook/addon-vitest/internal/setup-file-with-project-annotations', + ].filter(Boolean) as string[]; const baseConfig: Omit = { cacheDir: resolvePathInStorybookCache('sb-vitest', projectId), @@ -375,6 +376,7 @@ export const storybookTest = async (options?: UserOptions): Promise => [STORYBOOK_CORE_GHOST_STORIES_PROVIDE_KEY]: !!process.env.STORYBOOK_COMPONENT_PATHS, [STORYBOOK_CORE_RENDER_ANALYSIS_PROVIDE_KEY]: !!process.env.STORYBOOK_COMPONENT_PATHS || withinAgenticSetupSession, + [STORYBOOK_TEST_INITIAL_GLOBALS_PROVIDE_KEY]: finalOptions.initialGlobals, }, include: [...includeStories, ...getComponentTestPaths()], @@ -403,12 +405,27 @@ export const storybookTest = async (options?: UserOptions): Promise => screenshotFailures: false, } : {}), + + // Inject the cursor reset command we use to prevent accidental hover states when running + // Storybook tests in Chromium on Linux. There is a known race condition / special code path + // in Chromium causing it to sometimes apply :hover to the element under the mouse cursor even + // when there was no mouse movement. + commands: { + async resetMousePosition(ctx) { + if (ctx.provider.name === 'playwright') { + const frame = await ctx.frame(); + await frame.page().mouse.move(-1000, -1000); + } + }, + }, }, }, optimizeDeps: { include: [ '@storybook/addon-vitest/internal/setup-file', + '@storybook/addon-vitest/internal/setup-file.browser.3', + '@storybook/addon-vitest/internal/setup-file.browser.4', '@storybook/addon-vitest/internal/global-setup', '@storybook/addon-vitest/internal/test-utils', 'storybook/preview-api', @@ -450,6 +467,21 @@ export const storybookTest = async (options?: UserOptions): Promise => async configureVitest(context) { context.vitest.config.coverage.exclude.push('storybook-static'); + const isBrowserModeEnabled = context.vitest.config.browser?.enabled === true; + + if (isBrowserModeEnabled) { + const setupFilePath = context.vitest.version.startsWith('3') + ? '@storybook/addon-vitest/internal/setup-file.browser.3' + : '@storybook/addon-vitest/internal/setup-file.browser.4'; + + context.vitest.config.setupFiles = [ + setupFilePath, + ...(context.vitest.config.setupFiles ?? []).filter( + (configuredSetupFile) => configuredSetupFile !== setupFilePath + ), + ]; + } + // NOTE: we start telemetry immediately but do not wait on it. Typically it should complete // before the tests do. If not we may miss the event, we are OK with that. telemetry( diff --git a/code/addons/vitest/src/vitest-plugin/setup-file.browser.3.ts b/code/addons/vitest/src/vitest-plugin/setup-file.browser.3.ts new file mode 100644 index 000000000000..d40dc8d015d5 --- /dev/null +++ b/code/addons/vitest/src/vitest-plugin/setup-file.browser.3.ts @@ -0,0 +1,15 @@ +import { beforeEach } from 'vitest'; + +import { commands } from '@vitest/browser/context'; + +import { isFunction } from 'es-toolkit/predicate'; + +export const resetMousePositionBeforeTests = async () => { + if ('resetMousePosition' in commands && isFunction(commands.resetMousePosition)) { + await commands.resetMousePosition(); + } +}; + +beforeEach(async () => { + await resetMousePositionBeforeTests(); +}); diff --git a/code/addons/vitest/src/vitest-plugin/setup-file.browser.4.ts b/code/addons/vitest/src/vitest-plugin/setup-file.browser.4.ts new file mode 100644 index 000000000000..1c73841664a5 --- /dev/null +++ b/code/addons/vitest/src/vitest-plugin/setup-file.browser.4.ts @@ -0,0 +1,15 @@ +import { beforeEach } from 'vitest'; + +import { commands } from 'vitest/browser'; + +import { isFunction } from 'es-toolkit/predicate'; + +export const resetMousePositionBeforeTests = async () => { + if ('resetMousePosition' in commands && isFunction(commands.resetMousePosition)) { + await commands.resetMousePosition(); + } +}; + +beforeEach(async () => { + await resetMousePositionBeforeTests(); +}); diff --git a/code/addons/vitest/src/vitest-plugin/setup-file.test.ts b/code/addons/vitest/src/vitest-plugin/setup-file.test.ts index 5f3540e5db05..32d52742d733 100644 --- a/code/addons/vitest/src/vitest-plugin/setup-file.test.ts +++ b/code/addons/vitest/src/vitest-plugin/setup-file.test.ts @@ -1,6 +1,50 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { type Task, modifyErrorMessage } from './setup-file.ts'; +import { Channel, clearChannel, getChannel, setChannel } from 'storybook/internal/channels'; + +import { + type Task, + initTransport, + modifyErrorMessage, + restoreDefaultChannel, +} from './setup-file.ts'; + +describe('initTransport', () => { + afterEach(() => { + clearChannel(); + }); + + it('should initialize the addons channel when missing', () => { + clearChannel(); + + initTransport(); + + expect(getChannel()).toBeInstanceOf(Channel); + }); + + it('restoreDefaultChannel reinstalls the default when the slot was replaced', () => { + initTransport(); + const defaultRef = getChannel(); + + setChannel(new Channel({ transport: { setHandler: vi.fn(), send: vi.fn() } })); + + restoreDefaultChannel(); + + expect(getChannel()).toBe(defaultRef); + }); + + it('should not overwrite an existing addons channel', () => { + const transport = { setHandler: vi.fn(), send: vi.fn() }; + const existingChannel = new Channel({ transport }); + clearChannel(); + (globalThis as { __STORYBOOK_ADDONS_CHANNEL__?: Channel }).__STORYBOOK_ADDONS_CHANNEL__ = + existingChannel; + + initTransport(); + + expect(getChannel()).toBe(existingChannel); + }); +}); describe('modifyErrorMessage', () => { const originalUrl = import.meta.env.__STORYBOOK_URL__; @@ -77,3 +121,40 @@ describe('modifyErrorMessage', () => { expect(task.result?.errors?.[0].message).toBe('Non story test failure'); }); }); + +describe('resetMousePositionBeforeTests', () => { + afterEach(() => { + vi.clearAllMocks(); + vi.resetModules(); + vi.doUnmock('vitest/browser'); + vi.doUnmock('@vitest/browser/context'); + }); + + it('should reset the mouse position when the browser command exists', async () => { + const resetMousePosition = vi.fn().mockResolvedValue(undefined); + + vi.doMock('vitest/browser', () => ({ + commands: { + resetMousePosition, + }, + })); + + const { resetMousePositionBeforeTests } = await import('./setup-file.browser.4.ts'); + + await resetMousePositionBeforeTests(); + + expect(resetMousePosition).toHaveBeenCalledTimes(1); + }); + + it('should do nothing when resetMousePosition is not callable', async () => { + vi.doMock('vitest/browser', () => ({ + commands: { + resetMousePosition: 'not-a-function', + }, + })); + + const { resetMousePositionBeforeTests } = await import('./setup-file.browser.4.ts'); + + await expect(resetMousePositionBeforeTests()).resolves.toBeUndefined(); + }); +}); diff --git a/code/addons/vitest/src/vitest-plugin/setup-file.ts b/code/addons/vitest/src/vitest-plugin/setup-file.ts index 5621698ab1bc..1c836e2a150f 100644 --- a/code/addons/vitest/src/vitest-plugin/setup-file.ts +++ b/code/addons/vitest/src/vitest-plugin/setup-file.ts @@ -2,6 +2,7 @@ import { afterEach, beforeAll, vi } from 'vitest'; import type { RunnerTask } from 'vitest'; import { Channel } from 'storybook/internal/channels'; +import { getChannel, setChannel } from 'storybook/internal/channels'; import { COMPONENT_TESTING_PANEL_ID } from '../constants.ts'; @@ -16,8 +17,32 @@ export type Task = Partial & { meta: Record; }; -const transport = { setHandler: vi.fn(), send: vi.fn() }; -globalThis.__STORYBOOK_ADDONS_CHANNEL__ ??= new Channel({ transport }); +let defaultChannel: Channel | null = null; + +export const initTransport = () => { + const existing = getChannel(); + if (existing) { + defaultChannel ??= existing as Channel; + return; + } + + const transport = { setHandler: vi.fn(), send: vi.fn() }; + const channel = new Channel({ transport }); + defaultChannel = channel; + setChannel(channel); +}; + +/** Restore the channel installed for story tests (e.g. after manager stories swap in a mock). */ +export const restoreDefaultChannel = () => { + if (!defaultChannel) { + initTransport(); + return; + } + + if (getChannel() !== defaultChannel) { + setChannel(defaultChannel); + } +}; export const modifyErrorMessage = ({ task }: { task: Task }) => { const meta = task.meta; @@ -34,10 +59,15 @@ export const modifyErrorMessage = ({ task }: { task: Task }) => { } }; +initTransport(); + beforeAll(() => { if (globalThis.globalProjectAnnotations) { return globalThis.globalProjectAnnotations.beforeAll(); } }); -afterEach(modifyErrorMessage); +afterEach((ctx) => { + restoreDefaultChannel(); + modifyErrorMessage({ task: ctx.task }); +}); diff --git a/code/addons/vitest/src/vitest-plugin/test-utils.ts b/code/addons/vitest/src/vitest-plugin/test-utils.ts index 5ca0ebca8fd7..428b20d980df 100644 --- a/code/addons/vitest/src/vitest-plugin/test-utils.ts +++ b/code/addons/vitest/src/vitest-plugin/test-utils.ts @@ -8,8 +8,10 @@ import { type Report, composeStory, getCsfFactoryAnnotations } from 'storybook/p import { STORYBOOK_CORE_GHOST_STORIES_PROVIDE_KEY, STORYBOOK_CORE_RENDER_ANALYSIS_PROVIDE_KEY, + STORYBOOK_TEST_INITIAL_GLOBALS_PROVIDE_KEY, STORYBOOK_TEST_PROVIDE_KEY, } from '../constants.ts'; +import { composeInitialGlobals } from './compose-initial-globals.ts'; import { setViewport } from './viewports.ts'; /** @@ -77,27 +79,24 @@ export const testStory = ({ // Standalone Vitest runs might not provide Storybook render analysis config. } + // Globals the consumer pinned on this project via `storybookTest({ initialGlobals })`, e.g. + // `{ theme: 'dark' }` to run the suite under a specific theme. Spread first so Storybook's own + // run-control globals below always win. + let userInitialGlobals: Record = {}; + try { + userInitialGlobals = inject(STORYBOOK_TEST_INITIAL_GLOBALS_PROVIDE_KEY) ?? {}; + } catch { + // Standalone Vitest runs might not provide project initial globals. + } + const shouldRunA11yTests = !!runConfig.a11y; - const initialGlobals = { - [STORYBOOK_TEST_PROVIDE_KEY]: runConfig, - ...(ghostStoriesEnabled - ? { - ghostStories: { - enabled: true, - }, - } - : {}), - ...(renderAnalysisEnabled - ? { - renderAnalysis: { - enabled: true, - }, - } - : {}), - a11y: { - manual: !shouldRunA11yTests, - }, - }; + const initialGlobals = composeInitialGlobals({ + userInitialGlobals, + runConfig, + ghostStoriesEnabled, + renderAnalysisEnabled, + shouldRunA11yTests, + }); const composedStory = composeStory( storyAnnotations, diff --git a/code/addons/vitest/src/vitest-plugin/types.ts b/code/addons/vitest/src/vitest-plugin/types.ts index 4f071ec1a03d..c2595f612a46 100644 --- a/code/addons/vitest/src/vitest-plugin/types.ts +++ b/code/addons/vitest/src/vitest-plugin/types.ts @@ -37,6 +37,17 @@ export type UserOptions = { * @default true */ disableAddonDocs?: boolean; + + /** + * Globals applied to every story run by this project, merged under the values Storybook sets + * internally. Use it to pin a toolbar global for the whole run — most usefully to test a + * specific theme: define one Vitest project per theme, each with a different value, e.g. + * `storybookTest({ initialGlobals: { theme: 'dark' } })`. Equivalent to `initialGlobals` in + * `.storybook/preview`, but per project instead of global. + * + * @default {} + */ + initialGlobals?: Record; }; export type InternalOptions = Required & { diff --git a/code/addons/vitest/src/vitest-provided-context.d.ts b/code/addons/vitest/src/vitest-provided-context.d.ts index fd5071d82e18..352ef54d885f 100644 --- a/code/addons/vitest/src/vitest-provided-context.d.ts +++ b/code/addons/vitest/src/vitest-provided-context.d.ts @@ -3,6 +3,7 @@ import 'vitest'; import type { STORYBOOK_CORE_GHOST_STORIES_PROVIDE_KEY, STORYBOOK_CORE_RENDER_ANALYSIS_PROVIDE_KEY, + STORYBOOK_TEST_INITIAL_GLOBALS_PROVIDE_KEY, STORYBOOK_TEST_PROVIDE_KEY, } from './constants.ts'; @@ -11,5 +12,6 @@ declare module 'vitest' { [STORYBOOK_TEST_PROVIDE_KEY]: Record; [STORYBOOK_CORE_GHOST_STORIES_PROVIDE_KEY]: boolean; [STORYBOOK_CORE_RENDER_ANALYSIS_PROVIDE_KEY]: boolean; + [STORYBOOK_TEST_INITIAL_GLOBALS_PROVIDE_KEY]: Record; } } diff --git a/code/builders/builder-vite/package.json b/code/builders/builder-vite/package.json index 5ec75128a3da..0b820bd35d1e 100644 --- a/code/builders/builder-vite/package.json +++ b/code/builders/builder-vite/package.json @@ -1,6 +1,6 @@ { "name": "@storybook/builder-vite", - "version": "10.4.0-alpha.19", + "version": "10.5.0-beta.1", "description": "A Storybook builder to dev and build with Vite", "keywords": [ "storybook", diff --git a/code/builders/builder-vite/src/codegen-set-addon-channel.ts b/code/builders/builder-vite/src/codegen-set-addon-channel.ts index 8354f02fc9d2..95c60ce45987 100644 --- a/code/builders/builder-vite/src/codegen-set-addon-channel.ts +++ b/code/builders/builder-vite/src/codegen-set-addon-channel.ts @@ -5,7 +5,6 @@ export async function generateAddonSetupCode() { const channel = createBrowserChannel({ page: 'preview' }); addons.setChannel(channel); - window.__STORYBOOK_ADDONS_CHANNEL__ = channel; if (window.CONFIG_TYPE === 'DEVELOPMENT'){ window.__STORYBOOK_SERVER_CHANNEL__ = channel; diff --git a/code/builders/builder-vite/src/plugins/storybook-external-globals-plugin.test.ts b/code/builders/builder-vite/src/plugins/storybook-external-globals-plugin.test.ts index ceaae2475df7..78b282525579 100644 --- a/code/builders/builder-vite/src/plugins/storybook-external-globals-plugin.test.ts +++ b/code/builders/builder-vite/src/plugins/storybook-external-globals-plugin.test.ts @@ -1,4 +1,6 @@ -import { expect, it } from 'vitest'; +import { expect, it, vi } from 'vitest'; + +vi.mock('storybook/internal/preview/globals', { spy: true }); import { rewriteImport } from './storybook-external-globals-plugin.ts'; @@ -40,6 +42,24 @@ const cases = [ input: `import Foo from "${packageName}"`, output: `const {default: Foo} = ${globals[packageName]}`, }, + { + globals, + packageName, + input: `import {} from "${packageName}"`, + output: `void ${globals[packageName]}`, + }, + { + globals, + packageName, + input: `import {} from "${packageName}";`, + output: `void ${globals[packageName]};`, + }, + { + globals, + packageName, + input: `import '${packageName}';`, + output: `void ${globals[packageName]};`, + }, { globals, packageName, diff --git a/code/builders/builder-vite/src/plugins/storybook-external-globals-plugin.ts b/code/builders/builder-vite/src/plugins/storybook-external-globals-plugin.ts index ccc15137aa13..06e917f2e6c7 100644 --- a/code/builders/builder-vite/src/plugins/storybook-external-globals-plugin.ts +++ b/code/builders/builder-vite/src/plugins/storybook-external-globals-plugin.ts @@ -12,6 +12,7 @@ import type { Alias, Plugin } from 'vite'; const escapeKeys = (key: string) => key.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&'); const defaultImportRegExp = 'import ([^*{}]+) from'; +const emptyImportRegExp = /^import(?:\s*\{\s*\}\s*from)?\s*['"][^'"]+['"]\s*;?$/; const replacementMap = new Map([ ['import ', 'const '], ['import{', 'const {'], @@ -123,6 +124,15 @@ function getDefaultImportReplacement(match: string) { return matched && `const {default: ${matched[1]}} =`; } +function getEmptyImportReplacement(importStatement: string, globalReference: string) { + if (!emptyImportRegExp.test(importStatement.trim())) { + return undefined; + } + + const statementTerminator = importStatement.trimEnd().endsWith(';') ? ';' : ''; + return `void ${globalReference}${statementTerminator}`; +} + function getSearchRegExp(packageName: string) { const staticKeys = [...replacementMap.keys()].map(escapeKeys); const packageNameLiteral = `.${packageName}.`; @@ -136,6 +146,12 @@ export function rewriteImport( globs: Record, packageName: string ): string { + const emptyImportReplacement = getEmptyImportReplacement(importStatement, globs[packageName]); + + if (emptyImportReplacement) { + return emptyImportReplacement; + } + const search = getSearchRegExp(packageName); return importStatement.replace( search, diff --git a/code/builders/builder-vite/src/types.ts b/code/builders/builder-vite/src/types.ts index a3a2ab2ccc33..7bf4f0847fbe 100644 --- a/code/builders/builder-vite/src/types.ts +++ b/code/builders/builder-vite/src/types.ts @@ -21,4 +21,11 @@ export type StorybookConfigVite = { export type BuilderOptions = { /** Path to `vite.config` file, relative to `process.cwd()`. */ viteConfigPath?: string; + /** + * How Vite loads the config file. Equivalent to Vite's `--configLoader` CLI flag and the + * `configLoader` option of `loadConfigFromFile`. + * + * Requires Vite 6.1.0 or higher. On older Vite versions this option is silently ignored. + */ + configLoader?: 'bundle' | 'runner' | 'native'; }; diff --git a/code/builders/builder-vite/src/vite-config.test.ts b/code/builders/builder-vite/src/vite-config.test.ts index 1fc7cfdd4e8d..3dac92e0c5b7 100644 --- a/code/builders/builder-vite/src/vite-config.test.ts +++ b/code/builders/builder-vite/src/vite-config.test.ts @@ -49,6 +49,50 @@ describe('commonConfig', () => { expect(config.configFile).toBe(false); expect(config.plugins).toBeDefined(); }); + + it('should pass configLoader option to loadConfigFromFile', async () => { + const optionsWithConfigLoader: Options = { + ...dummyOptions, + presets: { + apply: async (key: string) => + ({ + framework: { name: '' }, + addons: [], + core: { + builder: { + name: '@storybook/builder-vite', + options: { + configLoader: 'native', + }, + }, + }, + options: {}, + })[key], + } as Presets, + }; + + // Inline mock: this test asserts a specific call signature, so it needs its + // own one-shot return value distinct from the shared default mock. + loadConfigFromFileMock.mockReturnValueOnce( + Promise.resolve({ + config: {}, + path: '', + dependencies: [], + }) + ); + + await commonConfig(optionsWithConfigLoader, 'development'); + + // Verify loadConfigFromFile was called with configLoader as the 6th argument + expect(loadConfigFromFileMock).toHaveBeenCalledWith( + expect.objectContaining({ command: 'serve' }), + undefined, + expect.any(String), + undefined, + undefined, + 'native' + ); + }); }); describe('storybookConfigPlugin', () => { diff --git a/code/builders/builder-vite/src/vite-config.ts b/code/builders/builder-vite/src/vite-config.ts index 27347853e650..8a67536987c0 100644 --- a/code/builders/builder-vite/src/vite-config.ts +++ b/code/builders/builder-vite/src/vite-config.ts @@ -42,7 +42,7 @@ export async function commonConfig( const configEnv = _type === 'development' ? configEnvServe : configEnvBuild; const { loadConfigFromFile, mergeConfig } = await import('vite'); - const { viteConfigPath } = await getBuilderOptions(options); + const { viteConfigPath, configLoader } = await getBuilderOptions(options); const projectRoot = resolve(options.configDir, '..'); @@ -50,7 +50,14 @@ export async function commonConfig( // I do this because I can contain config that breaks storybook, such as we had in a lit project. // If the user needs to configure the `build` they need to do so in the viteFinal function in main.js. const { config: { build: buildProperty = undefined, ...userConfig } = {} } = - (await loadConfigFromFile(configEnv, viteConfigPath, projectRoot)) ?? {}; + (await loadConfigFromFile( + configEnv, + viteConfigPath, + projectRoot, + undefined, + undefined, + configLoader + )) ?? {}; // Storybook's Vite config is assembled from self-contained plugins. // The config plugin handles base settings (root, cacheDir, resolve conditions, etc.), diff --git a/code/builders/builder-webpack5/package.json b/code/builders/builder-webpack5/package.json index 414ac392452b..4fd2b4a1c6ee 100644 --- a/code/builders/builder-webpack5/package.json +++ b/code/builders/builder-webpack5/package.json @@ -1,6 +1,6 @@ { "name": "@storybook/builder-webpack5", - "version": "10.4.0-alpha.19", + "version": "10.5.0-beta.1", "description": "A Storybook builder to dev and build with Webpack", "keywords": [ "storybook", @@ -60,6 +60,7 @@ "fork-ts-checker-webpack-plugin": "^9.1.0", "html-webpack-plugin": "^5.5.0", "magic-string": "^0.30.5", + "semver": "^7.7.3", "style-loader": "^4.0.0", "terser-webpack-plugin": "^5.3.17", "ts-dedent": "^2.0.0", diff --git a/code/builders/builder-webpack5/src/presets/custom-webpack-preset.ts b/code/builders/builder-webpack5/src/presets/custom-webpack-preset.ts index 95143303dc66..a00b28ff4cec 100644 --- a/code/builders/builder-webpack5/src/presets/custom-webpack-preset.ts +++ b/code/builders/builder-webpack5/src/presets/custom-webpack-preset.ts @@ -6,15 +6,23 @@ import type { Options, PresetProperty } from 'storybook/internal/types'; import { loadCustomWebpackConfig } from '@storybook/core-webpack'; -import webpackModule from 'webpack'; import type { Configuration } from 'webpack'; +import webpackModule from 'webpack'; import { WebpackInjectMockerRuntimePlugin } from '../plugins/webpack-inject-mocker-runtime-plugin.ts'; import { WebpackMockPlugin } from '../plugins/webpack-mock-plugin.ts'; import { createDefaultWebpackConfig } from '../preview/base-webpack.config.ts'; -export const swc: PresetProperty<'swc'> = (config: Record): Record => { - return { +export const swc: PresetProperty<'swc'> = ( + config: Record, + options: Options +): Record => { + const shouldRemoveBugfixes = + options.features && + 'babelRemoveBugfixes' in options.features && + options.features.babelRemoveBugfixes; + + const newConfig = { ...config, env: { ...(config?.env ?? {}), @@ -23,12 +31,17 @@ export const swc: PresetProperty<'swc'> = (config: Record): Record< safari: 15, firefox: 91, }, - // Transpiles the broken syntax to the closest non-broken modern syntax. - // E.g. it won't transpile parameter destructuring in Safari - // which would break how we detect if the mount context property is used in the play function. - bugfixes: config?.env?.bugfixes ?? true, }, }; + + // Transpiles the broken syntax to the closest non-broken modern syntax. + // E.g. it won't transpile parameter destructuring in Safari + // which would break how we detect if the mount context property is used in the play function. + if (!shouldRemoveBugfixes) { + newConfig.env.bugfixes = config?.env?.bugfixes ?? true; + } + + return newConfig; }; export async function webpackFinal(config: Configuration, options: Options) { diff --git a/code/builders/builder-webpack5/src/preview/virtual-module-mapping.ts b/code/builders/builder-webpack5/src/preview/virtual-module-mapping.ts index cedb275a5914..7eead1c9b8e1 100644 --- a/code/builders/builder-webpack5/src/preview/virtual-module-mapping.ts +++ b/code/builders/builder-webpack5/src/preview/virtual-module-mapping.ts @@ -11,8 +11,10 @@ import type { Options, PreviewAnnotation } from 'storybook/internal/types'; import { toImportFn } from '@storybook/core-webpack'; +import semver from 'semver'; // eslint-disable-next-line depend/ban-dependencies import slash from 'slash'; +import webpackModule from 'webpack'; import type { BuilderOptions } from '../types.ts'; @@ -48,7 +50,17 @@ export const getVirtualModules = async (options: Options) => { const storiesFilename = 'storybook-stories.js'; const storiesPath = resolve(join(workingDir, storiesFilename)); - const needPipelinedImport = !!builderOptions.lazyCompilation && !isProd; + // The import pipeline is a workaround for a webpack lazy-compilation bug that was fixed in + // webpack 5.101.3 (https://github.com/webpack/webpack/issues/15541#issuecomment-1143138832). + // We only enable it for lazy compilation in dev mode on older webpack versions. + // If the webpack version cannot be parsed, we conservatively disable the pipeline since + // the bug is fixed in newer versions and we prefer to avoid unnecessary performance overhead. + const webpackVersion = webpackModule.version ? semver.coerce(webpackModule.version) : null; + const needPipelinedImport = + !!builderOptions.lazyCompilation && + !isProd && + !!webpackVersion && + semver.lt(webpackVersion, '5.101.3'); virtualModules[storiesPath] = toImportFn(stories, { needPipelinedImport }); const configEntryPath = resolve(join(workingDir, 'storybook-config-entry.js')); virtualModules[configEntryPath] = ( diff --git a/code/builders/builder-webpack5/src/types.ts b/code/builders/builder-webpack5/src/types.ts index 8fe38c5d5793..915817cd978e 100644 --- a/code/builders/builder-webpack5/src/types.ts +++ b/code/builders/builder-webpack5/src/types.ts @@ -39,6 +39,13 @@ export interface StorybookConfigWebpack extends Omit< * @see https://storybook.js.org/docs/api/main-config/main-config-features#experimentaltestsyntax */ experimentalTestSyntax?: boolean; + + /** + * Remove the `bugfixes` option from `@babel/preset-env`. This is required when using Babel 8 and when + * Storybook fails to detect your Babel version. + * @default false + */ + babelRemoveBugfixes?: boolean; }; } diff --git a/code/builders/builder-webpack5/src/typings.d.ts b/code/builders/builder-webpack5/src/typings.d.ts new file mode 100644 index 000000000000..22eb9c03a481 --- /dev/null +++ b/code/builders/builder-webpack5/src/typings.d.ts @@ -0,0 +1 @@ +declare var FEATURES: import('storybook/internal/types').StorybookConfigRaw['features']; diff --git a/code/builders/builder-webpack5/templates/virtualModuleModernEntry.js b/code/builders/builder-webpack5/templates/virtualModuleModernEntry.js index c470929a6520..aa2b381898b6 100644 --- a/code/builders/builder-webpack5/templates/virtualModuleModernEntry.js +++ b/code/builders/builder-webpack5/templates/virtualModuleModernEntry.js @@ -30,7 +30,6 @@ const preview = new PreviewWeb(importFn, getProjectAnnotations); window.__STORYBOOK_PREVIEW__ = preview; window.__STORYBOOK_STORY_STORE__ = preview.storyStore; -window.__STORYBOOK_ADDONS_CHANNEL__ = channel; if (import.meta.webpackHot) { import.meta.webpackHot.addStatusHandler((status) => { diff --git a/code/core/build-config.ts b/code/core/build-config.ts index 3d09dd7b6020..5bbf398ad9de 100644 --- a/code/core/build-config.ts +++ b/code/core/build-config.ts @@ -45,6 +45,14 @@ const config: BuildEntries = { entryPoint: './src/oxc-parser/worker.ts', dts: false, }, + { + // Long-lived worker that runs docgen extraction off the main thread. Exposed as an internal + // export so docgen-worker-client.ts resolves it via the package map (import.meta.resolve) + // rather than a hard-coded dist path, keeping strict package managers (pnpm) happy. + exportEntries: ['./internal/docgen-worker'], + entryPoint: './src/shared/open-service/services/docgen/worker/docgen-worker.ts', + dts: false, + }, { entryPoint: './src/core-server/presets/common-override-preset.ts', exportEntries: ['./internal/core-server/presets/common-override-preset'], @@ -118,6 +126,10 @@ const config: BuildEntries = { exportEntries: ['./actions/decorator'], entryPoint: './src/actions/decorator.ts', }, + { + exportEntries: ['./backgrounds'], + entryPoint: './src/backgrounds/index.ts', + }, { exportEntries: ['./viewport'], entryPoint: './src/viewport/index.ts', @@ -186,6 +198,10 @@ const config: BuildEntries = { exportEntries: ['./internal/types'], entryPoint: './src/types/index.ts', }, + { + exportEntries: ['./open-service'], + entryPoint: './src/shared/open-service/index.ts', + }, ], runtime: [ { diff --git a/code/core/package.json b/code/core/package.json index be39a11a305e..0264a4452d1a 100644 --- a/code/core/package.json +++ b/code/core/package.json @@ -1,6 +1,6 @@ { "name": "storybook", - "version": "10.4.0-alpha.19", + "version": "10.5.0-beta.1", "description": "Storybook: Develop, document, and test UI components in isolation", "keywords": [ "storybook", @@ -56,6 +56,11 @@ "code": "./src/actions/decorator.ts", "default": "./dist/actions/decorator.js" }, + "./backgrounds": { + "types": "./dist/backgrounds/index.d.ts", + "code": "./src/backgrounds/index.ts", + "default": "./dist/backgrounds/index.js" + }, "./highlight": { "types": "./dist/highlight/index.d.ts", "code": "./src/highlight/index.ts", @@ -114,6 +119,7 @@ "code": "./src/csf-tools/index.ts", "default": "./dist/csf-tools/index.js" }, + "./internal/docgen-worker": "./dist/shared/open-service/services/docgen/worker/docgen-worker.js", "./internal/docs-tools": { "types": "./dist/docs-tools/index.d.ts", "code": "./src/docs-tools/index.ts", @@ -197,6 +203,11 @@ "code": "./src/manager-api/index.ts", "default": "./dist/manager-api/index.js" }, + "./open-service": { + "types": "./dist/shared/open-service/index.d.ts", + "code": "./src/shared/open-service/index.ts", + "default": "./dist/shared/open-service/index.js" + }, "./package.json": "./package.json", "./preview-api": { "types": "./dist/preview-api/index.d.ts", @@ -235,12 +246,13 @@ "dependencies": { "@storybook/global": "^5.0.0", "@storybook/icons": "^2.0.2", + "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/user-event": "^14.6.1", "@vitest/expect": "3.2.4", "@vitest/spy": "3.2.4", "@webcontainer/env": "^1.1.1", - "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0", + "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0 || ^0.28.0", "open": "^10.2.0", "oxc-parser": "^0.127.0", "oxc-resolver": "^11.19.1", @@ -267,6 +279,7 @@ "@happy-dom/global-registrator": "^20.0.11", "@ngard/tiny-isequal": "^1.1.0", "@polka/compression": "^1.0.0-next.28", + "@preact/signals-core": "^1.14.2", "@radix-ui/react-scroll-area": "1.2.0-rc.7", "@radix-ui/react-slot": "^1.0.2", "@react-aria/live-announcer": "^3.5.0", @@ -275,8 +288,8 @@ "@react-stately/tabs": "^3.9.0", "@react-types/shared": "^3.34.0", "@rolldown/pluginutils": "1.0.0-beta.18", + "@standard-schema/spec": "^1.1.0", "@tanstack/react-virtual": "^3.3.0", - "@testing-library/dom": "^10.4.1", "@testing-library/react": "^14.0.0", "@types/cross-spawn": "^6.0.6", "@types/detect-port": "^1.3.0", @@ -308,6 +321,7 @@ "copy-to-clipboard": "^3.3.1", "cross-spawn": "^7.0.6", "deep-object-diff": "^1.1.0", + "deepsignal": "^1.6.0", "dequal": "^2.0.2", "detect-indent": "^7.0.1", "detect-port": "^1.6.1", @@ -316,7 +330,7 @@ "ejs": "^3.1.10", "empathic": "^2.0.0", "es-toolkit": "^1.43.0", - "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0", + "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0 || ^0.28.0", "execa": "^9.6.1", "exsolve": "^1.0.7", "extend-to-be-announced": "^2.0.0", @@ -328,6 +342,7 @@ "globby": "^14.1.0", "hast-util-to-estree": "^3.0.0", "host-validation-middleware": "^0.1.2", + "immer": "11.1.8", "jiti": "^2.6.1", "js-yaml": "^4.1.0", "jsdoc-type-pratt-parser": "^4.0.0", @@ -347,14 +362,14 @@ "package-manager-detector": "^1.1.0", "picocolors": "^1.1.0", "picomatch": "^2.3.0", - "picoquery": "^1.4.0", + "picoquery": "^2.5.0", "polished": "^4.2.2", "polka": "^1.0.0-next.28", "prettier": "^3.7.1", "pretty-hrtime": "^1.0.3", "qrcode.react": "^4.2.0", "react": "^18.2.0", - "react-aria": "^3.48.0", + "react-aria": "patch:react-aria@npm%3A3.48.0#~/.yarn/patches/react-aria-npm-3.48.0-0945840d84.patch", "react-aria-components": "^1.17.0", "react-dom": "^18.2.0", "react-helmet-async": "^1.3.0", @@ -365,8 +380,6 @@ "react-textarea-autosize": "^8.3.0", "react-transition-state": "^2.3.1", "require-from-string": "^2.0.2", - "resolve": "^1.22.11", - "resolve.exports": "^2.0.3", "sirv": "^2.0.4", "slash": "^5.0.0", "source-map": "^0.7.4", @@ -379,10 +392,12 @@ "tinyspy": "^3.0.2", "ts-dedent": "^2.0.0", "tsconfig-paths": "^4.2.0", - "type-fest": "^4.18.1", + "type-fest": "^5.6.0", + "type-plus": "^8.0.0-beta.8", "typescript": "^5.8.3", "unique-string": "^3.0.0", "use-resize-observer": "^9.1.0", + "valibot": "^1.4.0", "watchpack": "^2.5.0", "wrap-ansi": "^9.0.2", "zod": "^3.25.76" @@ -390,7 +405,7 @@ "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "prettier": "^2 || ^3", - "vite-plus": "^0.1.15" + "vite-plus": "^0.1.15 || ^0.2.0" }, "peerDependenciesMeta": { "@types/react": { diff --git a/code/core/scripts/generate-source-files.ts b/code/core/scripts/generate-source-files.ts index b920c2509f3c..499d42bf67b0 100644 --- a/code/core/scripts/generate-source-files.ts +++ b/code/core/scripts/generate-source-files.ts @@ -131,6 +131,7 @@ const localAlias = { 'storybook/actions': join(CORE_ROOT_DIR, 'src', 'actions'), 'storybook/preview-api': join(CORE_ROOT_DIR, 'src', 'preview-api'), 'storybook/manager-api': join(CORE_ROOT_DIR, 'src', 'manager-api'), + 'storybook/open-service': join(CORE_ROOT_DIR, 'src', 'shared', 'open-service'), storybook: join(CORE_ROOT_DIR, 'src'), }; async function generateExportsFile(): Promise { diff --git a/code/core/src/actions/loaders.ts b/code/core/src/actions/loaders.ts index 4b179ba891f6..a59b671733b4 100644 --- a/code/core/src/actions/loaders.ts +++ b/code/core/src/actions/loaders.ts @@ -37,6 +37,7 @@ const logActionsWhenMockCalled: LoaderFunction = (context) => { 'next/router::useRouter()', 'next/navigation::useRouter()', 'next/navigation::redirect', + 'next/link::Link', 'next/cache::', 'next/headers::cookies().set', 'next/headers::cookies().delete', diff --git a/code/core/src/babel/vitest-config-helpers.test.ts b/code/core/src/babel/vitest-config-helpers.test.ts index 8bbfdd30b9cc..26a7684e4fc9 100644 --- a/code/core/src/babel/vitest-config-helpers.test.ts +++ b/code/core/src/babel/vitest-config-helpers.test.ts @@ -248,12 +248,32 @@ describe('canUpdateVitestConfigFile', () => { expect(canUpdateVitestConfigFile('const x = 1;')).toBe(false); }); - it('returns false for arrow function pattern: defineConfig(() => ({}))', () => { + it('returns true for arrow function pattern: defineConfig(({ mode }) => ({}))', () => { expect( canUpdateVitestConfigFile( ` import { defineConfig } from 'vitest/config'; - export default defineConfig(() => ({ test: {} })) + export default defineConfig(({ mode }) => ({ + test: { + globals: mode !== 'production', + }, + })) + ` + ) + ).toBe(true); + }); + + it('returns false for callback pattern with dynamic control flow', () => { + expect( + canUpdateVitestConfigFile( + ` + import { defineConfig } from 'vitest/config'; + export default defineConfig(({ mode }) => { + if (mode === 'production') { + return { test: { name: 'prod' } }; + } + return { test: { name: 'dev' } }; + }) ` ) ).toBe(false); diff --git a/code/core/src/babel/vitest-config-helpers.ts b/code/core/src/babel/vitest-config-helpers.ts index f8f04067dfb4..12b4318ebe33 100644 --- a/code/core/src/babel/vitest-config-helpers.ts +++ b/code/core/src/babel/vitest-config-helpers.ts @@ -133,6 +133,36 @@ export const getTargetConfigObject = ( ) { return resolved.arguments[0] as t.ObjectExpression; } + + if ( + resolved.type === 'CallExpression' && + isDefineConfigLike(resolved, target) && + (resolved.arguments[0]?.type === 'ArrowFunctionExpression' || + resolved.arguments[0]?.type === 'FunctionExpression') + ) { + const callbackArg = resolved.arguments[0]; + + // Support simple callbacks that directly return an object literal, e.g. + // defineConfig(({ mode }) => ({ ... })) or defineConfig(function () { return { ... }; }) + if (callbackArg.body.type === 'ObjectExpression') { + return callbackArg.body; + } + + if (callbackArg.body.type === 'BlockStatement') { + // Keep this conservative: only support callbacks that are exactly + // `{ return { ... } }` with no additional control flow or statements. + if ( + callbackArg.body.body.length === 1 && + callbackArg.body.body[0]?.type === 'ReturnStatement' + ) { + const returnedExpr = resolveExpression(callbackArg.body.body[0].argument, target); + if (returnedExpr?.type === 'ObjectExpression') { + return returnedExpr; + } + } + } + } + return null; }; @@ -152,7 +182,7 @@ export const getTargetConfigObject = ( * * Unsupported patterns (returns `false`): * - * - `export default defineConfig(() => ({ ... }))` (arrow function) + * - Callback-based defineConfig with non-literal/dynamic returns that cannot be safely resolved * - Completely unrecognizable export shapes * - No `export default` declaration at all */ @@ -171,17 +201,6 @@ export const canUpdateVitestConfigFile = (fileContent: string): boolean => { return false; } - // Reject arrow function pattern: defineConfig(() => ({...})) - const effectiveDecl = resolveExpression(exportDefault.declaration, parsedAst); - if ( - effectiveDecl?.type === 'CallExpression' && - isDefineConfigLike(effectiveDecl, parsedAst) && - effectiveDecl.arguments.length > 0 && - effectiveDecl.arguments[0].type === 'ArrowFunctionExpression' - ) { - return false; - } - return ( getTargetConfigObject(parsedAst, exportDefault) !== null || getEffectiveMergeConfigCall(exportDefault.declaration, parsedAst) !== null diff --git a/code/core/src/backgrounds/index.test-d.ts b/code/core/src/backgrounds/index.test-d.ts new file mode 100644 index 000000000000..48a291580cde --- /dev/null +++ b/code/core/src/backgrounds/index.test-d.ts @@ -0,0 +1,15 @@ +import { expectTypeOf } from 'vitest'; + +import { DEFAULT_BACKGROUNDS } from 'storybook/backgrounds'; +import type { Background, BackgroundMap } from 'storybook/backgrounds'; + +expectTypeOf().toEqualTypeOf>(); + +expectTypeOf(DEFAULT_BACKGROUNDS).toEqualTypeOf(); + +const customBackgrounds = { + light: { name: 'Light', value: '#ffffff' }, + dark: { name: 'Dark', value: '#1a1a1a' }, +} satisfies BackgroundMap; + +expectTypeOf(customBackgrounds.light).toExtend(); diff --git a/code/core/src/backgrounds/index.ts b/code/core/src/backgrounds/index.ts new file mode 100644 index 000000000000..3d80f1ce1b1d --- /dev/null +++ b/code/core/src/backgrounds/index.ts @@ -0,0 +1,3 @@ +export * from './constants.ts'; +export * from './types.ts'; +export * from './defaults.ts'; diff --git a/code/core/src/bin/core.ts b/code/core/src/bin/core.ts index 9ce0413d9bcb..88c81a93a601 100644 --- a/code/core/src/bin/core.ts +++ b/code/core/src/bin/core.ts @@ -1,4 +1,11 @@ -import { getEnvConfig, optionalEnvToBoolean, parseList } from 'storybook/internal/common'; +import { + HandledError, + PackageManagerName, + getEnvConfig, + optionalEnvToBoolean, + parseList, +} from 'storybook/internal/common'; +import { withTelemetry } from 'storybook/internal/core-server'; import { logTracker, logger } from 'storybook/internal/node-logger'; import { addToGlobalContext } from 'storybook/internal/telemetry'; @@ -7,10 +14,13 @@ import leven from 'leven'; import picocolors from 'picocolors'; import { version } from '../../package.json'; +import { aiSetup } from '../cli/ai/index.ts'; +import { isAiCliFeatureEnabled, registerAiMcpPassthrough } from '../cli/ai/mcp/register.ts'; import { build } from '../cli/build.ts'; import { buildIndex as index } from '../cli/buildIndex.ts'; import { dev } from '../cli/dev.ts'; import { globalSettings } from '../cli/globalSettings.ts'; +import { resolveDevCommandOptions } from './dev-options.ts'; addToGlobalContext('cliVersion', version); process.env.STORYBOOK = 'true'; @@ -23,6 +33,7 @@ process.env.STORYBOOK = 'true'; * - `dev`: Start the Storybook development server * - `build`: Build the Storybook static files * - `index`: Generate the Storybook index file + * - `ai`: AI agent helpers (always bundled so agent invocations never download an extra package) * * The dispatch CLI at ./dispatcher.ts routes commands to this core CLI. */ @@ -77,10 +88,14 @@ const command = (name: string) => logger.outro(`Debug logs are written to: ${logFile}`); } catch {} } + // Exit explicitly so Node won't hang on Windows due to lingering file handles + if (command.name() === 'build') { + process.exit(0); + } }); command('dev') - .option('-p, --port ', 'Port to run Storybook', (str) => parseInt(str, 10)) + .option('-p, --port ', 'Port to run Storybook') .option('-h, --host ', 'Host to run Storybook') .option('-c, --config-dir ', 'Directory where to load Storybook configurations from') .option( @@ -124,21 +139,15 @@ command('dev') logger.intro(`${packageJson.name} v${packageJson.version}`); - // The key is the field created in `options` variable for - // each command line argument. Value is the env variable. - getEnvConfig(options, { - port: 'SBCONFIG_PORT', - host: 'SBCONFIG_HOSTNAME', - staticDir: 'SBCONFIG_STATIC_DIR', - configDir: 'SBCONFIG_CONFIG_DIR', - ci: 'CI', - }); - - if (parseInt(`${options.port}`, 10)) { - options.port = parseInt(`${options.port}`, 10); + let resolvedOptions: typeof options; + try { + resolvedOptions = resolveDevCommandOptions(options); + } catch (error) { + logger.error(error instanceof Error ? error.message : String(error)); + return handleCommandFailure(options.logfile); } - await dev({ ...options, packageJson }).catch(() => { + await dev({ ...resolvedOptions, packageJson }).catch(() => { handleCommandFailure(options.logfile); }); }); @@ -218,6 +227,53 @@ command('index') }).catch(() => process.exit(1)); }); +// Like `handleCommandFailure`, but curried and surfacing the error, matching the signature the +// `ai` command handlers expect. +const handleAiCommandFailure = + (logFilePath: string | boolean | undefined) => + async (error: unknown): Promise => { + if (!(error instanceof HandledError)) { + logger.error(String(error)); + } + return handleCommandFailure(logFilePath ?? false); + }; + +const aiCommand = command('ai') + .description('AI agent helpers for Storybook') + .option( + '-o, --output ', + 'Write the prompt output to a file instead of printing it to stdout' + ); + +aiCommand + .command('setup') + .description('Generate setup instructions to write stories for real components') + .addOption( + new Option('--package-manager ', 'Force package manager for installing deps').choices( + Object.values(PackageManagerName) + ) + ) + .option('-c, --config-dir ', 'Directory of Storybook configuration') + .action(async (options, cmd) => { + const parentOptions = cmd.parent?.opts() ?? {}; + const runId = Math.random().toString(36); + const mergedOptions = { ...parentOptions, ...options, runId }; + await withTelemetry('ai-setup', { cliOptions: mergedOptions }, async () => { + await aiSetup(mergedOptions); + }).catch(handleAiCommandFailure(mergedOptions.logfile)); + }); + +// Show available subcommands when `storybook ai` is run without arguments +aiCommand.action(() => { + aiCommand.outputHelp(); +}); + +// Experimental `storybook ai ` passthrough to the local Storybook MCP server +// (storybookjs/storybook#35124). Overrides the help-only action above when enabled. +if (isAiCliFeatureEnabled()) { + registerAiMcpPassthrough(program, aiCommand, handleAiCommandFailure); +} + program.on('command:*', ([invalidCmd]) => { let errorMessage = ` Invalid command: ${picocolors.bold(invalidCmd)}.\n See --help for a list of available commands.`; const availableCommands = program.commands.map((cmd) => cmd.name()); diff --git a/code/core/src/bin/dev-options.test.ts b/code/core/src/bin/dev-options.test.ts new file mode 100644 index 000000000000..c0bbb8080e75 --- /dev/null +++ b/code/core/src/bin/dev-options.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveDevCommandOptions } from './dev-options.ts'; + +const getErrorMessage = (callback: () => unknown) => { + try { + callback(); + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + throw new Error('Expected callback to throw'); +}; + +describe('resolveDevCommandOptions', () => { + it('preserves truthy getEnvConfig-style env overrides for existing dev options', () => { + expect( + resolveDevCommandOptions( + { ci: false, configDir: 'cli-config', host: 'cli-host', staticDir: 'cli-static' }, + { + env: { + CI: 'false', + SBCONFIG_CONFIG_DIR: 'env-config', + SBCONFIG_HOSTNAME: 'env-host', + SBCONFIG_STATIC_DIR: 'env-static', + }, + } + ) + ).toMatchObject({ + ci: 'false', + configDir: 'env-config', + host: 'env-host', + staticDir: 'env-static', + }); + }); + + it('uses PORT when no explicit port or SBCONFIG_PORT was provided', () => { + expect(resolveDevCommandOptions({}, { env: { PORT: '6123' } })).toMatchObject({ + port: 6123, + }); + }); + + it('keeps explicit --port precedence over PORT outside Claude preview', () => { + expect(resolveDevCommandOptions({ port: '7007' }, { env: { PORT: '6123' } })).toMatchObject({ + port: 7007, + }); + }); + + it('uses SBCONFIG_PORT over PORT outside Claude preview', () => { + expect( + resolveDevCommandOptions({}, { env: { PORT: '6123', SBCONFIG_PORT: '7008' } }) + ).toMatchObject({ + port: 7008, + }); + }); + + it('keeps explicit --port precedence over SBCONFIG_PORT outside Claude preview', () => { + expect( + resolveDevCommandOptions({ port: '7007' }, { env: { SBCONFIG_PORT: '7008' } }) + ).toMatchObject({ + port: 7007, + }); + }); + + it('uses Claude preview PORT over explicit --port', () => { + expect( + resolveDevCommandOptions( + { port: '7007' }, + { env: { CLAUDE_AGENT_SDK_VERSION: '0.1.0', PORT: '6123' } } + ) + ).toMatchObject({ + port: 6123, + }); + }); + + it('uses explicit --port over SBCONFIG_PORT in Claude preview when PORT is absent', () => { + expect( + resolveDevCommandOptions( + { port: '7007' }, + { env: { CLAUDE_AGENT_SDK_VERSION: '0.1.0', SBCONFIG_PORT: '7008' } } + ) + ).toMatchObject({ + port: 7007, + }); + }); + + it('uses SBCONFIG_PORT in Claude preview when PORT and explicit --port are absent', () => { + expect( + resolveDevCommandOptions( + {}, + { env: { CLAUDE_AGENT_SDK_VERSION: '0.1.0', SBCONFIG_PORT: '7008' } } + ) + ).toMatchObject({ + port: 7008, + }); + }); + + it('defaults browser opening to false for Claude preview launches', () => { + expect( + resolveDevCommandOptions({ open: true }, { env: { CLAUDE_AGENT_SDK_VERSION: '0.1.0' } }) + ).toMatchObject({ + open: false, + }); + }); + + it('keeps the browser opening default outside Claude preview', () => { + expect(resolveDevCommandOptions({ open: true }, { env: {} })).toMatchObject({ + open: true, + }); + }); + + it('keeps unrelated dev command options after validation', () => { + expect( + resolveDevCommandOptions({ https: true, port: '6006', smokeTest: true }, { env: {} }) + ).toMatchObject({ + https: true, + port: 6006, + smokeTest: true, + }); + }); + + it('rejects invalid selected PORT values', () => { + const message = getErrorMessage(() => + resolveDevCommandOptions({}, { env: { PORT: 'not-a-port' } }) + ); + + expect(message).toContain( + 'Port must be a valid number from 1 to 65535, received "not-a-port".' + ); + expect(message).toContain('at port'); + }); + + it('rejects empty selected PORT values', () => { + const message = getErrorMessage(() => resolveDevCommandOptions({}, { env: { PORT: '' } })); + + expect(message).toContain('Port must be a valid number from 1 to 65535'); + expect(message).toContain('at port'); + }); + + it('rejects invalid Claude preview PORT values before explicit --port fallback', () => { + const message = getErrorMessage(() => + resolveDevCommandOptions( + { port: '7007' }, + { env: { CLAUDE_AGENT_SDK_VERSION: '0.1.0', PORT: ' ' } } + ) + ); + + expect(message).toContain('Port must be a valid number from 1 to 65535'); + expect(message).toContain('at port'); + }); + + it('rejects invalid selected SBCONFIG_PORT values', () => { + const message = getErrorMessage(() => + resolveDevCommandOptions({}, { env: { SBCONFIG_PORT: '7007abc' } }) + ); + + expect(message).toContain('Port must be a valid number from 1 to 65535, received "7007abc".'); + expect(message).toContain('at port'); + }); + + it('does not treat --port placeholders as PORT interpolation', () => { + const message = getErrorMessage(() => + resolveDevCommandOptions({ port: '$PORT' }, { env: { PORT: '6123' } }) + ); + + expect(message).toContain('Port must be a valid number from 1 to 65535, received "$PORT".'); + expect(message).toContain('at port'); + }); + + it('rejects selected ports outside the valid range', () => { + const message = getErrorMessage(() => resolveDevCommandOptions({ port: '70000' })); + + expect(message).toContain('Port must be a valid number from 1 to 65535, received 70000.'); + expect(message).toContain('at port'); + }); +}); diff --git a/code/core/src/bin/dev-options.ts b/code/core/src/bin/dev-options.ts new file mode 100644 index 000000000000..f2ebe4466c8b --- /dev/null +++ b/code/core/src/bin/dev-options.ts @@ -0,0 +1,68 @@ +import * as v from 'valibot'; +import { HandledError } from 'storybook/internal/common'; + +import { isClaudePreviewLaunch, type AgentEnvironment } from '../shared/utils/agent-environment.ts'; + +type DevCommandEnvironment = AgentEnvironment & { + CI?: string; + PORT?: string; + SBCONFIG_CONFIG_DIR?: string; + SBCONFIG_HOSTNAME?: string; + SBCONFIG_PORT?: string; + SBCONFIG_STATIC_DIR?: string; +}; + +type DevCommandOptions = { + ci?: boolean | string; + configDir?: string; + host?: string; + open?: boolean; + port?: number | string; + staticDir?: string; +}; + +const PortSchema = v.message( + v.pipe( + v.union([v.pipe(v.string(), v.trim(), v.regex(/^\d+$/), v.transform(Number)), v.number()]), + v.minValue(1), + v.integer(), + v.maxValue(65535) + ), + (issue) => `Port must be a valid number from 1 to 65535, received ${issue.received}.` +); + +const DevOptionsSchema = v.looseObject({ + ci: v.optional(v.union([v.boolean(), v.string()])), + configDir: v.optional(v.string()), + host: v.optional(v.string()), + open: v.optional(v.boolean()), + port: v.optional(PortSchema), + staticDir: v.optional(v.string()), +}); + +export function resolveDevCommandOptions( + options: TOptions, + { env = process.env }: { env?: DevCommandEnvironment } = {} +) { + const isClaudePreview = isClaudePreviewLaunch(env); + const PORT = env.PORT ?? undefined; + const SBCONFIG_PORT = env.SBCONFIG_PORT ?? undefined; + + const result = v.safeParse(DevOptionsSchema, { + ...options, + host: env.SBCONFIG_HOSTNAME || options.host, + staticDir: env.SBCONFIG_STATIC_DIR || options.staticDir, + configDir: env.SBCONFIG_CONFIG_DIR || options.configDir, + ci: env.CI || options.ci, + port: isClaudePreview + ? (PORT ?? options.port ?? SBCONFIG_PORT) + : (options.port ?? SBCONFIG_PORT ?? PORT), + open: isClaudePreview ? false : options.open, + }); + + if (!result.success) { + throw new HandledError(v.summarize(result.issues)); + } + + return result.output as TOptions & v.InferOutput; +} diff --git a/code/core/src/bin/dispatcher.ts b/code/core/src/bin/dispatcher.ts index 9cc5900489f0..a84b322f18e7 100644 --- a/code/core/src/bin/dispatcher.ts +++ b/code/core/src/bin/dispatcher.ts @@ -16,8 +16,9 @@ import { resolvePackageDir } from '../shared/utils/module.ts'; * * This function serves as the main entry point for Storybook CLI operations. * - * - Core Storybook commands (dev, build, index) are routed to the core binary at - * storybook/dist/bin/core.js + * - Core Storybook commands (dev, build, index, ai) are routed to the core binary at + * storybook/dist/bin/core.js — `ai` is bundled because agent skills invoke it repeatedly and + * must never wait on an npx download * - Init is routed to the create-storybook package via npx * - External CLI tools (upgrade, doctor, etc.) are routed to @storybook/cli via npx */ @@ -33,7 +34,7 @@ if (!isNodeVersionSupported(major, minor, patch)) { async function run() { const args = process.argv.slice(2); - if (['dev', 'build', 'index'].includes(args[0])) { + if (['dev', 'build', 'index', 'ai'].includes(args[0])) { const coreBin = pathToFileURL(join(resolvePackageDir('storybook'), 'dist/bin/core.js')).href; await import(coreBin); return; diff --git a/code/core/src/builder-manager/utils/template.test.ts b/code/core/src/builder-manager/utils/template.test.ts new file mode 100644 index 000000000000..92e2848708a0 --- /dev/null +++ b/code/core/src/builder-manager/utils/template.test.ts @@ -0,0 +1,57 @@ +import { expect, it } from 'vitest'; + +import type { Options } from 'storybook/internal/types'; + +import { customHeadHasFavicon, renderHTML } from './template.ts'; + +const template = ` + +<% if (favicon.endsWith('.svg')) {%> + +<% } else if (favicon.endsWith('.ico')) { %> + +<% } %> +<% if (typeof head !== 'undefined') { %><%- head %><% } %> + +`; + +const renderManagerHtml = (customHead = '') => + renderHTML( + Promise.resolve(template), + Promise.resolve('Example'), + Promise.resolve('favicon.svg'), + Promise.resolve(customHead), + [], + [], + Promise.resolve({}), + Promise.resolve({}), + Promise.resolve('info'), + Promise.resolve({}), + Promise.resolve({}), + { + versionCheck: undefined, + previewUrl: undefined, + configType: 'DEVELOPMENT', + ignorePreview: false, + } as Options, + {} + ); + +it('renders the default manager favicon when custom head does not provide one', async () => { + const html = await renderManagerHtml(''); + + expect(html).toContain('href="./favicon.svg"'); + expect(html).toContain('href="./manager.css"'); +}); + +it('does not render the default manager favicon when custom head provides an icon', async () => { + const html = await renderManagerHtml(''); + + expect(html).not.toContain('href="./favicon.svg"'); + expect(html).toContain('href="./ui.png"'); +}); + +it('detects shortcut icon links without treating touch icons as favicons', () => { + expect(customHeadHasFavicon('')).toBe(true); + expect(customHeadHasFavicon('')).toBe(false); +}); diff --git a/code/core/src/builder-manager/utils/template.ts b/code/core/src/builder-manager/utils/template.ts index 43ea18d2856e..f4a23dcfdd60 100644 --- a/code/core/src/builder-manager/utils/template.ts +++ b/code/core/src/builder-manager/utils/template.ts @@ -21,6 +21,17 @@ export async function getManagerMainTemplate() { return getTemplatePath(`manager.ejs`); } +export const customHeadHasFavicon = (head: string) => { + const linkTags = head.match(/]*>/gi) || []; + + return linkTags.some((tag) => { + const rel = tag.match(/\brel\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i); + const value = rel?.[1] || rel?.[2] || rel?.[3] || ''; + + return value.split(/\s+/).some((token) => token.toLowerCase() === 'icon'); + }); +}; + export const renderHTML = async ( template: Promise, title: Promise, @@ -38,6 +49,7 @@ export const renderHTML = async ( ) => { const titleRef = await title; const templateRef = await template; + const headRef = (await customHead) || ''; const stringifiedGlobals = Object.entries(globals).reduce( (transformed, [key, value]) => ({ ...transformed, [key]: JSON.stringify(value) }), {} @@ -46,20 +58,23 @@ export const renderHTML = async ( return render(templateRef, { title: titleRef ? `${titleRef} - Storybook` : 'Storybook', files: { js: jsFiles, css: cssFiles }, - favicon: await favicon, + favicon: customHeadHasFavicon(headRef) ? '' : await favicon, globals: { FEATURES: JSON.stringify(await features, null, 2), REFS: JSON.stringify(await refs, null, 2), LOGLEVEL: JSON.stringify(await logLevel, null, 2), DOCS_OPTIONS: JSON.stringify(await docsOptions, null, 2), CONFIG_TYPE: JSON.stringify(await configType, null, 2), + // Opt-in via STORYBOOK_DOCGEN_STORY_PREPARED: request server docgen for the Controls panel at + // STORY_PREPARED instead of the default STORY_RENDERED. See useStoryDocgenGateReady. + DOCGEN_STORY_PREPARED: JSON.stringify(process.env.STORYBOOK_DOCGEN_STORY_PREPARED === 'true'), // These two need to be double stringified because the UI expects a string VERSIONCHECK: JSON.stringify(JSON.stringify(versionCheck), null, 2), PREVIEW_URL: JSON.stringify(previewUrl, null, 2), // global preview URL TAGS_OPTIONS: JSON.stringify(await tagsOptions, null, 2), ...stringifiedGlobals, }, - head: (await customHead) || '', + head: headRef, ignorePreview, }); }; diff --git a/code/core/src/channels/README.md b/code/core/src/channels/README.md index 0076f33230bf..61a11baa9ff4 100644 --- a/code/core/src/channels/README.md +++ b/code/core/src/channels/README.md @@ -30,3 +30,27 @@ class Transport { ``` For more information visit: [storybook.js.org](https://storybook.js.org) + +## Channel access (internal) + +Storybook installs one shared addons channel per runtime (manager, preview iframe, dev server). +Use the channel-slot API from `storybook/internal/channels` in TypeScript — not direct reads of +`__STORYBOOK_ADDONS_CHANNEL__`. + +| Operation | API | +| --------- | --- | +| Read (nullable) | `getChannel()` | +| Read (installed) | `requireChannel()` — use when the runtime entry has already installed a channel | +| Install / replace | `setChannel(channel)` or `addons.setChannel(channel)` | +| Clear | `clearChannel()` / `setChannel(null)` | + +The global `__STORYBOOK_ADDONS_CHANNEL__` is mirrored when `setChannel` runs so builder preamble and +legacy snippets stay in sync. `getChannel()` reads the global slot first so duplicate bundles still +see the live channel. + +**Per-runtime install (call sites do not wait):** + +- **Preview iframe:** builders run generated `addons.setChannel(createBrowserChannel(...))` before `preview.ts` loads. +- **Manager:** `addons.setChannel` during manager boot, before `addons.register` callbacks. +- **Node server:** `services` preset calls `setChannel(options.channel)` before registering services; a noop channel is also bootstrapped at import in non-browser realms. +- **Tests:** `setChannel(mock)` in `beforeEach`, or rely on the Node import bootstrap noop. diff --git a/code/core/src/channels/channel-slot.test.ts b/code/core/src/channels/channel-slot.test.ts new file mode 100644 index 000000000000..031662511c42 --- /dev/null +++ b/code/core/src/channels/channel-slot.test.ts @@ -0,0 +1,116 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { Channel } from './main.ts'; +import { + clearChannel, + ensureChannel, + getChannel, + installNoopChannel, + setChannel, +} from './channel-slot.ts'; + +describe('channel slot', () => { + afterEach(() => { + clearChannel(); + vi.unstubAllGlobals(); + }); + + it('returns null after clearChannel', () => { + setChannel(new Channel({})); + clearChannel(); + + expect(getChannel()).toBeNull(); + expect(globalThis.__STORYBOOK_ADDONS_CHANNEL__).toBeUndefined(); + }); + + it('mirrors setChannel to the global slot', () => { + const channel = new Channel({}); + + setChannel(channel); + + expect(getChannel()).toBe(channel); + expect(globalThis.__STORYBOOK_ADDONS_CHANNEL__).toBe(channel); + }); + + it('hydrates the module slot from a pre-existing global assignment', () => { + const channel = new Channel({}); + clearChannel(); + vi.stubGlobal('__STORYBOOK_ADDONS_CHANNEL__', channel); + + expect(getChannel()).toBe(channel); + }); + + it('prefers the global slot over a stale module-level noop', () => { + clearChannel(); + installNoopChannel(); + + const real = new Channel({ transport: { setHandler: vi.fn(), send: vi.fn() } }); + vi.stubGlobal('__STORYBOOK_ADDONS_CHANNEL__', real); + + expect(getChannel()).toBe(real); + }); + + it('installNoopChannel provides an in-process channel', () => { + clearChannel(); + installNoopChannel(); + + expect(getChannel()).toBeInstanceOf(Channel); + expect(getChannel()?.hasTransport).toBe(false); + }); + + it('ensureChannel is a no-op when a channel is already installed', () => { + const channel = new Channel({}); + setChannel(channel); + + ensureChannel(); + + expect(getChannel()).toBe(channel); + }); + + it('ensureChannel installs a noop channel when missing', () => { + clearChannel(); + + ensureChannel(); + + expect(getChannel()).toBeInstanceOf(Channel); + }); + + it('setChannel(null) clears both module and global slots', () => { + const channel = new Channel({}); + setChannel(channel); + + setChannel(null); + + expect(getChannel()).toBeNull(); + expect(globalThis.__STORYBOOK_ADDONS_CHANNEL__).toBeUndefined(); + }); + + it('replaces an existing channel on setChannel', () => { + const first = new Channel({}); + const second = new Channel({ transport: { setHandler: vi.fn(), send: vi.fn() } }); + + setChannel(first); + setChannel(second); + + expect(getChannel()).toBe(second); + expect(globalThis.__STORYBOOK_ADDONS_CHANNEL__).toBe(second); + }); +}); + +describe('module import', () => { + it('does not auto-install a channel in a browser-like environment', async () => { + vi.stubGlobal('window', {}); + vi.stubGlobal('document', {}); + vi.stubEnv('VITEST', ''); + + vi.resetModules(); + try { + const { getChannel } = await import('./channel-slot.ts'); + expect(getChannel()).toBeNull(); + } finally { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + vi.resetModules(); + } + }); +}); diff --git a/code/core/src/channels/channel-slot.ts b/code/core/src/channels/channel-slot.ts new file mode 100644 index 000000000000..866b0e8da0c2 --- /dev/null +++ b/code/core/src/channels/channel-slot.ts @@ -0,0 +1,86 @@ +/// +/** + * Canonical install/read surface for Storybook's shared addons channel. + * + * Each runtime (manager, preview, dev server) installs one channel instance here. The module slot + * is the source of truth; {@link setChannel} also mirrors to `__STORYBOOK_ADDONS_CHANNEL__` + * so legacy code and builder preamble that read the global slot stay in sync. + */ + +import { Channel } from './main.ts'; +import type { ChannelLike } from './types.ts'; + +let channel: ChannelLike | undefined; + +function syncGlobalSlot(next: ChannelLike | undefined): void { + globalThis.__STORYBOOK_ADDONS_CHANNEL__ = next; +} + +/** + * Returns the installed addons channel, or `null` before one exists. + * + * The global slot wins over the module cache so duplicate copies of this module (e.g. dev-server + * preset code and `.storybook` service registration loading different bundles) still observe + * `setChannel` from whichever copy installed the live websocket channel. + */ +export function getChannel(): ChannelLike | null { + const fromGlobal = globalThis.__STORYBOOK_ADDONS_CHANNEL__ as ChannelLike | undefined; + if (fromGlobal) { + channel = fromGlobal; + } + + return channel ?? null; +} + +/** + * Returns the installed addons channel. + * + * Callers assume each runtime has installed a channel at its entry boundary (builder iframe setup, + * manager boot, server `services` preset, or Node module bootstrap). Prefer this over nullable + * `getChannel()` when the channel must exist. + */ +export function requireChannel(): ChannelLike { + const installed = getChannel(); + if (!installed) { + throw new Error( + 'Storybook addons channel is not installed in this runtime. Install it via setChannel() or addons.setChannel() at the runtime entry point before registering services or emitting events.' + ); + } + + return installed; +} + +/** Installs (or replaces) the shared addons channel. Pass `null` to clear. */ +export function setChannel(next: ChannelLike | null): void { + channel = next ?? undefined; + syncGlobalSlot(channel); +} + +/** Clears the shared channel slot. Alias for `setChannel(null)`. */ +export function clearChannel(): void { + setChannel(null); +} + +/** Installs a noop in-process channel — used by server presets and unit tests. */ +export function installNoopChannel(): void { + setChannel(new Channel({})); +} + +/** + * Installs a noop channel when none is present yet. + * + * Prefer explicit `setChannel` / `installNoopChannel` at runtime entry points. This helper remains for + * tests and tooling that need an in-process channel without a mock transport. + */ +export function ensureChannel(): void { + if (!getChannel()) { + installNoopChannel(); + } +} + +// Non-browser realms (Node server, Vitest without a DOM) bootstrap a noop channel at import so +// presets and tests can register services when no websocket transport exists (e.g. static build). +// Browser preview must not bootstrap here — builders install the real channel before preview config. +if (typeof window === 'undefined') { + ensureChannel(); +} diff --git a/code/core/src/channels/index.test.ts b/code/core/src/channels/index.test.ts index 22fab1c3906c..fd6ac555a0b3 100644 --- a/code/core/src/channels/index.test.ts +++ b/code/core/src/channels/index.test.ts @@ -30,12 +30,7 @@ const MockedWebsocket = vi.hoisted(() => { return { MyMockedWebsocket, ref }; }); -vi.mock('@storybook/global', () => ({ - global: { - ...global, - WebSocket: MockedWebsocket.MyMockedWebsocket, - }, -})); +vi.stubGlobal('WebSocket', MockedWebsocket.MyMockedWebsocket); describe('Channel', () => { let transport: ChannelTransport; @@ -148,7 +143,7 @@ describe('Channel', () => { }); it('should use setImmediate if async is true', () => { - global.setImmediate = vi.fn(setImmediate); + globalThis.setImmediate = vi.fn(setImmediate); channel = new Channel({ async: true, transport }); channel.addListener('event1', vi.fn()); diff --git a/code/core/src/channels/index.ts b/code/core/src/channels/index.ts index b4a32cd30047..4bb97dd65ffa 100644 --- a/code/core/src/channels/index.ts +++ b/code/core/src/channels/index.ts @@ -1,5 +1,4 @@ /// -import { global } from '@storybook/global'; import { UniversalStore } from '../shared/universal-store/index.ts'; import { Channel } from './main.ts'; @@ -7,9 +6,15 @@ import { PostMessageTransport } from './postmessage/index.ts'; import type { ChannelTransport, Config } from './types.ts'; import { WebsocketTransport } from './websocket/index.ts'; -const { CHANNEL_OPTIONS, CONFIG_TYPE } = global; - export * from './main.ts'; +export { + clearChannel, + ensureChannel, + getChannel, + installNoopChannel, + requireChannel, + setChannel, +} from './channel-slot.ts'; export default Channel; @@ -36,10 +41,10 @@ type Options = Config & { export function createBrowserChannel({ page, extraTransports = [] }: Options): Channel { const transports: ChannelTransport[] = [new PostMessageTransport({ page }), ...extraTransports]; - if (CONFIG_TYPE === 'DEVELOPMENT') { + if (globalThis.CONFIG_TYPE === 'DEVELOPMENT') { const protocol = window.location.protocol === 'http:' ? 'ws' : 'wss'; const { hostname, port } = window.location; - const { wsToken } = CHANNEL_OPTIONS || {}; + const { wsToken } = globalThis.CHANNEL_OPTIONS || {}; const channelUrl = `${protocol}://${hostname}:${port}/storybook-server-channel?token=${wsToken}`; transports.push(new WebsocketTransport({ url: channelUrl, onError: () => {}, page })); diff --git a/code/core/src/channels/mock-channel.ts b/code/core/src/channels/mock-channel.ts new file mode 100644 index 000000000000..b787bdb9e188 --- /dev/null +++ b/code/core/src/channels/mock-channel.ts @@ -0,0 +1,8 @@ +import { Channel } from './main.ts'; + +/** In-process channel with no transport — the default for unit tests and manager story mocks. */ +export function mockChannel(): Channel { + return new Channel({ + transport: { setHandler: () => {}, send: () => {} }, + }); +} diff --git a/code/core/src/channels/postmessage/index.ts b/code/core/src/channels/postmessage/index.ts index 3a8b4d8feda0..1ad6a86ca9b9 100644 --- a/code/core/src/channels/postmessage/index.ts +++ b/code/core/src/channels/postmessage/index.ts @@ -2,8 +2,6 @@ import { logger, pretty } from 'storybook/internal/client-logger'; import * as EVENTS from 'storybook/internal/core-events'; -import { global } from '@storybook/global'; - import { isJSON, parse, stringify } from 'telejson'; import invariant from 'tiny-invariant'; @@ -16,10 +14,12 @@ import type { } from '../types.ts'; import { getEventSourceUrl } from './getEventSourceUrl.ts'; -const { document, location } = global; +const { document, location } = globalThis; export const KEY = 'storybook-channel'; +const CHANNEL_OPTIONS = globalThis.CHANNEL_OPTIONS || {}; + const defaultEventOptions = { maxDepth: 25 }; // TODO: we should export a method for opening child windows here and keep track of em. @@ -36,8 +36,8 @@ export class PostMessageTransport implements ChannelTransport { constructor(private readonly config: Config) { this.buffer = []; - if (typeof global?.addEventListener === 'function') { - global.addEventListener('message', this.handleEvent.bind(this), false); + if (typeof globalThis.addEventListener === 'function') { + globalThis.addEventListener('message', this.handleEvent.bind(this), false); } // Check whether the config.page parameter has a valid value @@ -91,7 +91,7 @@ export class PostMessageTransport implements ChannelTransport { const stringifyOptions = { ...defaultEventOptions, - ...(global.CHANNEL_OPTIONS || {}), + ...CHANNEL_OPTIONS, ...eventOptions, }; @@ -156,8 +156,8 @@ export class PostMessageTransport implements ChannelTransport { return list?.length ? list : this.getCurrentFrames(); } - if (global && global.parent && global.parent !== global.self) { - return [global.parent]; + if (globalThis.parent && globalThis.parent !== globalThis.self) { + return [globalThis.parent]; } return []; @@ -170,8 +170,8 @@ export class PostMessageTransport implements ChannelTransport { ); return list.flatMap((e) => (e.contentWindow ? [e.contentWindow] : [])); } - if (global && global.parent) { - return [global.parent]; + if (globalThis.parent) { + return [globalThis.parent]; } return []; @@ -184,8 +184,8 @@ export class PostMessageTransport implements ChannelTransport { ); return list.flatMap((e) => (e.contentWindow ? [e.contentWindow] : [])); } - if (global && global.parent) { - return [global.parent]; + if (globalThis.parent) { + return [globalThis.parent]; } return []; @@ -195,7 +195,7 @@ export class PostMessageTransport implements ChannelTransport { try { const { data } = rawEvent; const { key, event, refId } = - typeof data === 'string' && isJSON(data) ? parse(data, global.CHANNEL_OPTIONS || {}) : data; + typeof data === 'string' && isJSON(data) ? parse(data, CHANNEL_OPTIONS) : data; if (key === KEY) { const pageString = @@ -230,6 +230,14 @@ export class PostMessageTransport implements ChannelTransport { ); invariant(this.handler, 'ChannelHandler should be set'); + + // Preview channel bootstraps after the iframe document loads. When the preview sends its + // first postMessage (e.g. open-service sync-start), flush any outbound events that + // were buffered while the iframe was not yet a postMessage target. + if (this.config.page === 'manager' && this.buffer.length) { + this.flush(); + } + this.handler(event); } } catch (error) { diff --git a/code/core/src/channels/test-channel.ts b/code/core/src/channels/test-channel.ts new file mode 100644 index 000000000000..277228869e83 --- /dev/null +++ b/code/core/src/channels/test-channel.ts @@ -0,0 +1,51 @@ +import { vi } from 'vitest'; +import type { Mock } from 'vitest'; + +import { clearChannel, setChannel } from './channel-slot.ts'; +import type { Channel } from './main.ts'; +import type { ChannelEvent } from './types.ts'; +import { mockChannel } from './mock-channel.ts'; + +export type TestChannel = Channel & { + /** + * Deliver an event to current listeners as if from an external peer, without going through the + * spied `emit` (so `emit.mock.calls` only reflects this runtime's own broadcasts). + */ + emitExternal(eventName: string, ...args: unknown[]): void; +}; + +export type SpiedTestChannel = TestChannel & { + on: Mock; + off: Mock; + emit: Mock; +}; + +/** + * {@link mockChannel} plus spied `on` / `off` / `emit` and {@link TestChannel.emitExternal} for tests + * that assert on channel wiring while simulating peer traffic. + */ +export function createTestChannel(): SpiedTestChannel { + const channel = mockChannel(); + const on = vi.spyOn(channel, 'on'); + const off = vi.spyOn(channel, 'off'); + const emit = vi.spyOn(channel, 'emit'); + + const emitExternal = (eventName: string, ...args: unknown[]) => { + const event: ChannelEvent = { type: eventName, from: '__test_external__', args }; + const listeners = channel.listeners(eventName); + + if (listeners) { + listeners.forEach((listener) => listener.apply(event, args)); + } + }; + + return Object.assign(channel, { on, off, emit, emitExternal }) as SpiedTestChannel; +} + +export function installTestChannel(channel: SpiedTestChannel | null): void { + if (channel === null) { + clearChannel(); + } else { + setChannel(channel); + } +} diff --git a/code/core/src/channels/websocket/index.ts b/code/core/src/channels/websocket/index.ts index 0c9a6ba5827c..8ed1f0110dc5 100644 --- a/code/core/src/channels/websocket/index.ts +++ b/code/core/src/channels/websocket/index.ts @@ -1,15 +1,11 @@ /// import * as EVENTS from 'storybook/internal/core-events'; -import { global } from '@storybook/global'; - import { isJSON, parse, stringify } from 'telejson'; import invariant from 'tiny-invariant'; import type { ChannelHandler, ChannelTransport, Config } from '../types.ts'; -const { WebSocket } = global; - type OnError = (message: Event) => void; interface WebsocketTransportArgs extends Partial { @@ -20,6 +16,8 @@ interface WebsocketTransportArgs extends Partial { export const HEARTBEAT_INTERVAL = 15000; export const HEARTBEAT_MAX_LATENCY = 5000; +const CHANNEL_OPTIONS = globalThis.CHANNEL_OPTIONS || {}; + export class WebsocketTransport implements ChannelTransport { private buffer: string[] = []; @@ -42,6 +40,7 @@ export class WebsocketTransport implements ChannelTransport { } constructor({ url, onError, page }: WebsocketTransportArgs) { + // eslint-disable-next-line compat/compat this.socket = new WebSocket(url); this.socket.onopen = () => { this.isReady = true; @@ -95,7 +94,7 @@ export class WebsocketTransport implements ChannelTransport { private sendNow(event: any) { const data = stringify(event, { maxDepth: 15, - ...global.CHANNEL_OPTIONS, + ...CHANNEL_OPTIONS, }); this.socket.send(data); } diff --git a/code/core/src/cli/AddonVitestService.constants.ts b/code/core/src/cli/AddonVitestService.constants.ts index 92804850e965..8726f80f348f 100644 --- a/code/core/src/cli/AddonVitestService.constants.ts +++ b/code/core/src/cli/AddonVitestService.constants.ts @@ -1,6 +1,7 @@ import { SupportedFramework } from '../types/index.ts'; export const SUPPORTED_FRAMEWORKS: readonly SupportedFramework[] = [ + SupportedFramework.ANGULAR_VITE, SupportedFramework.HTML_VITE, SupportedFramework.NEXTJS_VITE, SupportedFramework.PREACT_VITE, diff --git a/code/core/src/cli/AddonVitestService.test.ts b/code/core/src/cli/AddonVitestService.test.ts index 9b432fdd74d5..65068a71ceeb 100644 --- a/code/core/src/cli/AddonVitestService.test.ts +++ b/code/core/src/cli/AddonVitestService.test.ts @@ -683,13 +683,20 @@ describe('AddonVitestService', () => { expect(result.compatible).toBe(true); }); - it('should reject arrow function vitest config (unsupported)', async () => { + it('should reject arrow function vitest config with dynamic control flow (unsupported)', async () => { vi.mocked(find.any) .mockReturnValueOnce(undefined) // workspace .mockReturnValueOnce('vitest.config.ts'); // config + // A callback config that returns object literals directly is supported; one with branching + // control flow in a block body is not, and must be rejected. vi.mocked(fs.readFile).mockResolvedValue( `import { defineConfig } from 'vitest/config'; -export default defineConfig(() => ({ test: {} }))` +export default defineConfig(({ mode }) => { + if (mode === 'production') { + return { test: { name: 'prod' } }; + } + return { test: { name: 'dev' } }; +})` ); const result = await service.validateConfigFiles('.storybook'); diff --git a/code/lib/cli-storybook/src/ai/index.ts b/code/core/src/cli/ai/index.ts similarity index 92% rename from code/lib/cli-storybook/src/ai/index.ts rename to code/core/src/cli/ai/index.ts index bc3fa3c61547..62720866bc4c 100644 --- a/code/lib/cli-storybook/src/ai/index.ts +++ b/code/core/src/cli/ai/index.ts @@ -7,9 +7,8 @@ import { logger } from 'storybook/internal/node-logger'; import { telemetry } from 'storybook/internal/telemetry'; import { SupportedLanguage } from 'storybook/internal/types'; -import { ProjectTypeService } from '../../../create-storybook/src/services/ProjectTypeService.ts'; - -import { getStorybookData } from '../automigrate/helpers/mainConfigFile.ts'; +import { detectLanguage } from '../detectLanguage.ts'; +import { getStorybookData } from '../getStorybookData.ts'; import { getAiSetupMarkdownOutput } from './setup-prompts/index.ts'; import type { ProjectInfo, AiSetupOptions } from './types.ts'; @@ -35,8 +34,7 @@ export async function aiSetup(options: AiSetupOptions): Promise { ? parseMajorVersion(data.versionInstalled) : undefined; - const projectTypeService = new ProjectTypeService(data.packageManager); - const detectedLanguage = await projectTypeService.detectLanguage(); + const detectedLanguage = await detectLanguage(data.packageManager, data.workingDir); const language = detectedLanguage === SupportedLanguage.TYPESCRIPT ? 'ts' : 'js'; const needsUserOnboarding = await cache.get('onboarding-pending', false); diff --git a/code/core/src/cli/ai/mcp/client.test.ts b/code/core/src/cli/ai/mcp/client.test.ts new file mode 100644 index 000000000000..b38253ffd25e --- /dev/null +++ b/code/core/src/cli/ai/mcp/client.test.ts @@ -0,0 +1,420 @@ +import { versions } from 'storybook/internal/common'; + +import { describe, expect, it, vi } from 'vitest'; + +import { MCP_CLIENT_INFO, McpJsonRpcError, callMcpTool, listMcpTools } from './client.ts'; +import type { StorybookInstanceRecord } from './types.ts'; + +const record: StorybookInstanceRecord = { + schemaVersion: 1, + instanceId: 'i-1', + pid: 1, + cwd: '/projects/foo', + url: 'http://localhost:6006', + port: 6006, + mcp: { status: 'ready', endpoint: '/mcp' }, +}; + +const jsonResponse = (body: unknown, status = 200, headers: Record = {}) => + new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json', ...headers }, + }); + +const sseResponse = (body: string, status = 200, headers: Record = {}) => + new Response(body, { + status, + headers: { 'Content-Type': 'text/event-stream', ...headers }, + }); + +/** + * Every JSON-RPC request is preceded by a best-effort `initialize` handshake POST, so the actual + * request under test is always the last fetch call. + */ +const lastCall = (fetchImpl: typeof fetch) => vi.mocked(fetchImpl).mock.calls.at(-1)!; + +describe('callMcpTool', () => { + it('POSTs a JSON-RPC tools/call request to the endpoint (application/json)', async () => { + const fetchImpl = vi.fn(async () => + jsonResponse({ + jsonrpc: '2.0', + id: 'whatever', + result: { content: [{ type: 'text', text: 'hello' }] }, + }) + ) as unknown as typeof fetch; + + const result = await callMcpTool( + record, + { name: 'list-all-documentation', arguments: { withStoryIds: true } }, + fetchImpl + ); + + expect(result.content).toEqual([{ type: 'text', text: 'hello' }]); + + const call = lastCall(fetchImpl); + expect(call[0]).toBe('http://localhost:6006/mcp'); + const init = call[1] as RequestInit; + const headers = init.headers as Record; + expect(headers.Accept).toBe('application/json, text/event-stream'); + expect(headers['X-Storybook-MCP-Proxy']).toBe('true'); + expect(init.signal).toBeInstanceOf(AbortSignal); + const body = JSON.parse(init.body as string); + expect(body).toMatchObject({ + jsonrpc: '2.0', + method: 'tools/call', + params: { + name: 'list-all-documentation', + arguments: { withStoryIds: true }, + }, + }); + expect(typeof body.id).toBe('string'); + }); + + it('resolves the endpoint path against the instance url without mangling the scheme', async () => { + const fetchImpl = vi.fn(async () => + jsonResponse({ jsonrpc: '2.0', id: 'whatever', result: { content: [] } }) + ) as unknown as typeof fetch; + + await callMcpTool( + { ...record, url: 'http://127.0.0.1:6007', mcp: { status: 'ready', endpoint: '/mcp' } }, + { name: 'list-all-documentation' }, + fetchImpl + ); + + expect(lastCall(fetchImpl)[0]).toBe('http://127.0.0.1:6007/mcp'); + }); + + it('parses a single-event SSE response (text/event-stream)', async () => { + const sseBody = + 'event: message\n' + + 'data: {"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"hi"}]}}\n' + + '\n'; + const fetchImpl = (async () => sseResponse(sseBody)) as typeof fetch; + + const result = await callMcpTool(record, { name: 'list-all-documentation' }, fetchImpl); + expect(result.content).toEqual([{ type: 'text', text: 'hi' }]); + }); + + it('joins multi-line SSE data correctly', async () => { + const envelope = { + jsonrpc: '2.0', + id: 1, + result: { content: [{ type: 'text', text: 'line\nwith newline' }] }, + }; + const dataLines = JSON.stringify(envelope, null, 2) + .split('\n') + .map((l) => `data: ${l}`) + .join('\n'); + const sseBody = `event: message\n${dataLines}\n\n`; + const fetchImpl = (async () => sseResponse(sseBody)) as typeof fetch; + + const result = await callMcpTool(record, { name: 'list-all-documentation' }, fetchImpl); + expect(result.content?.[0]).toEqual({ type: 'text', text: 'line\nwith newline' }); + }); + + it('throws on SSE responses that contain no data event', async () => { + const fetchImpl = (async () => sseResponse('event: ping\n\n')) as typeof fetch; + await expect( + callMcpTool(record, { name: 'list-all-documentation' }, fetchImpl) + ).rejects.toThrow(/SSE response with no data event/); + }); + + it('throws when the record has no mcp.endpoint', async () => { + const noEndpoint: StorybookInstanceRecord = { ...record, mcp: { status: 'ready' } }; + const fetchImpl = vi.fn() as unknown as typeof fetch; + await expect( + callMcpTool(noEndpoint, { name: 'list-all-documentation' }, fetchImpl) + ).rejects.toThrow(/has no server endpoint registered/); + }); + + it('throws when the response is not ok', async () => { + const fetchImpl = (async () => + new Response('boom', { status: 500, statusText: 'Server Error' })) as typeof fetch; + await expect( + callMcpTool(record, { name: 'list-all-documentation' }, fetchImpl) + ).rejects.toThrow(/responded with 500/); + }); + + it('throws when the response content-type is neither JSON nor SSE', async () => { + const fetchImpl = (async () => + new Response('', { + status: 200, + headers: { 'Content-Type': 'text/html' }, + })) as typeof fetch; + await expect( + callMcpTool(record, { name: 'list-all-documentation' }, fetchImpl) + ).rejects.toThrow(/unsupported content-type "text\/html"/); + }); + + it('throws an McpJsonRpcError when the JSON-RPC payload carries an error', async () => { + const fetchImpl = (async () => + jsonResponse({ + jsonrpc: '2.0', + id: 'whatever', + error: { code: -32601, message: 'unknown tool' }, + })) as typeof fetch; + const promise = callMcpTool(record, { name: 'nope' }, fetchImpl); + await expect(promise).rejects.toThrow(/Storybook server error -32601: unknown tool/); + await expect(promise).rejects.toBeInstanceOf(McpJsonRpcError); + }); + + it.each([ + ['a primitive result', { jsonrpc: '2.0', id: 1, result: 'hello' }], + ['a null result', { jsonrpc: '2.0', id: 1, result: null }], + [ + 'a content item without a type', + { jsonrpc: '2.0', id: 1, result: { content: [{ text: 'x' }] } }, + ], + ['a malformed error object', { jsonrpc: '2.0', id: 1, error: { code: 'x' } }], + ])('rejects %s as an unexpected response shape', async (_label, body) => { + const fetchImpl = (async () => jsonResponse(body)) as typeof fetch; + await expect( + callMcpTool(record, { name: 'list-all-documentation' }, fetchImpl) + ).rejects.toThrow(/unexpected response shape/); + }); + + it('passes through extra content fields and result keys (loose validation)', async () => { + const fetchImpl = (async () => + jsonResponse({ + jsonrpc: '2.0', + id: 1, + result: { + content: [{ type: 'resource_link', uri: 'http://x' }], + _meta: { 'storybook.dev/foo': 1 }, + }, + })) as typeof fetch; + const result = await callMcpTool(record, { name: 'x' }, fetchImpl); + expect(result.content?.[0]).toMatchObject({ type: 'resource_link', uri: 'http://x' }); + }); +}); + +describe('listMcpTools', () => { + it('POSTs a JSON-RPC tools/list request and returns the tool descriptors', async () => { + const tools = [ + { name: 'get-documentation', description: 'Docs', inputSchema: { properties: {} } }, + { name: 'list-all-documentation' }, + ]; + const fetchImpl = vi.fn(async () => + jsonResponse({ jsonrpc: '2.0', id: 'x', result: { tools } }) + ) as unknown as typeof fetch; + + await expect(listMcpTools(record, fetchImpl)).resolves.toEqual(tools); + + const body = JSON.parse(lastCall(fetchImpl)[1]?.body as string); + expect(body).toMatchObject({ method: 'tools/list', params: {} }); + }); + + it('returns [] when the result has no tools array', async () => { + const fetchImpl = (async () => + jsonResponse({ jsonrpc: '2.0', id: 'x', result: {} })) as typeof fetch; + await expect(listMcpTools(record, fetchImpl)).resolves.toEqual([]); + }); + + it('rejects tool descriptors without a name as an unexpected response shape', async () => { + const fetchImpl = (async () => + jsonResponse({ + jsonrpc: '2.0', + id: 'x', + result: { tools: [{ description: 'nameless' }] }, + })) as typeof fetch; + await expect(listMcpTools(record, fetchImpl)).rejects.toThrow(/unexpected response shape/); + }); +}); + +describe('initialize handshake (clientInfo for telemetry segmentation)', () => { + const initializeResponse = (sessionId?: string) => + jsonResponse( + { + jsonrpc: '2.0', + id: 'init', + result: { + protocolVersion: '2025-06-18', + serverInfo: {}, + }, + }, + 200, + sessionId ? { 'mcp-session-id': sessionId } : {} + ); + + const toolResult = () => + jsonResponse({ + jsonrpc: '2.0', + id: 'call', + result: { content: [{ type: 'text', text: 'hi' }] }, + }); + + const toolListResult = (tools: unknown[] = []) => + jsonResponse({ + jsonrpc: '2.0', + id: 'list', + result: { tools }, + }); + + it('sends initialize with the storybook-cli clientInfo before the actual request', async () => { + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(initializeResponse('session-1')) + .mockResolvedValueOnce(toolResult()) as unknown as typeof fetch; + + await callMcpTool(record, { name: 'list-all-documentation' }, fetchImpl); + + expect(fetchImpl).toHaveBeenCalledTimes(2); + const [initTarget, initInit] = vi.mocked(fetchImpl).mock.calls[0]; + expect(initTarget).toBe('http://localhost:6006/mcp'); + const initBody = JSON.parse((initInit as RequestInit).body as string); + expect(initBody).toMatchObject({ + jsonrpc: '2.0', + method: 'initialize', + params: { + protocolVersion: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/), + capabilities: {}, + clientInfo: { name: 'storybook-cli', version: versions.storybook }, + }, + }); + expect(MCP_CLIENT_INFO).toEqual({ name: 'storybook-cli', version: versions.storybook }); + }); + + it('threads the session id from the handshake into the actual request', async () => { + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(initializeResponse('session-42')) + .mockResolvedValueOnce(toolResult()) as unknown as typeof fetch; + + await callMcpTool(record, { name: 'list-all-documentation' }, fetchImpl); + + const headers = (lastCall(fetchImpl)[1] as RequestInit).headers as Record; + expect(headers['Mcp-Session-Id']).toBe('session-42'); + }); + + it('proceeds without a session header when the handshake response has no session id', async () => { + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(initializeResponse(undefined)) + .mockResolvedValueOnce(toolResult()) as unknown as typeof fetch; + + const result = await callMcpTool(record, { name: 'list-all-documentation' }, fetchImpl); + + expect(result.content).toEqual([{ type: 'text', text: 'hi' }]); + const headers = (lastCall(fetchImpl)[1] as RequestInit).headers as Record; + expect(headers).not.toHaveProperty('Mcp-Session-Id'); + }); + + it('proceeds without a session header when the handshake request rejects', async () => { + const fetchImpl = vi + .fn() + .mockRejectedValueOnce(new Error('connection refused')) + .mockResolvedValueOnce(toolResult()) as unknown as typeof fetch; + + const result = await callMcpTool(record, { name: 'list-all-documentation' }, fetchImpl); + + expect(result.content).toEqual([{ type: 'text', text: 'hi' }]); + const headers = (lastCall(fetchImpl)[1] as RequestInit).headers as Record; + expect(headers).not.toHaveProperty('Mcp-Session-Id'); + }); + + it('ignores the session id of a non-ok handshake response', async () => { + let canceled = false; + const initBody = new ReadableStream({ + cancel() { + canceled = true; + }, + }); + const fetchImpl = vi + .fn() + .mockResolvedValueOnce( + new Response(initBody, { status: 500, headers: { 'mcp-session-id': 'session-broken' } }) + ) + .mockResolvedValueOnce(toolResult()) as unknown as typeof fetch; + + await callMcpTool(record, { name: 'list-all-documentation' }, fetchImpl); + + const headers = (lastCall(fetchImpl)[1] as RequestInit).headers as Record; + expect(headers).not.toHaveProperty('Mcp-Session-Id'); + expect(canceled).toBe(true); + }); + + it('drains the handshake response body before sending the actual request', async () => { + // The server stores the clientInfo while producing the handshake response body, so the + // follow-up request may only be sent after that body has been consumed. + let drained = false; + const initBody = new ReadableStream({ + pull(controller) { + drained = true; + controller.enqueue(new TextEncoder().encode('{}')); + controller.close(); + }, + }); + const fetchImpl = vi + .fn() + .mockResolvedValueOnce( + new Response(initBody, { + status: 200, + headers: { 'Content-Type': 'application/json', 'mcp-session-id': 'session-1' }, + }) + ) + .mockImplementationOnce(async () => { + expect(drained).toBe(true); + return toolResult(); + }) as unknown as typeof fetch; + + await callMcpTool(record, { name: 'list-all-documentation' }, fetchImpl); + expect(drained).toBe(true); + }); + + it('also performs the handshake for tools/list requests', async () => { + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(initializeResponse('session-7')) + .mockResolvedValueOnce( + jsonResponse({ jsonrpc: '2.0', id: 'x', result: { tools: [] } }) + ) as unknown as typeof fetch; + + await listMcpTools(record, fetchImpl); + + const initBody = JSON.parse( + (vi.mocked(fetchImpl).mock.calls[0][1] as RequestInit).body as string + ); + expect(initBody.method).toBe('initialize'); + const headers = (vi.mocked(fetchImpl).mock.calls[1][1] as RequestInit).headers as Record< + string, + string + >; + expect(headers['Mcp-Session-Id']).toBe('session-7'); + }); + + it.each([ + [ + 'malformed JSON', + () => + new Response('not json', { + status: 200, + headers: { 'Content-Type': 'application/json', 'mcp-session-id': 'session-1' }, + }), + ], + [ + 'a JSON-RPC error', + () => jsonResponse({ jsonrpc: '2.0', id: 'init', error: { code: -32000, message: 'bad' } }), + ], + ['no result', () => jsonResponse({ jsonrpc: '2.0', id: 'init' })], + ])('keeps tools/list working when initialize returns %s', async (_label, initResponse) => { + const tools = [{ name: 'get-documentation', description: 'Get docs' }]; + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(initResponse()) + .mockResolvedValueOnce(toolListResult(tools)) as unknown as typeof fetch; + + await expect(listMcpTools(record, fetchImpl)).resolves.toEqual(tools); + }); + + it('keeps listMcpTools returning only the tool descriptors', async () => { + const tools = [{ name: 'get-documentation', description: 'Get docs' }]; + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(initializeResponse('session-1')) + .mockResolvedValueOnce( + jsonResponse({ jsonrpc: '2.0', id: 'x', result: { tools } }) + ) as unknown as typeof fetch; + + await expect(listMcpTools(record, fetchImpl)).resolves.toEqual(tools); + }); +}); diff --git a/code/core/src/cli/ai/mcp/client.ts b/code/core/src/cli/ai/mcp/client.ts new file mode 100644 index 000000000000..3c32ed14bc5b --- /dev/null +++ b/code/core/src/cli/ai/mcp/client.ts @@ -0,0 +1,304 @@ +import { versions } from 'storybook/internal/common'; + +import * as v from 'valibot'; + +import { + type McpToolDescriptor, + McpToolDescriptorSchema, + type StorybookInstanceRecord, + type ToolCallResult, + ToolCallResultSchema, +} from './types.ts'; + +/** + * Marks the request as coming from a trusted local Storybook client. `@storybook/addon-mcp` uses + * this header to skip auth flows meant for remote (composed) Storybooks. + */ +const STORYBOOK_MCP_PROXY_HEADER = 'X-Storybook-MCP-Proxy'; +const STORYBOOK_MCP_PROXY_HEADER_VALUE = 'true'; + +/** + * Upper bound on a single request so a hung server cannot stall the CLI forever. Generous because + * `run-story-tests` on a full suite legitimately runs for minutes. + */ +const REQUEST_TIMEOUT_MS = 10 * 60 * 1000; + +/** + * Identifies the CLI on the MCP connection, so `@storybook/addon-mcp`'s server-side `tool:*` + * telemetry can segment CLI-originated calls from agents connected over MCP directly + * (storybookjs/storybook#35131). + */ +export const MCP_CLIENT_INFO = { name: 'storybook-cli', version: versions.storybook }; + +/** Protocol version sent on `initialize`; tmcp (the addon-mcp server library) supports it. */ +const MCP_PROTOCOL_VERSION = '2025-06-18'; + +export type ToolCallParams = { + name: string; + arguments?: Record; +}; + +/** A JSON-RPC level error returned by the Storybook MCP server (e.g. unknown tool). */ +export class McpJsonRpcError extends Error { + constructor( + public readonly code: number, + message: string + ) { + super(`Storybook server error ${code}: ${message}`); + this.name = 'McpJsonRpcError'; + } +} + +const JsonRpcEnvelopeSchema = v.looseObject({ + result: v.optional(v.unknown()), + error: v.optional(v.looseObject({ code: v.number(), message: v.string() })), +}); + +const ToolListResultSchema = v.looseObject({ + tools: v.optional(v.array(McpToolDescriptorSchema)), +}); + +/** Forward an MCP `tools/call` JSON-RPC request to a local Storybook MCP server. */ +export async function callMcpTool( + record: StorybookInstanceRecord, + params: ToolCallParams, + fetchImpl: typeof fetch = fetch +): Promise { + const { result } = await sendJsonRpcRequest( + record, + 'tools/call', + params, + ToolCallResultSchema, + fetchImpl + ); + return result; +} + +/** List the tools exposed by a local Storybook MCP server via `tools/list`. */ +export async function listMcpTools( + record: StorybookInstanceRecord, + fetchImpl: typeof fetch = fetch +): Promise { + const { result } = await sendJsonRpcRequest( + record, + 'tools/list', + {}, + ToolListResultSchema, + fetchImpl + ); + return result.tools ?? []; +} + +const REQUEST_HEADERS = { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + [STORYBOOK_MCP_PROXY_HEADER]: STORYBOOK_MCP_PROXY_HEADER_VALUE, +}; + +/** + * Send a minimal MCP `initialize` request carrying {@link MCP_CLIENT_INFO} and return the session + * id when the server assigns one. + * + * The session id is MCP Streamable HTTP spec behavior, not a tmcp implementation detail: the + * server assigns it during initialization, returns it in the `Mcp-Session-Id` response header, + * and associates the session's clientInfo with later requests echoing that header. The same + * initialize response body is still consumed so the follow-up request cannot race ahead of the + * server storing clientInfo for telemetry segmentation. + * + * The handshake is best-effort — when it fails (or a future server ignores sessions), the actual + * request proceeds without a session and keeps working; only telemetry segmentation is lost, and + * error reporting stays anchored on the real call. It shares the full {@link REQUEST_TIMEOUT_MS} + * budget rather than a tighter one because the only thing that slows the handshake is the dev + * server still starting up, which the command must wait through anyway. + * + * Sessions are deliberately one-shot: each JSON-RPC request gets its own handshake and the session + * is never reused or closed. A CLI invocation makes one request on the happy path (two on error + * paths that fetch the tool list), so against a localhost server the extra round-trip is + * negligible — not worth threading session state through the call sites. + * + * The response body is drained before returning because the transport produces it only after the + * server has processed the initialize message (and stored the clientInfo); returning on headers + * alone would race the follow-up request against that processing. + */ +async function initializeMcpSession( + target: string, + fetchImpl: typeof fetch +): Promise { + try { + const response = await fetchImpl(target, { + method: 'POST', + headers: REQUEST_HEADERS, + body: JSON.stringify({ + jsonrpc: '2.0', + id: crypto.randomUUID(), + method: 'initialize', + params: { + protocolVersion: MCP_PROTOCOL_VERSION, + capabilities: {}, + clientInfo: MCP_CLIENT_INFO, + }, + }), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + const sessionId = response.headers.get('mcp-session-id'); + if (!response.ok) { + await response.body?.cancel(); + return null; + } + + try { + await response.text(); + } catch { + // The initialize request is best-effort telemetry setup; a malformed body must not block the + // actual tools/list or tools/call request from preserving its existing behavior. + } + return sessionId; + } catch { + return null; + } +} + +/** + * Send a single JSON-RPC request to the instance's MCP endpoint over HTTP. + * + * This is deliberately NOT a full MCP client: the `initialize` request exists solely to convey + * `clientInfo` for telemetry (see {@link initializeMcpSession}) — there is no protocol-version + * negotiation, capability handling, or session lifecycle beyond this one follow-up request. The + * downstream is always `@storybook/addon-mcp`, whose tmcp HttpTransport serves `tools/*` + * per-request — the same local shortcut `@storybook/mcp-proxy` takes in its proxy-client. If the + * CLI ever needs to talk to arbitrary MCP servers, replace this with a real client instead of + * extending it. + * + * tmcp hardcodes `text/event-stream` for any request with an id, so we accept both content-types + * and parse the SSE envelope when needed. + */ +async function sendJsonRpcRequest( + record: StorybookInstanceRecord, + method: 'tools/call' | 'tools/list', + params: unknown, + resultSchema: v.GenericSchema, + fetchImpl: typeof fetch +): Promise<{ result: TResult }> { + const endpoint = record.mcp.endpoint; + if (!endpoint) { + throw new Error(`The Storybook instance at ${record.cwd} has no server endpoint registered`); + } + + const target = new URL(endpoint, record.url).href; + + const sessionId = await initializeMcpSession(target, fetchImpl); + + const response = await fetchImpl(target, { + method: 'POST', + headers: { + ...REQUEST_HEADERS, + ...(sessionId ? { 'Mcp-Session-Id': sessionId } : {}), + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: crypto.randomUUID(), + method, + params, + }), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + + if (!response.ok) { + throw new Error( + `The Storybook server at ${target} responded with ${response.status} ${response.statusText}` + ); + } + + const payload = await readJsonRpcResponse(response, target); + + const unwrapped = unwrapJsonRpcResult(payload, target); + if (!unwrapped.ok) { + throw unwrapped.error; + } + + const result = v.safeParse(resultSchema, unwrapped.result); + if (!result.success) { + throw unexpectedShapeError(target); + } + return { result: result.output }; +} + +function unexpectedShapeError(target: string): Error { + return new Error(`The Storybook server at ${target} returned an unexpected response shape`); +} + +/** + * Unwrap a parsed JSON-RPC payload into its `result`, or report why it isn't usable. The command + * path throws on the reported error. + */ +function unwrapJsonRpcResult( + payload: unknown, + target: string +): { ok: true; result: unknown } | { ok: false; error: Error } { + const envelope = v.safeParse(JsonRpcEnvelopeSchema, payload); + if (!envelope.success) { + return { ok: false, error: unexpectedShapeError(target) }; + } + if (envelope.output.error) { + return { + ok: false, + error: new McpJsonRpcError(envelope.output.error.code, envelope.output.error.message), + }; + } + if (envelope.output.result === undefined) { + return { ok: false, error: new Error('The Storybook server returned no result') }; + } + return { ok: true, result: envelope.output.result }; +} + +async function readJsonRpcResponse(response: Response, endpoint: string): Promise { + const contentType = (response.headers.get('content-type') ?? '').toLowerCase(); + const body = await response.text(); + + if (contentType.includes('application/json')) { + return JSON.parse(body); + } + + if (contentType.includes('text/event-stream')) { + return parseSseEnvelope(body, endpoint); + } + + throw new Error( + `The Storybook server at ${endpoint} returned unsupported content-type "${contentType}". Expected application/json or text/event-stream.` + ); +} + +/** + * Parse an MCP Streamable HTTP SSE response containing a single JSON-RPC envelope. Format per the + * SSE spec: lines starting with `data:` hold payload bytes; multiple `data:` lines in one event + * are joined with `\n`; the event terminates at the first blank line. We only care about the first + * event because a tools/call or tools/list response is always a single message. + */ +function parseSseEnvelope(body: string, endpoint: string): unknown { + const dataLines: string[] = []; + for (const rawLine of body.split('\n')) { + const line = rawLine.replace(/\r$/, ''); + if (line.startsWith('data:')) { + const value = line.slice(5); + dataLines.push(value.startsWith(' ') ? value.slice(1) : value); + continue; + } + if (line === '' && dataLines.length > 0) { + break; + } + } + if (dataLines.length === 0) { + throw new Error( + `The Storybook server at ${endpoint} returned an SSE response with no data event` + ); + } + try { + return JSON.parse(dataLines.join('\n')); + } catch (error) { + throw new Error( + `The Storybook server at ${endpoint} returned an SSE event whose data could not be parsed as JSON: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } +} diff --git a/code/core/src/cli/ai/mcp/intercepts.test.ts b/code/core/src/cli/ai/mcp/intercepts.test.ts new file mode 100644 index 000000000000..df444e6efcd6 --- /dev/null +++ b/code/core/src/cli/ai/mcp/intercepts.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; + +import { getInterceptMarkdown } from './intercepts.ts'; +import type { StorybookInstanceRecord } from './types.ts'; + +const record = (cwd: string, url: string): StorybookInstanceRecord => ({ + schemaVersion: 1, + instanceId: 'i-1', + pid: 1, + cwd, + url, + port: 6006, + mcp: { status: 'ready', endpoint: '/mcp' }, +}); + +describe('getInterceptMarkdown', () => { + it('no-instance without candidates tells the agent to start storybook dev', () => { + const markdown = getInterceptMarkdown('no-instance'); + expect(markdown).toContain('Storybook is not running at this cwd'); + expect(markdown).toContain('storybook dev'); + }); + + it('no-instance with candidates lists the running cwds', () => { + const markdown = getInterceptMarkdown('no-instance', { + records: [record('/projects/foo', 'http://localhost:6006')], + }); + expect(markdown).toContain('Running Storybooks:'); + expect(markdown).toContain('- `/projects/foo` (http://localhost:6006)'); + expect(markdown).toContain('--cwd'); + }); + + it('port-mismatch lists the ports running at the cwd', () => { + const markdown = getInterceptMarkdown('port-mismatch', { + port: 9999, + records: [record('/projects/foo', 'http://localhost:6006')], + }); + expect(markdown).toContain('not on port `9999`'); + expect(markdown).toContain('- port `6006`'); + expect(markdown).toContain('omit `--port`'); + }); + + it('addon-missing instructs installing the MCP addon', () => { + const markdown = getInterceptMarkdown('addon-missing'); + expect(markdown).toContain('`@storybook/addon-mcp` addon is missing'); + expect(markdown).toContain('npx storybook add @storybook/addon-mcp'); + }); + + it('mcp-starting asks to wait and retry', () => { + expect(getInterceptMarkdown('mcp-starting')).toContain('still starting up'); + }); + + it('mcp-error points at the Storybook terminal output', () => { + expect(getInterceptMarkdown('mcp-error')).toContain('Inspect the Storybook terminal output'); + }); +}); diff --git a/code/core/src/cli/ai/mcp/intercepts.ts b/code/core/src/cli/ai/mcp/intercepts.ts new file mode 100644 index 000000000000..3390445461c0 --- /dev/null +++ b/code/core/src/cli/ai/mcp/intercepts.ts @@ -0,0 +1,62 @@ +import type { InterceptReason, StorybookInstanceRecord } from './types.ts'; + +/** + * Repair-instruction markdown for agents, mirroring `@storybook/mcp-proxy` (storybookjs/mcp) so + * the CLI and the proxy give the same guidance — keep the two in sync when updating either. + */ +const NO_INSTANCE_EMPTY = `Storybook is not running at this cwd. Start \`storybook dev\` from the project's cwd and retry the command.`; + +const buildNoInstanceWithCandidates = (records: StorybookInstanceRecord[]) => + `No Storybook is running at this cwd. Either start Storybook from the project's cwd, or retry with \`--cwd\` set to one of the running cwds below. + +Running Storybooks: +${records.map((r) => `- \`${r.cwd}\` (${r.url})`).join('\n')}`; + +const buildPortMismatch = (port: number | undefined, records: StorybookInstanceRecord[]) => + `Storybook is running at this cwd, but not on port \`${port ?? 'unknown'}\`. Retry with one of the running ports below, or omit \`--port\` to route by cwd alone. + +Running Storybooks at this cwd: +${records.map((r) => `- port \`${r.port}\` (${r.url}, status: \`${r.mcp.status}\`)`).join('\n')}`; + +const ADDON_MISSING = `Storybook is running but does not provide these commands. The \`@storybook/addon-mcp\` addon is missing. + +Install it: +\`\`\` +npx storybook add @storybook/addon-mcp +\`\`\` + +Restart Storybook, then retry the command.`; + +const MCP_STARTING = `Storybook is running but its command server is still starting up. Wait a moment and retry the command.`; + +const MCP_ERROR = `Storybook is running but its command server reported an error. Inspect the Storybook terminal output, fix the underlying issue, then retry the command.`; + +export type InterceptExtras = { + records?: StorybookInstanceRecord[]; + port?: number; +}; + +export function getInterceptMarkdown( + reason: InterceptReason, + extras: InterceptExtras = {} +): string { + const { records, port } = extras; + switch (reason) { + case 'no-instance': + return records && records.length > 0 + ? buildNoInstanceWithCandidates(records) + : NO_INSTANCE_EMPTY; + case 'port-mismatch': + return buildPortMismatch(port, records ?? []); + case 'addon-missing': + return ADDON_MISSING; + case 'mcp-starting': + return MCP_STARTING; + case 'mcp-error': + return MCP_ERROR; + default: { + const unhandled: never = reason; + throw new Error(`Unhandled intercept reason: ${unhandled as string}`); + } + } +} diff --git a/code/core/src/cli/ai/mcp/local-metadata.test.ts b/code/core/src/cli/ai/mcp/local-metadata.test.ts new file mode 100644 index 000000000000..5cd88a462f4d --- /dev/null +++ b/code/core/src/cli/ai/mcp/local-metadata.test.ts @@ -0,0 +1,145 @@ +import { resolve } from 'node:path'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { experimental_loadStorybook as loadStorybook } from 'storybook/internal/core-server'; + +import { loadStorybookAiMetadata, resolveStorybookConfigDir } from './local-metadata.ts'; + +vi.mock('storybook/internal/core-server', { spy: true }); + +type LoadedStorybook = Awaited>; + +beforeEach(() => { + vi.mocked(loadStorybook).mockReset(); +}); + +function mockLoadedStorybook(apply: ReturnType) { + vi.mocked(loadStorybook).mockResolvedValue({ + presets: { apply }, + } as unknown as LoadedStorybook); +} + +describe('loadStorybookAiMetadata', () => { + it('loads AI metadata through the Storybook preset system', async () => { + const apply = vi.fn().mockResolvedValue({ + instructions: 'Follow the story workflow.', + tools: [ + { name: 'get-documentation', description: 'Get docs.' }, + { + name: 'get-storybook-story-instructions', + description: 'Get story guidance.', + }, + ], + localTools: { + 'get-storybook-story-instructions': { + call: vi.fn().mockResolvedValue({ content: [] }), + }, + }, + }); + mockLoadedStorybook(apply); + + const metadata = await loadStorybookAiMetadata({ cwd: '/repo' }); + + expect(loadStorybook).toHaveBeenCalledWith({ configDir: resolve('/repo/.storybook') }); + expect(apply).toHaveBeenCalledWith('experimental_storybookAi', undefined); + expect(metadata).toEqual({ + instructions: 'Follow the story workflow.', + tools: [ + { name: 'get-documentation', description: 'Get docs.' }, + { + name: 'get-storybook-story-instructions', + description: 'Get story guidance.', + }, + ], + localTools: { + 'get-storybook-story-instructions': { + call: expect.any(Function), + }, + }, + }); + }); + + it('loads AI metadata from a custom config dir relative to cwd', async () => { + const apply = vi.fn().mockResolvedValue({ tools: [] }); + mockLoadedStorybook(apply); + + await loadStorybookAiMetadata({ cwd: '/repo', configDir: 'config/storybook' }); + + expect(loadStorybook).toHaveBeenCalledWith({ configDir: resolve('/repo/config/storybook') }); + }); + + it('normalizes optional metadata fields and hides descriptor-less local tools', async () => { + const call = vi.fn().mockResolvedValue({ content: [] }); + mockLoadedStorybook( + vi.fn().mockResolvedValue({ + instructions: 123, + tools: [{ name: 'get-documentation' }], + localTools: { + invalid: {}, + notObject: true, + 'get-documentation': { call }, + }, + }) + ); + + const metadata = await loadStorybookAiMetadata({ cwd: '/repo' }); + + expect(metadata).toEqual({ + instructions: undefined, + tools: [{ name: 'get-documentation' }], + localTools: { + 'get-documentation': { call }, + }, + }); + }); + + it('rejects malformed metadata tool descriptors', async () => { + mockLoadedStorybook( + vi.fn().mockResolvedValue({ + tools: [{ name: 123 }], + }) + ); + + await expect(loadStorybookAiMetadata({ cwd: '/repo' })).rejects.toThrow( + 'invalid tool descriptor' + ); + }); + + it('rejects malformed visible local tools', async () => { + mockLoadedStorybook( + vi.fn().mockResolvedValue({ + tools: [{ name: 'get-storybook-story-instructions' }], + localTools: { + 'get-storybook-story-instructions': {}, + }, + }) + ); + + await expect(loadStorybookAiMetadata({ cwd: '/repo' })).rejects.toThrow('invalid local tool'); + }); + + it('returns undefined when no preset provides metadata', async () => { + mockLoadedStorybook(vi.fn().mockResolvedValue(undefined)); + + await expect(loadStorybookAiMetadata({ cwd: '/repo' })).resolves.toBeUndefined(); + }); +}); + +describe('resolveStorybookConfigDir', () => { + it('defaults to .storybook under the target cwd', () => { + expect(resolveStorybookConfigDir({ cwd: '/repo' })).toBe(resolve('/repo/.storybook')); + }); + + it('resolves relative config dirs from the target cwd', () => { + expect(resolveStorybookConfigDir({ cwd: '/repo', configDir: 'config/storybook' })).toBe( + resolve('/repo/config/storybook') + ); + }); + + it('keeps absolute config dirs unchanged', () => { + expect(resolveStorybookConfigDir({ cwd: '/repo', configDir: '/custom/.storybook' })).toBe( + '/custom/.storybook' + ); + }); +}); diff --git a/code/core/src/cli/ai/mcp/local-metadata.ts b/code/core/src/cli/ai/mcp/local-metadata.ts new file mode 100644 index 000000000000..50c651345e37 --- /dev/null +++ b/code/core/src/cli/ai/mcp/local-metadata.ts @@ -0,0 +1,120 @@ +import { isAbsolute, resolve } from 'node:path'; + +import { experimental_loadStorybook as loadStorybook } from 'storybook/internal/core-server'; + +import * as v from 'valibot'; + +import { McpToolDescriptorSchema, type McpToolDescriptor, type ToolCallResult } from './types.ts'; + +const STORYBOOK_AI_METADATA_PRESET = 'experimental_storybookAi'; + +class StorybookAiMetadataError extends Error { + constructor(message: string) { + super(message); + this.name = 'StorybookAiMetadataError'; + } +} + +export type StorybookAiMetadata = { + instructions?: string; + tools: McpToolDescriptor[]; + localTools?: Record< + string, + { call: (input?: Record) => Promise } + >; +}; + +export type StorybookAiMetadataOptions = { + cwd?: string; + configDir?: string; +}; + +export function resolveStorybookConfigDir({ cwd, configDir }: StorybookAiMetadataOptions = {}) { + const projectCwd = resolve(cwd ?? process.cwd()); + if (configDir) { + return isAbsolute(configDir) ? configDir : resolve(projectCwd, configDir); + } + return resolve(projectCwd, '.storybook'); +} + +export async function loadStorybookAiMetadata( + options: StorybookAiMetadataOptions = {} +): Promise { + const configDir = resolveStorybookConfigDir(options); + const storybook = await loadStorybook({ configDir }); + + const metadata = await storybook.presets.apply(STORYBOOK_AI_METADATA_PRESET, undefined); + + return normalizeStorybookAiMetadata(metadata); +} + +function normalizeStorybookAiMetadata(metadata: unknown): StorybookAiMetadata | undefined { + if (!metadata || typeof metadata !== 'object') { + return undefined; + } + const rawMetadata = metadata as Record; + const tools = normalizeTools(rawMetadata.tools); + + return { + instructions: + typeof rawMetadata.instructions === 'string' ? rawMetadata.instructions : undefined, + tools, + localTools: normalizeLocalTools(rawMetadata.localTools, tools), + }; +} + +function normalizeTools(metadata: unknown): McpToolDescriptor[] { + if (metadata === undefined) { + return []; + } + if (!Array.isArray(metadata)) { + throw new StorybookAiMetadataError('Storybook AI metadata must expose `tools` as an array'); + } + + return metadata.map((tool) => { + const result = v.safeParse(McpToolDescriptorSchema, tool); + if (!result.success) { + throw new StorybookAiMetadataError( + 'Storybook AI metadata contains an invalid tool descriptor' + ); + } + return result.output; + }); +} + +function normalizeLocalTools( + metadata: unknown, + tools: McpToolDescriptor[] +): StorybookAiMetadata['localTools'] { + if (!metadata || typeof metadata !== 'object') { + return undefined; + } + + const visibleToolNames = new Set(tools.map((tool) => tool.name)); + const entries = Object.entries(metadata as Record).flatMap(([name, tool]) => { + if (!visibleToolNames.has(name)) { + return []; + } + if (!tool || typeof tool !== 'object') { + throw new StorybookAiMetadataError( + `Storybook AI metadata contains an invalid local tool for \`${name}\`` + ); + } + const call = (tool as { call?: unknown }).call; + if (typeof call !== 'function') { + throw new StorybookAiMetadataError( + `Storybook AI metadata contains an invalid local tool for \`${name}\`` + ); + } + return [ + [ + name, + { + call: call as (input?: Record) => Promise, + }, + ] as const, + ]; + }); + + return entries.length > 0 ? Object.fromEntries(entries) : undefined; +} diff --git a/code/core/src/cli/ai/mcp/register.test.ts b/code/core/src/cli/ai/mcp/register.test.ts new file mode 100644 index 000000000000..1efc3842a60e --- /dev/null +++ b/code/core/src/cli/ai/mcp/register.test.ts @@ -0,0 +1,630 @@ +import { writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +import { optionalEnvToBoolean } from 'storybook/internal/common'; +import { sendTelemetryError, withTelemetry } from 'storybook/internal/core-server'; +import { telemetry } from 'storybook/internal/telemetry'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { Command } from 'commander'; + +import { isAiCliFeatureEnabled, registerAiMcpPassthrough } from './register.ts'; +import { buildStorybookCommandsHelp, runAiTool, runAiToolHelp } from './run-tool.ts'; + +vi.mock('./run-tool.ts', { spy: true }); + +vi.mock('node:fs/promises', { spy: true }); + +vi.mock('storybook/internal/core-server', { spy: true }); + +vi.mock('storybook/internal/telemetry', { spy: true }); + +describe('isAiCliFeatureEnabled', () => { + it.each([ + ['1', true], + ['true', true], + ['0', false], + ['false', false], + ['', false], + [undefined, false], + ])('STORYBOOK_FEATURE_AI_CLI=%j → %j', (value, expected) => { + expect(isAiCliFeatureEnabled({ STORYBOOK_FEATURE_AI_CLI: value })).toBe(expected); + }); +}); + +/** + * Replicate the `ai` command tree from `bin/run.ts`: a `setup` subcommand plus a help action. The + * `--disable-telemetry` and `--logfile` options mirror the shared options that the `command()` + * factory in `bin/run.ts` registers on every command, including the env-var default. + */ +function buildProgram({ withPassthrough }: { withPassthrough: boolean }) { + const program = new Command(); + program.exitOverride(); + const setupAction = vi.fn(); + const helpAction = vi.fn(); + const failures: unknown[] = []; + const handleCommandFailure = vi.fn((logFilePath: string | boolean | undefined) => { + void logFilePath; + return async (error: unknown): Promise => { + failures.push(error); + return undefined as never; + }; + }); + + const aiCommand = program + .command('ai') + .description('AI agent helpers for Storybook') + .option( + '--disable-telemetry', + 'Disable sending telemetry data', + optionalEnvToBoolean(process.env.STORYBOOK_DISABLE_TELEMETRY) + ) + .option('--logfile [path]', 'Write all debug logs to the specified file') + .option('-o, --output ', 'Write the prompt output to a file') + .exitOverride(); + aiCommand.configureOutput({ writeOut: () => {}, writeErr: () => {} }); + aiCommand.command('setup').action(setupAction); + aiCommand.action(helpAction); + + if (withPassthrough) { + registerAiMcpPassthrough(program, aiCommand, handleCommandFailure); + } + + return { program, aiCommand, setupAction, helpAction, handleCommandFailure, failures }; +} + +function parse(program: Command, argv: string[]) { + return program.parseAsync(['node', 'storybook', ...argv]); +} + +function stdoutText(): string { + return vi + .mocked(process.stdout.write) + .mock.calls.map(([chunk]) => String(chunk)) + .join(''); +} + +/** The payloads of all `ai-command` events fired through the mocked telemetry module. */ +function aiCommandPayloads(): unknown[] { + return vi + .mocked(telemetry) + .mock.calls.filter(([eventType]) => eventType === 'ai-command') + .map(([, payload]) => payload); +} + +beforeEach(() => { + // The CI/dev shell may have the opt-out set; tests control it explicitly via vi.stubEnv. + vi.stubEnv('STORYBOOK_DISABLE_TELEMETRY', undefined); + vi.mocked(runAiTool).mockResolvedValue({ + exitCode: 0, + output: 'ok', + outcome: { kind: 'success' }, + }); + vi.mocked(runAiToolHelp).mockResolvedValue({ + exitCode: 0, + output: 'tool help', + outcome: { kind: 'help' }, + }); + vi.mocked(buildStorybookCommandsHelp).mockResolvedValue( + 'Storybook commands (from the target Storybook configuration):' + ); + vi.mocked(writeFile).mockResolvedValue(undefined); + // Pass-through that mirrors the real contract: run the callback, propagate its rejection. + vi.mocked(withTelemetry).mockImplementation(async (_eventType, _options, run) => run()); + vi.mocked(telemetry).mockResolvedValue(undefined); + vi.mocked(sendTelemetryError).mockResolvedValue(undefined); + vi.spyOn(process.stdout, 'write').mockImplementation(() => true); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.mocked(runAiTool).mockReset(); + vi.mocked(runAiToolHelp).mockReset(); + vi.mocked(buildStorybookCommandsHelp).mockReset(); + vi.unstubAllEnvs(); + process.exitCode = undefined; +}); + +describe('without the feature flag (no registration)', () => { + it('keeps `setup` as the only subcommand', () => { + const { aiCommand } = buildProgram({ withPassthrough: false }); + expect(aiCommand.commands.map((c) => c.name())).toEqual(['setup']); + }); + + it('rejects tool names like today (excess arguments)', async () => { + const { program } = buildProgram({ withPassthrough: false }); + await expect(parse(program, ['ai', 'list-all-documentation'])).rejects.toMatchObject({ + code: 'commander.excessArguments', + }); + expect(runAiTool).not.toHaveBeenCalled(); + }); + + it('keeps the bare `ai` help action', async () => { + const { program, helpAction } = buildProgram({ withPassthrough: false }); + await parse(program, ['ai']); + expect(helpAction).toHaveBeenCalled(); + expect(buildStorybookCommandsHelp).not.toHaveBeenCalled(); + }); +}); + +describe('with the feature flag (passthrough registered)', () => { + it('forwards `ai ` with pass-through tokens to runAiTool', async () => { + const { program } = buildProgram({ withPassthrough: true }); + await parse(program, ['ai', 'get-documentation', '--id', 'button-docs']); + expect(runAiTool).toHaveBeenCalledWith('get-documentation', ['--id', 'button-docs'], { + cwd: undefined, + configDir: undefined, + port: undefined, + json: undefined, + }); + }); + + it('parses target options before the tool name and leaves later tokens as command args', async () => { + const { program } = buildProgram({ withPassthrough: true }); + await parse(program, [ + 'ai', + '--cwd', + '/repo', + '--config-dir', + 'config/storybook', + '--port', + '6007', + 'preview-stories', + '--storyIds', + '["button--primary"]', + ]); + expect(runAiTool).toHaveBeenCalledWith( + 'preview-stories', + ['--storyIds', '["button--primary"]'], + { + cwd: '/repo', + configDir: 'config/storybook', + port: '6007', + json: undefined, + } + ); + }); + + it('parses --json before the tool name as the command argument escape hatch', async () => { + const { program } = buildProgram({ withPassthrough: true }); + await parse(program, ['ai', '--json', '{"a":1}', 'get-documentation']); + expect(runAiTool).toHaveBeenCalledWith('get-documentation', [], { + cwd: undefined, + configDir: undefined, + port: undefined, + json: '{"a":1}', + }); + }); + + it('parses -c before the tool name as a config-dir CLI option', async () => { + const { program } = buildProgram({ withPassthrough: true }); + await parse(program, ['ai', '-c', 'config/storybook', 'get-documentation']); + expect(runAiTool).toHaveBeenCalledWith('get-documentation', [], { + cwd: undefined, + configDir: 'config/storybook', + port: undefined, + json: undefined, + }); + }); + + it('passes tokens after the tool name through verbatim, even option-like ones', async () => { + const { program } = buildProgram({ withPassthrough: true }); + await parse(program, [ + 'ai', + 'tool-x', + '--cwd', + '/y', + '--config-dir', + 'config/storybook', + '--port', + '6007', + '--output', + 'z', + ]); + expect(runAiTool).toHaveBeenCalledWith( + 'tool-x', + ['--cwd', '/y', '--config-dir', 'config/storybook', '--port', '6007', '--output', 'z'], + { + cwd: undefined, + configDir: undefined, + port: undefined, + json: undefined, + } + ); + }); + + it('writes the result to the file given via --output instead of stdout', async () => { + const { program } = buildProgram({ withPassthrough: true }); + vi.mocked(runAiTool).mockResolvedValue({ + exitCode: 0, + output: 'markdown result', + outcome: { kind: 'success' }, + }); + await parse(program, ['ai', '-o', '/out/result.md', 'tool-x']); + expect(writeFile).toHaveBeenCalledWith(resolve('/out/result.md'), 'markdown result\n', 'utf-8'); + expect(process.stdout.write).not.toHaveBeenCalledWith('markdown result\n'); + }); + + it('writes the result to stdout', async () => { + const { program } = buildProgram({ withPassthrough: true }); + vi.mocked(runAiTool).mockResolvedValue({ + exitCode: 0, + output: 'markdown result', + outcome: { kind: 'success' }, + }); + await parse(program, ['ai', 'tool-x']); + expect(process.stdout.write).toHaveBeenCalledWith('markdown result\n'); + expect(process.exitCode).toBeUndefined(); + }); + + it('sets a non-zero exit code on failure', async () => { + const { program } = buildProgram({ withPassthrough: true }); + vi.mocked(runAiTool).mockResolvedValue({ + exitCode: 1, + output: 'repair instructions', + outcome: { kind: 'intercept', reason: 'no-instance' }, + }); + await parse(program, ['ai', 'tool-x']); + expect(process.stdout.write).toHaveBeenCalledWith('repair instructions\n'); + expect(process.exitCode).toBe(1); + }); + + it('still dispatches `ai setup` to the setup subcommand', async () => { + const { program, setupAction } = buildProgram({ withPassthrough: true }); + await parse(program, ['ai', 'setup']); + expect(setupAction).toHaveBeenCalled(); + expect(runAiTool).not.toHaveBeenCalled(); + }); + + it.each([[['ai']], [['ai', '--help']], [['ai', '-h']]])( + 'shows commander help plus the tool commands section for %j', + async (argv) => { + const { program } = buildProgram({ withPassthrough: true }); + await parse(program, argv); + expect(buildStorybookCommandsHelp).toHaveBeenCalledWith({ + cwd: undefined, + configDir: undefined, + port: undefined, + }); + const output = stdoutText(); + expect(output).toContain('Usage:'); + expect(output).toContain('setup'); + expect(output).toContain('Storybook commands (from the target Storybook configuration):'); + expect(runAiTool).not.toHaveBeenCalled(); + } + ); + + it('passes --cwd and --port through to the tool commands section', async () => { + const { program } = buildProgram({ withPassthrough: true }); + await parse(program, ['ai', '--cwd', '/x', '--port', '6006', '--help']); + expect(buildStorybookCommandsHelp).toHaveBeenCalledWith({ + cwd: '/x', + configDir: undefined, + port: '6006', + }); + }); + + it('passes --config-dir through to the tool commands section', async () => { + const { program } = buildProgram({ withPassthrough: true }); + await parse(program, ['ai', '--config-dir', 'config/storybook', '--help']); + expect(buildStorybookCommandsHelp).toHaveBeenCalledWith({ + cwd: undefined, + configDir: 'config/storybook', + port: undefined, + }); + }); + + it('shows single-tool help for `ai --help `', async () => { + const { program } = buildProgram({ withPassthrough: true }); + await parse(program, ['ai', '--help', 'get-documentation']); + expect(runAiToolHelp).toHaveBeenCalledWith('get-documentation', { + cwd: undefined, + configDir: undefined, + port: undefined, + }); + expect(process.stdout.write).toHaveBeenCalledWith('tool help\n'); + expect(runAiTool).not.toHaveBeenCalled(); + }); + + it('passes a --help token after the tool name through to runAiTool', async () => { + const { program } = buildProgram({ withPassthrough: true }); + await parse(program, ['ai', 'get-documentation', '--help']); + expect(runAiTool).toHaveBeenCalledWith('get-documentation', ['--help'], { + cwd: undefined, + configDir: undefined, + port: undefined, + json: undefined, + }); + }); +}); + +describe('ai-command telemetry', () => { + it('wraps the command execution in withTelemetry with the opt-out cli options', async () => { + const { program } = buildProgram({ withPassthrough: true }); + await parse(program, ['ai', 'tool-x']); + expect(withTelemetry).toHaveBeenCalledWith( + 'ai-command', + { + cliOptions: { + disableTelemetry: undefined, + logfile: undefined, + configDir: resolve(process.cwd(), '.storybook'), + }, + // Keeps the no-instance intercept reportable from a cwd without a loadable main config. + fallbackTelemetryState: true, + }, + expect.any(Function) + ); + }); + + it('resolves the opt-out configDir from pre-command --cwd', async () => { + const { program } = buildProgram({ withPassthrough: true }); + await parse(program, ['ai', '--cwd', '/target/project', 'tool-x']); + // The target project's core.disableTelemetry must apply, not the invoking cwd's. + expect(withTelemetry).toHaveBeenCalledWith( + 'ai-command', + expect.objectContaining({ + cliOptions: expect.objectContaining({ + configDir: resolve('/target/project', '.storybook'), + }), + }), + expect.any(Function) + ); + }); + + it('resolves the opt-out configDir from pre-command --config-dir', async () => { + const { program } = buildProgram({ withPassthrough: true }); + await parse(program, [ + 'ai', + '--cwd', + '/target/project', + '--config-dir', + 'config/storybook', + 'tool-x', + ]); + expect(withTelemetry).toHaveBeenCalledWith( + 'ai-command', + expect.objectContaining({ + cliOptions: expect.objectContaining({ + configDir: resolve('/target/project', 'config/storybook'), + }), + }), + expect.any(Function) + ); + }); + + it('does not resolve the opt-out configDir from post-command target-looking flags', async () => { + const { program } = buildProgram({ withPassthrough: true }); + await parse(program, [ + 'ai', + 'tool-x', + '--cwd', + '/target/project', + '--config-dir', + 'config/storybook', + '--port', + '6007', + ]); + expect(withTelemetry).toHaveBeenCalledWith( + 'ai-command', + expect.objectContaining({ + cliOptions: expect.objectContaining({ + configDir: resolve(process.cwd(), '.storybook'), + }), + }), + expect.any(Function) + ); + }); + + it('honors pre-command --cwd target opt-out even when the command args are malformed', async () => { + const { program } = buildProgram({ withPassthrough: true }); + // `--json '{bad'` makes full arg parsing fail (invalid-arguments intercept), but the + // target project's core.disableTelemetry must still be the one consulted. + await parse(program, ['ai', '--cwd', '/target/project', 'tool-x', '--json', '{bad']); + expect(withTelemetry).toHaveBeenCalledWith( + 'ai-command', + expect.objectContaining({ + cliOptions: expect.objectContaining({ + configDir: resolve('/target/project', '.storybook'), + }), + }), + expect.any(Function) + ); + }); + + it('honors pre-command --config-dir even when the command args are malformed', async () => { + const { program } = buildProgram({ withPassthrough: true }); + await parse(program, [ + 'ai', + '--cwd', + '/target/project', + '--config-dir', + 'config/storybook', + 'tool-x', + '--json', + '{bad', + ]); + expect(withTelemetry).toHaveBeenCalledWith( + 'ai-command', + expect.objectContaining({ + cliOptions: expect.objectContaining({ + configDir: resolve('/target/project', 'config/storybook'), + }), + }), + expect.any(Function) + ); + }); + + it('fires ai-command with a success payload and no interceptReason', async () => { + const { program } = buildProgram({ withPassthrough: true }); + await parse(program, ['ai', 'tool-x']); + expect(telemetry).toHaveBeenCalledWith( + 'ai-command', + { + command: 'tool-x', + success: true, + duration: expect.any(Number), + }, + // Metadata is collected from the target project, like the opt-out resolution. + { configDir: resolve(process.cwd(), '.storybook') } + ); + expect(aiCommandPayloads()).toHaveLength(1); + expect(aiCommandPayloads()[0]).not.toHaveProperty('interceptReason'); + expect(sendTelemetryError).not.toHaveBeenCalled(); + }); + + it('collects the event metadata from the --cwd target project', async () => { + const { program } = buildProgram({ withPassthrough: true }); + await parse(program, ['ai', '--cwd', '/target/project', 'tool-x']); + expect(telemetry).toHaveBeenCalledWith('ai-command', expect.anything(), { + configDir: resolve('/target/project', '.storybook'), + }); + }); + + it.each([ + 'no-instance', + 'port-mismatch', + 'addon-missing', + 'mcp-starting', + 'mcp-error', + 'invalid-arguments', + 'unknown-command', + ] as const)( + 'fires ai-command with interceptReason %s on intercepted invocations', + async (reason) => { + const { program } = buildProgram({ withPassthrough: true }); + vi.mocked(runAiTool).mockResolvedValue({ + exitCode: 1, + output: 'repair instructions', + outcome: { kind: 'intercept', reason }, + }); + await parse(program, ['ai', 'tool-x']); + expect(telemetry).toHaveBeenCalledWith( + 'ai-command', + { + command: 'tool-x', + success: false, + interceptReason: reason, + duration: expect.any(Number), + }, + expect.anything() + ); + expect(sendTelemetryError).not.toHaveBeenCalled(); + } + ); + + it('routes server-reached errors through the standard sanitized error path', async () => { + const { program } = buildProgram({ withPassthrough: true }); + const error = new Error('connection reset'); + vi.mocked(runAiTool).mockResolvedValue({ + exitCode: 1, + output: 'Failed to reach the Storybook server', + outcome: { kind: 'error', error }, + }); + await parse(program, ['ai', 'tool-x']); + expect(telemetry).toHaveBeenCalledWith( + 'ai-command', + { + command: 'tool-x', + success: false, + duration: expect.any(Number), + }, + expect.anything() + ); + expect(sendTelemetryError).toHaveBeenCalledWith(error, 'ai-command', { + cliOptions: { + disableTelemetry: undefined, + logfile: undefined, + configDir: resolve(process.cwd(), '.storybook'), + }, + }); + }); + + it('still fires ai-command when writing the --output file fails after the command executed', async () => { + const { program, failures } = buildProgram({ withPassthrough: true }); + const writeError = new Error('EACCES: permission denied'); + vi.mocked(writeFile).mockRejectedValue(writeError); + await parse(program, ['ai', '-o', '/readonly/out.md', 'tool-x']); + expect(telemetry).toHaveBeenCalledWith( + 'ai-command', + { + command: 'tool-x', + success: true, + duration: expect.any(Number), + }, + expect.anything() + ); + expect(failures).toEqual([writeError]); + }); + + it('collapses non-command-shaped names to a placeholder (no paths in payloads)', async () => { + const { program } = buildProgram({ withPassthrough: true }); + vi.mocked(runAiTool).mockResolvedValue({ + exitCode: 1, + output: 'repair instructions', + outcome: { kind: 'intercept', reason: 'no-instance' }, + }); + await parse(program, ['ai', './projects/secret-app']); + expect(telemetry).toHaveBeenCalledWith( + 'ai-command', + expect.objectContaining({ command: '(invalid)' }), + expect.anything() + ); + }); + + it.each([[['ai']], [['ai', '--help']], [['ai', '--help', 'tool-x']]])( + 'does not fire ai-command for the help path %j, but still wraps it in withTelemetry', + async (argv) => { + const { program } = buildProgram({ withPassthrough: true }); + await parse(program, argv); + expect(withTelemetry).toHaveBeenCalledWith( + 'ai-command', + expect.anything(), + expect.any(Function) + ); + expect(aiCommandPayloads()).toHaveLength(0); + } + ); + + it('does not fire ai-command when a --help token after the command name turns the run into a help lookup', async () => { + const { program } = buildProgram({ withPassthrough: true }); + vi.mocked(runAiTool).mockResolvedValue({ + exitCode: 0, + output: 'tool help', + outcome: { kind: 'help' }, + }); + await parse(program, ['ai', 'tool-x', '--help']); + expect(aiCommandPayloads()).toHaveLength(0); + }); + + it('passes --disable-telemetry through to withTelemetry', async () => { + const { program } = buildProgram({ withPassthrough: true }); + await parse(program, ['ai', '--disable-telemetry', 'tool-x']); + expect(withTelemetry).toHaveBeenCalledWith( + 'ai-command', + expect.objectContaining({ cliOptions: expect.objectContaining({ disableTelemetry: true }) }), + expect.any(Function) + ); + }); + + it('defaults --disable-telemetry from STORYBOOK_DISABLE_TELEMETRY (as registered in bin/run.ts)', async () => { + vi.stubEnv('STORYBOOK_DISABLE_TELEMETRY', 'true'); + const { program } = buildProgram({ withPassthrough: true }); + await parse(program, ['ai', 'tool-x']); + expect(withTelemetry).toHaveBeenCalledWith( + 'ai-command', + expect.objectContaining({ cliOptions: expect.objectContaining({ disableTelemetry: true }) }), + expect.any(Function) + ); + }); + + it('hands unexpected failures to the command failure handler with the --logfile value', async () => { + const { program, handleCommandFailure, failures } = buildProgram({ withPassthrough: true }); + const error = new Error('unexpected'); + vi.mocked(runAiTool).mockRejectedValue(error); + await parse(program, ['ai', '--logfile', 'debug.log', 'tool-x']); + expect(handleCommandFailure).toHaveBeenCalledWith('debug.log'); + expect(failures).toEqual([error]); + }); +}); diff --git a/code/core/src/cli/ai/mcp/register.ts b/code/core/src/cli/ai/mcp/register.ts new file mode 100644 index 000000000000..ab2935f4c1b7 --- /dev/null +++ b/code/core/src/cli/ai/mcp/register.ts @@ -0,0 +1,208 @@ +import { writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +import { optionalEnvToBoolean } from 'storybook/internal/common'; +import { sendTelemetryError, withTelemetry } from 'storybook/internal/core-server'; +import { logger } from 'storybook/internal/node-logger'; +import { telemetry } from 'storybook/internal/telemetry'; +import type { CLIOptions } from 'storybook/internal/types'; + +import type { Command } from 'commander'; + +import { + type AiCommandOutcome, + type AiToolRunResult, + buildStorybookCommandsHelp, + runAiTool, + runAiToolHelp, +} from './run-tool.ts'; +import { resolveStorybookConfigDir } from './local-metadata.ts'; + +/** + * The `storybook ai ` MCP passthrough is experimental (storybookjs/storybook#35124) and only + * registered when this feature flag is set; without it, `storybook ai` exposes `setup` only. + */ +export function isAiCliFeatureEnabled(env: NodeJS.ProcessEnv = process.env): boolean { + return optionalEnvToBoolean(env.STORYBOOK_FEATURE_AI_CLI) === true; +} + +const CWD_DESCRIPTION = 'Project directory of the target Storybook; place before the command name'; +const CONFIG_DIR_DESCRIPTION = + 'Storybook config directory for help and local commands; place before the command name'; +const PORT_DESCRIPTION = + 'Port of the target Storybook for runtime commands; place before the command name'; + +type AiPassthroughOptions = { + cwd?: string; + configDir?: string; + port?: string; + json?: string; + output?: string; + help?: boolean; + /** From the shared command options in `bin/core.ts`; consumed by `withTelemetry`. */ + disableTelemetry?: boolean; + /** From the shared command options in `bin/core.ts`; consumed by the failure handler. */ + logfile?: string | boolean; +}; + +/** `handleAiCommandFailure` from `bin/core.ts`, passed in to avoid an import cycle. */ +export type CommandFailureHandler = ( + logFilePath: string | boolean | undefined +) => (error: unknown) => Promise; + +/** + * Register the passthrough on the `ai` command: a generic `[command] [args...]` argument pair that + * runs commands exposed by the target Storybook. Help and serverless local commands are loaded from + * Storybook configuration metadata; runtime-bound commands forward to the running Storybook's + * server (MCP under the hood, but that is an implementation detail — user-facing copy says + * "commands"). `passThroughOptions` hands every token after the command name to the command + * untouched, which requires positional options on the program. + * + * Commander's built-in (synchronous) help is replaced with our own `-h, --help` option so the help + * output can include the commands loaded from Storybook configuration. Target-selection options + * (`--cwd`, `--config-dir`, `--port`) belong before the command name; after the command name, + * tokens are command arguments. + */ +export function registerAiMcpPassthrough( + program: Command, + aiCommand: Command, + handleCommandFailure: CommandFailureHandler +): void { + program.enablePositionalOptions(); + + aiCommand + .helpOption(false) + .usage('[options] [command] [args...]') + .argument('[command]', 'A command loaded from the target Storybook configuration') + .argument( + '[args...]', + 'Command arguments as `--key value` flags; values are JSON-parsed when possible' + ) + .option('--cwd ', CWD_DESCRIPTION) + .option('-c, --config-dir ', CONFIG_DIR_DESCRIPTION) + .option('--port ', PORT_DESCRIPTION) + .option( + '--json ', + 'Raw JSON object with the command arguments (escape hatch for complex values)' + ) + .option( + '-h, --help', + 'Show help, including commands loaded from the target Storybook configuration' + ) + .passThroughOptions() + .action( + async (command: string | undefined, commandArgs: string[], options: AiPassthroughOptions) => { + const cliOptions = pickCliOptions(options); + // Like `init`, the fallback keeps telemetry on when no main config is loadable: running + // from a cwd without a Storybook is the `no-instance` intercept this event exists to + // measure. The explicit opt-outs (env var, flag, loadable `core.disableTelemetry`) + // still apply. + return withTelemetry( + 'ai-command', + { cliOptions, fallbackTelemetryState: true }, + async () => { + const target = { + cwd: options.cwd, + configDir: options.configDir, + port: options.port, + }; + if (options.help && command) { + await printResult(await runAiToolHelp(command, target), options.output); + return; + } + if (options.help || !command) { + const commandsSection = await buildStorybookCommandsHelp(target); + process.stdout.write(`${aiCommand.helpInformation()}\n${commandsSection}\n`); + return; + } + const start = Date.now(); + const result = await runAiTool(command, commandArgs, { ...target, json: options.json }); + const duration = Date.now() - start; + try { + await printResult(result, options.output); + } finally { + // The command has executed either way, so a failed `--output` write must not lose + // the event. Reporting after printing keeps a slow telemetry endpoint from ever + // delaying the user's result. + await reportAiCommandTelemetry(command, result.outcome, duration, cliOptions); + } + } + ).catch(handleCommandFailure(options.logfile)); + } + ); +} + +/** + * The cliOptions handed to the telemetry machinery. Only the opt-out tier is forwarded — the + * passthrough's own options (cwd, port, json) may contain paths and are never sent in payloads. + * `configDir` points at the config location of the *target* Storybook so `withTelemetry` resolves + * `core.disableTelemetry` from the project the command is aimed at. Target-selection options are + * commander-owned and must appear before the command name; after the command name, tokens are tool + * arguments and do not affect telemetry opt-out resolution. It is read locally, never sent. + */ +function pickCliOptions(options: AiPassthroughOptions): CLIOptions { + const targetCwd = options.cwd ?? process.cwd(); + const targetConfigDir = options.configDir; + return { + disableTelemetry: options.disableTelemetry, + logfile: options.logfile, + configDir: resolveStorybookConfigDir({ cwd: targetCwd, configDir: targetConfigDir }), + }; +} + +/** + * Command names are a fixed, server-defined vocabulary of short identifiers. Anything else is + * arbitrary agent input (a typo'd path, a stray flag value) that must not be sent verbatim, so it + * is collapsed to a placeholder. The intercept reason still tells the failure class apart. + */ +function sanitizeCommandName(command: string): string { + return /^[\w-]{1,64}$/.test(command) ? command : '(invalid)'; +} + +/** + * Fire the `ai-command` event, once per executed command (storybookjs/storybook#35131). Help + * lookups are excluded so they cannot skew command success rates. Server-side failures + * additionally go through the standard sanitized error path, like errors thrown under + * `withTelemetry`. + */ +async function reportAiCommandTelemetry( + command: string, + outcome: AiCommandOutcome, + duration: number, + cliOptions: CLIOptions +): Promise { + if (outcome.kind === 'help') { + return; + } + await telemetry( + 'ai-command', + { + command: sanitizeCommandName(command), + success: outcome.kind === 'success', + ...(outcome.kind === 'intercept' && { interceptReason: outcome.reason }), + duration, + }, + // Metadata must describe the target project, consistent with the opt-out resolution. + { configDir: cliOptions.configDir } + ); + if (outcome.kind === 'error') { + await sendTelemetryError(outcome.error, 'ai-command', { cliOptions }); + } +} + +/** Print to stdout, or to the file given via the `ai` command's `-o, --output` option. */ +async function printResult( + { output, exitCode }: AiToolRunResult, + outputPath: string | undefined +): Promise { + if (outputPath) { + const resolvedPath = resolve(outputPath); + await writeFile(resolvedPath, `${output}\n`, 'utf-8'); + logger.log(`Output written to ${resolvedPath}`); + } else { + process.stdout.write(`${output}\n`); + } + if (exitCode !== 0) { + process.exitCode = exitCode; + } +} diff --git a/code/core/src/cli/ai/mcp/registry.test.ts b/code/core/src/cli/ai/mcp/registry.test.ts new file mode 100644 index 000000000000..3e7e742c6c0e --- /dev/null +++ b/code/core/src/cli/ai/mcp/registry.test.ts @@ -0,0 +1,150 @@ +import { readFile, readdir, rm } from 'node:fs/promises'; + +import { vol } from 'memfs'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { readRegistry } from './registry.ts'; + +// Spy-only mock: keep the real `node:fs/promises` module shape, then redirect the calls used by +// the registry reader to `memfs` so disk state stays scoped to `vol`. +vi.mock('node:fs/promises', { spy: true }); + +const REGISTRY_DIR = '/registry'; + +beforeEach(async () => { + const memfs = await vi.importActual('memfs'); + + vi.mocked(readdir).mockImplementation( + memfs.fs.promises.readdir as unknown as typeof import('node:fs/promises').readdir + ); + vi.mocked(readFile).mockImplementation( + memfs.fs.promises.readFile as unknown as typeof import('node:fs/promises').readFile + ); + vi.mocked(rm).mockImplementation( + memfs.fs.promises.rm as unknown as typeof import('node:fs/promises').rm + ); + + // Deterministic PID liveness: in these tests only the current process counts as alive. + vi.spyOn(process, 'kill').mockImplementation((pid) => { + if (pid !== process.pid) { + const error = new Error('ESRCH') as NodeJS.ErrnoException; + error.code = 'ESRCH'; + throw error; + } + return true; + }); +}); + +afterEach(() => { + vol.reset(); + vi.restoreAllMocks(); +}); + +const aliveRecord = { + schemaVersion: 1, + instanceId: 'alive-uuid', + pid: process.pid, + cwd: '/projects/alive', + url: 'http://localhost:6006', + port: 6006, + storybookVersion: '10.5.0', + startedAt: '2026-05-18T12:00:00.000Z', + updatedAt: '2026-05-18T12:00:03.000Z', + mcp: { status: 'ready', endpoint: '/mcp' }, +}; + +describe('readRegistry', () => { + it('returns [] when the registry dir does not exist', async () => { + vol.fromNestedJSON({ '/elsewhere': {} }); + await expect(readRegistry('/registry-does-not-exist')).resolves.toEqual([]); + }); + + it('returns [] when the registry dir is empty', async () => { + vol.fromNestedJSON({ [REGISTRY_DIR]: {} }); + await expect(readRegistry(REGISTRY_DIR)).resolves.toEqual([]); + }); + + it('parses valid records and skips dead PIDs, bad schemas, malformed JSON and non-JSON files', async () => { + const dead = { ...aliveRecord, instanceId: 'dead-uuid', pid: 2147483646 }; + const unknownStatus = { + ...aliveRecord, + instanceId: 'bad-uuid', + mcp: { status: 'unrecognised', endpoint: '/mcp' }, + }; + + vol.fromNestedJSON({ + [REGISTRY_DIR]: { + 'alive.json': JSON.stringify(aliveRecord), + 'dead.json': JSON.stringify(dead), + 'bad-status.json': JSON.stringify(unknownStatus), + 'malformed.json': '{ not json', + 'wrong-shape.json': JSON.stringify({ foo: 'bar' }), + 'ignored.txt': 'should be ignored', + }, + }); + + await expect(readRegistry(REGISTRY_DIR)).resolves.toEqual([aliveRecord]); + }); + + it('removes the registry file of a dead PID', async () => { + const dead = { ...aliveRecord, instanceId: 'dead-uuid', pid: 2147483646 }; + vol.fromNestedJSON({ [REGISTRY_DIR]: { 'dead.json': JSON.stringify(dead) } }); + + await expect(readRegistry(REGISTRY_DIR)).resolves.toEqual([]); + expect(vol.toJSON()[`${REGISTRY_DIR}/dead.json`]).toBeUndefined(); + }); + + it('filters records with non-positive PIDs (process-group sentinels)', async () => { + vol.fromNestedJSON({ + [REGISTRY_DIR]: { + 'zero.json': JSON.stringify({ ...aliveRecord, instanceId: 'zero', pid: 0 }), + 'negative.json': JSON.stringify({ ...aliveRecord, instanceId: 'neg', pid: -1 }), + }, + }); + + await expect(readRegistry(REGISTRY_DIR)).resolves.toEqual([]); + }); + + it('treats EPERM on the liveness signal as alive (foreign-user process)', async () => { + vi.mocked(process.kill).mockImplementation(() => { + const error = new Error('EPERM') as NodeJS.ErrnoException; + error.code = 'EPERM'; + throw error; + }); + vol.fromNestedJSON({ [REGISTRY_DIR]: { 'alive.json': JSON.stringify(aliveRecord) } }); + + await expect(readRegistry(REGISTRY_DIR)).resolves.toEqual([aliveRecord]); + }); + + it('accepts records without the optional version and timestamp fields', async () => { + const minimal = { + schemaVersion: 1, + instanceId: 'minimal', + pid: process.pid, + cwd: '/projects/minimal', + url: 'http://localhost:6007', + port: 6007, + mcp: { status: 'starting' }, + }; + vol.fromNestedJSON({ [REGISTRY_DIR]: { 'minimal.json': JSON.stringify(minimal) } }); + + await expect(readRegistry(REGISTRY_DIR)).resolves.toEqual([minimal]); + }); + + it('accepts records with optional agent provenance', async () => { + const agentRecord = { ...aliveRecord, agent: 'claude-preview' }; + vol.fromNestedJSON({ [REGISTRY_DIR]: { 'agent.json': JSON.stringify(agentRecord) } }); + + await expect(readRegistry(REGISTRY_DIR)).resolves.toEqual([agentRecord]); + }); + + it('rejects out-of-range ports', async () => { + vol.fromNestedJSON({ + [REGISTRY_DIR]: { + 'bad-port.json': JSON.stringify({ ...aliveRecord, port: 65536 }), + }, + }); + + await expect(readRegistry(REGISTRY_DIR)).resolves.toEqual([]); + }); +}); diff --git a/code/core/src/cli/ai/mcp/registry.ts b/code/core/src/cli/ai/mcp/registry.ts new file mode 100644 index 000000000000..9cc702d43ef1 --- /dev/null +++ b/code/core/src/cli/ai/mcp/registry.ts @@ -0,0 +1,82 @@ +import * as fs from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +import * as v from 'valibot'; + +import { type StorybookInstanceRecord, StorybookInstanceRecordSchema } from './types.ts'; + +/** + * Must stay in sync with `getDefaultRuntimeInstanceRegistryDir` in + * `code/core/src/core-server/utils/runtime-instance-registry.ts` (the writer side). Duplicated + * here so this reader does not pull the core-server module graph into the CLI's unit tests; the + * path is specified in storybookjs/storybook#34826. + */ +export const DEFAULT_REGISTRY_DIR = join(homedir(), '.storybook', 'instances'); + +/** + * Errno codes for which we degrade to "no instance" rather than throwing. The command is meant to + * fail-soft for environmental issues; a noisy stack trace would be worse UX than the + * missing-instance repair instructions. + */ +const SOFT_REGISTRY_ERRORS = new Set(['ENOENT', 'EACCES', 'EPERM', 'ENOTDIR']); + +/** + * Read all Storybook instance records from `registryDir`. + * + * Each file is expected to be a single JSON object matching {@link StorybookInstanceRecord}. + * Records whose PID is no longer alive are filtered out (and their files removed). Malformed files + * are skipped silently — the command should degrade to "no instance" rather than fail loudly. + */ +export async function readRegistry( + registryDir: string = DEFAULT_REGISTRY_DIR +): Promise { + let entries: string[]; + try { + entries = await fs.readdir(registryDir); + } catch (error) { + if (SOFT_REGISTRY_ERRORS.has((error as NodeJS.ErrnoException).code ?? '')) { + return []; + } + throw error; + } + + const records = await Promise.all( + entries + .filter((name) => name.endsWith('.json')) + .map(async (name) => { + try { + const raw = await fs.readFile(join(registryDir, name), 'utf-8'); + const parsed = v.safeParse(StorybookInstanceRecordSchema, JSON.parse(raw)); + if (!parsed.success) { + return null; + } + if (!isProcessAlive(parsed.output.pid)) { + await fs.rm(join(registryDir, name), { force: true }).catch(() => {}); + return null; + } + return parsed.output; + } catch { + return null; + } + }) + ); + + return records.filter((r): r is StorybookInstanceRecord => r !== null); +} + +/** + * Liveness check by sending signal 0. `EPERM` means the PID exists but we lack permission to + * signal it (foreign user), which still counts as alive. + */ +function isProcessAlive(pid: number): boolean { + if (!Number.isInteger(pid) || pid <= 0) { + return false; + } + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'EPERM'; + } +} diff --git a/code/core/src/cli/ai/mcp/resolve-instance.test.ts b/code/core/src/cli/ai/mcp/resolve-instance.test.ts new file mode 100644 index 000000000000..10d619549ba2 --- /dev/null +++ b/code/core/src/cli/ai/mcp/resolve-instance.test.ts @@ -0,0 +1,415 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveInstance } from './resolve-instance.ts'; +import type { McpStatus, StorybookInstanceRecord } from './types.ts'; + +let nextInstance = 0; + +function record( + cwd: string, + status: McpStatus = 'ready', + overrides: Partial = {} +): StorybookInstanceRecord { + nextInstance += 1; + return { + schemaVersion: 1, + instanceId: `inst-${nextInstance}`, + pid: 1000 + nextInstance, + cwd, + url: `http://localhost:${6000 + nextInstance}`, + port: 6000 + nextInstance, + mcp: { + status, + endpoint: + status === 'ready' || status === 'error' + ? `http://localhost:${6000 + nextInstance}/mcp` + : undefined, + }, + ...overrides, + }; +} + +describe('resolveInstance', () => { + it('returns no-instance with empty candidates when registry is empty', () => { + const result = resolveInstance([], '/Users/x/projects/foo'); + expect(result).toEqual({ kind: 'intercept', reason: 'no-instance', records: [], matches: [] }); + }); + + it('returns no-instance with candidates when no record cwd matches', () => { + const a = record('/Users/x/projects/foo'); + const b = record('/Users/x/projects/bar'); + const result = resolveInstance([a, b], '/Users/x/projects/baz'); + expect(result.kind).toBe('intercept'); + if (result.kind === 'intercept') { + expect(result.reason).toBe('no-instance'); + expect(result.records).toEqual([a, b]); + } + }); + + it('matches a record by exact normalized cwd', () => { + const r = record('/Users/x/projects/foo'); + const result = resolveInstance([r], '/Users/x/projects/foo'); + expect(result).toEqual({ kind: 'instance', record: r, matches: [r] }); + }); + + it('normalizes trailing slashes and dot segments before matching', () => { + const r = record('/Users/x/projects/foo'); + const result = resolveInstance([r], '/Users/x/projects/foo/./'); + expect(result).toEqual({ kind: 'instance', record: r, matches: [r] }); + }); + + it('does NOT match a child path of a record cwd (exact only)', () => { + const r = record('/Users/x/projects/foo'); + const result = resolveInstance([r], '/Users/x/projects/foo/src/Button.tsx'); + expect(result.kind).toBe('intercept'); + if (result.kind === 'intercept') { + expect(result.reason).toBe('no-instance'); + } + }); + + it('does NOT match a sibling string prefix', () => { + const r = record('/Users/x/projects/foo'); + const result = resolveInstance([r], '/Users/x/projects/foobar'); + expect(result.kind).toBe('intercept'); + if (result.kind === 'intercept') { + expect(result.reason).toBe('no-instance'); + } + }); + + it('tie-breaks on lowest pid when 2+ records share the cwd and none carry a startedAt', () => { + const a = record('/Users/x/projects/foo', 'ready', { pid: 200 }); + const b = record('/Users/x/projects/foo', 'ready', { pid: 100 }); + const result = resolveInstance([a, b], '/Users/x/projects/foo'); + expect(result.kind).toBe('instance'); + if (result.kind === 'instance') { + expect(result.record).toBe(b); + expect(result.matches).toEqual([b, a]); + } + }); + + it('picks the most recently started ready instance when 2+ records share the cwd', () => { + const older = record('/Users/x/projects/foo', 'ready', { + pid: 100, + startedAt: '2026-06-09T10:00:00.000Z', + }); + const newer = record('/Users/x/projects/foo', 'ready', { + pid: 200, + startedAt: '2026-06-09T11:00:00.000Z', + }); + const result = resolveInstance([older, newer], '/Users/x/projects/foo'); + expect(result.kind).toBe('instance'); + if (result.kind === 'instance') { + expect(result.record).toBe(newer); + expect(result.matches).toEqual([newer, older]); + } + }); + + it('treats a record without startedAt as older than one with a startedAt', () => { + const noStamp = record('/Users/x/projects/foo', 'ready', { pid: 100 }); + const stamped = record('/Users/x/projects/foo', 'ready', { + pid: 200, + startedAt: '2026-06-09T11:00:00.000Z', + }); + const result = resolveInstance([noStamp, stamped], '/Users/x/projects/foo'); + expect(result.kind).toBe('instance'); + if (result.kind === 'instance') { + expect(result.record).toBe(stamped); + } + }); + + it('prefers a ready record over a more recently started non-ready one', () => { + const ready = record('/Users/x/projects/foo', 'ready', { + pid: 100, + startedAt: '2026-06-09T10:00:00.000Z', + }); + const newerStarting = record('/Users/x/projects/foo', 'starting', { + pid: 200, + startedAt: '2026-06-09T11:00:00.000Z', + }); + const result = resolveInstance([ready, newerStarting], '/Users/x/projects/foo'); + expect(result.kind).toBe('instance'); + if (result.kind === 'instance') { + expect(result.record).toBe(ready); + } + }); + + it('dispatches the most recently started instance status when none are ready', () => { + const olderError = record('/Users/x/projects/foo', 'error', { + pid: 100, + startedAt: '2026-06-09T10:00:00.000Z', + }); + const newerStarting = record('/Users/x/projects/foo', 'starting', { + pid: 200, + startedAt: '2026-06-09T11:00:00.000Z', + }); + const result = resolveInstance([olderError, newerStarting], '/Users/x/projects/foo'); + expect(result.kind).toBe('intercept'); + if (result.kind === 'intercept') { + expect(result.reason).toBe('mcp-starting'); + } + }); + + it('prefers a ready record over non-ready ones when multiple records share the cwd', () => { + const starting = record('/Users/x/projects/foo', 'starting', { pid: 100 }); + const ready = record('/Users/x/projects/foo', 'ready', { pid: 200 }); + const result = resolveInstance([starting, ready], '/Users/x/projects/foo'); + expect(result.kind).toBe('instance'); + if (result.kind === 'instance') { + expect(result.record).toBe(ready); + expect(result.matches).toEqual([starting, ready]); + } + }); + + it('falls back to dispatching the lowest-pid status when no record at the cwd is ready', () => { + const a = record('/Users/x/projects/foo', 'starting', { pid: 200 }); + const b = record('/Users/x/projects/foo', 'error', { pid: 100 }); + const result = resolveInstance([a, b], '/Users/x/projects/foo'); + expect(result).toEqual({ kind: 'intercept', reason: 'mcp-error', matches: [b, a] }); + }); + + it('dispatches mcp.status=starting as mcp-starting intercept', () => { + const r = record('/p', 'starting'); + const result = resolveInstance([r], '/p'); + expect(result).toEqual({ kind: 'intercept', reason: 'mcp-starting', matches: [r] }); + }); + + it('dispatches mcp.status=not-installed as addon-missing intercept', () => { + const r = record('/p', 'not-installed'); + const result = resolveInstance([r], '/p'); + expect(result).toEqual({ kind: 'intercept', reason: 'addon-missing', matches: [r] }); + }); + + it('dispatches mcp.status=error as mcp-error intercept', () => { + const r = record('/p', 'error'); + const result = resolveInstance([r], '/p'); + expect(result).toEqual({ kind: 'intercept', reason: 'mcp-error', matches: [r] }); + }); + + it('selects the instance matching BOTH cwd and port when a port is supplied', () => { + const a = record('/Users/x/projects/foo', 'ready', { + agent: 'claude-preview', + pid: 100, + port: 6006, + }); + const b = record('/Users/x/projects/foo', 'ready', { agent: 'codex', pid: 200, port: 6007 }); + const result = resolveInstance([a, b], '/Users/x/projects/foo', 6007, 'claude'); + expect(result.kind).toBe('instance'); + if (result.kind === 'instance') { + expect(result.record).toBe(b); + expect(result.matches).toEqual([b]); + } + }); + + it('prefers Claude preview records for Claude CLI invocations', () => { + const genericClaude = record('/Users/x/projects/foo', 'ready', { + agent: 'claude', + startedAt: '2026-06-09T11:00:00.000Z', + }); + const claudePreview = record('/Users/x/projects/foo', 'ready', { + agent: 'claude-preview', + startedAt: '2026-06-09T10:00:00.000Z', + }); + const newerCodex = record('/Users/x/projects/foo', 'ready', { + agent: 'codex', + startedAt: '2026-06-09T12:00:00.000Z', + }); + const result = resolveInstance( + [genericClaude, claudePreview, newerCodex], + '/Users/x/projects/foo', + undefined, + 'claude' + ); + + expect(result.kind).toBe('instance'); + if (result.kind === 'instance') { + expect(result.record).toBe(claudePreview); + expect(result.matches).toEqual([claudePreview]); + } + }); + + it('falls back to generic Claude records when no Claude preview record matches', () => { + const genericClaude = record('/Users/x/projects/foo', 'ready', { + agent: 'claude', + startedAt: '2026-06-09T10:00:00.000Z', + }); + const newerCodex = record('/Users/x/projects/foo', 'ready', { + agent: 'codex', + startedAt: '2026-06-09T11:00:00.000Z', + }); + const result = resolveInstance( + [genericClaude, newerCodex], + '/Users/x/projects/foo', + undefined, + 'claude' + ); + + expect(result.kind).toBe('instance'); + if (result.kind === 'instance') { + expect(result.record).toBe(genericClaude); + expect(result.matches).toEqual([genericClaude]); + } + }); + + it('stays in the preferred agent bucket even when another bucket is ready', () => { + const startingPreview = record('/Users/x/projects/foo', 'starting', { + agent: 'claude-preview', + startedAt: '2026-06-09T11:00:00.000Z', + }); + const readyCodex = record('/Users/x/projects/foo', 'ready', { + agent: 'codex', + startedAt: '2026-06-09T10:00:00.000Z', + }); + const result = resolveInstance( + [startingPreview, readyCodex], + '/Users/x/projects/foo', + undefined, + 'claude' + ); + + expect(result.kind).toBe('intercept'); + if (result.kind === 'intercept') { + expect(result.reason).toBe('mcp-starting'); + expect(result.matches).toEqual([startingPreview]); + } + }); + + it('reports errors from the preferred agent bucket instead of switching buckets', () => { + const erroredPreview = record('/Users/x/projects/foo', 'error', { + agent: 'claude-preview', + startedAt: '2026-06-09T11:00:00.000Z', + }); + const readyClaude = record('/Users/x/projects/foo', 'ready', { + agent: 'claude', + startedAt: '2026-06-09T10:00:00.000Z', + }); + const result = resolveInstance( + [erroredPreview, readyClaude], + '/Users/x/projects/foo', + undefined, + 'claude' + ); + + expect(result.kind).toBe('intercept'); + if (result.kind === 'intercept') { + expect(result.reason).toBe('mcp-error'); + expect(result.matches).toEqual([erroredPreview]); + } + }); + + it('prefers records matching the current non-Claude agent', () => { + const codex = record('/Users/x/projects/foo', 'ready', { + agent: 'codex', + startedAt: '2026-06-09T10:00:00.000Z', + }); + const newerCursor = record('/Users/x/projects/foo', 'ready', { + agent: 'cursor', + startedAt: '2026-06-09T11:00:00.000Z', + }); + const result = resolveInstance( + [codex, newerCursor], + '/Users/x/projects/foo', + undefined, + 'codex' + ); + + expect(result.kind).toBe('instance'); + if (result.kind === 'instance') { + expect(result.record).toBe(codex); + expect(result.matches).toEqual([codex]); + } + }); + + it('chooses the latest-started ready instance inside the selected agent bucket', () => { + const olderPreview = record('/Users/x/projects/foo', 'ready', { + agent: 'claude-preview', + startedAt: '2026-06-09T10:00:00.000Z', + }); + const newerPreview = record('/Users/x/projects/foo', 'ready', { + agent: 'claude-preview', + startedAt: '2026-06-09T11:00:00.000Z', + }); + const newestCodex = record('/Users/x/projects/foo', 'ready', { + agent: 'codex', + startedAt: '2026-06-09T12:00:00.000Z', + }); + const result = resolveInstance( + [olderPreview, newerPreview, newestCodex], + '/Users/x/projects/foo', + undefined, + 'claude' + ); + + expect(result.kind).toBe('instance'); + if (result.kind === 'instance') { + expect(result.record).toBe(newerPreview); + expect(result.matches).toEqual([newerPreview, olderPreview]); + } + }); + + it('falls back to latest-started behavior when no record matches the current agent', () => { + const older = record('/Users/x/projects/foo', 'ready', { + startedAt: '2026-06-09T10:00:00.000Z', + }); + const newer = record('/Users/x/projects/foo', 'ready', { + agent: 'cursor', + startedAt: '2026-06-09T11:00:00.000Z', + }); + const result = resolveInstance([older, newer], '/Users/x/projects/foo', undefined, 'codex'); + + expect(result.kind).toBe('instance'); + if (result.kind === 'instance') { + expect(result.record).toBe(newer); + expect(result.matches).toEqual([newer, older]); + } + }); + + it('falls back to latest-started behavior when no current agent is detected', () => { + const olderPreview = record('/Users/x/projects/foo', 'ready', { + agent: 'claude-preview', + startedAt: '2026-06-09T10:00:00.000Z', + }); + const newerCodex = record('/Users/x/projects/foo', 'ready', { + agent: 'codex', + startedAt: '2026-06-09T11:00:00.000Z', + }); + const result = resolveInstance([olderPreview, newerCodex], '/Users/x/projects/foo'); + + expect(result.kind).toBe('instance'); + if (result.kind === 'instance') { + expect(result.record).toBe(newerCodex); + expect(result.matches).toEqual([newerCodex, olderPreview]); + } + }); + + it('ignores port when it is not supplied (routes by cwd alone)', () => { + const a = record('/Users/x/projects/foo', 'ready', { pid: 100, port: 6006 }); + const b = record('/Users/x/projects/foo', 'ready', { pid: 200, port: 6007 }); + const result = resolveInstance([a, b], '/Users/x/projects/foo'); + expect(result.kind).toBe('instance'); + if (result.kind === 'instance') { + expect(result.record).toBe(a); + expect(result.matches).toEqual([a, b]); + } + }); + + it('returns port-mismatch with the cwd instances as candidates when cwd matches but no instance is on the port', () => { + const a = record('/Users/x/projects/foo', 'ready', { pid: 100, port: 6006 }); + const b = record('/Users/x/projects/foo', 'ready', { pid: 200, port: 6007 }); + const result = resolveInstance([a, b], '/Users/x/projects/foo', 9999); + expect(result.kind).toBe('intercept'); + if (result.kind === 'intercept') { + expect(result.reason).toBe('port-mismatch'); + expect(result.records).toEqual([a, b]); + expect(result.matches).toEqual([]); + } + }); + + it('returns no-instance (not port-mismatch) when the cwd itself does not match', () => { + const a = record('/Users/x/projects/foo', 'ready', { port: 6006 }); + const result = resolveInstance([a], '/Users/x/projects/bar', 6006); + expect(result.kind).toBe('intercept'); + if (result.kind === 'intercept') { + expect(result.reason).toBe('no-instance'); + } + }); +}); diff --git a/code/core/src/cli/ai/mcp/resolve-instance.ts b/code/core/src/cli/ai/mcp/resolve-instance.ts new file mode 100644 index 000000000000..dd038748a72e --- /dev/null +++ b/code/core/src/cli/ai/mcp/resolve-instance.ts @@ -0,0 +1,159 @@ +import { resolve } from 'node:path'; + +import { + CLAUDE_AGENT_NAME, + CLAUDE_PREVIEW_AGENT_NAME, +} from '../../../shared/constants/agent-provenance.ts'; +import type { InterceptReason, StorybookInstanceRecord } from './types.ts'; + +export type ResolveResult = + | { + kind: 'instance'; + record: StorybookInstanceRecord; + matches: StorybookInstanceRecord[]; + } + | { + kind: 'intercept'; + reason: InterceptReason; + records?: StorybookInstanceRecord[]; + matches: StorybookInstanceRecord[]; + }; + +/** + * Pick the Storybook instance whose cwd exactly matches `targetCwd` after normalisation. Per + * milestone 2 of storybookjs/storybook#34826: matching is exact-normalized, with no longest-prefix + * or fallback behaviour. + * + * When `targetPort` is supplied (e.g. an agent that launched Storybook on a known port and wants + * to address that exact instance), it further constrains the cwd matches: an instance must match + * BOTH cwd and port. If the cwd matches but no instance there is on `targetPort`, a + * `port-mismatch` intercept is returned with the cwd's instances as candidates so callers can + * surface the running ports. + * + * If at least one record matches, dispatch based on the selected instance's `mcp.status`: + * + * - `ready` → forward the call + * - `starting` → mcp-starting intercept + * - `not-installed` → addon-missing intercept + * - `error` → mcp-error intercept + * + * Zero matches → no-instance intercept (callers may surface running cwds). 2+ matches at the same + * cwd → use the current agent to select the competing bucket, then pick the most recently started + * instance in that bucket (latest `startedAt` among `ready` records, else latest overall). Records + * without a `startedAt` tie-break on lowest pid for determinism. The selected bucket is returned + * (most-recent first) as `matches` so callers can warn only about instances that competed. + */ +export function resolveInstance( + records: StorybookInstanceRecord[], + targetCwd: string, + targetPort?: number, + currentAgent?: string +): ResolveResult { + const normalisedTarget = resolve(targetCwd); + const cwdMatches = records.filter((r) => resolve(r.cwd) === normalisedTarget); + const matches = targetPort == null ? cwdMatches : cwdMatches.filter((r) => r.port === targetPort); + + if (matches.length === 0) { + // cwd matched, but no instance there is on the requested port: a distinct, + // more actionable failure than "nothing is running here". + if (targetPort != null && cwdMatches.length > 0) { + return { + kind: 'intercept', + reason: 'port-mismatch', + records: cwdMatches, + matches: [], + }; + } + return { + kind: 'intercept', + reason: 'no-instance', + records, + matches: [], + }; + } + + const sortedMatches = selectCompetingBucket(matches, targetPort, currentAgent); + const selected = sortedMatches.find((r) => r.mcp.status === 'ready') ?? sortedMatches[0]; + + switch (selected.mcp.status) { + case 'ready': + return { + kind: 'instance', + record: selected, + matches: sortedMatches, + }; + + case 'starting': + return { + kind: 'intercept', + reason: 'mcp-starting', + matches: sortedMatches, + }; + + case 'not-installed': + return { + kind: 'intercept', + reason: 'addon-missing', + matches: sortedMatches, + }; + + case 'error': + return { + kind: 'intercept', + reason: 'mcp-error', + matches: sortedMatches, + }; + + default: { + const unhandled: never = selected.mcp.status; + throw new Error(`Unhandled MCP status: ${unhandled as string}`); + } + } +} + +function selectCompetingBucket( + matches: StorybookInstanceRecord[], + targetPort: number | undefined, + currentAgent: string | undefined +) { + if (targetPort != null) { + return [...matches].sort(byMostRecentlyStarted); + } + + // std-env reports Claude CLI as `claude`; preview-launched Storybooks record `claude-preview`. + const agentBuckets = + currentAgent === CLAUDE_AGENT_NAME + ? [CLAUDE_PREVIEW_AGENT_NAME, CLAUDE_AGENT_NAME] + : currentAgent + ? [currentAgent] + : []; + const selectedAgent = agentBuckets.find((agent) => matches.some((r) => r.agent === agent)); + const bucket = selectedAgent ? matches.filter((r) => r.agent === selectedAgent) : matches; + + return [...bucket].sort(byMostRecentlyStarted); +} + +/** + * `startedAt` as epoch millis, or `-Infinity` when absent/unparseable so such records sort as the + * oldest (and fall through to the pid tie-break). + */ +function startedAtMs(r: StorybookInstanceRecord): number { + if (!r.startedAt) { + return Number.NEGATIVE_INFINITY; + } + const t = Date.parse(r.startedAt); + return Number.isNaN(t) ? Number.NEGATIVE_INFINITY : t; +} + +/** + * Sort comparator: most recently started first, tie-breaking on lowest pid so ordering stays + * deterministic when timestamps are equal or missing. + */ +function byMostRecentlyStarted(a: StorybookInstanceRecord, b: StorybookInstanceRecord): number { + const ta = startedAtMs(a); + const tb = startedAtMs(b); + if (ta !== tb) { + return tb > ta ? 1 : -1; + } + return a.pid - b.pid; +} diff --git a/code/core/src/cli/ai/mcp/run-tool.test.ts b/code/core/src/cli/ai/mcp/run-tool.test.ts new file mode 100644 index 000000000000..fae044a254ac --- /dev/null +++ b/code/core/src/cli/ai/mcp/run-tool.test.ts @@ -0,0 +1,799 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { resolve } from 'node:path'; +import { McpJsonRpcError, callMcpTool, listMcpTools } from './client.ts'; +import { loadStorybookAiMetadata, type StorybookAiMetadata } from './local-metadata.ts'; +import { readRegistry } from './registry.ts'; +import { buildStorybookCommandsHelp, runAiTool, runAiToolHelp } from './run-tool.ts'; +import type { StorybookInstanceRecord } from './types.ts'; + +vi.mock('./registry.ts', { spy: true }); +vi.mock('./client.ts', { spy: true }); +vi.mock('./local-metadata.ts', { spy: true }); + +const record: StorybookInstanceRecord = { + schemaVersion: 1, + instanceId: 'inst-1', + pid: 1, + cwd: '/projects/foo', + url: 'http://localhost:6006', + port: 6006, + mcp: { status: 'ready', endpoint: '/mcp' }, +}; + +const defaultRuntimeMetadata: StorybookAiMetadata = { + instructions: 'Follow the story workflow.', + tools: [ + { name: 'list-all-documentation', description: 'List docs' }, + { name: 'get-documentation', description: 'Get docs.' }, + ], +}; + +beforeEach(() => { + vi.mocked(readRegistry).mockReset().mockResolvedValue([record]); + vi.mocked(callMcpTool) + .mockReset() + .mockResolvedValue({ content: [{ type: 'text', text: 'upstream result' }] }); + vi.mocked(listMcpTools) + .mockReset() + .mockResolvedValue([{ name: 'list-all-documentation', description: 'List docs' }]); + vi.mocked(loadStorybookAiMetadata).mockReset().mockResolvedValue(defaultRuntimeMetadata); +}); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe('runAiTool', () => { + it('forwards the call to the matching instance and prints the markdown result', async () => { + const result = await runAiTool('list-all-documentation', ['--withStoryIds', 'true'], { + cwd: '/projects/foo', + }); + + expect(callMcpTool).toHaveBeenCalledWith( + record, + { name: 'list-all-documentation', arguments: { withStoryIds: true } }, + undefined + ); + expect(result).toEqual({ + exitCode: 0, + output: 'upstream result', + outcome: { kind: 'success' }, + }); + expect(loadStorybookAiMetadata).toHaveBeenCalledWith({ + cwd: resolve('/projects/foo'), + configDir: resolve('/projects/foo/.storybook'), + }); + }); + + it('runs local tools from Storybook AI metadata without contacting the MCP server', async () => { + vi.mocked(loadStorybookAiMetadata).mockResolvedValue({ + instructions: 'Follow the story workflow.', + tools: [{ name: 'get-storybook-story-instructions', description: 'Get story guidance' }], + localTools: { + 'get-storybook-story-instructions': { + call: vi.fn().mockResolvedValue({ + content: [{ type: 'text', text: 'local story instructions' }], + }), + }, + }, + }); + + const result = await runAiTool('get-storybook-story-instructions', [], { + cwd: '/projects/foo', + }); + + expect(result).toEqual({ + exitCode: 0, + output: 'local story instructions', + outcome: { kind: 'success' }, + }); + expect(readRegistry).not.toHaveBeenCalled(); + expect(callMcpTool).not.toHaveBeenCalled(); + }); + + it('runs any metadata-declared local tool locally even when a Storybook server is ready', async () => { + vi.mocked(loadStorybookAiMetadata).mockResolvedValue({ + instructions: 'Follow the story workflow.', + tools: [{ name: 'custom-local-command', description: 'Run custom local work' }], + localTools: { + 'custom-local-command': { + call: vi.fn().mockResolvedValue({ + content: [{ type: 'text', text: 'custom local result' }], + }), + }, + }, + }); + + const result = await runAiTool('custom-local-command', [], { + cwd: '/projects/foo', + }); + + expect(result.output).toBe('custom local result'); + expect(readRegistry).not.toHaveBeenCalled(); + expect(callMcpTool).not.toHaveBeenCalled(); + }); + + it('loads known local tools from a custom config dir option', async () => { + vi.mocked(loadStorybookAiMetadata).mockResolvedValue({ + instructions: 'Follow the story workflow.', + tools: [{ name: 'get-storybook-story-instructions', description: 'Get story guidance' }], + localTools: { + 'get-storybook-story-instructions': { + call: vi.fn().mockResolvedValue({ + content: [{ type: 'text', text: 'custom config instructions' }], + }), + }, + }, + }); + + const result = await runAiTool('get-storybook-story-instructions', [], { + cwd: '/projects/foo', + configDir: 'config/storybook', + }); + + expect(result.output).toBe('custom config instructions'); + expect(loadStorybookAiMetadata).toHaveBeenCalledWith({ + cwd: resolve('/projects/foo'), + configDir: resolve('/projects/foo/config/storybook'), + }); + expect(readRegistry).not.toHaveBeenCalled(); + }); + + it('ignores --port validation for metadata-declared local tools', async () => { + vi.mocked(loadStorybookAiMetadata).mockResolvedValue({ + instructions: 'Follow the story workflow.', + tools: [{ name: 'get-storybook-story-instructions', description: 'Get story guidance' }], + localTools: { + 'get-storybook-story-instructions': { + call: vi.fn().mockResolvedValue({ + content: [{ type: 'text', text: 'local story instructions' }], + }), + }, + }, + }); + + const result = await runAiTool('get-storybook-story-instructions', ['--port', 'abc'], { + cwd: '/projects/foo', + }); + + expect(result).toEqual({ + exitCode: 0, + output: 'local story instructions', + outcome: { kind: 'success' }, + }); + expect(readRegistry).not.toHaveBeenCalled(); + }); + + it('surfaces metadata loading errors before checking for a server', async () => { + vi.mocked(loadStorybookAiMetadata).mockRejectedValue(new Error('main config failed')); + + const result = await runAiTool('get-storybook-story-instructions', [], { + cwd: '/projects/foo', + }); + + expect(result.exitCode).toBe(1); + expect(result.output).toContain('Storybook command metadata is unavailable'); + expect(result.output).toContain('main config failed'); + expect(result.outcome).toEqual({ + kind: 'error', + error: expect.objectContaining({ name: 'LocalAiToolError' }), + }); + expect(readRegistry).not.toHaveBeenCalled(); + expect(callMcpTool).not.toHaveBeenCalled(); + }); + + it('surfaces local tool error results with the stable MCP error wrapper', async () => { + vi.mocked(loadStorybookAiMetadata).mockResolvedValue({ + instructions: 'Follow the story workflow.', + tools: [{ name: 'get-storybook-story-instructions', description: 'Get story guidance' }], + localTools: { + 'get-storybook-story-instructions': { + call: vi.fn().mockResolvedValue({ + content: [{ type: 'text', text: 'local failure' }], + isError: true, + }), + }, + }, + }); + + const result = await runAiTool('get-storybook-story-instructions', [], { + cwd: '/projects/foo', + }); + + expect(result).toEqual({ + exitCode: 1, + output: 'local failure', + outcome: { kind: 'error', error: expect.objectContaining({ name: 'McpToolResultError' }) }, + }); + }); + + it('wraps thrown local tool errors with a stable local command error', async () => { + const error = new Error('local command exploded'); + vi.mocked(loadStorybookAiMetadata).mockResolvedValue({ + instructions: 'Follow the story workflow.', + tools: [{ name: 'get-storybook-story-instructions', description: 'Get story guidance' }], + localTools: { + 'get-storybook-story-instructions': { + call: vi.fn().mockRejectedValue(error), + }, + }, + }); + + const result = await runAiTool('get-storybook-story-instructions', [], { + cwd: '/projects/foo', + }); + + expect(result.exitCode).toBe(1); + expect(result.output).toBe('local command exploded'); + expect(result.outcome).toEqual({ + kind: 'error', + error: expect.objectContaining({ name: 'LocalAiToolError', cause: error }), + }); + }); + + it('defaults the cwd to process.cwd()', async () => { + vi.mocked(readRegistry).mockResolvedValue([{ ...record, cwd: process.cwd() }]); + const result = await runAiTool('list-all-documentation', []); + expect(result.exitCode).toBe(0); + }); + + it('merges --json arguments with --key overrides', async () => { + await runAiTool('get-documentation', ['--id', 'override'], { + cwd: '/projects/foo', + json: '{"id":"base","verbose":true}', + }); + + expect(callMcpTool).toHaveBeenCalledWith( + record, + { name: 'get-documentation', arguments: { id: 'override', verbose: true } }, + undefined + ); + }); + + it('returns the arg-parsing error without contacting the registry', async () => { + const result = await runAiTool('get-documentation', ['positional'], { cwd: '/projects/foo' }); + expect(result.exitCode).toBe(1); + expect(result.output).toContain('Unexpected argument'); + expect(result.outcome).toEqual({ kind: 'intercept', reason: 'invalid-arguments' }); + expect(readRegistry).not.toHaveBeenCalled(); + }); + + it('prints the no-instance repair markdown and exits non-zero when nothing runs at the cwd', async () => { + vi.mocked(loadStorybookAiMetadata).mockResolvedValue({ + instructions: 'Follow the story workflow.', + tools: [{ name: 'get-documentation', description: 'Get docs.' }], + }); + + const result = await runAiTool('get-documentation', [], { cwd: '/projects/other' }); + expect(result.exitCode).toBe(1); + expect(result.output).toContain('No Storybook is running at this cwd'); + expect(result.output).toContain('/projects/foo'); + expect(result.outcome).toEqual({ kind: 'intercept', reason: 'no-instance' }); + }); + + it('prints metadata upgrade guidance when no metadata exists even if a server is ready', async () => { + vi.mocked(loadStorybookAiMetadata).mockResolvedValue(undefined); + + const result = await runAiTool('get-storybook-story-instructions', [], { + cwd: '/projects/foo', + }); + + expect(result.exitCode).toBe(1); + expect(result.output).toContain('Storybook command metadata is unavailable'); + expect(result.output).toContain('@storybook/addon-mcp'); + expect(result.outcome).toEqual({ kind: 'intercept', reason: 'addon-missing' }); + expect(readRegistry).not.toHaveBeenCalled(); + expect(callMcpTool).not.toHaveBeenCalled(); + }); + + it('routes to the instance on the requested --port when several share the cwd', async () => { + const onOtherPort = { ...record, instanceId: 'inst-2', pid: 2, port: 6007 }; + vi.mocked(readRegistry).mockResolvedValue([record, onOtherPort]); + const result = await runAiTool('list-all-documentation', [], { + cwd: '/projects/foo', + port: '6007', + }); + expect(callMcpTool).toHaveBeenCalledWith(onOtherPort, expect.anything(), undefined); + expect(result.exitCode).toBe(0); + }); + + it('prints the port-mismatch repair markdown when no instance at the cwd is on the port', async () => { + const result = await runAiTool('list-all-documentation', [], { + cwd: '/projects/foo', + port: '9999', + }); + expect(result.exitCode).toBe(1); + expect(result.output).toContain('not on port `9999`'); + expect(result.output).toContain('- port `6006`'); + expect(result.outcome).toEqual({ kind: 'intercept', reason: 'port-mismatch' }); + }); + + it('rejects an invalid --port without contacting the registry', async () => { + const result = await runAiTool('list-all-documentation', [], { + cwd: '/projects/foo', + port: 'abc', + }); + expect(result.exitCode).toBe(1); + expect(result.output).toContain('`--port` must be a port number'); + expect(readRegistry).not.toHaveBeenCalled(); + }); + + it.each([ + ['starting', 'still starting up', 'mcp-starting'], + ['not-installed', '`@storybook/addon-mcp` addon is missing', 'addon-missing'], + ['error', 'Inspect the Storybook terminal output', 'mcp-error'], + ] as const)('prints the repair markdown for mcp.status=%s', async (status, expected, reason) => { + vi.mocked(readRegistry).mockResolvedValue([{ ...record, mcp: { status } }]); + const result = await runAiTool('get-documentation', [], { cwd: '/projects/foo' }); + expect(result.exitCode).toBe(1); + expect(result.output).toContain(expected); + expect(result.outcome).toEqual({ kind: 'intercept', reason }); + }); + + it('prints a placeholder when the tool returns no content', async () => { + vi.mocked(callMcpTool).mockResolvedValue({ content: [] }); + const result = await runAiTool('list-all-documentation', [], { cwd: '/projects/foo' }); + expect(result).toEqual({ + exitCode: 0, + output: '(the command returned no content)', + outcome: { kind: 'success' }, + }); + }); + + it('surfaces a clean error when a ready record is missing its endpoint', async () => { + vi.mocked(callMcpTool).mockRejectedValue( + new Error('The Storybook instance at /projects/foo has no server endpoint registered') + ); + vi.mocked(readRegistry).mockResolvedValue([{ ...record, mcp: { status: 'ready' } }]); + const result = await runAiTool('list-all-documentation', [], { cwd: '/projects/foo' }); + expect(result.exitCode).toBe(1); + expect(result.output).toContain('Failed to reach the Storybook server at (no endpoint)'); + }); + + it('renders non-text content items as JSON blocks', async () => { + vi.mocked(callMcpTool).mockResolvedValue({ + content: [ + { type: 'text', text: 'intro' }, + { type: 'resource_link', uri: 'http://x' }, + ], + }); + const result = await runAiTool('get-documentation', [], { cwd: '/projects/foo' }); + expect(result.output).toContain('intro'); + expect(result.output).toContain('```json'); + expect(result.output).toContain('"resource_link"'); + }); + + it('does not call live MCP for commands hidden by preset metadata', async () => { + const result = await runAiTool('run-story-tests', [], { cwd: '/projects/foo' }); + + expect(result.exitCode).toBe(1); + expect(result.output).toContain('Unknown command `run-story-tests`'); + expect(result.output).toContain('- `list-all-documentation`'); + expect(result.output).not.toContain('- `run-story-tests`'); + expect(result.outcome).toEqual({ kind: 'intercept', reason: 'unknown-command' }); + expect(readRegistry).not.toHaveBeenCalled(); + expect(callMcpTool).not.toHaveBeenCalled(); + }); + + it('lists the server tools when a metadata-visible runtime command is missing server-side', async () => { + vi.mocked(loadStorybookAiMetadata).mockResolvedValue({ + ...defaultRuntimeMetadata, + tools: [...defaultRuntimeMetadata.tools, { name: 'no-such-tool', description: 'Stale tool' }], + }); + vi.mocked(callMcpTool).mockRejectedValue(new McpJsonRpcError(-32601, 'unknown tool')); + + const result = await runAiTool('no-such-tool', [], { cwd: '/projects/foo' }); + + expect(result.exitCode).toBe(1); + expect(result.output).toContain('Unknown command `no-such-tool`'); + expect(result.output).toContain('- `list-all-documentation`'); + expect(result.outcome).toEqual({ kind: 'intercept', reason: 'unknown-command' }); + }); + + it('lists the available tools when the server reports the unknown tool as an error result', async () => { + vi.mocked(loadStorybookAiMetadata).mockResolvedValue({ + ...defaultRuntimeMetadata, + tools: [...defaultRuntimeMetadata.tools, { name: 'no-such-tool', description: 'Stale tool' }], + }); + // addon-mcp (tmcp) reports unknown tools as an isError result, not a JSON-RPC error. + vi.mocked(callMcpTool).mockResolvedValue({ + content: [{ type: 'text', text: 'Tool no-such-tool not found' }], + isError: true, + }); + const result = await runAiTool('no-such-tool', [], { cwd: '/projects/foo' }); + expect(result.exitCode).toBe(1); + expect(result.output).toContain('Unknown command `no-such-tool`'); + expect(result.output).toContain('- `list-all-documentation`'); + expect(result.outcome).toEqual({ kind: 'intercept', reason: 'unknown-command' }); + }); + + it('keeps the original error result when the failing tool does exist', async () => { + vi.mocked(callMcpTool).mockResolvedValue({ + content: [{ type: 'text', text: 'tests failed' }], + isError: true, + }); + const result = await runAiTool('list-all-documentation', [], { cwd: '/projects/foo' }); + expect(result).toEqual({ + exitCode: 1, + output: 'tests failed', + outcome: { kind: 'error', error: expect.objectContaining({ name: 'McpToolResultError' }) }, + }); + // Constant message keeps the telemetry error hash aggregatable; the tool's error text only + // travels as `cause` (uploaded path-sanitized, and only with crash-reports consent). + const error = (result.outcome as { error: Error }).error; + expect(error.message).toBe('The Storybook AI command returned an error result'); + expect(error.cause).toBe('tests failed'); + }); + + it('prints the original JSON-RPC error when the tool exists', async () => { + const error = new McpJsonRpcError(-32602, 'invalid arguments'); + vi.mocked(callMcpTool).mockRejectedValue(error); + const result = await runAiTool('list-all-documentation', [], { cwd: '/projects/foo' }); + expect(result.exitCode).toBe(1); + expect(result.output).toContain('Storybook server error -32602: invalid arguments'); + expect(result.outcome).toEqual({ kind: 'error', error }); + }); + + it('prints the original JSON-RPC error when the tool list cannot be fetched', async () => { + const error = new McpJsonRpcError(-32601, 'unknown tool'); + vi.mocked(loadStorybookAiMetadata).mockResolvedValue({ + ...defaultRuntimeMetadata, + tools: [...defaultRuntimeMetadata.tools, { name: 'no-such-tool', description: 'Stale tool' }], + }); + vi.mocked(callMcpTool).mockRejectedValue(error); + vi.mocked(listMcpTools).mockRejectedValue(new Error('boom')); + const result = await runAiTool('no-such-tool', [], { cwd: '/projects/foo' }); + expect(result.exitCode).toBe(1); + expect(result.output).toContain('Storybook server error -32601: unknown tool'); + expect(result.outcome).toEqual({ kind: 'error', error }); + }); + + it('surfaces a friendly error when the MCP server is unreachable', async () => { + const error = new Error('connection refused'); + vi.mocked(callMcpTool).mockRejectedValue(error); + const result = await runAiTool('get-documentation', [], { cwd: '/projects/foo' }); + expect(result.exitCode).toBe(1); + expect(result.output).toContain('Failed to reach the Storybook server at /mcp'); + expect(result.output).toContain('connection refused'); + expect(result.outcome).toEqual({ kind: 'error', error }); + }); + + it('prepends a warning when multiple instances run at the same cwd', async () => { + const sibling = { ...record, instanceId: 'inst-2', pid: 2, url: 'http://localhost:6007' }; + vi.mocked(readRegistry).mockResolvedValue([record, sibling]); + const result = await runAiTool('list-all-documentation', [], { cwd: '/projects/foo' }); + expect(result.exitCode).toBe(0); + expect(result.output).toContain('Multiple matching Storybook instances'); + expect(result.output).toContain('Matching instances at `/projects/foo`'); + expect(result.output).toContain('pid `1`'); + expect(result.output).toContain('pid `2`'); + expect(result.output).toContain('(used)'); + expect(result.output).toContain('upstream result'); + }); + + describe('when multiple instances exist in the selected agent bucket', () => { + const olderPreview = { + ...record, + agent: 'claude-preview', + instanceId: 'inst-2', + pid: 2, + port: 6007, + startedAt: '2026-06-09T10:00:00.000Z', + url: 'http://localhost:6007', + }; + const selectedPreview = { + ...record, + agent: 'claude-preview', + instanceId: 'inst-3', + pid: 3, + port: 6008, + startedAt: '2026-06-09T11:00:00.000Z', + url: 'http://localhost:6008', + }; + const newerCodex = { + ...record, + agent: 'codex', + instanceId: 'inst-4', + pid: 4, + port: 6009, + startedAt: '2026-06-09T12:00:00.000Z', + url: 'http://localhost:6009', + }; + + beforeEach(() => { + vi.stubEnv('AI_AGENT', 'claude'); + vi.mocked(readRegistry).mockResolvedValue([olderPreview, selectedPreview, newerCodex]); + }); + + it('only warns about instances in the selected agent bucket', async () => { + const result = await runAiTool('list-all-documentation', [], { cwd: '/projects/foo' }); + + expect(callMcpTool).toHaveBeenCalledWith( + selectedPreview, + { name: 'list-all-documentation', arguments: {} }, + undefined + ); + expect(result.exitCode).toBe(0); + expect(result.output).toContain('Multiple matching Storybook instances'); + expect(result.output).toContain('Matching instances at `/projects/foo`'); + expect(result.output).toContain('pid `2`'); + expect(result.output).toContain('pid `3`'); + expect(result.output).not.toContain('pid `4`'); + expect(result.output).not.toContain('http://localhost:6009'); + }); + }); +}); + +describe('buildStorybookCommandsHelp', () => { + it('lists each tool with the first line of its description', async () => { + vi.mocked(loadStorybookAiMetadata).mockResolvedValue({ + tools: [ + { + name: 'get-documentation', + description: 'Get docs for a component.\n\nLong details that should not appear.', + }, + { name: 'list-all-documentation' }, + { name: 'get-storybook-story-instructions', description: 'Get story guidance.' }, + ], + localTools: { + 'get-storybook-story-instructions': { + call: vi.fn().mockResolvedValue({ content: [] }), + }, + }, + instructions: 'Follow the story workflow.', + }); + + const section = await buildStorybookCommandsHelp({ cwd: '/projects/foo' }); + expect(section).toContain( + `Storybook help from the Storybook configuration at ${resolve('/projects/foo/.storybook')}:` + ); + expect(section).toContain('# Storybook commands'); + expect(section).toContain('get-documentation'); + expect(section).toContain('[requires Storybook] Get docs for a component.'); + expect(section).toContain('get-storybook-story-instructions [local]'); + expect(section).toContain('Get docs for a component.'); + expect(section).not.toContain('Long details'); + expect(section).toContain("Run 'storybook ai --help'"); + expect(readRegistry).not.toHaveBeenCalled(); + }); + + it('prints workflow instructions before the dynamic commands list', async () => { + vi.mocked(loadStorybookAiMetadata).mockResolvedValue({ + tools: [{ name: 'get-documentation', description: 'Get docs for a component.' }], + instructions: 'Use existing stories as examples.\nRun tests after writing stories.', + }); + + const section = await buildStorybookCommandsHelp({ cwd: '/projects/foo' }); + expect(section).toBe( + [ + `Storybook help from the Storybook configuration at ${resolve('/projects/foo/.storybook')}:`, + '', + '# Storybook workflow instructions', + '', + 'Use existing stories as examples.', + 'Run tests after writing stories.', + '', + '# Storybook commands', + '', + ' get-documentation [requires Storybook] Get docs for a component.', + '', + '[local] commands run from configuration metadata without a running Storybook.', + '[requires Storybook] commands are forwarded to the running Storybook server.', + '', + "Run 'storybook ai --help' for a command's description and arguments.", + ].join('\n') + ); + }); + + it('degrades to a note when the metadata preset is unavailable', async () => { + vi.mocked(loadStorybookAiMetadata).mockResolvedValue(undefined); + const section = await buildStorybookCommandsHelp({ cwd: '/projects/foo' }); + expect(section).toContain('Storybook commands: (unavailable'); + expect(section).toContain('does not expose AI command metadata'); + expect(section).toContain('@storybook/addon-mcp'); + }); + + it('degrades to a note when metadata loading fails', async () => { + vi.mocked(loadStorybookAiMetadata).mockRejectedValue(new Error('main config failed')); + const section = await buildStorybookCommandsHelp({ cwd: '/projects/foo' }); + expect(section).toContain('Storybook commands: (unavailable'); + expect(section).toContain('could not be loaded'); + }); + + it('degrades to a note when no tools are exposed', async () => { + vi.mocked(loadStorybookAiMetadata).mockResolvedValue({ + tools: [], + instructions: '', + }); + const section = await buildStorybookCommandsHelp({ cwd: '/projects/foo' }); + expect(section).toContain('provides no commands'); + }); + + it('still lists commands when workflow instructions are absent', async () => { + vi.mocked(loadStorybookAiMetadata).mockResolvedValue({ + tools: [{ name: 'get-documentation', description: 'Get docs for a component.' }], + instructions: ' ', + }); + const section = await buildStorybookCommandsHelp({ cwd: '/projects/foo' }); + expect(section).toContain('# Storybook commands'); + expect(section).toContain('get-documentation'); + expect(section).not.toContain('# Storybook workflow instructions'); + }); + + it('ignores --port on the serverless help path', async () => { + const section = await buildStorybookCommandsHelp({ cwd: '/projects/foo', port: 'abc' }); + expect(section).toContain('Storybook help from the Storybook configuration'); + expect(section).toContain('list-all-documentation'); + expect(loadStorybookAiMetadata).toHaveBeenCalledWith({ + cwd: resolve('/projects/foo'), + configDir: resolve('/projects/foo/.storybook'), + }); + }); + + it('loads the command section from a custom config dir', async () => { + vi.mocked(loadStorybookAiMetadata).mockResolvedValue({ + tools: [{ name: 'get-documentation', description: 'Get docs for a component.' }], + instructions: 'Follow the story workflow.', + }); + + const section = await buildStorybookCommandsHelp({ + cwd: '/projects/foo', + configDir: 'config/storybook', + }); + + expect(section).toContain('get-documentation'); + expect(loadStorybookAiMetadata).toHaveBeenCalledWith({ + cwd: resolve('/projects/foo'), + configDir: resolve('/projects/foo/config/storybook'), + }); + }); +}); + +describe('runAiToolHelp', () => { + it('prints the description and arguments of a single tool', async () => { + vi.mocked(loadStorybookAiMetadata).mockResolvedValue({ + instructions: 'Follow the story workflow.', + tools: [ + { + name: 'get-documentation', + description: 'Get docs for a component.', + inputSchema: { + properties: { id: { type: 'string', description: 'Documentation id' } }, + required: ['id'], + }, + }, + ], + }); + + const result = await runAiToolHelp('get-documentation', { cwd: '/projects/foo' }); + expect(result.exitCode).toBe(0); + expect(result.output).toContain('Usage: storybook ai get-documentation'); + expect(result.output).toContain('Get docs for a component.'); + expect(result.output).toContain('Execution: requires a running Storybook.'); + expect(result.output).toContain('- `--id` (string, required): Documentation id'); + expect(result.outcome).toEqual({ kind: 'help' }); + }); + + it('marks local commands in single-command help', async () => { + vi.mocked(loadStorybookAiMetadata).mockResolvedValue({ + instructions: 'Follow the story workflow.', + tools: [{ name: 'get-storybook-story-instructions', description: 'Get story guidance.' }], + localTools: { + 'get-storybook-story-instructions': { + call: vi.fn().mockResolvedValue({ content: [] }), + }, + }, + }); + + const result = await runAiToolHelp('get-storybook-story-instructions', { + cwd: '/projects/foo', + }); + + expect(result.exitCode).toBe(0); + expect(result.output).toContain('Execution: local (no running Storybook required).'); + }); + + it('is reachable through runAiTool via a --help token after the tool name', async () => { + vi.mocked(loadStorybookAiMetadata).mockResolvedValue({ + instructions: 'Follow the story workflow.', + tools: [{ name: 'get-documentation', description: 'Get docs.' }], + }); + const result = await runAiTool('get-documentation', ['--help'], { cwd: '/projects/foo' }); + expect(result.exitCode).toBe(0); + expect(result.output).toContain('Usage: storybook ai get-documentation'); + expect(result.outcome).toEqual({ kind: 'help' }); + expect(callMcpTool).not.toHaveBeenCalled(); + expect(readRegistry).not.toHaveBeenCalled(); + }); + + it('honors the config dir option on the help path after the tool name', async () => { + vi.mocked(loadStorybookAiMetadata).mockResolvedValue({ + instructions: 'Follow the story workflow.', + tools: [{ name: 'get-documentation', description: 'Get docs.' }], + }); + const result = await runAiTool('get-documentation', ['--help'], { + cwd: '/projects/foo', + configDir: 'config/storybook', + }); + expect(result.exitCode).toBe(0); + expect(loadStorybookAiMetadata).toHaveBeenCalledWith({ + cwd: resolve('/projects/foo'), + configDir: resolve('/projects/foo/config/storybook'), + }); + }); + + it('ignores --port tokens on the help path without needing a running server', async () => { + vi.mocked(loadStorybookAiMetadata).mockResolvedValue({ + instructions: 'Follow the story workflow.', + tools: [{ name: 'get-documentation', description: 'Get docs.' }], + }); + const result = await runAiTool('get-documentation', ['--port', 'not-a-port', '--help'], { + cwd: '/projects/foo', + }); + expect(result.exitCode).toBe(0); + expect(result.output).toContain('Usage: storybook ai get-documentation'); + expect(readRegistry).not.toHaveBeenCalled(); + }); + + it('lists the available tools for an unknown tool name', async () => { + vi.mocked(loadStorybookAiMetadata).mockResolvedValue({ + instructions: 'Follow the story workflow.', + tools: [{ name: 'list-all-documentation', description: 'List docs' }], + }); + const result = await runAiToolHelp('no-such-tool', { cwd: '/projects/foo' }); + expect(result.exitCode).toBe(1); + expect(result.output).toContain('Unknown command `no-such-tool`'); + expect(result.output).toContain('- `list-all-documentation`'); + }); + + it('prints metadata unavailable guidance when no preset metadata is exposed', async () => { + vi.mocked(loadStorybookAiMetadata).mockResolvedValue(undefined); + const result = await runAiToolHelp('get-documentation', { cwd: '/projects/foo' }); + expect(result.exitCode).toBe(1); + expect(result.output).toContain('Storybook command metadata is unavailable'); + expect(result.output).toContain('@storybook/addon-mcp'); + expect(result.outcome).toEqual({ kind: 'help' }); + }); + + it('ignores an invalid --port on direct help lookup', async () => { + vi.mocked(loadStorybookAiMetadata).mockResolvedValue({ + instructions: 'Follow the story workflow.', + tools: [{ name: 'get-documentation', description: 'Get docs.' }], + }); + + const result = await runAiToolHelp('get-documentation', { + cwd: '/projects/foo', + port: 'abc', + }); + + expect(result.exitCode).toBe(0); + expect(result.output).toContain('Usage: storybook ai get-documentation'); + }); + + it('loads direct help from a custom config dir', async () => { + vi.mocked(loadStorybookAiMetadata).mockResolvedValue({ + instructions: 'Follow the story workflow.', + tools: [{ name: 'get-documentation', description: 'Get docs.' }], + }); + + const result = await runAiToolHelp('get-documentation', { + cwd: '/projects/foo', + configDir: 'config/storybook', + }); + + expect(result.exitCode).toBe(0); + expect(loadStorybookAiMetadata).toHaveBeenCalledWith({ + cwd: resolve('/projects/foo'), + configDir: resolve('/projects/foo/config/storybook'), + }); + }); + + it('surfaces metadata loading errors', async () => { + vi.mocked(loadStorybookAiMetadata).mockRejectedValue(new Error('main config failed')); + const result = await runAiToolHelp('get-documentation', { cwd: '/projects/foo' }); + expect(result.exitCode).toBe(1); + expect(result.output).toContain('Storybook command metadata is unavailable'); + expect(result.output).toContain('main config failed'); + }); +}); diff --git a/code/core/src/cli/ai/mcp/run-tool.ts b/code/core/src/cli/ai/mcp/run-tool.ts new file mode 100644 index 000000000000..6b3ad24770e9 --- /dev/null +++ b/code/core/src/cli/ai/mcp/run-tool.ts @@ -0,0 +1,575 @@ +import { resolve } from 'node:path'; + +import * as v from 'valibot'; + +import { McpJsonRpcError, callMcpTool, listMcpTools } from './client.ts'; +import { getInterceptMarkdown } from './intercepts.ts'; +import { + loadStorybookAiMetadata, + resolveStorybookConfigDir, + type StorybookAiMetadata, +} from './local-metadata.ts'; +import { detectAgent } from '../../../telemetry/detect-agent.ts'; +import { readRegistry } from './registry.ts'; +import { resolveInstance } from './resolve-instance.ts'; +import { parsePort, parseToolArgs } from './tool-args.ts'; +import { ToolCallResultSchema } from './types.ts'; +import type { + InterceptReason, + McpToolDescriptor, + StorybookInstanceRecord, + ToolCallResult, +} from './types.ts'; + +/** + * Why an invocation failed before any command executed, for the `ai-command` telemetry event + * (storybookjs/storybook#35131). Extends the instance-resolution intercepts with the two CLI-level + * cases: arguments that never parsed, and command names the server does not provide. + */ +export type AiCommandInterceptReason = InterceptReason | 'invalid-arguments' | 'unknown-command'; + +/** + * Telemetry-facing classification of a run. `help` marks lookups via `--help` flags, which are not + * command executions and are excluded from the `ai-command` event so they cannot skew command + * success rates. `error` carries command failures for the standard sanitized error path. + */ +export type AiCommandOutcome = + | { kind: 'success' } + | { kind: 'help' } + | { kind: 'intercept'; reason: AiCommandInterceptReason } + | { kind: 'error'; error: unknown }; + +export type AiToolRunResult = { exitCode: 0 | 1; output: string; outcome: AiCommandOutcome }; + +/** + * The command executed and reported an error result. The message is deliberately + * constant — the result text is arbitrary tool output (often containing project paths), and a + * constant message keeps the telemetry error hash stable and aggregatable. The tool's error text + * travels as `cause` instead, which the standard error path only uploads — path-sanitized — when + * the user opted into crash reports. + */ +class McpToolResultError extends Error { + constructor(options?: ErrorOptions) { + super('The Storybook AI command returned an error result', options); + this.name = 'McpToolResultError'; + } +} + +class LocalAiToolError extends Error { + constructor(options?: ErrorOptions) { + super('The Storybook local AI command failed', options); + this.name = 'LocalAiToolError'; + } +} + +/** Injectable dependencies for tests. */ +export type AiToolRunDeps = { + registryDir?: string; + fetchImpl?: typeof fetch; + loadStorybookAiMetadata?: typeof loadStorybookAiMetadata; +}; + +export type AiToolOptions = { + /** Project directory of the target Storybook; defaults to `process.cwd()`. */ + cwd?: string; + /** Directory where to load Storybook configuration from; relative paths resolve from `cwd`. */ + configDir?: string; + /** Port of the target Storybook, to address one specific instance when several share the cwd. */ + port?: string; + /** Raw JSON object with tool arguments (escape hatch for complex values). */ + json?: string; +}; + +/** + * Run a Storybook AI command and return its result as markdown. Commands exposed as local metadata + * run without a dev server; runtime-bound commands still go through the running Storybook MCP + * server and use the same repair-instruction markdown as `@storybook/mcp-proxy`. + */ +export async function runAiTool( + toolName: string, + toolArgTokens: string[], + options: AiToolOptions = {}, + deps: AiToolRunDeps = {} +): Promise { + const parsed = parseToolArgs(toolArgTokens, { + json: options.json, + }); + if (!parsed.ok) { + return { + exitCode: 1, + output: parsed.error, + outcome: { kind: 'intercept', reason: 'invalid-arguments' }, + }; + } + if (parsed.help) { + return toolHelp(toolName, options.cwd, options.configDir, deps); + } + + const toolLookup = await lookupAiTool(toolName, options.cwd, options.configDir, deps); + switch (toolLookup.kind) { + case 'local': + return runLocalAiTool(toolLookup.localTool, parsed.args); + case 'result': + return toolLookup.result; + case 'runtime': + break; + default: { + const exhaustive: never = toolLookup; + return exhaustive; + } + } + + const parsedPort = parsePort(options.port); + if (!parsedPort.ok) { + return { + exitCode: 1, + output: parsedPort.error, + outcome: { kind: 'intercept', reason: 'invalid-arguments' }, + }; + } + + const resolution = await resolveReadyInstance(options.cwd, parsedPort.port, deps); + if (resolution.kind === 'error') { + return { + exitCode: 1, + output: resolution.output, + outcome: { kind: 'intercept', reason: resolution.reason }, + }; + } + const { record, matches } = resolution; + + try { + const result = await callMcpTool( + record, + { name: toolName, arguments: parsed.args }, + deps.fetchImpl + ); + if (result.isError) { + // addon-mcp reports unknown tools as an error *result* rather than a JSON-RPC error. + const unknownTool = await describeUnknownTool(record, toolName, deps.fetchImpl); + if (unknownTool) { + return { + exitCode: 1, + output: unknownTool, + outcome: { kind: 'intercept', reason: 'unknown-command' }, + }; + } + } + const siblings = matches.filter((r) => r !== record); + const toolOutput = formatToolResult(result); + const sections = [ + ...(siblings.length > 0 ? [formatMultiInstanceWarning(record, siblings)] : []), + toolOutput, + ]; + const output = sections.join('\n\n'); + if (result.isError) { + return { + exitCode: 1, + output, + outcome: { kind: 'error', error: new McpToolResultError({ cause: toolOutput }) }, + }; + } + return { exitCode: 0, output, outcome: { kind: 'success' } }; + } catch (error) { + if (error instanceof McpJsonRpcError) { + const unknownTool = await describeUnknownTool(record, toolName, deps.fetchImpl); + if (unknownTool) { + return { + exitCode: 1, + output: unknownTool, + outcome: { kind: 'intercept', reason: 'unknown-command' }, + }; + } + return { exitCode: 1, output: error.message, outcome: { kind: 'error', error } }; + } + return { + exitCode: 1, + output: formatServerUnreachable(record, error), + outcome: { kind: 'error', error }, + }; + } +} + +/** + * Build the "Storybook commands" help section from Storybook config metadata, appended to + * `storybook ai --help`. Help must never fail, so any error degrades to a short note explaining why + * no commands are listed. + */ +export async function buildStorybookCommandsHelp( + options: AiToolOptions = {}, + deps: AiToolRunDeps = {} +): Promise { + const unavailable = (note: string) => `Storybook commands: (unavailable — ${note})`; + + const metadataResult = await loadLocalMetadata(options.cwd, options.configDir, deps); + if (metadataResult.kind === 'error') { + return unavailable( + `the Storybook config at ${metadataResult.configDir} could not be loaded: ${formatErrorMessage( + metadataResult.error + )}` + ); + } + const { metadata, configDir } = metadataResult; + if (!metadata) { + return unavailable(formatMetadataMissingHelp(configDir)); + } + const { tools } = metadata; + if (tools.length === 0) { + return unavailable(`the Storybook config at ${configDir} provides no commands`); + } + + const width = Math.max(...tools.map((tool) => tool.name.length)) + 2; + const localToolNames = getLocalToolNames(metadata); + const lines = tools.map((tool) => { + const mode = localToolNames.has(tool.name) ? '[local]' : '[requires Storybook]'; + const summary = tool.description?.trim().split('\n')[0] ?? ''; + return ` ${tool.name.padEnd(width)}${mode.padEnd(21)}${summary}`; + }); + const { instructions } = metadata; + const trimmedInstructions = instructions?.trim(); + const sections = [`Storybook help from the Storybook configuration at ${configDir}:`, '']; + if (trimmedInstructions) { + sections.push('# Storybook workflow instructions', '', trimmedInstructions, ''); + } + + sections.push( + '# Storybook commands', + '', + ...lines, + '', + '[local] commands run from configuration metadata without a running Storybook.', + '[requires Storybook] commands are forwarded to the running Storybook server.', + '', + `Run 'storybook ai --help' for a command's description and arguments.` + ); + return sections.join('\n'); +} + +/** Show the description and arguments of a single command (`storybook ai --help`). */ +export async function runAiToolHelp( + toolName: string, + options: AiToolOptions = {}, + deps: AiToolRunDeps = {} +): Promise { + return toolHelp(toolName, options.cwd, options.configDir, deps); +} + +/** All paths are help lookups, so every outcome is `help` regardless of success. */ +async function toolHelp( + toolName: string, + cwd: string | undefined, + configDir: string | undefined, + deps: AiToolRunDeps +): Promise { + const outcome: AiCommandOutcome = { kind: 'help' }; + + const metadataResult = await loadLocalMetadata(cwd, configDir, deps); + if (metadataResult.kind === 'error') { + return metadataLoadFailureResult(metadataResult, outcome); + } + const { metadata } = metadataResult; + if (!metadata) { + return metadataMissingResult(metadataResult.configDir, outcome); + } + const { tools } = metadata; + + const tool = tools.find((candidate) => candidate.name === toolName); + if (!tool) { + return { + exitCode: 1, + output: formatUnknownMetadataTool(toolName, tools, metadataResult.configDir), + outcome, + }; + } + return { + exitCode: 0, + output: formatToolHelp(tool, { local: getLocalToolNames(metadata).has(tool.name) }), + outcome, + }; +} + +type StorybookAiLocalTool = NonNullable[string]; + +type AiToolLookup = + | { kind: 'local'; localTool: StorybookAiLocalTool } + | { kind: 'runtime' } + | { kind: 'result'; result: AiToolRunResult }; + +async function lookupAiTool( + toolName: string, + cwd: string | undefined, + configDir: string | undefined, + deps: AiToolRunDeps +): Promise { + const metadataResult = await loadLocalMetadata(cwd, configDir, deps); + if (metadataResult.kind === 'error') { + return { + kind: 'result', + result: metadataLoadFailureResult(metadataResult, { + kind: 'error', + error: new LocalAiToolError({ cause: metadataResult.error }), + }), + }; + } + + const { metadata } = metadataResult; + if (!metadata) { + return { + kind: 'result', + result: metadataMissingResult(metadataResult.configDir, { + kind: 'intercept', + reason: 'addon-missing', + }), + }; + } + + const visibleTool = metadata.tools.find((tool) => tool.name === toolName); + if (!visibleTool) { + return { + kind: 'result', + result: { + exitCode: 1, + output: formatUnknownMetadataTool(toolName, metadata.tools, metadataResult.configDir), + outcome: { kind: 'intercept', reason: 'unknown-command' }, + }, + }; + } + + const localTool = metadata.localTools?.[toolName]; + if (!localTool) { + return { kind: 'runtime' }; + } + + return { kind: 'local', localTool }; +} + +async function runLocalAiTool( + localTool: StorybookAiLocalTool, + args: Record +): Promise { + try { + const rawResult = await localTool.call(args); + const parsedResult = v.safeParse(ToolCallResultSchema, rawResult); + if (!parsedResult.success) { + return { + exitCode: 1, + output: 'The Storybook local AI command returned an unexpected response shape', + outcome: { + kind: 'error', + error: new LocalAiToolError({ cause: parsedResult.issues }), + }, + }; + } + const result = parsedResult.output; + const output = formatToolResult(result); + if (result.isError) { + return { + exitCode: 1, + output, + outcome: { kind: 'error', error: new McpToolResultError({ cause: output }) }, + }; + } + return { exitCode: 0, output, outcome: { kind: 'success' } }; + } catch (error) { + return { + exitCode: 1, + output: error instanceof Error ? error.message : String(error), + outcome: { kind: 'error', error: new LocalAiToolError({ cause: error }) }, + }; + } +} + +async function loadLocalMetadata( + cwd: string | undefined, + configDir: string | undefined, + deps: AiToolRunDeps +): Promise { + const resolvedCwd = resolve(cwd ?? process.cwd()); + const resolvedConfigDir = resolveStorybookConfigDir({ cwd: resolvedCwd, configDir }); + const loadMetadata = deps.loadStorybookAiMetadata ?? loadStorybookAiMetadata; + try { + return { + kind: 'ok', + cwd: resolvedCwd, + configDir: resolvedConfigDir, + metadata: await loadMetadata({ cwd: resolvedCwd, configDir: resolvedConfigDir }), + }; + } catch (error) { + return { kind: 'error', cwd: resolvedCwd, configDir: resolvedConfigDir, error }; + } +} + +type LocalMetadataResult = + | { kind: 'ok'; cwd: string; configDir: string; metadata: StorybookAiMetadata | undefined } + | { kind: 'error'; cwd: string; configDir: string; error: unknown }; + +function metadataLoadFailureResult( + metadataResult: Extract, + outcome: AiCommandOutcome +): AiToolRunResult { + return { + exitCode: 1, + output: `Storybook command metadata is unavailable for ${ + metadataResult.configDir + }: ${formatErrorMessage(metadataResult.error)}`, + outcome, + }; +} + +function metadataMissingResult(configDir: string, outcome: AiCommandOutcome): AiToolRunResult { + return { + exitCode: 1, + output: `Storybook command metadata is unavailable for ${configDir}. Install or upgrade \`@storybook/addon-mcp\`.`, + outcome, + }; +} + +function formatMetadataMissingHelp(configDir: string): string { + return `the Storybook config at ${configDir} does not expose AI command metadata — install or upgrade \`@storybook/addon-mcp\``; +} + +function formatErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function formatServerUnreachable(record: StorybookInstanceRecord, error: unknown): string { + return `Failed to reach the Storybook server at ${record.mcp.endpoint ?? '(no endpoint)'}: ${ + error instanceof Error ? error.message : String(error) + }`; +} + +type InstanceResolution = + | { kind: 'ok'; record: StorybookInstanceRecord; matches: StorybookInstanceRecord[] } + | { + kind: 'error'; + output: string; + reason: InterceptReason; + }; + +/** + * Resolve the running Storybook instance for `cwdInput` via the registry. No version or + * installed checks: the CLI is invoked as `npx storybook`, so the fact that it is executing + * already proves the project has a compatible Storybook. + */ +async function resolveReadyInstance( + cwdInput: string | undefined, + port: number | undefined, + deps: AiToolRunDeps +): Promise { + const cwd = resolve(cwdInput ?? process.cwd()); + + const records = await readRegistry(deps.registryDir); + const resolution = resolveInstance(records, cwd, port, detectAgent()?.name); + + if (resolution.kind === 'intercept') { + return { + kind: 'error', + output: getInterceptMarkdown(resolution.reason, { records: resolution.records, port }), + reason: resolution.reason, + }; + } + + return { kind: 'ok', record: resolution.record, matches: resolution.matches }; +} + +/** + * Build the "unknown tool" error listing the available tools, or null when the tool does exist + * (the JSON-RPC error had another cause) or the tool list cannot be fetched. + */ +async function describeUnknownTool( + record: StorybookInstanceRecord, + toolName: string, + fetchImpl?: typeof fetch +): Promise { + let tools: McpToolDescriptor[]; + try { + tools = await listMcpTools(record, fetchImpl); + } catch { + return null; + } + if (tools.some((tool) => tool.name === toolName)) { + return null; + } + return formatUnknownTool(toolName, tools, `The Storybook running at ${record.url}`); +} + +function formatUnknownTool(toolName: string, tools: McpToolDescriptor[], source: string): string { + return `Unknown command \`${toolName}\`. ${source} provides: + +${tools.map((tool) => `- \`${tool.name}\``).join('\n')} + +Run \`storybook ai --help\` for all commands, or \`storybook ai --help\` for a command's arguments.`; +} + +function formatUnknownMetadataTool( + toolName: string, + tools: McpToolDescriptor[], + configDir: string +): string { + return formatUnknownTool(toolName, tools, `The Storybook configuration at ${configDir}`); +} + +/** Render a tools/call result as markdown: text content verbatim, other content as JSON blocks. */ +function formatToolResult(result: ToolCallResult): string { + const content = result.content ?? []; + if (content.length === 0) { + return '(the command returned no content)'; + } + return content + .map((item) => + item.type === 'text' && typeof item.text === 'string' + ? item.text + : `\`\`\`json\n${JSON.stringify(item, null, 2)}\n\`\`\`` + ) + .join('\n\n'); +} + +function getLocalToolNames(metadata: StorybookAiMetadata): Set { + return new Set(Object.keys(metadata.localTools ?? {})); +} + +function formatToolHelp(tool: McpToolDescriptor, { local }: { local: boolean }): string { + const lines = [`Usage: storybook ai ${tool.name} [--key value ...]`]; + if (tool.description) { + lines.push('', tool.description.trim()); + } + lines.push( + '', + local + ? 'Execution: local (no running Storybook required).' + : 'Execution: requires a running Storybook.' + ); + const properties = Object.entries(tool.inputSchema?.properties ?? {}); + if (properties.length > 0) { + const required = new Set(tool.inputSchema?.required ?? []); + lines.push( + '', + 'Arguments:', + ...properties.map(([name, schema]) => { + const meta = [schema.type, required.has(name) ? 'required' : undefined] + .filter(Boolean) + .join(', '); + const description = schema.description ? `: ${schema.description}` : ''; + return `- \`--${name}\`${meta ? ` (${meta})` : ''}${description}`; + }) + ); + } + return lines.join('\n'); +} + +function formatMultiInstanceWarning( + chosen: StorybookInstanceRecord, + siblings: StorybookInstanceRecord[] +): string { + const all = [chosen, ...siblings]; + const lines = all.map((r) => { + const marker = r === chosen ? ' (used)' : ''; + return `> - pid \`${r.pid}\` at ${r.url} (status: \`${r.mcp.status}\`)${marker}`; + }); + return `> Warning: Multiple matching Storybook instances are running at this cwd. This call was sent to pid \`${chosen.pid}\`. +> +> Matching instances at \`${chosen.cwd}\`: +${lines.join('\n')} +> +> If results look unexpected, ask the user whether they want to stop the other instance(s).`; +} diff --git a/code/core/src/cli/ai/mcp/tool-args.test.ts b/code/core/src/cli/ai/mcp/tool-args.test.ts new file mode 100644 index 000000000000..e81ea3b15c5a --- /dev/null +++ b/code/core/src/cli/ai/mcp/tool-args.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from 'vitest'; + +import { parsePort, parseToolArgs } from './tool-args.ts'; + +function args(tokens: string[], defaults?: { json?: string }) { + const result = parseToolArgs(tokens, defaults); + if (!result.ok) { + throw new Error(`expected ok, got error: ${result.error}`); + } + return result; +} + +function error(tokens: string[], defaults?: { json?: string }) { + const result = parseToolArgs(tokens, defaults); + if (result.ok) { + throw new Error(`expected error, got ok: ${JSON.stringify(result.args)}`); + } + return result.error; +} + +describe('parseToolArgs', () => { + it('returns empty args for no tokens', () => { + expect(args([])).toEqual({ + ok: true, + help: false, + args: {}, + }); + }); + + it('consumes --help and -h as a help request instead of forwarding them', () => { + expect(args(['--help'])).toMatchObject({ help: true, args: {} }); + expect(args(['-h'])).toMatchObject({ help: true, args: {} }); + expect(args(['--id', 'x', '--help'])).toMatchObject({ help: true, args: { id: 'x' } }); + }); + + it('maps `--key value` pairs to tool arguments', () => { + expect(args(['--id', 'button-docs']).args).toEqual({ id: 'button-docs' }); + }); + + it('supports `--key=value`', () => { + expect(args(['--id=button-docs']).args).toEqual({ id: 'button-docs' }); + }); + + describe('JSON-parse coercion', () => { + it('coerces booleans, numbers and null', () => { + expect(args(['--a', 'true', '--b', '42', '--c', 'null']).args).toEqual({ + a: true, + b: 42, + c: null, + }); + }); + + it('coerces JSON arrays and objects', () => { + expect(args(['--ids', '["a","b"]', '--filter', '{"tag":"x"}']).args).toEqual({ + ids: ['a', 'b'], + filter: { tag: 'x' }, + }); + }); + + it('falls back to the raw string when the value is not valid JSON', () => { + expect(args(['--id', 'button-docs', '--path', 'src/Button.tsx']).args).toEqual({ + id: 'button-docs', + path: 'src/Button.tsx', + }); + }); + + it('unquotes explicitly quoted JSON strings', () => { + expect(args(['--id', '"true"']).args).toEqual({ id: 'true' }); + }); + + it('accepts negative numbers as values', () => { + expect(args(['--offset', '-1']).args).toEqual({ offset: -1 }); + }); + }); + + it('treats a bare `--flag` as true', () => { + expect(args(['--withStoryIds']).args).toEqual({ withStoryIds: true }); + expect(args(['--withStoryIds', '--id', 'x']).args).toEqual({ withStoryIds: true, id: 'x' }); + }); + + it('lets the last occurrence of a repeated key win', () => { + expect(args(['--id', 'a', '--id', 'b']).args).toEqual({ id: 'b' }); + }); + + it('treats target-looking long flags after the command name as tool arguments', () => { + expect( + args(['--cwd', '/projects/foo', '--config-dir', 'config/storybook', '--port', '6006']).args + ).toEqual({ + cwd: '/projects/foo', + 'config-dir': 'config/storybook', + port: 6006, + }); + }); + + it('allows bare target-looking long flags as boolean tool arguments', () => { + expect(args(['--cwd', '--port']).args).toEqual({ cwd: true, port: true }); + }); + + it('rejects short flags after the command name', () => { + expect(error(['-c', 'config/storybook'])).toContain('Unexpected argument `-c`'); + }); + + describe('--json escape hatch', () => { + it('uses the JSON object as the tool arguments', () => { + expect(args(['--json', '{"id":"x","n":1}']).args).toEqual({ id: 'x', n: 1 }); + }); + + it('lets explicit --key flags override --json entries', () => { + expect(args(['--json', '{"id":"x","n":1}', '--id', 'y']).args).toEqual({ id: 'y', n: 1 }); + }); + + it('accepts --json parsed by commander before the command name', () => { + expect(args(['--id', 'y'], { json: '{"id":"x","n":1}' }).args).toEqual({ id: 'y', n: 1 }); + }); + + it('errors on invalid JSON', () => { + expect(error(['--json', '{nope'])).toContain('`--json` must be valid JSON'); + }); + + it('errors when the JSON is not an object', () => { + expect(error(['--json', '[1,2]'])).toContain('must be a JSON object'); + expect(error(['--json', '"text"'])).toContain('must be a JSON object'); + expect(error(['--json', 'null'])).toContain('must be a JSON object'); + }); + + it('errors when --json has no value', () => { + expect(error(['--json'])).toContain('`--json` requires a value'); + }); + }); + + it('errors on positional tokens', () => { + expect(error(['positional'])).toContain('Unexpected argument `positional`'); + }); + + it('errors on a bare `--` separator', () => { + expect(error(['--'])).toContain('Unexpected argument `--`'); + }); + + it('errors on `--=value`', () => { + expect(error(['--=x'])).toContain('Invalid flag'); + }); +}); + +describe('parsePort', () => { + it('returns undefined when no port is provided', () => { + expect(parsePort(undefined)).toEqual({ ok: true, port: undefined }); + }); + + it('parses valid port values', () => { + expect(parsePort('6006')).toEqual({ ok: true, port: 6006 }); + }); + + it('rejects non-numeric or out-of-range ports', () => { + expect(parsePort('abc')).toMatchObject({ ok: false }); + expect(parsePort('0')).toMatchObject({ ok: false }); + expect(parsePort('65536')).toMatchObject({ ok: false }); + expect(parsePort('6006.5')).toMatchObject({ ok: false }); + }); +}); diff --git a/code/core/src/cli/ai/mcp/tool-args.ts b/code/core/src/cli/ai/mcp/tool-args.ts new file mode 100644 index 000000000000..b9f56776de7a --- /dev/null +++ b/code/core/src/cli/ai/mcp/tool-args.ts @@ -0,0 +1,121 @@ +export type ParsedToolArgs = + | { + ok: true; + help: boolean; + args: Record; + } + | { ok: false; error: string }; + +/** + * Parse the pass-through tokens after `storybook ai ` into MCP tool arguments. + * + * - `--key value` and `--key=value` become tool arguments; values are coerced by attempting + * `JSON.parse`, falling back to the raw string. + * - A bare `--key` (no value) becomes `true`. + * - `--json ''` is an escape hatch providing the raw argument object; explicit `--key` + * flags override its entries. + * - `--help`/`-h` is consumed by the CLI itself and never forwarded to the tool. + * + * Target-selection options (`--cwd`, `--config-dir`, `--port`) are commander-owned and must + * appear before the command name; the same-looking flags after the command name are normal tool + * arguments. + */ +export function parseToolArgs( + tokens: string[], + defaults: { + json?: string; + } = {} +): ParsedToolArgs { + let rawJson = defaults.json; + let help = false; + const flagArgs: Record = {}; + + let i = 0; + while (i < tokens.length) { + const token = tokens[i]; + i += 1; + + if (token === '--help' || token === '-h') { + help = true; + continue; + } + + if (!token.startsWith('--') || token === '--') { + return { + ok: false, + error: `Unexpected argument \`${token}\`. Command arguments must be passed as \`--key value\` flags (or via \`--json ''\`).`, + }; + } + + let key = token.slice(2); + let value: string | undefined; + const equalsIndex = key.indexOf('='); + if (equalsIndex !== -1) { + value = key.slice(equalsIndex + 1); + key = key.slice(0, equalsIndex); + } else if (i < tokens.length && !tokens[i].startsWith('--')) { + value = tokens[i]; + i += 1; + } + + if (key === '') { + return { ok: false, error: `Invalid flag \`${token}\`.` }; + } + + if (key === 'json') { + if (value === undefined) { + return { ok: false, error: '`--json` requires a value.' }; + } + rawJson = value; + continue; + } + + flagArgs[key] = value === undefined ? true : coerceValue(value); + } + + let jsonArgs: Record = {}; + if (rawJson !== undefined) { + let parsed: unknown; + try { + parsed = JSON.parse(rawJson); + } catch (error) { + return { + ok: false, + error: `\`--json\` must be valid JSON: ${error instanceof Error ? error.message : String(error)}`, + }; + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + return { + ok: false, + error: '`--json` must be a JSON object, e.g. \'{"id": "button-docs"}\'.', + }; + } + jsonArgs = parsed as Record; + } + + return { ok: true, help, args: { ...jsonArgs, ...flagArgs } }; +} + +export function parsePort( + rawPort: string | undefined +): { ok: true; port: number | undefined } | { ok: false; error: string } { + if (rawPort === undefined) { + return { ok: true, port: undefined }; + } + const port = Number(rawPort); + if (!Number.isInteger(port) || port < 1 || port > 65535) { + return { + ok: false, + error: `\`--port\` must be a port number (1-65535), got \`${rawPort}\`.`, + }; + } + return { ok: true, port }; +} + +function coerceValue(raw: string): unknown { + try { + return JSON.parse(raw); + } catch { + return raw; + } +} diff --git a/code/core/src/cli/ai/mcp/types.ts b/code/core/src/cli/ai/mcp/types.ts new file mode 100644 index 000000000000..1e91bfdc08e5 --- /dev/null +++ b/code/core/src/cli/ai/mcp/types.ts @@ -0,0 +1,83 @@ +/** + * Reader-side types for the `storybook ai ` MCP passthrough, copied from + * `@storybook/mcp-proxy` (storybookjs/mcp) per storybookjs/storybook#35124. The + * writer side lives in `code/core/src/core-server/utils/runtime-instance-registry.ts`; + * this reader is intentionally more lenient (extra statuses, optional fields) so it + * also accepts records written by other Storybook versions and wrappers. + */ +import * as v from 'valibot'; + +/** + * The in-repo writer only emits `not-installed` and `ready`; `starting` and `error` are written by + * external wrappers (e.g. the storybookjs/mcp launch script) and must keep being dispatched here. + */ +export const McpStatusSchema = v.picklist(['not-installed', 'starting', 'ready', 'error']); +export type McpStatus = v.InferOutput; + +/** + * A single Storybook runtime record written under the registry dir (default + * `~/.storybook/instances`). One file per running `storybook dev` instance. + * Spec: storybookjs/storybook#34826. + */ +export const StorybookInstanceRecordSchema = v.object({ + schemaVersion: v.literal(1), + instanceId: v.string(), + pid: v.pipe(v.number(), v.minValue(1), v.integer()), + cwd: v.string(), + url: v.string(), + port: v.pipe(v.number(), v.minValue(1), v.maxValue(65535), v.integer()), + agent: v.optional(v.string()), + storybookVersion: v.optional(v.string()), + startedAt: v.optional(v.string()), + updatedAt: v.optional(v.string()), + mcp: v.object({ + status: McpStatusSchema, + endpoint: v.optional(v.string()), + }), +}); +export type StorybookInstanceRecord = v.InferOutput; + +export type InterceptReason = + | 'no-instance' + | 'port-mismatch' + | 'addon-missing' + | 'mcp-starting' + | 'mcp-error'; + +/** + * Result of an MCP `tools/call` request, as returned by `@storybook/addon-mcp`. Loose: servers may + * legally attach extra fields (`_meta`, `structuredContent`, image/audio content properties); we + * validate only what the CLI renders and pass the rest through. + */ +export const ToolResultContentItemSchema = v.looseObject({ + type: v.string(), + text: v.optional(v.string()), +}); +export type ToolResultContentItem = v.InferOutput; + +export const ToolCallResultSchema = v.looseObject({ + content: v.optional(v.array(ToolResultContentItemSchema)), + isError: v.optional(v.boolean()), +}); +export type ToolCallResult = v.InferOutput; + +/** A tool descriptor from an MCP `tools/list` response. */ +export const McpToolDescriptorSchema = v.looseObject({ + name: v.string(), + description: v.optional(v.string()), + inputSchema: v.optional( + v.looseObject({ + properties: v.optional( + v.record( + v.string(), + v.looseObject({ + type: v.optional(v.string()), + description: v.optional(v.string()), + }) + ) + ), + required: v.optional(v.array(v.string())), + }) + ), +}); +export type McpToolDescriptor = v.InferOutput; diff --git a/code/lib/cli-storybook/src/ai/setup-prompts/index.ts b/code/core/src/cli/ai/setup-prompts/index.ts similarity index 100% rename from code/lib/cli-storybook/src/ai/setup-prompts/index.ts rename to code/core/src/cli/ai/setup-prompts/index.ts diff --git a/code/lib/cli-storybook/src/ai/setup-prompts/monorepo-optimized-tests-relaxed-limits-no-story-deletion.ts b/code/core/src/cli/ai/setup-prompts/monorepo-optimized-tests-relaxed-limits-no-story-deletion.ts similarity index 100% rename from code/lib/cli-storybook/src/ai/setup-prompts/monorepo-optimized-tests-relaxed-limits-no-story-deletion.ts rename to code/core/src/cli/ai/setup-prompts/monorepo-optimized-tests-relaxed-limits-no-story-deletion.ts diff --git a/code/lib/cli-storybook/src/ai/setup-prompts/monorepo.ts b/code/core/src/cli/ai/setup-prompts/monorepo.ts similarity index 100% rename from code/lib/cli-storybook/src/ai/setup-prompts/monorepo.ts rename to code/core/src/cli/ai/setup-prompts/monorepo.ts diff --git a/code/lib/cli-storybook/src/ai/setup-prompts/optimized-tests.ts b/code/core/src/cli/ai/setup-prompts/optimized-tests.ts similarity index 100% rename from code/lib/cli-storybook/src/ai/setup-prompts/optimized-tests.ts rename to code/core/src/cli/ai/setup-prompts/optimized-tests.ts diff --git a/code/lib/cli-storybook/src/ai/setup-prompts/partials/dod.ts b/code/core/src/cli/ai/setup-prompts/partials/dod.ts similarity index 100% rename from code/lib/cli-storybook/src/ai/setup-prompts/partials/dod.ts rename to code/core/src/cli/ai/setup-prompts/partials/dod.ts diff --git a/code/lib/cli-storybook/src/ai/setup-prompts/partials/examples.ts b/code/core/src/cli/ai/setup-prompts/partials/examples.ts similarity index 100% rename from code/lib/cli-storybook/src/ai/setup-prompts/partials/examples.ts rename to code/core/src/cli/ai/setup-prompts/partials/examples.ts diff --git a/code/lib/cli-storybook/src/ai/setup-prompts/partials/rules.ts b/code/core/src/cli/ai/setup-prompts/partials/rules.ts similarity index 97% rename from code/lib/cli-storybook/src/ai/setup-prompts/partials/rules.ts rename to code/core/src/cli/ai/setup-prompts/partials/rules.ts index 02cd8a5a7fe8..e2ed5f989d0c 100644 --- a/code/lib/cli-storybook/src/ai/setup-prompts/partials/rules.ts +++ b/code/core/src/cli/ai/setup-prompts/partials/rules.ts @@ -1,6 +1,6 @@ import { dedent } from 'ts-dedent'; import type { SetupInstructionsContext } from '../../types.ts'; -import { getMonorepoType } from '../../../../../../core/src/shared/utils/get-monorepo-type.ts'; +import { getMonorepoType } from '../../../../shared/utils/get-monorepo-type.ts'; export function toolsVsShellRule(ctx: SetupInstructionsContext): string { return dedent`**Discover with Glob/Grep/Read, not shell.** Never use \`ls\`, \`find\`, \`cat\`, \`head\`, \`tail\`, shell \`grep\`, \`sed\`, or \`node -e\` for discovery or for editing files in bulk — these are slower per call and violate caching. Substitute bash commands for the specific tool names listed below, or available tools with the closest semantics: diff --git a/code/lib/cli-storybook/src/ai/setup-prompts/partials/steps.ts b/code/core/src/cli/ai/setup-prompts/partials/steps.ts similarity index 100% rename from code/lib/cli-storybook/src/ai/setup-prompts/partials/steps.ts rename to code/core/src/cli/ai/setup-prompts/partials/steps.ts diff --git a/code/lib/cli-storybook/src/ai/setup-prompts/partials/types.ts b/code/core/src/cli/ai/setup-prompts/partials/types.ts similarity index 100% rename from code/lib/cli-storybook/src/ai/setup-prompts/partials/types.ts rename to code/core/src/cli/ai/setup-prompts/partials/types.ts diff --git a/code/lib/cli-storybook/src/ai/setup-prompts/pattern-copy-play.ts b/code/core/src/cli/ai/setup-prompts/pattern-copy-play.ts similarity index 100% rename from code/lib/cli-storybook/src/ai/setup-prompts/pattern-copy-play.ts rename to code/core/src/cli/ai/setup-prompts/pattern-copy-play.ts diff --git a/code/lib/cli-storybook/src/ai/setup-prompts/relaxed-limits.ts b/code/core/src/cli/ai/setup-prompts/relaxed-limits.ts similarity index 100% rename from code/lib/cli-storybook/src/ai/setup-prompts/relaxed-limits.ts rename to code/core/src/cli/ai/setup-prompts/relaxed-limits.ts diff --git a/code/lib/cli-storybook/src/ai/setup-prompts/setup.ts b/code/core/src/cli/ai/setup-prompts/setup.ts similarity index 100% rename from code/lib/cli-storybook/src/ai/setup-prompts/setup.ts rename to code/core/src/cli/ai/setup-prompts/setup.ts diff --git a/code/lib/cli-storybook/src/ai/types.ts b/code/core/src/cli/ai/types.ts similarity index 100% rename from code/lib/cli-storybook/src/ai/types.ts rename to code/core/src/cli/ai/types.ts diff --git a/code/lib/cli-storybook/src/ai/utils/docs-markdown-url.ts b/code/core/src/cli/ai/utils/docs-markdown-url.ts similarity index 100% rename from code/lib/cli-storybook/src/ai/utils/docs-markdown-url.ts rename to code/core/src/cli/ai/utils/docs-markdown-url.ts diff --git a/code/lib/cli-storybook/src/ai/utils/ext.ts b/code/core/src/cli/ai/utils/ext.ts similarity index 100% rename from code/lib/cli-storybook/src/ai/utils/ext.ts rename to code/core/src/cli/ai/utils/ext.ts diff --git a/code/lib/cli-storybook/src/ai/utils/markdown.ts b/code/core/src/cli/ai/utils/markdown.ts similarity index 100% rename from code/lib/cli-storybook/src/ai/utils/markdown.ts rename to code/core/src/cli/ai/utils/markdown.ts diff --git a/code/lib/cli-storybook/src/ai/utils/project-overview.ts b/code/core/src/cli/ai/utils/project-overview.ts similarity index 100% rename from code/lib/cli-storybook/src/ai/utils/project-overview.ts rename to code/core/src/cli/ai/utils/project-overview.ts diff --git a/code/lib/cli-storybook/src/ai/utils/type-import-source.ts b/code/core/src/cli/ai/utils/type-import-source.ts similarity index 100% rename from code/lib/cli-storybook/src/ai/utils/type-import-source.ts rename to code/core/src/cli/ai/utils/type-import-source.ts diff --git a/code/core/src/cli/angular/helpers.test.ts b/code/core/src/cli/angular/helpers.test.ts new file mode 100644 index 000000000000..c29d360ed528 --- /dev/null +++ b/code/core/src/cli/angular/helpers.test.ts @@ -0,0 +1,61 @@ +import * as fs from 'node:fs'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { AngularJSON } from './helpers.ts'; + +vi.mock('node:fs', () => ({ + existsSync: vi.fn(() => true), + readFileSync: vi.fn(), + writeFileSync: vi.fn(), +})); + +const makeAngularJson = () => + JSON.stringify({ + projects: { + app: { root: '', projectType: 'application', architect: {} }, + }, + }); + +describe('AngularJSON.addStorybookEntries', () => { + beforeEach(() => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue(makeAngularJson()); + }); + + it('omits compodoc from the Vite builder options (it lives in framework.options)', () => { + const angularJSON = new AngularJSON(); + + angularJSON.addStorybookEntries({ + angularProjectName: 'app', + storybookFolder: '.storybook', + useCompodoc: true, + root: '', + useVite: true, + }); + + const { storybook, 'build-storybook': buildStorybook } = angularJSON.projects.app.architect; + expect(storybook.builder).toBe('@storybook/angular-vite:start-storybook'); + expect(storybook.options).not.toHaveProperty('compodoc'); + expect(storybook.options).not.toHaveProperty('compodocArgs'); + expect(buildStorybook.options).not.toHaveProperty('compodoc'); + expect(buildStorybook.options).not.toHaveProperty('compodocArgs'); + }); + + it('keeps compodoc in the Webpack builder options', () => { + const angularJSON = new AngularJSON(); + + angularJSON.addStorybookEntries({ + angularProjectName: 'app', + storybookFolder: '.storybook', + useCompodoc: true, + root: '', + useVite: false, + }); + + const { storybook } = angularJSON.projects.app.architect; + expect(storybook.builder).toBe('@storybook/angular:start-storybook'); + expect(storybook.options.compodoc).toBe(true); + expect(storybook.options.compodocArgs).toEqual(['-e', 'json', '-d', '.']); + }); +}); diff --git a/code/core/src/cli/angular/helpers.ts b/code/core/src/cli/angular/helpers.ts index 7c7301a69af0..21849f38ef08 100644 --- a/code/core/src/cli/angular/helpers.ts +++ b/code/core/src/cli/angular/helpers.ts @@ -36,7 +36,10 @@ export class AngularJSON { return Object.keys(this.projects).some((projectName) => { const { architect } = this.projects[projectName]; return Object.keys(architect).some((key) => { - return architect[key].builder === '@storybook/angular:start-storybook'; + return ( + architect[key].builder === '@storybook/angular:start-storybook' || + architect[key].builder === '@storybook/angular-vite:start-storybook' + ); }); }); } @@ -73,25 +76,36 @@ export class AngularJSON { storybookFolder, useCompodoc, root, + useVite = false, }: { angularProjectName: string; storybookFolder: string; useCompodoc: boolean; root: string; + useVite?: boolean; }) { // add an entry to the angular.json file to setup the storybook builders const { architect } = this.projects[angularProjectName]; + const builderPackage = useVite ? '@storybook/angular-vite' : '@storybook/angular'; + const baseOptions = { configDir: storybookFolder, browserTarget: `${angularProjectName}:build`, - compodoc: useCompodoc, - ...(useCompodoc && { compodocArgs: ['-e', 'json', '-d', root || '.'] }), + // Compodoc for the Vite framework is configured in main.ts + // (framework.options) because the Vite plugin owns it; only the Webpack + // builder reads Compodoc options from angular.json. + ...(useVite + ? {} + : { + compodoc: useCompodoc, + ...(useCompodoc && { compodocArgs: ['-e', 'json', '-d', root || '.'] }), + }), }; if (!architect.storybook) { architect.storybook = { - builder: '@storybook/angular:start-storybook', + builder: `${builderPackage}:start-storybook`, options: { ...baseOptions, port: 6006, @@ -101,7 +115,7 @@ export class AngularJSON { if (!architect['build-storybook']) { architect['build-storybook'] = { - builder: '@storybook/angular:build-storybook', + builder: `${builderPackage}:build-storybook`, options: { ...baseOptions, outputDir: diff --git a/code/core/src/cli/detectLanguage.test.ts b/code/core/src/cli/detectLanguage.test.ts new file mode 100644 index 000000000000..3c3e06353a7a --- /dev/null +++ b/code/core/src/cli/detectLanguage.test.ts @@ -0,0 +1,57 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { JsPackageManager } from 'storybook/internal/common'; +import { SupportedLanguage } from 'storybook/internal/types'; + +import * as memfs from 'memfs'; +import { vol } from 'memfs'; + +import { detectLanguage } from './detectLanguage.ts'; + +vi.mock('node:fs', { spy: true }); + +const packageManager = (dependencies: Record) => + ({ + getAllDependencies: () => dependencies, + getModulePackageJSON: async (pkg: string) => + dependencies[pkg] ? { version: dependencies[pkg] } : null, + }) as unknown as JsPackageManager; + +describe('detectLanguage', () => { + beforeEach(async () => { + vol.reset(); + const fs = await import('node:fs'); + vi.mocked(fs.existsSync).mockImplementation(memfs.fs.existsSync as typeof fs.existsSync); + }); + + it('scans the given workingDir, not the process cwd', async () => { + vol.fromNestedJSON({ '/project/tsconfig.json': '{}' }); + + await expect(detectLanguage(packageManager({}), '/project')).resolves.toBe( + SupportedLanguage.TYPESCRIPT + ); + await expect(detectLanguage(packageManager({}), '/elsewhere')).resolves.toBe( + SupportedLanguage.JAVASCRIPT + ); + }); + + it('treats a jsconfig.json in the workingDir as JavaScript even with a typescript dependency', async () => { + vol.fromNestedJSON({ '/project/jsconfig.json': '{}' }); + + await expect(detectLanguage(packageManager({ typescript: '5.6.0' }), '/project')).resolves.toBe( + SupportedLanguage.JAVASCRIPT + ); + }); + + it('detects TypeScript from a compatible direct dependency without config files', async () => { + await expect(detectLanguage(packageManager({ typescript: '5.6.0' }), '/project')).resolves.toBe( + SupportedLanguage.TYPESCRIPT + ); + }); + + it('falls back to JavaScript when the typescript dependency is incompatible', async () => { + await expect(detectLanguage(packageManager({ typescript: '4.0.0' }), '/project')).resolves.toBe( + SupportedLanguage.JAVASCRIPT + ); + }); +}); diff --git a/code/core/src/cli/detectLanguage.ts b/code/core/src/cli/detectLanguage.ts new file mode 100644 index 000000000000..a822f0fab4c7 --- /dev/null +++ b/code/core/src/cli/detectLanguage.ts @@ -0,0 +1,105 @@ +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; + +import type { JsPackageManager } from 'storybook/internal/common'; +import { SupportedLanguage } from 'storybook/internal/types'; + +import semver from 'semver'; + +/** + * Detect whether the project should be treated as TypeScript or JavaScript. The js/tsconfig lookup + * happens in `workingDir`, which defaults to the process cwd for callers (like `storybook init`) + * that already run from the project root. + */ +export async function detectLanguage( + packageManager: JsPackageManager, + workingDir: string = process.cwd() +): Promise { + let language = SupportedLanguage.JAVASCRIPT; + + if (existsSync(join(workingDir, 'jsconfig.json'))) { + return language; + } + + const isTypescriptDirectDependency = !!packageManager.getAllDependencies().typescript; + + if (isTypescriptDirectDependency) { + const incompatibleReasons = await detectIncompatiblePackageVersions(packageManager); + if (incompatibleReasons.length === 0) { + language = SupportedLanguage.TYPESCRIPT; + } + } else { + // No direct dependency on TypeScript, but could be a transitive dependency + // This is eg the case for Nuxt projects, which support a recent version of TypeScript + // Check for tsconfig.json (https://www.typescriptlang.org/docs/handbook/tsconfig-json.html) + if (existsSync(join(workingDir, 'tsconfig.json'))) { + language = SupportedLanguage.TYPESCRIPT; + } + } + + return language; +} + +/** Check installed tooling versions for TypeScript compatibility constraints */ +export async function detectIncompatiblePackageVersions( + packageManager: JsPackageManager +): Promise { + const getModulePackageJSONVersion = async (pkg: string) => { + return (await packageManager.getModulePackageJSON(pkg))?.version ?? null; + }; + + const [ + typescriptVersion, + prettierVersion, + babelPluginTransformTypescriptVersion, + typescriptEslintParserVersion, + eslintPluginStorybookVersion, + ] = await Promise.all([ + getModulePackageJSONVersion('typescript'), + getModulePackageJSONVersion('prettier'), + getModulePackageJSONVersion('@babel/plugin-transform-typescript'), + getModulePackageJSONVersion('@typescript-eslint/parser'), + getModulePackageJSONVersion('eslint-plugin-storybook'), + ]); + + const satisfies = (version: string | null, range: string) => { + if (!version) { + return false; + } + return semver.satisfies(version, range, { includePrerelease: true }); + }; + + const incompatibleReasons: string[] = []; + + if (typescriptVersion && !satisfies(typescriptVersion, '>=4.9.0')) { + incompatibleReasons.push(`typescript ${typescriptVersion} is below 4.9.0`); + } + if (prettierVersion && !semver.gte(prettierVersion, '2.8.0')) { + incompatibleReasons.push(`prettier ${prettierVersion} is below 2.8.0`); + } + if ( + babelPluginTransformTypescriptVersion && + !satisfies(babelPluginTransformTypescriptVersion, '>=7.20.0') + ) { + incompatibleReasons.push( + `@babel/plugin-transform-typescript ${babelPluginTransformTypescriptVersion} is below 7.20.0` + ); + } + if (typescriptEslintParserVersion && !satisfies(typescriptEslintParserVersion, '>=5.44.0')) { + incompatibleReasons.push( + `@typescript-eslint/parser ${typescriptEslintParserVersion} is below 5.44.0` + ); + } + // Treat Storybook canary/prerelease versions (e.g. 0.0.0-pr-*) as compatible + if ( + eslintPluginStorybookVersion && + !eslintPluginStorybookVersion.startsWith('0.0.0-') && + !satisfies(eslintPluginStorybookVersion, '>=0.6.8') + ) { + incompatibleReasons.push( + `eslint-plugin-storybook ${eslintPluginStorybookVersion} is below 0.6.8` + ); + } + + return incompatibleReasons; +} diff --git a/code/core/src/cli/dev.ts b/code/core/src/cli/dev.ts index 1b59ba9e4223..7127cd82df3b 100644 --- a/code/core/src/cli/dev.ts +++ b/code/core/src/cli/dev.ts @@ -49,7 +49,7 @@ export const dev = async (cliOptions: CLIOptions) => { configType: 'DEVELOPMENT', ignorePreview: !!cliOptions.previewUrl && !cliOptions.forceBuildPreview, cache: cache as any, - packageJson: packageJson as unknown as PackageJson, // type-fest types are wrong here because we're on an outdated version of the package + packageJson: packageJson, } as Options; await withTelemetry( diff --git a/code/core/src/cli/eslintPlugin.ts b/code/core/src/cli/eslintPlugin.ts index 95163eb619cb..573fb360e811 100644 --- a/code/core/src/cli/eslintPlugin.ts +++ b/code/core/src/cli/eslintPlugin.ts @@ -315,16 +315,15 @@ export async function configureEslintPlugin({ } } else { logger.debug('No ESLint config file found, configuring in package.json instead'); - const { packageJson } = packageManager.primaryPackageJson; + const { packageJson, operationDir } = packageManager.primaryPackageJson; const existingExtends = normalizeExtends(packageJson.eslintConfig?.extends).filter(Boolean); - packageManager.writePackageJson({ - ...packageJson, - eslintConfig: { - ...packageJson.eslintConfig, - extends: [...existingExtends, 'plugin:storybook/recommended'], - }, - }); + packageJson.eslintConfig = { + ...packageJson.eslintConfig, + extends: [...existingExtends, 'plugin:storybook/recommended'], + }; + + packageManager.writePackageJson(packageJson, operationDir); } } diff --git a/code/core/src/cli/getStorybookData.test.ts b/code/core/src/cli/getStorybookData.test.ts new file mode 100644 index 000000000000..758789723bc7 --- /dev/null +++ b/code/core/src/cli/getStorybookData.test.ts @@ -0,0 +1,22 @@ +import { dirname, resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { getWorkingDir } from './getStorybookData.ts'; + +describe('getWorkingDir', () => { + it.each([ + ['.', process.cwd()], + ['.storybook', process.cwd()], + ['./.storybook', process.cwd()], + ['packages/foo/.storybook', resolve(process.cwd(), 'packages/foo')], + ['./apps/web/.storybook', resolve(process.cwd(), 'apps/web')], + ])('resolves relative configDir %j to %j', (configDir, expected) => { + expect(getWorkingDir(configDir)).toBe(expected); + }); + + it('uses the parent directory for absolute config dirs', () => { + const configDir = resolve('/projects/foo/.storybook'); + expect(getWorkingDir(configDir)).toBe(dirname(configDir)); + }); +}); diff --git a/code/core/src/cli/getStorybookData.ts b/code/core/src/cli/getStorybookData.ts new file mode 100644 index 000000000000..38f126dcc303 --- /dev/null +++ b/code/core/src/cli/getStorybookData.ts @@ -0,0 +1,94 @@ +import { dirname, isAbsolute, resolve } from 'node:path'; + +import type { PackageManagerName } from 'storybook/internal/common'; +import { JsPackageManagerFactory, getStorybookInfo } from 'storybook/internal/common'; +import { getStoriesPathsFromConfig } from 'storybook/internal/core-server'; +import { isCsfFactoryPreview, readConfig } from 'storybook/internal/csf-tools'; +import { logger } from 'storybook/internal/node-logger'; + +/** + * The project directory the config dir lives in, used to resolve story globs and to locate + * `tsconfig.json`/`jsconfig.json` for language detection. Taking `dirname` before resolving (not + * after) keeps `--config-dir .` anchored to the project root instead of its parent. + */ +export function getWorkingDir(configDir: string): string { + return isAbsolute(configDir) ? dirname(configDir) : resolve(process.cwd(), dirname(configDir)); +} + +/** + * Gathers the project metadata CLI commands need from the target Storybook: config, framework, + * package manager, installed version, and story paths. The canonical collector — `automigrate`, + * `doctor`, `add`, and `ai setup` all consume it. + */ +export const getStorybookData = async ({ + configDir: userDefinedConfigDir, + packageManagerName, +}: { + configDir?: string; + packageManagerName?: PackageManagerName; +}) => { + logger.debug('Getting Storybook info...'); + const { + mainConfig, + mainConfigPath, + configDir: configDirFromScript, + previewConfigPath, + versionSpecifier, + frameworkPackage, + rendererPackage, + renderer, + builderPackage, + addons, + } = await getStorybookInfo( + userDefinedConfigDir, + userDefinedConfigDir ? dirname(userDefinedConfigDir) : undefined + ); + + const configDir = userDefinedConfigDir || configDirFromScript || '.storybook'; + + const workingDir = getWorkingDir(configDir); + + logger.debug('Getting stories paths...'); + const storiesPaths = await getStoriesPathsFromConfig({ + stories: mainConfig.stories, + configDir, + workingDir, + }); + + logger.debug('Getting package manager...'); + const packageManager = JsPackageManagerFactory.getPackageManager({ + force: packageManagerName, + configDir, + storiesPaths, + }); + + logger.debug('Getting Storybook version...'); + const versionInstalled = (await packageManager.getModulePackageJSON('storybook'))?.version; + + logger.debug('Detecting CSF factory usage...'); + const hasCsfFactoryPreview = previewConfigPath + ? isCsfFactoryPreview(await readConfig(previewConfigPath)) + : false; + + return { + configDir, + workingDir, + mainConfig, + /** The version specifier of Storybook from the user's package.json */ + versionSpecifier, + /** The version of Storybook installed in the user's project */ + versionInstalled, + mainConfigPath, + previewConfigPath, + packageManager, + storiesPaths, + hasCsfFactoryPreview, + frameworkPackage, + rendererPackage, + renderer, + builderPackage, + addons, + }; +}; + +export type GetStorybookData = typeof getStorybookData; diff --git a/code/core/src/cli/index.ts b/code/core/src/cli/index.ts index a7054c44fce5..d8f611ca2edd 100644 --- a/code/core/src/cli/index.ts +++ b/code/core/src/cli/index.ts @@ -7,3 +7,5 @@ export * from './NpmOptions.ts'; export * from './eslintPlugin.ts'; export * from './globalSettings.ts'; export * from './AddonVitestService.ts'; +export * from './detectLanguage.ts'; +export * from './getStorybookData.ts'; diff --git a/code/core/src/common/index.ts b/code/core/src/common/index.ts index 9a1e57374ca4..03aa2355ebb8 100644 --- a/code/core/src/common/index.ts +++ b/code/core/src/common/index.ts @@ -26,6 +26,7 @@ export * from './utils/load-preview-or-config-file.ts'; export * from './utils/log-config.ts'; export * from './utils/normalize-stories.ts'; export * from './utils/paths.ts'; +export * from './utils/read-dependency-manifest.ts'; export * from './utils/readTemplate.ts'; export * from './utils/remove.ts'; export * from './utils/resolve-path-in-sb-cache.ts'; @@ -34,8 +35,11 @@ export * from './utils/template.ts'; export * from './utils/validate-config.ts'; export * from './utils/validate-configuration-files.ts'; export * from './utils/satisfies.ts'; +export * from './utils/babel.ts'; export * from './utils/formatter.ts'; export * from './utils/get-story-id.ts'; +export * from './utils/component-id.ts'; +export * from './utils/select-component-entry.ts'; export * from './utils/posix.ts'; export * from './utils/sync-main-preview-addons.ts'; export * from './utils/setup-addon-in-config.ts'; @@ -52,3 +56,4 @@ export * from './node-version.ts'; export { versions }; export { createFileSystemCache, FileSystemCache } from './utils/file-cache.ts'; +export { registerService } from '../shared/open-service/server.ts'; diff --git a/code/core/src/common/js-package-manager/JsPackageManager.ts b/code/core/src/common/js-package-manager/JsPackageManager.ts index 8b8322e77e27..796b5d20b9ba 100644 --- a/code/core/src/common/js-package-manager/JsPackageManager.ts +++ b/code/core/src/common/js-package-manager/JsPackageManager.ts @@ -271,12 +271,10 @@ export abstract class JsPackageManager { // Update cache with the written content // Ensure dependencies and devDependencies exist (even if empty) to match PackageJsonWithDepsAndDevDeps type - const cachedPackageJson: PackageJsonWithIndent = { - ...packageJsonToWrite, - dependencies: { ...(packageJsonToWrite.dependencies || {}) }, - devDependencies: { ...(packageJsonToWrite.devDependencies || {}) }, - peerDependencies: { ...(packageJsonToWrite.peerDependencies || {}) }, - }; + const cachedPackageJson = packageJsonToWrite as PackageJsonWithIndent; + cachedPackageJson.dependencies = { ...(packageJsonToWrite.dependencies || {}) }; + cachedPackageJson.devDependencies = { ...(packageJsonToWrite.devDependencies || {}) }; + cachedPackageJson.peerDependencies = { ...(packageJsonToWrite.peerDependencies || {}) }; cachedPackageJson[indentSymbol] = indent; JsPackageManager.packageJsonCache.set(packageJsonPath, cachedPackageJson); } @@ -545,6 +543,7 @@ export abstract class JsPackageManager { JsPackageManager.latestVersionCache.set(cacheKey, result); return result; } catch (e) { + logger.debug(`Failed to fetch the latest version for ${packageName}: ${String(e)}`); JsPackageManager.latestVersionCache.set(cacheKey, null); return null; } @@ -612,16 +611,11 @@ export abstract class JsPackageManager { public addScripts(scripts: Record) { const { operationDir, packageJson } = this.#getPrimaryPackageJson(); - this.writePackageJson( - { - ...packageJson, - scripts: { - ...packageJson.scripts, - ...scripts, - }, - }, - operationDir - ); + packageJson.scripts = { + ...packageJson.scripts, + ...scripts, + }; + this.writePackageJson(packageJson, operationDir); } public addPackageResolutions(versions: Record) { diff --git a/code/core/src/common/js-package-manager/NPMProxy.test.ts b/code/core/src/common/js-package-manager/NPMProxy.test.ts index 6df6fd3ce877..10ca14d44123 100644 --- a/code/core/src/common/js-package-manager/NPMProxy.test.ts +++ b/code/core/src/common/js-package-manager/NPMProxy.test.ts @@ -237,6 +237,78 @@ describe('NPM Proxy', () => { }); }); + describe('findInstallations', () => { + it('parses stdout JSON and ignores npm warnings written to stderr', async () => { + const executeCommandSpy = mockedExecuteCommand.mockResolvedValue({ + stdout: JSON.stringify({ + dependencies: { + '@storybook/react': { version: '1.2.3' }, + }, + }), + // npm routinely emits peer-dependency / deprecation warnings on stderr + stderr: 'npm warn ERESOLVE overriding peer dependency\nnpm warn deprecated foo@1.0.0', + } as any); + + const installations = await npmProxy.findInstallations(['@storybook/*']); + + expect(executeCommandSpy).toHaveBeenCalledWith( + expect.objectContaining({ + command: 'npm', + args: ['ls', '--json', '--depth=99'], + env: { FORCE_COLOR: 'false' }, + stdio: ['pipe', 'pipe', 'ignore'], + }) + ); + expect(installations).toMatchObject({ + dependencies: { + '@storybook/react': [{ version: '1.2.3' }], + }, + }); + }); + + it('retries with --depth=0 when the deep listing exits non-zero (e.g. peer dependency issues)', async () => { + mockedExecuteCommand.mockReset(); + mockedExecuteCommand + .mockRejectedValueOnce(new Error('npm error code ELSPROBLEMS')) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + dependencies: { + '@storybook/react': { version: '1.2.3' }, + }, + }), + } as any); + + const installations = await npmProxy.findInstallations(['@storybook/*']); + + expect(mockedExecuteCommand).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ args: ['ls', '--json', '--depth=99'] }) + ); + expect(mockedExecuteCommand).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ args: ['ls', '--json', '--depth=0'] }) + ); + expect(installations).toMatchObject({ + dependencies: { + '@storybook/react': [{ version: '1.2.3' }], + }, + }); + }); + + it('returns undefined and never treats stderr as the dependency source when stdout is empty', async () => { + mockedExecuteCommand.mockReset(); + // Valid-looking JSON on stderr must never be parsed as the result. + const stderrJson = JSON.stringify({ + dependencies: { '@storybook/react': { version: '9.9.9' } }, + }); + mockedExecuteCommand.mockResolvedValue({ stdout: undefined, stderr: stderrJson } as any); + + const installations = await npmProxy.findInstallations(['@storybook/*']); + + expect(installations).toBeUndefined(); + }); + }); + describe('latestVersion', () => { it('without constraint it returns the latest version', async () => { const executeCommandSpy = mockedExecuteCommand.mockResolvedValue({ stdout: '5.3.19' } as any); diff --git a/code/core/src/common/js-package-manager/NPMProxy.ts b/code/core/src/common/js-package-manager/NPMProxy.ts index bb7f027fd90d..f7b2dfc44a8c 100644 --- a/code/core/src/common/js-package-manager/NPMProxy.ts +++ b/code/core/src/common/js-package-manager/NPMProxy.ts @@ -1,5 +1,4 @@ import { readFileSync } from 'node:fs'; -import { platform } from 'node:os'; import { join } from 'node:path'; import { logger, prompt } from 'storybook/internal/node-logger'; @@ -152,14 +151,14 @@ export class NPMProxy extends JsPackageManager { public async findInstallations(pattern: string[], { depth = 99 }: { depth?: number } = {}) { const exec = ({ packageDepth }: { packageDepth: number }) => { - const pipeToNull = platform() === 'win32' ? '2>NUL' : '2>/dev/null'; return executeCommand({ command: 'npm', - args: ['ls', '--json', `--depth=${packageDepth}`, pipeToNull], + args: ['ls', '--json', `--depth=${packageDepth}`], env: { FORCE_COLOR: 'false', }, cwd: this.instanceDir, + stdio: ['pipe', 'pipe', 'ignore'], }); }; diff --git a/code/core/src/common/utils/__tests__/resolve-import.test.ts b/code/core/src/common/utils/__tests__/resolve-import.test.ts new file mode 100644 index 000000000000..58cf27272666 --- /dev/null +++ b/code/core/src/common/utils/__tests__/resolve-import.test.ts @@ -0,0 +1,53 @@ +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { resolveImport } from '../interpret-files.ts'; + +const temporaryDirectories: string[] = []; + +function createTemporaryDirectory() { + const directory = mkdtempSync(join(tmpdir(), 'storybook-resolve-import-')); + temporaryDirectories.push(directory); + return directory; +} + +function writeFixture(directory: string, relativePath: string) { + const filePath = join(directory, relativePath); + mkdirSync(dirname(filePath), { recursive: true }); + writeFileSync(filePath, 'export {};'); + return realpathSync(filePath); +} + +describe('resolveImport', () => { + afterEach(() => { + for (const directory of temporaryDirectories) { + rmSync(directory, { force: true, recursive: true }); + } + temporaryDirectories.length = 0; + }); + + it('falls back from .js imports to .tsx files', () => { + const directory = createTemporaryDirectory(); + const expected = writeFixture(directory, 'Component.tsx'); + + expect(resolveImport('./Component.js', { basedir: directory })).toBe(expected); + }); + + it('prefers .ts files before the .tsx fallback for .js imports', () => { + const directory = createTemporaryDirectory(); + const expected = writeFixture(directory, 'Component.ts'); + writeFixture(directory, 'Component.tsx'); + + expect(resolveImport('./Component.js', { basedir: directory })).toBe(expected); + }); + + it('continues to fall back from .jsx imports to .tsx files', () => { + const directory = createTemporaryDirectory(); + const expected = writeFixture(directory, 'Component.tsx'); + + expect(resolveImport('./Component.jsx', { basedir: directory })).toBe(expected); + }); +}); diff --git a/code/core/src/common/utils/babel.ts b/code/core/src/common/utils/babel.ts new file mode 100644 index 000000000000..a14f8d5ce628 --- /dev/null +++ b/code/core/src/common/utils/babel.ts @@ -0,0 +1,36 @@ +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; + +import { logger } from 'storybook/internal/node-logger'; + +const require = createRequire(import.meta.url); + +/** + * Reads the major version of the `@babel/preset-env` that is actually installed in the consumer's + * project, at runtime. + * + * We can't rely on `@babel/core`'s exported `version`: it is a different package whose version can + * diverge from `@babel/preset-env`, and it gets resolved when Storybook is bundled rather than from + * the user's project. We instead resolve and read preset-env's own `package.json`. This must stay + * synchronous because some call sites (e.g. the Next.js babel preset factory and the swc loader + * transform) are synchronous. + * + * This is primarily used to gate the `bugfixes` option, which is valid in preset-env v7 but was + * removed in v8 (where it throws when set, as the bugfix plugins are always enabled). + * + * @returns The major version (e.g. `7` or `8`), or `0` when preset-env cannot be resolved. + */ +export const getBabelPresetEnvMajor = (): number | undefined => { + try { + const pkgPath = require.resolve('@babel/preset-env/package.json'); + const { version } = JSON.parse(readFileSync(pkgPath, 'utf8')); + const parsed = Number.parseInt(version, 10); + return Number.isNaN(parsed) ? undefined : parsed; + } catch { + logger.debug( + 'Could not determine @babel/preset-env version in use. In case of runtime errors with \@babel/preset-env, you may need to set the babelRemoveBugfixes feature flag in your `main.ts` file.' + ); + + return undefined; + } +}; diff --git a/code/core/src/common/utils/command.test.ts b/code/core/src/common/utils/command.test.ts new file mode 100644 index 000000000000..977e612ebc74 --- /dev/null +++ b/code/core/src/common/utils/command.test.ts @@ -0,0 +1,449 @@ +import { existsSync } from 'node:fs'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// eslint-disable-next-line depend/ban-dependencies +import { execa, execaCommandSync } from 'execa'; + +import { executeCommand, executeCommandSync } from './command.ts'; + +vi.mock('storybook/internal/node-logger', () => ({ + logger: { + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, + prompt: { + getPreferredStdio: vi.fn(() => 'pipe'), + }, +})); + +vi.mock('execa', () => ({ + execa: vi.fn(), + execaCommandSync: vi.fn(), + execaNode: vi.fn(), +})); + +vi.mock('node:fs', () => ({ + existsSync: vi.fn(() => false), +})); + +const mockedExeca = vi.mocked(execa); +const mockedExecaCommandSync = vi.mocked(execaCommandSync); +const mockedExistsSync = vi.mocked(existsSync); + +describe('command', () => { + beforeEach(() => { + vi.resetAllMocks(); + // Default: no executables found in PATH + mockedExistsSync.mockReturnValue(false); + }); + + describe('executeCommand on Windows', () => { + const originalPlatform = process.platform; + + beforeEach(() => { + Object.defineProperty(process, 'platform', { + value: 'win32', + configurable: true, + }); + }); + + afterEach(() => { + Object.defineProperty(process, 'platform', { + value: originalPlatform, + configurable: true, + }); + }); + + it('should use .cmd when found in PATH for pnpm', async () => { + mockedExistsSync.mockImplementation((p) => String(p).endsWith('pnpm.cmd')); + mockedExeca.mockResolvedValueOnce({ + stdout: 'success', + stderr: '', + } as any); + + await executeCommand({ + command: 'pnpm', + args: ['--version'], + }); + + expect(mockedExeca).toHaveBeenCalledTimes(1); + expect(mockedExeca).toHaveBeenCalledWith( + 'pnpm.cmd', + ['--version'], + expect.objectContaining({ + encoding: 'utf8', + cleanup: true, + }) + ); + }); + + it('should use .exe when .cmd not found but .exe exists in PATH', async () => { + mockedExistsSync.mockImplementation((p) => String(p).endsWith('pnpm.exe')); + mockedExeca.mockResolvedValueOnce({ + stdout: 'success', + stderr: '', + } as any); + + await executeCommand({ + command: 'pnpm', + args: ['--version'], + }); + + expect(mockedExeca).toHaveBeenCalledTimes(1); + expect(mockedExeca).toHaveBeenCalledWith( + 'pnpm.exe', + ['--version'], + expect.objectContaining({ + encoding: 'utf8', + cleanup: true, + }) + ); + }); + + it('should use .ps1 when neither .cmd nor .exe found but .ps1 exists in PATH', async () => { + mockedExistsSync.mockImplementation((p) => String(p).endsWith('pnpm.ps1')); + mockedExeca.mockResolvedValueOnce({ + stdout: 'success', + stderr: '', + } as any); + + await executeCommand({ + command: 'pnpm', + args: ['--version'], + }); + + expect(mockedExeca).toHaveBeenCalledTimes(1); + expect(mockedExeca).toHaveBeenCalledWith( + 'pnpm.ps1', + ['--version'], + expect.objectContaining({ + encoding: 'utf8', + cleanup: true, + }) + ); + }); + + it('should fall back to bare command when no variation found in PATH', async () => { + mockedExistsSync.mockReturnValue(false); + mockedExeca.mockResolvedValueOnce({ + stdout: 'success', + stderr: '', + } as any); + + await executeCommand({ + command: 'pnpm', + args: ['--version'], + }); + + expect(mockedExeca).toHaveBeenCalledTimes(1); + expect(mockedExeca).toHaveBeenCalledWith('pnpm', ['--version'], expect.anything()); + }); + + it('should prefer .cmd over .exe when both exist in PATH', async () => { + mockedExistsSync.mockImplementation( + (p) => String(p).endsWith('pnpm.exe') || String(p).endsWith('pnpm.cmd') + ); + mockedExeca.mockResolvedValueOnce({ + stdout: 'success', + stderr: '', + } as any); + + await executeCommand({ + command: 'pnpm', + args: ['--version'], + }); + + expect(mockedExeca).toHaveBeenCalledTimes(1); + expect(mockedExeca).toHaveBeenCalledWith('pnpm.cmd', ['--version'], expect.anything()); + }); + + it('should propagate errors from the resolved command', async () => { + mockedExistsSync.mockReturnValue(false); + mockedExeca.mockRejectedValueOnce({ + stderr: 'Some other error', + message: 'Command failed with different error', + }); + + await expect( + executeCommand({ + command: 'pnpm', + args: ['--version'], + }) + ).rejects.toEqual({ + stderr: 'Some other error', + message: 'Command failed with different error', + }); + + expect(mockedExeca).toHaveBeenCalledTimes(1); + }); + + it('should propagate errors when resolved command is not found', async () => { + mockedExistsSync.mockImplementation((p) => String(p).endsWith('pnpm.cmd')); + const error = { + stderr: "'pnpm.cmd' is not recognized as an internal or external command", + message: 'Command failed', + }; + mockedExeca.mockRejectedValueOnce(error); + + await expect( + executeCommand({ + command: 'pnpm', + args: ['--version'], + }) + ).rejects.toEqual(error); + + expect(mockedExeca).toHaveBeenCalledTimes(1); + }); + + it('should work for npm command', async () => { + mockedExistsSync.mockImplementation((p) => String(p).endsWith('npm.cmd')); + mockedExeca.mockResolvedValueOnce({ + stdout: 'success', + stderr: '', + } as any); + + await executeCommand({ + command: 'npm', + args: ['--version'], + }); + + expect(mockedExeca).toHaveBeenCalledWith('npm.cmd', ['--version'], expect.anything()); + }); + + it('should work for yarn command', async () => { + mockedExistsSync.mockImplementation((p) => String(p).endsWith('yarn.cmd')); + mockedExeca.mockResolvedValueOnce({ + stdout: 'success', + stderr: '', + } as any); + + await executeCommand({ + command: 'yarn', + args: ['--version'], + }); + + expect(mockedExeca).toHaveBeenCalledWith('yarn.cmd', ['--version'], expect.anything()); + }); + + it('should not modify unknown commands on Windows', async () => { + mockedExeca.mockResolvedValueOnce({ + stdout: 'success', + stderr: '', + } as any); + + await executeCommand({ + command: 'git', + args: ['--version'], + }); + + expect(mockedExeca).toHaveBeenCalledWith('git', ['--version'], expect.anything()); + }); + }); + + describe('executeCommand on non-Windows', () => { + const originalPlatform = process.platform; + + beforeEach(() => { + Object.defineProperty(process, 'platform', { + value: 'linux', + configurable: true, + }); + }); + + afterEach(() => { + Object.defineProperty(process, 'platform', { + value: originalPlatform, + configurable: true, + }); + }); + + it('should use command as-is for pnpm on Linux', async () => { + mockedExeca.mockResolvedValueOnce({ + stdout: 'success', + stderr: '', + } as any); + + await executeCommand({ + command: 'pnpm', + args: ['--version'], + }); + + expect(mockedExeca).toHaveBeenCalledWith('pnpm', ['--version'], expect.anything()); + }); + + it('should use command as-is for npm on macOS', async () => { + Object.defineProperty(process, 'platform', { + value: 'darwin', + configurable: true, + }); + + mockedExeca.mockResolvedValueOnce({ + stdout: 'success', + stderr: '', + } as any); + + await executeCommand({ + command: 'npm', + args: ['install'], + }); + + expect(mockedExeca).toHaveBeenCalledWith('npm', ['install'], expect.anything()); + }); + }); + + describe('executeCommandSync on Windows', () => { + const originalPlatform = process.platform; + + beforeEach(() => { + Object.defineProperty(process, 'platform', { + value: 'win32', + configurable: true, + }); + }); + + afterEach(() => { + Object.defineProperty(process, 'platform', { + value: originalPlatform, + configurable: true, + }); + }); + + it('should try .cmd first for pnpm and succeed', () => { + mockedExecaCommandSync.mockReturnValueOnce({ + stdout: '10.0.0', + stderr: '', + } as any); + + const result = executeCommandSync({ + command: 'pnpm', + args: ['--version'], + }); + + expect(result).toBe('10.0.0'); + expect(mockedExecaCommandSync).toHaveBeenCalledTimes(1); + expect(mockedExecaCommandSync).toHaveBeenCalledWith( + 'pnpm.cmd --version', + expect.objectContaining({ + encoding: 'utf8', + cleanup: true, + }) + ); + }); + + it('should try .exe after .cmd fails with "not recognized" error', () => { + // First call (.cmd) fails + mockedExecaCommandSync.mockImplementationOnce(() => { + throw { + stderr: "'pnpm.cmd' is not recognized as an internal or external command", + message: 'Command failed', + }; + }); + + // Second call (.exe) succeeds + mockedExecaCommandSync.mockReturnValueOnce({ + stdout: '10.0.0', + stderr: '', + } as any); + + const result = executeCommandSync({ + command: 'pnpm', + args: ['--version'], + }); + + expect(result).toBe('10.0.0'); + expect(mockedExecaCommandSync).toHaveBeenCalledTimes(2); + expect(mockedExecaCommandSync).toHaveBeenNthCalledWith( + 1, + 'pnpm.cmd --version', + expect.anything() + ); + expect(mockedExecaCommandSync).toHaveBeenNthCalledWith( + 2, + 'pnpm.exe --version', + expect.anything() + ); + }); + + it('should throw error immediately if first call fails with non-"not recognized" error', () => { + mockedExecaCommandSync.mockImplementationOnce(() => { + throw { + stderr: 'Some other error', + message: 'Command failed with different error', + }; + }); + + expect(() => + executeCommandSync({ + command: 'pnpm', + args: ['--version'], + }) + ).toThrow(); + + expect(mockedExecaCommandSync).toHaveBeenCalledTimes(1); + }); + }); + + describe('executeCommandSync on non-Windows', () => { + const originalPlatform = process.platform; + + beforeEach(() => { + Object.defineProperty(process, 'platform', { + value: 'linux', + configurable: true, + }); + }); + + afterEach(() => { + Object.defineProperty(process, 'platform', { + value: originalPlatform, + configurable: true, + }); + }); + + it('should use command as-is for pnpm on Linux', () => { + mockedExecaCommandSync.mockReturnValueOnce({ + stdout: '10.0.0', + stderr: '', + } as any); + + const result = executeCommandSync({ + command: 'pnpm', + args: ['--version'], + }); + + expect(result).toBe('10.0.0'); + expect(mockedExecaCommandSync).toHaveBeenCalledWith('pnpm --version', expect.anything()); + }); + }); + + describe('ignoreError option', () => { + it('should not throw unhandled rejection when ignoreError is true for executeCommand', async () => { + mockedExeca.mockRejectedValueOnce(new Error('Command failed')); + + const promise = executeCommand({ + command: 'pnpm', + args: ['--version'], + ignoreError: true, + }); + + // The .catch() handler in executeCommand prevents unhandled rejection warnings, + // but the returned promise still rejects since it's the original ResultPromise + await expect(promise).rejects.toThrow('Command failed'); + }); + + it('should return empty string when ignoreError is true for executeCommandSync', () => { + mockedExecaCommandSync.mockImplementationOnce(() => { + throw new Error('Command failed'); + }); + + const result = executeCommandSync({ + command: 'pnpm', + args: ['--version'], + ignoreError: true, + }); + + expect(result).toBe(''); + }); + }); +}); diff --git a/code/core/src/common/utils/command.ts b/code/core/src/common/utils/command.ts index 052a94df69b7..4fff01c9b8f0 100644 --- a/code/core/src/common/utils/command.ts +++ b/code/core/src/common/utils/command.ts @@ -1,4 +1,6 @@ import { logger, prompt } from 'storybook/internal/node-logger'; +import { existsSync } from 'node:fs'; +import { delimiter, join } from 'node:path'; // eslint-disable-next-line depend/ban-dependencies import { @@ -49,7 +51,9 @@ function getExecaOptions({ export function executeCommand(options: ExecuteCommandOptions): ResultPromise { const { command, args = [], ignoreError = false } = options; logger.debug(`Executing command: ${command} ${args.join(' ')}`); - const execaProcess = execa(resolveCommand(command), args, getExecaOptions(options)); + + const commandVariations = resolveCommand(command); + const execaProcess = tryCommandVariations(commandVariations, args, getExecaOptions(options)); if (ignoreError) { execaProcess.catch(() => { @@ -63,11 +67,12 @@ export function executeCommand(options: ExecuteCommandOptions): ResultPromise { export function executeCommandSync(options: ExecuteCommandOptions): string { const { command, args = [], ignoreError = false } = options; try { - const commandResult = execaCommandSync( - [resolveCommand(command), ...args].join(' '), + const commandVariations = resolveCommand(command); + return tryCommandVariationsSync( + commandVariations, + args, getExecaOptions(options) as SyncOptions ); - return typeof commandResult.stdout === 'string' ? commandResult.stdout : ''; } catch (err) { if (!ignoreError) { throw err; @@ -90,6 +95,91 @@ export function executeNodeCommand({ }); } +/** + * Check if an error is a "command not found" error on Windows. This happens when trying to execute + * a command that doesn't exist. + * + * @param error - The error to check + * @returns True if the error is a "command not found" error + */ +function isCommandNotFoundError(error: any): boolean { + if (!error) { + return false; + } + + const stderr = error.stderr || ''; + return stderr.includes('is not recognized as an internal or external command'); +} + +/** Helper to check if we should continue trying command variations. */ +function shouldRetry(error: any, isLastVariation: boolean): boolean { + return isCommandNotFoundError(error) && !isLastVariation; +} + +/** + * Check if a command is available in PATH by looking for the file directly. + * This avoids spawning a process just to check existence. + */ +function isExecutableInPath(command: string): boolean { + const pathDirs = (process.env.PATH || '').split(delimiter); + return pathDirs.some((dir) => existsSync(join(dir, command))); +} + +function tryCommandVariations( + commandVariations: string[], + args: string[], + options: Options +): ResultPromise { + if (commandVariations.length <= 1) { + return execa(commandVariations[0], args, options); + } + + // Resolve the best variation synchronously via PATH lookup + for (const cmd of commandVariations.slice(0, -1)) { + if (isExecutableInPath(cmd)) { + logger.debug(`Resolved command variation: ${cmd}`); + return execa(cmd, args, options); + } + } + + // Fallback to the last variation (bare command name) + return execa(commandVariations[commandVariations.length - 1], args, options); +} + +/** + * Synchronously try executing a command with multiple variations until one succeeds. + * + * @param commandVariations - Array of command variations to try + * @param args - Command arguments + * @param options - Execa sync options + * @returns Stdout from the successful command + */ +function tryCommandVariationsSync( + commandVariations: string[], + args: string[], + options: SyncOptions +): string { + let lastError: any; + + for (let i = 0; i < commandVariations.length; i++) { + const cmd = commandVariations[i]; + try { + const commandResult = execaCommandSync([cmd, ...args].join(' '), options); + return typeof commandResult.stdout === 'string' ? commandResult.stdout : ''; + } catch (error: any) { + lastError = error; + + if (!shouldRetry(error, i === commandVariations.length - 1)) { + throw error; + } + + logger.debug(`Command "${cmd}" not found, trying next variation...`); + } + } + + throw lastError; +} + /** * Resolve the actual executable name for a given command on the current platform. * @@ -97,9 +187,11 @@ export function executeNodeCommand({ * * - Many Node-based CLIs (npm, npx, pnpm, yarn, vite, eslint, anything in node_modules/.bin) do NOT * ship as real executables on Windows. - * - Instead, they install *.cmd and *.ps1 “shim” files. + * - Instead, they install *.cmd and *.ps1 "shim" files. * - When using execa/child_process with `shell: false` (our default), Node WILL NOT resolve these * shims. -> calling execa("npx") throws ENOENT on Windows. + * - HOWEVER, package managers like pnpm can be installed via system tools (Mise, Scoop) as native + * executables (.exe), not as Node packages. In these cases, the .cmd shim doesn't exist. * * This helper normalizes command names so they can be spawned cross-platform without using `shell: * true`. @@ -108,7 +200,8 @@ export function executeNodeCommand({ * * - If on Windows: * - * - For known shim-based commands, append `.cmd` (e.g., "npx" → "npx.cmd"). + * - For known shim-based commands, return an array of variations to try in order: [command.exe, + * command.cmd, command.ps1, command] * - For everything else, return the name unchanged. * - On non-Windows, return command unchanged. * @@ -118,9 +211,9 @@ export function executeNodeCommand({ * - If Storybook adds new internal commands later, extend the list. * * @param {string} command - The executable name passed into executeCommand. - * @returns {string} - The normalized executable name safe for passing to execa. + * @returns {string[]} - Array of command variations to try (most specific first). */ -function resolveCommand(command: string): string { +function resolveCommand(command: string): string[] { // Commands known to require .cmd on Windows (node-based & shim-installed) const WINDOWS_SHIM_COMMANDS = new Set([ 'npm', @@ -133,12 +226,17 @@ function resolveCommand(command: string): string { ]); if (process.platform !== 'win32') { - return command; + return [command]; } if (WINDOWS_SHIM_COMMANDS.has(command)) { - return `${command}.cmd`; + // On Windows, try multiple variations in order of likelihood: + // 1. .cmd - CMD shim (most common: npm-installed packages, corepack, PowerShell script) + // 2. .exe - native executable (less common: Scoop/Mise installations) + // 3. .ps1 - PowerShell shim (rare but possible) + // 4. bare command - fallback + return [`${command}.cmd`, `${command}.exe`, `${command}.ps1`, command]; } - return command; + return [command]; } diff --git a/code/core/src/common/utils/component-id.ts b/code/core/src/common/utils/component-id.ts new file mode 100644 index 000000000000..d35d8d5958e6 --- /dev/null +++ b/code/core/src/common/utils/component-id.ts @@ -0,0 +1,13 @@ +import type { IndexEntry } from 'storybook/internal/types'; + +/** + * Derives the componentId portion of a story index entry id. + * + * Storybook story ids have the shape `--`; the prefix before the first + * `--` is the stable component identifier shared by every story (and attached docs entry) that + * targets the same component. Centralising the split keeps the docgen service, manifest generator, + * and any future consumers on one definition. + */ +export function getComponentIdFromEntry(entry: Pick): string { + return entry.id.split('--')[0]; +} diff --git a/code/core/src/common/utils/framework.ts b/code/core/src/common/utils/framework.ts index f7af0719bf69..136507d3b614 100644 --- a/code/core/src/common/utils/framework.ts +++ b/code/core/src/common/utils/framework.ts @@ -6,6 +6,7 @@ export const frameworkToRenderer: Record< > = { // frameworks [SupportedFramework.ANGULAR]: SupportedRenderer.ANGULAR, + [SupportedFramework.ANGULAR_VITE]: SupportedRenderer.ANGULAR, [SupportedFramework.EMBER]: SupportedRenderer.EMBER, [SupportedFramework.HTML_VITE]: SupportedRenderer.HTML, [SupportedFramework.NEXTJS]: SupportedRenderer.REACT, @@ -42,6 +43,7 @@ export const frameworkToRenderer: Record< export const frameworkToBuilder: Record = { // frameworks [SupportedFramework.ANGULAR]: SupportedBuilder.WEBPACK5, + [SupportedFramework.ANGULAR_VITE]: SupportedBuilder.VITE, [SupportedFramework.EMBER]: SupportedBuilder.WEBPACK5, [SupportedFramework.HTML_VITE]: SupportedBuilder.VITE, [SupportedFramework.NEXTJS]: SupportedBuilder.WEBPACK5, diff --git a/code/core/src/common/utils/get-addon-names.test.ts b/code/core/src/common/utils/get-addon-names.test.ts index d2e692bdc2eb..645b13e15aa8 100644 --- a/code/core/src/common/utils/get-addon-names.test.ts +++ b/code/core/src/common/utils/get-addon-names.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { getAddonNames } from './get-addon-names.ts'; +import { getAddonNames, normalizeAddonName } from './get-addon-names.ts'; describe('getAddonNames', () => { it('should extract addon names from simple strings', () => { @@ -94,3 +94,23 @@ describe('getAddonNames', () => { ]); }); }); + +describe('normalizeAddonName', () => { + it('returns the bare name for a string entry', () => { + expect(normalizeAddonName('@storybook/addon-mcp')).toBe('@storybook/addon-mcp'); + }); + + it('returns the name for an object entry', () => { + expect(normalizeAddonName({ name: '@storybook/addon-mcp' })).toBe('@storybook/addon-mcp'); + }); + + it('resolves an absolute path to its bare package name', () => { + expect(normalizeAddonName('/sandbox/project/node_modules/@storybook/addon-mcp')).toBe( + '@storybook/addon-mcp' + ); + }); + + it('returns undefined for relative local addon paths', () => { + expect(normalizeAddonName('./local-addon')).toBeUndefined(); + }); +}); diff --git a/code/core/src/common/utils/get-addon-names.ts b/code/core/src/common/utils/get-addon-names.ts index 9c24ba4fcaee..179d601c64c8 100644 --- a/code/core/src/common/utils/get-addon-names.ts +++ b/code/core/src/common/utils/get-addon-names.ts @@ -2,35 +2,43 @@ import type { StorybookConfig } from 'storybook/internal/types'; import { normalizePath } from './normalize-path.ts'; +type AddonEntry = NonNullable[number]; + +/** + * Resolve an addon config entry (string, object, or absolute path) to its bare package name. + * Returns `undefined` for relative-path local addons. + */ +export const normalizeAddonName = (addon: AddonEntry): string | undefined => { + let name = ''; + if (typeof addon === 'string') { + name = addon; + } else if (typeof addon === 'object') { + name = addon.name; + } + + if (name.startsWith('.')) { + return undefined; + } + + // Ensure posix paths for plugin name sniffing + name = normalizePath(name); + + // For absolute paths, pnpm and yarn pnp, + // Remove everything before and including "node_modules/" + name = name.replace(/.*node_modules\//, ''); + + // Further clean up package names + return name + .replace(/\/dist\/.*$/, '') + .replace(/\.[mc]?[tj]?sx?$/, '') + .replace(/\/register$/, '') + .replace(/\/manager$/, '') + .replace(/\/preset$/, ''); +}; + export const getAddonNames = (mainConfig: StorybookConfig): string[] => { const addons = mainConfig.addons || []; - const addonList = addons.map((addon) => { - let name = ''; - if (typeof addon === 'string') { - name = addon; - } else if (typeof addon === 'object') { - name = addon.name; - } - - if (name.startsWith('.')) { - return undefined; - } - - // Ensure posix paths for plugin name sniffing - name = normalizePath(name); - - // For absolute paths, pnpm and yarn pnp, - // Remove everything before and including "node_modules/" - name = name.replace(/.*node_modules\//, ''); - - // Further clean up package names - return name - .replace(/\/dist\/.*$/, '') - .replace(/\.[mc]?[tj]?sx?$/, '') - .replace(/\/register$/, '') - .replace(/\/manager$/, '') - .replace(/\/preset$/, ''); - }); + const addonList = addons.map(normalizeAddonName); return addonList.filter((item): item is NonNullable => item != null); }; diff --git a/code/core/src/common/utils/get-storybook-info.ts b/code/core/src/common/utils/get-storybook-info.ts index 236508edb476..3652807aba3a 100644 --- a/code/core/src/common/utils/get-storybook-info.ts +++ b/code/core/src/common/utils/get-storybook-info.ts @@ -38,6 +38,7 @@ export const rendererPackages: Record = { export const frameworkPackages: Record = { '@storybook/angular': SupportedFramework.ANGULAR, + '@storybook/angular-vite': SupportedFramework.ANGULAR_VITE, '@storybook/ember': SupportedFramework.EMBER, '@storybook/html-vite': SupportedFramework.HTML_VITE, '@storybook/nextjs': SupportedFramework.NEXTJS, @@ -139,12 +140,17 @@ export const getConfigInfo = (configDir?: string) => { export const getStorybookInfo = async ( configDir = '.storybook', - cwd?: string + cwd?: string, + { skipCache }: { skipCache?: boolean } = {} ): Promise => { const configInfo = getConfigInfo(configDir); const mainConfig = (await loadMainConfig({ configDir: configInfo.configDir, cwd, + // When the main config may have been rewritten earlier in the same process (e.g. an + // automigration switching frameworks), callers must skip the module cache to read the + // current on-disk config instead of a stale, previously-evaluated version. + skipCache, })) as StorybookConfigRaw; invariant(mainConfig, `Unable to find or evaluate ${configInfo.mainConfigPath}`); diff --git a/code/core/src/common/utils/get-storybook-refs.ts b/code/core/src/common/utils/get-storybook-refs.ts index b12f8aabbd4b..853df1113a14 100644 --- a/code/core/src/common/utils/get-storybook-refs.ts +++ b/code/core/src/common/utils/get-storybook-refs.ts @@ -1,13 +1,12 @@ import { readFile } from 'node:fs/promises'; -import { dirname, join } from 'node:path'; +import { dirname } from 'node:path'; -import { logger } from 'storybook/internal/node-logger'; import type { Options, Ref } from 'storybook/internal/types'; import * as pkg from 'empathic/package'; -import * as resolve from 'empathic/resolve'; import { getProjectRoot } from './paths.ts'; +import { readDependencyManifest } from './read-dependency-manifest.ts'; export const getAutoRefs = async (options: Options): Promise> => { const location = pkg.up({ cwd: options.configDir, last: getProjectRoot() }); @@ -22,23 +21,10 @@ export const getAutoRefs = async (options: Options): Promise const list = await Promise.all( deps.map(async (d) => { - try { - const l = resolve.from(directory, join(d, 'package.json')); + const manifest = await readDependencyManifest(directory, d); - const { storybook, name, version } = - JSON.parse(await readFile(l, { encoding: 'utf8' })) || {}; - - if (storybook?.url) { - return { id: name, ...storybook, version }; - } - } catch (error) { - if ((error as any).code === 'ERR_PACKAGE_PATH_NOT_EXPORTED') { - // silent warning because user can't do anything about it - // "package.json" is not part of the package's "exports" field in its package.json - return undefined; - } - logger.warn(`unable to find package.json for ${d}`); - return undefined; + if (manifest?.storybook?.url) { + return { id: manifest.name, ...manifest.storybook, version: manifest.version }; } return undefined; }) diff --git a/code/core/src/common/utils/interpret-files.ts b/code/core/src/common/utils/interpret-files.ts index 97930031ae4a..dc4b186748fe 100644 --- a/code/core/src/common/utils/interpret-files.ts +++ b/code/core/src/common/utils/interpret-files.ts @@ -1,20 +1,18 @@ import { existsSync } from 'node:fs'; import { extname } from 'node:path'; -import resolve from 'resolve'; - -export const supportedExtensions = [ - '.js', - '.ts', - '.jsx', - '.tsx', - '.mjs', - '.mts', - '.mtsx', - '.cjs', - '.cts', - '.ctsx', -] as const; +import { ResolverFactory } from 'oxc-resolver'; + +import { storybookConfigExtensions } from '../../shared/constants/extensions.ts'; + +const typescriptFallbackExtensions: Record = { + '.js': ['.ts', '.tsx'], + '.mjs': ['.mts'], + '.cjs': ['.cts'], + '.jsx': ['.tsx'], +}; + +export const supportedExtensions = storybookConfigExtensions; export function getInterpretedFile(pathToFile: string) { return supportedExtensions @@ -22,21 +20,18 @@ export function getInterpretedFile(pathToFile: string) { .find((candidate) => existsSync(candidate)); } -export function resolveImport(id: string, options: resolve.SyncOpts): string { - const mergedOptions: resolve.SyncOpts = { - extensions: supportedExtensions, - packageFilter(pkg) { - // Prefer 'module' over 'main' if available - if (pkg.module) { - pkg.main = pkg.module; - } - return pkg; - }, - ...options, - }; +const importResolver = new ResolverFactory({ + extensions: [...supportedExtensions], + mainFields: ['module', 'main'], +}); +export interface ResolveImportOptions { + basedir: string; +} + +export function resolveImport(id: string, options: ResolveImportOptions): string { try { - return resolve.sync(id, { ...mergedOptions }); + return resolveSync(id, options.basedir); } catch (error) { const ext = extname(id); @@ -44,15 +39,31 @@ export function resolveImport(id: string, options: resolve.SyncOpts): string { // a TypeScript file. This can happen in ES modules as TypeScript requires to import other // TypeScript files with .js extensions // https://www.typescriptlang.org/docs/handbook/esm-node.html#type-in-packagejson-and-new-extensions - const newId = ['.js', '.mjs', '.cjs'].includes(ext) - ? `${id.slice(0, -2)}ts` - : ext === '.jsx' - ? `${id.slice(0, -3)}tsx` - : null; + const fallbackExtensions = typescriptFallbackExtensions[ext]; - if (!newId) { + if (!fallbackExtensions) { throw error; } - return resolve.sync(newId, { ...mergedOptions, extensions: [] }); + + let fallbackError: unknown = error; + const baseId = id.slice(0, -ext.length); + + for (const fallbackExtension of fallbackExtensions) { + try { + return resolveSync(`${baseId}${fallbackExtension}`, options.basedir); + } catch (err) { + fallbackError = err; + } + } + + throw fallbackError; + } +} + +function resolveSync(id: string, basedir: string): string { + const result = importResolver.sync(basedir, id); + if (result.path) { + return result.path; } + throw new Error(result.error ?? `Cannot resolve module '${id}' from '${basedir}'`); } diff --git a/code/core/src/common/utils/read-dependency-manifest.ts b/code/core/src/common/utils/read-dependency-manifest.ts new file mode 100644 index 000000000000..da1fa11af9bd --- /dev/null +++ b/code/core/src/common/utils/read-dependency-manifest.ts @@ -0,0 +1,78 @@ +import { readFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; + +import { logger } from 'storybook/internal/node-logger'; +import type { PackageJson } from 'storybook/internal/types'; + +import * as resolve from 'empathic/resolve'; + +/** + * Reads a dependency's own `package.json`, resolved relative to `directory`. + * + * `/package.json` cannot reliably be resolved as a subpath: subpath + * resolution runs through the package's `exports` field, and a `"./*"` wildcard + * (e.g. refractor's `"./*": "./lang/*.js"`) remaps `package.json` to a file + * that does not exist. + * + * Strategy: + * + * 1. Try the subpath anyway — correct for packages with no `exports` field, or + * that explicitly expose `"./package.json"`. + * 2. On failure, resolve the package's main entry (the `"."` export, which a + * `"./*"` wildcard never affects) and walk up to the package root. + * + * `empathic/resolve` wraps Node's `createRequire().resolve()`, so both passes + * stay correct under hoisted monorepos and Yarn PnP. + * + * @param directory Directory to resolve `dependency` from (Node module resolution). + * @param dependency Bare package name, e.g. `react` or `@scope/pkg`. + * @returns The parsed `package.json`, or `undefined` if it cannot be resolved. + * Never throws. + */ +export const readDependencyManifest = async ( + directory: string, + dependency: string +): Promise => { + // Fast path: subpath resolution. Correct for packages with no `exports` + // field, or that explicitly expose `"./package.json"`. + const subpath = resolve.from(directory, join(dependency, 'package.json'), true); + if (subpath) { + try { + const manifest = JSON.parse(await readFile(subpath, { encoding: 'utf8' })); + // A `name` confirms this is a real manifest, and not a file an `exports` + // wildcard happened to remap `package.json` onto. + if (manifest?.name) { + return manifest; + } + } catch { + // Not readable / not JSON — fall through to entry-based resolution. + } + } + + // Fallback: the package's `exports` blocks subpath access. Resolve its main + // entry instead, then walk up to the package's own `package.json`. + const entry = resolve.from(directory, dependency, true); + if (!entry) { + logger.debug(`readDependencyManifest: could not resolve "${dependency}"`); + return undefined; + } + + let dir = dirname(entry); + for (;;) { + try { + const manifest = JSON.parse(await readFile(join(dir, 'package.json'), { encoding: 'utf8' })); + // Only the package root carries a `name`; skip nested `package.json` + // markers such as a `dist/package.json` holding just `{ "type": "module" }`. + if (manifest?.name) { + return manifest; + } + } catch { + // No (or invalid) `package.json` at this level — keep walking up. + } + const parent = dirname(dir); + if (parent === dir) { + return undefined; + } + dir = parent; + } +}; diff --git a/code/core/src/common/utils/select-component-entry.test.ts b/code/core/src/common/utils/select-component-entry.test.ts new file mode 100644 index 000000000000..022c0e6cebed --- /dev/null +++ b/code/core/src/common/utils/select-component-entry.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; + +import { Tag } from '../../shared/constants/tags.ts'; +import type { DocsIndexEntry, IndexEntry } from '../../types/modules/indexer.ts'; + +import { + getStoryImportPathFromEntry, + selectComponentEntriesByComponentId, +} from './select-component-entry.ts'; + +function makeStoryEntry(id: string, title = 'Comp'): IndexEntry { + return { + id, + name: 'Default', + title, + type: 'story', + subtype: 'story', + importPath: `./${title.toLowerCase()}.stories.tsx`, + }; +} + +describe('selectComponentEntriesByComponentId', () => { + it('prefers stories over attached docs for the same componentId', () => { + const storyEntry = makeStoryEntry('comp--default', 'Comp'); + const docsEntry = { + id: 'comp--docs', + name: 'Docs', + title: 'Comp/Docs', + type: 'docs', + importPath: './comp.mdx', + storiesImports: ['./wrong.stories.tsx'], + tags: [Tag.ATTACHED_MDX, 'docs'], + } satisfies DocsIndexEntry; + + const map = selectComponentEntriesByComponentId([docsEntry, storyEntry]); + expect(map.get('comp')).toEqual(storyEntry); + }); + + it('falls back to attached docs when no story entry exists', () => { + const docsEntry = { + id: 'comp--docs', + name: 'Docs', + title: 'Comp/Docs', + type: 'docs', + importPath: './comp.mdx', + storiesImports: ['./comp.stories.tsx'], + tags: [Tag.ATTACHED_MDX, 'docs'], + } satisfies DocsIndexEntry; + + const map = selectComponentEntriesByComponentId([docsEntry]); + expect(map.get('comp')).toEqual(docsEntry); + expect(getStoryImportPathFromEntry(docsEntry)).toBe('./comp.stories.tsx'); + }); + + it('last story entry wins when multiple files share a componentId', () => { + const first = { ...makeStoryEntry('comp--a', 'Comp'), importPath: './comp-a.stories.tsx' }; + const second = { ...makeStoryEntry('comp--b', 'Comp'), importPath: './comp-b.stories.tsx' }; + + const map = selectComponentEntriesByComponentId([first, second]); + expect(map.get('comp')).toEqual(second); + }); +}); diff --git a/code/core/src/common/utils/select-component-entry.ts b/code/core/src/common/utils/select-component-entry.ts new file mode 100644 index 000000000000..0645dca6a27b --- /dev/null +++ b/code/core/src/common/utils/select-component-entry.ts @@ -0,0 +1,68 @@ +import { Tag } from '../../shared/constants/tags.ts'; +import type { DocsIndexEntry, IndexEntry } from '../../types/modules/indexer.ts'; + +import { getComponentIdFromEntry } from './component-id.ts'; + +/** + * Filename test for CSF story files (e.g. `Button.stories.tsx`, `stories.ts`). Single source of + * truth shared by the CSF indexer and the React docgen provider so both agree on which files count + * as story files. Has no `g` flag, so the shared instance is safe to reuse across `.test()` calls. + */ +export const STORY_FILE_TEST_REGEXP = /(stories|story)\.(m?js|ts)x?$/; + +function isAttachedDocsEntry( + entry: IndexEntry +): entry is DocsIndexEntry & { storiesImports: [string, ...string[]] } { + return ( + entry.type === 'docs' && + entry.tags?.includes(Tag.ATTACHED_MDX) === true && + entry.storiesImports.length > 0 + ); +} + +function isEligibleStoryEntry(entry: IndexEntry): boolean { + return entry.type === 'story' && entry.subtype === 'story'; +} + +/** + * CSF story file path used for component resolution — the story entry's `importPath`, or the first + * `storiesImports` entry for attached MDX docs (same rule as the React component manifest generator). + */ +export function getStoryImportPathFromEntry(entry: IndexEntry): string | undefined { + if (entry.type === 'story') { + return entry.importPath; + } + if (isAttachedDocsEntry(entry)) { + return entry.storiesImports[0]; + } + return undefined; +} + +/** + * Picks one index entry per componentId: story entries win; attached docs fill gaps only where no + * story exists for that componentId. + */ +export function selectComponentEntriesByComponentId( + indexEntries: IndexEntry[] +): Map { + const entriesByComponentId = new Map(); + + for (const entry of indexEntries) { + if (!isEligibleStoryEntry(entry)) { + continue; + } + entriesByComponentId.set(getComponentIdFromEntry(entry), entry); + } + + for (const entry of indexEntries) { + if (!isAttachedDocsEntry(entry)) { + continue; + } + const componentId = getComponentIdFromEntry(entry); + if (!entriesByComponentId.has(componentId)) { + entriesByComponentId.set(componentId, entry); + } + } + + return entriesByComponentId; +} diff --git a/code/core/src/common/utils/validate-config.ts b/code/core/src/common/utils/validate-config.ts index 2aabdda604fd..e74d801ec018 100644 --- a/code/core/src/common/utils/validate-config.ts +++ b/code/core/src/common/utils/validate-config.ts @@ -6,6 +6,7 @@ import { import { resolveModulePath } from 'exsolve'; +import { jsModuleExtensions } from '../../shared/constants/extensions.ts'; import { extractFrameworkPackageName } from '../index.ts'; import { frameworkPackages } from './get-storybook-info.ts'; @@ -36,7 +37,7 @@ export function validateFrameworkName( // If it's not a known framework, we need to validate that it's a valid package at least try { resolveModulePath(`${frameworkName}/preset`, { - extensions: ['.mjs', '.js', '.cjs'], + extensions: [...jsModuleExtensions], conditions: ['node', 'import', 'require'], }); } catch (err) { diff --git a/code/core/src/common/versions.ts b/code/core/src/common/versions.ts index 9f0bb315ea16..edb60a38d7de 100644 --- a/code/core/src/common/versions.ts +++ b/code/core/src/common/versions.ts @@ -1,46 +1,47 @@ // auto generated file, do not edit export default { - '@storybook/addon-a11y': '10.4.0-alpha.19', - '@storybook/addon-docs': '10.4.0-alpha.19', - '@storybook/addon-links': '10.4.0-alpha.19', - '@storybook/addon-onboarding': '10.4.0-alpha.19', - 'storybook-addon-pseudo-states': '10.4.0-alpha.19', - '@storybook/addon-themes': '10.4.0-alpha.19', - '@storybook/addon-vitest': '10.4.0-alpha.19', - '@storybook/builder-vite': '10.4.0-alpha.19', - '@storybook/builder-webpack5': '10.4.0-alpha.19', - storybook: '10.4.0-alpha.19', - '@storybook/angular': '10.4.0-alpha.19', - '@storybook/ember': '10.4.0-alpha.19', - '@storybook/html-vite': '10.4.0-alpha.19', - '@storybook/nextjs': '10.4.0-alpha.19', - '@storybook/nextjs-vite': '10.4.0-alpha.19', - '@storybook/preact-vite': '10.4.0-alpha.19', - '@storybook/react-native-web-vite': '10.4.0-alpha.19', - '@storybook/react-vite': '10.4.0-alpha.19', - '@storybook/react-webpack5': '10.4.0-alpha.19', - '@storybook/server-webpack5': '10.4.0-alpha.19', - '@storybook/svelte-vite': '10.4.0-alpha.19', - '@storybook/sveltekit': '10.4.0-alpha.19', - '@storybook/tanstack-react': '10.4.0-alpha.19', - '@storybook/vue3-vite': '10.4.0-alpha.19', - '@storybook/web-components-vite': '10.4.0-alpha.19', - sb: '10.4.0-alpha.19', - '@storybook/cli': '10.4.0-alpha.19', - '@storybook/codemod': '10.4.0-alpha.19', - '@storybook/core-webpack': '10.4.0-alpha.19', - 'create-storybook': '10.4.0-alpha.19', - '@storybook/csf-plugin': '10.4.0-alpha.19', - 'eslint-plugin-storybook': '10.4.0-alpha.19', - '@storybook/react-dom-shim': '10.4.0-alpha.19', - '@storybook/preset-create-react-app': '10.4.0-alpha.19', - '@storybook/preset-react-webpack': '10.4.0-alpha.19', - '@storybook/preset-server-webpack': '10.4.0-alpha.19', - '@storybook/html': '10.4.0-alpha.19', - '@storybook/preact': '10.4.0-alpha.19', - '@storybook/react': '10.4.0-alpha.19', - '@storybook/server': '10.4.0-alpha.19', - '@storybook/svelte': '10.4.0-alpha.19', - '@storybook/vue3': '10.4.0-alpha.19', - '@storybook/web-components': '10.4.0-alpha.19', + '@storybook/addon-a11y': '10.5.0-beta.1', + '@storybook/addon-docs': '10.5.0-beta.1', + '@storybook/addon-links': '10.5.0-beta.1', + '@storybook/addon-onboarding': '10.5.0-beta.1', + 'storybook-addon-pseudo-states': '10.5.0-beta.1', + '@storybook/addon-themes': '10.5.0-beta.1', + '@storybook/addon-vitest': '10.5.0-beta.1', + '@storybook/builder-vite': '10.5.0-beta.1', + '@storybook/builder-webpack5': '10.5.0-beta.1', + storybook: '10.5.0-beta.1', + '@storybook/angular': '10.5.0-beta.1', + '@storybook/angular-vite': '10.5.0-beta.1', + '@storybook/ember': '10.5.0-beta.1', + '@storybook/html-vite': '10.5.0-beta.1', + '@storybook/nextjs': '10.5.0-beta.1', + '@storybook/nextjs-vite': '10.5.0-beta.1', + '@storybook/preact-vite': '10.5.0-beta.1', + '@storybook/react-native-web-vite': '10.5.0-beta.1', + '@storybook/react-vite': '10.5.0-beta.1', + '@storybook/react-webpack5': '10.5.0-beta.1', + '@storybook/server-webpack5': '10.5.0-beta.1', + '@storybook/svelte-vite': '10.5.0-beta.1', + '@storybook/sveltekit': '10.5.0-beta.1', + '@storybook/tanstack-react': '10.5.0-beta.1', + '@storybook/vue3-vite': '10.5.0-beta.1', + '@storybook/web-components-vite': '10.5.0-beta.1', + sb: '10.5.0-beta.1', + '@storybook/cli': '10.5.0-beta.1', + '@storybook/codemod': '10.5.0-beta.1', + '@storybook/core-webpack': '10.5.0-beta.1', + 'create-storybook': '10.5.0-beta.1', + '@storybook/csf-plugin': '10.5.0-beta.1', + 'eslint-plugin-storybook': '10.5.0-beta.1', + '@storybook/react-dom-shim': '10.5.0-beta.1', + '@storybook/preset-create-react-app': '10.5.0-beta.1', + '@storybook/preset-react-webpack': '10.5.0-beta.1', + '@storybook/preset-server-webpack': '10.5.0-beta.1', + '@storybook/html': '10.5.0-beta.1', + '@storybook/preact': '10.5.0-beta.1', + '@storybook/react': '10.5.0-beta.1', + '@storybook/server': '10.5.0-beta.1', + '@storybook/svelte': '10.5.0-beta.1', + '@storybook/vue3': '10.5.0-beta.1', + '@storybook/web-components': '10.5.0-beta.1', }; diff --git a/code/core/src/component-testing/components/Interaction.stories.tsx b/code/core/src/component-testing/components/Interaction.stories.tsx index 1cd0ace8b2b0..2709e0ff8c20 100644 --- a/code/core/src/component-testing/components/Interaction.stories.tsx +++ b/code/core/src/component-testing/components/Interaction.stories.tsx @@ -87,6 +87,32 @@ export const Done: Story = { }, }; +export const PreventsFocusLossOnPointerDown: Story = { + tags: ['vitest'], + args: Done.args, + render: (args) => ( + <> +
  • + +
  • + + + ), + play: async ({ canvas }) => { + const input = canvas.getByTestId('test-input'); + input.focus(); + await expect(input).toHaveFocus(); + + await userEvent.pointer({ + target: canvas.getByRole('button', { + name: 'Go to interaction row: toHaveBeenCalled. Status: passed.', + }), + keys: '[MouseLeft]', + }); + await expect(input).toHaveFocus(); + }, +}; + export const WithParent: Story = { args: { call: { ...getCalls(CallStates.DONE, -1)[0], ancestors: ['parent-id'] }, diff --git a/code/core/src/component-testing/components/Interaction.tsx b/code/core/src/component-testing/components/Interaction.tsx index 85021b652dd1..5d32731e7eb0 100644 --- a/code/core/src/component-testing/components/Interaction.tsx +++ b/code/core/src/component-testing/components/Interaction.tsx @@ -274,6 +274,7 @@ export const Interaction = ({ })} call={call} onClick={() => controls.goto(call.id)} + onPointerDown={(e) => e.preventDefault()} disabled={isNavigationDisabled} onMouseEnter={() => controlStates.goto && setIsHovered(true)} onMouseLeave={() => controlStates.goto && setIsHovered(false)} diff --git a/code/core/src/component-testing/components/Toolbar.stories.tsx b/code/core/src/component-testing/components/Toolbar.stories.tsx index c7972586d78f..790ab2f77cb4 100644 --- a/code/core/src/component-testing/components/Toolbar.stories.tsx +++ b/code/core/src/component-testing/components/Toolbar.stories.tsx @@ -1,10 +1,18 @@ import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; + import { action } from 'storybook/actions'; +import type { API } from 'storybook/manager-api'; +import { expect, userEvent } from 'storybook/test'; import { Toolbar } from './Toolbar.tsx'; -export default { +const api = { + openInEditor: action('openInEditor'), +} as unknown as API; + +const meta = { title: 'Toolbar', component: Toolbar, parameters: { @@ -28,10 +36,13 @@ export default { end: false, }, storyFileName: 'Toolbar.stories.tsx', - hasNext: true, - hasPrevious: true, + api, }, -}; +} satisfies Meta; + +export default meta; + +type Story = StoryObj; export const Wait = { args: { @@ -149,3 +160,45 @@ export const WithOpenInEditorLink = { canOpenInEditor: true, }, }; + +export const PreventsFocusLossOnPointerDown: Story = { + tags: ['vitest'], + render: (args) => ( + <> + + + + ), + args: { + status: 'playing', + onScrollToEnd: action('scrollToEnd'), + controlStates: { + detached: false, + start: true, + back: true, + goto: true, + next: true, + end: true, + }, + }, + play: async ({ canvas }) => { + const input = canvas.getByTestId('test-input'); + input.focus(); + await expect(input).toHaveFocus(); + + for (const name of [ + 'Scroll to end', + 'Go to start', + 'Go back', + 'Go forward', + 'Go to end', + 'Rerun', + ]) { + await userEvent.pointer({ + target: canvas.getByRole('button', { name }), + keys: '[MouseLeft]', + }); + await expect(input).toHaveFocus(); + } + }, +}; diff --git a/code/core/src/component-testing/components/Toolbar.tsx b/code/core/src/component-testing/components/Toolbar.tsx index 55f04008adcf..a88e37737a2a 100644 --- a/code/core/src/component-testing/components/Toolbar.tsx +++ b/code/core/src/component-testing/components/Toolbar.tsx @@ -115,6 +115,8 @@ export const Toolbar: React.FC = ({ const buttonText = status === 'errored' ? 'Scroll to error' : 'Scroll to end'; const theme = useTheme(); + const preventFocusLoss = (e: React.PointerEvent) => e.preventDefault(); + return ( = ({ - + {buttonText} @@ -136,6 +143,7 @@ export const Toolbar: React.FC = ({ variant="ghost" ariaLabel="Go to start" onClick={controls.start} + onPointerDown={preventFocusLoss} disabled={!controlStates.start} > @@ -146,6 +154,7 @@ export const Toolbar: React.FC = ({ variant="ghost" ariaLabel="Go back" onClick={controls.back} + onPointerDown={preventFocusLoss} disabled={!controlStates.back} > @@ -156,6 +165,7 @@ export const Toolbar: React.FC = ({ variant="ghost" ariaLabel="Go forward" onClick={controls.next} + onPointerDown={preventFocusLoss} disabled={!controlStates.next} > @@ -166,12 +176,19 @@ export const Toolbar: React.FC = ({ variant="ghost" ariaLabel="Go to end" onClick={controls.end} + onPointerDown={preventFocusLoss} disabled={!controlStates.end} > - + diff --git a/code/core/src/components/components/ActionBar/ActionBar.tsx b/code/core/src/components/components/ActionBar/ActionBar.tsx index 59f6694501f5..d078a5214ba6 100644 --- a/code/core/src/components/components/ActionBar/ActionBar.tsx +++ b/code/core/src/components/components/ActionBar/ActionBar.tsx @@ -57,7 +57,8 @@ export const ActionButton = styled.button<{ disabled: boolean }>( outline: '0 none', '@media (forced-colors: active)': { - outline: '1px solid highlight', + outline: '2px solid ButtonBorder', + outlineOffset: '2px', }, }, }), @@ -71,6 +72,7 @@ ActionButton.displayName = 'ActionButton'; export interface ActionItem { title: string | ReactElement; + ariaLabel?: string; className?: string; onClick: (e: MouseEvent) => void; disabled?: boolean; @@ -89,8 +91,14 @@ export interface ActionBarProps { export const ActionBar = ({ actionItems, flexLayout = false, ...props }: ActionBarProps) => { return ( - {actionItems.map(({ title, className, onClick, disabled }, index: number) => ( - + {actionItems.map(({ title, ariaLabel, className, onClick, disabled }, index: number) => ( + {title} ))} diff --git a/code/core/src/components/components/Button/Button.stories.tsx b/code/core/src/components/components/Button/Button.stories.tsx index 961ffa82e252..776d439a981a 100644 --- a/code/core/src/components/components/Button/Button.stories.tsx +++ b/code/core/src/components/components/Button/Button.stories.tsx @@ -38,36 +38,25 @@ export const Variants = meta.story({ render: (args) => ( - - - - - - - - - - - @@ -87,6 +76,15 @@ export const PseudoStates = meta.story({ + + + + + + + + + + + + + + + + + ), parameters: { @@ -139,7 +175,7 @@ export const PseudoStates = meta.story({ hover: '#hover button', active: '#active button', focus: '#focus button', - focusVisible: '#focus-visible button', + focusVisible: '#focus-visible button, #focus-visible-agentic button', }, }, }); diff --git a/code/core/src/components/components/Button/Button.tsx b/code/core/src/components/components/Button/Button.tsx index 24cd6028e2d8..40b8d51784b6 100644 --- a/code/core/src/components/components/Button/Button.tsx +++ b/code/core/src/components/components/Button/Button.tsx @@ -5,7 +5,7 @@ import { deprecate } from 'storybook/internal/client-logger'; import { Slot } from '@radix-ui/react-slot'; import { darken, lighten, rgba, transparentize } from 'polished'; -import { type API_KeyCollection, shortcutToAriaKeyshortcuts } from 'storybook/manager-api'; +import { shortcutToAriaKeyshortcuts, type API_KeyCollection } from 'storybook/manager-api'; import { isPropValid, styled } from 'storybook/theming'; import type { PopperPlacement } from '../shared/overlayHelpers.tsx'; @@ -63,6 +63,7 @@ export const Button = forwardRef( asChild = false, animation = 'none', size = 'small', + appearance = 'default', variant = 'outline', padding = 'medium', disabled = false, @@ -143,6 +144,7 @@ export const Button = forwardRef( data-deprecated={deprecated} as={Comp} ref={ref} + appearance={appearance} variant={variant} size={size} padding={padding} @@ -172,24 +174,39 @@ const StyledButton = styled('button', { })<{ size?: 'small' | 'medium'; padding?: 'small' | 'medium' | 'none'; + appearance?: 'default' | 'agentic'; variant?: 'outline' | 'solid' | 'ghost'; active?: boolean; $disabled?: boolean; readOnly?: boolean; animating?: boolean; animation?: 'none' | 'rotate360' | 'glow' | 'jiggle'; -}>( - ({ - theme, - variant, - size, - $disabled, - readOnly, - active, - animating, - animation = 'none', - padding, - }) => ({ +}>(({ + theme, + appearance, + variant, + size, + $disabled, + readOnly, + active, + animating, + animation = 'none', + padding, +}) => { + const colors = + appearance === 'agentic' + ? { + foreground: theme.fgColor.agentic, + background: theme.bgColor.agentic, + outline: theme.borderColor.agentic, + } + : { + foreground: theme.color.secondary, + background: theme.button.background, + outline: theme.button.border, + }; + + return { border: 0, cursor: readOnly ? 'inherit' : $disabled ? 'not-allowed' : 'pointer', display: 'inline-flex', @@ -232,15 +249,18 @@ const StyledButton = styled('button', { lineHeight: '1', background: (() => { if (variant === 'solid') { - return theme.base === 'light' ? theme.color.secondary : darken(0.18, theme.color.secondary); + return theme.base === 'light' ? colors.foreground : darken(0.18, colors.foreground); } if (variant === 'outline') { - return theme.button.background; + return colors.background; } if (variant === 'ghost' && active) { - return transparentize(0.93, theme.barSelectedColor); + return transparentize( + 0.93, + appearance === 'agentic' ? theme.bgColor.agentic : theme.barSelectedColor + ); } return 'transparent'; @@ -251,11 +271,11 @@ const StyledButton = styled('button', { } if (variant === 'outline') { - return theme.input.color; + return appearance === 'agentic' ? theme.fgColor.agentic : theme.input.color; } if (variant === 'ghost' && active) { - return theme.base === 'light' ? darken(0.1, theme.color.secondary) : theme.color.secondary; + return theme.base === 'light' ? darken(0.1, colors.foreground) : colors.foreground; } if (variant === 'ghost') { @@ -263,57 +283,59 @@ const StyledButton = styled('button', { } return theme.input.color; })(), - boxShadow: variant === 'outline' ? `${theme.button.border} 0 0 0 1px inset` : 'none', + boxShadow: variant === 'outline' ? `${colors.outline} 0 0 0 1px inset` : 'none', borderRadius: theme.input.borderRadius, // Making sure that the button never shrinks below its minimum size flexShrink: 0, ...(!readOnly && { '&:hover': { - color: variant === 'ghost' ? theme.color.secondary : undefined, + color: variant === 'ghost' ? colors.foreground : undefined, background: (() => { - let bgColor = theme.color.secondary; + let bgColor = colors.foreground; if (variant === 'solid') { bgColor = theme.base === 'light' - ? lighten(0.1, theme.color.secondary) - : darken(0.3, theme.color.secondary); + ? lighten(0.1, colors.foreground) + : darken(0.3, colors.foreground); } if (variant === 'outline') { - bgColor = theme.button.background; + bgColor = colors.background; } if (variant === 'ghost') { - return transparentize(0.86, theme.color.secondary); + return transparentize(0.86, colors.foreground); } + return theme.base === 'light' ? darken(0.02, bgColor) : lighten(0.03, bgColor); })(), }, '&:active': { - color: variant === 'ghost' ? theme.color.secondary : undefined, + color: variant === 'ghost' ? colors.foreground : undefined, background: (() => { - let bgColor = theme.color.secondary; + let bgColor = colors.foreground; if (variant === 'solid') { - bgColor = theme.color.secondary; + bgColor = colors.foreground; } if (variant === 'outline') { - bgColor = theme.button.background; + bgColor = colors.background; } if (variant === 'ghost') { - return theme.background.hoverable; + return appearance === 'agentic' ? theme.bgColor.agentic : theme.background.hoverable; } + return theme.base === 'light' ? darken(0.02, bgColor) : lighten(0.03, bgColor); })(), }, '&:focus-visible': { - outline: `2px solid ${rgba(theme.color.secondary, 1)}`, + outline: `2px solid ${rgba(colors.foreground, 1)}`, outlineOffset: 2, // Should ensure focus outline gets drawn above next sibling zIndex: '1', @@ -329,8 +351,8 @@ const StyledButton = styled('button', { animation: animating && animation !== 'none' ? `${theme.animation[animation]} 1000ms ease-out` : '', }, - }) -); + }; +}); export const IconButton = forwardRef((props, ref) => { deprecate( diff --git a/code/core/src/components/components/Card/Card.stories.tsx b/code/core/src/components/components/Card/Card.stories.tsx index c38df61392dc..f16518ac84ea 100644 --- a/code/core/src/components/components/Card/Card.stories.tsx +++ b/code/core/src/components/components/Card/Card.stories.tsx @@ -27,19 +27,58 @@ export const Spinning = meta.story(() => ( )); +export const SpinningAgentic = meta.story(() => ( + + Spinning agentic + +)); + +// Filled agentic card with a spinning outline (as used by ReviewWidget). The +// `agentic` content background is translucent in dark mode, so this guards +// against the spinning gradient bleeding through the content instead of the ring. +export const SpinningAgenticFilled = meta.story(() => ( + + Spinning agentic filled + +)); + +export const Agentic = meta.story(() => ( + + Agentic + +)); + export const Positive = meta.story(() => ( - + Positive )); export const Warning = meta.story(() => ( - + Warning )); export const Negative = meta.story(() => ( + + Negative + +)); + +export const PositiveOutline = meta.story(() => ( + + Positive + +)); + +export const WarningOutline = meta.story(() => ( + + Warning + +)); + +export const NegativeOutline = meta.story(() => ( Negative diff --git a/code/core/src/components/components/Card/Card.tsx b/code/core/src/components/components/Card/Card.tsx index add8eef940e5..cc7808a50a96 100644 --- a/code/core/src/components/components/Card/Card.tsx +++ b/code/core/src/components/components/Card/Card.tsx @@ -1,8 +1,40 @@ -import React, { type ComponentProps, type DOMAttributes, forwardRef } from 'react'; +import React, { forwardRef, type ComponentProps, type DOMAttributes } from 'react'; -import type { CSSObject, color } from 'storybook/theming'; +import type { StorybookTheme } from 'storybook/theming'; import { keyframes, styled } from 'storybook/theming'; +type ThemeColor = keyof StorybookTheme['color'] | keyof StorybookTheme['fgColor']; + +// A ThemeColor may live in any of theme.color / fgColor / bgColor / borderColor, which don't share +// a key space, so each lookup is a presence check rather than a direct index. Returning undefined +// when absent is intentional: it lets the CSS property fall back to inherit/default. +const resolveThemeColor = ( + colors: Partial>, + key?: ThemeColor +): string | undefined => (key && key in colors ? colors[key] : undefined); + +const getColor = (theme: StorybookTheme, color?: ThemeColor): string | undefined => + resolveThemeColor(theme.fgColor, color) ?? resolveThemeColor(theme.color, color); + +const getBorderColor = ( + theme: StorybookTheme, + color?: ThemeColor, + outlineColor?: ThemeColor +): string | undefined => + resolveThemeColor(theme.borderColor, color) ?? resolveThemeColor(theme.color, outlineColor); + +const getBackgroundColor = (theme: StorybookTheme, color?: ThemeColor): string => + resolveThemeColor(theme.bgColor, color) ?? theme.background.content; + +// Compose the (possibly translucent) themed background over an opaque base. Some +// themed bgColors are intentionally translucent for tinting (e.g. `agentic` in +// dark mode), which would otherwise let the animated outline behind the content +// bleed through the whole card instead of just the 1px ring. +const getOpaqueBackground = (theme: StorybookTheme, color?: ThemeColor): string => { + const bg = getBackgroundColor(theme, color); + return `linear-gradient(${bg}, ${bg}), ${theme.background.content}`; +}; + const fadeInOut = keyframes({ '0%': { opacity: 0 }, '5%': { opacity: 1 }, @@ -12,11 +44,11 @@ const fadeInOut = keyframes({ const spin = keyframes({ '0%': { transform: 'rotate(0deg)' }, - '10%': { transform: 'rotate(10deg)' }, + '10%': { transform: 'rotate(20deg)' }, '40%': { transform: 'rotate(170deg)' }, '50%': { transform: 'rotate(180deg)' }, '60%': { transform: 'rotate(190deg)' }, - '90%': { transform: 'rotate(350deg)' }, + '90%': { transform: 'rotate(340deg)' }, '100%': { transform: 'rotate(360deg)' }, }); @@ -26,23 +58,25 @@ const slide = keyframes({ }, }); -const CardContent = styled.div(({ theme }) => ({ +const CardContent = styled.div<{ color?: ThemeColor }>(({ color, theme }) => ({ + color: getColor(theme, color), borderRadius: theme.appBorderRadius, - backgroundColor: theme.background.content, + background: getOpaqueBackground(theme, color), position: 'relative', })); const CardOutline = styled.div<{ animation?: 'none' | 'rainbow' | 'spin'; - color?: keyof typeof color; -}>(({ animation = 'none', color, theme }) => ({ + color?: ThemeColor; + outlineColor?: ThemeColor; +}>(({ animation = 'none', color, outlineColor = color, theme }) => ({ position: 'relative', width: '100%', padding: 1, overflow: 'hidden', - backgroundColor: theme.background.content, + backgroundColor: getBackgroundColor(theme, color), borderRadius: theme.appBorderRadius + 1, - boxShadow: `inset 0 0 0 1px ${(animation === 'none' && color && theme.color[color]) || theme.appBorderColor}, var(--card-box-shadow, transparent 0 0)`, + boxShadow: `inset 0 0 0 1px ${(animation === 'none' && getBorderColor(theme, color, outlineColor)) || theme.appBorderColor}, var(--card-box-shadow, transparent 0 0)`, transition: 'box-shadow 1s', '@supports (interpolate-size: allow-keywords)': { @@ -79,30 +113,45 @@ const CardOutline = styled.div<{ marginTop: 'calc(max(100vw, 100vh) * -0.5)', height: 'max(100vw, 100vh)', width: 'max(100vw, 100vh)', - animation: `${spin} 3s linear infinite`, + animation: `${spin} 5s linear infinite`, + // Hardcoded colors to prevent themes from messing with them + // (orange+gold, lavender+pink, secondary+seafoam) backgroundImage: - color === 'negative' - ? // Hardcoded colors to prevent themes from messing with them (orange+gold, secondary+seafoam) - `conic-gradient(transparent 90deg, #FC521F 150deg, #FFAE00 210deg, transparent 270deg)` - : `conic-gradient(transparent 90deg, #029CFD 150deg, #37D5D3 210deg, transparent 270deg)`, + outlineColor === 'negative' + ? `conic-gradient(transparent 90deg, #FC521F 150deg, #FFAE00 210deg, transparent 270deg)` + : outlineColor === 'agentic' + ? // Agentic is intentionally subtle. Pale lavender stays low-contrast on a + // light background, but the same colors read as a bright sweep on dark, so + // the dark arc is dimmed with low-alpha hues to keep it a faint glow. + theme.base === 'dark' + ? `conic-gradient(transparent 90deg, rgba(114,58,166,0.65) 150deg, rgba(157,98,214,0.6) 210deg, transparent 270deg)` + : `conic-gradient(transparent 90deg, #b6a7ff 150deg, #d8aeff 210deg, transparent 270deg)` + : `conic-gradient(transparent 90deg, #029CFD 150deg, #37D5D3 210deg, transparent 270deg)`, }), }, })); interface CardProps extends ComponentProps { outlineAnimation?: 'none' | 'rainbow' | 'spin'; - outlineColor?: keyof typeof color; + color?: ThemeColor; + outlineColor?: ThemeColor; outlineAttrs?: DOMAttributes; } export const Card = Object.assign( forwardRef(function Card( - { outlineAnimation = 'none', outlineColor, outlineAttrs: outlineAttrs = {}, ...props }, + { outlineAnimation = 'none', color, outlineColor, outlineAttrs: outlineAttrs = {}, ...props }, ref ) { return ( - - + + ); }), diff --git a/code/core/src/components/components/Modal/Modal.tsx b/code/core/src/components/components/Modal/Modal.tsx index b8bd489b8620..3d7b8f76bae6 100644 --- a/code/core/src/components/components/Modal/Modal.tsx +++ b/code/core/src/components/components/Modal/Modal.tsx @@ -189,7 +189,10 @@ function BaseModal({ } else { if (dismissOnEscape) { onEscapeKeyDown?.(e.nativeEvent); - close(); + // The deprecated handler can veto the close by preventing default (Radix contract). + if (!e.nativeEvent.defaultPrevented) { + close(); + } } } }, diff --git a/code/core/src/components/components/Toolbar/Toolbar.tsx b/code/core/src/components/components/Toolbar/Toolbar.tsx index 4186b6e905e5..013816e4213b 100644 --- a/code/core/src/components/components/Toolbar/Toolbar.tsx +++ b/code/core/src/components/components/Toolbar/Toolbar.tsx @@ -10,6 +10,7 @@ export interface AbstractToolbarProps { children?: React.ReactNode; 'aria-label'?: string; 'aria-labelledby'?: string; + lang?: string; } export const AbstractToolbar: FC = ({ diff --git a/code/core/src/components/index.ts b/code/core/src/components/index.ts index f5d9c0f1db62..279eb1624fe5 100644 --- a/code/core/src/components/index.ts +++ b/code/core/src/components/index.ts @@ -130,6 +130,9 @@ export { withReset, codeCommon } from './components/typography/lib/common.tsx'; export { ClipboardCode } from './components/clipboard/ClipboardCode.tsx'; +export { useCopyButton } from '../shared/useCopyButton.ts'; +export type { UseCopyButtonOptions, UseCopyButtonResult } from '../shared/useCopyButton.ts'; + export const components = typography.components; const resetComponents: Record = {}; diff --git a/code/core/src/controls/components/ControlsPanel.stories.tsx b/code/core/src/controls/components/ControlsPanel.stories.tsx new file mode 100644 index 000000000000..79a9755a52d3 --- /dev/null +++ b/code/core/src/controls/components/ControlsPanel.stories.tsx @@ -0,0 +1,468 @@ +import React from 'react'; + +import { STORY_FINISHED, STORY_PREPARED } from 'storybook/internal/core-events'; + +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { global } from '@storybook/global'; + +import { ManagerContext } from 'storybook/manager-api'; +import { expect, fn, waitFor, within } from 'storybook/test'; + +import { buildQueryState } from '../../shared/open-service/query-state.ts'; +import type { QueryState } from '../../shared/open-service/types.ts'; +import { ControlsPanel } from './ControlsPanel.tsx'; + +const refId = 'my-ref'; +const storyData = { + type: 'story', + id: `${refId}_example-button--primary`, + refId, + args: { label: 'Submit' }, + initialArgs: { label: 'Submit' }, + argTypes: { label: { name: 'label', control: { type: 'text' } } }, +}; +const serviceStoryData = { + type: 'story', + prepared: true, + id: 'example-button--primary', + args: { variant: 'primary' }, + initialArgs: { variant: 'primary' }, + // Custom argTypes reach the panel via the STORY_PREPARED channel (read through `useArgTypes`), + // and are merged with the service's extracted component docgen. + argTypes: { variant: { name: 'variant', control: { type: 'radio' } } }, + parameters: { __isArgsStory: true }, +}; + +// Reproduces the #34553 condition: the story comes from a composed ref, so the host's +// global `previewInitialized` stays false while the ref's own flag is true. +const managerContext: any = { + state: { + path: storyData.id, + previewInitialized: false, + refs: { [refId]: { id: refId, previewInitialized: true } }, + }, + api: { + getCurrentStoryData: fn(() => storyData).mockName('api::getCurrentStoryData'), + getCurrentParameter: fn(() => ({})).mockName('api::getCurrentParameter'), + getGlobals: fn(() => ({})).mockName('api::getGlobals'), + getStoryGlobals: fn(() => ({})).mockName('api::getStoryGlobals'), + getUserGlobals: fn(() => ({})).mockName('api::getUserGlobals'), + updateGlobals: fn().mockName('api::updateGlobals'), + updateStoryArgs: fn().mockName('api::updateStoryArgs'), + resetStoryArgs: fn().mockName('api::resetStoryArgs'), + on: fn().mockName('api::on'), + off: fn().mockName('api::off'), + emit: fn().mockName('api::emit'), + }, +}; + +const serviceManagerContext: any = { + ...managerContext, + state: { + ...managerContext.state, + path: serviceStoryData.id, + previewInitialized: true, + }, + api: { + ...managerContext.api, + getCurrentStoryData: fn(() => serviceStoryData).mockName('api::getCurrentStoryData'), + }, +}; + +// Same story, but not yet prepared. Used by the build-mode story to prove docgen is fetched +// immediately, without waiting for prepare or render. +const serviceUnpreparedStoryData = { + ...serviceStoryData, + prepared: false, +}; + +const serviceUnpreparedManagerContext: any = { + ...serviceManagerContext, + api: { + ...serviceManagerContext.api, + getCurrentStoryData: fn(() => serviceUnpreparedStoryData).mockName('api::getCurrentStoryData'), + }, +}; + +// A real (tiny) channel backing `api.on`/`off`/`emit`, so a story's `play` can emit story lifecycle +// events (STORY_FINISHED / STORY_PREPARED) and drive the dev-only docgen gate in `ControlsPanel`. +function createApiChannel() { + const listeners = new Map void>>(); + return { + on: fn((type: string, cb: (...args: any[]) => void) => { + const set = listeners.get(type) ?? new Set(); + set.add(cb); + listeners.set(type, set); + }).mockName('api::on'), + off: fn((type: string, cb: (...args: any[]) => void) => { + listeners.get(type)?.delete(cb); + }).mockName('api::off'), + emit: fn((type: string, ...args: any[]) => { + listeners.get(type)?.forEach((cb) => cb(...args)); + }).mockName('api::emit'), + }; +} + +const serviceRenderChannel = createApiChannel(); +const serviceRenderManagerContext: any = { + ...serviceManagerContext, + api: { + ...serviceManagerContext.api, + ...serviceRenderChannel, + }, +}; + +// A prepared story whose annotation argTypes carry no controls — its controls come entirely from +// docgen. Used to prove the gate shows the loading skeleton (not the "No controls" empty state) while +// docgen is still pending. +const serviceNoControlsStoryData = { + ...serviceStoryData, + argTypes: {}, +}; + +const serviceNoControlsManagerContext: any = { + ...serviceManagerContext, + api: { + ...serviceManagerContext.api, + getCurrentStoryData: fn(() => serviceNoControlsStoryData).mockName('api::getCurrentStoryData'), + }, +}; + +const serviceGetDocgen = Object.assign( + fn((_input?: { id: string }) => ({ + id: 'example-button', + name: 'Button', + path: './Button.stories.tsx', + jsDocTags: {}, + stories: [], + argTypes: { + variant: { + name: 'variant', + description: 'Visual style', + type: { name: 'enum', value: ['primary', 'secondary'] }, + }, + }, + })).mockName('docgenService::docgen'), + { + get: fn((input?: { id: string }) => serviceGetDocgen(input)).mockName( + 'docgenService::docgen.get' + ), + subscribe: fn((_input: { id: string }, callback: (state: QueryState) => void) => { + callback( + buildQueryState(serviceGetDocgen(_input), { + status: 'success', + error: undefined, + loadStatus: 'idle', + }) + ); + return fn(); + }).mockName('docgenService::docgen.subscribe'), + loaded: fn((input: { id: string }) => Promise.resolve(serviceGetDocgen(input))).mockName( + 'docgenService::docgen.loaded' + ), + } +); + +const docgenService: any = { queries: { docgen: serviceGetDocgen } }; + +const meta = { + component: ControlsPanel, + args: { saveStory: fn(), createStory: fn() }, + decorators: [ + (storyFn) => ( + {storyFn()} + ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/** + * Regression test for #34553: controls for a story from a composed ref must render even + * though the host's global `previewInitialized` flag never flips. Before the fix the panel + * read only that global flag, so it stayed in its loading state and showed skeletons forever. + */ +export const RefStoryControlsLoad: Story = { + play: async ({ canvas }) => { + await expect(await canvas.findByText('label')).toBeInTheDocument(); + }, +}; + +export const ServiceDocgenControlsLoad: Story = { + args: { docgenService }, + // Build mode (no dev render gate), so docgen loads and merges into the table immediately. + beforeEach: () => { + const original = global.CONFIG_TYPE; + global.CONFIG_TYPE = 'PRODUCTION'; + return () => { + global.CONFIG_TYPE = original; + }; + }, + decorators: [ + (storyFn) => ( + {storyFn()} + ), + ], + play: async ({ canvas }) => { + await expect(await canvas.findByRole('radio', { name: 'primary' })).toBeInTheDocument(); + await expect(serviceGetDocgen).toHaveBeenCalledWith({ id: 'example-button' }); + }, +}; + +/** + * In dev, the panel must not query docgen until the story has finished (STORY_FINISHED) — even when + * the story is already prepared. Querying earlier would make the docgen worker's CPU-bound extraction + * compete with the dev server's bundling and the preview's render during the slowest part of first + * render. Until then the panel shows the story's own annotation argTypes (here, the `variant` radio), + * or a loading skeleton when the story has none — never the "No controls" empty state. + */ +export const ServiceDocgenWaitsForStoryFinishedInDev: Story = { + args: { docgenService }, + beforeEach: () => { + serviceGetDocgen.mockClear(); + serviceGetDocgen.get.mockClear(); + serviceGetDocgen.subscribe.mockClear(); + const original = global.CONFIG_TYPE; + global.CONFIG_TYPE = 'DEVELOPMENT'; + return () => { + global.CONFIG_TYPE = original; + }; + }, + decorators: [ + (storyFn) => ( + {storyFn()} + ), + ], + play: async ({ canvas }) => { + // No STORY_FINISHED is emitted, so the gate stays closed and the panel shows the story's own + // annotation controls. Wait for that row to render (a stable signal that the gate hook mounted), + // then assert synchronously that docgen was never queried — a `waitFor` on the negative passes + // before mount settles and would miss a next-tick subscription regression. + await expect(await canvas.findByText('variant')).toBeInTheDocument(); + expect(serviceGetDocgen.subscribe).not.toHaveBeenCalled(); + }, +}; + +/** + * Regression test: while the dev gate is closed and the story has no annotation controls (its + * controls come entirely from docgen), the panel must show the loading skeleton — not the "No + * controls" empty state. Previously it flashed that empty state for the whole gate window before + * docgen resolved and filled the table. + */ +export const ServiceDocgenSkeletonWhileGatedWithoutControls: Story = { + args: { docgenService }, + beforeEach: () => { + serviceGetDocgen.mockClear(); + serviceGetDocgen.get.mockClear(); + serviceGetDocgen.subscribe.mockClear(); + const original = global.CONFIG_TYPE; + global.CONFIG_TYPE = 'DEVELOPMENT'; + return () => { + global.CONFIG_TYPE = original; + }; + }, + decorators: [ + (storyFn) => ( + + {storyFn()} + + ), + ], + play: async ({ canvas }) => { + // The empty state debounces for 100ms before rendering, so wait past it, then assert it never + // appears while the gate is closed and docgen hasn't been queried. + await new Promise((resolve) => setTimeout(resolve, 250)); + await expect(canvas.queryByText('This story has no controls')).not.toBeInTheDocument(); + await expect(serviceGetDocgen.subscribe).not.toHaveBeenCalled(); + }, +}; + +/** + * Once the story finishes in dev (STORY_FINISHED), the gate opens and the panel subscribes to docgen, + * merging the extracted argTypes into the table. + */ +export const ServiceDocgenLoadsAfterStoryFinishedInDev: Story = { + args: { docgenService }, + beforeEach: () => { + serviceGetDocgen.mockClear(); + serviceGetDocgen.get.mockClear(); + serviceGetDocgen.subscribe.mockClear(); + const original = global.CONFIG_TYPE; + global.CONFIG_TYPE = 'DEVELOPMENT'; + return () => { + global.CONFIG_TYPE = original; + }; + }, + decorators: [ + (storyFn) => ( + + {storyFn()} + + ), + ], + play: async ({ canvas }) => { + // Before the story finishes: the gate is closed, so only annotation controls render. Anchor on + // that row, then assert synchronously that docgen wasn't queried (deferred, no subscription). + await expect(await canvas.findByText('variant')).toBeInTheDocument(); + expect(serviceGetDocgen.subscribe).not.toHaveBeenCalled(); + // Simulate the preview reporting the story as finished. + serviceRenderChannel.emit(STORY_FINISHED, { storyId: serviceStoryData.id }); + await expect(await canvas.findByRole('radio', { name: 'primary' })).toBeInTheDocument(); + await expect(serviceGetDocgen).toHaveBeenCalledWith({ id: 'example-button' }); + }, +}; + +/** + * With STORYBOOK_DOCGEN_STORY_PREPARED="true" the dev gate opens earlier, at STORY_PREPARED instead of + * STORY_FINISHED, so the panel subscribes to docgen as soon as the story module is delivered. + */ +export const ServiceDocgenLoadsAfterStoryPreparedInDev: Story = { + args: { docgenService }, + beforeEach: () => { + serviceGetDocgen.mockClear(); + serviceGetDocgen.get.mockClear(); + serviceGetDocgen.subscribe.mockClear(); + const originalConfig = global.CONFIG_TYPE; + const originalFlag = global.DOCGEN_STORY_PREPARED; + global.CONFIG_TYPE = 'DEVELOPMENT'; + global.DOCGEN_STORY_PREPARED = true; + return () => { + global.CONFIG_TYPE = originalConfig; + global.DOCGEN_STORY_PREPARED = originalFlag; + }; + }, + decorators: [ + (storyFn) => ( + + {storyFn()} + + ), + ], + play: async ({ canvas }) => { + // Before any lifecycle event: the gate is closed, only annotation controls render. Anchor on that + // row, then assert synchronously that docgen wasn't queried (deferred, no subscription). + await expect(await canvas.findByText('variant')).toBeInTheDocument(); + expect(serviceGetDocgen.subscribe).not.toHaveBeenCalled(); + // STORY_FINISHED must not open the gate while waiting for STORY_PREPARED. Give a potential + // erroneous subscription time to land, then assert it never happened. + serviceRenderChannel.emit(STORY_FINISHED, { storyId: serviceStoryData.id }); + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(serviceGetDocgen.subscribe).not.toHaveBeenCalled(); + // STORY_PREPARED opens it. + serviceRenderChannel.emit(STORY_PREPARED, { id: serviceStoryData.id }); + await expect(await canvas.findByRole('radio', { name: 'primary' })).toBeInTheDocument(); + await expect(serviceGetDocgen).toHaveBeenCalledWith({ id: 'example-button' }); + }, +}; + +/** + * In a static build docgen is precomputed JSON with no bundling to contend with, so the panel + * subscribes to docgen immediately without waiting for the story to prepare or render. + */ +export const ServiceDocgenLoadsImmediatelyInBuild: Story = { + args: { docgenService }, + beforeEach: () => { + serviceGetDocgen.mockClear(); + serviceGetDocgen.get.mockClear(); + serviceGetDocgen.subscribe.mockClear(); + const original = global.CONFIG_TYPE; + global.CONFIG_TYPE = 'PRODUCTION'; + return () => { + global.CONFIG_TYPE = original; + }; + }, + decorators: [ + (storyFn) => ( + + {storyFn()} + + ), + ], + play: async () => { + await waitFor(() => expect(serviceGetDocgen).toHaveBeenCalledWith({ id: 'example-button' })); + }, +}; + +// A panel with more controls than fit, plus one edited arg so the "save from controls" bar +// renders. The bar is absolutely positioned at the bottom of the panel, so the scrollable +// content must reserve room for it — otherwise the last control sits under the bar (#34531). +const PROP_COUNT = 12; +const overlapArgTypes = Object.fromEntries( + Array.from({ length: PROP_COUNT }, (_, i) => [ + `prop${i + 1}`, + { name: `prop${i + 1}`, control: { type: 'text' } }, + ]) +); +const overlapStoryData = { + type: 'story', + id: `${refId}_example-button--primary`, + refId, + argTypes: overlapArgTypes, + initialArgs: Object.fromEntries( + Array.from({ length: PROP_COUNT }, (_, i) => [`prop${i + 1}`, '']) + ), + // The first arg differs from its initial value, so the panel is in the "unsaved changes" state. + args: Object.fromEntries( + Array.from({ length: PROP_COUNT }, (_, i) => [`prop${i + 1}`, i === 0 ? 'edited' : '']) + ), +}; +const overlapContext: any = { + state: { + path: overlapStoryData.id, + previewInitialized: false, + refs: { [refId]: { id: refId, previewInitialized: true } }, + }, + api: { ...managerContext.api, getCurrentStoryData: fn(() => overlapStoryData) }, +}; + +/** + * Regression test for #34531: when the controls overflow the panel, the "save from controls" + * bar must not cover the last control. The save bar only renders in development, with unsaved + * arg changes — reproduced here inside a short, scrollable host that mimics the addon panel. + */ +export const SaveBarDoesNotCoverLastControl: Story = { + // The save bar only renders in development builds; set it for this story and restore after, + // so the mutation can't leak into other stories. + beforeEach: () => { + const original = global.CONFIG_TYPE; + global.CONFIG_TYPE = 'DEVELOPMENT'; + return () => { + global.CONFIG_TYPE = original; + }; + }, + decorators: [ + (storyFn) => ( + + {/* relative parent = the bar's positioning context; inner div = the scroll container */} +
    +
    + {storyFn()} +
    +
    +
    + ), + ], + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await canvas.findByText(`prop${PROP_COUNT}`); + + const scroller = canvasElement.querySelector('[data-testid="panel-scroll"]'); + const saveBar = canvasElement.querySelector('#save-from-controls'); + await expect(scroller).not.toBeNull(); + await expect(saveBar).not.toBeNull(); + + // Scroll to the very bottom, where the last control would sit under the bar without the fix. + scroller!.scrollTop = scroller!.scrollHeight; + await new Promise((r) => requestAnimationFrame(() => r(null))); + + const rows = canvasElement.querySelectorAll('.docblock-argstable tbody tr'); + const lastRow = rows[rows.length - 1]; + const lastRowRect = lastRow.getBoundingClientRect(); + const barRect = saveBar!.getBoundingClientRect(); + + // The last control's bottom must clear the top of the save bar. + await expect(lastRowRect.bottom).toBeLessThanOrEqual(barRect.top + 1); + }, +}; diff --git a/code/core/src/controls/components/ControlsPanel.tsx b/code/core/src/controls/components/ControlsPanel.tsx index bbabb8a29181..b37124132fc4 100644 --- a/code/core/src/controls/components/ControlsPanel.tsx +++ b/code/core/src/controls/components/ControlsPanel.tsx @@ -1,18 +1,23 @@ import React, { useEffect, useMemo, useState } from 'react'; -import type { ArgTypes } from 'storybook/internal/types'; +import { STORY_FINISHED, STORY_PREPARED } from 'storybook/internal/core-events'; +import type { ArgTypes, StoryId } from 'storybook/internal/types'; import { global } from '@storybook/global'; import { dequal as deepEqual } from 'dequal'; +import { mergeServiceArgTypes } from '../../docs-tools/argTypes/docgenServiceArgTypes.ts'; import { useArgTypes, useArgs, + useChannel, useGlobals, useParameter, + useServiceQuery, useStorybookApi, useStorybookState, } from 'storybook/manager-api'; +import type { DocgenService } from 'storybook/open-service'; import { styled } from 'storybook/theming'; import { @@ -30,9 +35,16 @@ const clean = (obj: { [key: string]: any }) => {} as typeof obj ); +// True when at least one row carries an interactive control, i.e. the args table renders rows instead +// of its "No controls" empty state. Used to decide between showing controls and the loading skeleton +// while docgen for the story is still pending, so the empty state never flashes before docgen lands. +const hasAnyControl = (rows: ArgTypes) => Object.values(rows).some((arg) => arg?.control); + const AddonWrapper = styled.div<{ showSaveFromUI: boolean }>(({ showSaveFromUI, theme }) => ({ - height: '100%', - maxHeight: '100vh', + // `minHeight` (not `height`) so the wrapper fills a short panel but grows with tall + // content; otherwise a fixed height clips the bottom padding into the middle of the + // scrolled content and the save bar overlaps the last control (#34531). + minHeight: '100%', paddingBottom: showSaveFromUI ? 41 : 0, backgroundColor: theme.background.content, @@ -51,33 +63,10 @@ interface ControlsParameters { interface ControlsPanelProps { saveStory: () => Promise; createStory: (storyName: string) => Promise; + docgenService?: DocgenService; } -export const ControlsPanel = ({ saveStory, createStory }: ControlsPanelProps) => { - const api = useStorybookApi(); - const [isLoading, setIsLoading] = useState(true); - const [args, updateArgs, resetArgs, initialArgs] = useArgs(); - const [globals] = useGlobals(); - const rows = useArgTypes(); - const { - expanded, - sort, - presetColors, - disableSaveFromUI = false, - } = useParameter(PARAM_KEY, {}); - const { path, previewInitialized } = useStorybookState(); - const storyData = api.getCurrentStoryData(); - - // If the story is prepared, then show the args table - // and reset the loading states - useEffect(() => { - if (previewInitialized) { - setIsLoading(false); - } - }, [previewInitialized]); - - const hasControls = Object.values(rows).some((arg) => arg?.control); - +function applyPresetColors(rows: ArgTypes, presetColors: PresetColor[] | undefined) { const withPresetColors = Object.entries(rows).reduce((acc, [key, arg]) => { const control = arg?.control; @@ -89,6 +78,30 @@ export const ControlsPanel = ({ saveStory, createStory }: ControlsPanelProps) => return acc; }, {} as ArgTypes); + return withPresetColors; +} + +function ControlsPanelTable({ + rows, + isLoading, + saveStory, + createStory, +}: ControlsPanelProps & { rows: ArgTypes; isLoading: boolean }) { + const [args, updateArgs, resetArgs, initialArgs] = useArgs(); + const [globals] = useGlobals(); + const { + expanded, + sort, + presetColors, + disableSaveFromUI = false, + } = useParameter(PARAM_KEY, {}); + const { path } = useStorybookState(); + const api = useStorybookApi(); + const storyData = api.getCurrentStoryData(); + + const hasControls = hasAnyControl(rows); + const withPresetColors = applyPresetColors(rows, presetColors); + const hasUpdatedArgs = useMemo( () => !!args && !!initialArgs && !deepEqual(clean(args), clean(initialArgs)), [args, initialArgs] @@ -119,4 +132,169 @@ export const ControlsPanel = ({ saveStory, createStory }: ControlsPanelProps) => {showSaveFromUI && } ); +} + +function LegacyControlsPanel(props: ControlsPanelProps) { + const [isLoading, setIsLoading] = useState(true); + const rows = useArgTypes(); + const { refs, previewInitialized } = useStorybookState(); + const api = useStorybookApi(); + const storyData = api.getCurrentStoryData(); + + // Stories from a composed ref track their own `previewInitialized` flag; the host's + // global flag stays false for them, so read the ref's flag when on a ref story (#34553). + const isPreviewInitialized = storyData?.refId + ? !!refs[storyData.refId]?.previewInitialized + : previewInitialized; + + // If the story is prepared, then show the args table and reset the loading state + useEffect(() => { + if (isPreviewInitialized) { + setIsLoading(false); + } + }, [isPreviewInitialized]); + + return ; +} + +function LoadedServiceControlsPanel({ + customArgTypes, + docgenService, + id, + initialArgs, + isStoryPrepared, + storyData, + ...props +}: ControlsPanelProps & { + customArgTypes: ArgTypes; + docgenService: DocgenService; + id: string; + initialArgs: ReturnType[3]; + isStoryPrepared: boolean; + storyData: ReturnType['getCurrentStoryData']>; +}) { + const { data: docgenPayload, isInitialLoading } = useServiceQuery(docgenService.queries.docgen, { + id, + }); + + // The manager Controls panel only ever shows the main component's rows; subcomponent tabs are a + // docs-blocks-only feature, so this intentionally ignores `payload.subcomponents` to match the + // legacy panel's behavior. + const rows = useMemo( + () => + docgenPayload + ? mergeServiceArgTypes({ + payload: docgenPayload, + storyId: storyData.id, + parameters: storyData.parameters, + initialArgs, + customArgTypes, + }) + : customArgTypes, + [docgenPayload, initialArgs, storyData.id, storyData.parameters, customArgTypes] + ); + + // Keep the skeleton up only while there is genuinely nothing to show: the story isn't prepared, or + // docgen is still doing its first load and there are no annotation controls to fall back on. Once + // docgen resolves (even to nothing) the table or its "No controls" empty state is the real answer — + // never a flash. While docgen loads over already-available annotation controls, show those controls + // rather than skeletoning over them. + return ( + + ); +} + +// Tracks whether it is safe to query docgen for the selected story, gating the CPU-bound worker +// extraction out of the first-render window so it never starves the dev server's bundling or the +// preview's render/paint. The gate resets on every story change. +// +// In a static build there is no bundling to contend with and docgen is precomputed JSON, so the gate +// is open from the start. In dev it opens once the story reaches a safe point in its lifecycle: +// STORY_FINISHED by default, or — with the STORYBOOK_DOCGEN_STORY_PREPARED env var — the earlier +// STORY_PREPARED (the story module has been delivered to the browser, before it mounts and paints), +// trading a slice of the first-render budget for an earlier Controls population. The server reads the +// env var and injects it into the manager as the DOCGEN_STORY_PREPARED global so it can be evaluated +// per project. +function useStoryDocgenGateReady(storyId: StoryId): boolean { + const isDevelopment = global.CONFIG_TYPE === 'DEVELOPMENT'; + const requestAtStoryPrepared = global.DOCGEN_STORY_PREPARED === true; + // Key readiness by the story it was opened for (derived during render, not via an effect). On a + // story switch this is immediately false for the new id, so we never render one frame with a stale + // `ready=true` that would mount the query before the new story emits its gate event. + const [readyStoryId, setReadyStoryId] = useState(null); + const ready = !isDevelopment || readyStoryId === storyId; + + // STORY_PREPARED carries `{ id }`; STORY_FINISHED carries `{ storyId }`. + const gateEvent = requestAtStoryPrepared ? STORY_PREPARED : STORY_FINISHED; + useChannel( + { + [gateEvent]: (payload: { id?: StoryId; storyId?: StoryId }) => { + const eventStoryId = requestAtStoryPrepared ? payload?.id : payload?.storyId; + if (eventStoryId === storyId) { + setReadyStoryId(storyId); + } + }, + }, + [storyId, requestAtStoryPrepared, gateEvent] + ); + + return ready; +} + +function ServiceControlsPanel({ + docgenService, + ...props +}: ControlsPanelProps & { docgenService: DocgenService }) { + const api = useStorybookApi(); + const storyData = api.getCurrentStoryData(); + const [, , , initialArgs] = useArgs(); + // Custom argTypes (project + meta + story annotations) for the selected story arrive over the + // channel via STORY_PREPARED. With experimentalDocgenServer, prepareStory skips second-pass + // enhancers so these stay annotation-only; mergeServiceArgTypes layers them on server docgen. + const customArgTypes = useArgTypes(); + const id = storyData.id.split('--')[0]; + const isStory = storyData.type === 'story'; + // In a static build docgen is precomputed and there is no gate, so the loading decision must not + // hang on `prepared` — treat non-development as always prepared. In dev this is unchanged. + const isDevelopment = global.CONFIG_TYPE === 'DEVELOPMENT'; + const isStoryPrepared = !isDevelopment || (isStory ? storyData.prepared : true); + // Docs entries don't emit the story lifecycle events the gate listens for, so it only applies to + // actual stories; everything else falls through and queries docgen right away. + const gateReady = useStoryDocgenGateReady(storyData.id); + if (isStory && !gateReady) { + // Docgen hasn't been queried yet (the query is gated until the story reaches a safe lifecycle + // point). Show the story's own annotation argTypes when it has controls; otherwise keep the + // loading skeleton rather than flashing the "No controls" empty state before docgen resolves. + return ( + + ); + } + + return ( + + ); +} + +export const ControlsPanel = ({ docgenService, ...props }: ControlsPanelProps) => { + if (docgenService) { + return ; + } + + return ; }; diff --git a/code/core/src/controls/manager.tsx b/code/core/src/controls/manager.tsx index 5b48e2e39298..7a75596462a5 100644 --- a/code/core/src/controls/manager.tsx +++ b/code/core/src/controls/manager.tsx @@ -12,7 +12,7 @@ import type { Args } from 'storybook/internal/csf'; import { FailedIcon, PassedIcon } from '@storybook/icons'; import { dequal as deepEqual } from 'dequal'; -import { addons, experimental_requestResponse, types } from 'storybook/manager-api'; +import { addons, experimental_requestResponse, getService, types } from 'storybook/manager-api'; import { color } from 'storybook/theming'; import { ControlsPanel } from './components/ControlsPanel.tsx'; @@ -24,6 +24,9 @@ import { stringifyArgs } from './stringifyArgs.tsx'; export default addons.register(ADDON_ID, (api) => { if (globalThis?.FEATURES?.controls) { const channel = addons.getChannel(); + const docgenService = globalThis.FEATURES?.experimentalDocgenServer + ? getService('core/docgen') + : undefined; const saveStory = async () => { const data = api.getCurrentStoryData(); @@ -124,7 +127,11 @@ export default addons.register(ADDON_ID, (api) => { } return ( - + ); }, diff --git a/code/core/src/controls/typings.d.ts b/code/core/src/controls/typings.d.ts index 4a32763646ea..370581ae71e9 100644 --- a/code/core/src/controls/typings.d.ts +++ b/code/core/src/controls/typings.d.ts @@ -1,5 +1,6 @@ declare var DOCS_OPTIONS: any; declare var CONFIG_TYPE: 'DEVELOPMENT' | 'PRODUCTION'; +declare var DOCGEN_STORY_PREPARED: boolean; declare var PREVIEW_URL: any; declare var __STORYBOOK_ADDONS_MANAGER: any; diff --git a/code/core/src/core-server/build-dev.ts b/code/core/src/core-server/build-dev.ts index 1c9431b3a71d..860f92c310df 100644 --- a/code/core/src/core-server/build-dev.ts +++ b/code/core/src/core-server/build-dev.ts @@ -18,7 +18,7 @@ import { CLI_COLORS, deprecate, logger, prompt } from 'storybook/internal/node-l import { MissingBuilderError, NoStatsForViteDevError } from 'storybook/internal/server-errors'; import { detectAgent, oneWayHash, telemetry } from 'storybook/internal/telemetry'; import type { BuilderOptions, CLIOptions, LoadOptions, Options } from 'storybook/internal/types'; - +import { applyServicesPresetOnce } from './utils/apply-services-preset-once.ts'; import { global } from '@storybook/global'; import { join, relative, resolve } from 'pathe'; @@ -35,6 +35,11 @@ import { getManagerBuilder, getPreviewBuilder } from './utils/get-builders.ts'; import { getServerChannel } from './utils/get-server-channel.ts'; import { outputStartupInformation } from './utils/output-startup-information.ts'; import { outputStats } from './utils/output-stats.ts'; +import { + getMcpMetadataFromMainConfig, + type RuntimeInstanceRecord, + writeStorybookRuntimeInstanceRecord, +} from './utils/runtime-instance-registry.ts'; import { getServerAddresses, getServerChannelUrl, getServerPort } from './utils/server-address.ts'; import { getServer } from './utils/server-init.ts'; import { stripCommentsAndStrings } from './utils/strip-comments-and-strings.ts'; @@ -290,6 +295,8 @@ export async function buildDevStandalone( const features = await presets.apply('features'); global.FEATURES = features; + + await applyServicesPresetOnce(presets); await presets.apply('experimental_serverChannel', channel); const fullOptions: Options = { @@ -303,6 +310,18 @@ export async function buildDevStandalone( storybookDevServer(fullOptions, server) ); + const mcp: RuntimeInstanceRecord['mcp'] = getMcpMetadataFromMainConfig(config); + + await writeStorybookRuntimeInstanceRecord({ + address: localAddress, + mcp, + port, + storybookVersion, + }).catch((error: unknown) => { + logger.warn('Storybook failed to write its runtime instance registry record.'); + logger.debug(error instanceof Error ? (error.stack ?? error.message) : String(error)); + }); + const previewTotalTime = previewResult?.totalTime; const managerTotalTime = managerResult?.totalTime; const previewStats = previewResult?.stats; diff --git a/code/core/src/core-server/build-static.ts b/code/core/src/core-server/build-static.ts index 984fb47e3e14..2de2072abed4 100644 --- a/code/core/src/core-server/build-static.ts +++ b/code/core/src/core-server/build-static.ts @@ -17,6 +17,11 @@ import { global } from '@storybook/global'; import { join, relative, resolve } from 'pathe'; import picocolors from 'picocolors'; +import { + getRegisteredServices, + writeOpenServiceStaticFiles, +} from '../shared/open-service/server.ts'; +import { applyServicesPresetOnce } from './utils/apply-services-preset-once.ts'; import { resolvePackageDir } from '../shared/utils/module.ts'; import type { StoryIndexGenerator } from './utils/StoryIndexGenerator.ts'; import { buildOrThrow } from './utils/build-or-throw.ts'; @@ -129,6 +134,7 @@ export async function buildStaticStandalone(options: BuildStaticStandaloneOption const effects: Promise[] = []; global.FEATURES = features; + await applyServicesPresetOnce(presets); if (!options.previewOnly) { await buildOrThrow(async () => @@ -143,7 +149,27 @@ export async function buildStaticStandalone(options: BuildStaticStandaloneOption } const coreServerPublicDir = join(resolvePackageDir('storybook'), 'assets/browser'); - effects.push(cp(coreServerPublicDir, options.outputDir, { recursive: true })); + effects.push(cp(coreServerPublicDir, options.outputDir, { recursive: true, force: true })); + + const hasRegisteredServices = getRegisteredServices().length > 0; + const shouldWriteManifests = !options.ignorePreview && features?.componentsManifest; + + if (hasRegisteredServices || shouldWriteManifests) { + effects.push( + (async () => { + if (hasRegisteredServices) { + logger.info('Building open services..'); + await writeOpenServiceStaticFiles(options.outputDir); + } + + if (shouldWriteManifests) { + // Ref-based components.json reads docgen snapshots from outputDir/services/ — manifests + // must run after open-service static files are written. + await writeManifests(options.outputDir, presets); + } + })() + ); + } let storyIndexGeneratorPromise: Promise = Promise.resolve(undefined); @@ -156,10 +182,6 @@ export async function buildStaticStandalone(options: BuildStaticStandaloneOption storyIndexGeneratorPromise as Promise ) ); - - if (features?.componentsManifest) { - effects.push(writeManifests(options.outputDir, presets)); - } } if (!core?.disableProjectJson) { diff --git a/code/core/src/core-server/change-detection/ChangeDetectionService.ts b/code/core/src/core-server/change-detection/ChangeDetectionService.ts deleted file mode 100644 index 1458b10080ba..000000000000 --- a/code/core/src/core-server/change-detection/ChangeDetectionService.ts +++ /dev/null @@ -1,741 +0,0 @@ -import { writeFile } from 'node:fs/promises'; - -import { join, normalize } from 'pathe'; - -import { dequal } from 'dequal'; -import { logger } from 'storybook/internal/node-logger'; -import { disposeOxcParsePool } from 'storybook/internal/oxc-parser'; -import { getProjectRoot } from 'storybook/internal/common'; -import type { - Presets, - StatusValue, - StoryIndex, - Status, - StatusStoreByTypeId, -} from 'storybook/internal/types'; -import { CHANGE_DETECTION_STATUS_TYPE_ID } from 'storybook/internal/types'; - -import type { StoryIndexGenerator } from '../utils/StoryIndexGenerator.ts'; -import type { ChangeDetectionAdapter, FileChangeEvent } from './adapters/index.ts'; -import { - ChangeDetectionResolverFactory, - DependencyGraphBuilder, - IncrementalPatcher, - ParseResolveCache, -} from './dependency-graph/index.ts'; -import type { DependencyGraph, ReverseIndexImpl } from './dependency-graph/index.ts'; -import { ChangeDetectionFailureError, ChangeDetectionUnavailableError } from './errors.ts'; -import { GitDiffProvider } from './GitDiffProvider.ts'; -import { extractBaselineEntryIds, IndexBaselineService } from './IndexBaselineService.ts'; -import type { ImportParser } from './parser-registry/index.ts'; -import { ParserRegistry, builtinImportParsers } from './parser-registry/index.ts'; -import { resetChangeDetectionReadiness, setChangeDetectionReadiness } from './readiness.ts'; - -const CHANGE_DETECTION_DEBOUNCE_MS = 200; - -function isSameStatus(a: Status | undefined, b: Status): boolean { - if (!a) { - return false; - } - - return ( - a.storyId === b.storyId && - a.typeId === b.typeId && - a.value === b.value && - a.title === b.title && - a.description === b.description && - a.sidebarContextMenu === b.sidebarContextMenu && - dequal(a.data, b.data) - ); -} - -type StoryIdsByFileCacheKey = Awaited>; - -function getStoryIdsByAbsolutePath( - cache: WeakMap< - StoryIdsByFileCacheKey, - { workingDir: string; storyIdsByFile: Map> } - >, - storyIndex: StoryIdsByFileCacheKey, - workingDir: string -): Map> { - const cached = cache.get(storyIndex); - if (cached && cached.workingDir === workingDir) { - return cached.storyIdsByFile; - } - const storyIdsByFile = new Map>(); - Object.values(storyIndex.entries).forEach((entry) => { - if (entry.type === 'story' && !entry.importPath.startsWith('virtual:')) { - const filePath = normalize(join(workingDir, entry.importPath)); - const storyIds = storyIdsByFile.get(filePath) ?? new Set(); - storyIds.add(entry.id); - storyIdsByFile.set(filePath, storyIds); - } - }); - cache.set(storyIndex, { workingDir, storyIdsByFile }); - return storyIdsByFile; -} - -export function mergeStatusValues( - previousValue: StatusValue | undefined, - nextValue: StatusValue -): StatusValue { - if (previousValue === 'status-value:new' || nextValue === 'status-value:new') { - return 'status-value:new'; - } - - if (previousValue === 'status-value:modified' || nextValue === 'status-value:modified') { - return 'status-value:modified'; - } - - if (previousValue === 'status-value:affected' || nextValue === 'status-value:affected') { - return 'status-value:affected'; - } - - return nextValue; -} - -export function mergeChangeDetectionStatuses( - existing: Status | undefined, - incoming: Status -): Status { - return { - ...incoming, - value: mergeStatusValues(existing?.value, incoming.value), - title: incoming.title || existing?.title || '', - description: incoming.description || existing?.description || '', - sidebarContextMenu: incoming.sidebarContextMenu ?? existing?.sidebarContextMenu ?? false, - }; -} - -export function buildIndexBaselineStatuses( - storyIndex: StoryIndex, - baselineEntryIds: Set -): Map { - const statuses = new Map(); - if (baselineEntryIds.size === 0) { - return statuses; - } - - for (const entryId of extractBaselineEntryIds(storyIndex)) { - if (baselineEntryIds.has(entryId)) { - continue; - } - - statuses.set(entryId, { - storyId: entryId, - typeId: CHANGE_DETECTION_STATUS_TYPE_ID, - value: 'status-value:new', - title: '', - description: '', - sidebarContextMenu: false, - }); - } - - return statuses; -} - -/** - * Coordinates change detection by owning a builder-supplied {@link ChangeDetectionAdapter}, - * eagerly building a reverse-dependency index from story files at startup, applying - * file-system events incrementally to that index, resolving git-changed files, and publishing - * the resulting story statuses to the status store. - */ -export class ChangeDetectionService { - private disposed = false; - private debounceTimer: ReturnType | undefined; - private scanInFlight = false; - private rerunAfterCurrentScan = false; - private readinessResolved = false; - private refreshInFlight = false; - private previousStatuses = new Map(); - private gitDiffProvider: GitDiffProvider | undefined; - private indexBaselineService: IndexBaselineService | undefined; - private readonly workingDir: string; - private readonly debounceMs: number; - private adapter: ChangeDetectionAdapter | undefined; - private dependencyGraphBuilder: DependencyGraphBuilder | undefined; - private incrementalPatcher: IncrementalPatcher | undefined; - private reverseIndex: ReverseIndexImpl | undefined; - private storyFiles: Set = new Set(); - private readonly storyIdsByFileCache = new WeakMap< - StoryIdsByFileCacheKey, - { workingDir: string; storyIdsByFile: Map> } - >(); - /** - * Serialises file-change patches so two events touching the same dep set never interleave - * across `await` points inside `IncrementalPatcher.patch`. The chain ignores rejections - * (each call's failure is logged in {@link handleFileChange}). - */ - private patchQueue: Promise = Promise.resolve(); - private unsubscribeFileChange: (() => void) | undefined; - private unsubscribeStartupFailure: (() => void) | undefined; - - constructor( - private readonly options: { - storyIndexGeneratorPromise: Promise; - statusStore: StatusStoreByTypeId; - gitDiffProvider?: GitDiffProvider; - indexBaselineService?: IndexBaselineService; - workingDir?: string; - debounceMs?: number; - /** Presets instance used to resolve `experimental_importParsers` contributions from framework/renderer plugins. */ - presets?: Presets; - } - ) { - this.gitDiffProvider = options.gitDiffProvider; - this.indexBaselineService = options.indexBaselineService; - this.workingDir = options.workingDir ?? process.cwd(); - this.debounceMs = options.debounceMs ?? CHANGE_DETECTION_DEBOUNCE_MS; - resetChangeDetectionReadiness(); - } - - start(adapter: ChangeDetectionAdapter | undefined, enabled: boolean | undefined): void { - if (enabled === false) { - logger.debug('Change detection disabled.'); - this.resolveReadiness({ - status: 'unavailable', - reason: 'disabled', - }); - return; - } - - if (!adapter) { - logger.warn('Change detection unavailable: builder does not support change detection'); - this.resolveReadiness({ - status: 'unavailable', - reason: 'builder does not support change detection', - }); - return; - } - - logger.debug('Change detection enabled.'); - this.adapter = adapter; - - void this.startInternal().catch((error) => { - if (this.disposed) { - return; - } - const failure = - error instanceof Error ? error : new ChangeDetectionFailureError(String(error)); - logger.error(`Change detection failed to start: ${failure.message}`); - this.resolveReadiness({ status: 'error', error: failure }); - void this.dispose().catch(() => undefined); - }); - } - - /** - * Builds parser registry, resolver, dependency graph, and patcher; subscribes to - * file-change events queued behind {@link patchQueue}; kicks off the baseline service - * and initial scan. - */ - private async startInternal(): Promise { - const adapter = this.adapter; - if (!adapter) { - return; - } - - if (this.disposed) { - return; - } - - const resolveConfig = await adapter.getResolveConfig(); - const projectRoot = normalize(resolveConfig.projectRoot ?? this.workingDir); - - const pluginParsers = this.options.presets - ? await this.options.presets.apply('experimental_importParsers', []) - : []; - const registry = new ParserRegistry({ - defaultParsers: builtinImportParsers, - pluginParsers, - }); - const resolver = new ChangeDetectionResolverFactory(resolveConfig); - const workspaceRoots = new Set([normalize(getProjectRoot())]); - - const storyIndexGenerator = await this.options.storyIndexGeneratorPromise; - const storyIndex = await storyIndexGenerator.getIndex(); - const storyIdsByFile = getStoryIdsByAbsolutePath( - this.storyIdsByFileCache, - storyIndex, - this.workingDir - ); - this.storyFiles = new Set(storyIdsByFile.keys()); - - if (this.disposed) { - return; - } - - // Shared parse/resolve cache so the patcher reuses cold-start results instead of - // re-doing every file's parse + resolution on the first event after boot. The patcher - // invalidates per-file entries on every change/unlink before reading. - const debugEnv = process.env.STORYBOOK_CHANGE_DETECTION_DEBUG; - const cache = new ParseResolveCache({ - registry, - resolver, - workspaceRoots, - projectRoot, - logger, - debug: !!debugEnv, - }); - - this.dependencyGraphBuilder = new DependencyGraphBuilder({ - registry, - resolver, - workspaceRoots, - projectRoot, - cache, - }); - - // Subscribe BEFORE build — buffer events until patcher is ready - const eventBuffer: FileChangeEvent[] = []; - this.unsubscribeFileChange = adapter.onFileChange((event) => { - if (this.disposed) { - return; - } - eventBuffer.push(event); - }); - - const { reverseIndex, graph } = await this.dependencyGraphBuilder.build(this.storyFiles); - if (this.disposed) { - return; - } - this.reverseIndex = reverseIndex; - void this.dumpDebugSnapshot(reverseIndex, graph, projectRoot, workspaceRoots, cache); - - this.incrementalPatcher = new IncrementalPatcher({ - reverseIndex, - graph, - registry, - resolver, - workspaceRoots, - projectRoot, - cache, - isStoryFile: (path: string) => this.storyFiles.has(normalize(path)), - }); - - // Drain buffered events into patchQueue, then switch to live handler - this.unsubscribeFileChange?.(); - for (const event of eventBuffer) { - this.patchQueue = this.patchQueue - .then(() => this.handleFileChange(event)) - .catch(() => undefined); - } - - this.unsubscribeFileChange = adapter.onFileChange((event) => { - if (this.disposed) { - return; - } - this.patchQueue = this.patchQueue - .then(() => this.handleFileChange(event)) - .catch(() => undefined); - }); - - if (adapter.onStartupFailure) { - this.unsubscribeStartupFailure = adapter.onStartupFailure((event) => { - if (this.disposed) { - return; - } - logger.warn(`Change detection unavailable: ${event.reason}`); - this.resolveReadiness({ - status: 'unavailable', - reason: event.reason, - error: event.error, - }); - void this.dispose(); - }); - } - - void this.getIndexBaselineService().start(); - - this.getGitDiffProvider().onGitStateChange(() => { - if (this.disposed) { - return; - } - - this.scheduleScan(this.debounceMs); - void this.getIndexBaselineService() - .handleGitStateChange() - .catch(() => undefined); - }); - - // Initial scan surfaces git-pending diffs immediately. - this.scheduleScan(0); - } - - onStoryIndexInvalidated(): void { - if (this.disposed) { - return; - } - void this.refreshStoryFiles().catch(() => undefined); - this.scheduleScan(this.debounceMs); - } - - /** - * Re-reads the story index and reconciles {@link storyFiles} with stories that have - * appeared or disappeared since startup. For each story that newly entered the index, the - * patcher is asked to walk it (so its forward edges are recorded). For each story that - * left the index, the patcher is asked to unlink it (so its reverse-index entries are - * pruned). Replays are queued behind {@link patchQueue} to keep the serialised-patch - * invariant intact. - */ - private async refreshStoryFiles(): Promise { - if (this.refreshInFlight || !this.incrementalPatcher) { - return; - } - this.refreshInFlight = true; - try { - const storyIndexGenerator = await this.options.storyIndexGeneratorPromise; - const storyIndex = await storyIndexGenerator.getIndex(); - if (this.disposed) { - return; - } - const storyIdsByFile = getStoryIdsByAbsolutePath( - this.storyIdsByFileCache, - storyIndex, - this.workingDir - ); - const next = new Set(storyIdsByFile.keys()); - const previous = this.storyFiles; - - const added: string[] = []; - for (const path of next) { - if (!previous.has(path)) { - added.push(path); - } - } - const removed: string[] = []; - for (const path of previous) { - if (!next.has(path)) { - removed.push(path); - } - } - - if (added.length === 0 && removed.length === 0) { - return; - } - - this.storyFiles = next; - - for (const path of added) { - this.patchQueue = this.patchQueue - .then(() => this.handleFileChange({ kind: 'add', path })) - .catch(() => undefined); - } - for (const path of removed) { - this.patchQueue = this.patchQueue - .then(() => this.handleFileChange({ kind: 'unlink', path })) - .catch(() => undefined); - } - } finally { - this.refreshInFlight = false; - } - } - - private async dumpDebugSnapshot( - reverseIndex: ReverseIndexImpl, - graph: DependencyGraph, - projectRoot: string, - workspaceRoots: Set, - cache: ParseResolveCache - ): Promise { - const debugEnv = process.env.STORYBOOK_CHANGE_DETECTION_DEBUG; - if (!debugEnv) { - return; - } - const outPath = - debugEnv === '1' || debugEnv === 'true' - ? join(projectRoot, 'storybook-graph-debug.json') - : debugEnv; - - const graphObj: Record = {}; - for (const [story, deps] of graph) { - graphObj[story] = Array.from(deps).sort(); - } - - const reverseObj: Record> = {}; - for (const [dep, stories] of reverseIndex.asMap()) { - reverseObj[dep] = Array.from(stories.entries()) - .map(([story, depth]) => ({ story, depth })) - .sort((a, b) => a.depth - b.depth || a.story.localeCompare(b.story)); - } - - const snapshot = { - timestamp: new Date().toISOString(), - projectRoot, - workspaceRoots: Array.from(workspaceRoots).sort(), - // `graph` is keyed by every walked node (story roots + their transitive deps), - // and `reverseIndex` records each story root at depth 0 alongside real deps — - // so `graph.size` / `reverseIndex.asMap().size` over-report story and dep totals. - // Report `storyFiles` from the authoritative source-of-truth set, plus the raw - // node/entry counts under unambiguous names for diagnostics. - storyFiles: this.storyFiles.size, - graphNodes: graph.size, - reverseIndexEntries: reverseIndex.asMap().size, - graph: graphObj, - reverseIndex: reverseObj, - // Each entry records one named-import barrel lookup: which names were requested, - // which source files they resolved to, and whether the barrel itself was also - // included (needBarrel: true means at least one name fell back to the barrel). - barrelResolutions: cache.getBarrelTrace() ?? [], - }; - - try { - await writeFile(outPath, JSON.stringify(snapshot, null, 2), 'utf8'); - logger.debug(`Change detection: graph debug snapshot written to ${outPath}`); - } catch (error) { - logger.warn( - `Change detection: failed to write debug snapshot to ${outPath}: ${error instanceof Error ? error.message : String(error)}` - ); - } - } - - async dispose(): Promise { - this.disposed = true; - this.rerunAfterCurrentScan = false; - - if (this.debounceTimer) { - clearTimeout(this.debounceTimer); - this.debounceTimer = undefined; - } - - this.unsubscribeFileChange?.(); - this.unsubscribeFileChange = undefined; - this.unsubscribeStartupFailure?.(); - this.unsubscribeStartupFailure = undefined; - - this.gitDiffProvider?.dispose(); - // Drain in-flight patches before tearing down the OXC parse pool so no - // patch reads the pool after it has been disposed. - await this.patchQueue.catch(() => undefined); - await disposeOxcParsePool().catch(() => undefined); - } - - private async handleFileChange(event: FileChangeEvent): Promise { - if (this.disposed || !this.incrementalPatcher) { - return; - } - try { - await this.incrementalPatcher.patch(event); - } catch (error) { - logger.warn( - `Change detection: failed to apply ${event.kind} for ${event.path}: ${error instanceof Error ? error.message : String(error)}` - ); - } - if (this.disposed) { - return; - } - this.scheduleScan(this.debounceMs); - } - - private scheduleScan(delayMs: number): void { - if (this.debounceTimer) { - clearTimeout(this.debounceTimer); - } - - this.debounceTimer = setTimeout(() => { - this.debounceTimer = undefined; - void this.scan(); - }, delayMs); - } - - private async scan(): Promise { - if (this.disposed || !this.reverseIndex) { - return; - } - - // Snapshot and drain the current patch chain before reading reverseIndex. Without this - // await, a scan triggered mid-patch (between removeStory and the re-walk's recordEdges) - // reads a transiently empty reverseIndex and publishes incorrect statuses. - const patchSnapshot = this.patchQueue; - await patchSnapshot.catch(() => undefined); - - if (this.disposed || !this.reverseIndex) { - return; - } - - if (this.scanInFlight) { - this.rerunAfterCurrentScan = true; - return; - } - - this.scanInFlight = true; - - try { - const nextStatuses = await this.buildStatuses(this.reverseIndex); - if (this.disposed) { - return; - } - - this.applyStatusStorePatch(nextStatuses); - this.resolveReadiness({ status: 'ready' }); - } catch (error) { - if (this.disposed) { - return; - } - - if (error instanceof ChangeDetectionUnavailableError) { - logger.warn(`Change detection unavailable: ${error.message}`); - this.resolveReadiness({ - status: 'unavailable', - reason: error.message, - error, - }); - await this.dispose(); - } else if (error instanceof ChangeDetectionFailureError) { - logger.error(`Change detection failed: ${error.message}`); - this.resolveReadiness({ - status: 'error', - error, - }); - } else { - const failure = new ChangeDetectionFailureError( - error instanceof Error ? error.message : String(error), - { cause: error instanceof Error ? error : undefined } - ); - logger.error(`Change detection failed: ${failure.message}`); - this.resolveReadiness({ - status: 'error', - error: failure, - }); - } - } finally { - this.scanInFlight = false; - - if (!this.disposed && this.rerunAfterCurrentScan) { - this.rerunAfterCurrentScan = false; - void this.scan(); - } - } - } - - private async buildStatuses(reverseIndex: ReverseIndexImpl): Promise> { - const gitDiffProvider = this.getGitDiffProvider(); - const [changes, repoRoot, storyIndexGenerator, baselineEntryIds] = await Promise.all([ - gitDiffProvider.getChangedFiles(), - gitDiffProvider.getRepoRoot(), - this.options.storyIndexGeneratorPromise, - this.getIndexBaselineService().getBaselineEntryIds(), - ]); - - const changedFiles = new Set( - Array.from(changes.changed).map((path) => normalize(join(repoRoot, path))) - ); - const newFiles = new Set( - Array.from(changes.new).map((path) => normalize(join(repoRoot, path))) - ); - const scannedFiles = new Set([...changedFiles, ...newFiles]); - - const storyIndex = await storyIndexGenerator.getIndex(); - const baselineStatuses = buildIndexBaselineStatuses(storyIndex, baselineEntryIds); - const storyIdsByFile = getStoryIdsByAbsolutePath( - this.storyIdsByFileCache, - storyIndex, - this.workingDir - ); - const statuses = new Map(); - - for (const changedFile of scannedFiles) { - const affectedStoryFiles = reverseIndex.lookup(changedFile); - // Include the changed file as a story-at-distance-0 if it IS a story (parity with - // legacy trace-changed.ts:10-12). - const allEntries = new Map(affectedStoryFiles); - if (storyIdsByFile.has(changedFile)) { - allEntries.set(changedFile, 0); - } - if (allEntries.size === 0) { - continue; - } - let lowestDistance = Number.POSITIVE_INFINITY; - for (const distance of allEntries.values()) { - if (distance < lowestDistance) { - lowestDistance = distance; - } - } - - for (const [storyFile, distance] of allEntries.entries()) { - const storyIds = storyIdsByFile.get(storyFile); - if (!storyIds) { - continue; - } - - const value: Status['value'] = newFiles.has(storyFile) - ? 'status-value:new' - : distance === lowestDistance - ? 'status-value:modified' - : 'status-value:affected'; - - storyIds.forEach((storyId) => { - const existingStatus = statuses.get(storyId); - - const nextStatus: Status = { - storyId, - typeId: CHANGE_DETECTION_STATUS_TYPE_ID, - value: mergeStatusValues(existingStatus?.value, value), - title: '', - description: '', - sidebarContextMenu: false, - }; - - statuses.set(storyId, mergeChangeDetectionStatuses(existingStatus, nextStatus)); - }); - } - } - - baselineStatuses.forEach((status, storyId) => { - statuses.set(storyId, mergeChangeDetectionStatuses(statuses.get(storyId), status)); - }); - - return statuses; - } - - private getGitDiffProvider(): GitDiffProvider { - this.gitDiffProvider ??= new GitDiffProvider(this.workingDir); - return this.gitDiffProvider; - } - - private getIndexBaselineService(): IndexBaselineService { - this.indexBaselineService ??= new IndexBaselineService({ - storyIndexGeneratorPromise: this.options.storyIndexGeneratorPromise, - gitDiffProvider: this.getGitDiffProvider(), - onBaselineUpdated: () => this.scheduleScan(this.debounceMs), - }); - return this.indexBaselineService; - } - - private applyStatusStorePatch(nextStatuses: Map): void { - const removedStoryIds = Array.from(this.previousStatuses.keys()).filter( - (storyId) => !nextStatuses.has(storyId) - ); - const changedStatuses = Array.from(nextStatuses.values()).filter( - (status) => !isSameStatus(this.previousStatuses.get(status.storyId), status) - ); - - if (removedStoryIds.length === 0 && changedStatuses.length === 0) { - return; - } - - if (removedStoryIds.length > 0) { - this.options.statusStore.unset(removedStoryIds); - } - - if (changedStatuses.length > 0) { - this.options.statusStore.set(changedStatuses); - } - - this.previousStatuses = new Map(nextStatuses); - } - - private resolveReadiness( - readiness: - | { status: 'ready' } - | { status: 'unavailable'; reason: string; error?: Error } - | { status: 'error'; error: Error } - ): void { - if (this.readinessResolved) { - return; - } - - this.readinessResolved = true; - setChangeDetectionReadiness(readiness); - } -} diff --git a/code/core/src/core-server/change-detection/adapters/index.ts b/code/core/src/core-server/change-detection/adapters/index.ts deleted file mode 100644 index db24f8868698..000000000000 --- a/code/core/src/core-server/change-detection/adapters/index.ts +++ /dev/null @@ -1 +0,0 @@ -export type { ChangeDetectionAdapter, FileChangeEvent, ModuleResolveConfig } from './types.ts'; diff --git a/code/core/src/core-server/change-detection/ChangeDetectionService.test.ts b/code/core/src/core-server/change-detection/change-detection-service.test.ts similarity index 67% rename from code/core/src/core-server/change-detection/ChangeDetectionService.test.ts rename to code/core/src/core-server/change-detection/change-detection-service.test.ts index a7d07ce751de..974938d40be6 100644 --- a/code/core/src/core-server/change-detection/ChangeDetectionService.test.ts +++ b/code/core/src/core-server/change-detection/change-detection-service.test.ts @@ -9,119 +9,51 @@ import { UNIVERSAL_STATUS_STORE_OPTIONS, } from '../../shared/status-store/index.ts'; import { MockUniversalStore } from '../../shared/universal-store/mock.ts'; -import type { ChangeDetectionAdapter, FileChangeEvent } from './adapters/index.ts'; -import { getChangeDetectionReadiness, internal_resetChangeDetectionReadiness } from './index.ts'; -import { ChangeDetectionFailureError, ChangeDetectionUnavailableError } from './errors.ts'; + +import { getService } from '../../shared/open-service/server.ts'; +import { + buildReverseIndex, + createDeferred, + createMockAdapter, + createStoryIndex, + createWiredChangeDetection, + installDependencyGraphMocks, + installModuleGraphQueryMock, +} from './change-detection.test-helpers.ts'; import { buildIndexBaselineStatuses, ChangeDetectionService, mergeChangeDetectionStatuses, mergeStatusValues, -} from './ChangeDetectionService.ts'; -import * as oxcParser from 'storybook/internal/oxc-parser'; +} from './change-detection-service.ts'; +import { ChangeDetectionFailureError, ChangeDetectionUnavailableError } from './errors.ts'; import { - ChangeDetectionResolverFactory, - DependencyGraphBuilder, - IncrementalPatcher, - ReverseIndexImpl, -} from './dependency-graph/index.ts'; + getChangeDetectionReadiness, + resetChangeDetectionReadiness as internal_resetChangeDetectionReadiness, +} from './readiness.ts'; import type { GitDiffResult } from './GitDiffProvider.ts'; import { GitDiffProvider } from './GitDiffProvider.ts'; import type { IndexBaselineService } from './IndexBaselineService.ts'; +import type { ModuleGraphEngine } from '../../shared/open-service/services/module-graph/engine/module-graph-engine.ts'; vi.mock('storybook/internal/node-logger', { spy: true }); -vi.mock('./dependency-graph/index.ts', async (importOriginal) => { - // Keep ReverseIndexImpl + types real so tests can build synthetic indexes; replace the - // ChangeDetectionResolverFactory / DependencyGraphBuilder / IncrementalPatcher constructors - // with `vi.fn()`s so tests can override their behaviour per-case via - // `vi.mocked(Ctor).mockImplementation(...)`. - const actual = await importOriginal(); - return { - ...actual, - ChangeDetectionResolverFactory: vi.fn(), - DependencyGraphBuilder: vi.fn(), - IncrementalPatcher: vi.fn(), - }; -}); - -function createDeferred() { - let resolve!: (value: T) => void; - - return { - promise: new Promise((fulfill) => { - resolve = fulfill; - }), - resolve, - }; -} - -function createStoryIndex( - entries: Array<{ storyId: string; importPath: string; title?: string; name?: string }> -): StoryIndex { - return { - v: 5, - entries: Object.fromEntries( - entries.map(({ storyId, importPath, title = 'Story', name = 'Default' }) => [ - storyId, - { - id: storyId, - type: 'story', - subtype: 'story', - title, - name, - importPath, - }, - ]) - ), - }; -} - -interface MockAdapterHandle { - adapter: ChangeDetectionAdapter; - emitFileChange: (event: FileChangeEvent) => void; - emitStartupFailure: (event: { reason: string; error?: Error }) => void; - hasFileChangeSubscriber: () => boolean; - hasStartupFailureSubscriber: () => boolean; -} - -function createMockAdapter(opts?: { - resolveConfig?: { projectRoot?: string }; - withoutStartupFailure?: boolean; -}): MockAdapterHandle { - const fileHandlers = new Set<(e: FileChangeEvent) => void>(); - const startupHandlers = new Set<(e: { reason: string; error?: Error }) => void>(); - - const adapter: ChangeDetectionAdapter = { - async getResolveConfig() { - return { - projectRoot: opts?.resolveConfig?.projectRoot ?? '/repo', - }; - }, - onFileChange(handler) { - fileHandlers.add(handler); - return () => fileHandlers.delete(handler); - }, - }; - - if (!opts?.withoutStartupFailure) { - adapter.onStartupFailure = (handler) => { - startupHandlers.add(handler); - return () => startupHandlers.delete(handler); - }; +vi.mock('../../shared/open-service/server.ts', () => ({ + getService: vi.fn(), +})); +vi.mock( + '../../shared/open-service/services/module-graph/engine/dependency-graph/resolver-factory.ts', + { + spy: true, } - - return { - adapter, - emitFileChange: (event) => { - fileHandlers.forEach((h) => h(event)); - }, - emitStartupFailure: (event) => { - startupHandlers.forEach((h) => h(event)); - }, - hasFileChangeSubscriber: () => fileHandlers.size > 0, - hasStartupFailureSubscriber: () => startupHandlers.size > 0, - }; -} +); +vi.mock( + '../../shared/open-service/services/module-graph/engine/dependency-graph/dependency-graph-builder.ts', + { spy: true } +); +vi.mock( + '../../shared/open-service/services/module-graph/engine/dependency-graph/incremental-patcher.ts', + { spy: true } +); class MockGitDiffProvider extends GitDiffProvider { readonly getChangedFilesMock = vi.fn( @@ -197,50 +129,6 @@ function createStatus(value: Status['value'], data?: Status['data']): Status { }; } -/** - * Build a ReverseIndexImpl populated with the given (dep -> story -> depth) entries. - * Used by tests to control what `reverseIndex.lookup(changedFile)` returns. - */ -function buildReverseIndex(edges: Iterable): ReverseIndexImpl { - const reverseIndex = new ReverseIndexImpl(); - for (const [dep, story, depth] of edges) { - reverseIndex.record(dep, story, depth); - } - return reverseIndex; -} - -/** - * Stub the dependency-graph constructors so the service uses an in-test - * ReverseIndexImpl + an inert IncrementalPatcher. - * - * Note: `vi.mock` replaces these exports with plain `vi.fn()` constructors. When the - * service calls `new Ctor(...)` we must return objects via `mockImplementation` — - * but vitest invokes the impl with `Reflect.construct` on `new`, so arrow-function - * impls throw "is not a constructor". `function () { return obj; }` works because - * regular functions support `[[Construct]]`. - */ -function installDependencyGraphMocks(reverseIndex: ReverseIndexImpl): { - patchSpy: ReturnType; - buildSpy: ReturnType; -} { - const patchSpy = vi.fn(async () => undefined); - const buildSpy = vi.fn(async () => ({ reverseIndex, graph: new Map() })); - - vi.mocked(ChangeDetectionResolverFactory).mockImplementation(function () { - return { - resolve: vi.fn(async () => null), - } as unknown as ChangeDetectionResolverFactory; - } as unknown as new () => ChangeDetectionResolverFactory); - vi.mocked(DependencyGraphBuilder).mockImplementation(function () { - return { build: buildSpy } as unknown as DependencyGraphBuilder; - } as unknown as new () => DependencyGraphBuilder); - vi.mocked(IncrementalPatcher).mockImplementation(function () { - return { patch: patchSpy } as unknown as IncrementalPatcher; - } as unknown as new () => IncrementalPatcher); - - return { patchSpy, buildSpy }; -} - describe('ChangeDetectionService', () => { const workingDir = '/repo'; @@ -284,7 +172,7 @@ describe('ChangeDetectionService', () => { }); }); const { adapter } = createMockAdapter(); - const service = new ChangeDetectionService({ + const { service, graph } = createWiredChangeDetection({ storyIndexGeneratorPromise: Promise.resolve({ getIndex: vi.fn().mockResolvedValue(storyIndex), } as never), @@ -294,7 +182,8 @@ describe('ChangeDetectionService', () => { workingDir, }); - service.start(adapter, true); + graph.start(adapter); + service.start(true); await vi.runAllTimersAsync(); expect(getStatusStoreByTypeId(CHANGE_DETECTION_STATUS_TYPE_ID).getAll()).toEqual({ @@ -354,7 +243,7 @@ describe('ChangeDetectionService', () => { }); }); const { adapter } = createMockAdapter(); - const service = new ChangeDetectionService({ + const { service, graph } = createWiredChangeDetection({ storyIndexGeneratorPromise: Promise.resolve({ getIndex: vi.fn().mockResolvedValue(storyIndex), } as never), @@ -364,7 +253,8 @@ describe('ChangeDetectionService', () => { workingDir, }); - service.start(adapter, true); + graph.start(adapter); + service.start(true); await vi.runAllTimersAsync(); const all = getStatusStoreByTypeId(CHANGE_DETECTION_STATUS_TYPE_ID).getAll(); @@ -400,7 +290,7 @@ describe('ChangeDetectionService', () => { }); }); const { adapter } = createMockAdapter(); - const service = new ChangeDetectionService({ + const { service, graph } = createWiredChangeDetection({ storyIndexGeneratorPromise: Promise.resolve({ getIndex: vi.fn().mockResolvedValue(storyIndex), } as never), @@ -410,7 +300,8 @@ describe('ChangeDetectionService', () => { workingDir, }); - service.start(adapter, true); + graph.start(adapter); + service.start(true); await vi.runAllTimersAsync(); const all = getStatusStoreByTypeId(CHANGE_DETECTION_STATUS_TYPE_ID).getAll(); @@ -444,7 +335,7 @@ describe('ChangeDetectionService', () => { }); }); const { adapter } = createMockAdapter(); - const service = new ChangeDetectionService({ + const { service, graph } = createWiredChangeDetection({ storyIndexGeneratorPromise: Promise.resolve({ getIndex: vi.fn().mockResolvedValue(storyIndex), } as never), @@ -454,7 +345,8 @@ describe('ChangeDetectionService', () => { workingDir, }); - service.start(adapter, true); + graph.start(adapter); + service.start(true); await vi.runAllTimersAsync(); expect(getStatusStoreByTypeId(CHANGE_DETECTION_STATUS_TYPE_ID).getAll()).toEqual({}); @@ -496,7 +388,7 @@ describe('ChangeDetectionService', () => { onGitStateChange = callback; }); const { adapter } = createMockAdapter(); - const service = new ChangeDetectionService({ + const { service, graph } = createWiredChangeDetection({ storyIndexGeneratorPromise: Promise.resolve({ getIndex: vi.fn().mockResolvedValue(storyIndex), } as never), @@ -507,7 +399,8 @@ describe('ChangeDetectionService', () => { debounceMs: 10, }); - service.start(adapter, true); + graph.start(adapter); + service.start(true); await vi.runAllTimersAsync(); expect(getStatusStoreByTypeId(CHANGE_DETECTION_STATUS_TYPE_ID).getAll()).toEqual({ @@ -562,7 +455,7 @@ describe('ChangeDetectionService', () => { onGitStateChange = callback; }); const { adapter } = createMockAdapter(); - const service = new ChangeDetectionService({ + const { service, graph } = createWiredChangeDetection({ storyIndexGeneratorPromise: Promise.resolve({ getIndex: vi.fn().mockResolvedValue(storyIndex), } as never), @@ -573,7 +466,8 @@ describe('ChangeDetectionService', () => { debounceMs: 10, }); - service.start(adapter, true); + graph.start(adapter); + service.start(true); await vi.runAllTimersAsync(); expect(getStatusStoreByTypeId(CHANGE_DETECTION_STATUS_TYPE_ID).getAll()).toEqual({ @@ -631,7 +525,7 @@ describe('ChangeDetectionService', () => { }); }); const { adapter } = createMockAdapter(); - const service = new ChangeDetectionService({ + const { service, graph } = createWiredChangeDetection({ storyIndexGeneratorPromise: Promise.resolve({ getIndex: vi.fn().mockResolvedValue(storyIndex), } as never), @@ -642,7 +536,8 @@ describe('ChangeDetectionService', () => { debounceMs: 10, }); - service.start(adapter, true); + graph.start(adapter); + service.start(true); await vi.runAllTimersAsync(); expect(gitDiffProvider.onGitStateChangeMock).toHaveBeenCalledTimes(1); @@ -661,7 +556,14 @@ describe('ChangeDetectionService', () => { }); it('debounces consecutive file-change events into a single scan', async () => { - installDependencyGraphMocks(buildReverseIndex([])); + // The edited files must be in the graph (importers of Button.stories.tsx); out-of-graph + // changes bump no story and intentionally do not trigger a change-detection scan. + const reverseIndex = buildReverseIndex([ + ['/repo/src/A.ts', '/repo/src/Button.stories.tsx', 1], + ['/repo/src/B.ts', '/repo/src/Button.stories.tsx', 1], + ['/repo/src/C.ts', '/repo/src/Button.stories.tsx', 1], + ]); + installDependencyGraphMocks(reverseIndex); const storyIndex = createStoryIndex([ { storyId: 'button--primary', importPath: './src/Button.stories.tsx', title: 'Button' }, @@ -672,7 +574,7 @@ describe('ChangeDetectionService', () => { }); const gitDiffProvider = createMockGitDiffProvider(); const { adapter, emitFileChange } = createMockAdapter(); - const service = new ChangeDetectionService({ + const { service, graph } = createWiredChangeDetection({ storyIndexGeneratorPromise: Promise.resolve({ getIndex: vi.fn().mockResolvedValue(storyIndex), } as never), @@ -683,7 +585,8 @@ describe('ChangeDetectionService', () => { debounceMs: 50, }); - service.start(adapter, true); + graph.start(adapter); + service.start(true); // First scan from initial start — debounce 0 runs synchronously. await vi.runAllTimersAsync(); expect(gitDiffProvider.getChangedFilesMock).toHaveBeenCalledTimes(1); @@ -705,6 +608,47 @@ describe('ChangeDetectionService', () => { await service.dispose(); }); + it('does not rescan when an out-of-graph file changes', async () => { + // orphan.ts is not imported by any story, so the graph revision must not advance and no + // change-detection scan should run off the back of its file event. + const reverseIndex = buildReverseIndex([ + ['/repo/src/Button.tsx', '/repo/src/Button.stories.tsx', 1], + ]); + installDependencyGraphMocks(reverseIndex); + + const storyIndex = createStoryIndex([ + { storyId: 'button--primary', importPath: './src/Button.stories.tsx', title: 'Button' }, + ]); + const { getStatusStoreByTypeId } = createStatusStore({ + universalStatusStore: new MockUniversalStore(UNIVERSAL_STATUS_STORE_OPTIONS), + environment: 'server', + }); + const gitDiffProvider = createMockGitDiffProvider(); + const { adapter, emitFileChange } = createMockAdapter(); + const { service, graph } = createWiredChangeDetection({ + storyIndexGeneratorPromise: Promise.resolve({ + getIndex: vi.fn().mockResolvedValue(storyIndex), + } as never), + statusStore: getStatusStoreByTypeId(CHANGE_DETECTION_STATUS_TYPE_ID), + gitDiffProvider, + indexBaselineService: createMockStoryIndexBaselineService(), + workingDir, + debounceMs: 10, + }); + + graph.start(adapter); + service.start(true); + await vi.runAllTimersAsync(); + expect(gitDiffProvider.getChangedFilesMock).toHaveBeenCalledTimes(1); + + emitFileChange({ kind: 'change', path: '/repo/src/orphan.ts' }); + await vi.runAllTimersAsync(); + + expect(gitDiffProvider.getChangedFilesMock).toHaveBeenCalledTimes(1); + + await service.dispose(); + }); + it('does not subscribe to git state when change detection is disabled', async () => { const { getStatusStoreByTypeId } = createStatusStore({ universalStatusStore: new MockUniversalStore(UNIVERSAL_STATUS_STORE_OPTIONS), @@ -712,7 +656,7 @@ describe('ChangeDetectionService', () => { }); const gitDiffProvider = createMockGitDiffProvider(); const { adapter, hasFileChangeSubscriber } = createMockAdapter(); - const service = new ChangeDetectionService({ + const { service, graph } = createWiredChangeDetection({ storyIndexGeneratorPromise: Promise.resolve({ getIndex: vi.fn(), } as never), @@ -722,7 +666,7 @@ describe('ChangeDetectionService', () => { workingDir, }); - service.start(adapter, false); + service.start(false); expect(hasFileChangeSubscriber()).toBe(false); expect(gitDiffProvider.onGitStateChangeMock).not.toHaveBeenCalled(); @@ -733,7 +677,14 @@ describe('ChangeDetectionService', () => { await service.dispose(); }); - it('logs unavailability when the builder does not provide an adapter', async () => { + it('acts as a consumer of the module-graph open service', async () => { + const engine = { + whenSettled: vi.fn(async () => undefined), + hasGraph: vi.fn(() => false), + lookup: vi.fn(() => new Map()), + } as unknown as ModuleGraphEngine; + installModuleGraphQueryMock(engine); + const { getStatusStoreByTypeId } = createStatusStore({ universalStatusStore: new MockUniversalStore(UNIVERSAL_STATUS_STORE_OPTIONS), environment: 'server', @@ -748,7 +699,36 @@ describe('ChangeDetectionService', () => { workingDir, }); - service.start(undefined, true); + service.start(false); + await service.dispose(); + + expect(engine.lookup).not.toHaveBeenCalled(); + }); + + it('logs unavailability when the module graph service is unavailable', async () => { + const engine = { + whenSettled: vi.fn(async () => undefined), + hasGraph: vi.fn(() => false), + lookup: vi.fn(() => new Map()), + } as unknown as ModuleGraphEngine; + const moduleGraphMock = installModuleGraphQueryMock(engine); + + const { getStatusStoreByTypeId } = createStatusStore({ + universalStatusStore: new MockUniversalStore(UNIVERSAL_STATUS_STORE_OPTIONS), + environment: 'server', + }); + const service = new ChangeDetectionService({ + storyIndexGeneratorPromise: Promise.resolve({ + getIndex: vi.fn(), + } as never), + statusStore: getStatusStoreByTypeId(CHANGE_DETECTION_STATUS_TYPE_ID), + gitDiffProvider: createMockGitDiffProvider(), + indexBaselineService: createMockStoryIndexBaselineService(), + workingDir, + }); + + service.start(true); + moduleGraphMock.applyUnavailable('builder does not support change detection'); expect(logger.warn).toHaveBeenCalledWith( 'Change detection unavailable: builder does not support change detection' @@ -773,7 +753,7 @@ describe('ChangeDetectionService', () => { provider.getChangedFilesMock.mockImplementation(() => gitDeferred.promise); }); const { adapter, emitStartupFailure } = createMockAdapter(); - const service = new ChangeDetectionService({ + const { service, graph } = createWiredChangeDetection({ storyIndexGeneratorPromise: Promise.resolve({ getIndex: vi.fn().mockResolvedValue(createStoryIndex([])), } as never), @@ -783,7 +763,8 @@ describe('ChangeDetectionService', () => { workingDir, }); - service.start(adapter, true); + graph.start(adapter); + service.start(true); // Let startInternal subscribe before emitting the failure (initial scan parked on git). await vi.runAllTimersAsync(); @@ -812,7 +793,7 @@ describe('ChangeDetectionService', () => { environment: 'server', }); const { adapter } = createMockAdapter(); - const service = new ChangeDetectionService({ + const { service, graph } = createWiredChangeDetection({ storyIndexGeneratorPromise: Promise.resolve({ getIndex: vi.fn().mockResolvedValue(createStoryIndex([])), } as never), @@ -822,12 +803,11 @@ describe('ChangeDetectionService', () => { workingDir, }); - service.start(adapter, true); + graph.start(adapter); + service.start(true); await vi.runAllTimersAsync(); - expect(logger.error).toHaveBeenCalledWith( - 'Change detection failed to start: graph build blew up' - ); + expect(logger.error).toHaveBeenCalledWith('Module graph failed to start: graph build blew up'); expect(await getChangeDetectionReadiness()).toEqual({ status: 'error', error: expect.objectContaining({ message: 'graph build blew up' }), @@ -835,40 +815,6 @@ describe('ChangeDetectionService', () => { await service.dispose(); }); - it('disposes the pool when startInternal throws', async () => { - // The startInternal pipeline throws after startup. Without the dispose() call in the - // catch handler disposeOxcParsePool would not be called. - const { buildSpy } = installDependencyGraphMocks(buildReverseIndex([])); - buildSpy.mockImplementation(async () => { - throw new ChangeDetectionFailureError('graph build blew up'); - }); - - const disposePoolSpy = vi.spyOn(oxcParser, 'disposeOxcParsePool').mockResolvedValue(undefined); - - const { getStatusStoreByTypeId } = createStatusStore({ - universalStatusStore: new MockUniversalStore(UNIVERSAL_STATUS_STORE_OPTIONS), - environment: 'server', - }); - const { adapter } = createMockAdapter(); - const service = new ChangeDetectionService({ - storyIndexGeneratorPromise: Promise.resolve({ - getIndex: vi.fn().mockResolvedValue(createStoryIndex([])), - } as never), - statusStore: getStatusStoreByTypeId(CHANGE_DETECTION_STATUS_TYPE_ID), - gitDiffProvider: createMockGitDiffProvider(), - indexBaselineService: createMockStoryIndexBaselineService(), - workingDir, - }); - - service.start(adapter, true); - await vi.runAllTimersAsync(); - - // dispose() must have been called, which calls disposeOxcParsePool exactly once. - expect(disposePoolSpy).toHaveBeenCalledTimes(1); - - await service.dispose(); - }); - it('keeps the previous statuses when a live rescan fails', async () => { const reverseIndex = buildReverseIndex([ ['/repo/src/Button.stories.tsx', '/repo/src/Button.stories.tsx', 0], @@ -895,7 +841,7 @@ describe('ChangeDetectionService', () => { onGitStateChange = callback; }); const { adapter } = createMockAdapter(); - const service = new ChangeDetectionService({ + const { service, graph } = createWiredChangeDetection({ storyIndexGeneratorPromise: Promise.resolve({ getIndex: vi.fn().mockResolvedValue(storyIndex), } as never), @@ -906,7 +852,8 @@ describe('ChangeDetectionService', () => { debounceMs: 10, }); - service.start(adapter, true); + graph.start(adapter); + service.start(true); await vi.runAllTimersAsync(); onGitStateChange?.(); @@ -949,7 +896,7 @@ describe('ChangeDetectionService', () => { provider.getChangedFilesMock.mockImplementation(() => changedFilesDeferred.promise); }); const { adapter } = createMockAdapter(); - const service = new ChangeDetectionService({ + const { service, graph } = createWiredChangeDetection({ storyIndexGeneratorPromise: Promise.resolve({ getIndex: vi.fn().mockResolvedValue(storyIndex), } as never), @@ -960,10 +907,10 @@ describe('ChangeDetectionService', () => { debounceMs: 0, }); - service.start(adapter, true); + graph.start(adapter); + service.start(true); await vi.advanceTimersByTimeAsync(0); await service.dispose(); - changedFilesDeferred.resolve({ changed: new Set(['src/Button.stories.tsx']), new: new Set(), @@ -993,7 +940,7 @@ describe('ChangeDetectionService', () => { ); }); const { adapter } = createMockAdapter(); - const service = new ChangeDetectionService({ + const { service, graph } = createWiredChangeDetection({ storyIndexGeneratorPromise: Promise.resolve({ getIndex: vi.fn().mockResolvedValue(createStoryIndex([])), } as never), @@ -1004,7 +951,8 @@ describe('ChangeDetectionService', () => { debounceMs: 0, }); - service.start(adapter, true); + graph.start(adapter); + service.start(true); await vi.runAllTimersAsync(); expect(gitDiffProvider.getChangedFilesMock).toHaveBeenCalledTimes(1); @@ -1017,53 +965,6 @@ describe('ChangeDetectionService', () => { await service.dispose(); }); - it('serialises concurrent file-change events through the patch chain', async () => { - const reverseIndex = buildReverseIndex([]); - const { patchSpy, buildSpy } = installDependencyGraphMocks(reverseIndex); - buildSpy.mockResolvedValue({ reverseIndex, graph: new Map() }); - - let activePatches = 0; - let maxConcurrent = 0; - patchSpy.mockImplementation(async () => { - activePatches += 1; - maxConcurrent = Math.max(maxConcurrent, activePatches); - // Force an actual await so two concurrent calls would visibly interleave. - await new Promise((resolve) => setImmediate(resolve)); - activePatches -= 1; - }); - - const storyIndex = createStoryIndex([ - { storyId: 'button--primary', importPath: './src/Button.stories.tsx', title: 'Button' }, - ]); - const { getStatusStoreByTypeId } = createStatusStore({ - universalStatusStore: new MockUniversalStore(UNIVERSAL_STATUS_STORE_OPTIONS), - environment: 'server', - }); - const { adapter, emitFileChange } = createMockAdapter(); - const service = new ChangeDetectionService({ - storyIndexGeneratorPromise: Promise.resolve({ - getIndex: vi.fn().mockResolvedValue(storyIndex), - } as never), - statusStore: getStatusStoreByTypeId(CHANGE_DETECTION_STATUS_TYPE_ID), - gitDiffProvider: createMockGitDiffProvider(), - indexBaselineService: createMockStoryIndexBaselineService(), - workingDir, - }); - - service.start(adapter, true); - await vi.runAllTimersAsync(); - - emitFileChange({ kind: 'change', path: '/repo/src/Button.tsx' }); - emitFileChange({ kind: 'change', path: '/repo/src/Other.tsx' }); - emitFileChange({ kind: 'unlink', path: '/repo/src/Stale.tsx' }); - await vi.runAllTimersAsync(); - - expect(patchSpy).toHaveBeenCalledTimes(3); - expect(maxConcurrent).toBe(1); - - await service.dispose(); - }); - it('scan waits for the current patch to settle before reading reverseIndex', async () => { // Without the patchSnapshot await in scan(), a git-state-change that fires scheduleScan // while a patch is mid-rewalk (reverseIndex transiently empty) reads the empty index @@ -1098,7 +999,7 @@ describe('ChangeDetectionService', () => { triggerGitStateChange = callback; }); const { adapter, emitFileChange } = createMockAdapter(); - const service = new ChangeDetectionService({ + const { service, graph } = createWiredChangeDetection({ storyIndexGeneratorPromise: Promise.resolve({ getIndex: vi.fn().mockResolvedValue(storyIndex), } as never), @@ -1109,7 +1010,8 @@ describe('ChangeDetectionService', () => { debounceMs: 0, }); - service.start(adapter, true); + graph.start(adapter); + service.start(true); await vi.runAllTimersAsync(); // Start a patch that will block, then immediately schedule a scan mid-patch. @@ -1129,54 +1031,6 @@ describe('ChangeDetectionService', () => { await service.dispose(); }); - it('does not patch file-change events emitted before the adapter subscription is installed (pre-build events are dropped)', async () => { - const reverseIndex = buildReverseIndex([]); - const buildDeferred = createDeferred(); - const { patchSpy, buildSpy } = installDependencyGraphMocks(reverseIndex); - buildSpy.mockImplementation(async () => { - await buildDeferred.promise; - return { reverseIndex, graph: new Map() }; - }); - - const storyIndex = createStoryIndex([ - { storyId: 'button--primary', importPath: './src/Button.stories.tsx', title: 'Button' }, - ]); - const { getStatusStoreByTypeId } = createStatusStore({ - universalStatusStore: new MockUniversalStore(UNIVERSAL_STATUS_STORE_OPTIONS), - environment: 'server', - }); - const { adapter, emitFileChange } = createMockAdapter(); - const service = new ChangeDetectionService({ - storyIndexGeneratorPromise: Promise.resolve({ - getIndex: vi.fn().mockResolvedValue(storyIndex), - } as never), - statusStore: getStatusStoreByTypeId(CHANGE_DETECTION_STATUS_TYPE_ID), - gitDiffProvider: createMockGitDiffProvider(), - indexBaselineService: createMockStoryIndexBaselineService(), - workingDir, - }); - - service.start(adapter, true); - // Allow startInternal to reach the build step and start awaiting it. - await Promise.resolve(); - await Promise.resolve(); - - // The service subscribes to file-change events strictly after the eager build resolves, - // so anything emitted by the adapter before then has nowhere to land. Assert no patch - // calls have happened yet. - expect(patchSpy).not.toHaveBeenCalled(); - - buildDeferred.resolve(); - await vi.runAllTimersAsync(); - - // Now the adapter has subscribers — file events go through the patcher. - emitFileChange({ kind: 'change', path: '/repo/src/Button.tsx' }); - await vi.runAllTimersAsync(); - expect(patchSpy).toHaveBeenCalledTimes(1); - - await service.dispose(); - }); - it('calls gitDiffProvider.dispose() on service dispose when a git watcher was installed', async () => { // Watcher leak: onGitStateChange installs FS watchers; without dispose() the watchers // survive the service lifetime and fire stale callbacks on long-lived processes. @@ -1187,7 +1041,7 @@ describe('ChangeDetectionService', () => { provider.onGitStateChangeMock.mockImplementation(() => undefined); }); const { adapter } = createMockAdapter(); - const service = new ChangeDetectionService({ + const { service, graph } = createWiredChangeDetection({ storyIndexGeneratorPromise: Promise.resolve({ getIndex: vi.fn().mockResolvedValue(createStoryIndex([])), } as never), @@ -1200,11 +1054,11 @@ describe('ChangeDetectionService', () => { workingDir, }); - service.start(adapter, true); + graph.start(adapter); + service.start(true); await vi.runAllTimersAsync(); await service.dispose(); - expect(gitDiffProvider.disposeMock).toHaveBeenCalledTimes(1); }); @@ -1215,7 +1069,7 @@ describe('ChangeDetectionService', () => { universalStatusStore: new MockUniversalStore(UNIVERSAL_STATUS_STORE_OPTIONS), environment: 'server', }); - const service = new ChangeDetectionService({ + const { service, graph } = createWiredChangeDetection({ storyIndexGeneratorPromise: Promise.resolve({ getIndex: vi.fn(), } as never), @@ -1226,211 +1080,46 @@ describe('ChangeDetectionService', () => { workingDir, }); - service.start(undefined, false); + service.start(false); // Should not throw and should not attempt to call dispose on an unconstructed provider. await expect(service.dispose()).resolves.toBeUndefined(); }); - it('replays add/unlink through the patcher when onStoryIndexInvalidated reveals new/removed stories', async () => { + it('rescans the working tree when the module graph revision advances', async () => { + // Graph-side reconciliation (replaying add/unlink, the refreshInFlight guard) is covered by + // module-graph-engine.test.ts; here we assert the status side of the seam: a graph revision + // bump (from a file change or story-index reconciliation) re-runs the git-diff scan. const reverseIndex = buildReverseIndex([]); - const { patchSpy, buildSpy } = installDependencyGraphMocks(reverseIndex); - buildSpy.mockResolvedValue({ reverseIndex, graph: new Map() }); + installDependencyGraphMocks(reverseIndex); - const initialIndex = createStoryIndex([ - { storyId: 'a--default', importPath: './src/A.stories.tsx', title: 'A' }, - ]); - const updatedIndex = createStoryIndex([ + const storyIndex = createStoryIndex([ { storyId: 'a--default', importPath: './src/A.stories.tsx', title: 'A' }, - { storyId: 'b--default', importPath: './src/B.stories.tsx', title: 'B' }, ]); - const getIndex = vi - .fn() - .mockResolvedValueOnce(initialIndex) - .mockResolvedValueOnce(initialIndex) - .mockResolvedValue(updatedIndex); - const { getStatusStoreByTypeId } = createStatusStore({ universalStatusStore: new MockUniversalStore(UNIVERSAL_STATUS_STORE_OPTIONS), environment: 'server', }); + const gitDiffProvider = createMockGitDiffProvider(); const { adapter } = createMockAdapter(); - const service = new ChangeDetectionService({ - storyIndexGeneratorPromise: Promise.resolve({ getIndex } as never), - statusStore: getStatusStoreByTypeId(CHANGE_DETECTION_STATUS_TYPE_ID), - gitDiffProvider: createMockGitDiffProvider(), - indexBaselineService: createMockStoryIndexBaselineService(), - workingDir, - }); - - service.start(adapter, true); - await vi.runAllTimersAsync(); - expect(patchSpy).not.toHaveBeenCalled(); - - service.onStoryIndexInvalidated(); - await vi.runAllTimersAsync(); - - expect(patchSpy).toHaveBeenCalledWith({ - kind: 'add', - path: '/repo/src/B.stories.tsx', - }); - - await service.dispose(); - }); - - it('file events emitted during the eager build are buffered and applied after build resolves', async () => { - // The service subscribes to file-change events BEFORE awaiting the build. Events arriving - // during the build window should be buffered and drained into patchQueue once the build - // completes — not silently dropped. - const reverseIndex = buildReverseIndex([]); - const buildDeferred = createDeferred(); - const { patchSpy, buildSpy } = installDependencyGraphMocks(reverseIndex); - buildSpy.mockImplementation(async () => { - await buildDeferred.promise; - return { reverseIndex, graph: new Map() }; - }); - - const storyIndex = createStoryIndex([ - { storyId: 'button--primary', importPath: './src/Button.stories.tsx', title: 'Button' }, - ]); - const { getStatusStoreByTypeId } = createStatusStore({ - universalStatusStore: new MockUniversalStore(UNIVERSAL_STATUS_STORE_OPTIONS), - environment: 'server', - }); - const { adapter, emitFileChange } = createMockAdapter(); - const service = new ChangeDetectionService({ - storyIndexGeneratorPromise: Promise.resolve({ - getIndex: vi.fn().mockResolvedValue(storyIndex), - } as never), - statusStore: getStatusStoreByTypeId(CHANGE_DETECTION_STATUS_TYPE_ID), - gitDiffProvider: createMockGitDiffProvider(), - indexBaselineService: createMockStoryIndexBaselineService(), - workingDir, - }); - - service.start(adapter, true); - // Allow startInternal to advance past: getResolveConfig, storyIndexGeneratorPromise, - // getIndex, and the DependencyGraphBuilder constructor — reaching the build await. - // Each 'await' in the async function consumes one microtask tick. - for (let i = 0; i < 10; i++) { - await Promise.resolve(); - } - - // Emit a file-change event while the build is still in flight (buffering handler active). - emitFileChange({ kind: 'change', path: '/repo/src/Button.tsx' }); - - // Build has not resolved yet — no patch should have run. - expect(patchSpy).not.toHaveBeenCalled(); - - // Now resolve the build — the buffered event should be drained into patchQueue. - buildDeferred.resolve(); - await vi.runAllTimersAsync(); - - // The buffered event must have been patched exactly once. - expect(patchSpy).toHaveBeenCalledTimes(1); - expect(patchSpy).toHaveBeenCalledWith({ kind: 'change', path: '/repo/src/Button.tsx' }); - - await service.dispose(); - }); - - it('multiple file events buffered during build are all applied in order after build resolves', async () => { - const reverseIndex = buildReverseIndex([]); - const buildDeferred = createDeferred(); - const { patchSpy, buildSpy } = installDependencyGraphMocks(reverseIndex); - buildSpy.mockImplementation(async () => { - await buildDeferred.promise; - return { reverseIndex, graph: new Map() }; - }); - - const storyIndex = createStoryIndex([]); - const { getStatusStoreByTypeId } = createStatusStore({ - universalStatusStore: new MockUniversalStore(UNIVERSAL_STATUS_STORE_OPTIONS), - environment: 'server', - }); - const { adapter, emitFileChange } = createMockAdapter(); - const service = new ChangeDetectionService({ + const { service, graph, moduleGraphMock } = createWiredChangeDetection({ storyIndexGeneratorPromise: Promise.resolve({ getIndex: vi.fn().mockResolvedValue(storyIndex), } as never), statusStore: getStatusStoreByTypeId(CHANGE_DETECTION_STATUS_TYPE_ID), - gitDiffProvider: createMockGitDiffProvider(), - indexBaselineService: createMockStoryIndexBaselineService(), - workingDir, - }); - - service.start(adapter, true); - // Advance past all awaits in startInternal before the build step. - for (let i = 0; i < 10; i++) { - await Promise.resolve(); - } - - emitFileChange({ kind: 'change', path: '/repo/src/A.tsx' }); - emitFileChange({ kind: 'change', path: '/repo/src/B.tsx' }); - emitFileChange({ kind: 'unlink', path: '/repo/src/C.tsx' }); - - expect(patchSpy).not.toHaveBeenCalled(); - - buildDeferred.resolve(); - await vi.runAllTimersAsync(); - - expect(patchSpy).toHaveBeenCalledTimes(3); - expect(patchSpy).toHaveBeenNthCalledWith(1, { kind: 'change', path: '/repo/src/A.tsx' }); - expect(patchSpy).toHaveBeenNthCalledWith(2, { kind: 'change', path: '/repo/src/B.tsx' }); - expect(patchSpy).toHaveBeenNthCalledWith(3, { kind: 'unlink', path: '/repo/src/C.tsx' }); - - await service.dispose(); - }); - - it('calling onStoryIndexInvalidated twice rapidly does not enqueue duplicate patches', async () => { - // Two rapid onStoryIndexInvalidated() calls before the first refresh completes both - // compute the same diff from the same storyFiles baseline. The refreshInFlight guard - // ensures only one refresh runs; the second call is a no-op, preventing duplicate patches. - const reverseIndex = buildReverseIndex([]); - const { patchSpy, buildSpy } = installDependencyGraphMocks(reverseIndex); - buildSpy.mockResolvedValue({ reverseIndex, graph: new Map() }); - - const initialIndex = createStoryIndex([ - { storyId: 'a--default', importPath: './src/A.stories.tsx', title: 'A' }, - ]); - // After invalidation: B is added. - const updatedIndex = createStoryIndex([ - { storyId: 'a--default', importPath: './src/A.stories.tsx', title: 'A' }, - { storyId: 'b--default', importPath: './src/B.stories.tsx', title: 'B' }, - ]); - - // getIndex is called: (1) during startInternal (initial), (2+) during refreshStoryFiles. - // Both refresh calls will receive updatedIndex so they compute the same diff. - const getIndex = vi - .fn() - .mockResolvedValueOnce(initialIndex) // startInternal initial read - .mockResolvedValueOnce(initialIndex) // scan's storyIndexGenerator.getIndex() - .mockResolvedValue(updatedIndex); // both refresh calls - - const { getStatusStoreByTypeId } = createStatusStore({ - universalStatusStore: new MockUniversalStore(UNIVERSAL_STATUS_STORE_OPTIONS), - environment: 'server', - }); - const { adapter } = createMockAdapter(); - const service = new ChangeDetectionService({ - storyIndexGeneratorPromise: Promise.resolve({ getIndex } as never), - statusStore: getStatusStoreByTypeId(CHANGE_DETECTION_STATUS_TYPE_ID), - gitDiffProvider: createMockGitDiffProvider(), + gitDiffProvider, indexBaselineService: createMockStoryIndexBaselineService(), workingDir, + debounceMs: 10, }); - service.start(adapter, true); + graph.start(adapter); + service.start(true); await vi.runAllTimersAsync(); + expect(gitDiffProvider.getChangedFilesMock).toHaveBeenCalledTimes(1); - // Two rapid calls before the first refresh completes. - service.onStoryIndexInvalidated(); - service.onStoryIndexInvalidated(); + moduleGraphMock.bumpGraphRevision(); await vi.runAllTimersAsync(); - - // B.stories.tsx should be patched exactly once — not twice. - const addPatches = patchSpy.mock.calls.filter( - ([event]) => event.kind === 'add' && event.path === '/repo/src/B.stories.tsx' - ); - expect(addPatches).toHaveLength(1); + expect(gitDiffProvider.getChangedFilesMock).toHaveBeenCalledTimes(2); await service.dispose(); }); diff --git a/code/core/src/core-server/change-detection/change-detection-service.ts b/code/core/src/core-server/change-detection/change-detection-service.ts new file mode 100644 index 000000000000..2b336c0e230b --- /dev/null +++ b/code/core/src/core-server/change-detection/change-detection-service.ts @@ -0,0 +1,520 @@ +import { join, normalize } from 'pathe'; + +import { dequal } from 'dequal'; +import { logger } from 'storybook/internal/node-logger'; +import type { + Status, + StatusStoreByTypeId, + StatusValue, + StoryIndex, +} from 'storybook/internal/types'; +import { CHANGE_DETECTION_STATUS_TYPE_ID } from 'storybook/internal/types'; + +import { getService } from '../../shared/open-service/server.ts'; +import { getStoryIdsByAbsolutePath } from '../../shared/open-service/services/module-graph/story-files.ts'; +import type { + ErrorLike, + ModuleGraphStatus, +} from '../../shared/open-service/services/module-graph/types.ts'; +import { storyIndexPathToAbsolutePath } from '../../shared/open-service/services/module-graph/types.ts'; +import type { StoryIndexGenerator } from '../utils/StoryIndexGenerator.ts'; +import { ChangeDetectionFailureError, ChangeDetectionUnavailableError } from './errors.ts'; +import { GitDiffProvider } from './GitDiffProvider.ts'; +import { extractBaselineEntryIds, IndexBaselineService } from './IndexBaselineService.ts'; +import { resetChangeDetectionReadiness, setChangeDetectionReadiness } from './readiness.ts'; + +const CHANGE_DETECTION_DEBOUNCE_MS = 200; + +function errorLikeToError(errorLike: ErrorLike): Error { + const error = new Error(errorLike.message, { + cause: errorLike.cause ? errorLikeToError(errorLike.cause) : undefined, + }); + error.name = errorLike.name ?? error.name; + if (errorLike.stack) { + error.stack = errorLike.stack; + } + return error; +} + +function isSameStatus(a: Status | undefined, b: Status): boolean { + if (!a) { + return false; + } + + return ( + a.storyId === b.storyId && + a.typeId === b.typeId && + a.value === b.value && + a.title === b.title && + a.description === b.description && + a.sidebarContextMenu === b.sidebarContextMenu && + dequal(a.data, b.data) + ); +} + +export function mergeStatusValues( + previousValue: StatusValue | undefined, + nextValue: StatusValue +): StatusValue { + if (previousValue === 'status-value:new' || nextValue === 'status-value:new') { + return 'status-value:new'; + } + + if (previousValue === 'status-value:modified' || nextValue === 'status-value:modified') { + return 'status-value:modified'; + } + + if (previousValue === 'status-value:affected' || nextValue === 'status-value:affected') { + return 'status-value:affected'; + } + + return nextValue; +} + +export function mergeChangeDetectionStatuses( + existing: Status | undefined, + incoming: Status +): Status { + return { + ...incoming, + value: mergeStatusValues(existing?.value, incoming.value), + title: incoming.title || existing?.title || '', + description: incoming.description || existing?.description || '', + sidebarContextMenu: incoming.sidebarContextMenu ?? existing?.sidebarContextMenu ?? false, + }; +} + +export function buildIndexBaselineStatuses( + storyIndex: StoryIndex, + baselineEntryIds: Set +): Map { + const statuses = new Map(); + if (baselineEntryIds.size === 0) { + return statuses; + } + + for (const entryId of extractBaselineEntryIds(storyIndex)) { + if (baselineEntryIds.has(entryId)) { + continue; + } + + statuses.set(entryId, { + storyId: entryId, + typeId: CHANGE_DETECTION_STATUS_TYPE_ID, + value: 'status-value:new', + title: '', + description: '', + sidebarContextMenu: false, + }); + } + + return statuses; +} + +/** + * Publishes change-detection story statuses to the status store. It resolves git-changed files, + * maps them to affected stories through the `core/module-graph` open service, and emits + * `modified`/`affected`/`new` statuses (plus index-baseline `new` entries). + */ +export class ChangeDetectionService { + private disposed = false; + private debounceTimer: ReturnType | undefined; + private scanInFlight = false; + private rerunAfterCurrentScan = false; + private readinessResolved = false; + private statusPipelineStarted = false; + private changeDetectionEnabled = false; + private previousStatuses = new Map(); + private gitDiffProvider: GitDiffProvider | undefined; + private indexBaselineService: IndexBaselineService | undefined; + private unsubscribeModuleGraphStatus: (() => void) | undefined; + private unsubscribeModuleGraphRevision: (() => void) | undefined; + private readonly workingDir: string; + private readonly debounceMs: number; + + constructor( + private readonly options: { + storyIndexGeneratorPromise: Promise; + statusStore: StatusStoreByTypeId; + gitDiffProvider?: GitDiffProvider; + indexBaselineService?: IndexBaselineService; + workingDir?: string; + debounceMs?: number; + } + ) { + this.gitDiffProvider = options.gitDiffProvider; + this.indexBaselineService = options.indexBaselineService; + this.workingDir = options.workingDir ?? process.cwd(); + this.debounceMs = options.debounceMs ?? CHANGE_DETECTION_DEBOUNCE_MS; + resetChangeDetectionReadiness(); + } + + private getModuleGraph() { + return getService('core/module-graph'); + } + + /** True while the service is live and change-detection status publishing is enabled. */ + private isActive(): boolean { + return !this.disposed && this.changeDetectionEnabled; + } + + private onGraphReady(): void { + if (!this.isActive()) { + return; + } + + this.startStatusPipeline(); + } + + private onGraphChange(): void { + if (!this.isActive()) { + return; + } + + this.scheduleScan(this.debounceMs); + } + + private onGraphError(error: Error): void { + if (!this.isActive()) { + return; + } + + this.resolveReadiness({ status: 'error', error }); + void this.dispose().catch(() => undefined); + } + + private onGraphUnavailable(reason: string, error?: Error): void { + if (!this.isActive()) { + return; + } + + logger.warn(`Change detection unavailable: ${reason}`); + this.resolveReadiness({ status: 'unavailable', reason, error }); + void this.dispose(); + } + + private onModuleGraphStatus(status: ModuleGraphStatus): void { + if (!this.isActive()) { + return; + } + + if (status.value === 'ready') { + this.onGraphReady(); + return; + } + + if (status.value === 'error') { + this.onGraphError(errorLikeToError(status.error)); + return; + } + + if (status.value === 'unavailable') { + this.onGraphUnavailable( + status.reason, + status.error ? errorLikeToError(status.error) : undefined + ); + } + } + + start(enabled: boolean | undefined): void { + if (enabled === false) { + logger.debug('Change detection disabled.'); + this.resolveReadiness({ + status: 'unavailable', + reason: 'disabled', + }); + return; + } + + logger.debug('Change detection enabled.'); + this.changeDetectionEnabled = true; + + const moduleGraph = this.getModuleGraph(); + this.unsubscribeModuleGraphStatus = moduleGraph.queries.status.subscribe( + undefined, + ({ data }) => { + if (data !== undefined) { + this.onModuleGraphStatus(data as ModuleGraphStatus); + } + } + ); + this.unsubscribeModuleGraphRevision = moduleGraph.queries.graphRevision.subscribe( + undefined, + ({ data }) => { + if ((data ?? 0) > 0) { + this.onGraphChange(); + } + } + ); + + void moduleGraph.queries.status + .loaded(undefined) + .then((status) => { + this.onModuleGraphStatus(status as ModuleGraphStatus); + }) + .catch((error) => { + this.onGraphError(error instanceof Error ? error : new Error(String(error))); + }); + } + + /** + * Wires the git-diff-driven status pipeline. Runs once the dependency graph is ready (so the + * initial scan and every git-state-change scan read a populated reverse index). + */ + private startStatusPipeline(): void { + if (this.disposed || this.statusPipelineStarted) { + return; + } + this.statusPipelineStarted = true; + + void this.getIndexBaselineService().start(); + + this.getGitDiffProvider().onGitStateChange(() => { + if (this.disposed) { + return; + } + + this.scheduleScan(this.debounceMs); + void this.getIndexBaselineService() + .handleGitStateChange() + .catch(() => undefined); + }); + + // Initial scan surfaces git-pending diffs immediately. + this.scheduleScan(0); + } + + async dispose(): Promise { + if (this.disposed) { + return; + } + this.disposed = true; + this.rerunAfterCurrentScan = false; + + if (this.debounceTimer) { + clearTimeout(this.debounceTimer); + this.debounceTimer = undefined; + } + + this.gitDiffProvider?.dispose(); + this.unsubscribeModuleGraphStatus?.(); + this.unsubscribeModuleGraphStatus = undefined; + this.unsubscribeModuleGraphRevision?.(); + this.unsubscribeModuleGraphRevision = undefined; + } + + private scheduleScan(delayMs: number): void { + if (this.debounceTimer) { + clearTimeout(this.debounceTimer); + } + + this.debounceTimer = setTimeout(() => { + this.debounceTimer = undefined; + void this.scan(); + }, delayMs); + } + + private async scan(): Promise { + if (this.disposed) { + return; + } + + // Drain the graph's patch chain before reading it. Without this, a scan triggered mid-patch + // (between a story's removeStory and the re-walk's recordEdges) reads a transiently empty + // reverse index and publishes incorrect statuses. + const moduleGraph = this.getModuleGraph(); + const status = await moduleGraph.queries.status.loaded(undefined); + + if (this.disposed || status.value !== 'ready') { + return; + } + + if (this.scanInFlight) { + this.rerunAfterCurrentScan = true; + return; + } + + this.scanInFlight = true; + + try { + const nextStatuses = await this.buildStatuses(); + if (this.disposed) { + return; + } + + this.applyStatusStorePatch(nextStatuses); + this.resolveReadiness({ status: 'ready' }); + } catch (error) { + if (this.disposed) { + return; + } + + if (error instanceof ChangeDetectionUnavailableError) { + logger.warn(`Change detection unavailable: ${error.message}`); + this.resolveReadiness({ + status: 'unavailable', + reason: error.message, + error, + }); + await this.dispose(); + } else if (error instanceof ChangeDetectionFailureError) { + logger.error(`Change detection failed: ${error.message}`); + this.resolveReadiness({ + status: 'error', + error, + }); + } else { + const failure = new ChangeDetectionFailureError( + error instanceof Error ? error.message : String(error), + { cause: error instanceof Error ? error : undefined } + ); + logger.error(`Change detection failed: ${failure.message}`); + this.resolveReadiness({ + status: 'error', + error: failure, + }); + } + } finally { + this.scanInFlight = false; + + if (!this.disposed && this.rerunAfterCurrentScan) { + this.rerunAfterCurrentScan = false; + void this.scan(); + } + } + } + + private async buildStatuses(): Promise> { + const gitDiffProvider = this.getGitDiffProvider(); + const [changes, repoRoot, storyIndexGenerator, baselineEntryIds] = await Promise.all([ + gitDiffProvider.getChangedFiles(), + gitDiffProvider.getRepoRoot(), + this.options.storyIndexGeneratorPromise, + this.getIndexBaselineService().getBaselineEntryIds(), + ]); + + const changedFiles = new Set( + Array.from(changes.changed).map((path) => normalize(join(repoRoot, path))) + ); + const newFiles = new Set( + Array.from(changes.new).map((path) => normalize(join(repoRoot, path))) + ); + const scannedFiles = new Set([...changedFiles, ...newFiles]); + + const storyIndex = await storyIndexGenerator.getIndex(); + const baselineStatuses = buildIndexBaselineStatuses(storyIndex, baselineEntryIds); + const storyIdsByFile = getStoryIdsByAbsolutePath(storyIndex, this.workingDir); + const statuses = new Map(); + const scannedFilesArray = [...scannedFiles]; + const moduleGraph = this.getModuleGraph(); + const lookupResults = await moduleGraph.queries.storiesForFiles.loaded({ + files: scannedFilesArray, + }); + + for (let i = 0; i < scannedFilesArray.length; i++) { + const changedFile = scannedFilesArray[i]; + const allEntries = new Map(); + for (const { storyFile, depth } of lookupResults[i]) { + allEntries.set(storyIndexPathToAbsolutePath(storyFile, this.workingDir), depth); + } + // Include the changed file as a story-at-distance-0 if it IS a story. + if (storyIdsByFile.has(changedFile)) { + allEntries.set(changedFile, 0); + } + if (allEntries.size === 0) { + continue; + } + let lowestDistance = Number.POSITIVE_INFINITY; + for (const distance of allEntries.values()) { + if (distance < lowestDistance) { + lowestDistance = distance; + } + } + + for (const [storyFile, distance] of allEntries.entries()) { + const storyIds = storyIdsByFile.get(storyFile); + if (!storyIds) { + continue; + } + + const value: Status['value'] = newFiles.has(storyFile) + ? 'status-value:new' + : distance === lowestDistance + ? 'status-value:modified' + : 'status-value:affected'; + + storyIds.forEach((storyId) => { + const existingStatus = statuses.get(storyId); + + const nextStatus: Status = { + storyId, + typeId: CHANGE_DETECTION_STATUS_TYPE_ID, + value: mergeStatusValues(existingStatus?.value, value), + title: '', + description: '', + sidebarContextMenu: false, + }; + + statuses.set(storyId, mergeChangeDetectionStatuses(existingStatus, nextStatus)); + }); + } + } + + baselineStatuses.forEach((status, storyId) => { + statuses.set(storyId, mergeChangeDetectionStatuses(statuses.get(storyId), status)); + }); + + return statuses; + } + + private getGitDiffProvider(): GitDiffProvider { + this.gitDiffProvider ??= new GitDiffProvider(this.workingDir); + return this.gitDiffProvider; + } + + private getIndexBaselineService(): IndexBaselineService { + this.indexBaselineService ??= new IndexBaselineService({ + storyIndexGeneratorPromise: this.options.storyIndexGeneratorPromise, + gitDiffProvider: this.getGitDiffProvider(), + onBaselineUpdated: () => this.scheduleScan(this.debounceMs), + }); + return this.indexBaselineService; + } + + private applyStatusStorePatch(nextStatuses: Map): void { + const removedStoryIds = Array.from(this.previousStatuses.keys()).filter( + (storyId) => !nextStatuses.has(storyId) + ); + const changedStatuses = Array.from(nextStatuses.values()).filter( + (status) => !isSameStatus(this.previousStatuses.get(status.storyId), status) + ); + + if (removedStoryIds.length === 0 && changedStatuses.length === 0) { + return; + } + + if (removedStoryIds.length > 0) { + this.options.statusStore.unset(removedStoryIds); + } + + if (changedStatuses.length > 0) { + this.options.statusStore.set(changedStatuses); + } + + this.previousStatuses = new Map(nextStatuses); + } + + private resolveReadiness( + readiness: + | { status: 'ready' } + | { status: 'unavailable'; reason: string; error?: Error } + | { status: 'error'; error: Error } + ): void { + if (this.readinessResolved) { + return; + } + + this.readinessResolved = true; + setChangeDetectionReadiness(readiness); + } +} diff --git a/code/core/src/core-server/change-detection/change-detection.test-helpers.ts b/code/core/src/core-server/change-detection/change-detection.test-helpers.ts new file mode 100644 index 000000000000..a2e3a6a62666 --- /dev/null +++ b/code/core/src/core-server/change-detection/change-detection.test-helpers.ts @@ -0,0 +1,168 @@ +import { normalize } from 'pathe'; +import { vi } from 'vitest'; + +import { getService } from '../../shared/open-service/server.ts'; +import type { ModuleGraphService } from '../../shared/open-service/services/module-graph/definition.ts'; +import { ModuleGraphEngine } from '../../shared/open-service/services/module-graph/engine/module-graph-engine.ts'; +import type { ModuleGraphStatus } from '../../shared/open-service/services/module-graph/types.ts'; +import type { QueryState } from '../../shared/open-service/types.ts'; + +/** Wraps a value as a settled `success`/`idle` {@link QueryState}, mirroring a real subscription emission. */ +function toSuccessState(data: TData): QueryState { + return { + data, + error: undefined, + status: 'success', + loadStatus: 'idle', + isPending: false, + isSuccess: true, + isError: false, + isLoading: false, + isInitialLoading: false, + isRefreshing: false, + }; +} +import { + errorToErrorLike, + toStoryIndexPath, +} from '../../shared/open-service/services/module-graph/types.ts'; +import { ChangeDetectionService } from './change-detection-service.ts'; + +export { + createDeferred, + createMockAdapter, + createStoryIndex, + buildReverseIndex, + installDependencyGraphMocks, + type MockAdapterHandle, +} from '../../shared/open-service/services/module-graph/module-graph.test-helpers.ts'; + +type ChangeDetectionServiceOptions = ConstructorParameters[0]; + +/** + * Installs a `getService('core/module-graph')` mock backed by a real {@link ModuleGraphEngine} + * instance (for tests that call `graph.start(adapter)`). + */ +export function installModuleGraphQueryMock(engine: ModuleGraphEngine) { + let status: ModuleGraphStatus = engine.hasGraph() ? { value: 'ready' } : { value: 'booting' }; + let graphRevision = 0; + let latestChangedStoryFiles: string[] = []; + const statusSubscribers = new Set<(next: QueryState) => void>(); + const revisionSubscribers = new Set<(next: QueryState) => void>(); + const emitStatus = () => { + statusSubscribers.forEach((subscriber) => subscriber(toSuccessState(status))); + }; + const emitRevision = () => { + revisionSubscribers.forEach((subscriber) => subscriber(toSuccessState(graphRevision))); + }; + const storiesForFiles = ({ files }: { files: string[] }) => + files.map((file) => { + const hits = engine.lookup(normalize(file)); + return [...hits.entries()].map(([storyFile, depth]) => ({ + storyFile: toStoryIndexPath(storyFile, '/repo'), + depth, + })); + }); + + vi.mocked(getService).mockReturnValue({ + queries: { + status: { + get: () => status, + loaded: async () => { + await engine.whenSettled(); + return status; + }, + subscribe: vi.fn( + (_input: undefined, callback: (next: QueryState) => void) => { + statusSubscribers.add(callback); + callback(toSuccessState(status)); + return () => statusSubscribers.delete(callback); + } + ), + }, + storiesForFiles: { + get: storiesForFiles, + loaded: async (input: { files: string[] }) => { + await engine.whenSettled(); + return storiesForFiles(input); + }, + }, + graphRevision: { + get: () => 0, + subscribe: vi.fn((_input: undefined, callback: (next: QueryState) => void) => { + revisionSubscribers.add(callback); + callback(toSuccessState(graphRevision)); + return () => revisionSubscribers.delete(callback); + }), + }, + latestStoryChanges: { + get: () => ({ revision: graphRevision, storyFiles: latestChangedStoryFiles }), + subscribe: vi.fn(() => () => undefined), + }, + }, + } as unknown as ModuleGraphService); + + return { + applySnapshot: () => { + // The snapshot marks the graph ready but is the revision baseline, not a change. + status = { value: 'ready' }; + emitStatus(); + }, + applyUpdate: (bumpedStoryFiles: string[] = []) => { + // Out-of-graph changes bump no stories, so they must not advance the revision. + if (bumpedStoryFiles.length === 0) { + return; + } + graphRevision += 1; + latestChangedStoryFiles = bumpedStoryFiles; + emitRevision(); + }, + bumpGraphRevision: () => { + graphRevision += 1; + emitRevision(); + }, + applyError: (error: Error) => { + status = { value: 'error', error: errorToErrorLike(error) }; + emitStatus(); + }, + applyUnavailable: (reason: string, error?: Error) => { + status = { + value: 'unavailable', + reason, + ...(error ? { error: errorToErrorLike(error) } : {}), + }; + emitStatus(); + }, + }; +} + +/** + * Constructs {@link ChangeDetectionService} with lifecycle hooks wired to a + * {@link ModuleGraphEngine}, matching dev-server behaviour for integration-style tests. + */ +export function createWiredChangeDetection( + options: Omit, + graphOptions?: { withoutStartupFailure?: boolean } +): { + service: ChangeDetectionService; + graph: ModuleGraphEngine; + moduleGraphMock: ReturnType; +} { + const moduleGraphMockRef: { current?: ReturnType } = {}; + const graph = new ModuleGraphEngine({ + getIndex: async () => { + const storyIndexGenerator = await options.storyIndexGeneratorPromise; + return storyIndexGenerator.getIndex(); + }, + workingDir: options.workingDir, + onSnapshot: () => moduleGraphMockRef.current?.applySnapshot(), + onUpdate: ({ bumpedStoryFiles }) => moduleGraphMockRef.current?.applyUpdate(bumpedStoryFiles), + onError: (error) => moduleGraphMockRef.current?.applyError(error), + onUnavailable: (reason, error) => moduleGraphMockRef.current?.applyUnavailable(reason, error), + }); + const service = new ChangeDetectionService(options); + const moduleGraphMock = installModuleGraphQueryMock(graph); + moduleGraphMockRef.current = moduleGraphMock; + void graphOptions; + return { service, graph, moduleGraphMock }; +} diff --git a/code/core/src/core-server/change-detection/dependency-graph/index.ts b/code/core/src/core-server/change-detection/dependency-graph/index.ts deleted file mode 100644 index e0ddc7c77d53..000000000000 --- a/code/core/src/core-server/change-detection/dependency-graph/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export type { ImportEdge, ReverseIndex, DependencyGraph } from './types.ts'; -export { ChangeDetectionResolverFactory } from './ResolverFactory.ts'; -export { ReverseIndexImpl } from './ReverseIndex.ts'; -export { DependencyGraphBuilder } from './DependencyGraphBuilder.ts'; -export { IncrementalPatcher } from './IncrementalPatcher.ts'; -export { ParseResolveCache } from './ParseResolveCache.ts'; diff --git a/code/core/src/core-server/change-detection/index.ts b/code/core/src/core-server/change-detection/index.ts deleted file mode 100644 index 1bd5583d15aa..000000000000 --- a/code/core/src/core-server/change-detection/index.ts +++ /dev/null @@ -1,20 +0,0 @@ -export { ChangeDetectionFailureError, ChangeDetectionUnavailableError } from './errors.ts'; -export { GitDiffProvider } from './GitDiffProvider.ts'; -export { - getChangeDetectionReadiness, - resetChangeDetectionReadiness as internal_resetChangeDetectionReadiness, - type ChangeDetectionReadiness, -} from './readiness.ts'; -export { ChangeDetectionService } from './ChangeDetectionService.ts'; -export type { - ChangeDetectionAdapter, - FileChangeEvent, - ModuleResolveConfig, -} from './adapters/index.ts'; -export { ParserRegistry, builtinImportParsers } from './parser-registry/index.ts'; -export type { - ImportEdge, - ImportParser, - ImportParserContext, - ParseFileArgs, -} from './parser-registry/index.ts'; diff --git a/code/core/src/core-server/change-detection/parser-registry/index.ts b/code/core/src/core-server/change-detection/parser-registry/index.ts deleted file mode 100644 index fe906b1f67a4..000000000000 --- a/code/core/src/core-server/change-detection/parser-registry/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export type { ImportEdge, ImportParser, ImportParserContext, ParseFileArgs } from './types.ts'; -export { ParserRegistry } from './ParserRegistry.ts'; -export { builtinImportParsers, mdxImportParser, oxcImportParser } from './builtins.ts'; diff --git a/code/core/src/core-server/dev-server.ts b/code/core/src/core-server/dev-server.ts index e085a4446255..e4e1bb6b4a7d 100644 --- a/code/core/src/core-server/dev-server.ts +++ b/code/core/src/core-server/dev-server.ts @@ -7,9 +7,10 @@ import type { Options } from 'storybook/internal/types'; import compression from '@polka/compression'; import polka from 'polka'; +import type { ChangeDetectionAdapter } from '../shared/open-service/services/module-graph/engine/adapters/types.ts'; +import { resolveChangeDetectionAdapter } from '../shared/open-service/services/module-graph/server.ts'; import { isTelemetryModuleEnabled, telemetry } from '../telemetry/index.ts'; -import type { ChangeDetectionAdapter } from './change-detection/index.ts'; -import { ChangeDetectionService } from './change-detection/index.ts'; +import { ChangeDetectionService } from './change-detection/change-detection-service.ts'; import { getStatusStoreByTypeId } from './stores/status.ts'; import type { StoryIndexGenerator } from './utils/StoryIndexGenerator.ts'; import { doTelemetry } from './utils/doTelemetry.ts'; @@ -52,9 +53,12 @@ export async function storybookDevServer( storyIndexGeneratorPromise, statusStore: getStatusStoreByTypeId(CHANGE_DETECTION_STATUS_TYPE_ID), workingDir, - presets: options.presets, }); + const disposeChangeDetectionRuntime = async () => { + await changeDetectionService.dispose().catch(() => undefined); + }; + app.use(compression({ level: 1 })); if (typeof options.extendServer === 'function') { @@ -79,7 +83,6 @@ export async function storybookDevServer( channel: options.channel, workingDir, configDir, - onStoryIndexInvalidated: () => changeDetectionService.onStoryIndexInvalidated(), }); (await getMiddleware(options.configDir))(app); @@ -124,10 +127,6 @@ export async function storybookDevServer( await Promise.resolve(); if (!options.ignorePreview) { - if (!features.changeDetection) { - changeDetectionService.start(undefined, false); - } - logger.debug('Starting preview..'); previewResult = await previewBuilder .start({ @@ -141,7 +140,7 @@ export async function storybookDevServer( logger.error('Failed to build the preview'); process.exitCode = 1; - await changeDetectionService.dispose().catch(() => undefined); + await disposeChangeDetectionRuntime(); await managerBuilder?.bail().catch(() => undefined); // For some reason, even when Webpack fails e.g. wrong main.js config, // the preview may continue to print to stdout, which can affect output @@ -153,15 +152,21 @@ export async function storybookDevServer( throw e; }); - if (features.changeDetection) { - let adapter: ChangeDetectionAdapter | undefined; - try { - adapter = previewBuilder.changeDetectionAdapter?.(); - } catch (err) { - logger.warn('Change detection: adapter initialisation failed'); - logger.debug(err instanceof Error ? (err.stack ?? err.message) : String(err)); - } - changeDetectionService.start(adapter, true); + let adapter: ChangeDetectionAdapter | undefined; + try { + adapter = previewBuilder.changeDetectionAdapter?.(); + } catch (err) { + logger.warn('Change detection: adapter initialisation failed'); + logger.debug(err instanceof Error ? (err.stack ?? err.message) : String(err)); + } + + resolveChangeDetectionAdapter(adapter); + + const isChangeDetectionStatusEnabled = features.changeDetection !== false; + if (isChangeDetectionStatusEnabled) { + changeDetectionService.start(true); + } else { + changeDetectionService.start(false); } } @@ -180,6 +185,7 @@ export async function storybookDevServer( }); } } catch (e) { + await disposeChangeDetectionRuntime(); await managerBuilder?.bail().catch(() => undefined); await previewBuilder?.bail().catch(() => undefined); throw e; @@ -211,6 +217,7 @@ export async function storybookDevServer( } catch {} await telemetry('canceled', payload, { immediate: true }); } finally { + await disposeChangeDetectionRuntime(); // Always terminate on signal, even when telemetry is disabled. process.exit(0); } diff --git a/code/core/src/core-server/index.ts b/code/core/src/core-server/index.ts index c1a75eff1047..96d4ea12447f 100644 --- a/code/core/src/core-server/index.ts +++ b/code/core/src/core-server/index.ts @@ -9,6 +9,7 @@ export * from './withTelemetry.ts'; export { default as build } from './standalone.ts'; export { mapStaticDir } from './utils/server-statics.ts'; export { StoryIndexGenerator } from './utils/StoryIndexGenerator.ts'; +export { getStoriesPathsFromConfig } from './utils/get-stories-paths-from-config.ts'; export { generateStoryFile } from './utils/generate-story.ts'; export type { GenerateStoryResult, GenerateStoryOptions } from './utils/generate-story.ts'; export type { ComponentArgTypesData } from './utils/get-dummy-args-from-argtypes.ts'; @@ -17,6 +18,45 @@ export { loadStorybook as experimental_loadStorybook } from './load.ts'; export { Tag } from '../shared/constants/tags.ts'; export { analyzeMdx } from './utils/analyze-mdx.ts'; +export { + MDX_SERVICE_ID, + mdxQueryStaticPath, + mdxStaticStorePath, + mdxManifestRef, +} from './utils/manifests/mdx-manifest.ts'; +export type { + DocsManifestEntry, + DocsManifestRefEntry, + JsonRef, + MdxDocPayload, + MdxError, + MdxPayload, + MdxServiceContract, +} from './utils/manifests/mdx-manifest.ts'; +export { defineService as experimental_defineService } from '../shared/open-service/index.ts'; +export type { + Command, + CommandCtx, + CommandDefinition, + OperationDescriptor, + Query, + QueryCtx, + QueryDefinition, + RuntimeService, + SchemaDescriptor, + ServiceDefinition, + ServiceDescriptor, + ServiceInstance, + ServiceRegistrationOptions, + ServiceSummary, + ServerServiceRegistration, +} from '../shared/open-service/index.ts'; +export { + describeService, + getService, + listServices, + registerService as experimental_registerService, +} from '../shared/open-service/server.ts'; export { UniversalStore as experimental_UniversalStore } from '../shared/universal-store/index.ts'; export { MockUniversalStore as experimental_MockUniversalStore } from '../shared/universal-store/mock.ts'; @@ -26,23 +66,29 @@ export { universalStatusStore as internal_universalStatusStore, } from './stores/status.ts'; export { - getChangeDetectionReadiness as experimental_getChangeDetectionReadiness, - type ChangeDetectionReadiness as Experimental_ChangeDetectionReadiness, ChangeDetectionFailureError, ChangeDetectionUnavailableError, -} from './change-detection/index.ts'; +} from './change-detection/errors.ts'; +export { + getChangeDetectionReadiness as experimental_getChangeDetectionReadiness, + type ChangeDetectionReadiness as Experimental_ChangeDetectionReadiness, +} from './change-detection/readiness.ts'; export type { ChangeDetectionAdapter, FileChangeEvent, ModuleResolveConfig, -} from './change-detection/index.ts'; +} from '../shared/open-service/services/module-graph/engine/adapters/types.ts'; +export type { + moduleGraphServiceDef, + ModuleGraphService, +} from '../shared/open-service/services/module-graph/definition.ts'; export type { ImportEdge, ImportParser, ImportParserContext, ParseFileArgs, -} from './change-detection/index.ts'; -export { ChangeDetectionService } from './change-detection/ChangeDetectionService.ts'; +} from '../shared/open-service/services/module-graph/engine/parser-registry/types.ts'; +export { ChangeDetectionService } from './change-detection/change-detection-service.ts'; export { getTestProviderStoreById as experimental_getTestProviderStore, fullTestProviderStore as internal_fullTestProviderStore, diff --git a/code/core/src/core-server/load.ts b/code/core/src/core-server/load.ts index b0738dbf8fe0..af67320ce15b 100644 --- a/code/core/src/core-server/load.ts +++ b/code/core/src/core-server/load.ts @@ -1,4 +1,4 @@ -import { Channel } from 'storybook/internal/channels'; +import { Channel, setChannel } from 'storybook/internal/channels'; import { getProjectRoot, loadAllPresets, @@ -8,6 +8,7 @@ import { } from 'storybook/internal/common'; import { oneWayHash } from 'storybook/internal/telemetry'; import type { BuilderOptions, CLIOptions, LoadOptions, Options } from 'storybook/internal/types'; +import { applyServicesPresetOnce } from './utils/apply-services-preset-once.ts'; import { global } from '@storybook/global'; @@ -31,6 +32,10 @@ export async function loadStorybook( options.configDir = configDir; options.cacheKey = cacheKey; + // no-op channel, as it's only relevant in dev mode + const channel = new Channel({}); + setChannel(channel); + const config = await loadMainConfig(options); const { framework } = config; const corePresets = []; @@ -48,10 +53,6 @@ export async function loadStorybook( // Load first pass: We need to determine the builder // We need to do this because builders might introduce 'overridePresets' which we need to take into account // We hope to remove this in SB8 - - // no-op channel, as it's only relevant in dev mode - const channel = new Channel({}); - let presets = await loadAllPresets({ corePresets, overridePresets: [ @@ -95,6 +96,8 @@ export async function loadStorybook( const features = await presets.apply('features'); global.FEATURES = features; + await applyServicesPresetOnce(presets); + return { ...options, presets, diff --git a/code/core/src/core-server/presets/common-manager.ts b/code/core/src/core-server/presets/common-manager.ts index 024a34422a7c..524abd84f641 100644 --- a/code/core/src/core-server/presets/common-manager.ts +++ b/code/core/src/core-server/presets/common-manager.ts @@ -1,6 +1,7 @@ /* these imports are in the exact order in which the panels need to be registered */ // THE ORDER OF THESE IMPORTS MATTERS! IT DEFINES THE ORDER OF PANELS AND TOOLS! +import docgenManager from '../../shared/open-service/services/docgen/manager.tsx'; import controlsManager from '../../controls/manager.tsx'; import actionsManager from '../../actions/manager.tsx'; import componentTestingManager from '../../component-testing/manager.tsx'; @@ -10,6 +11,7 @@ import outlineManager from '../../outline/manager.tsx'; import viewportManager from '../../viewport/manager.tsx'; export default [ + docgenManager, measureManager, actionsManager, backgroundsManager, diff --git a/code/core/src/core-server/presets/common-preset.ts b/code/core/src/core-server/presets/common-preset.ts index cc9fb0c65653..1baf01d057d3 100644 --- a/code/core/src/core-server/presets/common-preset.ts +++ b/code/core/src/core-server/presets/common-preset.ts @@ -2,14 +2,17 @@ import { existsSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; import type { Channel } from 'storybook/internal/channels'; -import { normalizeStories, optionalEnvToBoolean } from 'storybook/internal/common'; import { JsPackageManagerFactory, type RemoveAddonOptions, + STORY_FILE_TEST_REGEXP, + getBabelPresetEnvMajor, getDirectoryFromWorkingDir, getPreviewBodyTemplate, getPreviewHeadTemplate, loadEnvs, + normalizeStories, + optionalEnvToBoolean, removeAddon as removeAddonBase, } from 'storybook/internal/common'; import { StoryIndexGenerator } from 'storybook/internal/core-server'; @@ -18,22 +21,32 @@ import { logger } from 'storybook/internal/node-logger'; import { telemetry } from 'storybook/internal/telemetry'; import type { CoreConfig, + DocgenProviderDescriptor, Indexer, Options, PresetProperty, PresetPropertyFn, + StoryDocsProvider, StorybookConfigRaw, } from 'storybook/internal/types'; -import { isAbsolute, join } from 'pathe'; +import { registerDocgenService } from '../../shared/open-service/services/docgen/server.ts'; +import { createDocgenWorkerClient } from '../../shared/open-service/services/docgen/worker/docgen-worker-client.ts'; +import { registerModuleGraphService } from '../../shared/open-service/services/module-graph/server.ts'; +import { registerStoryDocsService } from '../../shared/open-service/services/story-docs/server.ts'; + import * as pathe from 'pathe'; +import { isAbsolute, join } from 'pathe'; import { dedent } from 'ts-dedent'; import { resolvePackageDir } from '../../shared/utils/module.ts'; +import { initAIAnalyticsChannel } from '../server-channel/ai-setup-channel.ts'; import { initCreateNewStoryChannel } from '../server-channel/create-new-story-channel.ts'; import { initFileSearchChannel } from '../server-channel/file-search-channel.ts'; import { initGhostStoriesChannel } from '../server-channel/ghost-stories-channel.ts'; import { initOpenInEditorChannel } from '../server-channel/open-in-editor-channel.ts'; +import { isReviewFeatureEnabled } from '../../shared/review/features.ts'; +import { initReviewChannel } from '../server-channel/review-channel.ts'; import { initTelemetryChannel } from '../server-channel/telemetry-channel.ts'; import { initializeChecklist } from '../utils/checklist.ts'; import { defaultFavicon, defaultStaticDirs } from '../utils/constants.ts'; @@ -41,7 +54,6 @@ import { initializeSaveStory } from '../utils/save-story/save-story.ts'; import { parseStaticDir } from '../utils/server-statics.ts'; import { type OptionsWithRequiredCache, initializeWhatsNew } from '../utils/whats-new.ts'; import { getWsToken } from './wsToken.ts'; -import { initAIAnalyticsChannel } from '../server-channel/ai-setup-channel.ts'; const interpolate = (string: string, data: Record = {}) => Object.entries(data).reduce((acc, [k, v]) => acc.replace(new RegExp(`%${k}%`, 'g'), v), string); @@ -110,6 +122,24 @@ export const babel = async (_: unknown, options: Options) => { string, any >; + + const presetConfig: Record = { + targets: { + // This is the same browser supports that we use to bundle our manager and preview code. + chrome: 100, + safari: 15, + firefox: 91, + }, + }; + + const shouldRemoveBugfixes = + options?.features && + 'babelRemoveBugfixes' in options.features && + options.features.babelRemoveBugfixes; + if (!shouldRemoveBugfixes) { + presetConfig.bugfixes = true; + } + return { ...babelDefault, // This override makes sure that we will never transpile babel further down then the browsers that storybook supports. @@ -119,20 +149,7 @@ export const babel = async (_: unknown, options: Options) => { ...(babelDefault?.overrides ?? []), { include: /\.(story|stories)\.[cm]?[jt]sx?$/, - presets: [ - [ - '@babel/preset-env', - { - bugfixes: true, - targets: { - // This is the same browser supports that we use to bundle our manager and preview code. - chrome: 100, - safari: 15, - firefox: 91, - }, - }, - ], - ], + presets: [['@babel/preset-env', presetConfig]], }, ], }; @@ -202,26 +219,31 @@ export const core = async (existing: CoreConfig, options: Options): Promise = async (existing) => ({ ...existing, actions: true, argTypeTargetsV7: true, + babelRemoveBugfixes: babelPresetEnvMajor ? babelPresetEnvMajor >= 8 : false, backgrounds: true, changeDetection: true, componentsManifest: false, controls: true, disallowImplicitActionsInRenderV8: true, + experimentalReview: false, highlight: true, interactions: true, legacyDecoratorFileOrder: false, measure: true, outline: true, + menuOnboardingChecklist: true, sidebarOnboardingChecklist: true, viewport: true, }); export const csfIndexer: Indexer = { - test: /(stories|story)\.(m?js|ts)x?$/, + test: STORY_FILE_TEST_REGEXP, createIndex: async (fileName, options) => { const code = (await readFile(fileName, 'utf-8')).toString(); if (code.trim().length === 0) { @@ -280,6 +302,9 @@ export const experimental_serverChannel = async ( initCreateNewStoryChannel(channel, options); initGhostStoriesChannel(channel, options); initOpenInEditorChannel(channel); + if (isReviewFeatureEnabled(await options.presets.apply('features'))) { + initReviewChannel(channel); + } initTelemetryChannel(channel); return channel; @@ -310,6 +335,67 @@ export const managerEntries = async (existing: any) => { ]; }; +globalThis.STORYBOOK_SERVICES_LOADED = globalThis.STORYBOOK_SERVICES_LOADED ?? false; + +export const services = async (_value: void, options: Options): Promise => { + if (globalThis.STORYBOOK_SERVICES_LOADED) { + throw new Error( + 'The "services" preset property was applied twice, but should only be applied once. Multiple code paths applying it will cause service registration to fail.' + ); + } + globalThis.STORYBOOK_SERVICES_LOADED = true; + + // `presets.apply` flattens the generator preset's returned promise, so this is the resolved + // generator, not a promise. + const storyIndexGenerator = + await options.presets.apply('storyIndexGenerator'); + + registerModuleGraphService({ + channel: options.channel, + getIndex: () => storyIndexGenerator.getIndex(), + workingDir: process.cwd(), + presets: options.presets, + }); + + const features = await options.presets.apply('features'); + + // Skip when previewing is off — the docgen service's staticInputs depends on the story index, + // so registering it would force full story-index generation during manager-only builds (and + // produce docgen files that wouldn't be served anywhere). Mirrors the !options.ignorePreview + // gate around index.json and writeManifests in build-static.ts. + if (features?.experimentalDocgenServer && !options.ignorePreview) { + const [docgenDescriptors, storyDocsProvider] = await Promise.all([ + options.presets.apply('experimental_docgenProvider', []), + options.presets.apply( + 'experimental_storyDocsProvider', + async () => undefined + ), + ]); + + // Docgen extraction runs in a long-lived worker so its CPU-bound TypeScript work never starves + // the dev-server event loop. The worker composes the descriptor chain; here we forward one + // component to it. When the compiled worker script is unavailable (e.g. running from source + // without a build) the client is undefined and we skip docgen registration rather than + // extracting on the main thread. + const docgenWorker = + docgenDescriptors.length > 0 ? createDocgenWorkerClient(docgenDescriptors) : undefined; + + if (docgenWorker) { + registerDocgenService({ + getIndex: () => storyIndexGenerator.getIndex(), + docgenProvider: (input) => docgenWorker.extract(input.entry), + workingDir: process.cwd(), + }); + } + + registerStoryDocsService({ + getIndex: () => storyIndexGenerator.getIndex(), + storyDocsProvider, + workingDir: process.cwd(), + }); + } +}; + // Store the promise (not the result) to prevent race conditions. // The promise is assigned synchronously, so concurrent calls will share the same initialization. // This is essentially an async singleton pattern. diff --git a/code/core/src/core-server/server-channel/review-channel.test.ts b/code/core/src/core-server/server-channel/review-channel.test.ts new file mode 100644 index 000000000000..5d552e5ab587 --- /dev/null +++ b/code/core/src/core-server/server-channel/review-channel.test.ts @@ -0,0 +1,239 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { Channel } from 'storybook/internal/channels'; + +import { REVIEW_EVENTS } from '../../shared/review/events.ts'; +import type { ReviewState } from '../../shared/review/review-state.ts'; +import { initReviewChannel } from './review-channel.ts'; + +function createMockSubscribe() { + let captured: (() => void) | undefined; + const subscribeToModuleGraphChanges = vi.fn((onChange: () => void) => { + captured = onChange; + return () => { + captured = undefined; + }; + }); + return { + subscribeToModuleGraphChanges, + fireChange: () => captured?.(), + }; +} + +function createMockChannel() { + const listeners = new Map void>>(); + const emitted: Array<{ event: string; payload: unknown }> = []; + + const channel = { + on: vi.fn((event: string, listener: (...args: any[]) => void) => { + const arr = listeners.get(event) ?? []; + arr.push(listener); + listeners.set(event, arr); + }), + emit: vi.fn((event: string, payload?: unknown) => { + emitted.push({ event, payload }); + }), + fire: async (event: string, ...args: unknown[]) => { + const arr = listeners.get(event) ?? []; + for (const listener of arr) { + await listener(...args); + } + }, + } as unknown as Channel & { + fire: (event: string, ...args: unknown[]) => Promise; + }; + + return { channel, emitted }; +} + +const sampleReview: ReviewState = { + title: 'Recolour the primary button', + description: 'Button background changed from blue to green.', + collections: [ + { + title: 'Button', + rationale: 'The directly changed component.', + storyIds: ['button--primary'], + }, + ], +}; + +describe('initReviewChannel', () => { + const NOW = new Date().getTime(); + + beforeEach(() => { + vi.spyOn(Date, 'now').mockReturnValue(NOW); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('on PUSH_REVIEW, stamps createdAt, caches, and broadcasts DISPLAY_REVIEW', async () => { + const { channel, emitted } = createMockChannel(); + + initReviewChannel(channel); + await (channel as any).fire(REVIEW_EVENTS.PUSH_REVIEW, sampleReview); + + expect(emitted).toEqual([ + { + event: REVIEW_EVENTS.DISPLAY_REVIEW, + payload: { ...sampleReview, createdAt: NOW }, + }, + ]); + }); + + it('drops an agent-supplied stale flag so a fresh push starts non-stale', async () => { + const { channel, emitted } = createMockChannel(); + const payloadWithStale: ReviewState = { ...sampleReview, stale: true }; + + initReviewChannel(channel); + await (channel as any).fire(REVIEW_EVENTS.PUSH_REVIEW, payloadWithStale); + + expect(emitted).toEqual([ + { + event: REVIEW_EVENTS.DISPLAY_REVIEW, + payload: { ...sampleReview, createdAt: NOW }, + }, + ]); + expect((emitted[0].payload as ReviewState).stale).toBeUndefined(); + }); + + it('on REQUEST_REVIEW with no cached state, emits nothing', async () => { + const { channel, emitted } = createMockChannel(); + + initReviewChannel(channel); + await (channel as any).fire(REVIEW_EVENTS.REQUEST_REVIEW); + + expect(emitted).toEqual([]); + }); + + it('on REQUEST_REVIEW after a PUSH_REVIEW, replays the cached payload', async () => { + const { channel, emitted } = createMockChannel(); + + initReviewChannel(channel); + await (channel as any).fire(REVIEW_EVENTS.PUSH_REVIEW, sampleReview); + emitted.length = 0; + await (channel as any).fire(REVIEW_EVENTS.REQUEST_REVIEW); + + expect(emitted).toEqual([ + { event: REVIEW_EVENTS.DISPLAY_REVIEW, payload: { ...sampleReview, createdAt: NOW } }, + ]); + }); + + it('registers exactly one listener per cross-repo event', async () => { + const { channel } = createMockChannel(); + + initReviewChannel(channel, { + subscribeToModuleGraphChanges: vi.fn(() => () => {}), + }); + + expect(channel.on).toHaveBeenCalledWith(REVIEW_EVENTS.PUSH_REVIEW, expect.any(Function)); + expect(channel.on).toHaveBeenCalledWith(REVIEW_EVENTS.REQUEST_REVIEW, expect.any(Function)); + expect(channel.on).toHaveBeenCalledWith(REVIEW_EVENTS.DISMISS_REVIEW, expect.any(Function)); + expect(channel.on).toHaveBeenCalledTimes(3); + }); + + it('on DISMISS_REVIEW, clears cache and emits REVIEW_DISMISSED with return search', async () => { + const { channel, emitted } = createMockChannel(); + + initReviewChannel(channel); + await (channel as any).fire(REVIEW_EVENTS.PUSH_REVIEW, sampleReview); + emitted.length = 0; + await (channel as any).fire(REVIEW_EVENTS.DISMISS_REVIEW, '?path=/story/foo'); + + expect(emitted).toEqual([ + { event: REVIEW_EVENTS.REVIEW_DISMISSED, payload: '?path=/story/foo' }, + ]); + + emitted.length = 0; + await (channel as any).fire(REVIEW_EVENTS.REQUEST_REVIEW); + expect(emitted).toEqual([]); + }); + + describe('staleness', () => { + const setup = () => { + const { channel, emitted } = createMockChannel(); + const { subscribeToModuleGraphChanges, fireChange } = createMockSubscribe(); + initReviewChannel(channel, { subscribeToModuleGraphChanges }); + return { channel, emitted, fireChange }; + }; + + const staleOf = (emitted: Array<{ event: string; payload: unknown }>) => + emitted.filter((e) => e.event === REVIEW_EVENTS.REVIEW_STALE); + + it('marks the cached review stale and emits REVIEW_STALE after the grace window', async () => { + const { channel, emitted, fireChange } = setup(); + await (channel as any).fire(REVIEW_EVENTS.PUSH_REVIEW, sampleReview); + + // Past the grace window relative to createdAt (NOW). + vi.spyOn(Date, 'now').mockReturnValue(NOW + 12_000); + fireChange(); + + expect(staleOf(emitted)).toHaveLength(1); + + // Replay to a late tab carries the staleness on the cached state. + emitted.length = 0; + await (channel as any).fire(REVIEW_EVENTS.REQUEST_REVIEW); + expect(emitted).toEqual([ + { + event: REVIEW_EVENTS.DISPLAY_REVIEW, + payload: { + ...sampleReview, + createdAt: NOW, + stale: true, + }, + }, + ]); + }); + + it('ignores source changes within the grace window', async () => { + const { channel, emitted, fireChange } = setup(); + await (channel as any).fire(REVIEW_EVENTS.PUSH_REVIEW, sampleReview); + + // Date.now is still NOW (mocked in beforeEach) → within grace. + fireChange(); + + expect(staleOf(emitted)).toHaveLength(0); + emitted.length = 0; + await (channel as any).fire(REVIEW_EVENTS.REQUEST_REVIEW); + expect((emitted[0].payload as ReviewState).stale).toBeUndefined(); + }); + + it('ignores source changes when no review is cached', () => { + const { emitted, fireChange } = setup(); + + vi.spyOn(Date, 'now').mockReturnValue(NOW + 12_000); + fireChange(); + + expect(emitted).toEqual([]); + }); + + it('emits REVIEW_STALE only once across multiple changes', async () => { + const { channel, emitted, fireChange } = setup(); + await (channel as any).fire(REVIEW_EVENTS.PUSH_REVIEW, sampleReview); + + vi.spyOn(Date, 'now').mockReturnValue(NOW + 12_000); + fireChange(); + fireChange(); + fireChange(); + + expect(staleOf(emitted)).toHaveLength(1); + }); + + it('resets staleness when a new review is pushed', async () => { + const { channel, emitted, fireChange } = setup(); + await (channel as any).fire(REVIEW_EVENTS.PUSH_REVIEW, sampleReview); + + vi.spyOn(Date, 'now').mockReturnValue(NOW + 12_000); + fireChange(); + expect(staleOf(emitted)).toHaveLength(1); + + // A fresh push re-anchors createdAt and clears stale. + await (channel as any).fire(REVIEW_EVENTS.PUSH_REVIEW, sampleReview); + emitted.length = 0; + await (channel as any).fire(REVIEW_EVENTS.REQUEST_REVIEW); + expect((emitted[0].payload as ReviewState).stale).toBeUndefined(); + }); + }); +}); diff --git a/code/core/src/core-server/server-channel/review-channel.ts b/code/core/src/core-server/server-channel/review-channel.ts new file mode 100644 index 000000000000..f73d31b1aff7 --- /dev/null +++ b/code/core/src/core-server/server-channel/review-channel.ts @@ -0,0 +1,109 @@ +import type { Channel } from 'storybook/internal/channels'; + +import { getService } from '../../shared/open-service/server.ts'; +import type { ModuleGraphService } from '../../shared/open-service/services/module-graph/definition.ts'; +import { REVIEW_EVENTS } from '../../shared/review/events.ts'; +import type { ReviewState } from '../../shared/review/review-state.ts'; + +/** + * Window after a review's `createdAt` during which graph changes are ignored. + * Absorbs the agent's own edits (which precede the display-review call) whose + * file-system events may land a few milliseconds after the review is cached, + * preventing a freshly-pushed review from being marked stale immediately. + */ +const STALE_GRACE_MS = 10_000; + +type SubscribeToModuleGraphChanges = (onChange: () => void) => () => void; + +/** + * Default subscription to the `core/module-graph` open service. The review goes + * stale when any file in the story module graph changes (the service's revision + * only advances for in-graph changes, so unrelated file edits never trip it). + * The `services` preset registers the service before `experimental_serverChannel` + * runs, so the lookup succeeds synchronously here; if it's unavailable (e.g. a + * builder without module-graph support), staleness simply never triggers. + */ +const defaultSubscribeToModuleGraphChanges: SubscribeToModuleGraphChanges = (onChange) => { + try { + const service = getService('core/module-graph'); + // Omit the input to watch the entire graph. The initial emission carries + // revision 0 (or the current revision at subscribe time); only subsequent + // advances represent a change after the review was cached. + return service.queries.getGraphRevision.subscribe(undefined, ({ data: revision }) => { + if (revision !== undefined && revision > 0) { + onChange(); + } + }); + } catch { + // Module graph unavailable (e.g. builder without support); no staleness. + return () => {}; + } +}; + +export interface ReviewChannelOptions { + /** Override the module-graph-change subscription. Used by tests. */ + subscribeToModuleGraphChanges?: SubscribeToModuleGraphChanges; +} + +function prepareReview(payload: ReviewState): ReviewState { + // Staleness is server-authoritative (set by the file-watch handler), so a + // fresh push must never inherit a stale flag from the agent payload. + const { stale: _untrustedStale, ...rest } = payload; + return { + ...rest, + // Server-side timestamp is authoritative for "Created x minutes ago". + createdAt: Date.now(), + }; +} + +/** + * Owns the server-side review cache and staleness tracking. + * + * - PUSH_REVIEW (from `@storybook/addon-mcp`): stamp the server createdAt, + * cache, broadcast as DISPLAY_REVIEW so any open tab updates. + * - REQUEST_REVIEW (from a tab that just mounted): re-broadcast the cached + * payload as DISPLAY_REVIEW so the late tab catches up. + * - DISMISS_REVIEW: clear the cache and broadcast REVIEW_DISMISSED. + * + * The cache is a single in-memory slot scoped to this dev-server channel; it is + * intentionally not persisted, so a restart wipes the slate. + */ +export function initReviewChannel(channel: Channel, options: ReviewChannelOptions = {}) { + const subscribeToModuleGraphChanges = + options.subscribeToModuleGraphChanges ?? defaultSubscribeToModuleGraphChanges; + + let cached: ReviewState | undefined; + + channel.on(REVIEW_EVENTS.PUSH_REVIEW, (payload: ReviewState) => { + // A fresh review starts non-stale; its new createdAt re-anchors staleness. + cached = prepareReview(payload); + channel.emit(REVIEW_EVENTS.DISPLAY_REVIEW, cached); + }); + + channel.on(REVIEW_EVENTS.REQUEST_REVIEW, () => { + if (cached) { + channel.emit(REVIEW_EVENTS.DISPLAY_REVIEW, cached); + } + }); + + channel.on(REVIEW_EVENTS.DISMISS_REVIEW, (returnSearch?: string | null) => { + cached = undefined; + channel.emit(REVIEW_EVENTS.REVIEW_DISMISSED, returnSearch ?? null); + }); + + // Mark the cached review stale on the first module-graph change that lands + // after its createdAt (past the grace window). Staleness rides on the cached + // state so REQUEST_REVIEW replays it to tabs that open after the change. + subscribeToModuleGraphChanges(() => { + if (!cached || cached.stale || cached.createdAt === undefined) { + return; + } + if (Date.now() < cached.createdAt + STALE_GRACE_MS) { + return; + } + cached = { ...cached, stale: true }; + channel.emit(REVIEW_EVENTS.REVIEW_STALE); + }); + + return channel; +} diff --git a/code/core/src/core-server/server-channel/telemetry-channel.test.ts b/code/core/src/core-server/server-channel/telemetry-channel.test.ts index 3236de948d93..f891c3f0590d 100644 --- a/code/core/src/core-server/server-channel/telemetry-channel.test.ts +++ b/code/core/src/core-server/server-channel/telemetry-channel.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { SIDEBAR_FILTER_CHANGED } from 'storybook/internal/core-events'; +import { REVIEW_EVENTS } from '../../shared/review/events.ts'; import { initTelemetryChannel, makePayload } from './telemetry-channel.ts'; vi.mock('storybook/internal/telemetry', () => ({ @@ -42,6 +43,46 @@ describe('telemetry-channel', () => { expect(telemetry).toHaveBeenCalledWith('sidebar-filter', payload); }); }); + + describe('REVIEW_EVENTS.PAGEVIEW', () => { + it('forwards review summary pageview to telemetry', () => { + const listeners: Record = {}; + const channel = { + on: (event: string, listener: Function) => { + listeners[event] = listener; + }, + } as any; + + initTelemetryChannel(channel); + + listeners[REVIEW_EVENTS.PAGEVIEW]({ page: 'summary', reviewCreatedAt: 1700000000000 }); + expect(telemetry).toHaveBeenCalledWith('review', { + action: 'pageview', + source: 'mcp-review', + page: 'summary', + reviewCreatedAt: 1700000000000, + }); + }); + + it('forwards review detail pageview to telemetry', () => { + const listeners: Record = {}; + const channel = { + on: (event: string, listener: Function) => { + listeners[event] = listener; + }, + } as any; + + initTelemetryChannel(channel); + + listeners[REVIEW_EVENTS.PAGEVIEW]({ page: 'detail', reviewCreatedAt: 1700000000000 }); + expect(telemetry).toHaveBeenCalledWith('review', { + action: 'pageview', + source: 'mcp-review', + page: 'detail', + reviewCreatedAt: 1700000000000, + }); + }); + }); }); describe('makePayload', () => { diff --git a/code/core/src/core-server/server-channel/telemetry-channel.ts b/code/core/src/core-server/server-channel/telemetry-channel.ts index fd4c87af2b82..4f9e9f7d3d01 100644 --- a/code/core/src/core-server/server-channel/telemetry-channel.ts +++ b/code/core/src/core-server/server-channel/telemetry-channel.ts @@ -1,13 +1,19 @@ import type { Channel } from 'storybook/internal/channels'; import { + AI_PROMPT_NUDGE, PREVIEW_INITIALIZED, SHARE_ISOLATE_MODE, SIDEBAR_FILTER_CHANGED, - AI_PROMPT_NUDGE, } from 'storybook/internal/core-events'; -import { type InitPayload, telemetry } from 'storybook/internal/telemetry'; -import { type CacheEntry, getLastEvents } from 'storybook/internal/telemetry'; -import { getSessionId } from 'storybook/internal/telemetry'; +import { + type CacheEntry, + getLastEvents, + getSessionId, + type InitPayload, + telemetry, +} from 'storybook/internal/telemetry'; + +import { REVIEW_EVENTS, type ReviewPageviewPayload } from '../../shared/review/events.ts'; export const makePayload = ( userAgent: string, @@ -50,4 +56,9 @@ export function initTelemetryChannel(channel: Channel) { channel.on(AI_PROMPT_NUDGE, async ({ id, origin }: { id: string; origin: string }) => { telemetry('ai-prompt-nudge', { id, origin }); }); + channel.on(REVIEW_EVENTS.PAGEVIEW, ({ page, reviewCreatedAt }: ReviewPageviewPayload) => { + // Reviews are only produced by the MCP display-review tool today; other + // producers should report their own `source` under the same event. + telemetry('review', { action: 'pageview', source: 'mcp-review', page, reviewCreatedAt }); + }); } diff --git a/code/core/src/core-server/typings.d.ts b/code/core/src/core-server/typings.d.ts index 27369cd336c9..89cc4b416169 100644 --- a/code/core/src/core-server/typings.d.ts +++ b/code/core/src/core-server/typings.d.ts @@ -7,3 +7,4 @@ declare module 'watchpack'; declare var FEATURES: import('storybook/internal/types').StorybookConfigRaw['features']; declare var TAGS_OPTIONS: import('storybook/internal/types').TagsOptions; +declare var STORYBOOK_SERVICES_LOADED: boolean; diff --git a/code/core/src/core-server/utils/StoryIndexGenerator.test.ts b/code/core/src/core-server/utils/StoryIndexGenerator.test.ts index 4c3bb0817bd6..b0c15b315291 100644 --- a/code/core/src/core-server/utils/StoryIndexGenerator.test.ts +++ b/code/core/src/core-server/utils/StoryIndexGenerator.test.ts @@ -1,3 +1,4 @@ +import { existsSync } from 'node:fs'; import { join } from 'node:path'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -1957,6 +1958,39 @@ describe('StoryIndexGenerator', () => { `); }); + it('uses the explicit id prop on for standalone mdx docs', async () => { + const docsSpecifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( + './docs-id-generation/Standalone.docs.mdx', + options + ); + + const generator = new StoryIndexGenerator([docsSpecifier], options); + await generator.initialize(); + + const { storyIndex } = await generator.getIndexAndStats(); + expect(storyIndex).toMatchInlineSnapshot(` + { + "entries": { + "custom-standalone-id--docs": { + "id": "custom-standalone-id--docs", + "importPath": "./docs-id-generation/Standalone.docs.mdx", + "name": "docs", + "storiesImports": [], + "tags": [ + "dev", + "test", + "manifest", + "unattached-mdx", + ], + "title": "Standalone Page Title", + "type": "docs", + }, + }, + "v": 5, + } + `); + }); + it('puts the Meta of stories file first in storiesImports even when it is not the last import', async () => { const csfSpecifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( './src/*.stories.(js|ts)', @@ -1997,6 +2031,28 @@ describe('StoryIndexGenerator', () => { }); describe('warnings', () => { + it('does not match directories that have story-like names', async () => { + const specifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( + './src/**/*.stories.tsx', + options + ); + + const files = await StoryIndexGenerator.findMatchingFiles( + specifier, + options.workingDir, + true + ); + const storyLikeDirectory = join( + options.workingDir, + './src/__screenshots__/Button.stories.tsx' + ); + const storyLikeDirectoryFile = join(storyLikeDirectory, 'Primary.png'); + + expect(existsSync(storyLikeDirectoryFile)).toBe(true); + expect(Object.keys(files)).not.toContain(storyLikeDirectory); + expect(Object.keys(files)).not.toContain(storyLikeDirectoryFile); + }); + it('when entries do not match any files', async () => { const generator = new StoryIndexGenerator( [normalizeStoriesEntry('./src/docs2/wrong.js', options)], diff --git a/code/core/src/core-server/utils/StoryIndexGenerator.ts b/code/core/src/core-server/utils/StoryIndexGenerator.ts index 3848861d4fa5..480066ad3f1a 100644 --- a/code/core/src/core-server/utils/StoryIndexGenerator.ts +++ b/code/core/src/core-server/utils/StoryIndexGenerator.ts @@ -33,6 +33,7 @@ import { resolveImport, supportedExtensions } from '../../common/index.ts'; import { userOrAutoTitleFromSpecifier } from '../../preview-api/modules/store/autoTitle.ts'; import { sortStoriesV7 } from '../../preview-api/modules/store/sortStories.ts'; import { Tag } from '../../shared/constants/tags.ts'; +import { isMdxEntry } from '../../shared/utils/story-index-filters.ts'; import { IndexingError, MultipleIndexingError } from './IndexingError.ts'; import { autoName } from './autoName.ts'; import { analyzeMdx } from './analyze-mdx.ts'; @@ -65,11 +66,6 @@ export type StoryIndexGeneratorOptions = { build?: StorybookConfigRaw['build']; }; -/** Was this docs entry generated by a .mdx file? (see discussion below) */ -export function isMdxEntry({ tags }: DocsIndexEntry) { - return tags?.includes(Tag.UNATTACHED_MDX) || tags?.includes(Tag.ATTACHED_MDX); -} - const makeAbsolute = (otherImport: Path, normalizedPath: Path, workingDir: Path) => otherImport.startsWith('.') ? slash(resolve(workingDir, normalizeStoryPath(join(dirname(normalizedPath), otherImport)))) @@ -167,11 +163,11 @@ export class StoryIndexGenerator { // eslint-disable-next-line depend/ban-dependencies const { globby } = await import('globby'); - // Execute globby within the new CWD to ensure `ignore` patterns work correctly. const files = await globby(globPattern, { absolute: true, cwd: globCwd, ...commonGlobOptions(globPattern), + onlyFiles: true, }); if (files.length === 0 && !ignoreWarnings) { @@ -606,7 +602,7 @@ export class StoryIndexGenerator { result.name || (csfEntry ? autoName(importPath, csfEntry.importPath, defaultName) : defaultName); - const id = toId(csfEntry?.extra.metaId || title, name); + const id = toId(csfEntry?.extra.metaId || result.id || title, name); const tags = combineTags( ...projectTags, diff --git a/code/core/src/core-server/utils/__mockdata__/docs-id-generation/Standalone.docs.mdx b/code/core/src/core-server/utils/__mockdata__/docs-id-generation/Standalone.docs.mdx new file mode 100644 index 000000000000..7574a9d1bee0 --- /dev/null +++ b/code/core/src/core-server/utils/__mockdata__/docs-id-generation/Standalone.docs.mdx @@ -0,0 +1,7 @@ +import { Meta } from '@storybook/addon-docs/blocks'; + + + +# Standalone docs with explicit id + +hello docs diff --git a/code/core/src/core-server/utils/__mockdata__/src/__screenshots__/Button.stories.tsx/Primary.png b/code/core/src/core-server/utils/__mockdata__/src/__screenshots__/Button.stories.tsx/Primary.png new file mode 100644 index 000000000000..b97e4a75e2ca --- /dev/null +++ b/code/core/src/core-server/utils/__mockdata__/src/__screenshots__/Button.stories.tsx/Primary.png @@ -0,0 +1 @@ +mock screenshot fixture diff --git a/code/core/src/core-server/utils/analyze-mdx.test.ts b/code/core/src/core-server/utils/analyze-mdx.test.ts index f0215b81aa67..5e3cfbc16c4d 100644 --- a/code/core/src/core-server/utils/analyze-mdx.test.ts +++ b/code/core/src/core-server/utils/analyze-mdx.test.ts @@ -48,6 +48,7 @@ describe('analyzeMdx', () => { `; await expect(analyzeMdx(input)).resolves.toMatchInlineSnapshot(` { + "id": undefined, "imports": [], "isTemplate": false, "metaTags": undefined, @@ -79,6 +80,7 @@ describe('analyzeMdx', () => { `; await expect(analyzeMdx(input)).resolves.toMatchInlineSnapshot(` { + "id": undefined, "imports": [], "isTemplate": false, "metaTags": undefined, @@ -101,6 +103,38 @@ describe('analyzeMdx', () => { }); }); + describe('id', () => { + it('string literal id', async () => { + const input = dedent` + # hello + + + `; + await expect(analyzeMdx(input)).resolves.toMatchInlineSnapshot(` + { + "id": "custom-id", + "imports": [], + "isTemplate": false, + "metaTags": undefined, + "name": undefined, + "of": undefined, + "summary": undefined, + "title": "foobar", + } + `); + }); + it('template literal id', async () => { + const input = dedent` + # hello + + + `; + await expect(analyzeMdx(input)).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: Expected string literal id, received JSXExpressionContainer]` + ); + }); + }); + describe('of', () => { it('basic', async () => { const input = dedent` @@ -111,6 +145,7 @@ describe('analyzeMdx', () => { `; await expect(analyzeMdx(input)).resolves.toMatchInlineSnapshot(` { + "id": undefined, "imports": [ "@storybook/blocks", "./Button.stories", @@ -152,6 +187,7 @@ describe('analyzeMdx', () => { `; await expect(analyzeMdx(input)).resolves.toMatchInlineSnapshot(` { + "id": undefined, "imports": [ "@storybook/blocks", "./Button.stories", @@ -182,6 +218,7 @@ describe('analyzeMdx', () => { `; await expect(analyzeMdx(input)).resolves.toMatchInlineSnapshot(` { + "id": undefined, "imports": [ "../src/A.stories", ], @@ -214,6 +251,7 @@ describe('analyzeMdx', () => { `; await expect(analyzeMdx(input)).resolves.toMatchInlineSnapshot(` { + "id": undefined, "imports": [], "isTemplate": false, "metaTags": undefined, @@ -241,6 +279,7 @@ describe('analyzeMdx', () => { `; await expect(analyzeMdx(input)).resolves.toMatchInlineSnapshot(` { + "id": undefined, "imports": [], "isTemplate": true, "metaTags": undefined, @@ -257,6 +296,7 @@ describe('analyzeMdx', () => { `; await expect(analyzeMdx(input)).resolves.toMatchInlineSnapshot(` { + "id": undefined, "imports": [], "isTemplate": true, "metaTags": undefined, @@ -273,6 +313,7 @@ describe('analyzeMdx', () => { `; await expect(analyzeMdx(input)).resolves.toMatchInlineSnapshot(` { + "id": undefined, "imports": [], "isTemplate": false, "metaTags": undefined, @@ -312,6 +353,7 @@ describe('analyzeMdx', () => { `; await expect(analyzeMdx(input)).resolves.toMatchInlineSnapshot(` { + "id": undefined, "imports": [ "./Button.stories", ], @@ -361,6 +403,7 @@ describe('analyzeMdx', () => { `; await expect(analyzeMdx(input)).resolves.toMatchInlineSnapshot(` { + "id": undefined, "imports": [], "isTemplate": false, "metaTags": undefined, @@ -379,6 +422,7 @@ describe('analyzeMdx', () => { `; await expect(analyzeMdx(input)).resolves.toMatchInlineSnapshot(` { + "id": undefined, "imports": [ "./Button.stories", ], @@ -423,6 +467,7 @@ describe('analyzeMdx', () => { `; await expect(analyzeMdx(input)).resolves.toMatchInlineSnapshot(` { + "id": undefined, "imports": [ "./Button.stories", ], diff --git a/code/core/src/core-server/utils/analyze-mdx.ts b/code/core/src/core-server/utils/analyze-mdx.ts index bd4905d2aaa6..de699542173a 100644 --- a/code/core/src/core-server/utils/analyze-mdx.ts +++ b/code/core/src/core-server/utils/analyze-mdx.ts @@ -15,6 +15,7 @@ export type MdxAnalysisResult = { title: string | undefined; of: string | undefined; name: string | undefined; + id: string | undefined; summary: string | undefined; isTemplate: boolean; metaTags?: string[]; @@ -43,6 +44,7 @@ const createEmptyMdxMetadata = (): MdxMetadata => ({ title: undefined, of: undefined, name: undefined, + id: undefined, summary: undefined, isTemplate: false, metaTags: undefined, @@ -182,6 +184,7 @@ const extractMeta = (metaElement: MdxJsxFlowElement, importMap: ImportMap): MdxM title: getStringAttribute(metaElement, 'title'), of: getOfImportPath(metaElement, importMap), name: getStringAttribute(metaElement, 'name'), + id: getStringAttribute(metaElement, 'id'), summary: getStringAttribute(metaElement, 'summary'), isTemplate: getIsTemplate(metaElement), metaTags: getMetaTags(metaElement), diff --git a/code/core/src/core-server/utils/apply-services-preset-once.ts b/code/core/src/core-server/utils/apply-services-preset-once.ts new file mode 100644 index 000000000000..99bccb723a4f --- /dev/null +++ b/code/core/src/core-server/utils/apply-services-preset-once.ts @@ -0,0 +1,17 @@ +import type { Presets } from 'storybook/internal/types'; + +declare global { + // eslint-disable-next-line no-var + var STORYBOOK_SERVICES_PRESET_PROMISE: Promise | undefined; +} + +globalThis.STORYBOOK_SERVICES_PRESET_PROMISE = undefined; + +/** + * Applies the 'services' preset, but only once, as the services must not be registered multiple times. + * + * This is to ensure that we don't apply the preset multiple times in dev mode, which can lead to issues with the telemetry service and other services that are meant to be singletons. + */ +export async function applyServicesPresetOnce(presets: Presets): Promise { + return (globalThis.STORYBOOK_SERVICES_PRESET_PROMISE ??= presets.apply('services')); +} diff --git a/code/core/src/core-server/utils/copy-all-static-files.ts b/code/core/src/core-server/utils/copy-all-static-files.ts index 9cf8d1975ff1..895c2e170c3d 100644 --- a/code/core/src/core-server/utils/copy-all-static-files.ts +++ b/code/core/src/core-server/utils/copy-all-static-files.ts @@ -30,6 +30,7 @@ export async function copyAllStaticFiles(staticDirs: any[] | undefined, outputDi preserveTimestamps: true, filter: (_, dest) => !skipPaths.includes(dest), recursive: true, + force: true, }); } catch (e) { if (e instanceof Error) { @@ -73,6 +74,7 @@ export async function copyAllStaticFilesRelativeToMain( preserveTimestamps: true, filter: (_, dest) => !skipPaths.includes(dest), recursive: true, + force: true, }); }, Promise.resolve()); } diff --git a/code/core/src/core-server/utils/get-server-channel.ts b/code/core/src/core-server/utils/get-server-channel.ts index 2f1ca4a4ddb0..b3e5192504fe 100644 --- a/code/core/src/core-server/utils/get-server-channel.ts +++ b/code/core/src/core-server/utils/get-server-channel.ts @@ -1,7 +1,7 @@ import type { IncomingMessage } from 'node:http'; import type { ChannelHandler } from 'storybook/internal/channels'; -import { Channel, HEARTBEAT_INTERVAL } from 'storybook/internal/channels'; +import { Channel, HEARTBEAT_INTERVAL, setChannel } from 'storybook/internal/channels'; import { isJSON, parse, stringify } from 'telejson'; import WebSocket, { WebSocketServer } from 'ws'; @@ -105,6 +105,8 @@ export function getServerChannel(server: Server, options: ServerChannelTransport const channel = new Channel({ transports, async: true }); + setChannel(channel); + UniversalStore.__prepare(channel, UniversalStore.Environment.SERVER); return channel; diff --git a/code/core/src/core-server/utils/get-stories-paths-from-config.ts b/code/core/src/core-server/utils/get-stories-paths-from-config.ts new file mode 100644 index 000000000000..0f7c4d60fe5c --- /dev/null +++ b/code/core/src/core-server/utils/get-stories-paths-from-config.ts @@ -0,0 +1,43 @@ +import { normalizeStories } from 'storybook/internal/common'; +import type { StorybookConfigRaw } from 'storybook/internal/types'; + +import { StoryIndexGenerator } from './StoryIndexGenerator.ts'; + +/** + * Resolves story file paths from a main config's `stories` field without evaluating story files. + * + * @example + * + * ```typescript + * const storiesPaths = await getStoriesPathsFromConfig({ + * stories: ['src\/**\/*.stories.tsx'], + * configDir: '/path/to/.storybook', + * workingDir: '/path/to/project', + * }); + * ``` + */ +export const getStoriesPathsFromConfig = async ({ + stories, + configDir, + workingDir, +}: { + stories: StorybookConfigRaw['stories']; + configDir: string; + workingDir: string; +}) => { + if (stories.length === 0) { + return []; + } + + const normalizedStories = normalizeStories(stories, { configDir, workingDir }); + + const matchingStoryFiles = await StoryIndexGenerator.findMatchingFilesForSpecifiers( + normalizedStories, + workingDir, + true + ); + + return matchingStoryFiles.flatMap(([specifier, cache]) => + StoryIndexGenerator.storyFileNames(new Map([[specifier, cache]])) + ); +}; diff --git a/code/core/src/core-server/utils/index-json.test.ts b/code/core/src/core-server/utils/index-json.test.ts index d00e04bd5052..e895e1bf0dee 100644 --- a/code/core/src/core-server/utils/index-json.test.ts +++ b/code/core/src/core-server/utils/index-json.test.ts @@ -525,14 +525,12 @@ describe('registerIndexJsonRoute', () => { it('sends invalidate events', async () => { const mockServerChannel = { emit: vi.fn() } as any as ServerChannel; - const onStoryIndexInvalidated = vi.fn(); registerIndexJsonRoute({ app, channel: mockServerChannel, workingDir, normalizedStories, storyIndexGeneratorPromise: getStoryIndexGeneratorPromise(), - onStoryIndexInvalidated, }); expect(use).toHaveBeenCalledTimes(1); @@ -560,7 +558,6 @@ describe('registerIndexJsonRoute', () => { expect(mockServerChannel.emit).toHaveBeenCalledTimes(1); }); expect(mockServerChannel.emit).toHaveBeenCalledWith(STORY_INDEX_INVALIDATED); - expect(onStoryIndexInvalidated).toHaveBeenCalledTimes(1); }); it('only sends one invalidation when multiple event listeners are listening', async () => { diff --git a/code/core/src/core-server/utils/index-json.ts b/code/core/src/core-server/utils/index-json.ts index c5f3248a69d1..210f27ebe74f 100644 --- a/code/core/src/core-server/utils/index-json.ts +++ b/code/core/src/core-server/utils/index-json.ts @@ -30,7 +30,6 @@ export function registerIndexJsonRoute({ configDir, channel, normalizedStories, - onStoryIndexInvalidated, }: { app: Polka; storyIndexGeneratorPromise: Promise; @@ -38,12 +37,10 @@ export function registerIndexJsonRoute({ workingDir?: string; configDir?: string; normalizedStories: NormalizedStoriesSpecifier[]; - onStoryIndexInvalidated?: () => void; }) { const maybeInvalidate = debounce( () => { channel.emit(STORY_INDEX_INVALIDATED); - onStoryIndexInvalidated?.(); }, DEBOUNCE, { edges: ['leading', 'trailing'] } diff --git a/code/core/src/core-server/utils/manifests/components-ref-manifest.test.ts b/code/core/src/core-server/utils/manifests/components-ref-manifest.test.ts new file mode 100644 index 000000000000..3c679added1a --- /dev/null +++ b/code/core/src/core-server/utils/manifests/components-ref-manifest.test.ts @@ -0,0 +1,206 @@ +import { readFile } from 'node:fs/promises'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { vol } from 'memfs'; + +import { docgenManifestRef } from '../../../shared/open-service/services/docgen/paths.ts'; +import { storyDocsManifestRef } from '../../../shared/open-service/services/story-docs/paths.ts'; +import type { DocgenPayload } from '../../../shared/open-service/services/docgen/types.ts'; +import type { StoryDocsPayload } from '../../../shared/open-service/services/story-docs/types.ts'; + +import { + COMPONENTS_REF_MANIFEST_VERSION, + buildComponentsRefManifest, + loadDocgenPayloadsFromDisk, + loadStoryDocsPayloadsFromDisk, + mergeManifestPayloads, + toComponentManifestIndexEntries, +} from './components-ref-manifest.ts'; + +vi.mock('node:fs/promises', { spy: true }); + +beforeEach(async () => { + vol.reset(); + const memfs = await vi.importActual('memfs'); + + vi.mocked(readFile).mockImplementation( + memfs.fs.promises.readFile as unknown as typeof import('node:fs/promises').readFile + ); +}); + +describe('components-ref-manifest', () => { + it('builds ref-based component manifest entries with nested docgen refs', () => { + expect( + buildComponentsRefManifest({ + button: { + id: 'button', + name: 'Button', + docgen: { $ref: docgenManifestRef('button') }, + }, + card: { + id: 'card', + name: 'Card', + summary: 'A card', + }, + }) + ).toEqual({ + v: COMPONENTS_REF_MANIFEST_VERSION, + components: { + button: { + id: 'button', + name: 'Button', + docgen: { $ref: docgenManifestRef('button') }, + }, + card: { + id: 'card', + name: 'Card', + summary: 'A card', + }, + }, + }); + }); + + it('carries meta when provided', () => { + expect( + buildComponentsRefManifest({}, { docgen: 'react-component-meta', durationMs: 42 }) + ).toEqual({ + v: COMPONENTS_REF_MANIFEST_VERSION, + components: {}, + meta: { docgen: 'react-component-meta', durationMs: 42 }, + }); + }); + + it('loads full docgen payloads from built snapshots on disk', async () => { + const payload = { + id: 'button', + name: 'Button', + description: 'A button', + summary: 'Click me', + path: './button.stories.tsx', + jsDocTags: {}, + }; + vol.fromNestedJSON({ + '/output/services/core/docgen/button.json': JSON.stringify({ + components: { button: payload }, + }), + }); + + await expect(loadDocgenPayloadsFromDisk('/output', ['button'])).resolves.toEqual({ + button: payload, + }); + }); + + it('skips components without a readable snapshot', async () => { + await expect(loadDocgenPayloadsFromDisk('/output', ['button'])).resolves.toEqual({}); + }); + + it('builds index entries with nested docgen and story-docs refs when payloads exist', () => { + const docgen: DocgenPayload = { + id: 'button', + name: 'Button', + description: 'A button', + summary: 'Click me', + path: './button.stories.tsx', + jsDocTags: {}, + }; + const storyDocs: StoryDocsPayload = { + id: 'button', + name: 'Button', + path: './button.stories.tsx', + stories: { + 'button--primary': { id: 'button--primary', name: 'Primary', snippet: ' + + ); + } + + return ( + + + Code changes detected. This review may be stale.{' '} + + + Prompt for your agent to refresh this review: + {STALE_REFRESH_PROMPT} + + Copy prompt + + } + > + + Copy prompt + + + + } + > + + Ask your agent to refresh it. + + + + + ); +}; diff --git a/code/core/src/manager/components/review/components/CollectionGrid.stories.tsx b/code/core/src/manager/components/review/components/CollectionGrid.stories.tsx new file mode 100644 index 000000000000..e72c4fdfd0e2 --- /dev/null +++ b/code/core/src/manager/components/review/components/CollectionGrid.stories.tsx @@ -0,0 +1,429 @@ +import React, { type FC } from 'react'; + +import { expect, userEvent, waitFor, within } from 'storybook/test'; + +import preview from '../../../../../../.storybook/preview.tsx'; +import { + IFRAME_RESIZE_CONTEXT, + type IframeResizeViewport, +} from '../../../../shared/constants/iframe-resize.ts'; +import { RESPONSIVE_VIEWPORT_VALUE } from '../../../../viewport/constants.ts'; +import { IconSymbols } from '../../sidebar/IconSymbols.tsx'; +import type { StoryInfo } from '../review-types.ts'; +import { CollectionGrid, type CollectionGridProps } from './CollectionGrid.tsx'; + +// 40 unique story IDs drawn from real internal stories. +const fortyStoryIds = [ + 'button-component--base', + 'button-component--variants', + 'button-component--sizes', + 'button-component--paddings', + 'button-component--pseudo-states', + 'button-component--icon-only', + 'components-togglebutton--variants', + 'components-togglebutton--sizes', + 'components-tabs-tabsview--basic', + 'components-tabs--stateful-static', + 'components-tabs--stateless-with-tools', + 'components-toolbar--basic', + 'components-toolbar--scrollable', + 'components-abstracttoolbar--basic', + 'select-component--base', + 'components-card--default', + 'components-bar-bar--default', + 'components-collapsible--default', + 'overlay-modal--base', + 'overlay-modal--interactive-mouse', + 'overlay-popover--with-hide-button', + 'overlay-popover--with-chrome', + 'manager-main--default', + 'manager-main--about-page', + 'manager-main--guide-page', + + 'manager-settings-aboutscreen--default', + 'manager-settings-guidepage--default', + 'manager-settings-shortcutsscreen--defaults', + 'manager-settings-checklist--default', + 'manager-sidebar-sidebar--simple', + 'manager-sidebar-sidebar--with-refs', + 'manager-sidebar-sidebar--statuses-open', + 'manager-sidebar-sidebar--searching', + 'manager-sidebar-sidebar--with-cta-active', + 'manager-sidebar-filesearchmodal--default', + 'manager-sidebar-filesearchlist--default', + 'manager-container-menu--with-shortcuts', + 'manager-container-menu--with-shortcuts-active', + 'manager-components-preview-viewport--default', + 'bench--es-build-analyzer', +]; + +const demoStoryIds = [ + 'button-component--base', + 'button-component--variants', + 'button-component--sizes', + 'manager-main--default', + 'manager-sidebar-sidebar--simple', + 'manager-settings-aboutscreen--default', + 'components-tabs-tabsview--basic', + 'bench--es-build-analyzer', +]; + +const titleCase = (value: string) => + value + .split(/[-/]/) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' '); + +// Stand-in for the Storybook index: every demo story resolves to a title + +// name so the grid renders it (stories with no index entry are skipped). +const demoStoryInfo: Record = Object.fromEntries( + demoStoryIds.map((id) => { + const [componentId, ...rest] = id.split('--'); + return [id, { title: titleCase(componentId), name: titleCase(rest.join('--')) || 'Story' }]; + }) +); + +const meta = preview.meta({ + component: CollectionGrid, + decorators: [ + (Story) => ( + <> + + + + ), + ], + parameters: { + layout: 'fullscreen', + chromatic: { + ignoreSelectors: ['[data-testid="review-collection-grid-cell"] iframe'], + }, + }, + args: { + storyIds: demoStoryIds, + storyInfo: demoStoryInfo, + getStoryPreviewHref: (storyId: string) => + `iframe.html?id=${encodeURIComponent(storyId)}&viewMode=story&embed=true&freeze=finished`, + }, +}); + +const previewHref = (storyId: string) => + `iframe.html?id=${encodeURIComponent(storyId)}&viewMode=story&embed=true&freeze=finished`; + +const dispatchIframeResize = ( + cell: HTMLElement, + width: number, + height: number, + viewport?: IframeResizeViewport +) => { + const contentWindow = cell.querySelector('iframe')?.contentWindow; + if (!contentWindow) { + throw new Error('Preview iframe has no contentWindow'); + } + window.dispatchEvent( + new MessageEvent('message', { + data: JSON.stringify({ + context: IFRAME_RESIZE_CONTEXT, + width, + height, + ...(viewport ? { viewport } : {}), + }), + source: contentWindow, + }) + ); +}; + +/** Loader cleared after iframe src is assigned and resize is applied. */ +const waitForCellPreviewSettled = async ( + cell: HTMLElement, + dimensions: { + width: number; + height: number; + viewport?: IframeResizeViewport; + } = { width: 320, height: 240 } +) => { + await waitFor(() => { + expect(cell.querySelector('iframe')?.getAttribute('src')).toContain('embed=true'); + }); + dispatchIframeResize(cell, dimensions.width, dimensions.height, dimensions.viewport); + await waitFor(() => { + expect(within(cell).queryByTestId('review-preview-loading')).not.toBeInTheDocument(); + expect(Number(cell.querySelector('iframe')?.getAttribute('data-content-width'))).toBe( + dimensions.width + ); + }); +}; + +export const Default = meta.story({ + play: async ({ canvasElement }) => { + const cells = await within(canvasElement).findAllByTestId('review-collection-grid-cell'); + await waitFor(() => { + for (const cell of cells) { + const cellWidth = cell.getBoundingClientRect().width; + const frame = cell.querySelector( + '[data-testid="review-collection-grid-frame"]' + ); + expect(frame).toBeTruthy(); + expect(frame!.getBoundingClientRect().width).toBeLessThanOrEqual(cellWidth + 1); + } + }); + }, +}); + +export const QueuedPreviewShowsLoader = meta.story({ + args: { + storyIds: [ + 'manager-main--default', + 'manager-settings-aboutscreen--default', + 'manager-sidebar-sidebar--simple', + 'button-component--base', + 'button-component--variants', + ], + showAll: true, + }, + globals: { viewport: { value: 'desktop' } }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const cells = await canvas.findAllByTestId('review-collection-grid-cell'); + expect(cells.length).toBe(5); + + await waitFor(() => { + const started = cells.filter((cell) => cell.querySelector('iframe[src]')); + expect(started.length).toBeGreaterThanOrEqual(3); + }); + + const queuedCells = cells.filter((cell) => !cell.querySelector('iframe[src]')); + expect(queuedCells.length).toBeGreaterThan(0); + for (const cell of queuedCells) { + expect(within(cell).getByTestId('review-preview-loading')).toBeInTheDocument(); + } + }, +}); + +export const PreviewLoadingSettle = meta.story({ + args: { + storyIds: ['manager-main--default'], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const cell = await canvas.findByTestId('review-collection-grid-cell'); + await waitForCellPreviewSettled(cell); + + // Duplicate iframe.resize payloads should not leave the loader stuck. + dispatchIframeResize(cell, 320, 240); + expect(within(cell).queryByTestId('review-preview-loading')).not.toBeInTheDocument(); + }, +}); + +const StorySwapHarness: FC> = () => { + const [storyIds, setStoryIds] = React.useState(['manager-main--default']); + const storyInfo: Record = { + 'manager-main--default': demoStoryInfo['manager-main--default'], + 'manager-settings-aboutscreen--default': demoStoryInfo['manager-settings-aboutscreen--default'], + }; + + return ( +
    + + +
    + ); +}; + +export const PreviewRemountOnStoryChange = meta.story({ + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const cell = await canvas.findByTestId('review-collection-grid-cell'); + await waitForCellPreviewSettled(cell); + + expect(cell.querySelector('iframe')?.title).toBe('manager-main--default'); + + await userEvent.click(canvas.getByTestId('swap-preview-story')); + + let nextCell: HTMLElement | undefined; + await waitFor(async () => { + nextCell = await canvas.findByTestId('review-collection-grid-cell'); + expect(nextCell.querySelector('iframe')?.title).toBe('manager-settings-aboutscreen--default'); + }); + await waitForCellPreviewSettled(nextCell!, { width: 280, height: 180 }); + }, +}); + +// On a narrow (mobile) container the grid drops to a single column and caps at +// two rows, so eight stories overflow into the "Review all" affordance. +export const ManyStoriesOverflow = meta.story({ + globals: { viewport: { value: 'mobile1' } }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(await canvas.findByRole('button', { name: /Review all/i })).toBeInTheDocument(); + const reviewAllFrame = canvasElement.querySelector('[data-review-all] > :first-child'); + await expect(reviewAllFrame).toBeTruthy(); + await waitFor(() => { + expect((reviewAllFrame as HTMLElement).getBoundingClientRect().height).toBeGreaterThanOrEqual( + 50 + ); + }); + }, +}); + +export const FewStories = meta.story({ + args: { + storyIds: ['manager-main--default', 'manager-settings-aboutscreen--default'], + }, + globals: { viewport: { value: 'desktop' } }, + play: async ({ canvasElement }) => { + const cells = canvasElement.querySelectorAll('[data-testid="review-collection-grid-cell"]'); + await expect(cells.length).toBe(2); + await expect( + canvasElement.querySelector('[data-review-all] button') + ).not.toBeVisible(); + + const frames = Array.from(cells).map((cell) => + cell.querySelector('[data-testid="review-collection-grid-frame"]') + ); + await waitFor(() => { + expect(frames.every((frame) => (frame?.clientHeight ?? 0) > 0)).toBe(true); + for (const cell of cells) { + const cellWidth = (cell as HTMLElement).getBoundingClientRect().width; + const frame = cell.querySelector( + '[data-testid="review-collection-grid-frame"]' + ); + expect(frame).toBeTruthy(); + expect(frame!.getBoundingClientRect().width).toBeLessThanOrEqual(cellWidth + 1); + } + }); + const [firstHeight, secondHeight] = frames.map( + (frame) => frame?.getBoundingClientRect().height ?? 0 + ); + expect(Math.abs(firstHeight - secondHeight)).toBeLessThanOrEqual(2); + }, +}); + +// A single preview clamps to 400px instead of stretching to fill the card, so +// the grid layout stays consistent regardless of story count. +// 40 stories: the grid caps at 2 rows (7 cells + "Review all 40" in the last +// slot). Clicking the button drops the cap and loads all 40 with lazy eviction. +export const FortyStoriesOverflow = meta.story({ + args: { storyIds: fortyStoryIds }, + globals: { viewport: { value: 'desktop' } }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const reviewAllButton = await canvas.findByRole('button', { name: /Review all 40/i }); + await expect(reviewAllButton).toBeInTheDocument(); + // Only 7 story cells visible before expanding (8th slot is taken by the button). + const cells = canvasElement.querySelectorAll('[data-testid="review-collection-grid-cell"]'); + await expect(cells.length).toBe(40); // all mounted in DOM + const visibleCells = Array.from(cells).filter( + (el) => (el as HTMLElement).style.display !== 'none' && el.checkVisibility?.() + ); + await expect(visibleCells.length).toBeLessThanOrEqual(8); + + const reviewAllFrame = canvasElement.querySelector('[data-review-all] > :first-child'); + await expect(reviewAllFrame).toBeTruthy(); + await waitFor(() => { + expect((reviewAllFrame as HTMLElement).clientHeight).toBeGreaterThan(0); + }); + const reviewAllHeight = (reviewAllFrame as HTMLElement).clientHeight; + const getRowNeighborHeights = () => + visibleCells + .slice(-3) + .map((cell) => (cell.firstElementChild as HTMLElement | null)?.clientHeight ?? 0); + await waitFor(() => { + expect(getRowNeighborHeights().every((height) => height > 0)).toBe(true); + }); + for (const height of getRowNeighborHeights()) { + expect(height).toBe(reviewAllHeight); + } + }, +}); + +export const SingleCellClamped = meta.story({ + args: { + storyIds: ['manager-main--default'], + }, + globals: { viewport: { value: 'desktop' } }, + play: async ({ canvasElement }) => { + const cell = canvasElement.querySelector('[data-testid="review-collection-grid-cell"]'); + await expect(cell).toBeTruthy(); + await expect((cell as HTMLElement).getBoundingClientRect().width).toBeLessThanOrEqual(401); + }, +}); + +export const FrameFitsCellAfterResize = meta.story({ + args: { + storyIds: ['manager-main--default'], + }, + globals: { viewport: { value: 'desktop' } }, + play: async ({ canvasElement }) => { + const cell = await within(canvasElement).findByTestId('review-collection-grid-cell'); + await waitForCellPreviewSettled(cell, { + width: 1280, + height: 800, + viewport: { name: 'Desktop', value: 'desktop', width: 1280, height: 1024 }, + }); + await waitFor(() => { + const cellWidth = cell.getBoundingClientRect().width; + const frame = cell.querySelector('[data-testid="review-collection-grid-frame"]'); + expect(frame).toBeTruthy(); + expect(frame!.getBoundingClientRect().width).toBeLessThanOrEqual(cellWidth + 1); + }); + }, +}); + +export const ViewportAspectRatio = meta.story({ + args: { + storyIds: ['manager-main--default'], + }, + play: async ({ canvasElement }) => { + const cell = await within(canvasElement).findByTestId('review-collection-grid-cell'); + await waitForCellPreviewSettled(cell, { + width: 120, + height: 48, + viewport: { name: 'Small mobile', value: 'mobile1', width: 320, height: 568 }, + }); + + const shell = cell.firstElementChild as HTMLElement; + const frame = cell.querySelector('[data-testid="review-collection-grid-frame"]'); + expect(frame?.hasAttribute('data-viewport-fill')).toBe(true); + + const shellRect = shell.getBoundingClientRect(); + const frameRect = frame!.getBoundingClientRect(); + expect(frameRect.width).toBeCloseTo(shellRect.width, 0); + expect(frameRect.height).toBeCloseTo(shellRect.height, 0); + + const previewScale = frame?.querySelector('[data-preview-scale]') as HTMLElement | null; + expect(previewScale).toBeTruthy(); + expect(previewScale!.getBoundingClientRect().height).toBeGreaterThan(frameRect.height); + }, +}); + +export const ResponsiveViewportFillsCell = meta.story({ + args: { + storyIds: ['manager-main--default'], + }, + play: async ({ canvasElement }) => { + const cell = await within(canvasElement).findByTestId('review-collection-grid-cell'); + await waitForCellPreviewSettled(cell, { + width: 320, + height: 240, + viewport: { name: 'Responsive', value: RESPONSIVE_VIEWPORT_VALUE, width: 800, height: 600 }, + }); + + const shell = cell.firstElementChild as HTMLElement; + const frame = cell.querySelector('[data-testid="review-collection-grid-frame"]'); + expect(frame?.hasAttribute('data-viewport-fill')).toBe(false); + + const shellRect = shell.getBoundingClientRect(); + const frameRect = frame!.getBoundingClientRect(); + expect(frameRect.width).toBeCloseTo(shellRect.width, 0); + expect(frameRect.height).toBeCloseTo(shellRect.height, 0); + }, +}); diff --git a/code/core/src/manager/components/review/components/CollectionGrid.tsx b/code/core/src/manager/components/review/components/CollectionGrid.tsx new file mode 100644 index 000000000000..2c7253c05787 --- /dev/null +++ b/code/core/src/manager/components/review/components/CollectionGrid.tsx @@ -0,0 +1,353 @@ +import React, { type CSSProperties, type FC } from 'react'; + +import { Badge, Button, Loader } from 'storybook/internal/components'; +import { styled } from 'storybook/theming'; + +import { + hasFixedViewportDimensions, + type IframeResizeDimensions, +} from '../../../../shared/constants/iframe-resize.ts'; +import { fallbackStoryInfo, type StoryInfo } from '../review-types.ts'; +import { usePreviewThumbnail } from './usePreviewThumbnail.ts'; + +// Per-breakpoint grid: `cols` columns (each cell clamped to 400px) capped at +// two rows. Overflow beyond the cap is hidden and a "Review all" cell takes the +// last slot — all via CSS (`:has()` + `:nth-child`), no JS measurement. +const band = (cols: number) => { + const cap = cols * 2; + return { + gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`, + [`&:not([data-show-all]):has(> [data-cell]:nth-child(${cap + 1})) > [data-cell]:nth-child(n + ${cap})`]: + { + display: 'none', + }, + [`&:not([data-show-all]):has(> [data-cell]:nth-child(${cap + 1})) > [data-review-all]`]: { + display: 'flex', + }, + }; +}; + +const GridContainer = styled.div({ + containerType: 'inline-size', + containerName: 'review-grid', +}); + +const Grid = styled.div({ + display: 'grid', + alignItems: 'stretch', + gap: 12, + padding: 12, + gridTemplateColumns: 'minmax(0, 1fr)', + '@container review-grid (max-width: 629.98px)': band(1), + '@container review-grid (min-width: 630px) and (max-width: 844.98px)': band(2), + '@container review-grid (min-width: 845px) and (max-width: 1259.98px)': band(3), + '@container review-grid (min-width: 1260px)': band(4), +}); + +const Cell = styled.div({ + display: 'flex', + flexDirection: 'column', + minWidth: 0, + overflow: 'hidden', +}); + +const FrameShell = styled.div({ + width: '100%', + minWidth: 0, + maxWidth: '100%', + aspectRatio: '3 / 2', + position: 'relative', +}); + +/** Default `--content-w` before the embed iframe reports its size. */ +const DEFAULT_CONTENT_WIDTH = 300; + +/** Pre-measurement scale so the embed iframe viewport is 2× the frame width (100% / 0.5). */ +const THUMBNAIL_BOOTSTRAP_SCALE = 0.5; + +// Feeds the CSS variables consumed by `Frame` below (`--scale`, `--content-w`, +// `--vp-w`/`--vp-h`); `viewportFill` toggles the `data-viewport-fill` branch. +const getPreviewFrameLayout = ( + dimensions: IframeResizeDimensions | null +): { style: CSSProperties; viewportFill: boolean } => { + if (!dimensions) { + return { + style: { '--scale': THUMBNAIL_BOOTSTRAP_SCALE } as CSSProperties, + viewportFill: false, + }; + } + + if (hasFixedViewportDimensions(dimensions.viewport)) { + return { + style: { + '--vp-w': dimensions.viewport.width, + '--vp-h': dimensions.viewport.height, + } as CSSProperties, + viewportFill: true, + }; + } + + return { style: { '--content-w': dimensions.width } as CSSProperties, viewportFill: false }; +}; + +const Frame = styled.a(({ theme }) => ({ + position: 'absolute', + inset: 0, + display: 'block', + boxSizing: 'border-box', + containerType: 'inline-size', + containerName: 'preview-frame', + '--content-w': DEFAULT_CONTENT_WIDTH, + '--fit-w': 'calc(100cqw / (var(--content-w) * 1px))', + '--fit': 'min(1, var(--fit-w))', + '--scale': 'max(0.5, min(1, round(down, var(--fit), 0.25)))', + '--vp-scale': 'calc(100cqw / (var(--vp-w) * 1px))', + borderRadius: 6, + overflow: 'hidden', + background: theme.background.app, + border: `1px solid ${theme.appBorderColor}`, + transition: 'border-color 120ms ease', + textDecoration: 'none', + outline: 'none', + '& [data-preview-scale]': { + position: 'absolute', + top: 0, + left: 0, + transformOrigin: 'top left', + }, + '&[data-viewport-fill] [data-preview-scale]': { + width: 'calc(var(--vp-w) * 1px)', + height: 'calc(var(--vp-h) * 1px)', + transform: 'scale(var(--vp-scale))', + }, + '&:not([data-viewport-fill]) [data-preview-scale]': { + width: 'calc(100% / var(--scale))', + height: 'calc(100% / var(--scale))', + transform: 'scale(var(--scale))', + }, + '&[href]:hover': { + borderColor: theme.color.secondary, + }, + '&:focus-visible': { + outline: `${theme.barSelectedColor} solid 2px`, + outlineOffset: 2, + }, +})); + +const Preview = styled.iframe(({ theme }) => ({ + display: 'block', + width: '100%', + height: '100%', + background: theme.background.preview, + border: 0, + pointerEvents: 'none', +})); + +// Outside PreviewScale so bootstrap scaling does not shrink the loading indicator. +const PreviewLoading = styled.div(({ theme }) => ({ + position: 'absolute', + inset: 0, + zIndex: 1, + display: 'grid', + placeItems: 'center', + background: theme.background.app, + pointerEvents: 'none', +})); + +const ActionBar = styled.div({ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: 8, + minHeight: 36, + marginTop: 'auto', +}); + +const Label = styled.div({ + display: 'flex', + alignItems: 'center', + gap: 4, + flex: 1, + minWidth: 0, + marginLeft: 10, + overflow: 'hidden', +}); + +const LabelComponent = styled.span({ + fontWeight: 700, + whiteSpace: 'nowrap', + flexShrink: 0, + maxWidth: '60%', + overflow: 'hidden', + textOverflow: 'ellipsis', +}); + +const LabelSeparator = styled.span(({ theme }) => ({ + color: theme.textMutedColor, + flexShrink: 0, +})); + +const LabelStory = styled.span({ + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + marginRight: 4, +}); + +const NewBadge = styled(Badge)({ + flexShrink: 0, +}); + +const ReviewAllCell = styled(Cell)({ + display: 'none', +}); + +const ReviewAllShell = styled.div({ + width: '100%', + minWidth: 0, + maxWidth: '100%', + aspectRatio: '3 / 2', + minHeight: 50, +}); + +const ReviewAllFrame = styled.div(({ theme }) => ({ + display: 'grid', + placeItems: 'center', + width: '100%', + height: '100%', + boxSizing: 'border-box', + borderRadius: 6, + background: theme.background.app, + border: `1px dashed ${theme.appBorderColor}`, +})); + +const deriveStoryInfo = (info: StoryInfo): { component: string; name: string } => ({ + component: info.title.split('/').pop() ?? info.title, + name: info.name, +}); + +const StoryPreviewCell: FC<{ + storyId: string; + href?: string; + info: StoryInfo; + getPreviewHref: (storyId: string) => string; + summaryHidden?: boolean; +}> = ({ storyId, href, info, getPreviewHref, summaryHidden = false }) => { + const { + cellRef, + iframeRef, + src, + isPreviewLoading, + rememberedDimensions, + forceStartCurrent, + finishCurrent, + } = usePreviewThumbnail({ storyId, getPreviewHref, summaryHidden }); + + const { component, name } = deriveStoryInfo(info); + const { style: frameStyle, viewportFill } = getPreviewFrameLayout(rememberedDimensions); + + const preview = src ? ( +
    + +
    + ) : null; + + return ( + + + + {isPreviewLoading ? ( + + + + ) : null} + {preview} + + + + + + + ); +}; + +export interface CollectionGridProps { + storyIds: string[]; + getStoryHref?: (storyId: string, storyIndex: number) => string | undefined; + /** Builds the (frozen) preview iframe src for a story thumbnail. */ + getStoryPreviewHref: (storyId: string) => string; + /** Persisted "review all" state from the parent list. */ + showAll?: boolean; + /** Called when the user expands to "Review all". */ + onShowAll?: () => void; + /** Story id → component title + story name, for the cell label. */ + storyInfo: Record; + /** Keep loaded previews mounted while the summary overlay is hidden. */ + summaryHidden?: boolean; +} + +export const CollectionGrid: FC = ({ + storyIds, + getStoryHref, + getStoryPreviewHref, + showAll = false, + onShowAll, + storyInfo, + summaryHidden = false, +}) => ( + + + {storyIds.map((storyId, storyIndex) => { + const info = storyInfo[storyId] ?? fallbackStoryInfo(storyId); + return ( + + ); + })} + + + + + + + + + +); diff --git a/code/core/src/manager/components/review/components/CopyButton.tsx b/code/core/src/manager/components/review/components/CopyButton.tsx new file mode 100644 index 000000000000..002260a2192f --- /dev/null +++ b/code/core/src/manager/components/review/components/CopyButton.tsx @@ -0,0 +1,38 @@ +import type { ReactNode } from 'react'; +import React from 'react'; +import { + Button, + type ButtonProps, + type UseCopyButtonOptions, + useCopyButton, +} from 'storybook/internal/components'; + +export type CopyButtonProps = Omit & + UseCopyButtonOptions; + +export function CopyButton({ + children, + childrenOnCopy, + content, + onCopy, + ariaLabel, + ariaLabelOnCopy, + duration, + ...buttonProps +}: CopyButtonProps) { + const { children: buttonChildren, buttonProps: copyButtonProps } = useCopyButton({ + children, + childrenOnCopy, + content, + onCopy, + ariaLabel, + ariaLabelOnCopy, + duration, + }); + + return ( + + ); +} diff --git a/code/core/src/manager/components/review/components/Markdown.stories.tsx b/code/core/src/manager/components/review/components/Markdown.stories.tsx new file mode 100644 index 000000000000..f209bb17e29d --- /dev/null +++ b/code/core/src/manager/components/review/components/Markdown.stories.tsx @@ -0,0 +1,51 @@ +import { expect, within } from 'storybook/test'; + +import preview from '../../../../../../.storybook/preview.tsx'; +import { Markdown } from './Markdown.tsx'; + +const meta = preview.meta({ + component: Markdown, +}); + +export const Inline = meta.story({ + args: { + children: 'A **bold** word, an *italic* word, and some `inline code`.', + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText('bold').tagName).toBe('STRONG'); + await expect(canvas.getByText('italic').tagName).toBe('EM'); + await expect(canvas.getByText('inline code').tagName).toBe('CODE'); + }, +}); + +export const UnderscoreItalic = meta.story({ + args: { + children: 'An _underscored_ italic word.', + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText('underscored').tagName).toBe('EM'); + }, +}); + +export const Paragraphs = meta.story({ + args: { + children: 'First paragraph.\n\nSecond paragraph.', + }, + play: async ({ canvasElement }) => { + await expect(canvasElement.querySelectorAll('p')).toHaveLength(2); + }, +}); + +export const RawHtmlIsEscaped = meta.story({ + args: { + children: 'Not a tag.', + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + // The angle brackets are rendered as literal text, not parsed into markup. + await expect(canvas.getByText(/Not a tag<\/strong>\./)).toBeInTheDocument(); + await expect(canvasElement.querySelector('strong')).toBeNull(); + }, +}); diff --git a/code/core/src/manager/components/review/components/Markdown.tsx b/code/core/src/manager/components/review/components/Markdown.tsx new file mode 100644 index 000000000000..f0d545301ac0 --- /dev/null +++ b/code/core/src/manager/components/review/components/Markdown.tsx @@ -0,0 +1,63 @@ +import React, { type FC, type ReactNode } from 'react'; + +// A deliberately tiny markdown renderer. The review UI only ever emits a small +// subset of markdown — paragraphs plus inline `**bold**`, `*italic*`/`_italic_`, +// and `` `code` `` — so we render that by hand instead of pulling in a full +// parser like markdown-to-jsx, which adds several megabytes to the bundle. +// +// Raw HTML is intentionally not parsed: any markup in the input is emitted as +// literal text, matching the previous `disableParsingRawHTML: true` behavior. + +// Order matters: bold (`**`) is tried before italic (`*`) so a `**` opener is +// not mistaken for an italic span. Each branch captures its inner content. +// +// A fresh regex is created per call: the parser recurses (e.g. bold containing +// code), and a shared `/g` regex carries mutable `lastIndex` state that the +// inner call would clobber, sending the outer loop into an infinite loop. +const createInlinePattern = () => /(\*\*([\s\S]+?)\*\*|\*([\s\S]+?)\*|_([\s\S]+?)_|`([\s\S]+?)`)/g; + +const parseInline = (text: string): ReactNode[] => { + const nodes: ReactNode[] = []; + const pattern = createInlinePattern(); + let lastIndex = 0; + let key = 0; + let match: RegExpExecArray | null; + + while ((match = pattern.exec(text)) !== null) { + if (match.index > lastIndex) { + nodes.push(text.slice(lastIndex, match.index)); + } + const [token, , bold, italicStar, italicUnderscore, code] = match; + if (bold !== undefined) { + nodes.push({parseInline(bold)}); + } else if (italicStar !== undefined) { + nodes.push({parseInline(italicStar)}); + } else if (italicUnderscore !== undefined) { + nodes.push({parseInline(italicUnderscore)}); + } else if (code !== undefined) { + nodes.push({code}); + } + lastIndex = match.index + token.length; + } + if (lastIndex < text.length) { + nodes.push(text.slice(lastIndex)); + } + return nodes; +}; + +export interface MarkdownProps { + children: string; +} + +export const Markdown: FC = ({ children }) => { + // Blank lines separate paragraphs; single newlines collapse to whitespace, + // matching how a browser lays out inline content. + const paragraphs = children.split(/\n{2,}/).filter((paragraph) => paragraph.trim() !== ''); + return ( + <> + {paragraphs.map((paragraph, index) => ( +

    {parseInline(paragraph)}

    + ))} + + ); +}; diff --git a/code/core/src/manager/components/review/components/ReviewCollectionPicker.tsx b/code/core/src/manager/components/review/components/ReviewCollectionPicker.tsx new file mode 100644 index 000000000000..86fbe6fb09b3 --- /dev/null +++ b/code/core/src/manager/components/review/components/ReviewCollectionPicker.tsx @@ -0,0 +1,128 @@ +import React, { useEffect, useRef, type FC } from 'react'; + +import { styled } from 'storybook/theming'; + +import { + buildReviewStoryHref, + prettifyComponentId, + type ReviewNavEntry, +} from '../review-navigation.ts'; +import { type StoryInfo } from '../review-types.ts'; + +const PopoverList = styled.div(({ theme }) => ({ + display: 'flex', + flexDirection: 'column', + padding: '4px 0', + minWidth: 280, + maxHeight: '60vh', + overflowY: 'auto', + fontFamily: theme.typography.fonts.base, +})); + +const PopoverItem = styled.a<{ $active: boolean }>(({ theme, $active }) => ({ + display: 'flex', + alignItems: 'center', + gap: 10, + padding: '6px 10px', + background: $active ? theme.background.hoverable : 'transparent', + textDecoration: 'none', + color: theme.color.defaultText, + '&:hover': { background: theme.background.hoverable }, + '&:focus-visible': { + outline: `2px solid ${theme.color.secondary}`, + outlineOffset: -2, + }, +})); + +const PopoverItemText = styled.div({ + display: 'flex', + alignItems: 'center', + gap: 4, + flex: 1, + minWidth: 0, + overflow: 'hidden', +}); + +const PopoverItemComponent = styled.span(({ theme }) => ({ + fontWeight: theme.typography.weight.bold, + fontSize: theme.typography.size.s2, + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + flexShrink: 0, + maxWidth: '55%', +})); + +const PopoverItemSep = styled.span(({ theme }) => ({ + color: theme.textMutedColor, + flexShrink: 0, +})); + +const PopoverItemStoryName = styled.span(({ theme }) => ({ + fontSize: theme.typography.size.s2, + color: theme.textMutedColor, + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', +})); + +const derivePopoverLabel = ( + storyId: string, + info?: StoryInfo +): { component: string; story: string } => { + if (info) { + return { component: info.title.split('/').pop() ?? info.title, story: info.name }; + } + const [componentId, ...rest] = storyId.split('--'); + return { + component: prettifyComponentId(componentId), + story: prettifyComponentId(rest.join('--')) || 'Story', + }; +}; + +export interface ReviewCollectionPickerProps { + entries: ReviewNavEntry[]; + storyInfo: Record; + activeEntry: ReviewNavEntry; + onClose: () => void; +} + +export const ReviewCollectionPicker: FC = ({ + entries, + storyInfo, + activeEntry, + onClose, +}) => { + const activeRef = useRef(null); + + useEffect(() => { + activeRef.current?.scrollIntoView({ block: 'nearest' }); + }, [activeEntry.storyId, activeEntry.collectionIndex]); + + return ( + + {entries.map((entry, index) => { + const { component, story } = derivePopoverLabel(entry.storyId, storyInfo[entry.storyId]); + const href = buildReviewStoryHref(entry); + const isActive = + entry.storyId === activeEntry.storyId && + entry.collectionIndex === activeEntry.collectionIndex; + return ( + + + {component} + / + {story} + + + ); + })} + + ); +}; diff --git a/code/core/src/manager/components/review/components/ReviewHeader.tsx b/code/core/src/manager/components/review/components/ReviewHeader.tsx new file mode 100644 index 000000000000..967d8f16ab75 --- /dev/null +++ b/code/core/src/manager/components/review/components/ReviewHeader.tsx @@ -0,0 +1,146 @@ +import React, { type FC, type ReactNode, useEffect, useRef } from 'react'; + +import { styled } from 'storybook/theming'; + +const Root = styled.header<{ $variant: 'page' | 'toolbar' }>(({ theme, $variant }) => ({ + containerType: 'inline-size', + containerName: 'review-header', + display: 'flex', + flexDirection: 'column', + flexShrink: 0, + width: '100%', + background: $variant === 'toolbar' ? theme.barBg : theme.background.content, + color: theme.color.defaultText, + ...($variant === 'page' ? { borderBottom: `1px solid ${theme.appBorderColor}` } : {}), +})); + +const TopRow = styled.div<{ $variant: 'page' | 'toolbar' }>(({ $variant }) => ({ + display: 'flex', + flexWrap: 'wrap', + flexDirection: 'row', + alignItems: 'center', + gap: 8, + padding: $variant === 'toolbar' ? '16px 16px 8px 16px' : '16px', + minHeight: 40, +})); + +const Main = styled.div({ + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + gap: 8, + flex: '1 1 auto', + minWidth: 0, +}); + +const Leading = styled.div({ + display: 'flex', + alignItems: 'center', + alignSelf: 'flex-start', + flexShrink: 0, +}); + +const TextBlock = styled.div({ + display: 'flex', + flexDirection: 'column', + flexGrow: 1, + minWidth: 0, +}); + +const Title = styled.h1(({ theme }) => ({ + margin: '2px 0', + minWidth: 0, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + fontSize: theme.typography.size.m1, + fontWeight: theme.typography.weight.bold, + lineHeight: '24px', + // The heading is only focused programmatically on route change (see + // autoFocusTitle); it is not an interactive control, so suppress the ring. + '&:focus': { + outline: 'none', + }, +})); + +const Subtitle = styled.div(({ theme }) => ({ + display: 'flex', + flexWrap: 'wrap', + alignItems: 'center', + gap: 8, + color: theme.textMutedColor, + fontSize: theme.typography.size.s2, + lineHeight: '20px', +})); + +const Actions = styled.div({ + display: 'flex', + flexWrap: 'wrap', + alignItems: 'center', + justifyContent: 'flex-end', + gap: 6, + flex: '0 1 auto', + marginLeft: 'auto', +}); + +const SecondRow = styled.div({ + display: 'flex', + alignItems: 'center', + gap: 8, + padding: '0 16px 2px 16px', + minHeight: 39, +}); + +export interface ReviewHeaderProps { + /** Optional control rendered before the title (e.g. a back button). */ + leading?: ReactNode; + title: ReactNode; + subtitle?: ReactNode; + /** Trailing cluster on the right of the top row. */ + actions?: ReactNode; + /** Optional full-width second row (e.g. search or comparison controls). */ + secondRow?: ReactNode; + /** + * Move keyboard focus to the title heading on mount. Used on route changes + * (e.g. opening the detail screen) so assistive tech lands on the new view's + * heading instead of being left on the now-unmounted trigger. + */ + autoFocusTitle?: boolean; + /** Compact layout for the preview toolbar header row. */ + variant?: 'page' | 'toolbar'; +} + +export const ReviewHeader: FC = ({ + leading, + title, + subtitle, + actions, + secondRow, + autoFocusTitle = false, + variant = 'page', +}) => { + const titleRef = useRef(null); + useEffect(() => { + if (autoFocusTitle) { + titleRef.current?.focus(); + } + }, [autoFocusTitle]); + + return ( + + +
    + {leading ? {leading} : null} + + + {title} + + {subtitle ? {subtitle} : null} + +
    + {actions ? {actions} : null} +
    + {secondRow ? {secondRow} : null} +
    + ); +}; diff --git a/code/core/src/manager/components/review/components/ReviewNotification.tsx b/code/core/src/manager/components/review/components/ReviewNotification.tsx new file mode 100644 index 000000000000..c88853a4d997 --- /dev/null +++ b/code/core/src/manager/components/review/components/ReviewNotification.tsx @@ -0,0 +1,84 @@ +import React, { useCallback, useLayoutEffect, type FC } from 'react'; + +import { WandIcon } from '@storybook/icons'; + +import { useNavigate } from 'storybook/internal/router'; +import { useStorybookApi, useStorybookState } from 'storybook/manager-api'; +import { reviewAvailableNotificationId } from '../constants.ts'; +import { navigateToReviewSummary } from '../review-actions.ts'; +import { + acceptReviewNotification, + claimNotificationSlot, + pickReviewToNotify, + readCollectionIndex, + shouldAutoAcceptOnRoute, + shouldSkipArrivalNotification, +} from '../review-notification.ts'; +import { reviewStore, useReview } from '../review-store.ts'; +import { useReviewFiltersRef } from '../useReviewFiltersRef.ts'; + +/** Sidebar notification for unseen review pushes. Does not auto-navigate. */ +export const ReviewNotification: FC = () => { + const api = useStorybookApi(); + const navigate = useNavigate(); + const { path, customQueryParams } = useStorybookState(); + const { state: displayed, pendingReview: deferred } = useReview(); + const filtersRef = useReviewFiltersRef(); + const collectionIndex = readCollectionIndex(customQueryParams); + + const openReview = useCallback(() => { + navigateToReviewSummary(api, navigate, filtersRef.current); + }, [api, navigate, filtersRef]); + + const handleNotificationClick = useCallback( + (createdAt: number) => { + const { pendingReview, banner } = reviewStore.getState(); + if (pendingReview?.createdAt === createdAt && banner?.kind === 'pending-update') { + banner.onAccept(); + return; + } + acceptReviewNotification(api, createdAt); + openReview(); + }, + [api, openReview] + ); + + useLayoutEffect(() => { + const review = pickReviewToNotify(displayed, deferred); + if (!review) { + return; + } + + if (shouldAutoAcceptOnRoute(path, collectionIndex, review, displayed, deferred)) { + acceptReviewNotification(api, review.createdAt); + return; + } + + if (shouldSkipArrivalNotification(path, collectionIndex, review, displayed, deferred)) { + return; + } + + const createdAt = review.createdAt; + if ( + createdAt === undefined || + !claimNotificationSlot(api, createdAt, displayed?.createdAt, deferred?.createdAt) + ) { + return; + } + + api.addNotification({ + id: reviewAvailableNotificationId(createdAt), + content: { + headline: 'New review available', + subHeadline: review.title ?? 'Open the curated review to spot-check your changes', + }, + icon: , + onClick: ({ onDismiss }) => { + handleNotificationClick(createdAt); + onDismiss(); + }, + }); + }, [api, collectionIndex, handleNotificationClick, displayed, deferred, path]); + + return null; +}; diff --git a/code/core/src/manager/components/review/components/ReviewPersistentLayer.tsx b/code/core/src/manager/components/review/components/ReviewPersistentLayer.tsx new file mode 100644 index 000000000000..8cd0ae464958 --- /dev/null +++ b/code/core/src/manager/components/review/components/ReviewPersistentLayer.tsx @@ -0,0 +1,30 @@ +import React, { type FC } from 'react'; + +import { ReviewSummaryHost } from '../screens/ReviewSummaryHost.tsx'; +import { useReviewNavigationInterceptor } from '../useReviewNavigationInterceptor.ts'; +import { useReviewShortcuts } from '../useReviewShortcuts.ts'; +import { ReviewNotification } from './ReviewNotification.tsx'; +import { ReviewProvider } from './ReviewProvider.tsx'; + +const ReviewNavigationLayer: FC = () => { + useReviewNavigationInterceptor(); + useReviewShortcuts(); + + return ( + <> + + + + ); +}; + +/** + * Always-mounted review layer, rendered in the Layout's overlay slot. Hosts the review state + * provider, navigation interceptor/shortcuts, and the summary host so review survives story + * navigation. Self-gates: the provider stays dormant until a review is pushed. + */ +export const ReviewPersistentLayer: FC = () => ( + + + +); diff --git a/code/core/src/manager/components/review/components/ReviewProvider.tsx b/code/core/src/manager/components/review/components/ReviewProvider.tsx new file mode 100644 index 000000000000..4e17209229fb --- /dev/null +++ b/code/core/src/manager/components/review/components/ReviewProvider.tsx @@ -0,0 +1,252 @@ +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + type FC, + type ReactNode, +} from 'react'; + +import { useNavigate } from 'storybook/internal/router'; +import type { StatusesByStoryIdAndTypeId } from 'storybook/internal/types'; +import { REVIEW_STATUS_TYPE_ID } from 'storybook/internal/types'; +import { + experimental_getStatusStore, + experimental_useStatusStore, + useChannel, + useStorybookApi, + useStorybookState, +} from 'storybook/manager-api'; + +import { AUTO_ENTERED_SESSION_KEY, EVENTS, PRE_REVIEW_RETURN_KEY } from '../constants.ts'; +import { acceptPendingReview, navigateOutOfReview } from '../review-actions.ts'; +import { enterReviewMode, isReviewModeActive } from '../review-mode.ts'; +import { + REVIEW_COLLECTION_QUERY_PARAM, + buildFlattenedNavEntries, + isReviewReturnSearch, + isReviewSummaryPath, + parseCollectionIndex, + parseStoryIdFromPath, + resolveActiveNavEntry, + resolveNavIndex, +} from '../review-navigation.ts'; +import { clearReviewNotificationsOnDismiss } from '../review-notification.ts'; +import type { ReviewState } from '../review-state.ts'; +import { + clearReviewStatuses, + collectReviewStoryIds, + syncReviewStatuses, +} from '../review-status.ts'; +import { + reviewStore, + useReview, + type ReviewBanner, + type ReviewDerivedState, +} from '../review-store.ts'; +import { buildNewlyAddedStoryIds, buildStoryInfo } from '../review-story-info.ts'; +import { sessionStore } from '../session-store.ts'; +import { useReviewFiltersRef } from '../useReviewFiltersRef.ts'; + +const reviewStatusStore = experimental_getStatusStore(REVIEW_STATUS_TYPE_ID); + +const isDeferredReviewUpdate = (current: ReviewState | null, next: ReviewState): boolean => + current !== null && + current.createdAt !== undefined && + next.createdAt !== undefined && + current.createdAt !== next.createdAt; + +const isSameReviewPayload = (current: ReviewState | null, next: ReviewState): boolean => + current?.createdAt !== undefined && current.createdAt === next.createdAt; + +/** + * Wires channel events to reviewStore actions and keeps the store's derived + * values (index-, status- and route-dependent) up to date. The store owns the + * state; this component is its only React-side writer. + */ +export const ReviewProvider: FC<{ children: ReactNode }> = ({ children }) => { + const previousReviewStoryIdsRef = useRef>(new Set()); + + const api = useStorybookApi(); + const navigate = useNavigate(); + const { index, path, viewMode, customQueryParams, location } = useStorybookState(); + const { state, pendingReview, isStale, isInReviewMode } = useReview(); + // Last review page reported to telemetry; dedupes pageviews across re-renders. + const lastPageviewKeyRef = useRef(null); + + const collectionParam = customQueryParams?.[REVIEW_COLLECTION_QUERY_PARAM] as string | undefined; + + // Current sidebar filters, snapshotted by enterReviewMode and restored on exit. + const filtersRef = useReviewFiltersRef(); + + const emit = useChannel({ + [EVENTS.DISPLAY_REVIEW]: (next: ReviewState) => { + const current = reviewStore.getState().state; + if (isDeferredReviewUpdate(current, next)) { + reviewStore.deferReview(next); + return; + } + // REQUEST_REVIEW replays the cached payload to every tab when another tab + // mounts; ignore identical reviews so summary UI state is not reset. + if (isSameReviewPayload(current, next)) { + reviewStore.setStale(!!next.stale); + return; + } + // A fresh payload re-arms the one-time auto-enter. + sessionStore.remove(AUTO_ENTERED_SESSION_KEY); + reviewStore.displayReview(next); + }, + [EVENTS.REVIEW_STALE]: () => { + reviewStore.setStale(true); + }, + [EVENTS.REVIEW_DISMISSED]: (returnSearch?: string | null) => { + clearReviewStatuses(reviewStatusStore); + previousReviewStoryIdsRef.current = new Set(); + sessionStore.remove(AUTO_ENTERED_SESSION_KEY); + const { state: displayed, pendingReview: deferred } = reviewStore.getState(); + clearReviewNotificationsOnDismiss(api, displayed, deferred); + reviewStore.clearReview(); + void navigateOutOfReview(api, navigate, returnSearch, { recordVisit: false }); + }, + }); + + useEffect(() => { + emit(EVENTS.REQUEST_REVIEW); + }, [emit]); + + // Tag every story in the active review so the sidebar shows reviewing status + // and the Quick review widget can count them. Filtering is owned by review mode. + useEffect(() => { + if (!state) { + return; + } + const storyIds = collectReviewStoryIds(state); + previousReviewStoryIdsRef.current = syncReviewStatuses( + reviewStatusStore, + storyIds, + previousReviewStoryIdsRef.current + ); + }, [state]); + + const flattenedEntries = useMemo(() => (state ? buildFlattenedNavEntries(state) : []), [state]); + + const allStatuses = experimental_useStatusStore() as StatusesByStoryIdAndTypeId; + const newlyAddedStoryIds = useMemo( + () => (state ? buildNewlyAddedStoryIds(state, allStatuses) : new Set()), + [allStatuses, state] + ); + + const storyInfo = useMemo( + () => (state ? buildStoryInfo(state, index, api, allStatuses, newlyAddedStoryIds) : {}), + [allStatuses, api, index, newlyAddedStoryIds, state] + ); + + const collectionIndex = parseCollectionIndex(collectionParam); + const storyIdFromPath = parseStoryIdFromPath(path); + const activeEntry = + state && storyIdFromPath + ? resolveActiveNavEntry(flattenedEntries, storyIdFromPath, collectionIndex) + : null; + const activeIndex = activeEntry ? resolveNavIndex(flattenedEntries, activeEntry) : -1; + + const isSummaryVisible = isReviewSummaryPath(path); + + const onAcceptPendingUpdate = useCallback(() => { + acceptPendingReview(api, navigate, filtersRef.current); + }, [api, navigate, filtersRef]); + + // Pending-update outranks stale: accepting the update supersedes the warning. + const banner = useMemo( + () => + pendingReview !== null + ? { kind: 'pending-update', onAccept: onAcceptPendingUpdate } + : isStale + ? { kind: 'stale' } + : null, + [pendingReview, isStale, onAcceptPendingUpdate] + ); + + // Report a "pageview" whenever the active review surface changes: the summary + // overlay, or a specific reviewed story's detail view. Keyed so re-renders that + // don't change the surface (or story) don't re-fire. + useEffect(() => { + if (!state) { + lastPageviewKeyRef.current = null; + return; + } + let page: 'summary' | 'detail' | null = null; + let key: string | null = null; + if (isSummaryVisible) { + page = 'summary'; + key = 'summary'; + } else if (isInReviewMode && activeEntry) { + page = 'detail'; + key = `detail:${activeEntry.storyId}`; + } + if (!page || key === lastPageviewKeyRef.current) { + return; + } + lastPageviewKeyRef.current = key; + emit(EVENTS.PAGEVIEW, { page, reviewCreatedAt: state.createdAt }); + }, [state, isSummaryVisible, isInReviewMode, activeEntry, emit]); + + // First landing on the summary with a clean, newly available review enters + // review mode once. Deduplicated so reloads and post-exit returns don't re-enter. + useEffect(() => { + if (!state || !isSummaryVisible || isReviewModeActive()) { + return; + } + if (reviewStore.getState().isExiting) { + return; + } + if (sessionStore.read(AUTO_ENTERED_SESSION_KEY) === '1') { + return; + } + sessionStore.write(AUTO_ENTERED_SESSION_KEY, '1'); + void enterReviewMode(api, filtersRef.current); + }, [state, isSummaryVisible, api, filtersRef]); + + // Remember the last canvas search outside review mode so leaving review can + // return to the pre-review canvas (both summary back and dismiss). + useEffect(() => { + if (isInReviewMode) { + return; + } + if (viewMode !== 'story' && viewMode !== 'docs') { + return; + } + const search = location?.search; + if (search && !isReviewReturnSearch(search)) { + sessionStore.write(PRE_REVIEW_RETURN_KEY, search); + } + }, [isInReviewMode, viewMode, location?.search]); + + const derived = useMemo( + () => ({ + storyInfo, + flattenedEntries, + newlyAddedStoryIds, + activeEntry, + activeIndex, + isSummaryVisible, + banner, + }), + [ + storyInfo, + flattenedEntries, + newlyAddedStoryIds, + activeEntry, + activeIndex, + isSummaryVisible, + banner, + ] + ); + + // Sync before paint so toolbar surfaces read current route on first frame. + useLayoutEffect(() => { + reviewStore.setDerived(derived); + }, [derived]); + + return children; +}; diff --git a/code/core/src/manager/components/review/components/ReviewToolbarHeader.stories.tsx b/code/core/src/manager/components/review/components/ReviewToolbarHeader.stories.tsx new file mode 100644 index 000000000000..1a6011d3de75 --- /dev/null +++ b/code/core/src/manager/components/review/components/ReviewToolbarHeader.stories.tsx @@ -0,0 +1,239 @@ +import type { ReactNode } from 'react'; + +import { expect, fn, within } from 'storybook/test'; + +import { MemoryRouter } from 'storybook/internal/router'; +import { + ManagerContext, + internal_fullStatusStore, + type API, + type State, +} from 'storybook/manager-api'; + +import preview from '../../../../../../.storybook/preview.tsx'; +import { ADDON_ID, EVENTS } from '../constants.ts'; +import { buildReviewChangesSummaryHref, buildReviewStoryHref } from '../review-navigation.ts'; +import type { ReviewState } from '../review-state.ts'; +import { reviewStore } from '../review-store.ts'; +import { useReviewShortcuts } from '../useReviewShortcuts.ts'; +import { ReviewProvider } from './ReviewProvider.tsx'; +import { ReviewToolbarHeader } from './ReviewToolbarHeader.tsx'; + +type EventListener = (payload?: unknown) => void; + +const eventListeners = new Map>(); +const removeEventListener = (eventName: string, listener: EventListener) => { + eventListeners.get(eventName)?.delete(listener); +}; +const onMock = fn((eventName: string, listener: EventListener): (() => void) => { + if (!eventListeners.has(eventName)) { + eventListeners.set(eventName, new Set()); + } + eventListeners.get(eventName)?.add(listener); + return () => removeEventListener(eventName, listener); +}); +const offMock = fn((eventName: string, listener: EventListener) => { + removeEventListener(eventName, listener); +}); +const emitMock = fn((eventName: string, payload?: unknown) => { + eventListeners.get(eventName)?.forEach((listener) => { + listener(payload); + }); +}); +const toggleNavMock = fn(); +const setAddonShortcutMock = fn(); + +const reviewState: ReviewState = { + title: 'Manager settings polish', + description: 'Updated settings views and spacing.', + createdAt: Date.now(), + collections: [ + { + title: 'Settings', + rationale: 'Primary settings surfaces changed.', + storyIds: [ + 'manager-settings-checklist--default', + 'manager-settings-guidepage--default', + 'manager-settings-aboutscreen--default', + ], + }, + ], +}; + +const applyReviewState = () => { + expect(onMock).toHaveBeenCalledWith(EVENTS.DISPLAY_REVIEW, expect.any(Function)); + emitMock(EVENTS.DISPLAY_REVIEW, reviewState); +}; + +const managerStateBase: State = { + index: { + 'manager-settings-checklist--default': { + type: 'story', + id: 'manager-settings-checklist--default', + title: 'Manager/Settings/Checklist', + name: 'Default', + }, + 'manager-settings-guidepage--default': { + type: 'story', + id: 'manager-settings-guidepage--default', + title: 'Manager/Settings/Guide Page', + name: 'Default', + }, + 'manager-settings-aboutscreen--default': { + type: 'story', + id: 'manager-settings-aboutscreen--default', + title: 'Manager/Settings/About Screen', + name: 'Default', + }, + }, +} as unknown as State; + +const managerApi: API = { + on: onMock, + off: offMock, + emit: emitMock, + getIsNavShown: () => true, + getIsPanelShown: () => true, + toggleNav: toggleNavMock, + togglePanel: fn().mockName('api::togglePanel'), + setAddonShortcut: setAddonShortcutMock, + setQueryParams: fn(), + setAllTagFilters: fn().mockName('api::setAllTagFilters'), + setAllStatusFilters: fn().mockName('api::setAllStatusFilters'), + resetStatusFilters: fn().mockName('api::resetStatusFilters'), + addStatusFilters: fn().mockName('api::addStatusFilters'), + removeStatusFilters: fn().mockName('api::removeStatusFilters'), + getStoryHrefs: (storyId: string, options?: { embed?: boolean; freeze?: boolean }) => ({ + managerHref: `?path=/story/${storyId}`, + previewHref: `iframe.html?id=${storyId}&viewMode=story${options?.embed ? '&embed=true' : ''}${options?.freeze ? '&freeze=finished' : ''}`, + }), +} as unknown as API; + +const meta = preview.meta({ + component: ReviewToolbarHeader, + parameters: { layout: 'fullscreen' }, + decorators: [ + (Story, { parameters }) => ( + + + + + + + + + + ), + ], + beforeEach: () => { + reviewStore.reset(); + eventListeners.clear(); + onMock.mockReset(); + offMock.mockReset(); + emitMock.mockReset(); + toggleNavMock.mockReset(); + setAddonShortcutMock.mockReset(); + sessionStorage.clear(); + internal_fullStatusStore.unset(); + }, +}); + +const ReviewShortcutsHarness = ({ children }: { children: ReactNode }) => { + useReviewShortcuts(); + return children; +}; + +export const OnReviewedStory = meta.story({ + parameters: { + routerInitialEntries: ['/?path=/story/manager-settings-guidepage--default&collection=0'], + managerState: { + path: '/story/manager-settings-guidepage--default', + viewMode: 'story', + customQueryParams: { collection: '0' }, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + applyReviewState(); + + const counter = await canvas.findByRole('button', { name: 'Open story list' }); + await expect(counter).toHaveTextContent('2/3'); + await expect(setAddonShortcutMock).toHaveBeenCalledWith( + ADDON_ID, + expect.objectContaining({ + actionName: 'reviewNextStory', + defaultShortcut: ['ArrowRight'], + }) + ); + await expect(await canvas.findByRole('heading', { name: 'Settings' })).toBeInTheDocument(); + await expect(await canvas.findByRole('link', { name: 'Back to review' })).toHaveAttribute( + 'href', + buildReviewChangesSummaryHref() + ); + await expect(canvas.queryByText('New')).not.toBeInTheDocument(); + }, +}); + +export const Progress = meta.story({ + parameters: { + routerInitialEntries: ['/?path=/story/manager-settings-aboutscreen--default&collection=0'], + managerState: { + path: '/story/manager-settings-aboutscreen--default', + viewMode: 'story', + customQueryParams: { collection: '0' }, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + applyReviewState(); + + const counter = await canvas.findByRole('button', { name: 'Open story list' }); + await expect(counter).toHaveTextContent('3/3'); + const fill = await canvas.findByTestId('review-progress-fill'); + await expect(Math.round(parseFloat(fill.style.width))).toBe(100); + }, +}); + +export const NewStory = meta.story({ + parameters: { + routerInitialEntries: ['/?path=/story/manager-settings-checklist--default&collection=0'], + managerState: { + path: '/story/manager-settings-checklist--default', + viewMode: 'story', + customQueryParams: { collection: '0' }, + }, + }, + // A story is "newly added" when change detection reports it as new. + beforeEach: () => { + internal_fullStatusStore.set([ + { + storyId: 'manager-settings-checklist--default', + typeId: 'storybook/change-detection', + value: 'status-value:new', + title: 'Change Detection', + description: '', + }, + ]); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + applyReviewState(); + + await expect(await canvas.findByText('New')).toBeInTheDocument(); + await expect(await canvas.findByRole('link', { name: 'Next story' })).toHaveAttribute( + 'href', + buildReviewStoryHref({ + collectionIndex: 0, + storyId: 'manager-settings-guidepage--default', + }) + ); + }, +}); diff --git a/code/core/src/manager/components/review/components/ReviewToolbarHeader.tsx b/code/core/src/manager/components/review/components/ReviewToolbarHeader.tsx new file mode 100644 index 000000000000..f86a6d8848ee --- /dev/null +++ b/code/core/src/manager/components/review/components/ReviewToolbarHeader.tsx @@ -0,0 +1,200 @@ +import React, { type FC } from 'react'; + +import { Badge, Button, Popover, WithTooltip } from 'storybook/internal/components'; +import { styled } from 'storybook/theming'; + +import { ChevronSmallLeftIcon, ChevronSmallRightIcon, WandIcon } from '@storybook/icons'; + +import { + buildReviewChangesSummaryHref, + buildReviewStoryHref, + getAdjacentReviewEntries, +} from '../review-navigation.ts'; +import { useReview } from '../review-store.ts'; +import { AttentionBanner } from './AttentionBanner.tsx'; +import { ReviewCollectionPicker } from './ReviewCollectionPicker.tsx'; +import { ReviewHeader } from './ReviewHeader.tsx'; + +const Root = styled.div(({ theme }) => ({ + display: 'flex', + flexDirection: 'column', + flexShrink: 0, + width: '100%', + background: theme.background.content, + zIndex: 4, +})); + +const HeaderWrap = styled.div({ + position: 'relative', + flexShrink: 0, +}); + +const ProgressBar = styled.div(({ theme }) => ({ + position: 'absolute', + top: 0, + left: 0, + zIndex: 1, + width: '100%', + height: 3, + overflow: 'hidden', + background: theme.background.app, +})); + +const ProgressFill = styled.div(({ theme }) => ({ + position: 'absolute', + insetBlock: 0, + left: 0, + background: theme.color.secondary, + transition: 'width 200ms ease', +})); + +const SubtitleStrong = styled.span(({ theme }) => ({ + fontWeight: 700, + color: theme.color.defaultText, +})); + +const SubtitleSeparator = styled.span(({ theme }) => ({ + color: theme.color.defaultText, +})); + +const SubtitleText = styled.span(({ theme }) => ({ + color: theme.color.defaultText, +})); + +const Counter = styled(Button)(({ theme }) => ({ + fontVariantNumeric: 'tabular-nums', + fontFamily: theme.typography.fonts.mono, + fontWeight: theme.typography.weight.regular, +})); + +const componentName = (componentTitle: string): string => + componentTitle + .split('/') + .map((part) => part.trim()) + .filter(Boolean) + .pop() ?? componentTitle; + +export const ReviewToolbarHeader: FC = () => { + const { + state, + banner, + storyInfo, + flattenedEntries, + newlyAddedStoryIds, + activeEntry, + activeIndex, + } = useReview(); + + if (!state || !activeEntry || activeIndex < 0) { + return null; + } + + const collection = state.collections[activeEntry.collectionIndex]; + const collectionTitle = collection?.title ?? 'Review'; + const totalStories = flattenedEntries.length; + const neighbors = getAdjacentReviewEntries(flattenedEntries, activeIndex); + const previousEntry = neighbors?.previous ?? activeEntry; + const nextEntry = neighbors?.next ?? activeEntry; + const progress = totalStories > 1 ? activeIndex / (totalStories - 1) : 0; + const currentStoryInfo = storyInfo[activeEntry.storyId]; + const isNewlyAdded = newlyAddedStoryIds.has(activeEntry.storyId); + + const metadataSubtitle = + currentStoryInfo?.title && currentStoryInfo.name ? ( + <> + {componentName(currentStoryInfo.title)} + / + {currentStoryInfo.name} + + ) : null; + + const subtitle = + metadataSubtitle || isNewlyAdded ? ( + <> + {metadataSubtitle} + {isNewlyAdded ? New : null} + + ) : undefined; + + const counter = + totalStories > 0 ? ( + ( + + + + )} + > + + {activeIndex + 1}/{totalStories} + + + ) : ( + + {activeIndex + 1}/{totalStories} + + ); + + return ( + + {banner && } + + + + + + + + + + + } + title={collectionTitle} + subtitle={subtitle} + actions={ + <> + {counter} + + + + } + /> + + + ); +}; diff --git a/code/core/src/manager/components/review/components/previewScheduler.ts b/code/core/src/manager/components/review/components/previewScheduler.ts new file mode 100644 index 000000000000..0af1623cc347 --- /dev/null +++ b/code/core/src/manager/components/review/components/previewScheduler.ts @@ -0,0 +1,73 @@ +// Caps concurrent preview iframe boots across the review grid: booting many +// embed iframes at once can fail with `net::ERR_INSUFFICIENT_RESOURCES`. +const MAX_CONCURRENT_PREVIEWS = 3; + +/** + * How long a started preview may hold its concurrency slot. The iframe's load/error events release + * the slot earlier; this deadline keeps the queue draining when neither fires. + */ +export const PREVIEW_SETTLE_TIMEOUT_MS = 1500; + +/** Handle for a scheduled preview boot, returned by `enqueuePreview`. */ +export interface PreviewHandle { + /** Hover/focus: start a still-queued preview right away, bypassing the cap. */ + forceStart: () => void; + /** Release the concurrency slot (iframe loaded/errored/unmounted). Idempotent. */ + release: () => void; +} + +interface Task { + start: () => void; + state: 'queued' | 'started' | 'released'; + deadline?: ReturnType; +} + +let activePreviewLoads = 0; +const previewQueue: Task[] = []; + +function startTask(task: Task): void { + task.state = 'started'; + activePreviewLoads += 1; + task.deadline = setTimeout(() => releaseTask(task), PREVIEW_SETTLE_TIMEOUT_MS); + task.start(); +} + +function startQueuedPreviews(): void { + while (activePreviewLoads < MAX_CONCURRENT_PREVIEWS && previewQueue.length > 0) { + startTask(previewQueue.shift()!); + } +} + +function releaseTask(task: Task): void { + if (task.state === 'released') { + return; + } + if (task.state === 'started') { + clearTimeout(task.deadline); + activePreviewLoads -= 1; + } else { + previewQueue.splice(previewQueue.indexOf(task), 1); + } + task.state = 'released'; + startQueuedPreviews(); +} + +/** + * Queue a preview boot. `start` runs (synchronously or later) once a concurrency slot frees up. The + * slot is held until `release()` or the settle deadline, whichever comes first. + */ +export function enqueuePreview(start: () => void): PreviewHandle { + const task: Task = { start, state: 'queued' }; + previewQueue.push(task); + startQueuedPreviews(); + return { + forceStart: () => { + if (task.state !== 'queued') { + return; + } + previewQueue.splice(previewQueue.indexOf(task), 1); + startTask(task); + }, + release: () => releaseTask(task), + }; +} diff --git a/code/core/src/manager/components/review/components/previewThumbnailState.test.ts b/code/core/src/manager/components/review/components/previewThumbnailState.test.ts new file mode 100644 index 000000000000..32786ea27a35 --- /dev/null +++ b/code/core/src/manager/components/review/components/previewThumbnailState.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from 'vitest'; + +import { + initialThumbnailState, + isThumbnailLoading, + thumbnailReducer, + type ThumbnailEvent, + type ThumbnailState, +} from './previewThumbnailState.ts'; + +const run = (events: ThumbnailEvent[], from: ThumbnailState = initialThumbnailState) => + events.reduce(thumbnailReducer, from); + +const dimensions = { width: 320, height: 240 }; +const otherDimensions = { width: 640, height: 480 }; + +describe('thumbnailReducer', () => { + it('walks the happy path idle → queued → booting → measured → settled', () => { + let state = initialThumbnailState; + expect(isThumbnailLoading(state)).toBe(false); + + state = thumbnailReducer(state, { type: 'enqueued' }); + expect(state.phase).toBe('queued'); + expect(state.src).toBeUndefined(); + expect(isThumbnailLoading(state)).toBe(true); + + state = thumbnailReducer(state, { type: 'started', src: 'iframe.html?id=a' }); + expect(state.phase).toBe('booting'); + expect(state.src).toBe('iframe.html?id=a'); + expect(state.dimensions).toBeNull(); + expect(isThumbnailLoading(state)).toBe(true); + + state = thumbnailReducer(state, { type: 'resized', dimensions }); + expect(state.phase).toBe('measured'); + expect(state.dimensions).toEqual(dimensions); + expect(isThumbnailLoading(state)).toBe(true); + + state = thumbnailReducer(state, { type: 'settled' }); + expect(state.phase).toBe('settled'); + expect(state.dimensions).toEqual(dimensions); + expect(isThumbnailLoading(state)).toBe(false); + }); + + it('settles from booting when the fallback deadline passes without a resize', () => { + const state = run([{ type: 'enqueued' }, { type: 'started', src: 'a' }, { type: 'settled' }]); + expect(state.phase).toBe('settled'); + expect(state.dimensions).toBeNull(); + }); + + it('updates dimensions on late resizes without re-showing the spinner', () => { + const settled = run([ + { type: 'enqueued' }, + { type: 'started', src: 'a' }, + { type: 'resized', dimensions }, + { type: 'settled' }, + ]); + const state = thumbnailReducer(settled, { type: 'resized', dimensions: otherDimensions }); + expect(state.phase).toBe('settled'); + expect(state.dimensions).toEqual(otherDimensions); + expect(isThumbnailLoading(state)).toBe(false); + }); + + it('ignores duplicate resize payloads once settled', () => { + const settled = run([ + { type: 'enqueued' }, + { type: 'started', src: 'a' }, + { type: 'resized', dimensions }, + { type: 'settled' }, + ]); + expect(thumbnailReducer(settled, { type: 'resized', dimensions: { ...dimensions } })).toBe( + settled + ); + }); + + it('keeps state identity for equal-dimension resizes while measured', () => { + const measured = run([ + { type: 'enqueued' }, + { type: 'started', src: 'a' }, + { type: 'resized', dimensions }, + ]); + expect(thumbnailReducer(measured, { type: 'resized', dimensions: { ...dimensions } })).toBe( + measured + ); + }); + + it('ignores stale resize and settled events while idle or queued', () => { + expect(thumbnailReducer(initialThumbnailState, { type: 'resized', dimensions })).toBe( + initialThumbnailState + ); + expect(thumbnailReducer(initialThumbnailState, { type: 'settled' })).toBe( + initialThumbnailState + ); + const queued = run([{ type: 'enqueued' }]); + expect(thumbnailReducer(queued, { type: 'resized', dimensions })).toBe(queued); + expect(thumbnailReducer(queued, { type: 'settled' })).toBe(queued); + }); + + it('ignores started unless queued', () => { + const settled = run([{ type: 'enqueued' }, { type: 'started', src: 'a' }, { type: 'settled' }]); + expect(thumbnailReducer(settled, { type: 'started', src: 'b' })).toBe(settled); + }); + + it('evicts any phase back to idle, clearing src and dimensions', () => { + const settled = run([ + { type: 'enqueued' }, + { type: 'started', src: 'a' }, + { type: 'resized', dimensions }, + { type: 'settled' }, + ]); + const state = thumbnailReducer(settled, { type: 'evicted' }); + expect(state.phase).toBe('idle'); + expect(state.src).toBeUndefined(); + expect(state.dimensions).toBeNull(); + expect(isThumbnailLoading(state)).toBe(false); + expect(thumbnailReducer(state, { type: 'evicted' })).toBe(state); + }); + + it('re-enqueues a settled cell keeping the old src but dropping stale dimensions', () => { + const settled = run([ + { type: 'enqueued' }, + { type: 'started', src: 'a' }, + { type: 'resized', dimensions }, + { type: 'settled' }, + ]); + const state = thumbnailReducer(settled, { type: 'enqueued' }); + expect(state.phase).toBe('queued'); + expect(state.src).toBe('a'); + expect(state.dimensions).toBeNull(); + expect(isThumbnailLoading(state)).toBe(true); + }); + + it('increments bootId on every start, surviving eviction', () => { + const first = run([{ type: 'enqueued' }, { type: 'started', src: 'a' }]); + expect(first.bootId).toBe(1); + const second = run( + [{ type: 'evicted' }, { type: 'enqueued' }, { type: 'started', src: 'a' }], + first + ); + expect(second.bootId).toBe(2); + }); +}); diff --git a/code/core/src/manager/components/review/components/previewThumbnailState.ts b/code/core/src/manager/components/review/components/previewThumbnailState.ts new file mode 100644 index 000000000000..fec94bd5f59e --- /dev/null +++ b/code/core/src/manager/components/review/components/previewThumbnailState.ts @@ -0,0 +1,94 @@ +import { + iframeResizeDimensionsEqual, + type IframeResizeDimensions, +} from '../../../../shared/constants/iframe-resize.ts'; + +/** + * Lifecycle of one review thumbnail cell: + * + * - `idle`: not near the viewport; nothing rendered + * - `queued`: waiting for a scheduler concurrency slot; spinner shown + * - `booting`: slot granted, iframe src assigned; waiting for iframe.resize + * - `measured`: content dimensions arrived; waiting for the new scale to paint + * - `settled`: scale painted (or the fallback deadline passed); spinner hidden + * + * Eviction returns any phase to `idle`. + */ +export type ThumbnailPhase = 'idle' | 'queued' | 'booting' | 'measured' | 'settled'; + +export type ThumbnailState = { + phase: ThumbnailPhase; + src: string | undefined; + dimensions: IframeResizeDimensions | null; + /** Increments per boot (src assignment); keys the spinner fallback deadline. */ + bootId: number; +}; + +export type ThumbnailEvent = + | { type: 'enqueued' } + | { type: 'started'; src: string } + | { type: 'resized'; dimensions: IframeResizeDimensions } + | { type: 'settled' } + | { type: 'evicted' }; + +export const initialThumbnailState: ThumbnailState = { + phase: 'idle', + src: undefined, + dimensions: null, + bootId: 0, +}; + +/** The spinner covers the frame from enqueue until the measured scale has painted. */ +export const isThumbnailLoading = ({ phase }: ThumbnailState): boolean => + phase === 'queued' || phase === 'booting' || phase === 'measured'; + +export const thumbnailReducer = (state: ThumbnailState, event: ThumbnailEvent): ThumbnailState => { + switch (event.type) { + case 'enqueued': + // Keep the old src: the previous iframe stays mounted (hidden behind the + // spinner) until the scheduler grants a slot and swaps it out. + return { ...state, phase: 'queued', dimensions: null }; + + case 'started': + if (state.phase !== 'queued') { + return state; + } + return { ...state, phase: 'booting', src: event.src, bootId: state.bootId + 1 }; + + case 'resized': { + if (state.phase === 'booting' || state.phase === 'measured') { + const unchanged = iframeResizeDimensionsEqual(state.dimensions, event.dimensions); + if (state.phase === 'measured' && unchanged) { + return state; + } + return { + ...state, + phase: 'measured', + dimensions: unchanged ? state.dimensions : event.dimensions, + }; + } + if (state.phase === 'settled') { + // Late re-measures update the scale but never re-show the spinner. + return iframeResizeDimensionsEqual(state.dimensions, event.dimensions) + ? state + : { ...state, dimensions: event.dimensions }; + } + // idle/queued: stale message from an evicted or superseded iframe. + return state; + } + + case 'settled': + // From `booting` this is the fallback deadline (no resize ever arrived); + // ignored in other phases (stale rAF or timer). + if (state.phase === 'booting' || state.phase === 'measured') { + return { ...state, phase: 'settled' }; + } + return state; + + case 'evicted': + if (state.phase === 'idle') { + return state; + } + return { ...initialThumbnailState, bootId: state.bootId }; + } +}; diff --git a/code/core/src/manager/components/review/components/usePreviewThumbnail.ts b/code/core/src/manager/components/review/components/usePreviewThumbnail.ts new file mode 100644 index 000000000000..01a7f2015872 --- /dev/null +++ b/code/core/src/manager/components/review/components/usePreviewThumbnail.ts @@ -0,0 +1,232 @@ +import { + useCallback, + useEffect, + useLayoutEffect, + useReducer, + useRef, + useState, + type RefObject, +} from 'react'; + +import { + IFRAME_RESIZE_REQUEST_CONTEXT, + parseIframeResizeMessage, + type IframeResizeDimensions, +} from '../../../../shared/constants/iframe-resize.ts'; +import { + PREVIEW_SETTLE_TIMEOUT_MS, + enqueuePreview, + type PreviewHandle, +} from './previewScheduler.ts'; +import { + initialThumbnailState, + isThumbnailLoading, + thumbnailReducer, +} from './previewThumbnailState.ts'; + +/** + * If iframe.resize never arrives, clear the spinner anyway. 3× the scheduler slot deadline so even + * a boot that spent a full queue round waiting still gets time to report. + */ +const PREVIEW_SCALE_SETTLE_FALLBACK_MS = PREVIEW_SETTLE_TIMEOUT_MS * 3; + +// IntersectionObserver `rootMargin`s for the preview lifecycle: mount a cell's +// iframe well before it scrolls into view, evict it only much further out. +// The gap is hysteresis so scrolling near a boundary doesn't thrash. +const PREVIEW_MOUNT_ROOT_MARGIN = '50% 0px'; +const PREVIEW_EVICT_ROOT_MARGIN = '150% 0px'; + +// Percentage rootMargins are relative to the observer root, so anchor the +// observers to the nearest scroll container rather than the window. +const getScrollRoot = (element: HTMLElement): HTMLElement | null => { + const radixViewport = element.closest('[data-radix-scroll-area-viewport]'); + if (radixViewport) { + return radixViewport; + } + let current = element.parentElement; + while (current) { + const { overflowY } = window.getComputedStyle(current); + if (overflowY === 'auto' || overflowY === 'scroll') { + return current; + } + current = current.parentElement; + } + return null; +}; + +export type UsePreviewThumbnailOptions = { + storyId: string; + getPreviewHref: (storyId: string) => string; + summaryHidden?: boolean; +}; + +export type UsePreviewThumbnailResult = { + cellRef: RefObject; + iframeRef: RefObject; + src: string | undefined; + /** True until iframe.resize arrives and the measured scale has painted. */ + isPreviewLoading: boolean; + rememberedDimensions: IframeResizeDimensions | null; + forceStartCurrent: () => void; + finishCurrent: () => void; +}; + +export const usePreviewThumbnail = ({ + storyId, + getPreviewHref, + summaryHidden = false, +}: UsePreviewThumbnailOptions): UsePreviewThumbnailResult => { + const cellRef = useRef(null); + const iframeRef = useRef(null); + const [isInView, setIsInView] = useState(false); + const [state, dispatch] = useReducer(thumbnailReducer, initialThumbnailState); + const handleRef = useRef(null); + const settleRafRef = useRef({ raf1: 0, raf2: 0 }); + + const { src, bootId } = state; + + // Visibility: mount near the viewport, evict far outside it. While the summary + // overlay is hidden (`summaryHidden`), the whole subsystem freezes so loaded + // previews are neither evicted nor re-observed. + useEffect(() => { + const host = cellRef.current; + if (!host || summaryHidden) { + return undefined; + } + const scrollRoot = getScrollRoot(host); + const root = scrollRoot && scrollRoot.clientHeight > 0 ? scrollRoot : null; + const mountObserver = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + setIsInView(true); + } + }, + { root, rootMargin: PREVIEW_MOUNT_ROOT_MARGIN } + ); + const evictObserver = new IntersectionObserver( + ([entry]) => { + if (!entry.isIntersecting) { + setIsInView(false); + } + }, + { root, rootMargin: PREVIEW_EVICT_ROOT_MARGIN } + ); + mountObserver.observe(host); + evictObserver.observe(host); + return () => { + mountObserver.disconnect(); + evictObserver.disconnect(); + }; + }, [summaryHidden]); + + useEffect(() => { + if (!isInView) { + dispatch({ type: 'evicted' }); + } + }, [isInView]); + + // Queue a boot per (visible cell × preview href); the scheduler caps how + // many iframes load at once and releases slots on load/error or deadline. + useEffect(() => { + if (!isInView) { + return undefined; + } + const previewHref = getPreviewHref(storyId); + dispatch({ type: 'enqueued' }); + const handle = enqueuePreview(() => { + dispatch({ type: 'started', src: previewHref }); + }); + handleRef.current = handle; + return () => { + handle.release(); + handleRef.current = null; + }; + }, [isInView, storyId, getPreviewHref]); + + useEffect(() => { + if (!src) { + return undefined; + } + const timer = setTimeout(() => dispatch({ type: 'settled' }), PREVIEW_SCALE_SETTLE_FALLBACK_MS); + return () => clearTimeout(timer); + }, [bootId, src]); + + const handleResize = useCallback((dimensions: IframeResizeDimensions) => { + dispatch({ type: 'resized', dimensions }); + // Double rAF: let the new scale paint before the spinner clears. Stale + // callbacks are harmless — the reducer ignores `settled` outside a boot. + cancelAnimationFrame(settleRafRef.current.raf1); + cancelAnimationFrame(settleRafRef.current.raf2); + settleRafRef.current.raf1 = requestAnimationFrame(() => { + settleRafRef.current.raf2 = requestAnimationFrame(() => { + dispatch({ type: 'settled' }); + }); + }); + }, []); + + useEffect(() => { + const rafs = settleRafRef.current; + return () => { + cancelAnimationFrame(rafs.raf1); + cancelAnimationFrame(rafs.raf2); + }; + }, []); + + useLayoutEffect(() => { + if (!src) { + return undefined; + } + const iframe = iframeRef.current; + if (!iframe) { + return undefined; + } + + const onMessage = (event: MessageEvent) => { + // Guard against null === null when the iframe is detached; cross-origin + // sources are WindowProxy objects, but identity comparison still works. + if (event.source === null || event.source !== iframe.contentWindow) { + return; + } + const dimensions = parseIframeResizeMessage(event.data); + if (dimensions) { + handleResize(dimensions); + } + }; + window.addEventListener('message', onMessage); + + const requestRemeasure = () => { + try { + iframe.contentWindow?.postMessage( + JSON.stringify({ context: IFRAME_RESIZE_REQUEST_CONTEXT }), + '*' + ); + } catch { + // Cross-origin or detached iframe; ignore. + } + }; + requestRemeasure(); + iframe.addEventListener('load', requestRemeasure); + return () => { + window.removeEventListener('message', onMessage); + iframe.removeEventListener('load', requestRemeasure); + }; + }, [src, handleResize]); + + const finishCurrent = useCallback(() => { + handleRef.current?.release(); + }, []); + + const forceStartCurrent = useCallback(() => { + handleRef.current?.forceStart(); + }, []); + + return { + cellRef, + iframeRef, + src, + isPreviewLoading: isThumbnailLoading(state), + rememberedDimensions: state.dimensions, + forceStartCurrent, + finishCurrent, + }; +}; diff --git a/code/core/src/manager/components/review/constants.ts b/code/core/src/manager/components/review/constants.ts new file mode 100644 index 000000000000..7cc60ac32819 --- /dev/null +++ b/code/core/src/manager/components/review/constants.ts @@ -0,0 +1,33 @@ +// Import the core-owned ingest contract from its source module (a relative path, +// not the `storybook/internal/types` self-package barrel) so the manager bundle +// gets a live binding and avoids a circular self-import. Re-aliased here so the +// manager and the external `@storybook/addon-mcp` producer agree on the names. +import { REVIEW_EVENTS, REVIEW_NAMESPACE } from '../../../shared/review/index.ts'; + +export { REVIEW_NAMESPACE as ADDON_ID, REVIEW_EVENTS as EVENTS }; + +export const PAGE_ID = `${REVIEW_NAMESPACE}/page`; +export const REVIEW_CHANGES_URL = '/review/'; + +// sessionStorage key for the canvas search to return to when leaving review +// mode (both summary back-to-Storybook and dismiss). Captured while browsing +// stories/docs outside review mode, so it points at the pre-review canvas. +export const PRE_REVIEW_RETURN_KEY = `${REVIEW_NAMESPACE}/pre-review-return`; + +// sessionStorage marker deduplicating the one-time auto-enter on first landing +// on the review summary. Reset on dismiss and when a new review payload arrives. +export const AUTO_ENTERED_SESSION_KEY = `${REVIEW_NAMESPACE}/auto-entered`; + +// sessionStorage marker for the server `createdAt` of the review the user has +// opened. Non-review routes only surface a new-review notification while this +// differs from the active review's `createdAt`. +export const VISITED_REVIEW_CREATED_AT_KEY = `${REVIEW_NAMESPACE}/visited-created-at`; + +// sessionStorage marker for the `createdAt` of the review currently shown in +// the sidebar notification. Cleared on accept or dismiss. +export const NOTIFIED_REVIEW_CREATED_AT_KEY = `${REVIEW_NAMESPACE}/notified-created-at`; + +export const REVIEW_AVAILABLE_NOTIFICATION_ID = `${REVIEW_NAMESPACE}/review-available`; + +export const reviewAvailableNotificationId = (createdAt: number): string => + `${REVIEW_AVAILABLE_NOTIFICATION_ID}/${createdAt}`; diff --git a/code/core/src/manager/components/review/review-actions.test.ts b/code/core/src/manager/components/review/review-actions.test.ts new file mode 100644 index 000000000000..00f4a3b5a823 --- /dev/null +++ b/code/core/src/manager/components/review/review-actions.test.ts @@ -0,0 +1,205 @@ +// @vitest-environment happy-dom +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { NavigateFunction } from 'storybook/internal/router'; +import type { API } from 'storybook/manager-api'; + +import { + EVENTS, + NOTIFIED_REVIEW_CREATED_AT_KEY, + PRE_REVIEW_RETURN_KEY, + VISITED_REVIEW_CREATED_AT_KEY, + reviewAvailableNotificationId, +} from './constants.ts'; +import { acceptPendingReview, dismissReview, navigateOutOfReview } from './review-actions.ts'; +import { enterReviewMode, isReviewModeActive } from './review-mode.ts'; +import { + REVIEW_COLLECTION_QUERY_PARAM, + buildReviewChangesSummaryHref, +} from './review-navigation.ts'; +import type { ReviewState } from './review-state.ts'; +import { reviewStore } from './review-store.ts'; + +const review: ReviewState = { + title: 'Example review', + description: '', + createdAt: 1_700_000_000_000, + collections: [{ title: 'A', rationale: '', storyIds: ['story--default'] }], +}; + +const emptyFilters = { + includedStatusFilters: [], + excludedStatusFilters: [], + includedTagFilters: [], + excludedTagFilters: [], +}; + +const makeApi = () => { + const setAllStatusFilters = vi.fn(async () => {}); + const setAllTagFilters = vi.fn(async () => {}); + return { + api: { + setAllStatusFilters, + setAllTagFilters, + removeStatusFilters: vi.fn(async () => {}), + setQueryParams: vi.fn(), + selectFirstStory: vi.fn(), + clearNotification: vi.fn(), + } as unknown as API, + setAllStatusFilters, + setAllTagFilters, + }; +}; + +beforeEach(() => { + sessionStorage.clear(); + reviewStore.reset(); +}); + +describe('navigateOutOfReview', () => { + it('restores filters before navigating back to the canvas', async () => { + const { api, setAllStatusFilters, setAllTagFilters } = makeApi(); + const navigate = vi.fn(); + const order: string[] = []; + + setAllTagFilters.mockImplementation(async () => { + order.push('restore-tag-filters'); + }); + setAllStatusFilters.mockImplementation(async () => { + order.push('restore-status-filters'); + }); + navigate.mockImplementation(() => { + order.push('navigate'); + }); + + await enterReviewMode(api, emptyFilters); + order.length = 0; + vi.clearAllMocks(); + setAllTagFilters.mockImplementation(async () => { + order.push('restore-tag-filters'); + }); + setAllStatusFilters.mockImplementation(async () => { + order.push('restore-status-filters'); + }); + navigate.mockImplementation(() => { + order.push('navigate'); + }); + + await navigateOutOfReview( + api, + navigate as unknown as NavigateFunction, + '?path=/story/example--default' + ); + + expect(order).toEqual(['restore-tag-filters', 'restore-status-filters', 'navigate']); + expect(api.setQueryParams).toHaveBeenCalledWith({ [REVIEW_COLLECTION_QUERY_PARAM]: null }); + expect(api.selectFirstStory).not.toHaveBeenCalled(); + }); + + it('marks the displayed review as visited so the arrival toast does not re-fire', async () => { + const { api } = makeApi(); + const navigate = vi.fn() as unknown as NavigateFunction; + + reviewStore.displayReview(review); + + await navigateOutOfReview(api, navigate, '?path=/story/example--default'); + + expect(sessionStorage.getItem(VISITED_REVIEW_CREATED_AT_KEY)).toBe(String(review.createdAt)); + expect(api.clearNotification).toHaveBeenCalledWith( + reviewAvailableNotificationId(review.createdAt!) + ); + }); + + it('does not record a visit when dismissing the review', async () => { + const { api } = makeApi(); + const navigate = vi.fn() as unknown as NavigateFunction; + + reviewStore.displayReview(review); + + await navigateOutOfReview(api, navigate, '?path=/story/example--default', { + recordVisit: false, + }); + + expect(sessionStorage.getItem(VISITED_REVIEW_CREATED_AT_KEY)).toBeNull(); + expect(sessionStorage.getItem(NOTIFIED_REVIEW_CREATED_AT_KEY)).toBeNull(); + }); + + it('does not mark the review as visited when filter restoration fails', async () => { + const { api, setAllTagFilters } = makeApi(); + const navigate = vi.fn() as unknown as NavigateFunction; + + await enterReviewMode(api, emptyFilters); + vi.clearAllMocks(); + setAllTagFilters.mockRejectedValueOnce(new Error('restore failed')); + + reviewStore.displayReview(review); + + await expect( + navigateOutOfReview(api, navigate, '?path=/story/example--default') + ).rejects.toThrow('restore failed'); + + expect(sessionStorage.getItem(VISITED_REVIEW_CREATED_AT_KEY)).toBeNull(); + expect(api.clearNotification).not.toHaveBeenCalled(); + }); + + it('falls back to the first story when the return search points at a review route', async () => { + const { api } = makeApi(); + const navigate = vi.fn() as unknown as NavigateFunction; + + await navigateOutOfReview( + api, + navigate, + `?path=/story/story--default&${REVIEW_COLLECTION_QUERY_PARAM}=0` + ); + + expect(navigate).not.toHaveBeenCalled(); + expect(api.selectFirstStory).toHaveBeenCalled(); + }); +}); + +describe('dismissReview', () => { + it('emits the dismiss event with the pre-review return search', () => { + sessionStorage.setItem(PRE_REVIEW_RETURN_KEY, '?path=/story/example--default'); + const emit = vi.fn(); + + dismissReview({ emit } as unknown as API); + + expect(emit).toHaveBeenCalledWith(EVENTS.DISMISS_REVIEW, '?path=/story/example--default'); + }); +}); + +describe('acceptPendingReview', () => { + const pending: ReviewState = { + ...review, + title: 'Updated review', + createdAt: review.createdAt! + 60_000, + }; + + it('is a no-op when nothing is pending', () => { + const { api } = makeApi(); + const navigate = vi.fn() as unknown as NavigateFunction; + + acceptPendingReview(api, navigate, emptyFilters); + + expect(navigate).not.toHaveBeenCalled(); + expect(api.clearNotification).not.toHaveBeenCalled(); + }); + + it('displays the pending review, enters review mode and navigates to the summary', () => { + const { api } = makeApi(); + const navigate = vi.fn() as unknown as NavigateFunction; + reviewStore.displayReview(review); + reviewStore.deferReview(pending); + + acceptPendingReview(api, navigate, emptyFilters); + + expect(reviewStore.getState().state).toBe(pending); + expect(reviewStore.getState().pendingReview).toBeNull(); + expect(isReviewModeActive()).toBe(true); + expect(api.clearNotification).toHaveBeenCalledWith( + reviewAvailableNotificationId(pending.createdAt!) + ); + expect(sessionStorage.getItem(VISITED_REVIEW_CREATED_AT_KEY)).toBe(String(pending.createdAt)); + expect(navigate).toHaveBeenCalledWith(buildReviewChangesSummaryHref(), { plain: true }); + }); +}); diff --git a/code/core/src/manager/components/review/review-actions.ts b/code/core/src/manager/components/review/review-actions.ts new file mode 100644 index 000000000000..cde0760483cf --- /dev/null +++ b/code/core/src/manager/components/review/review-actions.ts @@ -0,0 +1,113 @@ +import type { NavigateFunction } from 'storybook/internal/router'; +import { type API } from 'storybook/manager-api'; + +import { + AUTO_ENTERED_SESSION_KEY, + EVENTS, + PRE_REVIEW_RETURN_KEY, + REVIEW_CHANGES_URL, +} from './constants.ts'; +import { enterReviewMode, exitReviewMode, type ReviewModeFilters } from './review-mode.ts'; +import { + REVIEW_COLLECTION_QUERY_PARAM, + buildReviewChangesSummaryHref, + buildReviewStoryTarget, + isReviewReturnSearch, + type ReviewNavEntry, +} from './review-navigation.ts'; +import { acceptReviewNotification } from './review-notification.ts'; +import { reviewStore } from './review-store.ts'; +import { sessionStore } from './session-store.ts'; + +export interface NavigateOutOfReviewOptions { + /** Mark the displayed review as visited so the arrival toast does not re-fire. */ + recordVisit?: boolean; +} + +/** + * Navigate to a curated story, entering review mode. Entering is idempotent, so + * this is safe whether or not the user is already reviewing. The summary overlay + * stays visible until the route leaves the summary; the main preview is unmounted + * while it does, so no stale story shows through. + */ +export const navigateToReviewEntry = ( + api: API, + navigate: NavigateFunction, + entry: ReviewNavEntry, + filters: ReviewModeFilters +): void => { + void enterReviewMode(api, filters); + api.setQueryParams({ [REVIEW_COLLECTION_QUERY_PARAM]: String(entry.collectionIndex) }); + navigate(buildReviewStoryTarget(entry)); +}; + +/** Navigate back to the review summary, entering (or staying in) review mode. */ +export const navigateToReviewSummary = ( + api: API, + navigate: NavigateFunction, + filters: ReviewModeFilters +): void => { + void enterReviewMode(api, filters); + api.setQueryParams({ [REVIEW_COLLECTION_QUERY_PARAM]: null }); + navigate(REVIEW_CHANGES_URL); +}; + +/** + * Leave review mode and return to the pre-review canvas. Shared by the summary + * back-to-Storybook link and review dismissal; restores filters via + * {@link exitReviewMode} and navigates to the captured return search. + */ +export const navigateOutOfReview = async ( + api: API, + navigate: NavigateFunction, + returnSearch: string | null | undefined, + { recordVisit = true }: NavigateOutOfReviewOptions = {} +): Promise => { + const visitCreatedAt = recordVisit ? reviewStore.getState().state?.createdAt : undefined; + + api.setQueryParams({ [REVIEW_COLLECTION_QUERY_PARAM]: null }); + + reviewStore.setExiting(true); + try { + await exitReviewMode(api); + + if (visitCreatedAt !== undefined) { + acceptReviewNotification(api, visitCreatedAt); + } + + if (returnSearch && !isReviewReturnSearch(returnSearch)) { + navigate(returnSearch.startsWith('?') ? returnSearch : `?${returnSearch}`, { plain: true }); + return; + } + + api.selectFirstStory(); + } finally { + reviewStore.setExiting(false); + } +}; + +/** Clear the active review (if any) and return to the pre-review canvas. */ +export const dismissReview = (api: Pick): void => { + api.emit(EVENTS.DISMISS_REVIEW, sessionStore.read(PRE_REVIEW_RETURN_KEY)); +}; + +/** + * Swap in the deferred review payload, enter review mode and navigate to the + * summary screen. No-op when there is nothing pending. + */ +export const acceptPendingReview = ( + api: API, + navigate: NavigateFunction, + filters: ReviewModeFilters +): void => { + const accepted = reviewStore.getState().pendingReview; + if (!accepted) { + return; + } + acceptReviewNotification(api, accepted.createdAt); + // A fresh payload re-arms the one-time auto-enter. + sessionStore.remove(AUTO_ENTERED_SESSION_KEY); + reviewStore.displayReview(accepted); + void enterReviewMode(api, filters); + navigate(buildReviewChangesSummaryHref(), { plain: true }); +}; diff --git a/code/core/src/manager/components/review/review-mode.test.ts b/code/core/src/manager/components/review/review-mode.test.ts new file mode 100644 index 000000000000..fea4a74d0fdd --- /dev/null +++ b/code/core/src/manager/components/review/review-mode.test.ts @@ -0,0 +1,129 @@ +// @vitest-environment happy-dom +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { StatusValue } from 'storybook/internal/types'; + +import { + enterReviewMode, + exitReviewMode, + isReviewModeActive, + type ReviewModeFilters, +} from './review-mode.ts'; +import { REVIEWING_STATUS_VALUE } from './review-status.ts'; +import { reviewStore } from './review-store.ts'; + +const emptyFilters: ReviewModeFilters = { + includedStatusFilters: [], + excludedStatusFilters: [], + includedTagFilters: [], + excludedTagFilters: [], +}; + +const makeApi = () => ({ + setAllStatusFilters: vi.fn(async () => {}), + setAllTagFilters: vi.fn(async () => {}), + removeStatusFilters: vi.fn(async () => {}), +}); + +beforeEach(() => { + sessionStorage.clear(); + reviewStore.reset(); +}); + +describe('enterReviewMode', () => { + it('narrows filters to reviewing and sets the flag without changing chrome', async () => { + const api = makeApi(); + await enterReviewMode(api, emptyFilters); + + expect(api.setAllTagFilters).toHaveBeenCalledWith([], []); + expect(api.setAllStatusFilters).toHaveBeenCalledWith(['status-value:reviewing'], []); + expect(isReviewModeActive()).toBe(true); + }); + + it('snapshots the pre-review filters only on the first entry', async () => { + const preReviewFilters: ReviewModeFilters = { + includedStatusFilters: ['status-value:error' as StatusValue], + excludedStatusFilters: [], + includedTagFilters: ['play-fn'], + excludedTagFilters: [], + }; + await enterReviewMode(makeApi(), preReviewFilters); + // A second (idempotent) entry with different filters must not overwrite the snapshot. + await enterReviewMode(makeApi(), emptyFilters); + + const api = makeApi(); + await exitReviewMode(api); + expect(api.setAllTagFilters).toHaveBeenCalledWith(['play-fn'], []); + expect(api.setAllStatusFilters).toHaveBeenCalledWith(['status-value:error'], []); + }); + + it('does not re-apply filters when already in review mode', async () => { + const api = makeApi(); + await enterReviewMode(api, emptyFilters); + vi.clearAllMocks(); + await enterReviewMode(api, emptyFilters); + expect(api.setAllTagFilters).not.toHaveBeenCalled(); + expect(api.setAllStatusFilters).not.toHaveBeenCalled(); + }); + + it('rolls back the review-mode flag when the filter setters fail', async () => { + const api = makeApi(); + api.setAllTagFilters.mockRejectedValueOnce(new Error('boom')); + await expect(enterReviewMode(api, emptyFilters)).rejects.toThrow('boom'); + expect(isReviewModeActive()).toBe(false); + }); + + it('omits reviewing from the snapshot even when the current filters include it', async () => { + const api = makeApi(); + await enterReviewMode(api, { + ...emptyFilters, + includedStatusFilters: ['status-value:error' as StatusValue, REVIEWING_STATUS_VALUE], + }); + + const exitApi = makeApi(); + await exitReviewMode(exitApi); + expect(exitApi.setAllStatusFilters).toHaveBeenCalledWith(['status-value:error'], []); + }); +}); + +describe('exitReviewMode', () => { + it('restores snapshotted filters and clears the flag', async () => { + const preReviewFilters: ReviewModeFilters = { + includedStatusFilters: ['status-value:error' as StatusValue], + excludedStatusFilters: [], + includedTagFilters: ['play-fn'], + excludedTagFilters: [], + }; + await enterReviewMode(makeApi(), preReviewFilters); + + const api = makeApi(); + await exitReviewMode(api); + expect(api.setAllTagFilters).toHaveBeenCalledWith(['play-fn'], []); + expect(api.setAllStatusFilters).toHaveBeenCalledWith(['status-value:error'], []); + expect(isReviewModeActive()).toBe(false); + }); + + it('is inert when there is no snapshot to restore', async () => { + const api = makeApi(); + await exitReviewMode(api); + expect(api.setAllTagFilters).not.toHaveBeenCalled(); + expect(api.setAllStatusFilters).not.toHaveBeenCalled(); + expect(api.removeStatusFilters).toHaveBeenCalledWith([REVIEWING_STATUS_VALUE]); + expect(isReviewModeActive()).toBe(false); + }); + + it('never restores the reviewing status filter', async () => { + await enterReviewMode(makeApi(), { + ...emptyFilters, + includedStatusFilters: ['status-value:error' as StatusValue], + }); + + const api = makeApi(); + await exitReviewMode(api); + expect(api.setAllStatusFilters).toHaveBeenCalledWith(['status-value:error'], []); + expect(api.setAllStatusFilters).not.toHaveBeenCalledWith( + expect.arrayContaining([REVIEWING_STATUS_VALUE]), + expect.anything() + ); + }); +}); diff --git a/code/core/src/manager/components/review/review-mode.ts b/code/core/src/manager/components/review/review-mode.ts new file mode 100644 index 000000000000..33c5cf085e61 --- /dev/null +++ b/code/core/src/manager/components/review/review-mode.ts @@ -0,0 +1,94 @@ +import type { StatusValue } from 'storybook/internal/types'; +import type { API } from 'storybook/manager-api'; + +import { REVIEW_NAMESPACE } from '../../../shared/review/index.ts'; +import { REVIEWING_STATUS_VALUE } from './review-status.ts'; +import { reviewStore } from './review-store.ts'; +import { sessionStore } from './session-store.ts'; + +// Snapshot of the sidebar filters taken when review mode is entered, so the +// pre-review filters can be restored on exit. +const FILTERS_SNAPSHOT_SESSION_KEY = `${REVIEW_NAMESPACE}/filters-snapshot`; + +/** Sidebar filter snapshot preserved across a review-mode session. */ +export interface ReviewModeFilters { + includedStatusFilters: StatusValue[]; + excludedStatusFilters: StatusValue[]; + includedTagFilters: string[]; + excludedTagFilters: string[]; +} + +type ReviewModeApi = Pick; + +/** Reviewing is owned by review mode and must never be restored after exit. */ +const stripReviewingStatusFilter = (filters: ReviewModeFilters): ReviewModeFilters => ({ + ...filters, + includedStatusFilters: filters.includedStatusFilters.filter( + (value) => value !== REVIEWING_STATUS_VALUE + ), + excludedStatusFilters: filters.excludedStatusFilters.filter( + (value) => value !== REVIEWING_STATUS_VALUE + ), +}); + +/** Whether the manager is currently in review mode (persisted across reloads). */ +export const isReviewModeActive = (): boolean => reviewStore.getState().isInReviewMode; + +const readJson = (key: string): T | null => { + const raw = sessionStore.read(key); + if (raw === null) { + return null; + } + try { + return JSON.parse(raw) as T; + } catch { + return null; + } +}; + +/** + * Enter review mode: snapshot sidebar filters and narrow to reviewing stories. + * Idempotent — re-entering while already in review mode is a no-op. + */ +export const enterReviewMode = async ( + api: ReviewModeApi, + filters: ReviewModeFilters +): Promise => { + if (isReviewModeActive()) { + return; + } + + sessionStore.write( + FILTERS_SNAPSHOT_SESSION_KEY, + JSON.stringify(stripReviewingStatusFilter(filters)) + ); + + // Enter optimistically so the UI flips before the async filter setters land. + reviewStore.setReviewMode(true); + try { + await api.setAllTagFilters([], []); + await api.setAllStatusFilters([REVIEWING_STATUS_VALUE], []); + } catch (error) { + reviewStore.setReviewMode(false); + sessionStore.remove(FILTERS_SNAPSHOT_SESSION_KEY); + throw error; + } +}; + +/** + * Exit review mode: restore the filters captured on entry and clear the + * persisted review-mode flag. + */ +export const exitReviewMode = async (api: ReviewModeApi): Promise => { + const filters = readJson(FILTERS_SNAPSHOT_SESSION_KEY); + if (filters) { + const restored = stripReviewingStatusFilter(filters); + await api.setAllTagFilters(restored.includedTagFilters, restored.excludedTagFilters); + await api.setAllStatusFilters(restored.includedStatusFilters, restored.excludedStatusFilters); + } else { + await api.removeStatusFilters([REVIEWING_STATUS_VALUE]); + } + + sessionStore.remove(FILTERS_SNAPSHOT_SESSION_KEY); + reviewStore.setReviewMode(false); +}; diff --git a/code/core/src/manager/components/review/review-navigation.test.ts b/code/core/src/manager/components/review/review-navigation.test.ts new file mode 100644 index 000000000000..8a76d3d73590 --- /dev/null +++ b/code/core/src/manager/components/review/review-navigation.test.ts @@ -0,0 +1,236 @@ +import { describe, expect, it } from 'vitest'; + +import { + REVIEW_COLLECTION_QUERY_PARAM, + buildFlattenedNavEntries, + buildReviewChangesSummaryHref, + buildReviewStoryHref, + buildReviewStoryTarget, + buildSummaryBackHref, + getAdjacentCollectionFirstStory, + getAdjacentReviewEntries, + isReviewReturnSearch, + isReviewSummaryPath, + parseCollectionIndex, + parseReviewStoryHref, + parseStoryIdFromPath, + resolveActiveNavEntry, + resolveNavIndex, +} from './review-navigation.ts'; +import type { ReviewState } from './review-state.ts'; + +const reviewState: ReviewState = { + title: 'Test review', + description: 'Test', + collections: [ + { + title: 'First', + rationale: '', + storyIds: ['story-a', 'story-b'], + }, + { + title: 'Second', + rationale: '', + storyIds: ['story-a', 'story-c'], + }, + ], +}; + +describe('buildReviewStoryHref', () => { + it('builds a story URL with collection query param', () => { + expect(buildReviewStoryHref({ storyId: 'story-a', collectionIndex: 1 })).toBe( + `?path=/story/story-a&${REVIEW_COLLECTION_QUERY_PARAM}=1` + ); + }); +}); + +describe('buildReviewStoryTarget', () => { + it('builds a router navigate target without the query wrapper', () => { + expect(buildReviewStoryTarget({ storyId: 'story-a', collectionIndex: 1 })).toBe( + `/story/story-a&${REVIEW_COLLECTION_QUERY_PARAM}=1` + ); + }); +}); + +describe('parseReviewStoryHref', () => { + it('parses story hrefs built by buildReviewStoryHref', () => { + const entry = { storyId: 'button-component--sizes', collectionIndex: 0 }; + expect(parseReviewStoryHref(buildReviewStoryHref(entry))).toEqual(entry); + }); + + it('returns null for malformed hrefs', () => { + expect(parseReviewStoryHref('?path=/review/')).toBeNull(); + expect(parseReviewStoryHref('?path=/story/foo')).toBeNull(); + }); +}); + +describe('buildFlattenedNavEntries', () => { + it('includes every story occurrence across collections', () => { + expect(buildFlattenedNavEntries(reviewState)).toEqual([ + { storyId: 'story-a', collectionIndex: 0 }, + { storyId: 'story-b', collectionIndex: 0 }, + { storyId: 'story-a', collectionIndex: 1 }, + { storyId: 'story-c', collectionIndex: 1 }, + ]); + }); +}); + +describe('resolveActiveNavEntry', () => { + const entries = buildFlattenedNavEntries(reviewState); + + it('matches collection index when provided', () => { + expect(resolveActiveNavEntry(entries, 'story-a', 1)).toEqual({ + storyId: 'story-a', + collectionIndex: 1, + }); + }); + + it('falls back to the first matching story when collection is absent', () => { + expect(resolveActiveNavEntry(entries, 'story-a')).toEqual({ + storyId: 'story-a', + collectionIndex: 0, + }); + }); + + it('returns null when the story is not in the review', () => { + expect(resolveActiveNavEntry(entries, 'missing')).toBeNull(); + }); +}); + +describe('resolveNavIndex', () => { + it('returns the index in the flattened list', () => { + const entries = buildFlattenedNavEntries(reviewState); + expect(resolveNavIndex(entries, { storyId: 'story-a', collectionIndex: 1 })).toBe(2); + }); +}); + +describe('path helpers', () => { + it('detects the review summary path', () => { + expect(isReviewSummaryPath('/review/')).toBe(true); + expect(isReviewSummaryPath('/review/0/story-a')).toBe(false); + }); + + it('parses story ids from story paths', () => { + expect(parseStoryIdFromPath('/story/foo--bar')).toBe('foo--bar'); + expect(parseStoryIdFromPath('/review/')).toBeNull(); + }); + + it('parses collection index from query params', () => { + expect(parseCollectionIndex('1')).toBe(1); + expect(parseCollectionIndex('nope')).toBeUndefined(); + expect(parseCollectionIndex('')).toBeUndefined(); + expect(parseCollectionIndex(' ')).toBeUndefined(); + }); + + it('builds the summary href', () => { + expect(buildReviewChangesSummaryHref()).toBe('?path=/review/'); + }); +}); + +describe('getAdjacentReviewEntries', () => { + const sequence = buildFlattenedNavEntries(reviewState); + + it('crosses into the next collection at a collection boundary', () => { + expect(getAdjacentReviewEntries(sequence, 1)?.next).toEqual({ + collectionIndex: 1, + storyId: 'story-a', + }); + }); + + it('crosses into the previous collection at a collection boundary', () => { + expect(getAdjacentReviewEntries(sequence, 2)?.previous).toEqual({ + collectionIndex: 0, + storyId: 'story-b', + }); + }); + + it('wraps from the last story to the first and back', () => { + expect(getAdjacentReviewEntries(sequence, sequence.length - 1)?.next).toEqual({ + collectionIndex: 0, + storyId: 'story-a', + }); + expect(getAdjacentReviewEntries(sequence, 0)?.previous).toEqual({ + collectionIndex: 1, + storyId: 'story-c', + }); + }); + + it('returns null for an empty sequence or an out-of-range index', () => { + expect(getAdjacentReviewEntries([], 0)).toBeNull(); + expect(getAdjacentReviewEntries(sequence, -1)).toBeNull(); + expect(getAdjacentReviewEntries(sequence, sequence.length)).toBeNull(); + }); +}); + +describe('getAdjacentCollectionFirstStory', () => { + const collections = reviewState.collections; + + it('jumps to the first story of the next collection', () => { + expect(getAdjacentCollectionFirstStory(collections, 0, 1)).toEqual({ + collectionIndex: 1, + storyId: 'story-a', + }); + }); + + it('jumps to the first story of the previous collection', () => { + expect(getAdjacentCollectionFirstStory(collections, 1, -1)).toEqual({ + collectionIndex: 0, + storyId: 'story-a', + }); + }); + + it('wraps from the last collection forward to the first', () => { + expect(getAdjacentCollectionFirstStory(collections, 1, 1)).toEqual({ + collectionIndex: 0, + storyId: 'story-a', + }); + }); + + it('wraps from the first collection backward to the last', () => { + expect(getAdjacentCollectionFirstStory(collections, 0, -1)).toEqual({ + collectionIndex: 1, + storyId: 'story-a', + }); + }); + + it('skips empty collections when stepping', () => { + const withEmpty = [{ storyIds: ['a--1'] }, { storyIds: [] }, { storyIds: ['c--1'] }]; + expect(getAdjacentCollectionFirstStory(withEmpty, 0, 1)).toEqual({ + collectionIndex: 2, + storyId: 'c--1', + }); + }); + + it('returns null when no collection has stories', () => { + expect(getAdjacentCollectionFirstStory([{ storyIds: [] }], 0, 1)).toBeNull(); + expect(getAdjacentCollectionFirstStory([], 0, 1)).toBeNull(); + }); +}); + +describe('isReviewReturnSearch', () => { + it('treats the review summary as a review route', () => { + expect(isReviewReturnSearch('?path=/review/')).toBe(true); + expect(isReviewReturnSearch(buildReviewChangesSummaryHref())).toBe(true); + }); + + it('treats curated review stories as review routes', () => { + expect( + isReviewReturnSearch(buildReviewStoryHref({ storyId: 'story-a', collectionIndex: 0 })) + ).toBe(true); + }); + + it('allows normal canvas stories as exit targets', () => { + expect(isReviewReturnSearch('?path=/story/foo')).toBe(false); + }); +}); + +describe('buildSummaryBackHref', () => { + it('returns the last viewed search when recorded', () => { + expect(buildSummaryBackHref('?path=/story/foo')).toBe('?path=/story/foo'); + }); + + it('falls back to the Storybook root when nothing is recorded', () => { + expect(buildSummaryBackHref(null)).toBe('/'); + expect(buildSummaryBackHref(undefined)).toBe('/'); + }); +}); diff --git a/code/core/src/manager/components/review/review-navigation.ts b/code/core/src/manager/components/review/review-navigation.ts new file mode 100644 index 000000000000..8e4dea77f962 --- /dev/null +++ b/code/core/src/manager/components/review/review-navigation.ts @@ -0,0 +1,199 @@ +import { + REVIEW_COLLECTION_QUERY_PARAM, + isReviewSummaryPath, +} from '../../../shared/review/routes.ts'; +import { REVIEW_CHANGES_URL } from './constants.ts'; +import type { ReviewState } from './review-state.ts'; + +export { REVIEW_COLLECTION_QUERY_PARAM, isReviewSummaryPath }; + +/** Fallback display name when the Storybook index has not resolved a title. */ +export const prettifyComponentId = (componentId: string) => + componentId + .split(/[-/]/) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' '); + +/** A single navigable slot in the flattened review list (duplicates allowed). */ +export interface ReviewNavEntry { + storyId: string; + collectionIndex: number; +} + +export const buildReviewChangesSummaryHref = () => `?path=${REVIEW_CHANGES_URL}`; + +/** Default back target when no story has been visited yet. */ +export const STORYBOOK_ROOT_HREF = '/'; + +export const buildSummaryBackHref = (returnSearch: string | null | undefined): string => + returnSearch || STORYBOOK_ROOT_HREF; + +/** Marks summary-header back links for SPA navigation in useReviewNavigationInterceptor. */ +export const REVIEW_SUMMARY_BACK_ATTR = 'data-review-summary-back'; + +/** Storybook router navigate target for a review story (no `?path=` wrapper). */ +export const buildReviewStoryTarget = (entry: ReviewNavEntry): string => + `/story/${entry.storyId}&${REVIEW_COLLECTION_QUERY_PARAM}=${entry.collectionIndex}`; + +/** Full `?path=` href for a review story, derived from the router target. */ +export const buildReviewStoryHref = (entry: ReviewNavEntry): string => + `?path=${buildReviewStoryTarget(entry)}`; + +export const parseReviewStoryHref = (href: string): ReviewNavEntry | null => { + if (!href.startsWith('?path=/story/')) { + return null; + } + const query = href.startsWith('?') ? href.slice(1) : href; + const params = new URLSearchParams(query); + const path = params.get('path'); + if (!path?.startsWith('/story/')) { + return null; + } + const storyId = path.slice('/story/'.length); + const collectionIndex = parseCollectionIndex( + params.get(REVIEW_COLLECTION_QUERY_PARAM) ?? undefined + ); + if (!storyId || collectionIndex === undefined) { + return null; + } + return { storyId, collectionIndex }; +}; + +/** Walk collections in order, pushing every story occurrence. */ +export const buildFlattenedNavEntries = (state: ReviewState): ReviewNavEntry[] => { + const entries: ReviewNavEntry[] = []; + state.collections.forEach((collection, collectionIndex) => { + for (const storyId of collection.storyIds) { + entries.push({ storyId, collectionIndex }); + } + }); + return entries; +}; + +/** True when a manager search string points back at a review route (not a canvas). */ +export const isReviewReturnSearch = (search: string): boolean => { + const normalized = search.startsWith('?') ? search.slice(1) : search; + const params = new URLSearchParams(normalized); + const path = params.get('path') ?? ''; + if (isReviewSummaryPath(path) || path.startsWith(REVIEW_CHANGES_URL)) { + return true; + } + return path.startsWith('/story/') && params.has(REVIEW_COLLECTION_QUERY_PARAM); +}; + +export const parseStoryIdFromPath = (path: string): string | null => { + if (!path.startsWith('/story/')) { + return null; + } + const storyId = path.slice('/story/'.length); + return storyId || null; +}; + +export const parseCollectionIndex = (value: string | undefined): number | undefined => { + if (value === undefined) { + return undefined; + } + if (!/^\d+$/.test(value)) { + return undefined; + } + const parsed = Number(value); + return Number.isInteger(parsed) ? parsed : undefined; +}; + +/** + * Resolve the active navigation slot from the current story and optional + * `collection` query param. Falls back to the first matching storyId. + */ +export const resolveActiveNavEntry = ( + entries: ReviewNavEntry[], + storyId: string, + collectionIndex?: number +): ReviewNavEntry | null => { + if (entries.length === 0) { + return null; + } + if (collectionIndex !== undefined) { + const exact = entries.find( + (entry) => entry.storyId === storyId && entry.collectionIndex === collectionIndex + ); + if (exact) { + return exact; + } + } + return entries.find((entry) => entry.storyId === storyId) ?? null; +}; + +export const resolveNavIndex = (entries: ReviewNavEntry[], active: ReviewNavEntry): number => + entries.findIndex( + (entry) => entry.storyId === active.storyId && entry.collectionIndex === active.collectionIndex + ); + +/** Previous/next targets in the flattened review sequence, wrapping at the ends. */ +export const getAdjacentReviewEntries = ( + entries: readonly ReviewNavEntry[], + index: number +): { previous: ReviewNavEntry; next: ReviewNavEntry } | null => { + const total = entries.length; + if (total === 0 || index < 0 || index >= total) { + return null; + } + return { + previous: entries[(index - 1 + total) % total], + next: entries[(index + 1) % total], + }; +}; + +/** First story of the collection one step away, wrapping and skipping empty collections. */ +export const getAdjacentCollectionFirstStory = ( + collections: readonly { storyIds: string[] }[], + collectionIndex: number, + direction: 1 | -1 +): ReviewNavEntry | null => { + const total = collections.length; + if (total === 0) { + return null; + } + for (let step = 1; step <= total; step += 1) { + const index = (((collectionIndex + direction * step) % total) + total) % total; + const candidate = collections[index]; + if (candidate && candidate.storyIds.length > 0) { + return { collectionIndex: index, storyId: candidate.storyIds[0] }; + } + } + return null; +}; + +/** Keyboard shortcut targets for the active reviewed story, as ready-to-navigate hrefs. */ +export interface ReviewShortcutHrefs { + back: string; + previous: string; + next: string; + previousCollection: string; + nextCollection: string; +} + +export const buildReviewShortcutHrefs = ( + collections: readonly { storyIds: string[] }[], + entries: readonly ReviewNavEntry[], + activeIndex: number +): ReviewShortcutHrefs | null => { + if (activeIndex < 0 || entries.length === 0) { + return null; + } + const active = entries[activeIndex]; + const neighbors = getAdjacentReviewEntries(entries, activeIndex); + const fallback = active; + const previousCollection = + getAdjacentCollectionFirstStory(collections, active.collectionIndex, -1) ?? fallback; + const nextCollection = + getAdjacentCollectionFirstStory(collections, active.collectionIndex, 1) ?? fallback; + + return { + back: buildReviewChangesSummaryHref(), + previous: buildReviewStoryHref(neighbors?.previous ?? fallback), + next: buildReviewStoryHref(neighbors?.next ?? fallback), + previousCollection: buildReviewStoryHref(previousCollection), + nextCollection: buildReviewStoryHref(nextCollection), + }; +}; diff --git a/code/core/src/manager/components/review/review-notification.test.ts b/code/core/src/manager/components/review/review-notification.test.ts new file mode 100644 index 000000000000..5d8df97625b8 --- /dev/null +++ b/code/core/src/manager/components/review/review-notification.test.ts @@ -0,0 +1,144 @@ +// @vitest-environment happy-dom +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + NOTIFIED_REVIEW_CREATED_AT_KEY, + VISITED_REVIEW_CREATED_AT_KEY, + reviewAvailableNotificationId, +} from './constants.ts'; +import { + acceptReviewNotification, + claimNotificationSlot, + clearReviewNotificationsOnDismiss, + pickReviewToNotify, + shouldAutoAcceptOnRoute, + shouldSkipArrivalNotification, +} from './review-notification.ts'; +import type { ReviewState } from './review-state.ts'; +import { sessionStore } from './session-store.ts'; + +const review = (createdAt: number, title: string): ReviewState => ({ + title, + description: '', + createdAt, + collections: [{ title: 'A', rationale: '', storyIds: ['s1'] }], +}); + +describe('pickReviewToNotify', () => { + beforeEach(() => { + sessionStore.remove(VISITED_REVIEW_CREATED_AT_KEY); + sessionStore.remove(NOTIFIED_REVIEW_CREATED_AT_KEY); + }); + + it('prefers an unseen pending review', () => { + expect(pickReviewToNotify(review(100, 'First'), review(200, 'Second'))).toEqual( + review(200, 'Second') + ); + }); + + it('returns null when already accepted', () => { + sessionStore.write(VISITED_REVIEW_CREATED_AT_KEY, '100'); + expect(pickReviewToNotify(review(100, 'First'), null)).toBeNull(); + }); + + it('skips a stale displayed review after a newer one was accepted', () => { + sessionStore.write(VISITED_REVIEW_CREATED_AT_KEY, '200'); + expect(pickReviewToNotify(review(100, 'First'), null)).toBeNull(); + }); +}); + +describe('acceptReviewNotification', () => { + beforeEach(() => { + sessionStore.remove(VISITED_REVIEW_CREATED_AT_KEY); + sessionStore.remove(NOTIFIED_REVIEW_CREATED_AT_KEY); + }); + + it('clears the notification and records acceptance', () => { + const clearNotification = vi.fn(); + acceptReviewNotification({ clearNotification }, 100); + expect(clearNotification).toHaveBeenCalledWith(reviewAvailableNotificationId(100)); + expect(sessionStore.read(VISITED_REVIEW_CREATED_AT_KEY)).toBe('100'); + expect(sessionStore.read(NOTIFIED_REVIEW_CREATED_AT_KEY)).toBe('100'); + }); +}); + +describe('claimNotificationSlot', () => { + beforeEach(() => { + sessionStore.remove(VISITED_REVIEW_CREATED_AT_KEY); + sessionStore.remove(NOTIFIED_REVIEW_CREATED_AT_KEY); + }); + + it('clears older notifications before claiming a new slot', () => { + const clearNotification = vi.fn(); + expect(claimNotificationSlot({ clearNotification }, 200, 100)).toBe(true); + expect(clearNotification).toHaveBeenCalledWith(reviewAvailableNotificationId(100)); + expect(claimNotificationSlot({ clearNotification }, 200)).toBe(false); + }); +}); + +describe('clearReviewNotificationsOnDismiss', () => { + beforeEach(() => { + sessionStore.remove(NOTIFIED_REVIEW_CREATED_AT_KEY); + sessionStore.remove(VISITED_REVIEW_CREATED_AT_KEY); + sessionStore.write(VISITED_REVIEW_CREATED_AT_KEY, '100'); + }); + + it('clears notifications and the acceptance marker', () => { + const clearNotification = vi.fn(); + clearReviewNotificationsOnDismiss({ clearNotification }, review(100, 'First'), null); + expect(clearNotification).toHaveBeenCalledWith(reviewAvailableNotificationId(100)); + expect(sessionStore.read(VISITED_REVIEW_CREATED_AT_KEY)).toBeNull(); + }); +}); + +describe('shouldSkipArrivalNotification', () => { + it('skips deferred updates while already in review', () => { + const displayed = review(100, 'First'); + const pending = review(200, 'Second'); + expect(shouldSkipArrivalNotification('/review/', undefined, pending, displayed, pending)).toBe( + true + ); + expect( + shouldSkipArrivalNotification('/story/example--default', 0, pending, displayed, pending) + ).toBe(true); + }); + + it('still notifies for unseen arrivals off review routes', () => { + const displayed = review(100, 'First'); + const pending = review(200, 'Second'); + expect( + shouldSkipArrivalNotification( + '/story/example--default', + undefined, + pending, + displayed, + pending + ) + ).toBe(false); + }); +}); + +describe('shouldAutoAcceptOnRoute', () => { + beforeEach(() => { + sessionStore.remove(VISITED_REVIEW_CREATED_AT_KEY); + sessionStore.remove(NOTIFIED_REVIEW_CREATED_AT_KEY); + }); + + it('accepts on the review summary', () => { + const displayed = review(200, 'Second'); + expect(shouldAutoAcceptOnRoute('/review/', undefined, displayed, displayed, null)).toBe(true); + }); + + it('accepts on a review story route with a collection param', () => { + const displayed = review(200, 'Second'); + expect(shouldAutoAcceptOnRoute('/story/example--default', 0, displayed, displayed, null)).toBe( + true + ); + }); + + it('skips while a deferred update is showing', () => { + const displayed = review(100, 'First'); + const pending = review(200, 'Second'); + expect(shouldAutoAcceptOnRoute('/review/', undefined, pending, displayed, pending)).toBe(false); + }); +}); diff --git a/code/core/src/manager/components/review/review-notification.ts b/code/core/src/manager/components/review/review-notification.ts new file mode 100644 index 000000000000..dfa20e2956b3 --- /dev/null +++ b/code/core/src/manager/components/review/review-notification.ts @@ -0,0 +1,153 @@ +import type { API } from 'storybook/manager-api'; + +import { + NOTIFIED_REVIEW_CREATED_AT_KEY, + REVIEW_AVAILABLE_NOTIFICATION_ID, + VISITED_REVIEW_CREATED_AT_KEY, + reviewAvailableNotificationId, +} from './constants.ts'; +import { + REVIEW_COLLECTION_QUERY_PARAM, + isReviewSummaryPath, + parseCollectionIndex, +} from './review-navigation.ts'; +import type { ReviewState } from './review-state.ts'; +import { sessionStore } from './session-store.ts'; + +const isUnseen = (createdAt: number | undefined): boolean => + createdAt !== undefined && sessionStore.read(VISITED_REVIEW_CREATED_AT_KEY) !== String(createdAt); + +const readVisitedCreatedAt = (): number | undefined => { + const raw = sessionStore.read(VISITED_REVIEW_CREATED_AT_KEY); + if (raw === null) { + return undefined; + } + const visited = Number(raw); + return Number.isFinite(visited) ? visited : undefined; +}; + +/** True when the user already opened a newer review push. */ +const isSupersededByVisit = (createdAt: number | undefined): boolean => { + const visited = readVisitedCreatedAt(); + return createdAt !== undefined && visited !== undefined && visited > createdAt; +}; + +const readNotifiedCreatedAt = (): number | undefined => { + const raw = sessionStore.read(NOTIFIED_REVIEW_CREATED_AT_KEY); + if (raw === null) { + return undefined; + } + const notified = Number(raw); + return Number.isFinite(notified) ? notified : undefined; +}; + +export const isOnReviewRoute = (path: string, collectionIndex: number | undefined): boolean => + isReviewSummaryPath(path) || (path.startsWith('/story/') && collectionIndex !== undefined); + +/** In-review pushes use the toolbar Update banner instead of the sidebar toast. */ +export const shouldSkipArrivalNotification = ( + path: string, + collectionIndex: number | undefined, + review: ReviewState, + displayed: ReviewState | null, + deferred: ReviewState | null +): boolean => { + if (!isOnReviewRoute(path, collectionIndex)) { + return false; + } + if (deferred?.createdAt !== undefined && deferred.createdAt === review.createdAt) { + return true; + } + return displayed?.createdAt !== undefined && displayed.createdAt === review.createdAt; +}; + +export const pickReviewToNotify = ( + displayed: ReviewState | null, + deferred: ReviewState | null +): ReviewState | null => { + if (deferred?.createdAt !== undefined && isUnseen(deferred.createdAt)) { + return deferred; + } + if ( + displayed?.createdAt !== undefined && + isUnseen(displayed.createdAt) && + !isSupersededByVisit(displayed.createdAt) + ) { + return displayed; + } + return null; +}; + +/** User opened the displayed review (summary or review story with collection param). */ +export const shouldAutoAcceptOnRoute = ( + path: string, + collectionIndex: number | undefined, + candidate: ReviewState, + displayed: ReviewState | null, + deferred: ReviewState | null +): boolean => { + const createdAt = candidate.createdAt; + if (createdAt === undefined || !isUnseen(createdAt) || displayed?.createdAt !== createdAt) { + return false; + } + if (!isOnReviewRoute(path, collectionIndex)) { + return false; + } + if (deferred?.createdAt !== undefined && deferred.createdAt !== displayed.createdAt) { + return false; + } + return true; +}; + +export const clearReviewNotifications = ( + api: Pick, + ...createdAts: Array +): void => { + for (const createdAt of new Set( + createdAts.filter((value): value is number => value !== undefined && value !== null) + )) { + api.clearNotification(reviewAvailableNotificationId(createdAt)); + } + api.clearNotification(REVIEW_AVAILABLE_NOTIFICATION_ID); +}; + +export const acceptReviewNotification = ( + api: Pick, + createdAt: number | undefined +): void => { + if (createdAt === undefined) { + return; + } + clearReviewNotifications(api, createdAt); + sessionStore.write(VISITED_REVIEW_CREATED_AT_KEY, String(createdAt)); + // Mark notified too so a layout-effect re-run cannot re-post the arrival toast. + sessionStore.write(NOTIFIED_REVIEW_CREATED_AT_KEY, String(createdAt)); +}; + +export const clearReviewNotificationsOnDismiss = ( + api: Pick, + displayed: ReviewState | null | undefined, + deferred: ReviewState | null | undefined +): void => { + clearReviewNotifications(api, displayed?.createdAt, deferred?.createdAt, readNotifiedCreatedAt()); + sessionStore.remove(VISITED_REVIEW_CREATED_AT_KEY); + sessionStore.remove(NOTIFIED_REVIEW_CREATED_AT_KEY); +}; + +export const claimNotificationSlot = ( + api: Pick, + createdAt: number, + ...extraCreatedAts: Array +): boolean => { + if (!isUnseen(createdAt) || readNotifiedCreatedAt() === createdAt) { + return false; + } + clearReviewNotifications(api, readNotifiedCreatedAt(), ...extraCreatedAts); + sessionStore.write(NOTIFIED_REVIEW_CREATED_AT_KEY, String(createdAt)); + return true; +}; + +export const readCollectionIndex = ( + queryParams: Record | undefined +): number | undefined => + parseCollectionIndex(queryParams?.[REVIEW_COLLECTION_QUERY_PARAM] as string | undefined); diff --git a/code/core/src/manager/components/review/review-state.ts b/code/core/src/manager/components/review/review-state.ts new file mode 100644 index 000000000000..c627d0cd8552 --- /dev/null +++ b/code/core/src/manager/components/review/review-state.ts @@ -0,0 +1,3 @@ +// The review payload type is the core-owned ingest contract. Re-exported here so +// the addon's manager code keeps importing it from a local path. +export type { ReviewCollection, ReviewState } from 'storybook/internal/types'; diff --git a/code/core/src/manager/components/review/review-status.test.ts b/code/core/src/manager/components/review/review-status.test.ts new file mode 100644 index 000000000000..ae6cf990ca49 --- /dev/null +++ b/code/core/src/manager/components/review/review-status.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { Status, StatusStoreByTypeId } from 'storybook/internal/types'; +import { REVIEW_STATUS_TYPE_ID } from 'storybook/internal/types'; + +import type { ReviewState } from './review-state.ts'; +import { + REVIEWING_STATUS_VALUE, + clearReviewStatuses, + collectReviewStoryIds, + syncReviewStatuses, +} from './review-status.ts'; + +const sampleReview: ReviewState = { + title: 'Button refresh', + description: 'Updated primary button styling.', + collections: [ + { + title: 'Button', + rationale: 'Directly changed component.', + storyIds: ['button--primary', 'button--secondary'], + }, + { + title: 'Forms', + rationale: 'Consumer stories.', + storyIds: ['form--default', 'button--primary'], + }, + ], +}; + +const createMockStatusStore = () => { + const statuses = new Map(); + + const store: StatusStoreByTypeId = { + typeId: REVIEW_STATUS_TYPE_ID, + getAll: vi.fn(() => ({})), + set: vi.fn((nextStatuses: Status[]) => { + for (const status of nextStatuses) { + statuses.set(status.storyId, status); + } + }), + unset: vi.fn((storyIds?: string[]) => { + if (!storyIds) { + statuses.clear(); + return; + } + for (const storyId of storyIds) { + statuses.delete(storyId); + } + }), + onAllStatusChange: vi.fn(() => () => {}), + onSelect: vi.fn(() => () => {}), + }; + + return { store, statuses }; +}; + +describe('collectReviewStoryIds', () => { + it('returns the unique story ids across all collections', () => { + expect([...collectReviewStoryIds(sampleReview)].sort()).toEqual([ + 'button--primary', + 'button--secondary', + 'form--default', + ]); + }); +}); + +describe('syncReviewStatuses', () => { + it('sets reviewing statuses for every story in the review', () => { + const { store, statuses } = createMockStatusStore(); + const storyIds = collectReviewStoryIds(sampleReview); + + syncReviewStatuses(store, storyIds, new Set()); + + expect(store.set).toHaveBeenCalledOnce(); + expect([...statuses.keys()].sort()).toEqual([ + 'button--primary', + 'button--secondary', + 'form--default', + ]); + expect(statuses.get('button--primary')).toMatchObject({ + typeId: REVIEW_STATUS_TYPE_ID, + value: REVIEWING_STATUS_VALUE, + }); + }); + + it('removes reviewing statuses for stories no longer in the review', () => { + const { store, statuses } = createMockStatusStore(); + const initial = syncReviewStatuses(store, collectReviewStoryIds(sampleReview), new Set()); + const nextReviewIds = new Set(['button--primary']); + + syncReviewStatuses(store, nextReviewIds, initial); + + expect(store.unset).toHaveBeenCalledWith(['button--secondary', 'form--default']); + expect([...statuses.keys()]).toEqual(['button--primary']); + }); +}); + +describe('clearReviewStatuses', () => { + it('clears all review statuses from the typed store', () => { + const { store, statuses } = createMockStatusStore(); + syncReviewStatuses(store, collectReviewStoryIds(sampleReview), new Set()); + + clearReviewStatuses(store); + + expect(store.unset).toHaveBeenCalledWith(); + expect(statuses.size).toBe(0); + }); +}); diff --git a/code/core/src/manager/components/review/review-status.ts b/code/core/src/manager/components/review/review-status.ts new file mode 100644 index 000000000000..69ca79e1ed99 --- /dev/null +++ b/code/core/src/manager/components/review/review-status.ts @@ -0,0 +1,46 @@ +import type { Status, StatusStoreByTypeId, StatusValue } from 'storybook/internal/types'; +import { REVIEW_STATUS_TYPE_ID } from 'storybook/internal/types'; + +import type { ReviewState } from './review-state.ts'; + +export const REVIEWING_STATUS_VALUE = 'status-value:reviewing' as StatusValue; + +export const collectReviewStoryIds = (review: ReviewState): Set => { + const storyIds = new Set(); + for (const collection of review.collections) { + for (const storyId of collection.storyIds) { + storyIds.add(storyId); + } + } + return storyIds; +}; + +const createReviewStatus = (storyId: string): Status => ({ + storyId, + typeId: REVIEW_STATUS_TYPE_ID, + value: REVIEWING_STATUS_VALUE, + title: '', + description: '', + sidebarContextMenu: false, +}); + +export const syncReviewStatuses = ( + statusStore: StatusStoreByTypeId, + storyIds: Set, + previousStoryIds: Set +): Set => { + const removedStoryIds = [...previousStoryIds].filter((storyId) => !storyIds.has(storyId)); + if (removedStoryIds.length > 0) { + statusStore.unset(removedStoryIds); + } + + if (storyIds.size > 0) { + statusStore.set([...storyIds].map(createReviewStatus)); + } + + return new Set(storyIds); +}; + +export const clearReviewStatuses = (statusStore: StatusStoreByTypeId): void => { + statusStore.unset(); +}; diff --git a/code/core/src/manager/components/review/review-store.test.ts b/code/core/src/manager/components/review/review-store.test.ts new file mode 100644 index 000000000000..6158b1293516 --- /dev/null +++ b/code/core/src/manager/components/review/review-store.test.ts @@ -0,0 +1,108 @@ +// @vitest-environment happy-dom +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { ReviewState } from './review-state.ts'; +import { reviewStore, type ReviewDerivedState } from './review-store.ts'; + +const REVIEW_MODE_SESSION_KEY = 'storybook/review/review-mode'; + +const review: ReviewState = { + title: 'Example review', + description: '', + createdAt: 1_700_000_000_000, + collections: [{ title: 'A', rationale: '', storyIds: ['story--default'] }], +}; + +const updatedReview: ReviewState = { + ...review, + title: 'Updated review', + createdAt: review.createdAt! + 60_000, +}; + +const derived: ReviewDerivedState = { + storyInfo: {}, + flattenedEntries: [], + newlyAddedStoryIds: new Set(), + activeEntry: null, + activeIndex: -1, + isSummaryVisible: false, + banner: null, +}; + +beforeEach(() => { + sessionStorage.clear(); + reviewStore.reset(); +}); + +describe('displayReview', () => { + it('shows the review and derives staleness from the payload', () => { + reviewStore.displayReview({ ...review, stale: true }); + expect(reviewStore.getState().state?.title).toBe('Example review'); + expect(reviewStore.getState().isStale).toBe(true); + + reviewStore.displayReview(updatedReview); + expect(reviewStore.getState().state).toBe(updatedReview); + expect(reviewStore.getState().isStale).toBe(false); + }); + + it('clears any deferred payload', () => { + reviewStore.displayReview(review); + reviewStore.deferReview(updatedReview); + reviewStore.displayReview(updatedReview); + expect(reviewStore.getState().pendingReview).toBeNull(); + }); +}); + +describe('deferReview', () => { + it('holds the update without replacing the displayed review', () => { + reviewStore.displayReview(review); + reviewStore.deferReview(updatedReview); + expect(reviewStore.getState().state).toBe(review); + expect(reviewStore.getState().pendingReview).toBe(updatedReview); + }); +}); + +describe('clearReview', () => { + it('drops displayed, deferred, stale, and review-mode state', () => { + reviewStore.displayReview({ ...review, stale: true }); + reviewStore.deferReview(updatedReview); + reviewStore.setReviewMode(true); + + reviewStore.clearReview(); + + const state = reviewStore.getState(); + expect(state.state).toBeNull(); + expect(state.pendingReview).toBeNull(); + expect(state.isStale).toBe(false); + expect(state.isInReviewMode).toBe(false); + expect(sessionStorage.getItem(REVIEW_MODE_SESSION_KEY)).toBeNull(); + }); +}); + +describe('setReviewMode', () => { + it('persists the flag so review mode survives reloads', () => { + reviewStore.setReviewMode(true); + expect(reviewStore.getState().isInReviewMode).toBe(true); + expect(sessionStorage.getItem(REVIEW_MODE_SESSION_KEY)).toBe('1'); + + reviewStore.setReviewMode(false); + expect(reviewStore.getState().isInReviewMode).toBe(false); + expect(sessionStorage.getItem(REVIEW_MODE_SESSION_KEY)).toBeNull(); + }); +}); + +describe('subscribe', () => { + it('notifies on writes and returns a fresh snapshot', () => { + const listener = vi.fn(); + const unsubscribe = reviewStore.subscribe(listener); + + const before = reviewStore.getState(); + reviewStore.displayReview(review); + expect(listener).toHaveBeenCalledTimes(1); + expect(reviewStore.getState()).not.toBe(before); + + unsubscribe(); + reviewStore.setStale(true); + expect(listener).toHaveBeenCalledTimes(1); + }); +}); diff --git a/code/core/src/manager/components/review/review-store.ts b/code/core/src/manager/components/review/review-store.ts new file mode 100644 index 000000000000..584652c9dd36 --- /dev/null +++ b/code/core/src/manager/components/review/review-store.ts @@ -0,0 +1,155 @@ +import { useSyncExternalStore } from 'react'; + +import { REVIEW_NAMESPACE } from '../../../shared/review/index.ts'; + +import type { AttentionBannerProps } from './components/AttentionBanner.tsx'; +import type { ReviewNavEntry } from './review-navigation.ts'; +import type { ReviewState } from './review-state.ts'; +import type { StoryInfo } from './review-types.ts'; +import { sessionStore } from './session-store.ts'; + +// Persisted flag marking the manager as being in review mode. Review mode is +// interaction-driven (never inferred from the URL) and survives reloads via +// this key. Owned by the store so `isInReviewMode` has a single write path. +const REVIEW_MODE_SESSION_KEY = `${REVIEW_NAMESPACE}/review-mode`; + +/** + * The attention banner to render at the top of review surfaces, if any. + * Pending-update outranks stale: accepting the update supersedes the warning. + */ +export type ReviewBanner = AttentionBannerProps | null; + +/** + * Values the store cannot compute itself because they depend on React-land + * inputs (the Storybook index, statuses, and the current route). ReviewProvider + * recomputes them whenever their inputs change and pushes them via + * {@link reviewStore.setDerived}. + */ +export interface ReviewDerivedState { + storyInfo: Record; + flattenedEntries: ReviewNavEntry[]; + newlyAddedStoryIds: Set; + activeEntry: ReviewNavEntry | null; + activeIndex: number; + isSummaryVisible: boolean; + banner: ReviewBanner; +} + +export interface ReviewStoreState extends ReviewDerivedState { + /** The displayed review. */ + state: ReviewState | null; + /** An updated payload held back until the user accepts it. */ + pendingReview: ReviewState | null; + isStale: boolean; + isInReviewMode: boolean; + /** True while navigateOutOfReview is in flight; blocks the summary auto-enter. */ + isExiting: boolean; +} + +interface ReviewCoreState { + state: ReviewState | null; + pendingReview: ReviewState | null; + isStale: boolean; + isInReviewMode: boolean; + isExiting: boolean; +} + +const emptyCore: ReviewCoreState = { + state: null, + pendingReview: null, + isStale: false, + isInReviewMode: false, + isExiting: false, +}; + +const emptyDerived: ReviewDerivedState = { + storyInfo: {}, + flattenedEntries: [], + newlyAddedStoryIds: new Set(), + activeEntry: null, + activeIndex: -1, + isSummaryVisible: false, + banner: null, +}; + +let core: ReviewCoreState = { + ...emptyCore, + isInReviewMode: sessionStore.read(REVIEW_MODE_SESSION_KEY) === '1', +}; +let derived: ReviewDerivedState = emptyDerived; + +const buildSnapshot = (): ReviewStoreState => ({ + ...derived, + state: core.state, + pendingReview: core.pendingReview, + isStale: core.isStale, + isInReviewMode: core.isInReviewMode, + isExiting: core.isExiting, +}); + +let snapshot: ReviewStoreState = buildSnapshot(); + +const listeners = new Set<() => void>(); + +const notify = () => { + snapshot = buildSnapshot(); + listeners.forEach((listener) => listener()); +}; + +const commit = (patch: Partial) => { + core = { ...core, ...patch }; + notify(); +}; + +/** + * Single source of truth for review state. Channel handlers and actions write + * to it directly; React reads it via {@link useReview}. + */ +export const reviewStore = { + getState: (): ReviewStoreState => snapshot, + subscribe: (listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + /** Show a review, replacing any displayed or deferred one. */ + displayReview: (next: ReviewState) => { + commit({ state: next, pendingReview: null, isStale: !!next.stale }); + }, + /** Hold an updated payload until the user accepts it. */ + deferReview: (next: ReviewState) => { + commit({ pendingReview: next }); + }, + setStale: (isStale: boolean) => { + commit({ isStale }); + }, + /** Drop all review state (dismissal), including the persisted review-mode flag. */ + clearReview: () => { + sessionStore.remove(REVIEW_MODE_SESSION_KEY); + commit({ state: null, pendingReview: null, isStale: false, isInReviewMode: false }); + }, + /** Toggle review mode, persisted so it survives reloads. */ + setReviewMode: (active: boolean) => { + if (active) { + sessionStore.write(REVIEW_MODE_SESSION_KEY, '1'); + } else { + sessionStore.remove(REVIEW_MODE_SESSION_KEY); + } + commit({ isInReviewMode: active }); + }, + setExiting: (isExiting: boolean) => { + commit({ isExiting }); + }, + /** Push values derived by ReviewProvider from index/status/route inputs. */ + setDerived: (next: ReviewDerivedState) => { + derived = next; + notify(); + }, + reset: () => { + core = { ...emptyCore }; + derived = emptyDerived; + notify(); + }, +}; + +export const useReview = (): ReviewStoreState => + useSyncExternalStore(reviewStore.subscribe, reviewStore.getState, reviewStore.getState); diff --git a/code/core/src/manager/components/review/review-story-info.ts b/code/core/src/manager/components/review/review-story-info.ts new file mode 100644 index 000000000000..4cb312061da1 --- /dev/null +++ b/code/core/src/manager/components/review/review-story-info.ts @@ -0,0 +1,76 @@ +import type { API_IndexHash, StatusesByStoryIdAndTypeId } from 'storybook/internal/types'; +import { CHANGE_DETECTION_STATUS_TYPE_ID } from 'storybook/internal/types'; +import type { API } from 'storybook/manager-api'; + +import { fallbackStoryInfo, type StoryChangeStatus, type StoryInfo } from './review-types.ts'; +import type { ReviewState } from './review-state.ts'; + +const getStoryChangeStatus = ( + allStatuses: StatusesByStoryIdAndTypeId, + storyId: string +): StoryChangeStatus | undefined => { + const changeValue = Object.values(allStatuses[storyId] ?? {}).find( + (status) => status.typeId === CHANGE_DETECTION_STATUS_TYPE_ID + )?.value; + if (changeValue === 'status-value:new') { + return 'new'; + } + if (changeValue === 'status-value:modified') { + return 'modified'; + } + return undefined; +}; + +export const buildNewlyAddedStoryIds = ( + state: ReviewState, + allStatuses: StatusesByStoryIdAndTypeId +): Set => { + const ids = new Set(); + const isChangeDetectedNew = (storyId: string) => + Object.values(allStatuses[storyId] ?? {}).some((status) => status.value === 'status-value:new'); + for (const collection of state.collections) { + for (const storyId of collection.storyIds) { + if (isChangeDetectedNew(storyId)) { + ids.add(storyId); + } + } + } + return ids; +}; + +export const buildStoryInfo = ( + state: ReviewState, + index: API_IndexHash | undefined, + api: API, + allStatuses: StatusesByStoryIdAndTypeId, + newlyAddedStoryIds: Set +): Record => { + const info: Record = {}; + for (const collection of state.collections) { + for (const storyId of collection.storyIds) { + if (storyId in info) { + continue; + } + const direct = index?.[storyId]; + const entry = + direct?.type === 'story' ? direct : index ? api.findLeafEntry(index, storyId) : undefined; + const shared = { + isNewlyAdded: newlyAddedStoryIds.has(storyId) || undefined, + changeStatus: getStoryChangeStatus(allStatuses, storyId), + }; + if (entry?.type === 'story' && entry.title) { + info[storyId] = { + title: entry.title, + name: entry.name, + ...shared, + }; + } else { + info[storyId] = { + ...fallbackStoryInfo(storyId), + ...shared, + }; + } + } + } + return info; +}; diff --git a/code/core/src/manager/components/review/review-types.ts b/code/core/src/manager/components/review/review-types.ts new file mode 100644 index 000000000000..fc7ab8a544f8 --- /dev/null +++ b/code/core/src/manager/components/review/review-types.ts @@ -0,0 +1,20 @@ +export type StoryChangeStatus = 'new' | 'modified'; + +export interface StoryInfo { + title: string; + name: string; + isNewlyAdded?: boolean; + changeStatus?: StoryChangeStatus; +} + +/** Best-effort labels when the Storybook index has not resolved a story yet. */ +export const fallbackStoryInfo = (storyId: string): StoryInfo => { + const separator = storyId.indexOf('--'); + if (separator === -1) { + return { title: storyId, name: 'Story' }; + } + return { + title: storyId.slice(0, separator), + name: storyId.slice(separator + 2) || 'Story', + }; +}; diff --git a/code/core/src/manager/components/review/screens/ReviewSummaryHost.tsx b/code/core/src/manager/components/review/screens/ReviewSummaryHost.tsx new file mode 100644 index 000000000000..2f1630973641 --- /dev/null +++ b/code/core/src/manager/components/review/screens/ReviewSummaryHost.tsx @@ -0,0 +1,61 @@ +import React, { useCallback, type FC } from 'react'; + +import { useStorybookApi } from 'storybook/manager-api'; +import { styled } from 'storybook/theming'; + +import { PRE_REVIEW_RETURN_KEY } from '../constants.ts'; +import { dismissReview } from '../review-actions.ts'; +import { useReview } from '../review-store.ts'; +import { sessionStore } from '../session-store.ts'; +import { SummaryScreen } from './SummaryScreen.tsx'; + +// Fills the layout's content overlay cell. While a reviewed story is open the +// host stays mounted but hidden (visibility, not display or unmount) so +// thumbnail iframes keep their documents alive. +const SummaryHost = styled.div<{ $visible: boolean }>(({ $visible }) => ({ + position: 'absolute', + inset: 0, + display: 'flex', + flexDirection: 'column', + overflow: 'hidden', + visibility: $visible ? 'visible' : 'hidden', + pointerEvents: $visible ? 'auto' : 'none', +})); + +export const ReviewSummaryHost: FC = () => { + const api = useStorybookApi(); + const { state, storyInfo, banner, isInReviewMode, isSummaryVisible } = useReview(); + const getStoryPreviewHref = useCallback( + (storyId: string) => api.getStoryHrefs(storyId, { embed: true, freeze: true }).previewHref, + [api] + ); + const onDismiss = useCallback(() => dismissReview(api), [api]); + + // Mount on the summary route (so the page renders) and throughout review mode + // (so hidden thumbnail iframes survive round-trips to individual stories). + if (!isSummaryVisible && !isInReviewMode) { + return null; + } + + return ( + { + if (node) { + node.inert = !isSummaryVisible; + } + }} + $visible={isSummaryVisible} + data-review-summary={isSummaryVisible ? 'visible' : 'hidden'} + > + + + ); +}; diff --git a/code/core/src/manager/components/review/screens/SummaryScreen.stories.tsx b/code/core/src/manager/components/review/screens/SummaryScreen.stories.tsx new file mode 100644 index 000000000000..71f683c99ff3 --- /dev/null +++ b/code/core/src/manager/components/review/screens/SummaryScreen.stories.tsx @@ -0,0 +1,580 @@ +import React from 'react'; + +import { expect, fn, within } from 'storybook/test'; + +import preview from '../../../../../../.storybook/preview.tsx'; +import { IconSymbols } from '../../sidebar/IconSymbols.tsx'; +import type { ReviewState } from '../review-state.ts'; +import type { StoryInfo } from '../review-types.ts'; +import { SummaryScreen } from './SummaryScreen.tsx'; + +const getStoryPreviewHref = (storyId: string) => + `iframe.html?id=${encodeURIComponent(storyId)}&viewMode=story&embed=true&freeze=finished`; + +const minimal: ReviewState = { + title: 'Button prop rename', + description: + 'Renamed the Button `appearance` prop to `variant` and updated all internal usages. No visual change is expected.', + createdAt: Date.now() - 2 * 60 * 1000, + collections: [ + { + title: 'Button', + rationale: 'The directly changed component.', + storyIds: ['button-component--variants', 'button-component--base'], + }, + ], +}; + +const full: ReviewState = { + title: 'Primary button visual refresh', + createdAt: Date.now() - 14 * 60 * 1000, + description: + 'Made the primary/solid `Button` bolder: font-weight 700 → 800 and larger padding. Outline and ghost variants are unchanged. Start with Variants and Sizes/Paddings, then sanity-check ToggleButton and ReviewChangesButton.', + changedFiles: ['code/core/src/components/components/Button/Button.tsx'], + collections: [ + { + title: 'Core Button — primary/solid variant', + rationale: 'Render the **solid variant** directly; best show the heavier weight and padding.', + storyIds: ['button-component--variants', 'button-component--base', 'button-component--sizes'], + }, + { + title: 'Related Button-based components', + rationale: '`ToggleButton` and `ReviewChangesButton` build on the same `Button` primitive.', + storyIds: ['components-togglebutton--variants', 'components-togglebutton--sizes'], + }, + ], +}; + +const largeCascade: ReviewState = { + title: 'Theme token cascade review', + description: + 'Refactored the shared theme tokens. Change-detection flagged a broad cascade; most consumers are transitive and unlikely to shift visibly. Spot-check the atomic and consumer tiers first.', + changedFiles: ['code/core/src/theming/tokens.ts', 'code/core/src/theming/create.ts'], + collections: [ + ...full.collections, + { + title: 'Storybook manager surfaces', + rationale: + 'Manager views consume shared typography, spacing, and theming tokens that can shift subtly.', + storyIds: [ + 'manager-main--default', + 'manager-sidebar-sidebar--simple', + 'manager-sidebar-sidebar--with-refs', + 'manager-sidebar-sidebar--statuses-open', + ], + }, + { + title: 'Sidebar density and search experience', + rationale: + 'Sidebar layouts are sensitive to token updates in spacing and color contrast, especially in filtered states.', + storyIds: [ + 'manager-sidebar-sidebar--searching', + 'manager-sidebar-sidebar--statuses-open', + 'manager-sidebar-sidebar--with-refs', + 'manager-sidebar-sidebar--with-cta-active', + ], + }, + { + title: 'Settings and onboarding pages', + rationale: + 'Settings pages use shared UI primitives and should be spot-checked for readability and alignment.', + storyIds: [ + 'manager-settings-aboutscreen--default', + 'manager-settings-guidepage--default', + 'manager-settings-shortcutsscreen--defaults', + 'manager-settings-checklist--default', + ], + }, + { + title: 'Core UI primitives', + rationale: + 'Primitive components amplify token regressions across addons and manager surfaces.', + storyIds: [ + 'components-tabs-tabsview--basic', + 'components-card--default', + 'components-collapsible--default', + 'components-bar-bar--default', + ], + }, + { + title: 'Performance and analyzer tools', + rationale: + 'Bench and diagnostics views exercise dense layouts where token changes are easy to miss.', + storyIds: [ + 'bench--es-build-analyzer', + 'manager-sidebar-filesearchmodal--default', + 'manager-sidebar-filesearchlist--default', + 'manager-components-preview-viewport--default', + ], + }, + ], +}; + +const pagesAndBench: ReviewState = { + title: 'Page components and bench analyzer', + description: + 'Validating the review grid with manager page stories alongside the bench analyzer story.', + collections: [ + { + title: 'Manager pages + Bench', + rationale: 'Mixes page-level stories with the ESBuild analyzer story for preview coverage.', + storyIds: [ + 'manager-settings-aboutscreen--default', + 'manager-settings-guidepage--default', + 'manager-main--default', + 'bench--es-build-analyzer', + ], + }, + ], +}; + +const atomicChange: ReviewState = { + title: 'Round up Button border-radius: 4px → 12px (3× theme multiplier)', + description: + 'Changed `borderRadius: theme.input.borderRadius` to `theme.input.borderRadius * 3` in Button.tsx, making all buttons noticeably more pill-shaped; start reviewing Button/Variants and Toolbar/Basic to see the ripple.', + collections: [ + { + title: 'Button — atomic', + rationale: + 'Directly changed component; all variants and sizes now use 12px border-radius instead of 4px.', + storyIds: [ + 'button-component--variants', + 'button-component--sizes', + 'button-component--paddings', + 'button-component--pseudo-states', + 'button-component--icon-only', + 'button-component--base', + ], + }, + { + title: 'Toolbar, Tabs & Select — direct consumers', + rationale: + 'These components embed Button directly and their toolbar/tab buttons will visually reflect the rounder corners.', + storyIds: [ + 'components-toolbar--basic', + 'components-toolbar--scrollable', + 'components-abstracttoolbar--basic', + 'components-tabs--stateful-static', + 'components-tabs--stateless-with-tools', + 'select-component--base', + ], + }, + { + title: 'Modal & Popover — overlays with buttons', + rationale: + 'Modal and Popover action bars contain buttons whose rounded corners are now more prominent.', + storyIds: [ + 'overlay-modal--base', + 'overlay-modal--interactive-mouse', + 'overlay-popover--with-hide-button', + 'overlay-popover--with-chrome', + ], + }, + { + title: 'Docs blocks & Manager menu — transitive', + rationale: + 'Preview action bars and manager menus further down the dependency graph pick up the change through toolbar/button usage.', + storyIds: [ + 'addons-docs-blocks-components-preview--with-toolbar', + 'addons-docs-blocks-components-preview--code-expanded', + 'manager-container-menu--with-shortcuts', + 'manager-container-menu--with-shortcuts-active', + ], + }, + ], + changedFiles: ['core/src/components/components/Button/Button.tsx'], +}; + +const manyCollections: ReviewState = { + title: 'Large review surface: many collections and stories', + description: + 'Stress-test the Summary screen with many collections and many story previews using real stories from the internal Storybook.', + collections: [ + { + title: 'Collection 01 — Button core', + rationale: 'Base button stories and primary variants.', + storyIds: ['button-component--base', 'button-component--variants', 'button-component--sizes'], + }, + { + title: 'Collection 02 — Button details', + rationale: 'Additional button states and icon usage.', + storyIds: [ + 'button-component--paddings', + 'button-component--pseudo-states', + 'button-component--icon-only', + ], + }, + { + title: 'Collection 03 — Toggle and tabs', + rationale: 'ToggleButton and tab view combinations.', + storyIds: [ + 'components-togglebutton--variants', + 'components-togglebutton--sizes', + 'components-tabs-tabsview--basic', + ], + }, + { + title: 'Collection 04 — Tabs and toolbar', + rationale: 'Navigation/tab states with toolbar layouts.', + storyIds: [ + 'components-tabs--stateful-static', + 'components-tabs--stateless-with-tools', + 'components-toolbar--basic', + ], + }, + { + title: 'Collection 05 — Toolbar variants', + rationale: 'Scrollable toolbar and abstract toolbar coverage.', + storyIds: [ + 'components-toolbar--scrollable', + 'components-abstracttoolbar--basic', + 'select-component--base', + ], + }, + { + title: 'Collection 06 — Surface primitives', + rationale: 'Cards, bars, and collapsible primitives.', + storyIds: [ + 'components-card--default', + 'components-bar-bar--default', + 'components-collapsible--default', + ], + }, + { + title: 'Collection 07 — Overlay modal', + rationale: 'Modal base and interactive states.', + storyIds: [ + 'overlay-modal--base', + 'overlay-modal--interactive-mouse', + 'overlay-popover--with-hide-button', + ], + }, + { + title: 'Collection 08 — Overlay popover', + rationale: 'Popover/chrome combinations and behavior.', + storyIds: [ + 'overlay-popover--with-chrome', + 'overlay-popover--with-hide-button', + 'overlay-modal--base', + ], + }, + { + title: 'Collection 09 — Manager main', + rationale: 'Main manager page states.', + storyIds: ['manager-main--default', 'manager-main--about-page', 'manager-main--guide-page'], + }, + { + title: 'Collection 10 — Manager settings', + rationale: 'Settings pages and checklist entry points.', + storyIds: [ + 'manager-settings-aboutscreen--default', + 'manager-settings-guidepage--default', + 'manager-settings-shortcutsscreen--defaults', + ], + }, + { + title: 'Collection 11 — Guide variants', + rationale: 'Guide page variations for state coverage.', + storyIds: [ + 'manager-settings-guidepage--all-done', + 'manager-settings-guidepage--ai-cta-open', + 'manager-settings-guidepage--ai-cta-skipped', + ], + }, + { + title: 'Collection 12 — Guide + freeze stories', + rationale: 'Guide and freeze test stories.', + storyIds: [ + 'manager-settings-guidepage--ai-cta-done', + 'manager-settings-freezebehavior--play-clicks-button', + 'manager-settings-freezebehavior--delayed-play-completion', + ], + }, + { + title: 'Collection 13 — Freeze loop and sidebar', + rationale: 'Freeze animation loop and basic sidebar states.', + storyIds: [ + 'manager-settings-freezebehavior--continuous-loop-and-animation', + 'manager-sidebar-sidebar--simple', + 'manager-sidebar-sidebar--with-refs', + ], + }, + { + title: 'Collection 14 — Sidebar status/search', + rationale: 'Search and status-focused sidebar views.', + storyIds: [ + 'manager-sidebar-sidebar--statuses-open', + 'manager-sidebar-sidebar--searching', + 'manager-sidebar-sidebar--with-cta-active', + ], + }, + { + title: 'Collection 15 — Sidebar file search', + rationale: 'Sidebar file-search specific components.', + storyIds: [ + 'manager-sidebar-filesearchmodal--default', + 'manager-sidebar-filesearchlist--default', + 'manager-container-menu--with-shortcuts', + ], + }, + { + title: 'Collection 16 — Menu and viewport', + rationale: 'Menu variants and preview viewport panel.', + storyIds: [ + 'manager-container-menu--with-shortcuts-active', + 'manager-components-preview-viewport--default', + 'manager-sidebar-sidebar--simple', + ], + }, + { + title: 'Collection 17 — Docs preview blocks', + rationale: 'Docs block preview stories with toolbar/code.', + storyIds: [ + 'addons-docs-blocks-components-preview--with-toolbar', + 'addons-docs-blocks-components-preview--code-expanded', + 'manager-components-preview-viewport--default', + ], + }, + { + title: 'Collection 18 — Bench + docs mix', + rationale: 'Heavier analyzer story plus docs previews.', + storyIds: [ + 'bench--es-build-analyzer', + 'addons-docs-blocks-components-preview--with-toolbar', + 'addons-docs-blocks-components-preview--code-expanded', + ], + }, + { + title: 'Collection 19 — Cross-manager mix', + rationale: 'Mixed manager states to create dense list.', + storyIds: [ + 'manager-main--default', + 'manager-settings-checklist--default', + 'manager-sidebar-sidebar--statuses-open', + ], + }, + { + title: 'Collection 20 — Large mixed tail', + rationale: 'Final mixed collection to reach 20 groups.', + storyIds: [ + 'components-tabs-tabsview--basic', + 'manager-container-menu--with-shortcuts', + 'manager-settings-guidepage--default', + 'bench--es-build-analyzer', + ], + }, + ], +}; + +const titleCase = (value: string) => + value + .split(/[-/]/) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' '); + +// Stand-in for the Storybook index: every story referenced by a fixture resolves +// to a title + name so the grid renders it (stories with no entry are skipped). +const demoStoryInfo: Record = {}; +for (const state of [minimal, full, largeCascade, pagesAndBench, atomicChange, manyCollections]) { + for (const collection of state.collections) { + for (const id of collection.storyIds) { + if (!demoStoryInfo[id]) { + const [componentId, ...rest] = id.split('--'); + demoStoryInfo[id] = { + title: titleCase(componentId), + name: titleCase(rest.join('--')) || 'Story', + }; + } + } + } +} + +const fortyStoryCollection: ReviewState = { + title: 'Large collection overflow', + description: + 'A single collection with 40 stories to exercise the two-row cap and "Review all" overflow affordance.', + collections: [ + { + title: 'All changed stories', + rationale: 'Every story affected by this change.', + storyIds: [ + 'button-component--base', + 'button-component--variants', + 'button-component--sizes', + 'button-component--paddings', + 'button-component--pseudo-states', + 'button-component--icon-only', + 'components-togglebutton--variants', + 'components-togglebutton--sizes', + 'components-tabs-tabsview--basic', + 'components-tabs--stateful-static', + 'components-tabs--stateless-with-tools', + 'components-toolbar--basic', + 'components-toolbar--scrollable', + 'components-abstracttoolbar--basic', + 'select-component--base', + 'components-card--default', + 'components-bar-bar--default', + 'components-collapsible--default', + 'overlay-modal--base', + 'overlay-modal--interactive-mouse', + 'overlay-popover--with-hide-button', + 'overlay-popover--with-chrome', + 'manager-main--default', + 'manager-main--about-page', + 'manager-main--guide-page', + 'manager-settings-aboutscreen--default', + 'manager-settings-guidepage--default', + 'manager-settings-shortcutsscreen--defaults', + 'manager-settings-checklist--default', + 'manager-sidebar-sidebar--simple', + 'manager-sidebar-sidebar--with-refs', + 'manager-sidebar-sidebar--statuses-open', + 'manager-sidebar-sidebar--searching', + 'manager-sidebar-sidebar--with-cta-active', + 'manager-sidebar-filesearchmodal--default', + 'manager-sidebar-filesearchlist--default', + 'manager-container-menu--with-shortcuts', + 'manager-container-menu--with-shortcuts-active', + 'manager-components-preview-viewport--default', + 'bench--es-build-analyzer', + ], + }, + ], +}; + +const meta = preview.meta({ + component: SummaryScreen, + decorators: [ + (Story) => ( + <> + + + + ), + ], + parameters: { + layout: 'fullscreen', + chromatic: { + ignoreSelectors: ['[data-testid="review-collection-grid-cell"] iframe'], + }, + }, + args: { getStoryPreviewHref, storyInfo: demoStoryInfo, onDismiss: () => {} }, +}); + +export const Empty = meta.story({ + args: { state: null }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(await canvas.findByText(/Waiting for the agent/i)).toBeInTheDocument(); + }, +}); + +export const Minimal = meta.story({ + args: { state: minimal }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(await canvas.findByText('Button prop rename')).toBeInTheDocument(); + await expect(await canvas.findByText(/2 stories for quick review/i)).toBeInTheDocument(); + await expect(await canvas.findByText('Button')).toBeInTheDocument(); + await expect(await canvas.findByText('The directly changed component.')).toBeInTheDocument(); + await expect(await canvas.findByRole('link', { name: 'Exit review' })).toBeInTheDocument(); + }, +}); + +export const Full = meta.story({ + args: { + state: full, + storyInfo: { + // Collection 1 — first story is new + 'button-component--variants': { title: 'Button', name: 'Variants', isNewlyAdded: true }, + 'button-component--base': { title: 'Button', name: 'Base' }, + 'button-component--sizes': { title: 'Button', name: 'Sizes' }, + // Collection 2 — first story is new + 'components-togglebutton--variants': { + title: 'ToggleButton', + name: 'Variants', + isNewlyAdded: true, + }, + 'components-togglebutton--sizes': { title: 'ToggleButton', name: 'Sizes' }, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(await canvas.findByText('Primary button visual refresh')).toBeInTheDocument(); + }, +}); + +export const LargeCascade = meta.story({ + args: { state: largeCascade }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(await canvas.findByText('Theme token cascade review')).toBeInTheDocument(); + await expect(await canvas.findByText('Storybook manager surfaces')).toBeInTheDocument(); + }, +}); + +export const PagesAndBench = meta.story({ + args: { state: pagesAndBench }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(await canvas.findByText('Page components and bench analyzer')).toBeInTheDocument(); + await expect(await canvas.findByText('Manager pages + Bench')).toBeInTheDocument(); + }, +}); + +export const RealAtomicChange = meta.story({ + args: { state: atomicChange }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + await canvas.findByText('Round up Button border-radius: 4px → 12px (3× theme multiplier)') + ).toBeInTheDocument(); + await expect(await canvas.findByText('Button — atomic')).toBeInTheDocument(); + }, +}); + +export const Stale = meta.story({ + args: { state: full, banner: { kind: 'stale' } }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(await canvas.findByText(/Code changes detected/)).toBeInTheDocument(); + await expect(await canvas.findByText('Ask your agent to refresh it.')).toBeInTheDocument(); + await expect(await canvas.findByText('Primary button visual refresh')).toBeInTheDocument(); + }, +}); + +export const PendingUpdate = meta.story({ + args: { state: full, banner: { kind: 'pending-update', onAccept: fn() } }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(await canvas.findByText('A new review is available.')).toBeInTheDocument(); + await expect(await canvas.findByRole('button', { name: 'Update' })).toBeInTheDocument(); + await expect(canvas.queryByText(/Code changes detected/)).not.toBeInTheDocument(); + }, +}); + +// A single collection with 40 stories: the grid caps at 2 rows and shows a +// "Review all 40" button in the last slot until the user expands it. +export const LargeCollectionOverflow = meta.story({ + args: { state: fortyStoryCollection }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(await canvas.findByText('Large collection overflow')).toBeInTheDocument(); + await expect(await canvas.findByRole('button', { name: /Review all 40/i })).toBeInTheDocument(); + }, +}); + +export const ManyCollections = meta.story({ + args: { state: manyCollections }, + parameters: { chromatic: { disableSnapshot: true } }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + await canvas.findByText('Large review surface: many collections and stories') + ).toBeInTheDocument(); + await expect(await canvas.findByText('Collection 01 — Button core')).toBeInTheDocument(); + await expect(await canvas.findByText('Collection 20 — Large mixed tail')).toBeInTheDocument(); + }, +}); diff --git a/code/core/src/manager/components/review/screens/SummaryScreen.tsx b/code/core/src/manager/components/review/screens/SummaryScreen.tsx new file mode 100644 index 000000000000..d6b10f52a190 --- /dev/null +++ b/code/core/src/manager/components/review/screens/SummaryScreen.tsx @@ -0,0 +1,433 @@ +import React, { useEffect, useLayoutEffect, useMemo, useState, type FC } from 'react'; + +import { + Button, + Card, + Collapsible, + DocumentWrapper, + IconButton, + ScrollArea, +} from 'storybook/internal/components'; +import { styled } from 'storybook/theming'; + +import { + CheckIcon, + ChevronSmallDownIcon, + ChevronSmallLeftIcon, + CloseIcon, + CopyIcon, + StorybookIcon, + WandIcon, +} from '@storybook/icons'; + +import { AttentionBanner } from '../components/AttentionBanner.tsx'; +import { CollectionGrid } from '../components/CollectionGrid.tsx'; +import { CopyButton } from '../components/CopyButton.tsx'; +import { Markdown } from '../components/Markdown.tsx'; +import { ReviewHeader } from '../components/ReviewHeader.tsx'; +import { + REVIEW_SUMMARY_BACK_ATTR, + buildReviewStoryHref, + buildSummaryBackHref, +} from '../review-navigation.ts'; +import type { ReviewState } from '../review-state.ts'; +import type { ReviewBanner } from '../review-store.ts'; +import type { StoryInfo } from '../review-types.ts'; + +const MarkdownWrapper = styled(DocumentWrapper)(({ theme }) => ({ + color: theme.color.defaultText, + p: { + margin: 0, + }, + 'p + p': { + marginTop: 10, + }, + code: { + color: 'inherit', + verticalAlign: 'text-bottom', + fontSize: '0.85em', + margin: 0, + padding: '0 4px', + background: 'transparent', + border: 'none', + boxShadow: `inset 0 0 0 1px ${theme.appBorderColor}`, + borderRadius: theme.appBorderRadius, + }, +})); + +// `100dvh` fills the manager's page cell and also works in the addon's own +// fullscreen stories, where #storybook-root has no height. The card list below +// the fixed header is the single scroll container. +const Page = styled.div(({ theme }) => ({ + display: 'flex', + flexDirection: 'column', + height: '100dvh', + minHeight: 0, + overflow: 'hidden', + background: theme.background.app, + color: theme.color.defaultText, + fontFamily: theme.typography.fonts.base, + fontSize: theme.typography.size.s2, +})); + +const Empty = styled.div(({ theme }) => ({ + display: 'flex', + flexDirection: 'column', + gap: 16, + alignItems: 'center', + justifyContent: 'center', + height: '100dvh', + color: theme.color.defaultText, + '& > div': { + display: 'flex', + alignItems: 'center', + gap: 8, + }, +})); + +// Wrapper that gives the overlay ScrollArea a bounded height to scroll within. +const ListScroll = styled.div({ + flex: 1, + minHeight: 0, +}); + +const List = styled.div({ + display: 'flex', + flexDirection: 'column', + gap: 12, + padding: 12, + // Cards must keep their intrinsic height so the list scrolls; without this + // the default flex-shrink collapses them to fit the viewport and Card's + // overflow:hidden clips the content at the bottom. + '& > *': { + flexShrink: 0, + }, +}); + +const SummaryCard = styled(Card)({ + display: 'flex', + alignItems: 'flex-start', + padding: '9px 12px', + gap: 10, + svg: { + flexShrink: 0, + marginTop: 4, + }, +}); + +const SummaryContent = styled(MarkdownWrapper)({ + flex: 1, + minWidth: 0, +}); + +// A plain clickable row, not a semantic control: making the whole header +// toggle is just a convenience affordance for pointer users. The real +// accessible control is the chevron inside, which carries the +// aria-label and aria-expanded state for assistive technologies. +const CardHead = styled.div({ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: 8, + padding: '6px 10px 6px 12px', + minHeight: 40, + cursor: 'pointer', +}); + +const CardTitle = styled.strong(({ theme }) => ({ + minWidth: 0, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + fontWeight: theme.typography.weight.bold, + lineHeight: '20px', + color: theme.color.defaultText, +})); + +const CardControls = styled.div({ + display: 'flex', + alignItems: 'center', + justifyContent: 'flex-end', + gap: 6, + flexShrink: 0, +}); + +const CardCount = styled.span(({ theme }) => ({ + minWidth: 28, + fontFamily: theme.typography.fonts.mono, + fontSize: theme.typography.size.s2 - 1, + lineHeight: '20px', + textAlign: 'center', + color: theme.textMutedColor, +})); + +const ToggleChevronIcon = styled(ChevronSmallDownIcon)({ + transition: 'transform 160ms ease', +}); + +const CardRationale = styled(MarkdownWrapper)(({ theme }) => ({ + color: theme.textMutedColor, + margin: '0 12px', +})); + +const Footer = styled.div(({ theme }) => ({ + color: theme.textMutedColor, + padding: '10px 10px 30px', + fontSize: theme.typography.size.s2, + textAlign: 'center', + textWrap: 'balance', +})); + +const formatCreatedAgo = (createdAt: number, nowMs: number): string => { + const elapsedMs = Math.max(0, nowMs - createdAt); + if (elapsedMs < 60_000) { + return 'just now'; + } + const elapsedMinutes = Math.floor(elapsedMs / 60_000); + if (elapsedMinutes < 60) { + return `${elapsedMinutes}m ago`; + } + return `${Math.floor(elapsedMinutes / 60)}h ago`; +}; + +export interface SummaryScreenProps { + state: ReviewState | null; + /** Story id → component title + name, resolved from the Storybook index. */ + storyInfo?: Record; + /** Builds the (frozen) preview iframe src for a story thumbnail. */ + getStoryPreviewHref: (storyId: string) => string; + /** Attention banner to render at the top (pending-update or stale). */ + banner?: ReviewBanner; + /** Keep summary preview iframes mounted while the overlay is hidden. */ + summaryHidden?: boolean; + /** Clears the active review (if any) and returns to the pre-review canvas. */ + onDismiss: () => void; + /** Pre-review canvas search, or root when none is recorded yet. */ + returnSearch?: string | null; +} + +export const SummaryScreen: FC = ({ + state, + storyInfo = {}, + getStoryPreviewHref, + banner = null, + summaryHidden = false, + onDismiss, + returnSearch = null, +}) => { + const [expandedCollections, setExpandedCollections] = useState>(() => new Set()); + const [showAllCollections, setShowAllCollections] = useState>(() => new Set()); + const [showNewOnly, setShowNewOnly] = useState(false); + const [nowMs, setNowMs] = useState(() => Date.now()); + + useEffect(() => { + const intervalId = window.setInterval(() => { + setNowMs(Date.now()); + }, 10000); + return () => window.clearInterval(intervalId); + }, []); + + useLayoutEffect(() => { + if (!state) { + setExpandedCollections(new Set()); + setShowAllCollections(new Set()); + return; + } + setExpandedCollections(new Set(state.collections.map((_, index) => index))); + setShowAllCollections(new Set()); + }, [state?.createdAt]); + + // Must be computed before the early return — hooks cannot be called conditionally. + const newStoryCount = useMemo( + () => + new Set( + (state?.collections ?? []) + .flatMap((c) => c.storyIds) + .filter((id) => storyInfo[id]?.isNewlyAdded) + ).size, + [state, storyInfo] + ); + + const visibleCollections = useMemo( + () => + (state?.collections ?? []) + .map((collection, index) => { + const storyIds = showNewOnly + ? collection.storyIds.filter((id) => storyInfo[id]?.isNewlyAdded) + : collection.storyIds; + return { collection, index, storyIds }; + }) + .filter((entry) => entry.storyIds.length > 0), + [state?.collections, showNewOnly, storyInfo] + ); + + if (!state) { + return ( + + Waiting for the agent to display a review… +
    + + Copy prompt + + } + > + + Copy prompt + + +
    +
    + ); + } + + const storyCount = new Set(state.collections.flatMap((collection) => collection.storyIds)).size; + const createdAgo = state.createdAt ? formatCreatedAgo(state.createdAt, nowMs) : null; + + const toggleCollection = (index: number) => { + setExpandedCollections((previous) => { + const next = new Set(previous); + if (next.has(index)) { + next.delete(index); + } else { + next.add(index); + } + return next; + }); + }; + const markCollectionShowAll = (index: number) => { + setShowAllCollections((previous) => + previous.has(index) ? previous : new Set(previous).add(index) + ); + }; + + return ( + + {banner && } + + + + + + + } + title={state.title} + subtitle={ + <> + + Showing {storyCount} {storyCount === 1 ? 'story' : 'stories'} for quick review + + {createdAgo ? ( + <> + + {createdAgo} + + ) : null} + + } + actions={ + newStoryCount > 0 ? ( + + ) : null + } + /> + + + + + + + + {'**Summary:** ' + state.description} + + + {visibleCollections.length === 0 ? ( +
    {showNewOnly ? 'No new stories found.' : 'No collections found.'}
    + ) : ( + visibleCollections.map(({ collection, index, storyIds }) => { + const isExpanded = expandedCollections.has(index); + return ( + + toggleCollection(index)}> + {collection.title} + + {storyIds.length} + { + event.stopPropagation(); + toggleCollection(index); + }} + > + + + + + } + > + {collection.rationale ? ( + + {collection.rationale} + + ) : null} + markCollectionShowAll(index)} + storyInfo={storyInfo} + getStoryHref={(storyId) => + buildReviewStoryHref({ collectionIndex: index, storyId }) + } + getStoryPreviewHref={getStoryPreviewHref} + summaryHidden={summaryHidden} + /> + + + ); + }) + )} +
    + This review shows the {storyCount} {storyCount === 1 ? 'story' : 'stories'} most + relevant for you to spot-check right now. Because this is AI-curated, results may be + inaccurate or incomplete. +
    +
    +
    +
    +
    + ); +}; diff --git a/code/core/src/manager/components/review/session-store.ts b/code/core/src/manager/components/review/session-store.ts new file mode 100644 index 000000000000..f9a12233a587 --- /dev/null +++ b/code/core/src/manager/components/review/session-store.ts @@ -0,0 +1,27 @@ +// The single place the review addon touches `sessionStorage`. Centralizing it +// here keeps persistence consistent and guards every access: `sessionStorage` +// can throw (privacy modes, disabled storage, sandboxed iframes), so failures +// degrade gracefully and in-memory state still drives the current session. +export const sessionStore = { + read(key: string): string | null { + try { + return sessionStorage.getItem(key); + } catch { + return null; + } + }, + write(key: string, value: string): void { + try { + sessionStorage.setItem(key, value); + } catch { + // Storage unavailable — ignore. + } + }, + remove(key: string): void { + try { + sessionStorage.removeItem(key); + } catch { + // Storage unavailable — ignore. + } + }, +}; diff --git a/code/core/src/manager/components/review/useReviewFiltersRef.ts b/code/core/src/manager/components/review/useReviewFiltersRef.ts new file mode 100644 index 000000000000..f3514cb24b31 --- /dev/null +++ b/code/core/src/manager/components/review/useReviewFiltersRef.ts @@ -0,0 +1,30 @@ +import { type MutableRefObject, useRef } from 'react'; + +import { useStorybookState } from 'storybook/manager-api'; +import type { StatusValue } from 'storybook/internal/types'; + +import { type ReviewModeFilters } from './review-mode.ts'; + +/** + * Keep the current sidebar filters in a ref so click/shortcut handlers can read + * them without re-binding. enterReviewMode snapshots these and they're restored + * on exit; the ref is updated on every render to stay in sync with the store. + */ +export const useReviewFiltersRef = (): MutableRefObject => { + const { includedStatusFilters, excludedStatusFilters, includedTagFilters, excludedTagFilters } = + useStorybookState(); + + const filtersRef = useRef({ + includedStatusFilters: [], + excludedStatusFilters: [], + includedTagFilters: [], + excludedTagFilters: [], + }); + filtersRef.current = { + includedStatusFilters: (includedStatusFilters ?? []) as StatusValue[], + excludedStatusFilters: (excludedStatusFilters ?? []) as StatusValue[], + includedTagFilters: includedTagFilters ?? [], + excludedTagFilters: excludedTagFilters ?? [], + }; + return filtersRef; +}; diff --git a/code/core/src/manager/components/review/useReviewNavigationInterceptor.ts b/code/core/src/manager/components/review/useReviewNavigationInterceptor.ts new file mode 100644 index 000000000000..f4e98998913c --- /dev/null +++ b/code/core/src/manager/components/review/useReviewNavigationInterceptor.ts @@ -0,0 +1,79 @@ +import { useEffect } from 'react'; + +import { useNavigate } from 'storybook/internal/router'; +import { useStorybookApi } from 'storybook/manager-api'; + +import { PRE_REVIEW_RETURN_KEY } from './constants.ts'; +import { + navigateOutOfReview, + navigateToReviewEntry, + navigateToReviewSummary, +} from './review-actions.ts'; +import { + REVIEW_COLLECTION_QUERY_PARAM, + REVIEW_SUMMARY_BACK_ATTR, + buildReviewChangesSummaryHref, + parseReviewStoryHref, +} from './review-navigation.ts'; +import { sessionStore } from './session-store.ts'; +import { useReviewFiltersRef } from './useReviewFiltersRef.ts'; + +const isReviewStoryHref = (href: string) => + href.startsWith('?path=/story/') && href.includes(`${REVIEW_COLLECTION_QUERY_PARAM}=`); + +const isReviewSummaryHref = (href: string) => href === buildReviewChangesSummaryHref(); + +/** + * Intercepts primary clicks on in-page review navigation links for SPA + * transitions. Real hrefs are preserved for middle-click and open-in-new-tab. + */ +export const useReviewNavigationInterceptor = () => { + const navigate = useNavigate(); + const api = useStorybookApi(); + const filtersRef = useReviewFiltersRef(); + + useEffect(() => { + const onClick = (event: MouseEvent) => { + if ( + event.defaultPrevented || + event.button !== 0 || + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.altKey + ) { + return; + } + const { target } = event; + const anchor = target instanceof Element ? target.closest('a') : null; + const href = anchor?.getAttribute('href'); + if (!href) { + return; + } + + if (anchor?.hasAttribute(REVIEW_SUMMARY_BACK_ATTR)) { + event.preventDefault(); + void navigateOutOfReview(api, navigate, sessionStore.read(PRE_REVIEW_RETURN_KEY)); + return; + } + + if (!isReviewStoryHref(href) && !isReviewSummaryHref(href)) { + return; + } + event.preventDefault(); + + if (isReviewSummaryHref(href)) { + navigateToReviewSummary(api, navigate, filtersRef.current); + return; + } + + const entry = parseReviewStoryHref(href); + if (!entry) { + return; + } + navigateToReviewEntry(api, navigate, entry, filtersRef.current); + }; + document.addEventListener('click', onClick); + return () => document.removeEventListener('click', onClick); + }, [api, navigate, filtersRef]); +}; diff --git a/code/core/src/manager/components/review/useReviewShortcuts.ts b/code/core/src/manager/components/review/useReviewShortcuts.ts new file mode 100644 index 000000000000..459a59581eaa --- /dev/null +++ b/code/core/src/manager/components/review/useReviewShortcuts.ts @@ -0,0 +1,92 @@ +import { useCallback, useEffect, useRef } from 'react'; + +import { useNavigate } from 'storybook/internal/router'; +import { useStorybookApi } from 'storybook/manager-api'; + +import { ADDON_ID } from './constants.ts'; +import { navigateToReviewEntry, navigateToReviewSummary } from './review-actions.ts'; +import { + buildReviewShortcutHrefs, + parseReviewStoryHref, + type ReviewShortcutHrefs, +} from './review-navigation.ts'; +import { useReview } from './review-store.ts'; +import { useReviewFiltersRef } from './useReviewFiltersRef.ts'; + +/** + * Register review navigation as customizable addon shortcuts and keep their + * targets in sync with the active reviewed story. + */ +export const useReviewShortcuts = () => { + const api = useStorybookApi(); + const navigate = useNavigate(); + const { state, flattenedEntries, activeEntry, activeIndex } = useReview(); + const shortcutHrefsRef = useRef(null); + const filtersRef = useReviewFiltersRef(); + + const navigateToShortcut = useCallback( + (target: keyof ReviewShortcutHrefs) => { + const hrefs = shortcutHrefsRef.current; + if (!hrefs) { + return; + } + + if (target === 'back') { + navigateToReviewSummary(api, navigate, filtersRef.current); + return; + } + + const entry = parseReviewStoryHref(hrefs[target]); + if (!entry) { + return; + } + navigateToReviewEntry(api, navigate, entry, filtersRef.current); + }, + [api, navigate, filtersRef] + ); + + useEffect(() => { + if (state && activeEntry && activeIndex >= 0) { + shortcutHrefsRef.current = buildReviewShortcutHrefs( + state.collections, + flattenedEntries, + activeIndex + ); + } else { + shortcutHrefsRef.current = null; + } + }, [state, activeEntry, activeIndex, flattenedEntries]); + + useEffect(() => { + api.setAddonShortcut(ADDON_ID, { + label: 'Review: back to overview', + defaultShortcut: ['escape'], + actionName: 'reviewBack', + action: () => navigateToShortcut('back'), + }); + api.setAddonShortcut(ADDON_ID, { + label: 'Review: previous story', + defaultShortcut: ['ArrowLeft'], + actionName: 'reviewPreviousStory', + action: () => navigateToShortcut('previous'), + }); + api.setAddonShortcut(ADDON_ID, { + label: 'Review: next story', + defaultShortcut: ['ArrowRight'], + actionName: 'reviewNextStory', + action: () => navigateToShortcut('next'), + }); + api.setAddonShortcut(ADDON_ID, { + label: 'Review: previous collection', + defaultShortcut: ['ArrowUp'], + actionName: 'reviewPreviousCollection', + action: () => navigateToShortcut('previousCollection'), + }); + api.setAddonShortcut(ADDON_ID, { + label: 'Review: next collection', + defaultShortcut: ['ArrowDown'], + actionName: 'reviewNextCollection', + action: () => navigateToShortcut('nextCollection'), + }); + }, [api, navigateToShortcut]); +}; diff --git a/code/core/src/manager/components/sidebar/ContextMenu.tsx b/code/core/src/manager/components/sidebar/ContextMenu.tsx index 05842b9c2b5b..1575b33bafa3 100644 --- a/code/core/src/manager/components/sidebar/ContextMenu.tsx +++ b/code/core/src/manager/components/sidebar/ContextMenu.tsx @@ -23,6 +23,8 @@ import { TypeIconWithSymbol } from './TypeIcon.tsx'; export type ContextMenuEntryMethod = 'pointer' | 'keyboard'; +const EMPTY_LINKS: Link[] = []; + function getGoToLabel(context: API_HashEntry): string | null { if (context.type === 'docs') { return 'Go to page'; @@ -37,7 +39,7 @@ function getGoToLabel(context: API_HashEntry): string | null { return null; } -export function hasContextMenu(context: API_HashEntry): boolean { +export function hasContextMenu(context: API_HashEntry, hasTestProviders = false): boolean { // Never show the ContextMenu in production. if (globalThis.CONFIG_TYPE !== 'DEVELOPMENT') { return false; @@ -50,7 +52,9 @@ export function hasContextMenu(context: API_HashEntry): boolean { return ( ('importPath' in context && Boolean(context.importPath)) || context.type === 'story' || - context.type === 'docs' + context.type === 'docs' || + // Test providers contribute entries (e.g. "run tests for this group") to branch rows. + (hasTestProviders && (context.type === 'group' || context.type === 'component')) ); } @@ -61,114 +65,130 @@ export const ContextMenu: FC<{ onSelectStoryId: (id: string) => void; api: API; entryMethod?: ContextMenuEntryMethod; -}> = memo(({ context, isOpen, setIsOpen, onSelectStoryId, api, entryMethod }) => { - const exportName = context && 'exportName' in context ? (context.exportName ?? '') : ''; - const { children: copyText, buttonProps: copyButtonProps } = useCopyButton({ - children: 'Copy story name', - content: exportName, - }); - - const topLinks = useMemo(() => { - const defaultLinks: Link[] = []; - - const shortcutKeys = api.getShortcutKeys(); - - // When opened via keyboard shortcut, put a navigation link at the top, so users with - // motor disability have a way to navigate to stories with child tests. - if (entryMethod === 'keyboard') { - const goToLabel = getGoToLabel(context); - if (goToLabel) { + /** Per-status entries (navigate + select the status); rendered as their own group. */ + statusLinks?: Link[]; + /** Whether any test provider addon is registered (they add entries for branch rows). */ + hasTestProviders?: boolean; +}> = memo( + ({ + context, + isOpen, + setIsOpen, + onSelectStoryId, + api, + entryMethod, + statusLinks = EMPTY_LINKS, + hasTestProviders = false, + }) => { + const exportName = context && 'exportName' in context ? (context.exportName ?? '') : ''; + const { children: copyText, buttonProps: copyButtonProps } = useCopyButton({ + children: 'Copy story name', + content: exportName, + }); + + const topLinks = useMemo(() => { + const defaultLinks: Link[] = []; + + const shortcutKeys = api.getShortcutKeys(); + + // When opened via keyboard shortcut, put a navigation link at the top, so users with + // motor disability have a way to navigate to stories with child tests. + if (entryMethod === 'keyboard') { + const goToLabel = getGoToLabel(context); + if (goToLabel) { + defaultLinks.push({ + id: 'go-to-item', + title: goToLabel, + icon: , + onClick: (e: SyntheticEvent) => { + e.preventDefault(); + onSelectStoryId(context.id); + setIsOpen(false); + }, + }); + } + } + + if (context && 'importPath' in context && context.importPath) { defaultLinks.push({ - id: 'go-to-item', - title: goToLabel, - icon: , + id: 'open-in-editor', + title: 'Open in editor', + icon: , + right: shortcutKeys?.openInEditor ? : null, onClick: (e: SyntheticEvent) => { - e.preventDefault(); - onSelectStoryId(context.id); - setIsOpen(false); + if (context.importPath) { + e.preventDefault(); + api.openInEditor({ file: context.importPath }); + } }, }); } - } - if (context && 'importPath' in context && context.importPath) { - defaultLinks.push({ - id: 'open-in-editor', - title: 'Open in editor', - icon: , - right: shortcutKeys ? : null, - onClick: (e: SyntheticEvent) => { - if (context.importPath) { + if (context.type === 'story') { + defaultLinks.push({ + id: 'copy-story-name', + title: copyText, + icon: , + // FIXME/TODO: bring this back once we want to add shortcuts for this + // right: + // enableShortcuts && shortcutKeys.copyStoryName ? ( + // + // ) : null, + onClick: (e: SyntheticEvent) => { e.preventDefault(); - api.openInEditor({ file: context.importPath }); - } - }, - }); - } - - if (context.type === 'story') { - defaultLinks.push({ - id: 'copy-story-name', - title: copyText, - icon: , - // FIXME/TODO: bring this back once we want to add shortcuts for this - // right: - // enableShortcuts && shortcutKeys.copyStoryName ? ( - // - // ) : null, - onClick: (e: SyntheticEvent) => { - e.preventDefault(); - copyButtonProps.onClick(e); - }, - }); - } + copyButtonProps.onClick(e); + }, + }); + } - return defaultLinks; - }, [api, onSelectStoryId, context, copyText, copyButtonProps, entryMethod, setIsOpen]); + return defaultLinks; + }, [api, onSelectStoryId, context, copyText, copyButtonProps, entryMethod, setIsOpen]); - const handleOpen = useCallback( - (event: SyntheticEvent) => { - event.stopPropagation(); - setIsOpen(true); - }, - [setIsOpen] - ); + const handleOpen = useCallback( + (event: SyntheticEvent) => { + event.stopPropagation(); + setIsOpen(true); + }, + [setIsOpen] + ); - // Never show the ContextMenu in production - if (globalThis.CONFIG_TYPE !== 'DEVELOPMENT') { - return null; - } + // Never show the ContextMenu in production + if (globalThis.CONFIG_TYPE !== 'DEVELOPMENT') { + return null; + } - const shouldRender = !context.refId && topLinks.length > 0; - if (!shouldRender) { - return null; - } + const shouldRender = + !context.refId && (topLinks.length > 0 || statusLinks.length > 0 || hasTestProviders); + if (!shouldRender) { + return null; + } - return ( - } - hasChrome={true} - padding={0} - > - } + hasChrome={true} + padding={0} > - - - - ); -}); + + + + + ); + } +); ContextMenu.displayName = 'ContextMenu'; /** @@ -194,7 +214,7 @@ const LiveContextMenu: FC<{ context: API_HashEntry } & ComponentProps group.length > 0); return ; }; diff --git a/code/core/src/manager/components/sidebar/Explorer.stories.tsx b/code/core/src/manager/components/sidebar/Explorer.stories.tsx index 774986ca7d39..53aecfdaf837 100644 --- a/code/core/src/manager/components/sidebar/Explorer.stories.tsx +++ b/code/core/src/manager/components/sidebar/Explorer.stories.tsx @@ -1,5 +1,9 @@ import React from 'react'; +import type { API } from 'storybook/manager-api'; + +import { fn } from 'storybook/test'; + import { Explorer } from './Explorer.tsx'; import { IconSymbols } from './IconSymbols.tsx'; import * as RefStories from './Refs.stories.tsx'; @@ -73,6 +77,14 @@ const withRefs: Record = { }, }; +const mockApi = { + on: fn().mockName('api::on'), + off: fn().mockName('api::off'), + emit: fn().mockName('api::emit'), + getElements: fn(() => ({})).mockName('api::getElements'), + getShortcutKeys: fn(() => ({})).mockName('api::getShortcutKeys'), +} as unknown as API; + export const Simple = () => ( ( storyId: 'root-1-child-a2--grandchild-a1-1:test1', }} isLoading={false} - isDevelopment={true} + api={mockApi} isBrowsing hasEntries={true} isHidden={false} @@ -96,7 +108,7 @@ export const WithRefs = () => ( storyId: 'root-1-child-a2--grandchild-a1-1', }} isLoading={false} - isDevelopment={true} + api={mockApi} isBrowsing hasEntries={true} isHidden={false} diff --git a/code/core/src/manager/components/sidebar/FilterPanel.stories.tsx b/code/core/src/manager/components/sidebar/FilterPanel.stories.tsx index a8d760be9989..6612feb0898c 100644 --- a/code/core/src/manager/components/sidebar/FilterPanel.stories.tsx +++ b/code/core/src/manager/components/sidebar/FilterPanel.stories.tsx @@ -6,11 +6,20 @@ import type { StatusValue, } from 'storybook/internal/types'; import type { Meta, StoryObj } from '@storybook/react-vite'; +import { expect, within } from 'storybook/test'; import type { API } from '../../../manager-api/index.ts'; +import { REVIEW_STATUS_TYPE_ID } from '../../../shared/status-store/index.ts'; import { IconSymbolsDecorator, MockAPIDecorator } from './Filter.story-helpers.tsx'; import { FilterPanel } from './FilterPanel.tsx'; +const getStatusFilterTitles = (canvas: ReturnType) => + canvas + .getAllByRole('checkbox') + .map((checkbox: HTMLElement) => checkbox.getAttribute('aria-label') ?? '') + .filter((label: string) => label.startsWith('status filter:')) + .map((label: string) => label.replace(/^status filter: (?:exclude )?/, '')); + const getEntries = (includeUserTags: boolean) => { const entries = { 'c1-autodocs': { tags: ['tag1', 'autodocs'], type: 'docs' } as DocsIndexEntry, @@ -251,6 +260,63 @@ export const WithStatuses: Story = { } ), }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await expect(getStatusFilterTitles(canvas)).toEqual(['New', 'Modified', 'Related']); + expect(canvas.queryByRole('checkbox', { name: /Reviewing/i })).not.toBeInTheDocument(); + }, +}; + +export const WithAllStatuses: Story = { + args: { + allStatuses: makeStatuses( + { + storyId: 'c1-story1', + typeId: 'change-detection', + statusValue: 'status-value:new', + title: 'New', + }, + { + storyId: 'c1-story2', + typeId: 'change-detection', + statusValue: 'status-value:modified', + title: 'Modified', + }, + { + storyId: 'c2-story1', + typeId: 'change-detection', + statusValue: 'status-value:affected', + title: 'Related', + }, + { + storyId: 'c2-story2', + typeId: REVIEW_STATUS_TYPE_ID, + statusValue: 'status-value:reviewing', + title: 'Reviewing', + } + ), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await expect(getStatusFilterTitles(canvas)).toEqual([ + 'Reviewing', + 'New', + 'Modified', + 'Related', + ]); + + const labels = canvas + .getAllByRole('checkbox') + .map((checkbox) => checkbox.getAttribute('aria-label')); + const reviewingIndex = labels.findIndex((label) => label?.includes('Reviewing')); + const docsIndex = labels.findIndex((label) => label?.includes('Documentation')); + + expect(reviewingIndex).toBeGreaterThanOrEqual(0); + expect(docsIndex).toBeGreaterThanOrEqual(0); + expect(reviewingIndex).toBeLessThan(docsIndex); + }, }; export const WithStatusesIncluded: Story = { @@ -299,3 +365,24 @@ export const OnlyRelatedStatus: Story = { }), }, }; + +export const OnlyReviewingStatus: Story = { + args: { + allStatuses: makeStatuses({ + storyId: 'c2-story2', + typeId: REVIEW_STATUS_TYPE_ID, + statusValue: 'status-value:reviewing', + title: 'Reviewing', + }), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await expect(getStatusFilterTitles(canvas)).toEqual([ + 'Reviewing', + 'New', + 'Modified', + 'Related', + ]); + }, +}; diff --git a/code/core/src/manager/components/sidebar/FilterPanel.tsx b/code/core/src/manager/components/sidebar/FilterPanel.tsx index d91db0873840..d26dc63bf421 100644 --- a/code/core/src/manager/components/sidebar/FilterPanel.tsx +++ b/code/core/src/manager/components/sidebar/FilterPanel.tsx @@ -84,12 +84,20 @@ export const FilterPanel = ({ const toStatusFilterItem = useCallback( (entry: StatusFilterEntry): FilterItem => { - const shortName = entry.shortName === 'affected' ? 'related' : entry.shortName; + const isRelated = entry.statusValue === 'status-value:affected'; + const shortName = isRelated ? 'related' : entry.shortName; const isIncluded = includedStatusFilters.includes(entry.statusValue); const isExcluded = excludedStatusFilters.includes(entry.statusValue); const isChecked = isIncluded || isExcluded; - const { icon: statusIconEl, iconColor } = getStatus(theme, entry.statusValue); - const showIcon = statusIconEl && entry.statusValue !== 'status-value:affected'; + const { icon: statusIconEl } = getStatus(theme, entry.statusValue); + // Related has no status icon, but ActionList only hides the checkbox until hover when a + // non-input sibling precedes it — an empty placeholder preserves that behavior. + // Status icons carry their own color, so no $iconColor override is needed. + const icon = isRelated ? ( +