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/workflows/agent-scan.yml b/.github/workflows/agent-scan.yml index 8460b724c962..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,13 +81,16 @@ 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 @@ -51,13 +99,13 @@ jobs: 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" + 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/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 297ad4e0379c..1c7f29efb05d 100644 --- a/.github/workflows/danger-js.yml +++ b/.github/workflows/danger-js.yml @@ -30,8 +30,11 @@ # # ################################################################################################### +name: Danger JS + on: # 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 @@ -47,16 +50,16 @@ concurrency: group: ${{ github.workflow }}-${{ github.event.number }} cancel-in-progress: true -permissions: - contents: read - issues: read - pull-requests: write +permissions: {} -name: Danger JS jobs: dangerJS: name: Danger JS runs-on: ubuntu-latest + permissions: + contents: read + issues: read + pull-requests: write steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: diff --git a/.github/workflows/fork-checks.yml b/.github/workflows/fork-checks.yml index 7a7bc9b4dcfe..4d7c46b8931b 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 @@ -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..5a7a2feca4ef 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 #6.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..0e91a7d46240 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -11,7 +11,7 @@ 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: @@ -22,10 +22,7 @@ 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: @@ -44,12 +41,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 +189,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,9 +202,9 @@ 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 @@ -214,9 +217,13 @@ jobs: ) && 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 @@ -240,11 +247,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 @@ -267,11 +275,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`. 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 c2f7b79de58d..2e429aa10e79 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: @@ -18,37 +52,45 @@ 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 }} @@ -58,18 +100,18 @@ jobs: 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 + 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 + echo "result=true" >> "$GITHUB_OUTPUT" # Trusted members of the organization can also write to cache (core team, DX, and a few maintainers) elif { [ "$ASSOCIATION" = "OWNER" ] || [ "$ASSOCIATION" = "MEMBER" ]; } && [ "$USER_TYPE" != "Bot" ]; then - echo "result=true" >> $GITHUB_OUTPUT + echo "result=true" >> "$GITHUB_OUTPUT" else - echo "result=false" >> $GITHUB_OUTPUT + echo "result=false" >> "$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 }} @@ -78,11 +120,25 @@ jobs: 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 }} + run: | + PARAMETERS=$(jq -nc \ + --arg workflow "$WORKFLOW" \ + --arg ghBaseBranch "$GH_BASE_BRANCH" \ + --arg ghPrNumber "$GH_PR_NUMBER" \ + '{workflow: $workflow, ghBaseBranch: $ghBaseBranch, ghPrNumber: $ghPrNumber}') + 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..2cbc6c7fda4d --- /dev/null +++ b/.github/workflows/zizmor.yml @@ -0,0 +1,38 @@ +name: GitHub Actions Security Analysis with zizmor 🌈 + +on: + push: + branches: ['main', 'next', 'next-release', 'latest-release', 'release-*'] + pull_request: + branches: ['**'] + +permissions: {} + +jobs: + zizmor: + name: zizmor latest via PyPI + runs-on: ubuntu-latest + permissions: + security-events: write # Required for upload-sarif (used by zizmor-action) to upload SARIF files. + 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 + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - 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/.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/.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/AGENTS.md b/AGENTS.md index c805a77786ba..490744300d4a 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.12+` (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 @@ -299,6 +299,7 @@ These are recurring failure modes in agent-authored changes to this repo. Apply - **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 diff --git a/CHANGELOG.prerelease.md b/CHANGELOG.prerelease.md index d1a167f51b87..d4bfea2171c7 100644 --- a/CHANGELOG.prerelease.md +++ b/CHANGELOG.prerelease.md @@ -1,3 +1,41 @@ +## 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 Vitest: Fix dynamic import failure with Vitest 3 - [#34927](https://github.com/storybookjs/storybook/pull/34927), thanks @Sidnioulz! +- 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! +- 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! +- CLI: Respect BROWSER and BROWSER_ARGS - [#34513](https://github.com/storybookjs/storybook/pull/34513), thanks @ianzone! +- CSF Next: Propagate skip tags to .test children - [#34964](https://github.com/storybookjs/storybook/pull/34964), thanks @kwonoj! +- 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! +- 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: 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: 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! +- 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! +- Docs: Prevent heading anchor cutoff on docs pages - [#34945](https://github.com/storybookjs/storybook/pull/34945), thanks @copilot-swe-agent! +- Maintenance: Centralize supported file extension lists - [#34844](https://github.com/storybookjs/storybook/pull/34844), thanks @valentinpalkovic! +- 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! +- UI: Allow manager-head favicon override - [#34809](https://github.com/storybookjs/storybook/pull/34809), thanks @MukundaKatta! +- Vue: Ensure vue-component-meta runs in post - [#34976](https://github.com/storybookjs/storybook/pull/34976), thanks @Sidnioulz! + ## 10.5.0-alpha.2 - A11y: Fix MDX heading anchors not keyboard accessible - [#34368](https://github.com/storybookjs/storybook/pull/34368), thanks @TheSeydiCharyyev! diff --git a/code/.storybook/main.ts b/code/.storybook/main.ts index c1e5d725acdc..edbc9e4bd8ee 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'; @@ -118,6 +120,7 @@ const config = defineMain({ '@storybook/addon-mcp', 'storybook-addon-pseudo-states', '@chromatic-com/storybook', + './services-preset.ts', ], previewAnnotations: [ './core/template/stories/preview.ts', @@ -152,7 +155,7 @@ const config = defineMain({ changeDetection: 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, { @@ -184,7 +187,6 @@ const config = defineMain({ }, } satisfies typeof viteConfig); }, - // logLevel: 'debug', }); export default config; diff --git a/code/.storybook/open-service-debug-service.ts b/code/.storybook/open-service-debug-service.ts new file mode 100644 index 000000000000..627856b79700 --- /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 '../core/src/shared/open-service/index.ts'; +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: { + getActivity: { + 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 getActivity'); + return ctx.self.state.activity.slice(-input.limit); + }, + }, + getStoryIndexSummary: { + 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 getStoryIndexSummary'); + return { + entryCount: ctx.self.state.storyIndexEntryCount, + sampleIds: input.includeSampleIds ? ctx.self.state.storyIndexSampleIds : [], + }; + }, + }, + getPreloadedValue: { + 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 getPreloadedValue(${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 getPreloadedValue(${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((draft) => { + draft.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((draft) => { + draft.storyIndexEntryCount = Object.keys(storyIndex.entries).length; + draft.storyIndexSampleIds = sampleIds; + draft.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 = (await ctx.self.queries.getStoryIndexSummary({ + includeSampleIds: false, + })) as { entryCount: number; sampleIds: string[] }; + const value = `${input.source}:${input.entryId}:${summary.entryCount}`; + + logger.warn( + `[open-service debug] command recordPreloadVisit(${input.entryId}, ${input.source}) => ${value}` + ); + ctx.self.setState((draft) => { + draft.preloadedByEntryId[input.entryId] = value; + draft.lastObservedValue = value; + draft.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.getPreloadedValue.subscribe( + { entryId: 'startup' }, + (value) => { + logger.warn(`[open-service debug] subscription getPreloadedValue(startup) => ${value}`); + } + ); + + 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.getActivity({ limit: 10 }); + service.queries.getStoryIndexSummary({ includeSampleIds: true }); + await service.queries.getPreloadedValue.loaded({ entryId: 'startup' }); + await new Promise((resolve) => queueMicrotask(resolve)); + } finally { + unsubscribe(); + } +} diff --git a/code/.storybook/services-preset.ts b/code/.storybook/services-preset.ts new file mode 100644 index 000000000000..808e0fcbad67 --- /dev/null +++ b/code/.storybook/services-preset.ts @@ -0,0 +1,20 @@ +import type { Options, StorybookConfigRaw } from 'storybook/internal/types'; + +import { registerOpenServiceDebugService } from './open-service-debug-service.ts'; + +/** + * Preset hook that registers the internal open-service debug service. + * + * Lives in its own preset file so the `services` slot stays out of the public `StorybookConfig` + * surface while still letting the internal Storybook self-test the registration path. Set + * `STORYBOOK_OPEN_SERVICE_DEBUG=true` to opt in. + */ +export const services = async (_value: void, options: Options): Promise => { + if (process.env.STORYBOOK_OPEN_SERVICE_DEBUG === 'true') { + await registerOpenServiceDebugService( + options.presets.apply>( + 'storyIndexGenerator' + ) + ); + } +}; diff --git a/code/addons/docs/src/blocks/blocks/mdx.tsx b/code/addons/docs/src/blocks/blocks/mdx.tsx index 2245613bdf42..4339ea8a24e6 100644 --- a/code/addons/docs/src/blocks/blocks/mdx.tsx +++ b/code/addons/docs/src/blocks/blocks/mdx.tsx @@ -143,30 +143,36 @@ const SUPPORTED_MDX_HEADERS = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] as const; const OcticonHeaders = SUPPORTED_MDX_HEADERS.reduce( (acc, headerType) => ({ ...acc, - [headerType]: styled(headerType)({ - position: 'relative', - '& svg': { - position: 'relative', - top: '-0.1em', - visibility: 'hidden', - }, - '&:hover svg, &:focus-within svg': { - visibility: 'visible', - }, - }), + [headerType]: styled(headerType)({}), }), {} ); +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: 0, + top: '50%', right: '100%', lineHeight: 'inherit', - paddingRight: '10px', + 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': { @@ -175,6 +181,10 @@ const OcticonAnchorWrapper = styled.span({ }, }); +const HeaderTitle = styled.span({ + // marginInlineStart: -40, +}); + interface HeaderWithOcticonAnchorProps { as: string; id: string; @@ -194,24 +204,32 @@ const HeaderWithOcticonAnchor: FC - - - - {children} + ) => { + event.preventDefault(); + const element = document.getElementById(id); + if (element) { + navigate(context, hash); + } + }} + > + + + + + {children} + ); }; 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/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/DocsPage.tsx b/code/addons/docs/src/blocks/components/DocsPage.tsx index ac5576ed7d58..fdc7ec4f16bd 100644 --- a/code/addons/docs/src/blocks/components/DocsPage.tsx +++ b/code/addons/docs/src/blocks/components/DocsPage.tsx @@ -440,7 +440,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', 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/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 6ebd4c572753..75b0b6daf2bd 100644 --- a/code/addons/vitest/package.json +++ b/code/addons/vitest/package.json @@ -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", diff --git a/code/addons/vitest/src/vitest-plugin/index.ts b/code/addons/vitest/src/vitest-plugin/index.ts index bf44f4a43ace..258558863486 100644 --- a/code/addons/vitest/src/vitest-plugin/index.ts +++ b/code/addons/vitest/src/vitest-plugin/index.ts @@ -324,13 +324,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), @@ -423,6 +421,8 @@ export const storybookTest = async (options?: UserOptions): Promise => 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', @@ -464,6 +464,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 911fa566a7fe..4d3e82e78738 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,36 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { type Task, modifyErrorMessage, resetMousePositionBeforeTests } from './setup-file.ts'; +import { Channel } from 'storybook/internal/channels'; + +import { type Task, initTransport, modifyErrorMessage } from './setup-file.ts'; + +describe('initTransport', () => { + afterEach(() => { + // Cleanup the global channel so each test can assert initialization behavior independently. + + (globalThis as { __STORYBOOK_ADDONS_CHANNEL__?: Channel }).__STORYBOOK_ADDONS_CHANNEL__ = + undefined; + }); + + it('should initialize the addons channel when missing', () => { + (globalThis as { __STORYBOOK_ADDONS_CHANNEL__?: Channel }).__STORYBOOK_ADDONS_CHANNEL__ = + undefined; + + initTransport(); + + expect(globalThis.__STORYBOOK_ADDONS_CHANNEL__).toBeInstanceOf(Channel); + }); + + it('should not overwrite an existing addons channel', () => { + const transport = { setHandler: vi.fn(), send: vi.fn() }; + const existingChannel = new Channel({ transport }); + globalThis.__STORYBOOK_ADDONS_CHANNEL__ = existingChannel; + + initTransport(); + + expect(globalThis.__STORYBOOK_ADDONS_CHANNEL__).toBe(existingChannel); + }); +}); describe('modifyErrorMessage', () => { const originalUrl = import.meta.env.__STORYBOOK_URL__; @@ -81,6 +111,7 @@ describe('modifyErrorMessage', () => { describe('resetMousePositionBeforeTests', () => { afterEach(() => { vi.clearAllMocks(); + vi.resetModules(); vi.doUnmock('vitest/browser'); vi.doUnmock('@vitest/browser/context'); }); @@ -94,6 +125,8 @@ describe('resetMousePositionBeforeTests', () => { }, })); + const { resetMousePositionBeforeTests } = await import('./setup-file.browser.4.ts'); + await resetMousePositionBeforeTests(); expect(resetMousePosition).toHaveBeenCalledTimes(1); @@ -106,39 +139,8 @@ describe('resetMousePositionBeforeTests', () => { }, })); - await expect(resetMousePositionBeforeTests()).resolves.toBeUndefined(); - }); - - it('should rethrow unexpected errors', async () => { - const error = new Error('boom'); - - vi.doMock('vitest/browser', () => { - throw error; - }); - - await expect(resetMousePositionBeforeTests()).rejects.toThrow(); - }); - - it('should fallback to vitest v3 browser context when vitest/browser is not found', async () => { - const resetMousePosition = vi.fn().mockResolvedValue(undefined); - - vi.doMock('vitest/browser', () => { - const browser = {}; - Object.defineProperty(browser, 'commands', { - get: () => { - throw new Error("Cannot find module 'vitest/browser'"); - }, - }); - return browser; - }); - vi.doMock('@vitest/browser/context', () => ({ - commands: { - resetMousePosition, - }, - })); - - await resetMousePositionBeforeTests(); + const { resetMousePositionBeforeTests } = await import('./setup-file.browser.4.ts'); - expect(resetMousePosition).toHaveBeenCalledTimes(1); + 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 662a5051a6e7..af54592d924e 100644 --- a/code/addons/vitest/src/vitest-plugin/setup-file.ts +++ b/code/addons/vitest/src/vitest-plugin/setup-file.ts @@ -1,10 +1,9 @@ -import { beforeEach, afterEach, beforeAll, vi } from 'vitest'; +import { afterEach, beforeAll, vi } from 'vitest'; import type { RunnerTask } from 'vitest'; import { Channel } from 'storybook/internal/channels'; import { COMPONENT_TESTING_PANEL_ID } from '../constants.ts'; -import { isFunction } from 'es-toolkit/predicate'; declare global { // eslint-disable-next-line @typescript-eslint/ban-ts-comment @@ -17,8 +16,10 @@ export type Task = Partial & { meta: Record; }; -const transport = { setHandler: vi.fn(), send: vi.fn() }; -globalThis.__STORYBOOK_ADDONS_CHANNEL__ ??= new Channel({ transport }); +export const initTransport = () => { + const transport = { setHandler: vi.fn(), send: vi.fn() }; + globalThis.__STORYBOOK_ADDONS_CHANNEL__ ??= new Channel({ transport }); +}; export const modifyErrorMessage = ({ task }: { task: Task }) => { const meta = task.meta; @@ -35,55 +36,7 @@ export const modifyErrorMessage = ({ task }: { task: Task }) => { } }; -export const resetMousePositionBeforeTests = async () => { - try { - const browserCommands = await import('vitest/browser').then((module) => module.commands); - if ('resetMousePosition' in browserCommands && isFunction(browserCommands.resetMousePosition)) { - await browserCommands.resetMousePosition(); - } - } catch (error) { - // Retry with Vitest 3 context module when vitest/browser is not found. - if (error instanceof Error && error.message.includes("Cannot find module 'vitest/browser'")) { - try { - const browserCommands = await import('@vitest/browser/context').then( - (module) => module.commands - ); - if ( - 'resetMousePosition' in browserCommands && - isFunction(browserCommands.resetMousePosition) - ) { - await browserCommands.resetMousePosition(); - } - return; - } catch (vitest3Error) { - if ( - vitest3Error instanceof Error && - vitest3Error.message.includes("Cannot find module '@vitest/browser/context'") - ) { - return; - } - if ( - vitest3Error instanceof Error && - vitest3Error.message.includes('can be imported only inside the Browser Mode') - ) { - return; - } - throw vitest3Error; - } - } - - // Ignore "Error: vitest/browser can be imported only inside the Browser Mode." - if ( - error instanceof Error && - error.message.includes('can be imported only inside the Browser Mode') - ) { - return; - } - - // Throw anything else - throw error; - } -}; +initTransport(); beforeAll(() => { if (globalThis.globalProjectAnnotations) { @@ -91,6 +44,4 @@ beforeAll(() => { } }); -beforeEach(resetMousePositionBeforeTests); - afterEach(modifyErrorMessage); diff --git a/code/core/package.json b/code/core/package.json index 5a1d8a57615b..7d00d9dc88e5 100644 --- a/code/core/package.json +++ b/code/core/package.json @@ -281,6 +281,7 @@ "@react-stately/tabs": "^3.8.5", "@react-types/shared": "^3.32.0", "@rolldown/pluginutils": "1.0.0-beta.18", + "@standard-schema/spec": "^1.1.0", "@tanstack/react-virtual": "^3.3.0", "@testing-library/react": "^14.0.0", "@types/cross-spawn": "^6.0.6", @@ -303,6 +304,7 @@ "@yarnpkg/libzip": "2.3.0", "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", + "alien-signals": "^3.2.0", "ansi-to-html": "^0.7.2", "browser-dtector": "^3.4.0", "bundle-require": "^5.1.0", @@ -333,6 +335,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", @@ -385,6 +388,7 @@ "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" 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/core-server/build-dev.ts b/code/core/src/core-server/build-dev.ts index 9ba3aeafac96..cb4e3b589b76 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'; @@ -295,6 +295,7 @@ 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 = { diff --git a/code/core/src/core-server/build-static.ts b/code/core/src/core-server/build-static.ts index d2fa571dd071..71c5b22f27c9 100644 --- a/code/core/src/core-server/build-static.ts +++ b/code/core/src/core-server/build-static.ts @@ -17,6 +17,8 @@ import { global } from '@storybook/global'; import { join, relative, resolve } from 'pathe'; import picocolors from 'picocolors'; +import { applyServicesPresetOnce } from './utils/apply-services-preset-once.ts'; +import { writeOpenServiceStaticFiles } from '../shared/open-service/server.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 +131,7 @@ export async function buildStaticStandalone(options: BuildStaticStandaloneOption const effects: Promise[] = []; global.FEATURES = features; + await applyServicesPresetOnce(presets); if (!options.previewOnly) { await buildOrThrow(async () => @@ -144,6 +147,7 @@ export async function buildStaticStandalone(options: BuildStaticStandaloneOption const coreServerPublicDir = join(resolvePackageDir('storybook'), 'assets/browser'); effects.push(cp(coreServerPublicDir, options.outputDir, { recursive: true, force: true })); + effects.push(writeOpenServiceStaticFiles(options.outputDir)); let storyIndexGeneratorPromise: Promise = Promise.resolve(undefined); diff --git a/code/core/src/core-server/change-detection/ChangeDetectionService.test.ts b/code/core/src/core-server/change-detection/ChangeDetectionService.test.ts index a7d07ce751de..26fdc5e4eb91 100644 --- a/code/core/src/core-server/change-detection/ChangeDetectionService.test.ts +++ b/code/core/src/core-server/change-detection/ChangeDetectionService.test.ts @@ -9,25 +9,28 @@ 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 * as oxcParser from 'storybook/internal/oxc-parser'; + +import { + buildReverseIndex, + createDeferred, + createMockAdapter, + createStoryIndex, + createWiredChangeDetection, + installDependencyGraphMocks, +} from './change-detection.test-helpers.ts'; import { buildIndexBaselineStatuses, ChangeDetectionService, mergeChangeDetectionStatuses, mergeStatusValues, } from './ChangeDetectionService.ts'; -import * as oxcParser from 'storybook/internal/oxc-parser'; -import { - ChangeDetectionResolverFactory, - DependencyGraphBuilder, - IncrementalPatcher, - ReverseIndexImpl, -} from './dependency-graph/index.ts'; +import { ChangeDetectionFailureError, ChangeDetectionUnavailableError } from './errors.ts'; +import { getChangeDetectionReadiness, internal_resetChangeDetectionReadiness } from './index.ts'; import type { GitDiffResult } from './GitDiffProvider.ts'; import { GitDiffProvider } from './GitDiffProvider.ts'; import type { IndexBaselineService } from './IndexBaselineService.ts'; +import type { StoryDependencyGraphService } from './StoryDependencyGraphService.ts'; vi.mock('storybook/internal/node-logger', { spy: true }); vi.mock('./dependency-graph/index.ts', async (importOriginal) => { @@ -44,85 +47,6 @@ vi.mock('./dependency-graph/index.ts', async (importOriginal) => { }; }); -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); - }; - } - - return { - adapter, - emitFileChange: (event) => { - fileHandlers.forEach((h) => h(event)); - }, - emitStartupFailure: (event) => { - startupHandlers.forEach((h) => h(event)); - }, - hasFileChangeSubscriber: () => fileHandlers.size > 0, - hasStartupFailureSubscriber: () => startupHandlers.size > 0, - }; -} - class MockGitDiffProvider extends GitDiffProvider { readonly getChangedFilesMock = vi.fn( async (): Promise => ({ @@ -197,50 +121,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 +164,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,6 +174,7 @@ describe('ChangeDetectionService', () => { workingDir, }); + graph.start(adapter); service.start(adapter, true); await vi.runAllTimersAsync(); @@ -320,6 +201,7 @@ describe('ChangeDetectionService', () => { }, }); await service.dispose(); + await graph.dispose(); }); it('edits a non-story dep at distance 1 from one story and distance 2 from another -> nearest is modified, farther is affected', async () => { @@ -354,7 +236,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,6 +246,7 @@ describe('ChangeDetectionService', () => { workingDir, }); + graph.start(adapter); service.start(adapter, true); await vi.runAllTimersAsync(); @@ -375,6 +258,7 @@ describe('ChangeDetectionService', () => { 'status-value:affected' ); await service.dispose(); + await graph.dispose(); }); it('edits a non-story dep at equal distance from two stories -> both stories tie and are both modified', async () => { @@ -400,7 +284,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,6 +294,7 @@ describe('ChangeDetectionService', () => { workingDir, }); + graph.start(adapter); service.start(adapter, true); await vi.runAllTimersAsync(); @@ -421,6 +306,7 @@ describe('ChangeDetectionService', () => { 'status-value:modified' ); await service.dispose(); + await graph.dispose(); }); it('edits a non-story file with no story importers -> reverse-index lookup is empty -> no status emitted', async () => { @@ -444,7 +330,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,12 +340,14 @@ describe('ChangeDetectionService', () => { workingDir, }); + graph.start(adapter); service.start(adapter, true); await vi.runAllTimersAsync(); expect(getStatusStoreByTypeId(CHANGE_DETECTION_STATUS_TYPE_ID).getAll()).toEqual({}); expect(await getChangeDetectionReadiness()).toEqual({ status: 'ready' }); await service.dispose(); + await graph.dispose(); }); // ------------------------------------------------------------------ @@ -496,7 +384,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,6 +395,7 @@ describe('ChangeDetectionService', () => { debounceMs: 10, }); + graph.start(adapter); service.start(adapter, true); await vi.runAllTimersAsync(); @@ -530,6 +419,7 @@ describe('ChangeDetectionService', () => { 'new-button--primary': {}, }); await service.dispose(); + await graph.dispose(); }); it('replaces prior scan status data instead of cumulatively merging with store state', async () => { @@ -562,7 +452,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,6 +463,7 @@ describe('ChangeDetectionService', () => { debounceMs: 10, }); + graph.start(adapter); service.start(adapter, true); await vi.runAllTimersAsync(); @@ -605,6 +496,7 @@ describe('ChangeDetectionService', () => { }, }); await service.dispose(); + await graph.dispose(); }); it('rescans on git state changes using the normal debounce', async () => { @@ -631,7 +523,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,6 +534,7 @@ describe('ChangeDetectionService', () => { debounceMs: 10, }); + graph.start(adapter); service.start(adapter, true); await vi.runAllTimersAsync(); @@ -658,6 +551,7 @@ describe('ChangeDetectionService', () => { expect(gitDiffProvider.getChangedFilesMock).toHaveBeenCalledTimes(2); await service.dispose(); + await graph.dispose(); }); it('debounces consecutive file-change events into a single scan', async () => { @@ -672,7 +566,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,6 +577,7 @@ describe('ChangeDetectionService', () => { debounceMs: 50, }); + graph.start(adapter); service.start(adapter, true); // First scan from initial start — debounce 0 runs synchronously. await vi.runAllTimersAsync(); @@ -703,6 +598,7 @@ describe('ChangeDetectionService', () => { expect(gitDiffProvider.getChangedFilesMock).toHaveBeenCalledTimes(2); await service.dispose(); + await graph.dispose(); }); it('does not subscribe to git state when change detection is disabled', async () => { @@ -712,7 +608,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), @@ -731,14 +627,45 @@ describe('ChangeDetectionService', () => { reason: 'disabled', }); await service.dispose(); + await graph.dispose(); }); - it('logs unavailability when the builder does not provide an adapter', async () => { + it('acts as a consumer when an external graph is injected', async () => { + const graph = { + start: vi.fn(), + dispose: vi.fn(async () => undefined), + whenSettled: vi.fn(async () => undefined), + hasGraph: vi.fn(() => false), + lookup: vi.fn(() => new Map()), + } as unknown as StoryDependencyGraphService; const { getStatusStoreByTypeId } = createStatusStore({ universalStatusStore: new MockUniversalStore(UNIVERSAL_STATUS_STORE_OPTIONS), environment: 'server', }); const service = new ChangeDetectionService({ + graph, + storyIndexGeneratorPromise: Promise.resolve({ + getIndex: vi.fn(), + } as never), + statusStore: getStatusStoreByTypeId(CHANGE_DETECTION_STATUS_TYPE_ID), + gitDiffProvider: createMockGitDiffProvider(), + indexBaselineService: createMockStoryIndexBaselineService(), + workingDir, + }); + + service.start(undefined, false); + await service.dispose(); + + expect(graph.start).not.toHaveBeenCalled(); + expect(graph.dispose).not.toHaveBeenCalled(); + }); + + it('logs unavailability when the builder does not provide an adapter', async () => { + const { getStatusStoreByTypeId } = createStatusStore({ + universalStatusStore: new MockUniversalStore(UNIVERSAL_STATUS_STORE_OPTIONS), + environment: 'server', + }); + const { service, graph } = createWiredChangeDetection({ storyIndexGeneratorPromise: Promise.resolve({ getIndex: vi.fn(), } as never), @@ -758,6 +685,7 @@ describe('ChangeDetectionService', () => { reason: 'builder does not support change detection', }); await service.dispose(); + await graph.dispose(); }); it('resolves readiness as unavailable when the adapter reports a startup failure', async () => { @@ -773,7 +701,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,6 +711,7 @@ describe('ChangeDetectionService', () => { workingDir, }); + graph.start(adapter); service.start(adapter, true); // Let startInternal subscribe before emitting the failure (initial scan parked on git). await vi.runAllTimersAsync(); @@ -799,6 +728,7 @@ describe('ChangeDetectionService', () => { // Unblock the parked git call so dispose can drain. gitDeferred.resolve({ changed: new Set(), new: new Set() }); await service.dispose(); + await graph.dispose(); }); it('resolves readiness as error when the eager build throws', async () => { @@ -812,7 +742,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,6 +752,7 @@ describe('ChangeDetectionService', () => { workingDir, }); + graph.start(adapter); service.start(adapter, true); await vi.runAllTimersAsync(); @@ -833,6 +764,7 @@ describe('ChangeDetectionService', () => { error: expect.objectContaining({ message: 'graph build blew up' }), }); await service.dispose(); + await graph.dispose(); }); it('disposes the pool when startInternal throws', async () => { @@ -850,7 +782,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), @@ -860,6 +792,7 @@ describe('ChangeDetectionService', () => { workingDir, }); + graph.start(adapter); service.start(adapter, true); await vi.runAllTimersAsync(); @@ -867,6 +800,7 @@ describe('ChangeDetectionService', () => { expect(disposePoolSpy).toHaveBeenCalledTimes(1); await service.dispose(); + await graph.dispose(); }); it('keeps the previous statuses when a live rescan fails', async () => { @@ -895,7 +829,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,6 +840,7 @@ describe('ChangeDetectionService', () => { debounceMs: 10, }); + graph.start(adapter); service.start(adapter, true); await vi.runAllTimersAsync(); @@ -926,6 +861,7 @@ describe('ChangeDetectionService', () => { }); expect(logger.error).toHaveBeenCalledWith('Change detection failed: scan blew up'); await service.dispose(); + await graph.dispose(); }); it('does not apply scan results or rerun after disposal', async () => { @@ -949,7 +885,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,9 +896,11 @@ describe('ChangeDetectionService', () => { debounceMs: 0, }); + graph.start(adapter); service.start(adapter, true); await vi.advanceTimersByTimeAsync(0); await service.dispose(); + await graph.dispose(); changedFilesDeferred.resolve({ changed: new Set(['src/Button.stories.tsx']), @@ -993,7 +931,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,6 +942,7 @@ describe('ChangeDetectionService', () => { debounceMs: 0, }); + graph.start(adapter); service.start(adapter, true); await vi.runAllTimersAsync(); @@ -1015,53 +954,7 @@ describe('ChangeDetectionService', () => { error: expect.any(ChangeDetectionUnavailableError), }); 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(); + await graph.dispose(); }); it('scan waits for the current patch to settle before reading reverseIndex', async () => { @@ -1098,7 +991,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,6 +1002,7 @@ describe('ChangeDetectionService', () => { debounceMs: 0, }); + graph.start(adapter); service.start(adapter, true); await vi.runAllTimersAsync(); @@ -1127,54 +1021,7 @@ 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(); + await graph.dispose(); }); it('calls gitDiffProvider.dispose() on service dispose when a git watcher was installed', async () => { @@ -1187,7 +1034,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,10 +1047,12 @@ describe('ChangeDetectionService', () => { workingDir, }); + graph.start(adapter); service.start(adapter, true); await vi.runAllTimersAsync(); await service.dispose(); + await graph.dispose(); expect(gitDiffProvider.disposeMock).toHaveBeenCalledTimes(1); }); @@ -1215,7 +1064,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), @@ -1229,210 +1078,47 @@ describe('ChangeDetectionService', () => { service.start(undefined, false); // Should not throw and should not attempt to call dispose on an unconstructed provider. await expect(service.dispose()).resolves.toBeUndefined(); + await graph.dispose(); }); - it('replays add/unlink through the patcher when onStoryIndexInvalidated reveals new/removed stories', async () => { + it('rescans the working tree when the story index is invalidated', async () => { + // Graph-side reconciliation (replaying add/unlink, the refreshInFlight guard) is covered by + // StoryDependencyGraphService.test.ts; here we assert the status side of the seam: an index + // invalidation 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({ + const { service, graph } = 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); - // 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({ - 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, }); + graph.start(adapter); service.start(adapter, true); await vi.runAllTimersAsync(); + expect(gitDiffProvider.getChangedFilesMock).toHaveBeenCalledTimes(1); - // Two rapid calls before the first refresh completes. - service.onStoryIndexInvalidated(); - service.onStoryIndexInvalidated(); + service.onGraphChange(); 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(); + await graph.dispose(); }); }); diff --git a/code/core/src/core-server/change-detection/ChangeDetectionService.ts b/code/core/src/core-server/change-detection/ChangeDetectionService.ts index 1458b10080ba..6cbadf9c3c80 100644 --- a/code/core/src/core-server/change-detection/ChangeDetectionService.ts +++ b/code/core/src/core-server/change-detection/ChangeDetectionService.ts @@ -1,35 +1,23 @@ -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, + StatusValue, + StoryIndex, } 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 type { ChangeDetectionAdapter } from './adapters/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'; +import { getStoryIdsByAbsolutePath } from './story-files.ts'; +import type { StoryDependencyGraphService } from './StoryDependencyGraphService.ts'; const CHANGE_DETECTION_DEBOUNCE_MS = 200; @@ -49,33 +37,6 @@ function isSameStatus(a: Status | undefined, b: Status): boolean { ); } -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 @@ -136,10 +97,14 @@ export function buildIndexBaselineStatuses( } /** - * 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. + * Publishes change-detection story statuses to the status store. It resolves git-changed files, + * maps them to affected stories through a shared {@link StoryDependencyGraphService}, and emits + * `modified`/`affected`/`new` statuses (plus index-baseline `new` entries). + * + * The module dependency graph itself — building the reverse index, watching builder file events, + * and incrementally patching — lives in {@link StoryDependencyGraphService}, which this class + * composes. The two responsibilities are independent: the graph never depends on git, the status + * store, or the readiness signal, and could be driven by other consumers in the future. */ export class ChangeDetectionService { private disposed = false; @@ -147,49 +112,74 @@ export class ChangeDetectionService { private scanInFlight = false; private rerunAfterCurrentScan = false; private readinessResolved = false; - private refreshInFlight = false; + private statusPipelineStarted = false; + private changeDetectionEnabled = false; + private readonly graph: StoryDependencyGraphService; 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: { + graph: StoryDependencyGraphService; 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; + this.graph = options.graph; resetChangeDetectionReadiness(); } + /** True while the service is live and change-detection status publishing is enabled. */ + private isActive(): boolean { + return !this.disposed && this.changeDetectionEnabled; + } + + onGraphReady(): void { + if (!this.isActive()) { + return; + } + + this.startStatusPipeline(); + } + + onGraphChange(): void { + if (!this.isActive()) { + return; + } + + this.scheduleScan(this.debounceMs); + } + + onGraphError(error: Error): void { + if (!this.isActive()) { + return; + } + + this.resolveReadiness({ status: 'error', error }); + void this.dispose().catch(() => undefined); + } + + 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(); + } + start(adapter: ChangeDetectionAdapter | undefined, enabled: boolean | undefined): void { if (enabled === false) { logger.debug('Change detection disabled.'); @@ -210,140 +200,19 @@ export class ChangeDetectionService { } 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); - }); + this.changeDetectionEnabled = true; + this.onGraphReady(); } /** - * 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. + * 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 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) { + private startStatusPipeline(): void { + if (this.disposed || this.statusPipelineStarted) { 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(); - }); - } + this.statusPipelineStarted = true; void this.getIndexBaselineService().start(); @@ -362,134 +231,10 @@ export class ChangeDetectionService { this.scheduleScan(0); } - onStoryIndexInvalidated(): void { + async dispose(): Promise { 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; @@ -498,33 +243,7 @@ export class ChangeDetectionService { 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 { @@ -539,17 +258,16 @@ export class ChangeDetectionService { } private async scan(): Promise { - if (this.disposed || !this.reverseIndex) { + if (this.disposed) { 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); + // 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. + await this.graph.whenSettled(); - if (this.disposed || !this.reverseIndex) { + if (this.disposed || !this.graph.hasGraph()) { return; } @@ -561,7 +279,7 @@ export class ChangeDetectionService { this.scanInFlight = true; try { - const nextStatuses = await this.buildStatuses(this.reverseIndex); + const nextStatuses = await this.buildStatuses(); if (this.disposed) { return; } @@ -608,7 +326,7 @@ export class ChangeDetectionService { } } - private async buildStatuses(reverseIndex: ReverseIndexImpl): Promise> { + private async buildStatuses(): Promise> { const gitDiffProvider = this.getGitDiffProvider(); const [changes, repoRoot, storyIndexGenerator, baselineEntryIds] = await Promise.all([ gitDiffProvider.getChangedFiles(), @@ -627,17 +345,12 @@ export class ChangeDetectionService { const storyIndex = await storyIndexGenerator.getIndex(); const baselineStatuses = buildIndexBaselineStatuses(storyIndex, baselineEntryIds); - const storyIdsByFile = getStoryIdsByAbsolutePath( - this.storyIdsByFileCache, - storyIndex, - this.workingDir - ); + const storyIdsByFile = getStoryIdsByAbsolutePath(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 affectedStoryFiles = this.graph.lookup(changedFile); + // Include the changed file as a story-at-distance-0 if it IS a story. const allEntries = new Map(affectedStoryFiles); if (storyIdsByFile.has(changedFile)) { allEntries.set(changedFile, 0); diff --git a/code/core/src/core-server/change-detection/StoryDependencyGraphService.test.ts b/code/core/src/core-server/change-detection/StoryDependencyGraphService.test.ts new file mode 100644 index 000000000000..dd85410dfce4 --- /dev/null +++ b/code/core/src/core-server/change-detection/StoryDependencyGraphService.test.ts @@ -0,0 +1,449 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { logger } from 'storybook/internal/node-logger'; +import * as oxcParser from 'storybook/internal/oxc-parser'; +import type { StoryIndex } from 'storybook/internal/types'; + +import { + buildReverseIndex, + createDeferred, + createMockAdapter, + createStoryIndex, + installDependencyGraphMocks, +} from './change-detection.test-helpers.ts'; +import { ChangeDetectionFailureError } from './errors.ts'; +import { StoryDependencyGraphService } from './StoryDependencyGraphService.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 + // graph-building constructors with `vi.fn()`s so tests can override their behaviour per-case. + const actual = await importOriginal(); + return { + ...actual, + ChangeDetectionResolverFactory: vi.fn(), + DependencyGraphBuilder: vi.fn(), + IncrementalPatcher: vi.fn(), + }; +}); + +const workingDir = '/repo'; + +function setup(options?: { + storyIndex?: StoryIndex; + getIndex?: ReturnType; + withoutStartupFailure?: boolean; +}) { + const callbacks = { + onReady: vi.fn(), + onChange: vi.fn(), + onError: vi.fn(), + onUnavailable: vi.fn(), + }; + const adapterHandle = createMockAdapter({ + withoutStartupFailure: options?.withoutStartupFailure, + }); + const getIndex = + options?.getIndex ?? vi.fn().mockResolvedValue(options?.storyIndex ?? createStoryIndex([])); + + const service = new StoryDependencyGraphService({ + storyIndexGeneratorPromise: Promise.resolve({ getIndex } as never), + workingDir, + ...callbacks, + }); + + return { service, getIndex, callbacks, ...adapterHandle }; +} + +describe('StoryDependencyGraphService', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.mocked(logger.info).mockImplementation(() => undefined); + vi.mocked(logger.warn).mockImplementation(() => undefined); + vi.mocked(logger.error).mockImplementation(() => undefined); + vi.mocked(logger.debug).mockImplementation(() => undefined); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.resetAllMocks(); + }); + + it('builds the graph, fires onReady, and exposes the reverse index via lookup', async () => { + 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 { service, callbacks, adapter } = setup({ storyIndex }); + + expect(service.hasGraph()).toBe(false); + + service.start(adapter); + await vi.runAllTimersAsync(); + + expect(callbacks.onReady).toHaveBeenCalledTimes(1); + expect(callbacks.onError).not.toHaveBeenCalled(); + expect(service.hasGraph()).toBe(true); + expect(service.lookup('/repo/src/Button.tsx')).toEqual( + new Map([['/repo/src/Button.stories.tsx', 1]]) + ); + expect(service.lookup('/repo/src/Unknown.tsx')).toEqual(new Map()); + + 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); + await new Promise((resolve) => setImmediate(resolve)); + activePatches -= 1; + }); + + const { service, adapter, emitFileChange } = setup({ + storyIndex: createStoryIndex([ + { storyId: 'button--primary', importPath: './src/Button.stories.tsx', title: 'Button' }, + ]), + }); + + service.start(adapter); + 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('whenSettled resolves only after the in-flight patch settles, so a later lookup observes it', async () => { + const reverseIndex = buildReverseIndex([]); + const patchDeferred = createDeferred(); + const { patchSpy, buildSpy } = installDependencyGraphMocks(reverseIndex); + buildSpy.mockResolvedValue({ reverseIndex, graph: new Map() }); + + patchSpy.mockImplementationOnce(async () => { + await patchDeferred.promise; + reverseIndex.record('/repo/src/Button.stories.tsx', '/repo/src/Button.stories.tsx', 0); + }); + + const { service, adapter, emitFileChange } = setup({ + storyIndex: createStoryIndex([ + { storyId: 'button--primary', importPath: './src/Button.stories.tsx', title: 'Button' }, + ]), + }); + + service.start(adapter); + await vi.runAllTimersAsync(); + + emitFileChange({ kind: 'change', path: '/repo/src/Button.stories.tsx' }); + + let settled = false; + void service.whenSettled().then(() => { + settled = true; + }); + // The patch is parked, so the settle barrier must not resolve yet. + await Promise.resolve(); + expect(settled).toBe(false); + + patchDeferred.resolve(); + await vi.runAllTimersAsync(); + + expect(settled).toBe(true); + expect(service.lookup('/repo/src/Button.stories.tsx')).toEqual( + new Map([['/repo/src/Button.stories.tsx', 0]]) + ); + + await service.dispose(); + }); + + it('fires onChange after each file-change patch settles, but not for the initial build', async () => { + installDependencyGraphMocks(buildReverseIndex([])); + const { service, adapter, emitFileChange, callbacks } = setup({ + storyIndex: createStoryIndex([ + { storyId: 'b--default', importPath: './src/B.stories.tsx', title: 'B' }, + ]), + }); + + service.start(adapter); + await vi.runAllTimersAsync(); + expect(callbacks.onChange).not.toHaveBeenCalled(); + + emitFileChange({ kind: 'change', path: '/repo/src/B.tsx' }); + emitFileChange({ kind: 'change', path: '/repo/src/C.tsx' }); + await vi.runAllTimersAsync(); + + expect(callbacks.onChange).toHaveBeenCalledTimes(2); + + await service.dispose(); + }); + + it('buffers file events emitted during the build and applies them 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 { service, adapter, emitFileChange } = setup({ storyIndex: createStoryIndex([]) }); + + service.start(adapter); + // Advance past all awaits in startInternal up to the build await. + 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('replays add through the patcher when onStoryIndexInvalidated reveals a new story', async () => { + 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' }, + ]); + const updatedIndex = 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).mockResolvedValue(updatedIndex); + + const { service, adapter, callbacks } = setup({ getIndex }); + + service.start(adapter); + await vi.runAllTimersAsync(); + expect(patchSpy).not.toHaveBeenCalled(); + + service.onStoryIndexInvalidated(); + await vi.runAllTimersAsync(); + + expect(patchSpy).toHaveBeenCalledWith({ kind: 'add', path: '/repo/src/B.stories.tsx' }); + // The index changed, so consumers are notified to recompute derived state. + expect(callbacks.onChange).toHaveBeenCalled(); + + await service.dispose(); + }); + + it('guards duplicate onStoryIndexInvalidated so a newly-added story is replayed only once', async () => { + 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' }, + ]); + const updatedIndex = 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).mockResolvedValue(updatedIndex); + + const { service, adapter } = setup({ getIndex }); + + service.start(adapter); + await vi.runAllTimersAsync(); + + service.onStoryIndexInvalidated(); + service.onStoryIndexInvalidated(); + await vi.runAllTimersAsync(); + + const addPatches = patchSpy.mock.calls.filter( + ([event]) => event.kind === 'add' && event.path === '/repo/src/B.stories.tsx' + ); + expect(addPatches).toHaveLength(1); + + await service.dispose(); + }); + + it('whenSettled waits for an in-flight story-index reconciliation, so a later lookup is post-reconciliation', async () => { + // Regression guard for the onChange-before-reconciliation gap: onStoryIndexInvalidated starts + // an async refresh (getIndex + add/unlink) and fires onChange synchronously, before the + // reconciliation patches exist. whenSettled() must await that in-flight reconciliation, not + // just the current patch tail, or a consumer reacting to onChange would read a pre-reconciliation + // graph. + const reverseIndex = buildReverseIndex([]); + const getIndexDeferred = createDeferred(); + const { patchSpy, buildSpy } = installDependencyGraphMocks(reverseIndex); + buildSpy.mockResolvedValue({ reverseIndex, graph: new Map() }); + + // The reconciliation's add patch records B into the reverse index, so a post-reconciliation + // lookup can observe the freshly-walked story. + patchSpy.mockImplementation(async (event) => { + if (event.kind === 'add' && event.path === '/repo/src/B.stories.tsx') { + reverseIndex.record('/repo/src/B.stories.tsx', '/repo/src/B.stories.tsx', 0); + } + }); + + const initialIndex = createStoryIndex([ + { storyId: 'a--default', importPath: './src/A.stories.tsx', title: 'A' }, + ]); + const updatedIndex = createStoryIndex([ + { storyId: 'a--default', importPath: './src/A.stories.tsx', title: 'A' }, + { storyId: 'b--default', importPath: './src/B.stories.tsx', title: 'B' }, + ]); + // Build reads the initial index; the reconciliation's getIndex parks until released. + const getIndex = vi + .fn() + .mockResolvedValueOnce(initialIndex) + .mockImplementationOnce(async () => { + await getIndexDeferred.promise; + return updatedIndex; + }); + + const { service, adapter } = setup({ getIndex }); + service.start(adapter); + await vi.runAllTimersAsync(); + + service.onStoryIndexInvalidated(); + + let settled = false; + void service.whenSettled().then(() => { + settled = true; + }); + + // The reconciliation is parked in getIndex, so the barrier must not resolve and the add patch + // must not have run yet. + await Promise.resolve(); + await Promise.resolve(); + expect(settled).toBe(false); + expect(patchSpy).not.toHaveBeenCalled(); + + getIndexDeferred.resolve(); + await vi.runAllTimersAsync(); + + expect(settled).toBe(true); + expect(patchSpy).toHaveBeenCalledWith({ kind: 'add', path: '/repo/src/B.stories.tsx' }); + expect(service.lookup('/repo/src/B.stories.tsx')).toEqual( + new Map([['/repo/src/B.stories.tsx', 0]]) + ); + + await service.dispose(); + }); + + it('fires onError, logs, and disposes the oxc pool when the eager build throws', async () => { + const { buildSpy } = installDependencyGraphMocks(buildReverseIndex([])); + buildSpy.mockImplementation(async () => { + throw new ChangeDetectionFailureError('graph build blew up'); + }); + const disposePoolSpy = vi.spyOn(oxcParser, 'disposeOxcParsePool').mockResolvedValue(undefined); + + const { service, adapter, callbacks } = setup({ storyIndex: createStoryIndex([]) }); + + service.start(adapter); + await vi.runAllTimersAsync(); + + expect(logger.error).toHaveBeenCalledWith( + 'Change detection failed to start: graph build blew up' + ); + expect(callbacks.onError).toHaveBeenCalledWith( + expect.objectContaining({ message: 'graph build blew up' }) + ); + expect(callbacks.onReady).not.toHaveBeenCalled(); + expect(disposePoolSpy).toHaveBeenCalledTimes(1); + expect(service.hasGraph()).toBe(false); + + await service.dispose(); + }); + + it('fires onUnavailable and tears down when the adapter reports a startup failure', async () => { + installDependencyGraphMocks(buildReverseIndex([])); + const disposePoolSpy = vi.spyOn(oxcParser, 'disposeOxcParsePool').mockResolvedValue(undefined); + + const { service, adapter, emitStartupFailure, callbacks, hasFileChangeSubscriber } = setup({ + storyIndex: createStoryIndex([]), + }); + + service.start(adapter); + await vi.runAllTimersAsync(); + expect(callbacks.onReady).toHaveBeenCalledTimes(1); + expect(hasFileChangeSubscriber()).toBe(true); + + emitStartupFailure({ reason: 'vite warmup failed', error: new Error('warmup failed') }); + await vi.runAllTimersAsync(); + + expect(callbacks.onUnavailable).toHaveBeenCalledWith( + 'vite warmup failed', + expect.objectContaining({ message: 'warmup failed' }) + ); + expect(disposePoolSpy).toHaveBeenCalledTimes(1); + // Disposal tears down the file-change subscription. + expect(hasFileChangeSubscriber()).toBe(false); + + await service.dispose(); + }); + + it('drains the in-flight patch before disposing the oxc pool, and dispose is idempotent', async () => { + const reverseIndex = buildReverseIndex([]); + const patchDeferred = createDeferred(); + const { patchSpy, buildSpy } = installDependencyGraphMocks(reverseIndex); + buildSpy.mockResolvedValue({ reverseIndex, graph: new Map() }); + patchSpy.mockImplementationOnce(async () => { + await patchDeferred.promise; + }); + const disposePoolSpy = vi.spyOn(oxcParser, 'disposeOxcParsePool').mockResolvedValue(undefined); + + const { service, adapter, emitFileChange } = setup({ + storyIndex: createStoryIndex([ + { storyId: 'b--default', importPath: './src/B.stories.tsx', title: 'B' }, + ]), + }); + + service.start(adapter); + await vi.runAllTimersAsync(); + + emitFileChange({ kind: 'change', path: '/repo/src/B.tsx' }); + // Let the patch start and park inside patchSpy (now in-flight, past the disposed guard). + await Promise.resolve(); + await Promise.resolve(); + expect(patchSpy).toHaveBeenCalledTimes(1); + + let disposed = false; + const disposePromise = service.dispose().then(() => { + disposed = true; + }); + await Promise.resolve(); + // Dispose must wait for the in-flight patch to settle before disposing the pool. + expect(disposed).toBe(false); + expect(disposePoolSpy).not.toHaveBeenCalled(); + + patchDeferred.resolve(); + await disposePromise; + + expect(disposed).toBe(true); + expect(disposePoolSpy).toHaveBeenCalledTimes(1); + + // Idempotent: a second dispose is a no-op. + await service.dispose(); + expect(disposePoolSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/code/core/src/core-server/change-detection/StoryDependencyGraphService.ts b/code/core/src/core-server/change-detection/StoryDependencyGraphService.ts new file mode 100644 index 000000000000..6461c0b2e9e3 --- /dev/null +++ b/code/core/src/core-server/change-detection/StoryDependencyGraphService.ts @@ -0,0 +1,416 @@ +import { writeFile } from 'node:fs/promises'; + +import { join, normalize } from 'pathe'; + +import { getProjectRoot } from 'storybook/internal/common'; +import { logger } from 'storybook/internal/node-logger'; +import { disposeOxcParsePool } from 'storybook/internal/oxc-parser'; +import type { Presets } 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 } from './errors.ts'; +import type { ImportParser } from './parser-registry/index.ts'; +import { ParserRegistry, builtinImportParsers } from './parser-registry/index.ts'; +import { getStoryIdsByAbsolutePath } from './story-files.ts'; + +export interface StoryDependencyGraphServiceOptions { + storyIndexGeneratorPromise: Promise; + workingDir?: string; + /** Presets instance used to resolve `experimental_importParsers` contributions from plugins. */ + presets?: Presets; + /** Fired once the initial graph build succeeds and the reverse index is ready to be queried. */ + onReady?: () => void; + /** + * Edge-triggered "the dependency graph may have changed; recompute derived state" signal. Fires + * after each settled file-change patch, and synchronously on a story-index invalidation (before + * its reconciliation patches are enqueued). It is a coalesce signal, NOT a settled read: do not + * call {@link StoryDependencyGraphService.lookup} synchronously inside the callback — schedule a + * (debounced) recompute whose first step is `await whenSettled()`. May fire more than once per + * logical change, so consumers must be idempotent. + */ + onChange?: () => void; + /** Fired when the eager build (or start pipeline) fails irrecoverably. */ + onError?: (error: Error) => void; + /** Fired when the builder adapter reports a startup failure. */ + onUnavailable?: (reason: string, error?: Error) => void; +} + +/** + * Owns the module dependency graph: it consumes a builder-supplied {@link ChangeDetectionAdapter}, + * eagerly builds a reverse-dependency index from story files at startup, applies file-system events + * incrementally to that index, and reconciles the story-root set when the story index changes. + * + * It is deliberately independent of git diffing, the status store, and the change-detection + * readiness signal — those belong to the {@link ChangeDetectionService} status publisher (and, in + * the future, other consumers such as server-side docgen). Consumers observe the graph through a + * narrow surface: {@link lookup} for `changedFile -> affected stories`, {@link whenSettled} as a + * patch-settle barrier, and the lifecycle callbacks supplied at construction. + */ +export class StoryDependencyGraphService { + private disposed = false; + private readonly workingDir: string; + private adapter: ChangeDetectionAdapter | undefined; + private dependencyGraphBuilder: DependencyGraphBuilder | undefined; + private incrementalPatcher: IncrementalPatcher | undefined; + private reverseIndex: ReverseIndexImpl | undefined; + private storyFiles: Set = new Set(); + private refreshInFlight = false; + /** + * Resolves once the in-flight story-index reconciliation has enqueued its add/unlink patches. + * {@link whenSettled} awaits this before snapshotting {@link patchQueue}, so a barrier taken + * while a reconciliation is still in `getIndex()` does not miss its patches (which would let a + * later {@link lookup} observe a pre-reconciliation graph). + */ + private refreshSettled: Promise = Promise.resolve(); + /** + * 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: StoryDependencyGraphServiceOptions) { + this.workingDir = options.workingDir ?? process.cwd(); + } + + start(adapter: ChangeDetectionAdapter): void { + 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.options.onError?.(failure); + void this.dispose().catch(() => undefined); + }); + } + + /** Returns the per-story BFS-depth map for `dep`. EMPTY map if `dep` is unknown or unbuilt. */ + lookup(dep: string): Map { + return this.reverseIndex?.lookup(dep) ?? new Map(); + } + + /** True once the initial build has produced a reverse index. */ + hasGraph(): boolean { + return this.reverseIndex !== undefined; + } + + /** + * Read barrier. First awaits any in-flight story-index reconciliation so its add/unlink patches + * are enqueued, then snapshots the current tail of {@link patchQueue} (rather than re-reading the + * live field, so a continuous stream of file events cannot livelock the awaiter) and awaits it. + * When it resolves, every patch enqueued as of this call — including that reconciliation — has + * fully settled. + * + * This is a point-in-time barrier, not a freeze: file events arriving after the snapshot enqueue + * patches this call does not await, so a {@link lookup} taken after any further `await` may + * observe a newer (still non-mid-patch) graph. For a read pinned to this barrier, call + * {@link lookup} immediately after this resolves with no intervening `await`. Each new event + * re-fires {@link onChange}, so coalescing consumers converge without holding this barrier open. + */ + async whenSettled(): Promise { + // Phase 1: let any in-flight story-index reconciliation enqueue its add/unlink patches, so the + // tail snapshot below includes them. + await this.refreshSettled.catch(() => undefined); + // Phase 2: drain the patch chain, including any patches phase 1 just enqueued. + const tail = this.patchQueue; + await tail.catch(() => undefined); + } + + /** + * Builds parser registry, resolver, dependency graph, and patcher; subscribes to file-change + * events queued behind {@link patchQueue}; then signals readiness via {@link onReady}. + */ + 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(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; + } + this.options.onUnavailable?.(event.reason, event.error); + void this.dispose(); + }); + } + + if (this.disposed) { + return; + } + this.options.onReady?.(); + } + + onStoryIndexInvalidated(): void { + if (this.disposed) { + return; + } + // Single-flight: a reconciliation already running will pick up this invalidation's changes when + // its getIndex() reads the (already-nulled) index cache, so we don't start a second. Track the + // running reconciliation in refreshSettled so whenSettled() can wait for it to enqueue its + // add/unlink patches before snapshotting the patch tail. The guard lives here (not inside + // refreshStoryFiles) so a dropped second invalidation can't overwrite refreshSettled with a + // resolved no-op and let the barrier skip the real in-flight reconciliation. + if (!this.refreshInFlight && this.incrementalPatcher) { + this.refreshInFlight = true; + this.refreshSettled = this.refreshStoryFiles() + .catch(() => undefined) + .finally(() => { + this.refreshInFlight = false; + }); + } + // The story index changed even when no story files were added/removed (e.g. a story renamed + // within a file); signal consumers so derived state is recomputed. + this.options.onChange?.(); + } + + /** + * 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. + * + * Single-flight is enforced by the sole caller, {@link onStoryIndexInvalidated}, which also + * exposes this run via {@link refreshSettled} so {@link whenSettled} can wait for the add/unlink + * patches to be enqueued. + */ + private async refreshStoryFiles(): Promise { + const storyIndexGenerator = await this.options.storyIndexGeneratorPromise; + const storyIndex = await storyIndexGenerator.getIndex(); + if (this.disposed) { + return; + } + const storyIdsByFile = getStoryIdsByAbsolutePath(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); + } + } + + 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)}` + ); + } + } + + 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.options.onChange?.(); + } + + async dispose(): Promise { + if (this.disposed) { + return; + } + this.disposed = true; + + this.unsubscribeFileChange?.(); + this.unsubscribeFileChange = undefined; + this.unsubscribeStartupFailure?.(); + this.unsubscribeStartupFailure = undefined; + + // 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); + } +} diff --git a/code/core/src/core-server/change-detection/active-service-registry.ts b/code/core/src/core-server/change-detection/active-service-registry.ts new file mode 100644 index 000000000000..368ac277f9d8 --- /dev/null +++ b/code/core/src/core-server/change-detection/active-service-registry.ts @@ -0,0 +1,20 @@ +import type { StoryDependencyGraphService } from './StoryDependencyGraphService.ts'; + +let activeStoryDependencyGraphService: StoryDependencyGraphService | undefined; + +/** @internal */ +export function setDependencyGraphService(service: StoryDependencyGraphService | undefined): void { + activeStoryDependencyGraphService = service; +} + +/** + * Returns the active graph service registered by the dev-server lifecycle, or `undefined` when + * the dev-server has not finished booting yet or has already torn down. The service may exist even + * when change-detection statuses are disabled. Use {@link StoryDependencyGraphService.hasGraph} to + * check whether the initial build has completed. + * + * @experimental + */ +export function getDependencyGraphService(): StoryDependencyGraphService | undefined { + return activeStoryDependencyGraphService; +} 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..f0f5694ba5f8 --- /dev/null +++ b/code/core/src/core-server/change-detection/change-detection.test-helpers.ts @@ -0,0 +1,166 @@ +import { vi } from 'vitest'; + +import type { StoryIndex } from 'storybook/internal/types'; + +import type { ChangeDetectionAdapter, FileChangeEvent } from './adapters/index.ts'; +import { ChangeDetectionService } from './ChangeDetectionService.ts'; +import { + ChangeDetectionResolverFactory, + DependencyGraphBuilder, + IncrementalPatcher, + ReverseIndexImpl, +} from './dependency-graph/index.ts'; +import { StoryDependencyGraphService } from './StoryDependencyGraphService.ts'; + +type ChangeDetectionServiceOptions = ConstructorParameters[0]; + +/** + * Shared scaffolding for the change-detection unit tests. The dependency-graph constructors are + * mocked per test file (each file declares its own `vi.mock('./dependency-graph/index.ts', ...)`); + * these helpers drive those mocks and build synthetic adapters / indexes / reverse indexes. + */ + +export function createDeferred() { + let resolve!: (value: T) => void; + + return { + promise: new Promise((fulfill) => { + resolve = fulfill; + }), + resolve, + }; +} + +export 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, + }, + ]) + ), + }; +} + +export interface MockAdapterHandle { + adapter: ChangeDetectionAdapter; + emitFileChange: (event: FileChangeEvent) => void; + emitStartupFailure: (event: { reason: string; error?: Error }) => void; + hasFileChangeSubscriber: () => boolean; + hasStartupFailureSubscriber: () => boolean; +} + +/** + * Constructs a {@link StoryDependencyGraphService} wired to a {@link ChangeDetectionService} the + * same way the dev-server does: the graph is always injected, and its lifecycle callbacks are + * routed to the service's `onGraph*` handlers. Tests drive `graph.start(adapter)` and + * `service.start(adapter, enabled)` themselves (to keep timing control) and dispose both. + */ +export function createWiredChangeDetection(options: Omit): { + service: ChangeDetectionService; + graph: StoryDependencyGraphService; +} { + const ref: { current?: ChangeDetectionService } = {}; + const graph = new StoryDependencyGraphService({ + storyIndexGeneratorPromise: options.storyIndexGeneratorPromise, + workingDir: options.workingDir, + onReady: () => ref.current?.onGraphReady(), + onChange: () => ref.current?.onGraphChange(), + onError: (error) => ref.current?.onGraphError(error), + onUnavailable: (reason, error) => ref.current?.onGraphUnavailable(reason, error), + }); + const service = new ChangeDetectionService({ ...options, graph }); + ref.current = service; + return { service, graph }; +} + +export 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); + }; + } + + return { + adapter, + emitFileChange: (event) => { + fileHandlers.forEach((h) => h(event)); + }, + emitStartupFailure: (event) => { + startupHandlers.forEach((h) => h(event)); + }, + hasFileChangeSubscriber: () => fileHandlers.size > 0, + hasStartupFailureSubscriber: () => startupHandlers.size > 0, + }; +} + +/** + * Build a ReverseIndexImpl populated with the given (dep -> story -> depth) entries. + * Used by tests to control what `reverseIndex.lookup(changedFile)` returns. + */ +export 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 under test uses an in-test + * ReverseIndexImpl + an inert IncrementalPatcher. The mock implementations must be regular + * `function`s, not arrow functions: the service calls them with `new`, which arrow functions do + * not support. + */ +export 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 }; +} diff --git a/code/core/src/core-server/change-detection/index.ts b/code/core/src/core-server/change-detection/index.ts index 1bd5583d15aa..09337fa8d704 100644 --- a/code/core/src/core-server/change-detection/index.ts +++ b/code/core/src/core-server/change-detection/index.ts @@ -6,6 +6,9 @@ export { type ChangeDetectionReadiness, } from './readiness.ts'; export { ChangeDetectionService } from './ChangeDetectionService.ts'; +export { StoryDependencyGraphService } from './StoryDependencyGraphService.ts'; +export type { StoryDependencyGraphServiceOptions } from './StoryDependencyGraphService.ts'; +export { getDependencyGraphService, setDependencyGraphService } from './active-service-registry.ts'; export type { ChangeDetectionAdapter, FileChangeEvent, diff --git a/code/core/src/core-server/change-detection/story-files.ts b/code/core/src/core-server/change-detection/story-files.ts new file mode 100644 index 000000000000..fa9c828028b3 --- /dev/null +++ b/code/core/src/core-server/change-detection/story-files.ts @@ -0,0 +1,44 @@ +import { join, normalize } from 'pathe'; + +import type { StoryIndexGenerator } from '../utils/StoryIndexGenerator.ts'; + +type StoryIndex = Awaited>; + +/** + * Maps each story index to its absolute-story-file -> story-id sets, keyed by the index object + * so repeat calls within a scan/build reuse the result. The story index is referentially stable + * for a given generation, so identity-keying is safe; the `workingDir` field guards against the + * (test-only) case of the same index resolved against a different working directory. + */ +const cache = new WeakMap< + StoryIndex, + { workingDir: string; storyIdsByFile: Map> } +>(); + +/** + * Builds (or returns a cached) map from absolute story-file path to the set of story ids declared + * in that file. Virtual entries are skipped. Shared by the dependency-graph tracker (to derive its + * story-root set) and the status publisher (to map affected files back to story ids). + */ +export function getStoryIdsByAbsolutePath( + storyIndex: StoryIndex, + 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; +} diff --git a/code/core/src/core-server/dev-server.ts b/code/core/src/core-server/dev-server.ts index e085a4446255..9dee7f62fc62 100644 --- a/code/core/src/core-server/dev-server.ts +++ b/code/core/src/core-server/dev-server.ts @@ -9,7 +9,11 @@ import polka from 'polka'; import { isTelemetryModuleEnabled, telemetry } from '../telemetry/index.ts'; import type { ChangeDetectionAdapter } from './change-detection/index.ts'; -import { ChangeDetectionService } from './change-detection/index.ts'; +import { + ChangeDetectionService, + setDependencyGraphService, + StoryDependencyGraphService, +} from './change-detection/index.ts'; import { getStatusStoreByTypeId } from './stores/status.ts'; import type { StoryIndexGenerator } from './utils/StoryIndexGenerator.ts'; import { doTelemetry } from './utils/doTelemetry.ts'; @@ -48,12 +52,33 @@ export async function storybookDevServer( const storyIndexGeneratorPromise = options.presets.apply('storyIndexGenerator'); + // Graph callbacks are wired before service construction; callbacks no-op until assigned below. + const changeDetectionServiceRef: { current?: ChangeDetectionService } = {}; + const storyDependencyGraphService = new StoryDependencyGraphService({ + storyIndexGeneratorPromise, + workingDir, + presets: options.presets, + onReady: () => changeDetectionServiceRef.current?.onGraphReady(), + onChange: () => changeDetectionServiceRef.current?.onGraphChange(), + onError: (failure) => changeDetectionServiceRef.current?.onGraphError(failure), + onUnavailable: (reason, error) => + changeDetectionServiceRef.current?.onGraphUnavailable(reason, error), + }); + setDependencyGraphService(storyDependencyGraphService); + const changeDetectionService = new ChangeDetectionService({ + graph: storyDependencyGraphService, storyIndexGeneratorPromise, statusStore: getStatusStoreByTypeId(CHANGE_DETECTION_STATUS_TYPE_ID), workingDir, - presets: options.presets, }); + changeDetectionServiceRef.current = changeDetectionService; + + const disposeChangeDetectionRuntime = async () => { + await changeDetectionService.dispose().catch(() => undefined); + setDependencyGraphService(undefined); + await storyDependencyGraphService.dispose().catch(() => undefined); + }; app.use(compression({ level: 1 })); @@ -79,7 +104,7 @@ export async function storybookDevServer( channel: options.channel, workingDir, configDir, - onStoryIndexInvalidated: () => changeDetectionService.onStoryIndexInvalidated(), + onStoryIndexInvalidated: () => storyDependencyGraphService.onStoryIndexInvalidated(), }); (await getMiddleware(options.configDir))(app); @@ -124,10 +149,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 +162,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 +174,24 @@ 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)); - } + 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)); + } + + if (adapter) { + storyDependencyGraphService.start(adapter); + } + + const isChangeDetectionStatusEnabled = features.changeDetection !== false; + if (isChangeDetectionStatusEnabled) { changeDetectionService.start(adapter, true); + } else { + // Status publication is explicitly feature-gated; graph service may still be consumed elsewhere. + changeDetectionService.start(undefined, false); } } @@ -180,6 +210,7 @@ export async function storybookDevServer( }); } } catch (e) { + await disposeChangeDetectionRuntime(); await managerBuilder?.bail().catch(() => undefined); await previewBuilder?.bail().catch(() => undefined); throw e; @@ -211,6 +242,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..9781654d6671 100644 --- a/code/core/src/core-server/index.ts +++ b/code/core/src/core-server/index.ts @@ -17,6 +17,30 @@ export { loadStorybook as experimental_loadStorybook } from './load.ts'; export { Tag } from '../shared/constants/tags.ts'; export { analyzeMdx } from './utils/analyze-mdx.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'; @@ -43,6 +67,8 @@ export type { ParseFileArgs, } from './change-detection/index.ts'; export { ChangeDetectionService } from './change-detection/ChangeDetectionService.ts'; +export { StoryDependencyGraphService } from './change-detection/StoryDependencyGraphService.ts'; +export { getDependencyGraphService as experimental_getDependencyGraphService } from './change-detection/active-service-registry.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..2cea939d4073 100644 --- a/code/core/src/core-server/load.ts +++ b/code/core/src/core-server/load.ts @@ -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'; @@ -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-preset.ts b/code/core/src/core-server/presets/common-preset.ts index cc9fb0c65653..1bab61adaac0 100644 --- a/code/core/src/core-server/presets/common-preset.ts +++ b/code/core/src/core-server/presets/common-preset.ts @@ -310,6 +310,16 @@ export const managerEntries = async (existing: any) => { ]; }; +globalThis.STORYBOOK_SERVICES_LOADED = globalThis.STORYBOOK_SERVICES_LOADED ?? false; +export const services = async () => { + 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; +}; + // 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/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/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/runtime-instance-registry.test.ts b/code/core/src/core-server/utils/runtime-instance-registry.test.ts index 2dcc1f02a4b3..28dd01198614 100644 --- a/code/core/src/core-server/utils/runtime-instance-registry.test.ts +++ b/code/core/src/core-server/utils/runtime-instance-registry.test.ts @@ -1,17 +1,27 @@ -import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs'; +import { + existsSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + utimesSync, + writeFileSync, +} from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve } from 'pathe'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { createRuntimeInstanceRecord, + getRuntimeInstanceRegistryCleanupDecision, getMcpMetadataFromMainConfig, writeRuntimeInstanceRecord, writeStorybookRuntimeInstanceRecord, } from './runtime-instance-registry.ts'; const tempDirs: string[] = []; +const NOW = new Date('2026-06-02T12:00:00.000Z'); function makeTempDir() { const dir = mkdtempSync(join(tmpdir(), 'storybook-runtime-registry-')); @@ -19,7 +29,78 @@ function makeTempDir() { return dir; } +function hoursAgo(hours: number) { + return new Date(NOW.getTime() - hours * 60 * 60 * 1000); +} + +function daysAgo(days: number) { + return hoursAgo(days * 24); +} + +function writeRecordFile({ + instanceId, + mtime, + pid = 12345, + registryDir, + startedAt, + updatedAt, +}: { + instanceId: string; + mtime?: Date; + pid?: number; + registryDir: string; + startedAt?: string; + updatedAt?: string; +}) { + const record = createRuntimeInstanceRecord({ + address: 'http://localhost:6006/', + instanceId, + now: NOW, + pid, + port: 6006, + storybookVersion: '10.5.0-alpha.0', + }); + const recordPath = join(registryDir, `${instanceId}.json`); + + writeFileSync( + recordPath, + `${JSON.stringify( + { + ...record, + ...(startedAt === undefined ? {} : { startedAt }), + ...(updatedAt === undefined ? {} : { updatedAt }), + }, + null, + 2 + )}\n`, + 'utf-8' + ); + + if (mtime) { + utimesSync(recordPath, mtime, mtime); + } + + return recordPath; +} + +async function writeCurrentRecord(registryDir: string) { + return writeRuntimeInstanceRecord( + createRuntimeInstanceRecord({ + address: 'http://localhost:7007/', + instanceId: 'current-instance', + now: NOW, + pid: 7007, + port: 7007, + storybookVersion: '10.5.0-alpha.0', + }), + registryDir + ); +} + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + while (tempDirs.length > 0) { rmSync(tempDirs.pop()!, { force: true, recursive: true }); } @@ -111,6 +192,122 @@ describe('createRuntimeInstanceRecord', () => { }); }); +describe('getRuntimeInstanceRegistryCleanupDecision', () => { + const recentRecord = createRuntimeInstanceRecord({ + address: 'http://localhost:6006/', + instanceId: 'recent-instance', + now: NOW, + pid: 12345, + port: 6006, + storybookVersion: '10.5.0-alpha.0', + }); + + it.each([ + { + name: 'keeps temp files newer than a day', + entry: { + kind: 'temp-file' as const, + fileModifiedAtMs: hoursAgo(23).getTime(), + nowMs: NOW.getTime(), + }, + expected: { action: 'keep' }, + }, + { + name: 'removes temp files older than a day', + entry: { + kind: 'temp-file' as const, + fileModifiedAtMs: daysAgo(2).getTime(), + nowMs: NOW.getTime(), + }, + expected: { action: 'remove' }, + }, + { + name: 'keeps malformed JSON newer than seven days', + entry: { + kind: 'malformed-json' as const, + fileModifiedAtMs: daysAgo(6).getTime(), + nowMs: NOW.getTime(), + }, + expected: { action: 'keep' }, + }, + { + name: 'removes malformed JSON older than seven days', + entry: { + kind: 'malformed-json' as const, + fileModifiedAtMs: daysAgo(8).getTime(), + nowMs: NOW.getTime(), + }, + expected: { action: 'remove' }, + }, + { + name: 'keeps valid records newer than a day', + entry: { + kind: 'record' as const, + fileModifiedAtMs: daysAgo(8).getTime(), + nowMs: NOW.getTime(), + record: { ...recentRecord, updatedAt: hoursAgo(23).toISOString() }, + }, + expected: { action: 'keep' }, + }, + { + name: 'checks the PID for valid records from a day through seven days old', + entry: { + kind: 'record' as const, + fileModifiedAtMs: daysAgo(8).getTime(), + nowMs: NOW.getTime(), + record: { ...recentRecord, pid: 202, updatedAt: hoursAgo(24).toISOString() }, + }, + expected: { action: 'check-pid', pid: 202 }, + }, + { + name: 'keeps stale valid records without a usable PID', + entry: { + kind: 'record' as const, + fileModifiedAtMs: daysAgo(8).getTime(), + nowMs: NOW.getTime(), + record: { ...recentRecord, pid: 0, updatedAt: daysAgo(2).toISOString() }, + }, + expected: { action: 'keep' }, + }, + { + name: 'removes valid records older than seven days', + entry: { + kind: 'record' as const, + fileModifiedAtMs: hoursAgo(1).getTime(), + nowMs: NOW.getTime(), + record: { ...recentRecord, updatedAt: daysAgo(8).toISOString() }, + }, + expected: { action: 'remove' }, + }, + { + name: 'falls back from updatedAt to startedAt', + entry: { + kind: 'record' as const, + fileModifiedAtMs: hoursAgo(1).getTime(), + nowMs: NOW.getTime(), + record: { + ...recentRecord, + startedAt: daysAgo(8).toISOString(), + updatedAt: 'not-a-date', + }, + }, + expected: { action: 'remove' }, + }, + { + name: 'falls back to file mtime when record timestamps are invalid', + entry: { + kind: 'record' as const, + fileModifiedAtMs: daysAgo(8).getTime(), + nowMs: NOW.getTime(), + record: { ...recentRecord, startedAt: 'not-a-date', updatedAt: 'not-a-date' }, + }, + expected: { action: 'remove' }, + }, + ])('$name', ({ entry, expected }) => { + expect(getRuntimeInstanceRegistryCleanupDecision(entry)).toEqual(expected); + }); +}); + describe('writeRuntimeInstanceRecord', () => { it('writes via a temporary file in the registry directory and renames to the final JSON path', async () => { const registryDir = makeTempDir(); @@ -129,6 +326,175 @@ describe('writeRuntimeInstanceRecord', () => { expect(readdirSync(registryDir)).toEqual([`${record.instanceId}.json`]); expect(JSON.parse(readFileSync(recordPath, 'utf-8'))).toEqual(record); }); + + it('sweeps stale registry entries before writing the current record', async () => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + + const registryDir = makeTempDir(); + writeRecordFile({ + instanceId: 'recent-valid', + pid: 101, + registryDir, + updatedAt: hoursAgo(23).toISOString(), + }); + writeRecordFile({ + instanceId: 'stale-inactive', + pid: 202, + registryDir, + updatedAt: hoursAgo(24).toISOString(), + }); + writeRecordFile({ + instanceId: 'stale-active', + pid: 303, + registryDir, + updatedAt: daysAgo(2).toISOString(), + }); + writeRecordFile({ + instanceId: 'stale-ambiguous', + pid: 404, + registryDir, + updatedAt: daysAgo(2).toISOString(), + }); + writeRecordFile({ + instanceId: 'old-valid', + pid: 505, + registryDir, + updatedAt: daysAgo(8).toISOString(), + }); + writeFileSync(join(registryDir, 'recent-malformed.json'), '{', 'utf-8'); + writeFileSync(join(registryDir, 'old-malformed.json'), '{', 'utf-8'); + writeFileSync(join(registryDir, 'recent.tmp'), '{}', 'utf-8'); + writeFileSync(join(registryDir, 'old.tmp'), '{}', 'utf-8'); + utimesSync(join(registryDir, 'recent-malformed.json'), daysAgo(6), daysAgo(6)); + utimesSync(join(registryDir, 'old-malformed.json'), daysAgo(8), daysAgo(8)); + utimesSync(join(registryDir, 'recent.tmp'), hoursAgo(23), hoursAgo(23)); + utimesSync(join(registryDir, 'old.tmp'), daysAgo(2), daysAgo(2)); + + const killSpy = vi.spyOn(process, 'kill').mockImplementation((pid) => { + if (pid === 202) { + throw Object.assign(new Error('missing process'), { code: 'ESRCH' }); + } + + if (pid === 404) { + throw Object.assign(new Error('permission denied'), { code: 'EPERM' }); + } + + return true; + }); + + await writeCurrentRecord(registryDir); + + expect(readdirSync(registryDir).sort()).toEqual([ + 'current-instance.json', + 'recent-malformed.json', + 'recent-valid.json', + 'recent.tmp', + 'stale-active.json', + 'stale-ambiguous.json', + ]); + expect(killSpy.mock.calls.map(([pid]) => pid).sort()).toEqual([202, 303, 404]); + }); + + it('does not block writing the current record when one stale file cannot be removed', async () => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + + const registryDir = makeTempDir(); + const lockedRecordPath = writeRecordFile({ + instanceId: 'locked-instance', + registryDir, + updatedAt: daysAgo(8).toISOString(), + }); + const actualFsPromises = + await vi.importActual('node:fs/promises'); + + vi.resetModules(); + vi.doMock('node:fs/promises', () => ({ + ...actualFsPromises, + rm: vi.fn(async (path, options) => { + if (path === lockedRecordPath) { + throw Object.assign(new Error('locked'), { code: 'EBUSY' }); + } + + return actualFsPromises.rm(path, options); + }), + })); + + try { + const { + createRuntimeInstanceRecord: createRuntimeInstanceRecordWithMockedFs, + writeRuntimeInstanceRecord: writeRuntimeInstanceRecordWithMockedFs, + } = await import('./runtime-instance-registry.ts'); + const currentRecordPath = await writeRuntimeInstanceRecordWithMockedFs( + createRuntimeInstanceRecordWithMockedFs({ + address: 'http://localhost:7007/', + instanceId: 'current-instance', + now: NOW, + pid: 7007, + port: 7007, + storybookVersion: '10.5.0-alpha.0', + }), + registryDir + ); + + expect(existsSync(lockedRecordPath)).toBe(true); + expect(existsSync(currentRecordPath)).toBe(true); + } finally { + vi.doUnmock('node:fs/promises'); + vi.resetModules(); + } + }); + + it('does not treat unreadable JSON files as malformed JSON', async () => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + + const registryDir = makeTempDir(); + const unreadableRecordPath = writeRecordFile({ + instanceId: 'unreadable-instance', + registryDir, + updatedAt: daysAgo(8).toISOString(), + }); + const actualFsPromises = + await vi.importActual('node:fs/promises'); + + vi.resetModules(); + vi.doMock('node:fs/promises', () => ({ + ...actualFsPromises, + readFile: vi.fn(async (path, options) => { + if (path === unreadableRecordPath) { + throw Object.assign(new Error('permission denied'), { code: 'EACCES' }); + } + + return actualFsPromises.readFile(path, options); + }), + })); + + try { + const { + createRuntimeInstanceRecord: createRuntimeInstanceRecordWithMockedFs, + writeRuntimeInstanceRecord: writeRuntimeInstanceRecordWithMockedFs, + } = await import('./runtime-instance-registry.ts'); + const currentRecordPath = await writeRuntimeInstanceRecordWithMockedFs( + createRuntimeInstanceRecordWithMockedFs({ + address: 'http://localhost:7007/', + instanceId: 'current-instance', + now: NOW, + pid: 7007, + port: 7007, + storybookVersion: '10.5.0-alpha.0', + }), + registryDir + ); + + expect(existsSync(unreadableRecordPath)).toBe(true); + expect(existsSync(currentRecordPath)).toBe(true); + } finally { + vi.doUnmock('node:fs/promises'); + vi.resetModules(); + } + }); }); describe('writeStorybookRuntimeInstanceRecord', () => { diff --git a/code/core/src/core-server/utils/runtime-instance-registry.ts b/code/core/src/core-server/utils/runtime-instance-registry.ts index 0b6b19d2bc97..41a78223a9a3 100644 --- a/code/core/src/core-server/utils/runtime-instance-registry.ts +++ b/code/core/src/core-server/utils/runtime-instance-registry.ts @@ -1,5 +1,5 @@ import { existsSync, rmSync } from 'node:fs'; -import { mkdir, rename, rm, writeFile } from 'node:fs/promises'; +import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises'; import { randomUUID } from 'node:crypto'; import { homedir } from 'node:os'; @@ -9,6 +9,8 @@ import { join, resolve } from 'pathe'; const STORYBOOK_MCP_ADDON = '@storybook/addon-mcp'; const DEFAULT_MCP_ENDPOINT = '/mcp'; +const ONE_DAY_MS = 24 * 60 * 60 * 1000; +const SEVEN_DAYS_MS = 7 * ONE_DAY_MS; export type RuntimeInstanceRecord = { schemaVersion: 1; @@ -30,6 +32,16 @@ export type RuntimeInstanceRegistration = { unregisterProcessCleanup: () => void; }; +export type RuntimeInstanceRegistryCleanupEntry = + | { kind: 'temp-file'; fileModifiedAtMs: number; nowMs: number } + | { kind: 'malformed-json'; fileModifiedAtMs: number; nowMs: number } + | { kind: 'record'; fileModifiedAtMs: number; nowMs: number; record: unknown }; + +export type RuntimeInstanceRegistryCleanupDecision = + | { action: 'keep' } + | { action: 'remove' } + | { action: 'check-pid'; pid: number }; + export function getDefaultRuntimeInstanceRegistryDir() { return join(homedir(), '.storybook', 'instances'); } @@ -100,6 +112,7 @@ export async function writeRuntimeInstanceRecord( registryDir = getDefaultRuntimeInstanceRegistryDir() ) { await mkdir(registryDir, { recursive: true }); + await cleanupRuntimeInstanceRegistry(registryDir); const recordPath = join(registryDir, `${record.instanceId}.json`); const tempPath = join( @@ -118,6 +131,157 @@ export async function writeRuntimeInstanceRecord( return recordPath; } +function isRecordObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function getTimestampMs(record: Record, key: 'updatedAt' | 'startedAt') { + const value = record[key]; + + if (typeof value !== 'string') { + return undefined; + } + + const timestamp = Date.parse(value); + + return Number.isFinite(timestamp) ? timestamp : undefined; +} + +function getRecordAgeMs(record: unknown, fileModifiedAtMs: number, nowMs: number) { + const recordTimestampMs = isRecordObject(record) + ? (getTimestampMs(record, 'updatedAt') ?? getTimestampMs(record, 'startedAt')) + : undefined; + + return nowMs - (recordTimestampMs ?? fileModifiedAtMs); +} + +function getRecordPid(record: unknown) { + if (!isRecordObject(record) || typeof record.pid !== 'number') { + return undefined; + } + + return Number.isInteger(record.pid) && record.pid > 0 ? record.pid : undefined; +} + +function isErrnoException(error: unknown): error is NodeJS.ErrnoException { + return typeof error === 'object' && error !== null && 'code' in error; +} + +export function getRuntimeInstanceRegistryCleanupDecision( + entry: RuntimeInstanceRegistryCleanupEntry +): RuntimeInstanceRegistryCleanupDecision { + if (entry.kind === 'temp-file') { + return entry.nowMs - entry.fileModifiedAtMs > ONE_DAY_MS + ? { action: 'remove' } + : { action: 'keep' }; + } + + if (entry.kind === 'malformed-json') { + return entry.nowMs - entry.fileModifiedAtMs > SEVEN_DAYS_MS + ? { action: 'remove' } + : { action: 'keep' }; + } + + const recordAgeMs = getRecordAgeMs(entry.record, entry.fileModifiedAtMs, entry.nowMs); + + if (recordAgeMs > SEVEN_DAYS_MS) { + return { action: 'remove' }; + } + + if (recordAgeMs < ONE_DAY_MS) { + return { action: 'keep' }; + } + + const recordPid = getRecordPid(entry.record); + + return recordPid === undefined ? { action: 'keep' } : { action: 'check-pid', pid: recordPid }; +} + +function isPidInactive(pid: number) { + try { + process.kill(pid, 0); + return false; + } catch (error) { + return isErrnoException(error) && error.code === 'ESRCH'; + } +} + +async function applyCleanupDecision( + recordPath: string, + decision: RuntimeInstanceRegistryCleanupDecision +) { + if ( + decision.action === 'remove' || + (decision.action === 'check-pid' && isPidInactive(decision.pid)) + ) { + await rm(recordPath, { force: true }); + } +} + +async function cleanupRuntimeInstanceRegistry(registryDir: string) { + const nowMs = Date.now(); + const entries = await readdir(registryDir, { withFileTypes: true }).catch(() => []); + + await Promise.all( + entries.map(async (entry) => { + if (!entry.isFile()) { + return; + } + + const recordPath = join(registryDir, entry.name); + + try { + const { mtimeMs } = await stat(recordPath); + + if (entry.name.endsWith('.tmp')) { + await applyCleanupDecision( + recordPath, + getRuntimeInstanceRegistryCleanupDecision({ + kind: 'temp-file', + fileModifiedAtMs: mtimeMs, + nowMs, + }) + ); + return; + } + + if (!entry.name.endsWith('.json')) { + return; + } + + const content = await readFile(recordPath, 'utf-8'); + let record: unknown; + + try { + record = JSON.parse(content) as unknown; + } catch { + await applyCleanupDecision( + recordPath, + getRuntimeInstanceRegistryCleanupDecision({ + kind: 'malformed-json', + fileModifiedAtMs: mtimeMs, + nowMs, + }) + ); + return; + } + + await applyCleanupDecision( + recordPath, + getRuntimeInstanceRegistryCleanupDecision({ + kind: 'record', + fileModifiedAtMs: mtimeMs, + nowMs, + record, + }) + ); + } catch { + // Registry cleanup is opportunistic; one bad file should not block the current record. + } + }) + ); +} + function registerProcessCleanup(recordPath: string) { const cleanupSync = () => { if (existsSync(recordPath)) { diff --git a/code/core/src/csf-tools/vitest-plugin/transformer.test.ts b/code/core/src/csf-tools/vitest-plugin/transformer.test.ts index 7d27cc5a1dc8..bb62bd3b27bd 100644 --- a/code/core/src/csf-tools/vitest-plugin/transformer.test.ts +++ b/code/core/src/csf-tools/vitest-plugin/transformer.test.ts @@ -595,6 +595,62 @@ describe('transformer', () => { } `); }); + + it('should pass skip tags to child .test() calls using tags.skip', async () => { + const code = ` + export default {}; + export const Primary = {}; + Primary.test("runs", () => {}); + Primary.test("skipped", { tags: ['skip-me'] }, () => {}); + `; + + const result = await transform({ + code, + tagsFilter: { include: [Tag.TEST], exclude: [], skip: ['skip-me'] }, + }); + + expect(result.code).toMatchInlineSnapshot(` + import { test as _test, expect as _expect, describe as _describe } from "vitest"; + import { testStory as _testStory, convertToFilePath } from "@storybook/addon-vitest/internal/test-utils"; + const _meta = { + title: "automatic/calculated/title" + }; + export default _meta; + export const Primary = {}; + Primary.test("runs", () => {}); + Primary.test("skipped", { + tags: ['skip-me'] + }, () => {}); + const _isRunningFromThisFile = convertToFilePath(import.meta.url).includes(globalThis.__vitest_worker__.filepath ?? _expect.getState().testPath); + if (_isRunningFromThisFile) { + _describe("Primary ", () => { + _test("base story", _testStory({ + exportName: "Primary", + story: Primary, + meta: _meta, + skipTags: ["skip-me"], + storyId: "automatic-calculated-title--primary" + })); + _test("runs", _testStory({ + exportName: "Primary", + story: Primary, + meta: _meta, + skipTags: ["skip-me"], + storyId: "automatic-calculated-title--primary:runs", + testName: "runs" + })); + _test("skipped", _testStory({ + exportName: "Primary", + story: Primary, + meta: _meta, + skipTags: ["skip-me"], + storyId: "automatic-calculated-title--primary:skipped", + testName: "skipped" + })); + }); + } + `); + }); }); describe('component info extraction', () => { @@ -1187,6 +1243,63 @@ describe('transformer', () => { } `); }); + + it('should pass skip tags to child .test() calls using tags.skip', async () => { + const code = ` + import { config } from '#.storybook/preview'; + const meta = config.meta({}); + export const Primary = meta.story({}); + Primary.test("runs", () => {}); + Primary.test("skipped", { tags: ['skip-me'] }, () => {}); + `; + + const result = await transform({ + code, + tagsFilter: { include: [Tag.TEST], exclude: [], skip: ['skip-me'] }, + }); + + expect(result.code).toMatchInlineSnapshot(` + import { test as _test, expect as _expect, describe as _describe } from "vitest"; + import { testStory as _testStory, convertToFilePath } from "@storybook/addon-vitest/internal/test-utils"; + import { config } from '#.storybook/preview'; + const meta = config.meta({ + title: "automatic/calculated/title" + }); + export const Primary = meta.story({}); + Primary.test("runs", () => {}); + Primary.test("skipped", { + tags: ['skip-me'] + }, () => {}); + const _isRunningFromThisFile = convertToFilePath(import.meta.url).includes(globalThis.__vitest_worker__.filepath ?? _expect.getState().testPath); + if (_isRunningFromThisFile) { + _describe("Primary ", () => { + _test("base story", _testStory({ + exportName: "Primary", + story: Primary, + meta: meta, + skipTags: ["skip-me"], + storyId: "automatic-calculated-title--primary" + })); + _test("runs", _testStory({ + exportName: "Primary", + story: Primary, + meta: meta, + skipTags: ["skip-me"], + storyId: "automatic-calculated-title--primary:runs", + testName: "runs" + })); + _test("skipped", _testStory({ + exportName: "Primary", + story: Primary, + meta: meta, + skipTags: ["skip-me"], + storyId: "automatic-calculated-title--primary:skipped", + testName: "skipped" + })); + }); + } + `); + }); }); describe('source map calculation', () => { diff --git a/code/core/src/csf-tools/vitest-plugin/transformer.ts b/code/core/src/csf-tools/vitest-plugin/transformer.ts index c3a0a1a204e3..4fc8332b532e 100644 --- a/code/core/src/csf-tools/vitest-plugin/transformer.ts +++ b/code/core/src/csf-tools/vitest-plugin/transformer.ts @@ -309,7 +309,7 @@ export async function vitestTransform({ t.objectProperty(t.identifier('exportName'), t.stringLiteral(exportName)), t.objectProperty(t.identifier('story'), t.identifier(localName)), t.objectProperty(t.identifier('meta'), t.identifier(metaExportName)), - t.objectProperty(t.identifier('skipTags'), t.arrayExpression([])), + t.objectProperty(t.identifier('skipTags'), skipTagsId), t.objectProperty(t.identifier('storyId'), t.stringLiteral(storyId)), ]; diff --git a/code/core/src/manager-api/modules/layout.ts b/code/core/src/manager-api/modules/layout.ts index f8d053773926..5fdaf61d99b3 100644 --- a/code/core/src/manager-api/modules/layout.ts +++ b/code/core/src/manager-api/modules/layout.ts @@ -11,6 +11,7 @@ import { global } from '@storybook/global'; import { pick, toMerged } from 'es-toolkit/object'; import { isEqual as deepEqual } from 'es-toolkit/predicate'; import type { ThemeVars } from 'storybook/theming'; +import { deprecate } from 'storybook/internal/client-logger'; import { create } from 'storybook/theming/create'; import merge from '../lib/merge.ts'; @@ -132,7 +133,6 @@ export const getDefaultLayoutState: () => SubState = () => { }, layout: { initialActive: ActiveTabs.CANVAS, - showToolbar: true, navSize: DEFAULT_NAV_SIZE, bottomPanelHeight: DEFAULT_BOTTOM_PANEL_HEIGHT, rightPanelWidth: DEFAULT_RIGHT_PANEL_WIDTH, @@ -142,9 +142,13 @@ export const getDefaultLayoutState: () => SubState = () => { rightPanelWidth: DEFAULT_RIGHT_PANEL_WIDTH, }, panelPosition: 'bottom', + showNav: true, + showPanel: true, showTabs: true, + showToolbar: true, }, layoutCustomisations: { + showPanel: undefined, showSidebar: undefined, showToolbar: undefined, }, @@ -192,6 +196,81 @@ const getRecentVisibleSizes = (layoutState: API_Layout) => { }; }; +/** + * Merges layout options into the existing layout state and translates + * `showNav` / `showPanel` booleans into the underlying size fields. + * + * Layout keys can be provided either at the top level (deprecated) or under + * `options.layout` (preferred). Nested layout keys take precedence. + * + * Numeric sizes are merged in before applying show/hide flags, so + * `recentVisibleSizes` is captured from the latest size values. + */ +const applyLayoutOptions = ( + layoutState: API_Layout, + options: { layout?: Partial; [key: string]: any }, + singleStory: boolean +) => { + const layoutKeys = Object.keys(layoutState); + const layoutAtTopLevel = pick(options, layoutKeys); + + for (const key of Object.keys(layoutAtTopLevel)) { + deprecate( + `Calling \`setConfig({ ${key}: ... })\` is deprecated. Please call \`setConfig({ layout: { ${key}: ... } })\` instead.` + ); + } + + const mergedLayoutOptions = toMerged(layoutAtTopLevel, options.layout || {}); + const { showPanel, showNav } = mergedLayoutOptions; + + // Safety net: drop any unknown keys that aren't part of API_Layout. + const typedLayoutKeys = layoutKeys as (keyof API_Layout)[]; + const nextLayoutState = toMerged(layoutState, pick(mergedLayoutOptions, typedLayoutKeys)); + + // singleStory always hides the sidebar; otherwise honor showSidebar. + if (showNav === false || singleStory) { + nextLayoutState.recentVisibleSizes = getRecentVisibleSizes(nextLayoutState); + nextLayoutState.navSize = 0; + } else if (showNav === true) { + nextLayoutState.navSize = nextLayoutState.recentVisibleSizes.navSize; + } + + if (showPanel === false) { + nextLayoutState.recentVisibleSizes = getRecentVisibleSizes(nextLayoutState); + nextLayoutState.bottomPanelHeight = 0; + nextLayoutState.rightPanelWidth = 0; + } else if (showPanel === true) { + nextLayoutState.bottomPanelHeight = nextLayoutState.recentVisibleSizes.bottomPanelHeight; + nextLayoutState.rightPanelWidth = nextLayoutState.recentVisibleSizes.rightPanelWidth; + } + + return nextLayoutState; +}; + +/** + * Merges ui options into the existing ui state. + * + * Ui keys can be provided either at the top level (deprecated) or under + * `options.ui` (preferred). Nested ui keys take precedence. + * + * Numeric sizes are merged in before applying show/hide flags, so + * `recentVisibleSizes` is captured from the latest size values. + */ +const applyUiOptions = (uiState: API_UI, options: { ui?: Partial; [key: string]: any }) => { + const uiKeys = Object.keys(uiState); + const uiAtTopLevel = pick(options, uiKeys); + + for (const key of Object.keys(uiAtTopLevel)) { + deprecate( + `Calling \`setConfig({ ${key}: ... })\` is deprecated. Please call \`setConfig({ ui: { ${key}: ... } })\` instead.` + ); + } + + // Safety net: drop any unknown keys that aren't part of API_UI. + const typedUiKeys = uiKeys as (keyof API_UI)[]; + return toMerged(uiState, pick(toMerged(uiAtTopLevel, options.ui || {}), typedUiKeys)); +}; + export const init: ModuleFn = ({ store, provider, singleStory }) => { const api = { toggleFullscreen(nextState?: boolean) { @@ -439,23 +518,19 @@ export const init: ModuleFn = ({ store, provider, singleStory }, getInitialOptions() { - const { theme, selectedPanel, layoutCustomisations, ...options } = provider.getConfig(); + const userConfig = provider.getConfig(); const defaultLayoutState = getDefaultLayoutState(); + const { theme, selectedPanel, layoutCustomisations } = userConfig; + return { ...defaultLayoutState, - layout: { - ...toMerged( - defaultLayoutState.layout, - pick(options, Object.keys(defaultLayoutState.layout)) - ), - ...(singleStory && { navSize: 0 }), - }, + layout: applyLayoutOptions(defaultLayoutState.layout, userConfig, !!singleStory), layoutCustomisations: { ...defaultLayoutState.layoutCustomisations, ...(layoutCustomisations ?? {}), }, - ui: toMerged(defaultLayoutState.ui, pick(options, Object.keys(defaultLayoutState.ui))), + ui: applyUiOptions(defaultLayoutState.ui, userConfig), selectedPanel: selectedPanel || defaultLayoutState.selectedPanel, theme: theme || defaultLayoutState.theme, }; @@ -513,18 +588,9 @@ export const init: ModuleFn = ({ store, provider, singleStory return; } - const updatedLayout = { - ...layout, - ...(options.layout || {}), - ...pick(options, Object.keys(layout)), - ...(singleStory && { navSize: 0 }), - }; + const updatedLayout = applyLayoutOptions(layout, options, !!singleStory); - const updatedUi = { - ...ui, - ...options.ui, - ...toMerged(options.ui || {}, pick(options, Object.keys(ui))), - }; + const updatedUi = applyUiOptions(ui, options); const updatedTheme = { ...theme, diff --git a/code/core/src/manager-api/tests/layout.test.ts b/code/core/src/manager-api/tests/layout.test.ts index 30fd16e0e4ad..f8c8c0b45e56 100644 --- a/code/core/src/manager-api/tests/layout.test.ts +++ b/code/core/src/manager-api/tests/layout.test.ts @@ -2,6 +2,7 @@ import type { Mock } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { API_Provider } from 'storybook/internal/types'; +import * as clientLogger from 'storybook/internal/client-logger'; import EventEmitter from 'events'; import { themes } from 'storybook/theming'; @@ -450,7 +451,7 @@ describe('layout API', () => { }); it('should not change selectedPanel if it is undefined in the options, but something else has changed', () => { - layoutApi.setOptions({ panelPosition: 'right' }); + layoutApi.setOptions({ layout: { panelPosition: 'right' } }); expect(getLastSetStateArgs()[0].selectedPanel).toBeUndefined(); }); @@ -467,7 +468,10 @@ describe('layout API', () => { it('should not change selectedPanel if it is currently the same, but something else has changed', () => { layoutApi.setOptions({}); // second call is needed to overwrite initial layout - layoutApi.setOptions({ panelPosition: 'right', selectedPanel: currentState.selectedPanel }); + layoutApi.setOptions({ + layout: { panelPosition: 'right' }, + selectedPanel: currentState.selectedPanel, + }); expect(getLastSetStateArgs()[0].selectedPanel).toBeUndefined(); }); @@ -486,6 +490,197 @@ describe('layout API', () => { expect(getLastSetStateArgs()[0].selectedPanel).toEqual(panelName); }); + + it('should hide the panel when layout.showPanel is false', () => { + layoutApi.setSizes({ + bottomPanelHeight: 200, + rightPanelWidth: 250, + }); + + layoutApi.setOptions({ layout: { showPanel: false } }); + + expect(currentState.layout.bottomPanelHeight).toBe(0); + expect(currentState.layout.rightPanelWidth).toBe(0); + expect(currentState.layout.recentVisibleSizes.bottomPanelHeight).toBe(200); + expect(currentState.layout.recentVisibleSizes.rightPanelWidth).toBe(250); + + layoutApi.togglePanel(true); + + expect(currentState.layout.bottomPanelHeight).toBe(200); + expect(currentState.layout.rightPanelWidth).toBe(250); + }); + + it('should hide nav and preserve provided navSize when layout.showNav is false', () => { + layoutApi.setOptions({ layout: { navSize: 180, showNav: false } }); + + expect(currentState.layout.navSize).toBe(0); + expect(currentState.layout.recentVisibleSizes.navSize).toBe(180); + + layoutApi.toggleNav(true); + + expect(currentState.layout.navSize).toBe(180); + }); + + it('should hide panel and preserve provided sizes when layout.showPanel is false', () => { + layoutApi.setOptions({ + layout: { bottomPanelHeight: 210, rightPanelWidth: 260, showPanel: false }, + }); + + expect(currentState.layout.bottomPanelHeight).toBe(0); + expect(currentState.layout.rightPanelWidth).toBe(0); + expect(currentState.layout.recentVisibleSizes.bottomPanelHeight).toBe(210); + expect(currentState.layout.recentVisibleSizes.rightPanelWidth).toBe(260); + + layoutApi.togglePanel(true); + + expect(currentState.layout.bottomPanelHeight).toBe(210); + expect(currentState.layout.rightPanelWidth).toBe(260); + }); + + it('should prioritize options.layout over top-level layout keys', () => { + const deprecateSpy = vi.spyOn(clientLogger, 'deprecate').mockImplementation(() => {}); + + layoutApi.setOptions({ + showNav: true, + showPanel: true, + layout: { showNav: false, showPanel: false }, + }); + + expect(currentState.layout.navSize).toBe(0); + expect(currentState.layout.bottomPanelHeight).toBe(0); + expect(currentState.layout.rightPanelWidth).toBe(0); + expect(deprecateSpy).toHaveBeenCalled(); + }); + + it('should deprecate top-level layout keys in setOptions', () => { + const deprecateSpy = vi.spyOn(clientLogger, 'deprecate').mockImplementation(() => {}); + + layoutApi.setOptions({ showNav: false, panelPosition: 'right' }); + + expect(deprecateSpy).toHaveBeenCalledWith( + 'Calling `setConfig({ showNav: ... })` is deprecated. Please call `setConfig({ layout: { showNav: ... } })` instead.' + ); + expect(deprecateSpy).toHaveBeenCalledWith( + 'Calling `setConfig({ panelPosition: ... })` is deprecated. Please call `setConfig({ layout: { panelPosition: ... } })` instead.' + ); + }); + + it('should prioritize options.ui over top-level ui keys', () => { + layoutApi.setOptions({ + enableShortcuts: false, + ui: { enableShortcuts: true }, + }); + + expect(currentState.ui.enableShortcuts).toBe(true); + }); + + it('should deprecate top-level ui keys in setOptions', () => { + const deprecateSpy = vi.spyOn(clientLogger, 'deprecate').mockImplementation(() => {}); + + layoutApi.setOptions({ enableShortcuts: false }); + + expect(deprecateSpy).toHaveBeenCalledWith( + 'Calling `setConfig({ enableShortcuts: ... })` is deprecated. Please call `setConfig({ ui: { enableShortcuts: ... } })` instead.' + ); + }); + }); + + describe('getInitialOptions', () => { + it('should apply layout.showPanel from the initial config', () => { + (provider.getConfig as Mock).mockReturnValue({ + layout: { showPanel: false }, + }); + + const storeWithoutPersistedLayout = { + ...store, + getState: () => ({ selectedPanel: currentState.selectedPanel }) as unknown as State, + } as unknown as Store; + + const { state } = initLayout({ + store: storeWithoutPersistedLayout, + provider, + singleStory: false, + } as unknown as ModuleArgs); + + expect(state.layout.bottomPanelHeight).toBe(0); + expect(state.layout.rightPanelWidth).toBe(0); + expect(state.layout.recentVisibleSizes.bottomPanelHeight).toBe(300); + expect(state.layout.recentVisibleSizes.rightPanelWidth).toBe(400); + }); + + it('should apply layout.showNav from the initial config', () => { + (provider.getConfig as Mock).mockReturnValue({ + layout: { showNav: false }, + }); + + const storeWithoutPersistedLayout = { + ...store, + getState: () => ({ selectedPanel: currentState.selectedPanel }) as unknown as State, + } as unknown as Store; + + const { state } = initLayout({ + store: storeWithoutPersistedLayout, + provider, + singleStory: false, + } as unknown as ModuleArgs); + + expect(state.layout.navSize).toBe(0); + expect(state.layout.recentVisibleSizes.navSize).toBe(300); + }); + + it('should prioritize layout over top-level config keys', () => { + const deprecateSpy = vi.spyOn(clientLogger, 'deprecate').mockImplementation(() => {}); + (provider.getConfig as Mock).mockReturnValue({ + showPanel: true, + showNav: true, + layout: { showPanel: false, showNav: false }, + }); + + const storeWithoutPersistedLayout = { + ...store, + getState: () => ({ selectedPanel: currentState.selectedPanel }) as unknown as State, + } as unknown as Store; + + const { state } = initLayout({ + store: storeWithoutPersistedLayout, + provider, + singleStory: false, + } as unknown as ModuleArgs); + + expect(state.layout.navSize).toBe(0); + expect(state.layout.bottomPanelHeight).toBe(0); + expect(state.layout.rightPanelWidth).toBe(0); + expect(deprecateSpy).toHaveBeenCalledWith( + 'Calling `setConfig({ showPanel: ... })` is deprecated. Please call `setConfig({ layout: { showPanel: ... } })` instead.' + ); + expect(deprecateSpy).toHaveBeenCalledWith( + 'Calling `setConfig({ showNav: ... })` is deprecated. Please call `setConfig({ layout: { showNav: ... } })` instead.' + ); + }); + + it('should prioritize ui over top-level config keys', () => { + const deprecateSpy = vi.spyOn(clientLogger, 'deprecate').mockImplementation(() => {}); + (provider.getConfig as Mock).mockReturnValue({ + enableShortcuts: false, + ui: { enableShortcuts: true }, + }); + + const storeWithoutPersistedLayout = { + ...store, + getState: () => ({ selectedPanel: currentState.selectedPanel }) as unknown as State, + } as unknown as Store; + + const { state } = initLayout({ + store: storeWithoutPersistedLayout, + provider, + singleStory: false, + } as unknown as ModuleArgs); + + expect(state.ui.enableShortcuts).toBe(true); + expect(deprecateSpy).toHaveBeenCalledWith( + 'Calling `setConfig({ enableShortcuts: ... })` is deprecated. Please call `setConfig({ ui: { enableShortcuts: ... } })` instead.' + ); + }); }); describe('state getters', () => { diff --git a/code/core/src/manager/globals/exports.ts b/code/core/src/manager/globals/exports.ts index 05fb98c0a364..1836eedcf4e2 100644 --- a/code/core/src/manager/globals/exports.ts +++ b/code/core/src/manager/globals/exports.ts @@ -652,6 +652,7 @@ export default { 'ProviderDoesNotExtendBaseProviderError', 'StatusTypeIdMismatchError', 'UncaughtManagerError', + 'UniversalStoreFollowerTimeoutError', ], 'storybook/internal/router': [ 'BaseLocationProvider', diff --git a/code/core/src/server-errors.ts b/code/core/src/server-errors.ts index f862e185e94c..c61d52717a15 100644 --- a/code/core/src/server-errors.ts +++ b/code/core/src/server-errors.ts @@ -3,6 +3,9 @@ import { dedent } from 'ts-dedent'; import type { Status } from './shared/status-store/index.ts'; import type { StatusTypeId } from './shared/status-store/index.ts'; +import { formatIssues } from './shared/open-service/errors.ts'; +import type { ServiceId } from './shared/open-service/types.ts'; +import type { ValidationMeta } from './shared/open-service/errors.ts'; import { StorybookError } from './storybook-error.ts'; export { StorybookError } from './storybook-error.ts'; @@ -152,6 +155,92 @@ export class InvalidStoriesEntryError extends StorybookError { } } +export class OpenServiceValidationError extends StorybookError { + constructor(public data: ValidationMeta) { + super({ + name: 'OpenServiceValidationError', + category: Category.CORE_COMMON, + code: 5, + message: `Invalid ${data.phase} for ${data.kind} "${data.serviceId}.${data.name}":\n${formatIssues( + data.issues + )}`, + }); + } +} + +export class OpenServiceDuplicateRegistrationError extends StorybookError { + constructor(public data: { serviceId: ServiceId }) { + super({ + name: 'OpenServiceDuplicateRegistrationError', + category: Category.CORE_COMMON, + code: 6, + message: `A service with id "${data.serviceId}" is already registered.`, + }); + } +} + +export class OpenServiceMissingServiceError extends StorybookError { + constructor(public data: { serviceId: ServiceId }) { + super({ + name: 'OpenServiceMissingServiceError', + category: Category.CORE_COMMON, + code: 7, + message: `No registered service with id "${data.serviceId}" exists in this environment.`, + }); + } +} + +export class OpenServiceUnimplementedOperationError extends StorybookError { + constructor(public data: { serviceId: ServiceId; name: string; kind: 'query' | 'command' }) { + super({ + name: 'OpenServiceUnimplementedOperationError', + category: Category.CORE_COMMON, + code: 8, + message: `${data.kind[0].toUpperCase()}${data.kind.slice(1)} "${data.serviceId}.${data.name}" is not implemented for this environment.`, + }); + } +} + +export class OpenServiceInvalidStaticPathError extends StorybookError { + constructor(public data: { serviceId: ServiceId; name: string; path: string }) { + super({ + name: 'OpenServiceInvalidStaticPathError', + category: Category.CORE_COMMON, + code: 10, + message: `Invalid static path "${data.path}" for query "${data.serviceId}.${data.name}": use a relative path with forward slashes and no ".." segments.`, + }); + } +} + +export class OpenServiceAsyncSchemaError extends StorybookError { + constructor( + public data: { + serviceId: ServiceId; + name: string; + kind: 'query' | 'command'; + phase: 'input' | 'output'; + } + ) { + super({ + name: 'OpenServiceAsyncSchemaError', + category: Category.CORE_COMMON, + code: 9, + message: `Async schema for ${data.kind} "${data.serviceId}.${data.name}" (${data.phase}): query input and output schemas must validate synchronously.`, + }); + } +} + +export class OpenServiceLoadedDrainExceededError extends StorybookError { + constructor(public data: { serviceId: ServiceId; name: string; iterations: number }) { + super({ + name: 'OpenServiceLoadedDrainExceededError', + category: Category.CORE_COMMON, + code: 11, + message: `Query "${data.serviceId}.${data.name}".loaded(...) did not settle after ${data.iterations} drain iterations. Check for handlers that keep discovering new dependencies after every state change.`, + }); + } +} + export class WebpackMissingStatsError extends StorybookError { constructor() { super({ diff --git a/code/core/src/shared/open-service/README.md b/code/core/src/shared/open-service/README.md new file mode 100644 index 000000000000..2043f6febb5d --- /dev/null +++ b/code/core/src/shared/open-service/README.md @@ -0,0 +1,400 @@ +# Open Service + +`open-service` is a small schema-driven service system for Storybook internals. + +Its goals are: + +- define stateful services in one declarative object +- expose synchronous queries and async commands with strong TypeScript inference +- validate all query and command input/output through Standard Schema +- support reactive query subscriptions through `alien-signals` +- support server-side static state snapshots driven by query `load` hooks + +The main audience for this README is agents and maintainers who need to understand how the pieces +fit together, where behavior lives, and how to define new services correctly. + +## Public Surface + +External callers should import from one of two entrypoints: + +- [index.ts](./index.ts) for environment-agnostic definition helpers and shared types +- [server.ts](./server.ts) for server-only registration, discovery, and static snapshot writing + +The environment-agnostic API consists of: + +- `defineService` +- the exported type aliases from [types.ts](./types.ts) + +The server-only API consists of: + +- `registerService` +- `listServices` +- `describeService` +- `getService` +- `getRegisteredServices` +- `buildStaticFiles` +- `writeOpenServiceStaticFiles` + +Internal tests and implementation code may import from the individual modules directly. + +## File Layout + +- [index.ts](./index.ts): environment-agnostic barrel for definition helpers and shared types +- [server.ts](./server.ts): server-only entrypoint that re-exports registration APIs and owns static snapshot building/writing +- [types.ts](./types.ts): core type model for definitions, contexts, runtime instances, and static build data +- [service-definition.ts](./service-definition.ts): `defineService()` typing that preserves inline inference when declaring services +- [service-validation.ts](./service-validation.ts): sync + async schema validation helpers and error wrapping +- [errors.ts](./errors.ts): validation metadata formatting helpers +- [service-runtime.ts](./service-runtime.ts): signal-backed runtime construction, in-flight load registry, drain logic, and subscriptions +- [service-registration.ts](./service-registration.ts): server-side global registry implementation and the shared registry API passed into runtimes +- [fixtures.ts](./fixtures.ts): scenario fixtures used by the test suite +- `*.test.ts`: focused tests for runtime behavior, validation behavior, server registration, and server static builds + +## Core Concepts + +### Service + +A service is a state container with: + +- a stable `id` +- an `initialState` +- a `queries` map +- a `commands` map +- optional descriptions on the service and each operation + +Use `defineService()` to preserve the concrete query and command map types. + +### Query + +A query is: + +- **synchronous at call time**: `service.queries.foo(input)` returns the validated handler result immediately +- **read-only**: the handler receives `{ state, queries }` and cannot mutate state or call commands +- **load-coupled**: calling a query also fires its optional `load` hook in the background, deduped per `(service, query, input)` while one is already in flight +- **subscribable** through `query.subscribe(input, callback)` +- **awaitable in full** through `query.loaded(input)`, which returns a promise that settles once the load and every transitively touched dependency have completed +- **statically buildable** when the query declares `staticPath` and `staticInputs` + +Query handlers receive a `QueryCtx`: + +- `ctx.self.state` +- `ctx.self.queries` +- `ctx.getService(serviceId)` — synchronous + +Query handlers do **not** receive `commands` or `setState`. Mutations belong in commands; load-time preparation belongs in `load`. + +### Load + +`load` is an optional async hook on each query definition. It receives a `LoadCtx`: + +- `ctx.self.state` +- `ctx.self.queries` — wrapped versions of the service's own queries; calling them inside `load` registers transitively triggered loads into the current drain +- `ctx.self.commands` — declared commands, used for all state mutation (load contexts do not receive `setState` directly) +- `ctx.getService(serviceId)` — synchronous + +`load` mutations must go through commands. Cross-service `getService(...).queries.*` calls inside a load body are not auto-tracked for the drain; use `await ctx.getService(id).queries.foo.loaded(input)` when you need a cross-service dependency awaited before your own load completes. + +### Command + +A command is: + +- always async at call time +- allowed to mutate state through `ctx.self.setState(...)` +- validated on both input and output + +Commands receive a `CommandCtx` whose `self` includes `state`, `queries`, `commands`, and `setState`. + +### Cross-service composition + +Handlers resolve other registered services through `ctx.getService(serviceId)`. Without a type +parameter, the return type is `RuntimeService` — query and command results are erased to +`unknown`. + +Pass the source service definition as a generic to recover the full typed runtime surface: + +```ts +import type { mutableRecordLookupServiceDef } from './mutable-record-lookup.ts'; + +handler: (input, ctx) => { + const lookup = ctx.getService( + 'internal-fixture/mutable-record-lookup' + ); + + const record = lookup.queries.getRecordFields({ entryId: input.entryId }); + // record is fully typed — do not cast individual query results + + return record?.marker === 'match'; +}; +``` + +Guidelines: + +- Import the source definition **type-only** when it is only needed for the generic parameter +- Pair the generic with the correct service id — TypeScript cannot verify they match at compile time +- Omit the generic when the target service is not known statically; the untyped `RuntimeService` + surface is the correct fallback +- Do **not** cast individual query or command results; type the service handle once instead + +The exported `ServiceInstanceOf` alias is available for named handle types when +a service is referenced from many call sites. + +### Validation + +Every query and command must declare: + +- `input` +- `output` + +Both must be Standard Schema compatible. + +The runtime validates: + +- caller input before a handler runs +- handler output before the result is returned or emitted + +Queries validate **synchronously**. Their input and output schemas must produce sync results. If a Standard Schema returns a Promise during a query validation, the runtime throws `OpenServiceAsyncSchemaError` immediately. + +Commands validate asynchronously and accept async schemas. + +Validation failures become `OpenServiceValidationError` with a message that includes: + +- whether the failure happened on input or output +- whether the failing operation is a query or command +- the full `serviceId.operationName` +- one line per issue, including path and the schema's expectation text + +Handling of extra object fields depends on the schema implementation you choose. The current test fixtures use Valibot `object(...)` schemas, which accept unexpected extra fields rather than rejecting them. + +## Server Registration Flow + +Server-side registration happens through the `services` preset hook. Storybook calls `await presets.apply('services')` during both dev startup and static builds, and each service author's preset implementation is responsible for calling `registerService(...)` directly. + +That split is intentional: + +- [index.ts](./index.ts) stays environment-agnostic so preview, manager, and server code can share + one definition surface +- [server.ts](./server.ts) owns the concrete registry and static snapshot writing for the current + server process + +`registerService(definition)` throws `OpenServiceDuplicateRegistrationError` if a service with the +same id is already registered. The default `services` preset hook in +[common-preset.ts](../../../core-server/presets/common-preset.ts) also throws if the preset is applied +more than once in the same process, which catches duplicate registration paths early. + +The internal Storybook config registers an example debug service through a dedicated preset file +([`code/.storybook/services-preset.ts`](../../../../.storybook/services-preset.ts)), gated on +`STORYBOOK_OPEN_SERVICE_DEBUG=true`. The flag stays unset by default so normal `yarn storybook:ui` +and `yarn storybook:ui:build` runs do not register the debug service. + +## Runtime Flow + +When a server registers a service definition: + +1. [service-registration.ts](./service-registration.ts) merges any registration-time handler overrides. +2. It passes the shared registry API into [service-runtime.ts](./service-runtime.ts). +3. [service-runtime.ts](./service-runtime.ts) creates a signal-backed state container from `initialState`. +4. It builds a writable `commandSelf` reference around that state. +5. It builds commands that validate input, run handlers, and validate output. +6. It builds queries that validate input synchronously, fire any pending `load` in the background (deduped while in flight), run the handler synchronously, and validate the output. +7. [service-registration.ts](./service-registration.ts) stores the resulting runtime behind the server registry entry for later lookup. + +## In-flight Load Registry + +`service-runtime.ts` owns one process-global in-flight load registry keyed by `${serviceId}::${queryName}::${stableHash(parsedInput)}`. The hash uses stable JSON (sorted keys) computed from the post-validation parsed input, so inputs are expected to be JSON-safe. Two concurrent callers for the same key share one load; once it settles, the entry is removed so future calls can refire it. There is no caller-facing invalidation API. + +## `.loaded()` Drain + +`query.loaded(input)` returns a promise that settles only when the load body and every dependency the handler transitively reads are fully populated. Implementation lives in `runLoaded` in [service-runtime.ts](./service-runtime.ts). + +### Algorithm + +1. **Setup.** Validate input. Build a `LoadedSession`: + - `ancestorChain` — the set of load keys we are currently nested inside (used to break cycles). Inherits from any parent `.loaded()` chain and is extended with this query's own key. + - `collector` — load promises waiting to be drained. + - `settledKeys` — load keys that have already settled in this session (do not refire them). +2. **Fire own load.** If this query has a `load` hook, push its promise into the collector via the in-flight registry. Skip if the key is already on the parent ancestor chain. +3. **Drain + discover loop** (capped at 32 iterations): + - **Drain**: while `collector` has entries, snapshot them, clear, `Promise.allSettled` them, mark their keys settled, surface the first rejection (others attached as `cause.aggregated`). + - **Discovery**: run the sync handler under `activeHandlerLoadSession = session`. Sync reads of dependencies fire and register their loads into `collector` — provided the dep is not already on the ancestor chain (cycle) and not already settled this session (already loaded). + - If discovery added new entries, loop. Otherwise exit. +4. **Final read.** Run the handler one last time without the session and return the validated output. + +If iteration count exceeds 32, throw `OpenServiceLoadedDrainExceededError`. This catches pathological cases (e.g. a handler that reads a query with an ever-changing input key) instead of hanging. + +### Worked example + +`bar.loaded(input)` where `bar.handler` reads `foo` and `foo` has its own `load`: + +| Step | What happens | +|------|--------------| +| Setup | `session = { ancestorChain: {barKey}, collector: ∅, settledKeys: ∅ }`. `bar.load` is undefined → no own load fired. | +| Iter 1, drain | Collector empty, skip. | +| Iter 1, discovery | Handler runs. Reads `ctx.self.queries.foo(...)`. Default `foo` query sees the session, sees `fooKey` is not in ancestor or settled, fires `foo.load` and pushes promise into `collector`. Handler returns (state still empty). | +| Iter 2, drain | `await Promise.allSettled([fooPromise])`. Mark `fooKey` settled. State now populated by `foo.load`. | +| Iter 2, discovery | Handler runs again. `foo` is in `settledKeys` → fires nothing. Collector stays empty. | +| Exit | Final handler call (no session): returns the now-populated value. | + +### Inside a `load` body + +When the discovery handler runs, sync reads via `ctx.self.queries.*` go through the *default* query map (the same one consumers see) and register against `activeHandlerLoadSession`. That works for sync code because module-scoped state is stable across one synchronous handler call. + +When an **async** `load` body runs, it instead gets a *wrapped* `ctx.self.queries.*` from `buildLoadWrappedQueries`. Each wrapper closes over the load's own ancestor chain and local collector, so reads inside the body register dependencies regardless of how many `await`s the body has between them. After the body resolves, the load promise waits for its local collector to drain before settling — which is what gives `.loaded()` its transitive guarantee through async load bodies. + +Cross-service `ctx.getService(id).queries.*` calls inside a load body are **not** wrapped; authors must use `.loaded()` explicitly when they need a cross-service dep awaited from inside a load. From a sync handler, cross-service queries are tracked because they consult the module-scoped session like any other call. + +## Subscription Flow + +Subscriptions are implemented with `alien-signals` in [service-runtime.ts](./service-runtime.ts): + +1. `subscribe(input, callback)` defers all work to a microtask. +2. The microtask validates the input synchronously and fires the dependency's `load` in the background. +3. A `computed()` value wraps the synchronous handler. An `effect()` runs the handler immediately (delivering the current value to the callback) and re-runs whenever the handler's tracked state dependencies change. +4. Subscribers receive the current state right away, then a follow-up emission once the load settles and state changes. UI consumers that want to suppress the pre-load emission should branch on the value (e.g. show a spinner for `null`). +5. Each emitted value is output-validated before the subscriber callback runs. + +Tests should use `vi.waitFor(...)` when asserting the first emission or follow-up emissions. + +## Static Snapshot Flow + +`buildStaticFiles()` in [server.ts](./server.ts) iterates every registered service and looks for +queries that define: + +- `staticPath` at definition time +- `load` (definition or registration) +- `staticInputs` (definition or registration) + +For each static input it: + +1. creates a fresh runtime from `initialState` +2. validates the static input using the query's `input` schema +3. runs the runtime's `runLoadOnce(queryName, validatedInput)` helper, which drives the load body (and any loads it triggers via wrapped self queries) to completion +4. resolves the normalized logical output path as `/` +5. stores the resulting runtime state in the final `StaticStore` + +`staticPath` is declared on the definition layer as `(input) => string`, relative to the service's +own output folder. The static build always prepends the service id so two services cannot write to +the same JSON path. It is exposed to callers through `describeService()` as `staticPath: true` on the +matching query descriptor. Manager code can use that flag to choose between live runtime queries +and prebuilt JSON snapshots. + +`staticInputs` may be declared in the definition when the input list has no runtime dependencies. +Registration may override or supply `staticInputs` when the enumerator needs registry access, +story-index data, or other server context. + +Cross-service `ctx.getService(...)` lookups during load resolve through the same registry the +dev server uses, so a load sees the same set of services that any other handler in the process +would see. + +If multiple tasks resolve to the same path, their states are deep-merged. + +`writeOpenServiceStaticFiles(outputDir)` then writes those logical paths underneath `/services`, converting slash-separated logical keys into native filesystem paths for the current operating system. + +These snapshots are currently only a build artifact for the server-side static build flow. This slice does not implement a separate runtime mode that consumes prebuilt snapshot stores instead of running `load` normally. + +Static path rules: + +- `staticPath` values are relative to the service; the build prepends `/` automatically +- authors should think in forward-slash logical paths such as `nested/file.json` or `${input.entryId}.json` +- leading `./` and `/` are normalized away +- backslashes are normalized to `/` +- `..` segments are rejected so snapshots cannot escape the service folder + +```mermaid +flowchart TD + A[buildStaticFiles] --> B{query has staticPath,\nload, and staticInputs?} + B -- no --> C[skip query] + B -- yes --> D[create fresh runtime from initialState] + D --> E[resolve static inputs] + E --> F[validate each input] + F --> G[run load for that input] + G --> H[resolve logical output path from staticPath] + H --> I[capture runtime state snapshot] + I --> J[merge snapshots by path into StaticStore] + J --> K[writeOpenServiceStaticFiles outputDir] +``` + +## How To Define A Service + +Define queries and commands inline inside `defineService()` so the service-level schema maps can contextually type every handler, load hook, and `ctx.self.commands.*` call: + +```ts +import * as v from 'valibot'; + +import { defineService } from './index.ts'; +import { registerService } from './server.ts'; + +type ExampleState = { + values: Record; +}; + +const entryIdSchema = v.object({ entryId: v.string() }); +const valueSchema = v.nullable(v.string()); + +export const exampleServiceDef = defineService({ + id: 'example/service', + description: 'Example service used in documentation.', + initialState: { values: {} } satisfies ExampleState, + queries: { + getValue: { + description: 'Returns one value by id.', + input: entryIdSchema, + output: valueSchema, + handler: (input, ctx) => ctx.self.state.values[input.entryId] ?? null, + load: async (input, ctx) => { + if (!(input.entryId in ctx.self.state.values)) { + await ctx.self.commands.preloadValue(input); + } + }, + staticPath: () => 'state.json', + staticInputs: async () => [{ entryId: 'a' }, { entryId: 'b' }], + }, + }, + commands: { + preloadValue: { + description: 'Fills state for one id.', + input: entryIdSchema, + output: v.void(), + handler: async (input, ctx) => { + ctx.self.setState((draft) => { + draft.values[input.entryId] = 'ready'; + }); + }, + }, + }, +}); + +const exampleService = registerService(exampleServiceDef); + +// Sync read — returns current state (null if load hasn't run yet) and fires load in the background. +const current = exampleService.queries.getValue({ entryId: 'a' }); + +// Awaited variant — waits for load (and any transitive deps) to settle, then returns the value. +const ready = await exampleService.queries.getValue.loaded({ entryId: 'a' }); +``` + +## Design Rules + +- Always declare both `input` and `output` schemas on every query and command. +- Use `load` for read-side warming. The hook is async and must mutate via commands. +- Query handlers are strict readers: sync, no commands, no `setState`. +- Use commands for all state mutation. +- Keep environment-agnostic imports on [index.ts](./index.ts) and server-only imports on [server.ts](./server.ts). Import internal modules directly only from tests or implementation code in this directory. +- Use `.loaded()` when a caller wants to await the full state; use the sync form when "current best" is fine. + +## Testing Guidance + +- Runtime behavior belongs in [service-runtime.test.ts](./service-runtime.test.ts) +- Validation behavior belongs in [service-validation.test.ts](./service-validation.test.ts) +- Server registration and static snapshot behavior belong in [server.test.ts](./server.test.ts) +- Reusable scenario definitions belong in [fixtures.ts](./fixtures.ts) + +When adding validation tests, prefer asserting the full exact error message. That keeps the tests useful as executable documentation for callers and agents. + +## Agent Notes + +- If you need to change runtime behavior, start in [service-runtime.ts](./service-runtime.ts). +- If you need to change server registration, start in [service-registration.ts](./service-registration.ts). +- If you need to change static snapshot building or writing, start in [server.ts](./server.ts). +- If you need to change validation wording, start in [errors.ts](./errors.ts). +- If you need to change schema handling, start in [service-validation.ts](./service-validation.ts). +- If you need to change service authoring ergonomics, start in [service-definition.ts](./service-definition.ts) and [types.ts](./types.ts). diff --git a/code/core/src/shared/open-service/errors.ts b/code/core/src/shared/open-service/errors.ts new file mode 100644 index 000000000000..0bd067516f58 --- /dev/null +++ b/code/core/src/shared/open-service/errors.ts @@ -0,0 +1,52 @@ +import type { StandardSchemaV1 } from '@standard-schema/spec'; + +import type { ServiceId } from './types.ts'; + +/** Identifies which operation surface produced a validation failure. */ +export type OperationKind = 'query' | 'command'; + +/** + * Describes the operation and validation phase associated with a schema failure. + */ +export type ValidationMeta = { + kind: OperationKind; + serviceId: ServiceId; + name: string; + phase: 'input' | 'output'; + issues: ReadonlyArray; +}; + +/** + * Formats a schema issue path into the field notation shown in validation errors. + * + * Examples: + * - `['foo']` -> `foo` + * - `['items', 0, 'id']` -> `items[0].id` + */ +function formatIssuePath(path?: readonly (PropertyKey | StandardSchemaV1.PathSegment)[]): string { + if (!path?.length) { + return ''; + } + + return path + .map((segment) => { + const key = + typeof segment === 'object' && segment !== null && 'key' in segment ? segment.key : segment; + + return typeof key === 'number' ? `[${key}]` : `.${String(key)}`; + }) + .join('') + .replace(/^\./, ''); +} + +/** + * Converts schema issues into the newline-separated detail block appended to user-facing errors. + */ +export function formatIssues(issues: ReadonlyArray): string { + return issues + .map((issue) => { + const path = formatIssuePath(issue.path); + return path === '' ? issue.message : `${path}: ${issue.message}`; + }) + .join('\n'); +} diff --git a/code/core/src/shared/open-service/fixtures.ts b/code/core/src/shared/open-service/fixtures.ts new file mode 100644 index 000000000000..c9ed3a4ca78e --- /dev/null +++ b/code/core/src/shared/open-service/fixtures.ts @@ -0,0 +1,274 @@ +import * as v from 'valibot'; + +import { defineService } from './service-definition.ts'; + +/** Shared schema used by fixtures that address one logical record by id. */ +export const entryIdInputSchema = v.object({ entryId: v.string() }); +/** Shared schema used by fixtures that write one named field on one record. */ +export const assignEntryFieldInputSchema = v.object({ + entryId: v.string(), + fieldKey: v.string(), + fieldValue: v.string(), +}); +/** Shared schema for nullable record payloads returned from lookup queries. */ +export const recordFieldsOutputSchema = v.nullable(v.record(v.string(), v.string())); +/** Shared schema for nullable string payloads used by load-oriented fixtures. */ +export const preloadedValueOutputSchema = v.nullable(v.string()); +export const noInputSchema = v.void(); +export const voidOutputSchema = v.void(); +export const booleanOutputSchema = v.boolean(); + +export type MutableRecordState = Record | undefined>; + +/** + * Baseline service fixture used by most runtime and validation tests. + * + * It models a simple mutable lookup table so tests can focus on open-service behavior rather than + * domain-specific logic. + */ +export const mutableRecordLookupServiceDef = defineService({ + id: 'internal-fixture/mutable-record-lookup', + description: 'Provides a mutable record lookup keyed by entry id.', + initialState: {} as MutableRecordState, + queries: { + getRecordFields: { + description: 'Returns all stored fields for one entry, or null when absent.', + input: entryIdInputSchema, + output: recordFieldsOutputSchema, + handler: (input, ctx) => ctx.self.state[input.entryId] ?? null, + }, + }, + commands: { + assignRecordField: { + description: 'Writes one field value onto the selected entry.', + input: assignEntryFieldInputSchema, + output: voidOutputSchema, + handler: (input, ctx) => { + ctx.self.setState((draft) => { + draft[input.entryId] ??= {}; + draft[input.entryId]![input.fieldKey] = input.fieldValue; + }); + }, + }, + }, +}); + +export type PreloadedValueState = Record; + +/** Service fixture that loads state from a command before returning it. */ +export const awaitedPreloadValueServiceDef = defineService({ + id: 'internal-fixture/awaited-preload-value', + description: 'Loads a value on demand via a command and reads it back from state.', + initialState: {} as PreloadedValueState, + queries: { + getPreloadedValue: { + description: 'Returns the value for an entry; load triggers a command to populate state.', + input: entryIdInputSchema, + output: preloadedValueOutputSchema, + handler: (input, ctx) => ctx.self.state[input.entryId] ?? null, + load: (input, ctx) => { + if (!(input.entryId in ctx.self.state)) { + return ctx.self.commands.preloadValue(input).then(() => undefined); + } + }, + staticPath: () => 'state.json', + staticInputs: async () => [{ entryId: 'entry-a' }, { entryId: 'entry-b' }], + }, + }, + commands: { + preloadValue: { + description: 'Loads a deterministic value for one entry id.', + input: entryIdInputSchema, + output: voidOutputSchema, + handler: async (input, ctx) => { + await Promise.resolve(); + ctx.self.setState((draft) => { + draft[input.entryId] = 'preloaded'; + }); + }, + }, + }, +}); + +/** Service fixture that starts load work in the background and returns immediately. */ +export const fireAndForgetPreloadValueServiceDef = defineService({ + id: 'internal-fixture/fire-and-forget-preload-value', + description: 'Loads a value in the background without awaiting it.', + initialState: {} as PreloadedValueState, + queries: { + getPreloadedValue: { + description: + 'Returns the current value; load fires a command in the background when missing.', + input: entryIdInputSchema, + output: preloadedValueOutputSchema, + handler: (input, ctx) => ctx.self.state[input.entryId] ?? null, + load: (input, ctx) => { + if (!(input.entryId in ctx.self.state)) { + void ctx.self.commands.preloadValue(input); + } + }, + }, + }, + commands: { + preloadValue: { + description: 'Loads a deterministic value for one entry id.', + input: entryIdInputSchema, + output: voidOutputSchema, + handler: async (input, ctx) => { + await Promise.resolve(); + ctx.self.setState((draft) => { + draft[input.entryId] = 'preloaded'; + }); + }, + }, + }, +}); + +export type SharedStaticFileState = { left?: string; right?: string }; + +/** Creates a fixture where multiple queries contribute state to one shared static file. */ +export function createSharedStaticFileServiceDef() { + return defineService({ + id: 'internal-fixture/shared-static-file', + description: 'Builds two independent query outputs into one shared static file.', + initialState: {} as SharedStaticFileState, + queries: { + getLeftValue: { + description: 'Loads the left value into the shared file state.', + input: noInputSchema, + output: preloadedValueOutputSchema, + handler: (_input, ctx) => ctx.self.state.left ?? null, + load: async (_input, ctx) => { + await ctx.self.commands.writeLeftValue(undefined); + }, + staticPath: () => 'shared.json', + staticInputs: async () => [undefined], + }, + getRightValue: { + description: 'Loads the right value into the shared file state.', + input: noInputSchema, + output: preloadedValueOutputSchema, + handler: (_input, ctx) => ctx.self.state.right ?? null, + load: async (_input, ctx) => { + await ctx.self.commands.writeRightValue(undefined); + }, + staticPath: () => 'shared.json', + staticInputs: async () => [undefined], + }, + }, + commands: { + writeLeftValue: { + description: 'Writes the left static value into state.', + input: noInputSchema, + output: voidOutputSchema, + handler: (_input, ctx) => { + ctx.self.setState((draft) => { + draft.left = 'preloaded'; + }); + }, + }, + writeRightValue: { + description: 'Writes the right static value into state.', + input: noInputSchema, + output: voidOutputSchema, + handler: (_input, ctx) => { + ctx.self.setState((draft) => { + draft.right = 'preloaded'; + }); + }, + }, + }, + }); +} + +/** + * Creates a service that composes one service's query inside another service's query. + * + * The derived service resolves the source service through `ctx.getService(...)` at call time — + * the same lookup any consumer code would use — rather than capturing the registered instance in + * a closure. The source service must already be registered when the derived query runs. + */ +export function createDerivedBooleanFromChildQueryServiceDef() { + type DerivedState = Record; + + return defineService({ + id: 'internal-fixture/derived-boolean-from-child-query', + description: 'Derives a boolean from the child lookup query.', + initialState: {} as DerivedState, + queries: { + isEntryMarked: { + description: 'Returns whether the child query reports marker=match for an entry.', + input: entryIdInputSchema, + output: booleanOutputSchema, + handler: (input, ctx) => { + const source = ctx.getService( + mutableRecordLookupServiceDef.id + ); + const record = source.queries.getRecordFields({ + entryId: input.entryId, + }); + + return record?.marker === 'match'; + }, + }, + }, + commands: {}, + }); +} + +/** Creates a fixture that intentionally returns an invalid query output. */ +export function createInvalidQueryOutputServiceDef() { + return defineService({ + id: 'internal-fixture/invalid-query-output', + description: 'Returns an invalid query output on purpose.', + initialState: {} as Record, + queries: { + getBrokenValue: { + description: 'Returns a string-shaped output that is actually a number.', + input: noInputSchema, + output: preloadedValueOutputSchema, + handler: () => 42 as unknown as string | null, + }, + }, + commands: {}, + }); +} + +/** Creates a fixture that intentionally returns an invalid command output. */ +export function createInvalidCommandOutputServiceDef() { + return defineService({ + id: 'internal-fixture/invalid-command-output', + description: 'Returns an invalid command output on purpose.', + initialState: {} as Record, + queries: {}, + commands: { + runBrokenCommand: { + description: 'Returns a string-shaped output that is actually a number.', + input: noInputSchema, + output: v.string(), + handler: () => 42 as unknown as string, + }, + }, + }); +} + +/** Creates a fixture that intentionally yields invalid static load inputs. */ +export function createInvalidStaticInputServiceDef() { + return defineService({ + id: 'internal-fixture/invalid-static-input', + description: 'Provides an invalid static load input on purpose.', + initialState: {} as PreloadedValueState, + queries: { + getPreloadedValue: { + description: 'Validates static inputs before load runs.', + input: entryIdInputSchema, + output: preloadedValueOutputSchema, + handler: (input, ctx) => ctx.self.state[input.entryId] ?? null, + load: async () => {}, + staticPath: () => 'state.json', + staticInputs: async () => [{} as unknown as { entryId: string }], + }, + }, + commands: {}, + }); +} diff --git a/code/core/src/shared/open-service/index.test-d.ts b/code/core/src/shared/open-service/index.test-d.ts new file mode 100644 index 000000000000..023320d87026 --- /dev/null +++ b/code/core/src/shared/open-service/index.test-d.ts @@ -0,0 +1,157 @@ +import * as v from 'valibot'; +import { describe, expectTypeOf, it } from 'vitest'; + +import { defineService } from './index.ts'; +import { registerService } from './server.ts'; + +type OpenServiceState = { + count: number; + valuesById: Record; +}; + +const entryIdInputSchema = v.object({ entryId: v.string() }); +const incrementInputSchema = v.number(); + +const openServiceDef = defineService({ + id: 'internal-fixture/open-service-types', + initialState: { + count: 0, + valuesById: {} as Record, + }, + queries: { + getCount: { + input: v.undefined(), + output: v.number(), + handler: (input, ctx) => { + expectTypeOf(input).toEqualTypeOf(); + expectTypeOf(ctx.self.state).toEqualTypeOf(); + // @ts-expect-error query handlers do not receive commands on self + void ctx.self.commands; + // @ts-expect-error queries only receive a read-only self handle + ctx.self.setState(() => {}); + + return ctx.self.state.count; + }, + }, + getValue: { + input: entryIdInputSchema, + output: v.nullable(v.string()), + handler: (input, ctx) => { + expectTypeOf(input).toEqualTypeOf<{ entryId: string }>(); + // @ts-expect-error query handlers do not receive commands on self + void ctx.self.commands; + + return ctx.self.state.valuesById[input.entryId] ?? null; + }, + load: async (input, ctx) => { + expectTypeOf(input).toEqualTypeOf<{ entryId: string }>(); + expectTypeOf(ctx.self.commands.preloadValue).parameter(0).toEqualTypeOf<{ + entryId: string; + }>(); + expectTypeOf(ctx.self.commands.preloadValue).returns.toEqualTypeOf>(); + await ctx.self.commands.preloadValue(input); + + // @ts-expect-error preloadValue requires an entryId object + await ctx.self.commands.preloadValue({ entryId: 1 }); + // @ts-expect-error load contexts do not receive setState directly + ctx.self.setState(() => {}); + }, + staticPath: (input) => { + expectTypeOf(input).toEqualTypeOf<{ entryId: string }>(); + return `${input.entryId}.json`; + }, + staticInputs: () => [{ entryId: 'entry-a' }], + }, + }, + commands: { + increment: { + input: incrementInputSchema, + output: v.void(), + handler: (input, ctx) => { + expectTypeOf(input).toEqualTypeOf(); + ctx.self.setState((draft) => { + expectTypeOf(draft).toEqualTypeOf(); + draft.count += input; + }); + }, + }, + preloadValue: { + input: entryIdInputSchema, + output: v.void(), + handler: async (input, ctx) => { + expectTypeOf(input).toEqualTypeOf<{ entryId: string }>(); + ctx.self.setState((draft) => { + expectTypeOf(draft.valuesById[input.entryId]).toEqualTypeOf(); + draft.valuesById[input.entryId] = 'ready'; + }); + }, + }, + }, +}); + +const openService = registerService(openServiceDef); + +describe('open-service type inference', () => { + it('infers runtime query and command signatures from inline schemas', () => { + expectTypeOf(openService.queries.getCount).parameter(0).toEqualTypeOf(); + expectTypeOf(openService.queries.getCount).returns.toEqualTypeOf(); + expectTypeOf(openService.queries.getCount.loaded).returns.toEqualTypeOf>(); + + expectTypeOf(openService.queries.getValue).parameter(0).toEqualTypeOf<{ + entryId: string; + }>(); + expectTypeOf(openService.queries.getValue).returns.toEqualTypeOf(); + expectTypeOf(openService.queries.getValue.loaded).returns.toEqualTypeOf< + Promise + >(); + + expectTypeOf(openService.commands.increment).parameter(0).toEqualTypeOf(); + expectTypeOf(openService.commands.increment).returns.toEqualTypeOf>(); + + expectTypeOf(openService.commands.preloadValue).parameter(0).toEqualTypeOf<{ + entryId: string; + }>(); + expectTypeOf(openService.commands.preloadValue).returns.toEqualTypeOf>(); + }); + + it('rejects invalid runtime call signatures', () => { + // @ts-expect-error getValue requires an entryId string + openService.queries.getValue({}); + + // @ts-expect-error increment requires a numeric payload + openService.commands.increment(undefined); + }); + + it('rejects handlers that do not match the declared schemas', () => { + defineService({ + id: 'internal-fixture/invalid-open-service-types', + initialState: {} as Record, + queries: { + getBrokenValue: { + input: v.undefined(), + output: v.number(), + // @ts-expect-error query handler output must match the output schema input type + handler: () => 'wrong', + }, + }, + commands: {}, + }); + }); + + it('rejects dependency-aware staticInputs on the definition layer', () => { + defineService({ + id: 'internal-fixture/invalid-definition-static-inputs', + initialState: {} as OpenServiceState, + queries: { + getValue: { + input: entryIdInputSchema, + output: v.nullable(v.string()), + staticPath: () => 'value.json', + // @ts-expect-error definition staticInputs cannot depend on load context + staticInputs: (_ctx) => [{ entryId: 'entry-a' }], + }, + }, + commands: {}, + }); + }); +}); diff --git a/code/core/src/shared/open-service/index.ts b/code/core/src/shared/open-service/index.ts new file mode 100644 index 000000000000..ec77df044730 --- /dev/null +++ b/code/core/src/shared/open-service/index.ts @@ -0,0 +1,34 @@ +/** + * Public API for the open-service system. + * + * This barrel intentionally exposes only the authoring and runtime entry points that callers + * outside this directory should rely on. Tests and internal modules can import implementation + * files directly without widening the supported public surface. + */ +export { defineService } from './service-definition.ts'; + +export type { + AnyServiceDefinition, + Command, + CommandCtx, + CommandDefinition, + CommandSelf, + LoadCtx, + LoadSelf, + OperationDescriptor, + Query, + QueryCtx, + QueryDefinition, + QuerySelf, + RuntimeService, + SchemaDescriptor, + ServerServiceRegistration, + ServiceDefinition, + ServiceDescriptor, + ServiceId, + ServiceInstance, + ServiceInstanceOf, + ServiceRegistrationOptions, + ServiceSummary, + StaticStore, +} from './types.ts'; diff --git a/code/core/src/shared/open-service/server.test-d.ts b/code/core/src/shared/open-service/server.test-d.ts new file mode 100644 index 000000000000..aab109588488 --- /dev/null +++ b/code/core/src/shared/open-service/server.test-d.ts @@ -0,0 +1,153 @@ +import * as v from 'valibot'; +import { describe, expectTypeOf, it } from 'vitest'; + +import { defineService } from './index.ts'; +import { mutableRecordLookupServiceDef } from './fixtures.ts'; +import { registerService } from './server.ts'; +import type { RuntimeService } from './types.ts'; + +const entryIdInputSchema = v.object({ entryId: v.string() }); + +const registrationOnlyServiceDef = defineService({ + id: 'internal-fixture/open-service-registration-types', + initialState: { + count: 0, + valuesById: {} as Record, + }, + queries: { + getValue: { + input: entryIdInputSchema, + output: v.nullable(v.string()), + staticPath: (input) => { + expectTypeOf(input).toEqualTypeOf<{ entryId: string }>(); + return `${input.entryId}.json`; + }, + }, + }, + commands: { + increment: { + input: v.number(), + output: v.void(), + }, + preloadValue: { + input: entryIdInputSchema, + output: v.void(), + }, + }, +}); + +const registeredService = registerService(registrationOnlyServiceDef, { + queries: { + getValue: { + handler: (input, ctx) => { + expectTypeOf(input).toEqualTypeOf<{ entryId: string }>(); + expectTypeOf(ctx.self.state.valuesById[input.entryId]).toEqualTypeOf(); + // @ts-expect-error query handlers do not receive commands on self + void ctx.self.commands; + expectTypeOf(ctx.getService).parameter(0).toEqualTypeOf(); + expectTypeOf( + ctx.getService('internal-fixture/missing-service') + ).toEqualTypeOf(); + + return ctx.self.state.valuesById[input.entryId] ?? null; + }, + load: async (input, ctx) => { + expectTypeOf(input).toEqualTypeOf<{ entryId: string }>(); + expectTypeOf(ctx.self.commands.preloadValue).parameter(0).toEqualTypeOf<{ + entryId: string; + }>(); + await ctx.self.commands.preloadValue(input); + }, + staticInputs: () => [{ entryId: 'entry-a' }], + }, + }, + commands: { + increment: { + handler: (input, ctx) => { + expectTypeOf(input).toEqualTypeOf(); + ctx.self.setState((draft) => { + draft.count += input; + }); + }, + }, + preloadValue: { + handler: async (input, ctx) => { + expectTypeOf(input).toEqualTypeOf<{ entryId: string }>(); + ctx.self.setState((draft) => { + draft.valuesById[input.entryId] = 'ready'; + }); + }, + }, + }, +}); + +describe('open-service registration types', () => { + it('infers registration overrides and the registered runtime surface', () => { + expectTypeOf(registeredService.queries.getValue).parameter(0).toEqualTypeOf<{ + entryId: string; + }>(); + expectTypeOf(registeredService.queries.getValue).returns.toEqualTypeOf(); + expectTypeOf(registeredService.queries.getValue.loaded).returns.toEqualTypeOf< + Promise + >(); + + expectTypeOf(registeredService.commands.increment).parameter(0).toEqualTypeOf(); + expectTypeOf(registeredService.commands.increment).returns.toEqualTypeOf>(); + + expectTypeOf(registeredService.commands.preloadValue).parameter(0).toEqualTypeOf<{ + entryId: string; + }>(); + expectTypeOf(registeredService.getService).parameter(0).toEqualTypeOf(); + expectTypeOf( + registeredService.getService('internal-fixture/missing-service') + ).toEqualTypeOf(); + }); + + it('rejects invalid registration overrides', () => { + registerService(registrationOnlyServiceDef, { + queries: { + getValue: { + // @ts-expect-error query registration output must match the declared schema + handler: () => 123, + }, + }, + }); + + registerService(registrationOnlyServiceDef, { + commands: { + preloadValue: { + // @ts-expect-error command registration input must match the declared schema + handler: async (input: { entryId: number }) => { + void input; + }, + }, + }, + }); + }); + + it('types cross-service lookups when getService receives a definition generic', () => { + registerService(mutableRecordLookupServiceDef); + registerService(registrationOnlyServiceDef, { + queries: { + getValue: { + handler: (_input, ctx) => { + const lookup = ctx.getService( + 'internal-fixture/mutable-record-lookup' + ); + + expectTypeOf(lookup.queries.getRecordFields).returns.toEqualTypeOf | null>(); + const missingService = ctx.getService('internal-fixture/missing-service'); + expectTypeOf(missingService).toEqualTypeOf(); + // @ts-expect-error getRecordFields requires an entryId string + lookup.queries.getRecordFields({}); + + return null; + }, + }, + }, + }); + }); +}); diff --git a/code/core/src/shared/open-service/server.test.ts b/code/core/src/shared/open-service/server.test.ts new file mode 100644 index 000000000000..0ef1d7187aae --- /dev/null +++ b/code/core/src/shared/open-service/server.test.ts @@ -0,0 +1,484 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises'; + +import * as v from 'valibot'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { join } from 'pathe'; +import { vol } from 'memfs'; + +import { defineService } from './service-definition.ts'; +import { + buildStaticFiles, + clearRegistry, + registerService, + writeOpenServiceStaticFiles, +} from './server.ts'; +import { + awaitedPreloadValueServiceDef, + createSharedStaticFileServiceDef, + mutableRecordLookupServiceDef, +} from './fixtures.ts'; + +// Spy-only mock: keep the real `node:fs/promises` module shape, then redirect the calls used by +// the static-files writer (and this test's own `readFile` assertions) to `memfs` so disk state +// stays scoped to `vol`. +vi.mock('node:fs/promises', { spy: true }); + +beforeEach(async () => { + const memfs = await vi.importActual('memfs'); + + vi.mocked(mkdir).mockImplementation( + memfs.fs.promises.mkdir as unknown as typeof import('node:fs/promises').mkdir + ); + vi.mocked(writeFile).mockImplementation( + memfs.fs.promises.writeFile as unknown as typeof import('node:fs/promises').writeFile + ); + vi.mocked(readFile).mockImplementation( + memfs.fs.promises.readFile as unknown as typeof import('node:fs/promises').readFile + ); +}); + +afterEach(() => { + clearRegistry(); + vol.reset(); +}); + +describe('server static builds', () => { + describe('buildStaticFiles', () => { + it('runs load from initial state for each input and deep-merges by path', async () => { + registerService(awaitedPreloadValueServiceDef); + + await expect(buildStaticFiles()).resolves.toEqual({ + 'internal-fixture/awaited-preload-value/state.json': { + 'entry-a': 'preloaded', + 'entry-b': 'preloaded', + }, + }); + }); + + it('uses a single staticPath for every input on one query', async () => { + registerService(awaitedPreloadValueServiceDef); + + const store = await buildStaticFiles(); + + expect(Object.keys(store)).toEqual(['internal-fixture/awaited-preload-value/state.json']); + }); + + it('skips services and queries without staticPath or staticInputs', async () => { + registerService(mutableRecordLookupServiceDef); + + const store = await buildStaticFiles(); + + expect(Object.keys(store)).toHaveLength(0); + }); + + it('deep-merges outputs from different queries that resolve to the same staticPath', async () => { + registerService(createSharedStaticFileServiceDef()); + + await expect(buildStaticFiles()).resolves.toEqual({ + 'internal-fixture/shared-static-file/shared.json': { + left: 'preloaded', + right: 'preloaded', + }, + }); + }); + + it('uses the shared registry when static load and static inputs resolve another service', async () => { + const sourceService = registerService(mutableRecordLookupServiceDef); + await sourceService.commands.assignRecordField({ + entryId: 'entry-a', + fieldKey: 'marker', + fieldValue: 'match', + }); + + const staticLookupServiceDef = defineService({ + id: 'internal-fixture/static-build-service-lookup', + description: 'Copies state from another registered service during static load.', + initialState: { value: null as string | null }, + queries: { + getValue: { + description: 'Returns the value copied during static load.', + input: v.object({ build: v.literal('once') }), + output: v.nullable(v.string()), + handler: (_input, ctx) => ctx.self.state.value, + load: async (_input, ctx) => { + await ctx.self.commands.copyValue(undefined); + }, + staticPath: () => 'state.json', + staticInputs: async () => [{ build: 'once' as const }], + }, + }, + commands: { + copyValue: { + description: 'Reads marker state from the lookup service in the registry.', + input: v.undefined(), + output: v.undefined(), + handler: async (_input, ctx) => { + const source = ctx.getService( + 'internal-fixture/mutable-record-lookup' + ); + const record = source.queries.getRecordFields({ + entryId: 'entry-a', + }); + + ctx.self.setState((draft) => { + draft.value = record?.marker ?? null; + }); + + return undefined; + }, + }, + }, + }); + + registerService(staticLookupServiceDef); + + await expect(buildStaticFiles()).resolves.toEqual({ + 'internal-fixture/static-build-service-lookup/state.json': { + value: 'match', + }, + }); + }); + + it('runs load tasks in parallel so one snapshot can read state another snapshot publishes', async () => { + const readyEntryIds: string[] = []; + const parallelSourceServiceDef = defineService({ + id: 'internal-fixture/parallel-static-input-source', + description: 'Publishes static input ids once its own load task starts running.', + initialState: { built: false }, + queries: { + getReadyEntryIds: { + description: 'Returns the entry ids published by the source static build task.', + input: v.undefined(), + output: v.array(v.string()), + handler: () => readyEntryIds, + load: async (_input, ctx) => { + await Promise.resolve(); + await ctx.self.commands.publishReadyEntryIds(undefined); + }, + staticPath: () => 'state.json', + staticInputs: async () => [undefined], + }, + }, + commands: { + publishReadyEntryIds: { + description: 'Publishes one static entry id and marks the source snapshot as built.', + input: v.undefined(), + output: v.undefined(), + handler: async (_input, ctx) => { + readyEntryIds.splice(0, readyEntryIds.length, 'entry-a'); + ctx.self.setState((draft) => { + draft.built = true; + }); + + return undefined; + }, + }, + }, + }); + + const parallelLookupServiceDef = defineService({ + id: 'internal-fixture/parallel-static-input-consumer', + description: + 'Waits for another service query to publish its static inputs before running load.', + initialState: { value: null as string | null }, + queries: { + getValue: { + description: 'Stores one value for each id discovered through another service query.', + input: v.object({ entryId: v.string() }), + output: v.nullable(v.string()), + handler: (_input, ctx) => ctx.self.state.value, + load: async (input, ctx) => { + await ctx.self.commands.setValue(input); + }, + staticPath: () => 'state.json', + }, + }, + commands: { + setValue: { + description: 'Stores the discovered entry id in the consumer snapshot.', + input: v.object({ entryId: v.string() }), + output: v.undefined(), + handler: async (input, ctx) => { + ctx.self.setState((draft) => { + draft.value = input.entryId; + }); + + return undefined; + }, + }, + }, + }); + + registerService(parallelSourceServiceDef); + registerService(parallelLookupServiceDef, { + queries: { + getValue: { + staticInputs: async (ctx) => { + const source = ctx.getService('internal-fixture/parallel-static-input-source'); + + for (let attempt = 0; attempt < 5; attempt += 1) { + const entryIds = (await source.queries.getReadyEntryIds.loaded( + undefined + )) as string[]; + + if (entryIds.length > 0) { + return entryIds.map((entryId) => ({ entryId })); + } + + await Promise.resolve(); + } + + throw new Error( + 'Timed out waiting for parallel static inputs from the source service.' + ); + }, + }, + }, + }); + + await expect(buildStaticFiles()).resolves.toEqual({ + 'internal-fixture/parallel-static-input-consumer/state.json': { + value: 'entry-a', + }, + 'internal-fixture/parallel-static-input-source/state.json': { + built: true, + }, + }); + }); + + it('normalizes custom static paths to slash-separated logical keys', async () => { + const customPathServiceDef = defineService({ + id: 'internal-fixture/custom-static-paths', + description: 'Exercises logical static path normalization.', + initialState: { value: null as string | null }, + queries: { + getValue: { + description: 'Stores one custom value per static input.', + input: v.object({ + path: v.string(), + value: v.string(), + }), + output: v.nullable(v.string()), + handler: (_input, ctx) => ctx.self.state.value, + load: async (input, ctx) => { + await ctx.self.commands.setValue(input); + }, + staticPath: (input) => input.path, + staticInputs: async () => [ + { path: './nested/value.json', value: 'dot' }, + { path: '/rooted.json', value: 'rooted' }, + { path: 'windows\\style.json', value: 'windows' }, + ], + }, + }, + commands: { + setValue: { + description: 'Stores one value while preserving the custom path from the load input.', + input: v.object({ + path: v.string(), + value: v.string(), + }), + output: v.undefined(), + handler: async (input, ctx) => { + ctx.self.setState((draft) => { + draft.value = input.value; + }); + + return undefined; + }, + }, + }, + }); + + registerService(customPathServiceDef); + + await expect(buildStaticFiles()).resolves.toEqual({ + 'internal-fixture/custom-static-paths/nested/value.json': { value: 'dot' }, + 'internal-fixture/custom-static-paths/rooted.json': { value: 'rooted' }, + 'internal-fixture/custom-static-paths/windows/style.json': { value: 'windows' }, + }); + }); + + it('scopes staticPath values under the service id so two services cannot collide', async () => { + const firstServiceDef = defineService({ + id: 'internal-fixture/scoped-static-path-a', + description: 'Uses the same relative staticPath as another service.', + initialState: { value: 'a' }, + queries: { + getValue: { + description: 'Returns one scoped value.', + input: v.undefined(), + output: v.string(), + handler: (_input, ctx) => ctx.self.state.value, + load: async () => {}, + staticPath: () => 'state.json', + staticInputs: async () => [undefined], + }, + }, + commands: {}, + }); + + const secondServiceDef = defineService({ + id: 'internal-fixture/scoped-static-path-b', + description: 'Uses the same relative staticPath as another service.', + initialState: { value: 'b' }, + queries: { + getValue: { + description: 'Returns one scoped value.', + input: v.undefined(), + output: v.string(), + handler: (_input, ctx) => ctx.self.state.value, + load: async () => {}, + staticPath: () => 'state.json', + staticInputs: async () => [undefined], + }, + }, + commands: {}, + }); + + registerService(firstServiceDef); + registerService(secondServiceDef); + + await expect(buildStaticFiles()).resolves.toEqual({ + 'internal-fixture/scoped-static-path-a/state.json': { value: 'a' }, + 'internal-fixture/scoped-static-path-b/state.json': { value: 'b' }, + }); + }); + + it('rejects static paths that escape the service output folder', async () => { + const invalidPathServiceDef = defineService({ + id: 'internal-fixture/invalid-static-path', + description: 'Attempts to escape the static snapshot root.', + initialState: { value: null as string | null }, + queries: { + getValue: { + description: 'Uses an invalid static path.', + input: v.object({ build: v.literal('once') }), + output: v.nullable(v.string()), + handler: (_input, ctx) => ctx.self.state.value, + load: async (_input, ctx) => { + await ctx.self.commands.setValue(undefined); + }, + staticPath: () => '../escape.json', + staticInputs: async () => [{ build: 'once' as const }], + }, + }, + commands: { + setValue: { + description: 'Stores one placeholder value before the invalid path is resolved.', + input: v.undefined(), + output: v.undefined(), + handler: async (_input, ctx) => { + ctx.self.setState((draft) => { + draft.value = 'invalid'; + }); + + return undefined; + }, + }, + }, + }); + + registerService(invalidPathServiceDef); + + await expect(buildStaticFiles()).rejects.toMatchObject({ + fromStorybook: true, + code: 10, + message: + 'Invalid static path "../escape.json" for query "internal-fixture/invalid-static-path.getValue": use a relative path with forward slashes and no ".." segments.', + }); + }); + }); + + describe('writeOpenServiceStaticFiles', () => { + it('writes normalized snapshot files underneath outputDir/services', async () => { + const outputDir = '/app/dist'; + const customPathServiceDef = defineService({ + id: 'internal-fixture/write-open-service-static-files', + description: 'Writes custom static paths to disk.', + initialState: { value: null as string | null }, + queries: { + getValue: { + description: 'Stores one custom value per static input.', + input: v.object({ + path: v.string(), + value: v.string(), + }), + output: v.nullable(v.string()), + handler: (_input, ctx) => ctx.self.state.value, + load: async (input, ctx) => { + await ctx.self.commands.setValue(input); + }, + staticPath: (input) => input.path, + staticInputs: async () => [ + { path: './nested/value.json', value: 'dot' }, + { path: '/rooted.json', value: 'rooted' }, + { path: 'windows\\style.json', value: 'windows' }, + ], + }, + }, + commands: { + setValue: { + description: 'Stores one value before the snapshot is written to disk.', + input: v.object({ + path: v.string(), + value: v.string(), + }), + output: v.undefined(), + handler: async (input, ctx) => { + ctx.self.setState((draft) => { + draft.value = input.value; + }); + + return undefined; + }, + }, + }, + }); + + registerService(customPathServiceDef); + + await writeOpenServiceStaticFiles(outputDir); + + await expect( + readFile( + join( + outputDir, + 'services', + 'internal-fixture', + 'write-open-service-static-files', + 'nested', + 'value.json' + ), + 'utf8' + ) + ).resolves.toBe(JSON.stringify({ value: 'dot' }, null, 2)); + await expect( + readFile( + join( + outputDir, + 'services', + 'internal-fixture', + 'write-open-service-static-files', + 'rooted.json' + ), + 'utf8' + ) + ).resolves.toBe(JSON.stringify({ value: 'rooted' }, null, 2)); + await expect( + readFile( + join( + outputDir, + 'services', + 'internal-fixture', + 'write-open-service-static-files', + 'windows', + 'style.json' + ), + 'utf8' + ) + ).resolves.toBe(JSON.stringify({ value: 'windows' }, null, 2)); + }); + }); +}); diff --git a/code/core/src/shared/open-service/server.ts b/code/core/src/shared/open-service/server.ts new file mode 100644 index 000000000000..f5a8a362adc4 --- /dev/null +++ b/code/core/src/shared/open-service/server.ts @@ -0,0 +1,125 @@ +import { mkdir, writeFile } from 'node:fs/promises'; + +import { dirname, join } from 'pathe'; + +import { toMerged } from 'es-toolkit/object'; + +import { + clearRegistry, + describeService, + getRegisteredServices, + getService, + listServices, + registerService, + serviceRegistryApi, +} from './service-registration.ts'; +import { createServiceRuntime, resolveStaticPath } from './service-runtime.ts'; +import { validateSchema } from './service-validation.ts'; +import type { + AnyQueryDefinition, + BuildTaskResult, + Commands, + Queries, + ServiceDefinition, + StaticStore, +} from './types.ts'; + +type RuntimeServiceDefinition = ServiceDefinition, Commands>; +type RuntimeQueryDefinition = AnyQueryDefinition; + +export { + clearRegistry, + describeService, + getRegisteredServices, + getService, + listServices, + registerService, +}; + +/** + * Builds serialized static-state snapshots for `load`-enabled queries across every service + * currently in the registry. + * + * Each static input runs against a fresh service runtime so one load path cannot leak state + * into another path's snapshot. The runtime's `runLoadOnce` helper drives the load to completion + * (including transitively triggered self-queries) before the resulting state is captured. + * Cross-service `ctx.getService(...)` lookups inside a load resolve through the live registry, + * matching dev-server behavior. + */ +export async function buildStaticFiles(): Promise { + const store: StaticStore = {}; + const buildTasks: Promise[] = []; + + for (const service of getRegisteredServices() as RuntimeServiceDefinition[]) { + for (const [queryName, query] of Object.entries(service.queries) as [ + string, + RuntimeQueryDefinition, + ][]) { + const { load, staticPath, staticInputs } = query; + if (!staticPath || !load || !staticInputs) { + continue; + } + + buildTasks.push( + (async () => { + const inputsRuntime = createServiceRuntime( + service, + { registryApi: serviceRegistryApi }, + structuredClone(service.initialState) + ); + const inputs = await staticInputs(inputsRuntime.loadCtxForStatic); + + return Promise.all( + inputs.map(async (input) => { + // Build every static input from a clean initial state so the serialized output mirrors + // the one path this task is responsible for. + const buildRuntime = createServiceRuntime( + service, + { registryApi: serviceRegistryApi }, + structuredClone(service.initialState) + ); + const validatedInput = await validateSchema(query.input, input, { + kind: 'query', + serviceId: service.id, + name: queryName, + phase: 'input', + }); + const path = resolveStaticPath(service.id, queryName, { staticPath }, validatedInput); + + await buildRuntime.runLoadOnce(queryName, validatedInput); + + return { path, state: buildRuntime.stateSignal() }; + }) + ); + })() + ); + } + } + + const builtStates = (await Promise.all(buildTasks)).flat(); + + for (const { path, state } of builtStates) { + store[path] = path in store ? toMerged(store[path] as object, state as object) : state; + } + + return store; +} + +/** + * Writes the registered services' static snapshots to `/services`. + * + * The snapshot keys are normalized slash-separated logical paths; splitting them here lets `join` + * produce the correct native separators for the current operating system. + */ +export async function writeOpenServiceStaticFiles(outputDir: string): Promise { + const staticStore = await buildStaticFiles(); + + await Promise.all( + Object.entries(staticStore).map(async ([relativePath, state]) => { + const outputPath = join(outputDir, 'services', ...relativePath.split('/')); + + await mkdir(dirname(outputPath), { recursive: true }); + await writeFile(outputPath, JSON.stringify(state, null, 2)); + }) + ); +} diff --git a/code/core/src/shared/open-service/service-definition.ts b/code/core/src/shared/open-service/service-definition.ts new file mode 100644 index 000000000000..2f9c389448e2 --- /dev/null +++ b/code/core/src/shared/open-service/service-definition.ts @@ -0,0 +1,98 @@ +import type { + CommandDefinition, + MatchingOutputSchemas, + OperationInputSchemas, + QueryDefinition, + ServiceDefinition, + ServiceId, +} from './types.ts'; + +/** + * Authoring-side query map derived from separate query input/output schema maps. + * + * The second mapped-type intersection is deliberate. During experiments, TypeScript would infer + * the `input` schema for each inline query, but then lose the corresponding `output` schema before + * it contextually typed sibling callbacks. Repeating the output map through a keyed `output` view + * keeps each query key's input and output schemas correlated while handlers, load hooks, and + * static callbacks are being typed. + */ +type DefinedQueries< + TState, + TQueryInputSchemas extends OperationInputSchemas, + TQueryOutputSchemas extends MatchingOutputSchemas, + TCommandInputSchemas extends OperationInputSchemas, + TCommandOutputSchemas extends MatchingOutputSchemas, +> = { + [TKey in keyof TQueryInputSchemas]: QueryDefinition< + TState, + TQueryInputSchemas[TKey], + TQueryOutputSchemas[TKey], + TCommandInputSchemas, + TCommandOutputSchemas + >; +} & { + [TKey in keyof TQueryOutputSchemas]: { + output: TQueryOutputSchemas[TKey]; + }; +}; + +/** + * Authoring-side command map derived from separate command input/output schema maps. + * + * Commands do not need access to the command schema maps in their own context, but they still + * benefit from the same key-correlation trick as queries so TypeScript preserves each inline + * command object's `output` schema while typing its `handler`. + */ +type DefinedCommands< + TState, + TCommandInputSchemas extends OperationInputSchemas, + TCommandOutputSchemas extends MatchingOutputSchemas, +> = { + [TKey in keyof TCommandInputSchemas]: CommandDefinition< + TState, + TCommandInputSchemas[TKey], + TCommandOutputSchemas[TKey] + >; +} & { + [TKey in keyof TCommandOutputSchemas]: { + output: TCommandOutputSchemas[TKey]; + }; +}; + +/** + * Finalizes a service definition while preserving inline query and command inference. + * + * The generic order matters here. We infer the per-operation schema maps first, then derive the + * concrete query/command definition maps from those schemas. If we instead ask TypeScript to infer + * the full runtime `ServiceDefinition` maps directly, it widens callback parameters to `unknown` + * before it has correlated each inline object's `input` and `output` properties. + */ +export const defineService = < + TState, + const TQueryInputSchemas extends OperationInputSchemas, + const TQueryOutputSchemas extends MatchingOutputSchemas, + const TCommandInputSchemas extends OperationInputSchemas, + const TCommandOutputSchemas extends MatchingOutputSchemas, +>(def: { + id: ServiceId; + description?: string; + initialState: TState; + queries: DefinedQueries< + TState, + TQueryInputSchemas, + TQueryOutputSchemas, + TCommandInputSchemas, + TCommandOutputSchemas + >; + commands: DefinedCommands; +}): ServiceDefinition< + TState, + DefinedQueries< + TState, + TQueryInputSchemas, + TQueryOutputSchemas, + TCommandInputSchemas, + TCommandOutputSchemas + >, + DefinedCommands +> => def; diff --git a/code/core/src/shared/open-service/service-registration.test.ts b/code/core/src/shared/open-service/service-registration.test.ts new file mode 100644 index 000000000000..0b4e8c0abcbe --- /dev/null +++ b/code/core/src/shared/open-service/service-registration.test.ts @@ -0,0 +1,275 @@ +import * as v from 'valibot'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { defineService } from './service-definition.ts'; +import { + assignEntryFieldInputSchema, + awaitedPreloadValueServiceDef, + createDerivedBooleanFromChildQueryServiceDef, + entryIdInputSchema, + mutableRecordLookupServiceDef, + recordFieldsOutputSchema, + voidOutputSchema, +} from './fixtures.ts'; +import { + buildStaticFiles, + clearRegistry, + describeService, + getRegisteredServices, + getService, + listServices, + registerService, +} from './server.ts'; + +afterEach(() => { + clearRegistry(); +}); + +describe('service registration', () => { + it('registers services globally and exposes summaries and descriptors by id', async () => { + const service = registerService(mutableRecordLookupServiceDef); + + expect(getService('internal-fixture/mutable-record-lookup')).toBe(service); + expect(getRegisteredServices()).toHaveLength(1); + await expect(listServices()).resolves.toEqual([ + { + id: 'internal-fixture/mutable-record-lookup', + description: 'Provides a mutable record lookup keyed by entry id.', + queryNames: ['getRecordFields'], + commandNames: ['assignRecordField'], + }, + ]); + + const descriptor = await describeService('internal-fixture/mutable-record-lookup'); + + expect(descriptor).toMatchObject({ + id: 'internal-fixture/mutable-record-lookup', + description: 'Provides a mutable record lookup keyed by entry id.', + queries: { + getRecordFields: { + name: 'getRecordFields', + description: 'Returns all stored fields for one entry, or null when absent.', + }, + }, + commands: { + assignRecordField: { + name: 'assignRecordField', + description: 'Writes one field value onto the selected entry.', + }, + }, + }); + expect(descriptor.queries.getRecordFields.input).toBe(entryIdInputSchema); + expect(descriptor.queries.getRecordFields.output).toBe(recordFieldsOutputSchema); + expect(descriptor.queries.getRecordFields.staticPath).toBeUndefined(); + expect(descriptor.commands.assignRecordField.input).toBe(assignEntryFieldInputSchema); + expect(descriptor.commands.assignRecordField.output).toBe(voidOutputSchema); + }); + + it('throws when registering the same service id twice', () => { + registerService(mutableRecordLookupServiceDef); + + try { + registerService(mutableRecordLookupServiceDef); + expect.unreachable('Expected duplicate registration to throw'); + } catch (error) { + expect(error).toMatchObject({ + fromStorybook: true, + code: 6, + message: + 'A service with id "internal-fixture/mutable-record-lookup" is already registered.', + }); + } + }); + + it('throws a Storybook error when resolving a missing registered service id', () => { + expect(() => getService('internal-fixture/missing-service')).toThrow( + 'No registered service with id "internal-fixture/missing-service" exists in this environment.' + ); + }); + + it('throws a Storybook error when a registered query or command is missing its handler', async () => { + const service = registerService( + defineService({ + id: 'internal-fixture/unimplemented-operations', + description: 'Leaves handlers undefined so registration can supply them later.', + initialState: {} as Record, + queries: { + getValue: { + description: 'Reads a value that is not implemented in this environment.', + input: v.undefined(), + output: v.string(), + }, + }, + commands: { + run: { + description: 'Runs a command that is not implemented in this environment.', + input: v.undefined(), + output: voidOutputSchema, + }, + }, + }) + ); + + expect(() => service.queries.getValue(undefined)).toThrow( + 'Query "internal-fixture/unimplemented-operations.getValue" is not implemented for this environment.' + ); + await expect(service.commands.run(undefined)).rejects.toMatchObject({ + fromStorybook: true, + code: 8, + message: + 'Command "internal-fixture/unimplemented-operations.run" is not implemented for this environment.', + }); + }); + + it('lets handlers resolve another registered service by id through ctx.getService', async () => { + const sourceService = registerService(mutableRecordLookupServiceDef); + const derivedService = registerService(createDerivedBooleanFromChildQueryServiceDef()); + + expect(derivedService.queries.isEntryMarked({ entryId: 'entry-a' })).toBe(false); + + await sourceService.commands.assignRecordField({ + entryId: 'entry-a', + fieldKey: 'marker', + fieldValue: 'match', + }); + + expect(derivedService.queries.isEntryMarked({ entryId: 'entry-a' })).toBe(true); + }); + + it('allows server registration to provide handlers that are omitted from the definition', async () => { + const incrementableServiceDef = defineService({ + id: 'internal-fixture/registered-command-override', + description: 'Provides a command handler at registration time.', + initialState: { count: 0 }, + queries: { + getCount: { + description: 'Reads the current count.', + input: v.undefined(), + output: v.number(), + handler: (_input, ctx) => ctx.self.state.count, + }, + }, + commands: { + increment: { + description: 'Increments the current count.', + input: v.undefined(), + output: voidOutputSchema, + }, + assignFromLookup: { + description: 'Reads another service and mirrors whether a marker exists.', + input: assignEntryFieldInputSchema, + output: voidOutputSchema, + }, + }, + }); + + registerService(mutableRecordLookupServiceDef); + const service = registerService(incrementableServiceDef, { + commands: { + increment: { + handler: async (_input, ctx) => { + ctx.self.setState((draft) => { + draft.count += 1; + }); + }, + }, + assignFromLookup: { + handler: async (input, ctx) => { + const lookup = ctx.getService( + 'internal-fixture/mutable-record-lookup' + ); + + await lookup.commands.assignRecordField(input); + + const record = lookup.queries.getRecordFields({ + entryId: input.entryId, + }); + ctx.self.setState((draft) => { + draft.count = record?.marker === input.fieldValue ? 1 : 0; + }); + }, + }, + }, + }); + + await service.commands.increment(undefined); + expect(service.queries.getCount(undefined)).toBe(1); + + await service.commands.assignFromLookup({ + entryId: 'entry-a', + fieldKey: 'marker', + fieldValue: 'match', + }); + expect(service.queries.getCount(undefined)).toBe(1); + + expect( + getService('internal-fixture/mutable-record-lookup').queries.getRecordFields({ + entryId: 'entry-a', + }) + ).toEqual({ marker: 'match' }); + }); + + it('exposes staticPath presence on query descriptors', async () => { + registerService(awaitedPreloadValueServiceDef); + + const descriptor = await describeService('internal-fixture/awaited-preload-value'); + + expect(descriptor.queries.getPreloadedValue.staticPath).toBe(true); + }); + + it('allows load and staticInputs to be supplied only at registration time', async () => { + const serviceDef = defineService({ + id: 'internal-fixture/registration-only-static-build', + description: 'Declares staticPath in the definition and load at registration.', + initialState: { value: null as string | null }, + queries: { + getValue: { + description: 'Returns one statically built value.', + input: v.object({ build: v.literal('once') }), + output: v.nullable(v.string()), + handler: (_input, ctx) => ctx.self.state.value, + staticPath: () => 'state.json', + staticInputs: async () => [{ build: 'once' as const }], + }, + }, + commands: { + setValue: { + description: 'Stores one value during static load.', + input: v.undefined(), + output: voidOutputSchema, + }, + }, + }); + + registerService(serviceDef, { + queries: { + getValue: { + load: async (_input, ctx) => { + await ctx.self.commands.setValue(undefined); + }, + }, + }, + commands: { + setValue: { + handler: async (_input, ctx) => { + ctx.self.setState((draft) => { + draft.value = 'built-at-registration'; + }); + }, + }, + }, + }); + + await expect(buildStaticFiles()).resolves.toEqual({ + 'internal-fixture/registration-only-static-build/state.json': { + value: 'built-at-registration', + }, + }); + + expect( + getService('internal-fixture/registration-only-static-build').queries.getValue({ + build: 'once', + }) + ).toBe(null); + }); +}); diff --git a/code/core/src/shared/open-service/service-registration.ts b/code/core/src/shared/open-service/service-registration.ts new file mode 100644 index 000000000000..aad03d396d15 --- /dev/null +++ b/code/core/src/shared/open-service/service-registration.ts @@ -0,0 +1,279 @@ +import { createServiceRuntime } from './service-runtime.ts'; +import { + OpenServiceDuplicateRegistrationError, + OpenServiceMissingServiceError, +} from '../../server-errors.ts'; +import type { + AnyServiceDefinition, + AnyQueryDefinition, + Commands, + Queries, + RegisteredStaticInputs, + RuntimeService, + ServiceDefinition, + ServiceDescriptor, + ServiceId, + ServiceInstance, + ServiceInstanceOf, + ServiceRegistrationOptions, + ServiceRegistryApi, + ServiceSummary, +} from './types.ts'; +type RegistryEntry = { + definition: AnyServiceDefinition; + runtime: RuntimeService; + summary: ServiceSummary; + descriptor: ServiceDescriptor; +}; + +const OPEN_SERVICE_REGISTRY_SYMBOL = Symbol.for('storybook.open-service.registry'); + +/** + * Returns the process-global registry backing server-side service registration. + * + * The registry is anchored on a symbol-keyed `globalThis` slot so all modules in the same process + * share one registration map even if this file is imported through different paths. That keeps + * runtime lookups, static builds, and tests pointed at the same service inventory. + */ +function getRegistry(): Map { + const registryGlobal = globalThis as { + [key: symbol]: Map | undefined; + }; + + // Lazily create the registry so importing the module does not eagerly mutate global state. + registryGlobal[OPEN_SERVICE_REGISTRY_SYMBOL] ??= new Map(); + + return registryGlobal[OPEN_SERVICE_REGISTRY_SYMBOL]; +} + +/** + * Converts one service definition into the serializable descriptor returned by registry metadata + * APIs. + * + * Descriptors intentionally expose schemas and descriptions, but not runtime handlers, so callers + * can inspect the contract of a registered service without gaining access to executable behavior. + */ +function describeDefinition(definition: AnyServiceDefinition): ServiceDescriptor { + return { + id: definition.id, + description: definition.description, + queries: Object.fromEntries( + Object.entries(definition.queries).map(([name, query]) => [ + name, + { + name, + description: query.description, + input: query.input, + output: query.output, + ...(query.staticPath ? { staticPath: true as const } : {}), + }, + ]) + ), + commands: Object.fromEntries( + Object.entries(definition.commands).map(([name, command]) => [ + name, + { + name, + description: command.description, + input: command.input, + output: command.output, + }, + ]) + ), + }; +} + +/** + * Derives the lightweight summary returned by `listServices()` from a full descriptor. + * + * Keeping this separate avoids recomputing names from the live definition shape whenever callers + * only need discovery metadata for navigation or debugging UIs. + */ +function summarizeDescriptor(descriptor: ServiceDescriptor): ServiceSummary { + return { + id: descriptor.id, + description: descriptor.description, + queryNames: Object.keys(descriptor.queries), + commandNames: Object.keys(descriptor.commands), + }; +} + +/** + * Resolves the static input enumerator stored on a registered query. + * + * Registration may override the authored definition. When it does not, the definition's + * `staticInputs` is forwarded as-is so ctx-aware enumerators keep receiving `LoadCtx` at call time. + */ +function resolveRegisteredStaticInputs( + query: AnyQueryDefinition, + registrationQuery?: { staticInputs?: RegisteredStaticInputs } +): RegisteredStaticInputs | undefined { + if (registrationQuery?.staticInputs) { + return registrationQuery.staticInputs; + } + + return query.staticInputs; +} + +/** + * Applies optional server-side overrides to an authored service definition. + * + * Registration overrides are shallow merges over the authored definition. That lets the server + * swap handlers, load hooks, or dependency-aware static input enumerators per operation while the + * original schema contract, `staticPath`, and operation names remain the source of truth. + */ +function applyRegistration< + TState, + TQueries extends Queries, + TCommands extends Commands, +>( + definition: ServiceDefinition, + registration?: ServiceRegistrationOptions +): ServiceDefinition { + return { + ...definition, + queries: Object.fromEntries( + Object.entries(definition.queries).map(([name, query]) => { + const registrationQuery = registration?.queries?.[name as keyof TQueries]; + const staticInputs = resolveRegisteredStaticInputs(query, registrationQuery); + + return [ + name, + { + ...query, + ...registrationQuery, + ...(staticInputs ? { staticInputs } : {}), + }, + ]; + }) + ) as TQueries, + commands: Object.fromEntries( + Object.entries(definition.commands).map(([name, command]) => [ + name, + registration?.commands?.[name as keyof TCommands] + ? { ...command, ...registration.commands[name as keyof TCommands] } + : command, + ]) + ) as TCommands, + }; +} + +/** + * Shared registry API injected into registered runtimes and static-build runtimes. + * + * Exporting the object keeps all call sites on the same lookup implementation instead of each + * environment assembling a structurally identical wrapper. + */ +export const serviceRegistryApi: ServiceRegistryApi = { + listServices, + describeService, + getService, +}; + +/** + * Registers one service definition in the process-global registry and returns its runtime surface. + * + * Registration resolves any server-side operation overrides first, then builds the runtime that + * query and command callers will use, and finally stores both the runtime and its metadata in the + * shared registry. Duplicate ids are rejected up front so lookups remain deterministic. + */ +export function registerService< + TState, + TQueries extends Queries, + TCommands extends Commands, +>( + definition: ServiceDefinition, + registration?: ServiceRegistrationOptions +): ServiceInstance & ServiceRegistryApi { + const registry = getRegistry(); + + if (registry.has(definition.id)) { + throw new OpenServiceDuplicateRegistrationError({ serviceId: definition.id }); + } + + const resolvedDefinition = applyRegistration(definition, registration); + const runtime = createServiceRuntime(resolvedDefinition, { registryApi: serviceRegistryApi }); + const registeredRuntime = { + queries: runtime.queries, + commands: runtime.commands, + ...serviceRegistryApi, + } as ServiceInstance & ServiceRegistryApi; + const descriptor = describeDefinition(resolvedDefinition as AnyServiceDefinition); + + // Persist the runtime together with precomputed metadata so later lookups stay cheap and do not + // need to rebuild descriptors from the authored definition each time. + registry.set(definition.id, { + definition: resolvedDefinition as AnyServiceDefinition, + runtime: registeredRuntime as unknown as RuntimeService, + descriptor, + summary: summarizeDescriptor(descriptor), + }); + + return registeredRuntime; +} + +/** + * Returns the authored definitions currently registered in this server process. + * + * Static build code uses this to discover which services contribute static snapshots. + */ +export function getRegisteredServices(): AnyServiceDefinition[] { + return Array.from(getRegistry().values(), ({ definition }) => definition); +} + +/** + * Returns one summary entry per registered service. + * + * This is the lowest-cost discovery endpoint for callers that only need ids, descriptions, and + * operation names. + */ +export async function listServices(): Promise { + return Array.from(getRegistry().values(), ({ summary }) => summary); +} + +/** + * Returns the schema-backed descriptor for one registered service. + * + * The descriptor mirrors the public contract of the service without exposing handlers or state. + */ +export async function describeService(serviceId: ServiceId): Promise { + const entry = getRegistry().get(serviceId); + + if (!entry) { + throw new OpenServiceMissingServiceError({ serviceId }); + } + + return entry.descriptor; +} + +/** + * Resolves a registered runtime service by id from the current server process. + * + * Query and command contexts delegate cross-service calls through this lookup so one service can + * reuse another service's runtime contract. Synchronous because callers need it available inside + * sync query handlers. + */ +export function getService(serviceId: ServiceId): RuntimeService; +export function getService( + serviceId: ServiceId +): ServiceInstanceOf; +export function getService( + serviceId: ServiceId +): RuntimeService | ServiceInstanceOf { + const entry = getRegistry().get(serviceId); + + if (!entry) { + throw new OpenServiceMissingServiceError({ serviceId }); + } + + return entry.runtime as unknown as ServiceInstanceOf; +} + +/** + * Clears the process-global registry. + * + * Tests call this after each case so registrations from one scenario do not leak into the next. + */ +export function clearRegistry(): void { + getRegistry().clear(); +} diff --git a/code/core/src/shared/open-service/service-runtime.test.ts b/code/core/src/shared/open-service/service-runtime.test.ts new file mode 100644 index 000000000000..884a95dcc9ae --- /dev/null +++ b/code/core/src/shared/open-service/service-runtime.test.ts @@ -0,0 +1,653 @@ +import * as v from 'valibot'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { defineService } from './service-definition.ts'; +import { serviceRegistryApi } from './service-registration.ts'; +import { createServiceRuntime } from './service-runtime.ts'; +import { clearRegistry, registerService } from './server.ts'; +import { + awaitedPreloadValueServiceDef, + createDerivedBooleanFromChildQueryServiceDef, + entryIdInputSchema, + fireAndForgetPreloadValueServiceDef, + mutableRecordLookupServiceDef, + preloadedValueOutputSchema, + voidOutputSchema, +} from './fixtures.ts'; + +afterEach(() => { + clearRegistry(); +}); + +describe('service runtime', () => { + describe('direct query calls', () => { + it('returns the initial record lookup value synchronously', () => { + const service = registerService(mutableRecordLookupServiceDef); + + expect(service.queries.getRecordFields({ entryId: 'entry-a' })).toBeNull(); + }); + + it('reflects state after a mutating command', async () => { + const service = registerService(mutableRecordLookupServiceDef); + + await service.commands.assignRecordField({ + entryId: 'entry-a', + fieldKey: 'marker', + fieldValue: 'match', + }); + + expect(service.queries.getRecordFields({ entryId: 'entry-a' })).toEqual({ + marker: 'match', + }); + }); + }); + + describe('subscriptions', () => { + it('delivers the current value after subscription starts', async () => { + const service = registerService(mutableRecordLookupServiceDef); + const calls: Array | null> = []; + + const unsubscribe = service.queries.getRecordFields.subscribe( + { entryId: 'entry-a' }, + (value) => { + calls.push(value); + } + ); + + await vi.waitFor(() => expect(calls).toEqual([null])); + unsubscribe(); + }); + + it('notifies subscribers when their own record changes', async () => { + const service = registerService(mutableRecordLookupServiceDef); + const calls: Array | null> = []; + + const unsubscribe = service.queries.getRecordFields.subscribe( + { entryId: 'entry-a' }, + (value) => { + calls.push(value); + } + ); + + await vi.waitFor(() => expect(calls).toEqual([null])); + await service.commands.assignRecordField({ + entryId: 'entry-a', + fieldKey: 'marker', + fieldValue: 'updated', + }); + + expect(calls).toEqual([null, { marker: 'updated' }]); + unsubscribe(); + }); + + it('does not notify subscribers for a different record', async () => { + const service = registerService(mutableRecordLookupServiceDef); + const callsA: Array | null> = []; + const callsB: Array | null> = []; + + const unsubscribeA = service.queries.getRecordFields.subscribe( + { entryId: 'entry-a' }, + (value) => { + callsA.push(value); + } + ); + const unsubscribeB = service.queries.getRecordFields.subscribe( + { entryId: 'entry-b' }, + (value) => { + callsB.push(value); + } + ); + + await vi.waitFor(() => expect(callsA).toEqual([null])); + await vi.waitFor(() => expect(callsB).toEqual([null])); + await service.commands.assignRecordField({ + entryId: 'entry-b', + fieldKey: 'marker', + fieldValue: 'match', + }); + + expect(callsA).toEqual([null]); + expect(callsB).toEqual([null, { marker: 'match' }]); + unsubscribeA(); + unsubscribeB(); + }); + + it('stops notifying after unsubscribe', async () => { + const service = registerService(mutableRecordLookupServiceDef); + const calls: Array | null> = []; + + const unsubscribe = service.queries.getRecordFields.subscribe( + { entryId: 'entry-a' }, + (value) => { + calls.push(value); + } + ); + + await vi.waitFor(() => expect(calls).toEqual([null])); + await service.commands.assignRecordField({ + entryId: 'entry-a', + fieldKey: 'marker', + fieldValue: 'first', + }); + unsubscribe(); + await service.commands.assignRecordField({ + entryId: 'entry-a', + fieldKey: 'marker', + fieldValue: 'second', + }); + + expect(calls).toEqual([null, { marker: 'first' }]); + }); + + it('supports multiple subscribers on the same query', async () => { + const service = registerService(mutableRecordLookupServiceDef); + const callsA: Array | null> = []; + const callsB: Array | null> = []; + + const unsubscribeA = service.queries.getRecordFields.subscribe( + { entryId: 'entry-a' }, + (value) => { + callsA.push(value); + } + ); + const unsubscribeB = service.queries.getRecordFields.subscribe( + { entryId: 'entry-a' }, + (value) => { + callsB.push(value); + } + ); + + await vi.waitFor(() => expect(callsA).toEqual([null])); + await vi.waitFor(() => expect(callsB).toEqual([null])); + await service.commands.assignRecordField({ + entryId: 'entry-a', + fieldKey: 'marker', + fieldValue: 'shared', + }); + + expect(callsA).toEqual([null, { marker: 'shared' }]); + expect(callsB).toEqual([null, { marker: 'shared' }]); + unsubscribeA(); + unsubscribeB(); + }); + + it('emits the initial value but skips the late value when unsubscribed before a load settles', async () => { + let resolveLoad!: () => void; + let loadStarted = false; + let loadFinished = false; + const loadReady = new Promise((resolve) => { + resolveLoad = resolve; + }); + const delayedQueryServiceDef = defineService({ + id: 'internal-fixture/delayed-subscription-value', + description: 'Resolves a load after the subscriber has already unsubscribed.', + initialState: { value: null as string | null }, + queries: { + getValue: { + input: v.undefined(), + output: v.nullable(v.string()), + handler: (_input, ctx) => ctx.self.state.value, + load: async (_input, ctx) => { + loadStarted = true; + await loadReady; + await ctx.self.commands.assignValue('late'); + loadFinished = true; + }, + }, + }, + commands: { + assignValue: { + input: v.string(), + output: v.void(), + handler: (input, ctx) => { + ctx.self.setState((draft) => { + draft.value = input; + }); + }, + }, + }, + }); + const service = registerService(delayedQueryServiceDef); + const calls: Array = []; + + const unsubscribe = service.queries.getValue.subscribe(undefined, (value) => { + calls.push(value); + }); + + await vi.waitFor(() => expect(loadStarted).toBe(true)); + await vi.waitFor(() => expect(calls).toEqual([null])); + unsubscribe(); + resolveLoad(); + + await vi.waitFor(() => expect(loadFinished).toBe(true)); + expect(calls).toEqual([null]); + }); + + it('rethrows subscription input validation failures through queueMicrotask', async () => { + const queuedCallbacks: Array<() => void> = []; + const queueMicrotaskSpy = vi + .spyOn(globalThis, 'queueMicrotask') + .mockImplementation((callback: VoidFunction) => { + queuedCallbacks.push(callback); + }); + const service = registerService(mutableRecordLookupServiceDef); + + service.queries.getRecordFields.subscribe({} as unknown as { entryId: string }, () => {}); + + await vi.waitFor(() => expect(queuedCallbacks).toHaveLength(1)); + try { + try { + queuedCallbacks[0](); + expect.unreachable('Expected queued validation error to be thrown'); + } catch (error) { + expect(error).toMatchObject({ + fromStorybook: true, + code: 5, + message: + 'Invalid input for query "internal-fixture/mutable-record-lookup.getRecordFields":\nentryId: Invalid key: Expected "entryId" but received undefined', + }); + } + } finally { + queueMicrotaskSpy.mockRestore(); + } + }); + }); + + describe('background load', () => { + it('returns the current value synchronously and triggers load in the background', async () => { + const service = registerService(awaitedPreloadValueServiceDef); + + expect(service.queries.getPreloadedValue({ entryId: 'entry-a' })).toBeNull(); + + await vi.waitFor(() => + expect(service.queries.getPreloadedValue({ entryId: 'entry-a' })).toBe('preloaded') + ); + }); + + it('does not call the load command twice for concurrent in-flight calls', async () => { + const service = registerService(awaitedPreloadValueServiceDef); + const preloadValueSpy = vi.spyOn( + awaitedPreloadValueServiceDef.commands.preloadValue, + 'handler' + ); + + try { + const [first, second] = await Promise.all([ + service.queries.getPreloadedValue.loaded({ entryId: 'entry-a' }), + service.queries.getPreloadedValue.loaded({ entryId: 'entry-a' }), + ]); + + expect(first).toBe('preloaded'); + expect(second).toBe('preloaded'); + expect(preloadValueSpy).toHaveBeenCalledTimes(1); + } finally { + preloadValueSpy.mockRestore(); + } + }); + + it('emits the current value immediately and the loaded value once load settles', async () => { + const service = registerService(awaitedPreloadValueServiceDef); + const calls: Array = []; + + const unsubscribe = service.queries.getPreloadedValue.subscribe( + { entryId: 'entry-a' }, + (value) => { + calls.push(value); + } + ); + + await vi.waitFor(() => expect(calls).toEqual([null, 'preloaded'])); + + unsubscribe(); + }); + + it('preloads distinct values independently by input', async () => { + const service = registerService(awaitedPreloadValueServiceDef); + const callsA: Array = []; + const callsB: Array = []; + + const unsubscribeA = service.queries.getPreloadedValue.subscribe( + { entryId: 'entry-a' }, + (value) => { + callsA.push(value); + } + ); + const unsubscribeB = service.queries.getPreloadedValue.subscribe( + { entryId: 'entry-b' }, + (value) => { + callsB.push(value); + } + ); + + await vi.waitFor(() => expect(callsA).toEqual([null, 'preloaded'])); + await vi.waitFor(() => expect(callsB).toEqual([null, 'preloaded'])); + unsubscribeA(); + unsubscribeB(); + }); + + it('returns the fully loaded value from .loaded()', async () => { + const service = registerService(awaitedPreloadValueServiceDef); + + await expect(service.queries.getPreloadedValue.loaded({ entryId: 'entry-a' })).resolves.toBe( + 'preloaded' + ); + }); + + it('resolves .loaded() immediately when state is already populated', async () => { + const service = registerService(awaitedPreloadValueServiceDef); + const preloadValueSpy = vi.spyOn( + awaitedPreloadValueServiceDef.commands.preloadValue, + 'handler' + ); + + try { + await service.queries.getPreloadedValue.loaded({ entryId: 'entry-a' }); + preloadValueSpy.mockClear(); + + await expect( + service.queries.getPreloadedValue.loaded({ entryId: 'entry-a' }) + ).resolves.toBe('preloaded'); + expect(preloadValueSpy).not.toHaveBeenCalled(); + } finally { + preloadValueSpy.mockRestore(); + } + }); + + it('fires background load on every sync call but dedupes while in flight', async () => { + const service = registerService(fireAndForgetPreloadValueServiceDef); + const preloadValueSpy = vi.spyOn( + fireAndForgetPreloadValueServiceDef.commands.preloadValue, + 'handler' + ); + + try { + expect(service.queries.getPreloadedValue({ entryId: 'entry-a' })).toBeNull(); + expect(service.queries.getPreloadedValue({ entryId: 'entry-a' })).toBeNull(); + expect(service.queries.getPreloadedValue({ entryId: 'entry-a' })).toBeNull(); + + await vi.waitFor(() => + expect(service.queries.getPreloadedValue({ entryId: 'entry-a' })).toBe('preloaded') + ); + + expect(preloadValueSpy).toHaveBeenCalledTimes(1); + } finally { + preloadValueSpy.mockRestore(); + } + }); + + it('updates subscribers reactively after the background load finishes', async () => { + const service = registerService(fireAndForgetPreloadValueServiceDef); + const calls: Array = []; + + const unsubscribe = service.queries.getPreloadedValue.subscribe( + { entryId: 'entry-a' }, + (value) => { + calls.push(value); + } + ); + + await vi.waitFor(() => expect(calls).toEqual([null, 'preloaded'])); + + unsubscribe(); + }); + }); + + describe('cross-service query composition', () => { + it('reads a child query synchronously from another service', async () => { + const sourceService = registerService(mutableRecordLookupServiceDef); + const derivedService = registerService(createDerivedBooleanFromChildQueryServiceDef()); + + expect(derivedService.queries.isEntryMarked({ entryId: 'entry-a' })).toBe(false); + + await sourceService.commands.assignRecordField({ + entryId: 'entry-a', + fieldKey: 'marker', + fieldValue: 'match', + }); + + expect(derivedService.queries.isEntryMarked({ entryId: 'entry-a' })).toBe(true); + }); + }); + + describe('loaded() drain', () => { + it('awaits a transitive dependency before returning', async () => { + const sourceService = registerService(awaitedPreloadValueServiceDef); + const derivedDef = defineService({ + id: 'internal-fixture/derived-loaded-from-source', + description: 'Reads the loaded value from the source service through a query.', + initialState: {} as Record, + queries: { + getLength: { + input: v.object({ entryId: v.string() }), + output: v.number(), + handler: (input) => { + const value = sourceService.queries.getPreloadedValue({ entryId: input.entryId }); + return value === null ? 0 : value.length; + }, + }, + }, + commands: {}, + }); + const derivedService = registerService(derivedDef); + + await expect(derivedService.queries.getLength.loaded({ entryId: 'entry-a' })).resolves.toBe( + 'preloaded'.length + ); + }); + + it('does not refire dependency loads on the final .loaded() evaluation', async () => { + const loadSpy = vi.spyOn(awaitedPreloadValueServiceDef.queries.getPreloadedValue, 'load'); + const sourceService = registerService(awaitedPreloadValueServiceDef); + const derivedDef = defineService({ + id: 'internal-fixture/derived-loaded-from-spied-source', + description: 'Reads the spied source query from a sync handler.', + initialState: {} as Record, + queries: { + getLength: { + input: v.object({ entryId: v.string() }), + output: v.number(), + handler: (input) => { + const value = sourceService.queries.getPreloadedValue({ entryId: input.entryId }); + return value === null ? 0 : value.length; + }, + }, + }, + commands: {}, + }); + const derivedService = registerService(derivedDef); + + try { + await expect(derivedService.queries.getLength.loaded({ entryId: 'entry-a' })).resolves.toBe( + 'preloaded'.length + ); + expect(loadSpy).toHaveBeenCalledTimes(1); + } finally { + loadSpy.mockRestore(); + } + }); + + it('surfaces rejections from a transitive load through .loaded()', async () => { + const failingDef = defineService({ + id: 'internal-fixture/failing-loaded', + description: 'Rejects from the load body to exercise .loaded() error propagation.', + initialState: { value: null as string | null }, + queries: { + getValue: { + input: v.undefined(), + output: v.nullable(v.string()), + handler: (_input, ctx) => ctx.self.state.value, + load: async () => { + throw new Error('boom'); + }, + }, + }, + commands: {}, + }); + const service = registerService(failingDef); + + await expect(service.queries.getValue.loaded(undefined)).rejects.toThrow('boom'); + }); + + it('breaks a load cycle without deadlocking', async () => { + const cycleDef = defineService({ + id: 'internal-fixture/load-cycle', + description: 'Two queries whose loads call each other through self.queries.', + initialState: { aDone: false, bDone: false }, + queries: { + a: { + input: v.undefined(), + output: v.boolean(), + handler: (_input, ctx) => ctx.self.state.aDone, + load: async (_input, ctx) => { + // Reading b inside a's load would normally also await b's load — but since b's load + // would in turn read a (the running ancestor), the runtime must break the cycle. + ctx.self.queries.b(undefined); + await ctx.self.commands.markA(undefined); + }, + }, + b: { + input: v.undefined(), + output: v.boolean(), + handler: (_input, ctx) => ctx.self.state.bDone, + load: async (_input, ctx) => { + ctx.self.queries.a(undefined); + await ctx.self.commands.markB(undefined); + }, + }, + }, + commands: { + markA: { + input: v.undefined(), + output: v.void(), + handler: (_input, ctx) => { + ctx.self.setState((draft) => { + draft.aDone = true; + }); + }, + }, + markB: { + input: v.undefined(), + output: v.void(), + handler: (_input, ctx) => { + ctx.self.setState((draft) => { + draft.bDone = true; + }); + }, + }, + }, + }); + const service = registerService(cycleDef); + + await expect(service.queries.a.loaded(undefined)).resolves.toBe(true); + expect(service.queries.b(undefined)).toBe(true); + }); + + it('throws OpenServiceLoadedDrainExceededError on persistent oscillation', async () => { + const oscillatingDef = defineService({ + id: 'internal-fixture/oscillating-load', + description: 'Handler reads a dynamic-keyed query on every discovery pass.', + initialState: { counter: 0 }, + queries: { + getCounter: { + input: v.undefined(), + output: v.number(), + handler: (_input, ctx) => { + // Each discovery pass produces a fresh input key, so the runtime can never observe + // a stable set of dependencies — the drain loop hits its iteration cap. + ctx.self.queries.dynamic({ tick: ctx.self.state.counter }); + return ctx.self.state.counter; + }, + }, + dynamic: { + input: v.object({ tick: v.number() }), + output: v.number(), + handler: (input) => input.tick, + load: async (_input, ctx) => { + await ctx.self.commands.bump(undefined); + }, + }, + }, + commands: { + bump: { + input: v.undefined(), + output: v.void(), + handler: (_input, ctx) => { + ctx.self.setState((draft) => { + draft.counter += 1; + }); + }, + }, + }, + }); + const service = registerService(oscillatingDef); + + await expect(service.queries.getCounter.loaded(undefined)).rejects.toMatchObject({ + fromStorybook: true, + code: 11, + }); + }); + }); + + /** + * `buildStaticFiles()` drives each snapshot through `runLoadOnce`. Load bodies normally pull + * dependencies via `ctx.self.queries.*`; those reads run `triggerLoad` during the synchronous + * prefix (before the first `await`). The root `runLoadOnce` load must therefore appear in the + * process-global in-flight map before the body starts, same as dev-server `.loaded()` does via + * `triggerLoad`. Otherwise a read that resolves to the same load key — including a deliberate + * same-query re-read or a short cycle back to the running query — starts a second `runLoadBody` + * and can double-apply command side effects in static output. + * + * The test below uses a same-query re-read as the smallest repro; reading other queries in the + * sync prefix is common too, but only same-key (or cyclic) paths hit this duplicate. + */ + describe('runLoadOnce (static snapshot loads)', () => { + it('registers the root load so a synchronous self.queries re-read does not refire the load body', async () => { + const queryName = 'getValue'; + const loadBodySpy = vi.fn(); + const bumpCommandSpy = vi.fn(); + + const staticSnapshotServiceDef = defineService({ + id: 'internal-fixture/run-load-once-sync-self-read', + description: + 'Static build fixture: load reads its own query through wrapped self.queries before awaiting.', + initialState: { count: 0 }, + queries: { + [queryName]: { + input: v.undefined(), + output: v.number(), + handler: (_input, ctx) => ctx.self.state.count, + load: async (_input, ctx) => { + loadBodySpy(); + // Mirrors static snapshot loads: sync handler read before the first await. + ctx.self.queries[queryName](undefined); + await ctx.self.commands.bump(undefined); + }, + }, + }, + commands: { + bump: { + input: v.undefined(), + output: v.void(), + handler: (_input, ctx) => { + bumpCommandSpy(); + ctx.self.setState((draft) => { + draft.count += 1; + }); + }, + }, + }, + }); + + const buildRuntime = createServiceRuntime(staticSnapshotServiceDef, { + registryApi: serviceRegistryApi, + }); + + await buildRuntime.runLoadOnce(queryName, undefined); + + // A duplicate load body would run bump twice and leave count at 2. + expect(buildRuntime.stateSignal().count).toBe(1); + expect(loadBodySpy).toHaveBeenCalledTimes(1); + expect(bumpCommandSpy).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/code/core/src/shared/open-service/service-runtime.ts b/code/core/src/shared/open-service/service-runtime.ts new file mode 100644 index 000000000000..86d316b4a7cf --- /dev/null +++ b/code/core/src/shared/open-service/service-runtime.ts @@ -0,0 +1,1074 @@ +/** + * # service-runtime + * + * Builds the runtime surface for one registered service: state signal, sync queries with + * `.loaded()` and `.subscribe()`, async commands, and the in-flight load registry that powers + * dependency tracking for `.loaded()`. + * + * ## Mental model in one paragraph + * + * A query call is synchronous: it validates input, calls the handler against current state, and + * returns the result immediately. If the query declares a `load` hook, that hook is fired + * **fire-and-forget** at the same time so state is gradually populated in the background and + * later sync calls (or subscribers) see fresher results. The async sugar `query.loaded(input)` + * is the "wait until fully loaded" form — it must guarantee that **every dependency the handler + * transitively reads is settled** before returning, even though those dependencies are not + * declared statically anywhere. That guarantee is what the drain machinery in this file exists + * to provide. + * + * ## Dependency-tracking algorithm + * + * `.loaded()` runs a *drain loop*: + * + * 1. Fire this query's own `load` and put the promise into a `LoadedSession` collector. + * 2. Repeat: + * - Await everything currently in the collector with `Promise.allSettled`. + * - Mark those load keys as `settled` for the session. + * - Run the sync handler under the session as a *discovery pass*. Every sync read of a + * dependency query (via `ctx.self.queries.*` or `ctx.getService(...).queries.*`) consults + * the module-scoped `activeHandlerLoadSession`; if that dep's load is not already + * settled or on the ancestor chain, its promise is added to the collector. + * - If the discovery pass added new entries, loop again. Otherwise, exit. + * 3. Final handler call (no session) returns the validated output. + * + * Inside a `load` body, `ctx.self.queries.*` are *wrapped* by {@link buildLoadWrappedQueries} so + * the same registration happens against a load-local collector — the load promise only resolves + * when its body **and** its own dependencies have settled. This propagates the "wait" through + * async load bodies without needing AsyncLocalStorage. + * + * ## Why each piece exists + * + * - **{@link inFlightLoads}** dedups concurrent calls for the same `(service, query, input)` so + * two consumers asking for the same data share one load. + * - **{@link LoadedSession.ancestorChain}** breaks cycles: a dep whose load key is already on the + * call chain is skipped (not added to any collector) so two queries that read each other do + * not self-deadlock. + * - **{@link LoadedSession.settledKeys}** prevents the discovery loop from refiring already + * completed loads on each iteration — without it, every reread of a dep in the handler would + * re-trigger its load and the drain loop would never converge. + * - **{@link MAX_DRAIN_ITERATIONS}** caps pathological cases (e.g. a handler reading a query with + * an ever-changing input key) so a buggy service surfaces a real error instead of hanging. + * + * ## Boundaries + * + * Cross-service `getService(...).queries.*` calls **inside a load body** are intentionally not + * tracked into the load-local collector. Authors who need cross-service deps awaited from inside + * a load should call `.loaded()` explicitly (e.g. `await ctx.getService(id).queries.foo + * .loaded(input)`). Cross-service calls from a sync handler still go through the session-aware + * path because handler reads are tracked by `activeHandlerLoadSession`, which is module-scoped + * and stable for the duration of a sync handler call. + */ +import { produce } from 'immer'; +import { computed, effect, endBatch, signal, startBatch } from 'alien-signals'; + +import { + OpenServiceInvalidStaticPathError, + OpenServiceLoadedDrainExceededError, + OpenServiceUnimplementedOperationError, +} from '../../server-errors.ts'; +import { rethrowAsync, validateSchema, validateSchemaSync } from './service-validation.ts'; +import type { + AnySchema, + Command, + CommandCtx, + CommandSelf, + Commands, + LoadCtx, + LoadSelf, + Queries, + Query, + QueryCtx, + QueryDefinition, + QuerySelf, + RuntimeService, + ServiceDefinition, + ServiceId, + ServiceInstance, + ServiceRegistryApi, +} from './types.ts'; + +type ServiceSignal = ReturnType>; +type RuntimeQueryDefinition = QueryDefinition; + +/** + * Internal runtime object returned while a service instance is being assembled. + * + * It keeps the raw signal and `self` reference available for static building and registration so + * callers can capture the post-load state snapshot without rebuilding the runtime. + */ +export type ServiceRuntime< + TState, + TQueries extends Queries, + TCommands extends Commands, +> = { + stateSignal: ServiceSignal; + commandSelf: CommandSelf; + queryCtx: QueryCtx; + loadCtxForStatic: LoadCtx; + commands: ServiceInstance['commands']; + queries: ServiceInstance['queries']; + runLoadOnce(queryName: string, validatedInput: unknown): Promise; +}; + +/** Max number of drain iterations before `.loaded()` gives up to avoid infinite oscillation. */ +const MAX_DRAIN_ITERATIONS = 32; + +/** + * One pending load promise plus the load key that identifies it for cycle and dedup checks. + * + * The key is stored alongside the promise so collectors can be drained and marked settled in one + * pass without re-deriving the key from the promise after the fact. + */ +type CollectorEntry = { key: string; promise: Promise }; + +/** + * State shared by every sync handler call inside one `.loaded()` invocation. + * + * The session tracks the set of load keys that are ancestors in the call chain (for cycle + * detection), the loads collected during this iteration (waiting to be drained), and the load + * keys that have already settled in this session so re-running the handler does not refire them. + */ +type LoadedSession = { + ancestorChain: ReadonlySet; + collector: Set; + settledKeys: Set; +}; + +/** + * Process-global registry of in-flight `load` promises keyed by `${serviceId}::${queryName}::${hash}`. + * + * The dedup is in-flight only: once a load settles, its entry is removed so a subsequent call can + * refire it. The same registry is consulted by both same-service and cross-service callers so two + * queries that depend on the same dependency share one load. + */ +const inFlightLoads = new Map>(); + +/** + * Active session for `.loaded()` while a sync handler is being re-run for dependency discovery. + * + * Default query functions consult this variable to know whether to register their load promise + * into a caller-owned collector. Sync handlers don't `await`, so the variable is stable for the + * duration of one handler call and can safely live at module scope. + */ +let activeHandlerLoadSession: LoadedSession | undefined; + +const EMPTY_SET: ReadonlySet = new Set(); + +/** + * Returns a deterministic JSON encoding so the same logical input always produces the same key. + * + * Object keys are sorted recursively so `{a:1, b:2}` and `{b:2, a:1}` hash identically. Inputs are + * expected to be JSON-safe; non-serializable values like `Date`, `Map`, or functions fall back to + * `JSON.stringify`'s defaults and may produce ambiguous keys. + */ +function stableHash(value: unknown): string { + return JSON.stringify(value, (_key, raw) => { + if (raw === undefined) { + // `JSON.stringify` would otherwise drop `undefined` from object values silently. + return '__undefined__'; + } + if (raw && typeof raw === 'object' && !Array.isArray(raw)) { + const sorted: Record = {}; + for (const k of Object.keys(raw as Record).sort()) { + sorted[k] = (raw as Record)[k]; + } + return sorted; + } + return raw; + }); +} + +function makeLoadKey(serviceId: ServiceId, queryName: string, validatedInput: unknown): string { + return `${serviceId}::${queryName}::${stableHash(validatedInput)}`; +} + +/** + * Resolves which serialized static-state file should back a query input. + * + * The returned value is a logical slash-separated store key scoped under the service id, not a raw + * filesystem path. + */ +function normalizeStaticStoragePath(serviceId: ServiceId, name: string, rawPath: string): string { + const segments = rawPath + .replaceAll('\\', '/') + .split('/') + .filter((segment) => segment.length > 0 && segment !== '.'); + + // Keep static snapshot keys relative so server-side writers can always anchor them under the + // build output, regardless of whether authors used '/', './', or Windows-style separators. + if (segments.length === 0 || segments.some((segment) => segment === '..')) { + throw new OpenServiceInvalidStaticPathError({ serviceId, name, path: rawPath }); + } + + return segments.join('/'); +} + +export function resolveStaticPath( + serviceId: ServiceId, + name: string, + queryDef: { staticPath: (input: unknown) => string }, + input: unknown +): string { + const rawPath = queryDef.staticPath(input); + const relativePath = normalizeStaticStoragePath(serviceId, name, rawPath); + + // Scope every snapshot under the service id so two services cannot collide on disk. + return `${serviceId}/${relativePath}`; +} + +/** + * Surfaces the first rejected settlement as the thrown error, aggregating the rest under `cause`. + * + * Drainers use `Promise.allSettled` so one rejected dependency does not prevent siblings from + * finishing their work; this helper preserves all rejections without losing the primary one. + */ +function surfaceRejections(settlements: PromiseSettledResult[]): void { + const rejections = settlements.filter((s): s is PromiseRejectedResult => s.status === 'rejected'); + + if (rejections.length === 0) { + return; + } + + const [first, ...rest] = rejections.map((r) => r.reason); + + if (rest.length > 0) { + if (first instanceof Error) { + const aggregated = Reflect.get(first, 'cause'); + + if (aggregated === undefined) { + try { + Reflect.set(first, 'cause', { aggregated: rest }); + } catch { + // Frozen errors keep their primary message; aggregated rejections are still observable + // through the `rest` array if a caller decides to traverse it themselves. + } + } + } + } + + throw first; +} + +/** + * Drains a collector of pending load promises until no new entries appear. + * + * Each iteration snapshots the current entries, clears the collector, awaits them with + * `Promise.allSettled`, marks their keys as settled (for caller bookkeeping), then loops if the + * settled loads' bodies surfaced more entries. Caps at `MAX_DRAIN_ITERATIONS` to avoid hanging on + * pathological oscillation. + */ +async function drainCollector( + collector: Set, + settledKeys: Set | undefined, + serviceId: ServiceId, + queryName: string +): Promise { + let iterations = 0; + + while (collector.size > 0) { + if (iterations++ > MAX_DRAIN_ITERATIONS) { + throw new OpenServiceLoadedDrainExceededError({ + serviceId, + name: queryName, + iterations: MAX_DRAIN_ITERATIONS, + }); + } + + const pending = [...collector]; + collector.clear(); + + const settlements = await Promise.allSettled(pending.map((entry) => entry.promise)); + + if (settledKeys) { + // Mark keys as settled even for rejected loads so the discovery loop does not refire them. + for (const entry of pending) { + settledKeys.add(entry.key); + } + } + + surfaceRejections(settlements); + } +} + +/** + * Creates the writable `self` object that backs every runtime ctx for one service instance. + * + * State writes are wrapped in an alien-signals batch so one command can update multiple fields + * without causing intermediate reactive notifications between writes. + */ +function createCommandSelf(stateSignal: ServiceSignal): CommandSelf { + return { + get state() { + return stateSignal(); + }, + setState(mutate) { + // Batch signal writes so one command only triggers subscribers after the full draft update. + startBatch(); + try { + stateSignal(produce(stateSignal(), mutate)); + } finally { + endBatch(); + } + }, + queries: {}, + commands: {}, + }; +} + +/** + * Builds the runtime command map from the declarative command definitions. + * + * Each runtime command validates raw caller input, invokes the handler with parsed values, and + * validates the resolved output before returning it to the caller. + */ +function buildCommands( + serviceId: ServiceId, + commands: Commands, + createCommandCtx: () => CommandCtx +): Command { + return Object.fromEntries( + Object.entries(commands).map(([name, def]) => { + return [ + name, + async (input: unknown) => { + if (!def.handler) { + throw new OpenServiceUnimplementedOperationError({ + kind: 'command', + serviceId, + name, + }); + } + + const validatedInput = await validateSchema(def.input, input, { + kind: 'command', + serviceId, + name, + phase: 'input', + }); + const output = await def.handler(validatedInput, createCommandCtx()); + + return validateSchema(def.output, output, { + kind: 'command', + serviceId, + name, + phase: 'output', + }); + }, + ]; + }) + ); +} + +/** + * Captures the per-runtime data needed by query helpers that operate across multiple queries. + * + * Bundling the references lets `createDefaultQuery`, the load body wrapper, and `.loaded()` share + * the same closure shape without each one re-deriving the per-service callbacks. + */ +type QueryRuntimeRefs = { + serviceId: ServiceId; + commandSelf: CommandSelf; + stateSignal: ServiceSignal; + registryApi: ServiceRegistryApi; + queryDefinitions: Map>; + defaultQueries: Record>; +}; + +/** + * Validates query input synchronously, falling through to the dedicated async-schema error if a + * schema returns a Promise. + */ +function validateQueryInput( + refs: QueryRuntimeRefs, + queryName: string, + queryDef: RuntimeQueryDefinition, + input: unknown +): unknown { + return validateSchemaSync(queryDef.input, input, { + kind: 'query', + serviceId: refs.serviceId, + name: queryName, + phase: 'input', + }); +} + +function validateQueryOutput( + refs: QueryRuntimeRefs, + queryName: string, + queryDef: RuntimeQueryDefinition, + output: unknown +): unknown { + return validateSchemaSync(queryDef.output, output, { + kind: 'query', + serviceId: refs.serviceId, + name: queryName, + phase: 'output', + }); +} + +/** + * Runs the query handler synchronously and validates the resolved value. + * + * The `selfQueries` parameter lets the caller swap in load-aware wrappers when running inside a + * load body or a `.loaded()` discovery pass; ordinary handler calls pass the default queries. + */ +function runHandlerSync( + refs: QueryRuntimeRefs, + queryName: string, + queryDef: RuntimeQueryDefinition, + validatedInput: unknown, + selfQueries: Record>, + getService: ServiceRegistryApi['getService'] +): unknown { + if (!queryDef.handler) { + throw new OpenServiceUnimplementedOperationError({ + kind: 'query', + serviceId: refs.serviceId, + name: queryName, + }); + } + + const handlerSelf: QuerySelf = { + get state() { + return refs.stateSignal(); + }, + queries: selfQueries, + }; + const handlerCtx: QueryCtx = { self: handlerSelf, getService }; + const result = queryDef.handler(validatedInput, handlerCtx); + + return validateQueryOutput(refs, queryName, queryDef, result); +} + +/** + * Triggers a `load` if one is not already in flight for the same key, or returns the existing + * promise so concurrent callers share the work. + * + * Returns the in-flight promise (whether newly created or reused). The body runs through + * {@link runLoadBody} and its promise is registered into {@link inFlightLoads} **before** that + * body starts; see the "register before run" comment below for why. + * + * ### Ancestor chain + * + * The parent caller passes its own ancestor chain (the load keys it is currently nested + * inside). This function extends that chain with the dependency's own key before kicking off the + * body, so any transitive read of an ancestor — e.g. `a.load` calls `b.load` which calls `a.load` + * again — short-circuits via cycle detection in {@link createDefaultQuery} and + * {@link buildLoadWrappedQueries}, rather than deadlocking on its own ancestor's promise. + * + * ### Cycle example + * + * Service exposes queries `a` and `b`; `a.load` reads `b`, `b.load` reads `a`. + * + * 1. `a.loaded()` → `triggerLoad(a, parentChain={})` → body runs with chain `{a}`. + * 2. `a.load` body calls `ctx.self.queries.b(...)` → wrapper calls + * `triggerLoad(b, parentChain={a})` → body runs with chain `{a, b}`. + * 3. `b.load` body calls `ctx.self.queries.a(...)` → wrapper calls + * `triggerLoad(a, parentChain={a, b})`. `a` is already in flight, so the existing promise is + * reused. But the wrapper sees `aKey ∈ ancestorChain` and **skips** adding it to b's local + * collector — `b` does not wait on `a`'s promise. + * 4. Both load bodies progress past their sync reads, await their commands, settle. + */ +function triggerLoad( + refs: QueryRuntimeRefs, + queryName: string, + queryDef: RuntimeQueryDefinition, + validatedInput: unknown, + loadKey: string, + parentAncestorChain: ReadonlySet +): Promise { + const existing = inFlightLoads.get(loadKey); + if (existing) { + return existing; + } + + const extendedChain = new Set(parentAncestorChain); + extendedChain.add(loadKey); + + // Register the promise into `inFlightLoads` BEFORE the load body starts so any reentrant query + // call made inside the body's synchronous prefix (the part before the body's first `await`) + // sees this load as in-flight and reuses the same promise instead of starting a duplicate run. + // The body itself is wrapped in `Promise.resolve().then(...)` to enforce a microtask boundary + // between registration and execution, which guarantees this ordering even if `runLoadBody` + // happened to be synchronous up to its first await. + const promise = Promise.resolve() + .then(() => runLoadBody(refs, queryName, queryDef, validatedInput, extendedChain)) + .finally(() => { + // Only clear the entry if it still points at this promise — another caller may have already + // overwritten it with a fresh in-flight load for the next round. + if (inFlightLoads.get(loadKey) === promise) { + inFlightLoads.delete(loadKey); + } + }); + + inFlightLoads.set(loadKey, promise); + return promise; +} + +/** + * Executes one `load` invocation with its own local collector and a wrapped `self`. + * + * Each `load` invocation gets its **own** collector and its **own** map of wrapped queries. This + * matters because the wrappers close over the collector and ancestor chain that belong to this + * particular load — a different load running concurrently for a different key has a different + * collector and a different chain, so the same `defaultQuery` cannot serve both. + * + * The wrapper around `self.queries` registers transitively triggered loads into the local + * collector. After the user's load body resolves, we still await that collector via + * {@link drainCollector} so the returned promise only resolves once every dependency the load + * body touched has also settled. That is what gives `.loaded()` its transitive guarantee through + * async load bodies: an outer caller awaiting this load promise is, by construction, also + * waiting for all descendant loads triggered by self.queries reads. + * + * Cross-service `ctx.getService(id).queries.*` calls are intentionally **not** wrapped — that + * would require recursively wrapping the registry's runtime services and would entangle load + * scoping across service boundaries. Authors must use `.loaded()` explicitly when they need a + * cross-service dependency awaited from inside a load body. + */ +async function runLoadBody( + refs: QueryRuntimeRefs, + queryName: string, + queryDef: RuntimeQueryDefinition, + validatedInput: unknown, + ancestorChain: ReadonlySet +): Promise { + if (!queryDef.load) { + return; + } + + const collector = new Set(); + const wrappedQueries = buildLoadWrappedQueries(refs, ancestorChain, collector); + const loadSelf: LoadSelf = { + get state() { + return refs.stateSignal(); + }, + queries: wrappedQueries, + commands: refs.commandSelf.commands as LoadSelf['commands'], + }; + const loadCtx: LoadCtx = { self: loadSelf, getService: refs.registryApi.getService }; + + await Promise.resolve(queryDef.load(validatedInput, loadCtx)); + await drainCollector(collector, undefined, refs.serviceId, queryName); +} + +/** + * Builds the wrapped `self.queries` map exposed inside one load body. + * + * The returned map shadows the runtime's default query map. Each wrapped call still validates + * input, fires the dependency's `load` via {@link triggerLoad}, and runs the same sync handler — + * but it also **registers the dependency's load promise into the load-local collector** so + * `runLoadBody`'s drain can await it before returning. The wrappers therefore turn an ordinary + * sync read of a dependency into "fire load + remember it for the outer drain to wait on." + * + * ### Why one map per load invocation? + * + * Two separate `load` calls (different keys, possibly different services) have different + * `ancestorChain` sets and different `collector` instances. Each wrapped function closes over + * those values, so the maps cannot be reused — they are recreated cheaply per invocation. Inside + * a single load body, all nested handler reads share **this same** wrapped map (the closure + * captures `wrappedQueries` from this scope), so transitive dependency reads continue to register + * against the same collector. + * + * ### Cycle detection + * + * If the dependency's load key is already on this load's ancestor chain, we still call + * `triggerLoad` (it returns the in-flight promise) but we **skip** adding it to the collector. + * Adding it would deadlock: the outer load's drain would wait on its own ancestor's promise, + * which is itself waiting on this load. See {@link triggerLoad}'s walkthrough for a concrete + * `a → b → a` example. + * + * ### `.loaded()` and `.subscribe` on the wrapped queries + * + * `.loaded()` on a wrapped query forwards the **current** ancestor chain so a load body author + * can write `await ctx.self.queries.foo.loaded(input)` and trust that the resulting drain + * inherits the right cycle-protection set. `.subscribe` is passed through unchanged because + * subscriptions are never part of a load drain — they have their own lifecycle. + */ +function buildLoadWrappedQueries( + refs: QueryRuntimeRefs, + ancestorChain: ReadonlySet, + collector: Set +): Record> { + const wrappedQueries: Record> = {}; + + for (const [name, queryDef] of refs.queryDefinitions) { + const defaultQuery = refs.defaultQueries[name]; + const wrapped = ((input: unknown) => { + const validatedInput = validateQueryInput(refs, name, queryDef, input); + const loadKey = makeLoadKey(refs.serviceId, name, validatedInput); + + if (queryDef.load) { + const promise = triggerLoad(refs, name, queryDef, validatedInput, loadKey, ancestorChain); + + if (!ancestorChain.has(loadKey)) { + collector.add({ key: loadKey, promise }); + } + } + + return runHandlerSync( + refs, + name, + queryDef, + validatedInput, + wrappedQueries, + refs.registryApi.getService + ); + }) as Query; + + wrapped.loaded = (input: unknown) => + runLoaded(refs, name, queryDef, input, ancestorChain) as Promise; + wrapped.subscribe = defaultQuery.subscribe; + wrappedQueries[name] = wrapped; + } + + return wrappedQueries; +} + +/** + * Implements `query.loaded(input)`. + * + * Returns a promise that resolves to the validated handler output once this query's `load` and + * every dependency the handler transitively reads has settled. + * + * ### Algorithm + * + * 1. **Setup** — validate input, build the session (`ancestorChain` extended with this load key, + * an empty `collector`, an empty `settledKeys`). The ancestor chain inherits from + * `parentAncestorChain` when this is called from inside a wrapped query (so the inner + * `.loaded()` shares cycle protection with the outer load body). + * + * 2. **Fire own load** — if this query has a `load` hook, push its in-flight promise into + * `session.collector`. Skip if the key is already on the parent's ancestor chain (we are + * inside that load already; we cannot deadlock on ourselves). + * + * 3. **Drain + discover loop**, up to {@link MAX_DRAIN_ITERATIONS} times: + * - Inner loop: while `session.collector` has entries, snapshot them, clear, await with + * `Promise.allSettled`, mark their keys in `session.settledKeys`, surface rejections. + * - Run the handler synchronously under `activeHandlerLoadSession = session` (a discovery + * pass). Sync reads of dependencies inside the handler go through {@link createDefaultQuery} + * and register any non-settled, non-ancestor load into `session.collector`. + * - If the handler threw, swallow (state might still be partial; a later iteration may fix it). + * - If the handler added nothing to the collector, we have converged — exit the loop. + * + * 4. **Return** — run the handler one final time under the same session (so dependency reads + * respect `settledKeys` and do not refire loads) and return the validated output. This is the + * value the caller sees. If state is still incomplete at this point the handler may throw, and + * that throw propagates. + * + * ### Worked example: `bar.loaded(input)` where `bar.handler` reads `foo` + * + * Assume `bar` has no `load` of its own; `foo` does. + * + * - **Setup**: session = `{ ancestorChain: {barKey}, collector: ∅, settledKeys: ∅ }`. No own load + * to fire (`bar.load` is undefined). + * - **Iteration 1**: + * - Inner drain: collector is empty, skip. + * - Discovery pass: handler runs, reads `ctx.self.queries.foo(...)`. The default `foo` query + * sees `activeHandlerLoadSession === session`, sees that `fooKey` is neither in + * `ancestorChain` nor in `settledKeys`, and fires + registers foo's load into + * `session.collector`. The handler returns (possibly with stale state). + * - `hasMoreWork = true` (collector now has one entry). + * - **Iteration 2**: + * - Inner drain: `await Promise.allSettled([fooPromise])`, mark `fooKey` settled, surface any + * rejection. + * - Discovery pass: handler runs again. The default `foo` query is now in `settledKeys`, so + * it fires nothing and the collector stays empty. + * - `hasMoreWork = false`; exit. + * - **Final**: run handler once more (state is now populated by foo's load), validate, return. + * + * The same machinery handles deeper chains — every settled load may have populated state that + * causes the handler to read **more** queries on the next iteration. The loop keeps draining + * until the read set stabilizes. + */ +async function runLoaded( + refs: QueryRuntimeRefs, + queryName: string, + queryDef: RuntimeQueryDefinition, + rawInput: unknown, + parentAncestorChain: ReadonlySet = EMPTY_SET +): Promise { + const validatedInput = validateQueryInput(refs, queryName, queryDef, rawInput); + const loadKey = makeLoadKey(refs.serviceId, queryName, validatedInput); + const ancestorChain = new Set(parentAncestorChain); + ancestorChain.add(loadKey); + + const session: LoadedSession = { + ancestorChain, + collector: new Set(), + settledKeys: new Set(), + }; + + if (queryDef.load && !parentAncestorChain.has(loadKey)) { + const promise = triggerLoad( + refs, + queryName, + queryDef, + validatedInput, + loadKey, + parentAncestorChain + ); + session.collector.add({ key: loadKey, promise }); + } + + let iterations = 0; + let hasMoreWork = true; + + // Always run at least one discovery pass even when this query has no `load` of its own — the + // handler may still call other queries whose loads need to be awaited. + while (hasMoreWork) { + if (iterations++ > MAX_DRAIN_ITERATIONS) { + throw new OpenServiceLoadedDrainExceededError({ + serviceId: refs.serviceId, + name: queryName, + iterations: MAX_DRAIN_ITERATIONS, + }); + } + + while (session.collector.size > 0) { + const pending = [...session.collector]; + session.collector.clear(); + + const settlements = await Promise.allSettled(pending.map((entry) => entry.promise)); + + // Mark keys as settled before surfacing rejections so the discovery pass below does not + // refire them even when the very first load failed. + for (const entry of pending) { + session.settledKeys.add(entry.key); + } + + surfaceRejections(settlements); + } + + // Discovery: run the handler under the session so each sync read of a dependency that is not + // settled yet (and not on the ancestor chain) gets registered into the session collector. + const previousSession = activeHandlerLoadSession; + activeHandlerLoadSession = session; + + try { + runHandlerSync( + refs, + queryName, + queryDef, + validatedInput, + refs.defaultQueries, + refs.registryApi.getService + ); + } catch { + // Handlers may throw when state isn't fully populated yet; the next drain iteration may fix + // it. The final post-loop handler call propagates any persistent failure. + } finally { + activeHandlerLoadSession = previousSession; + } + + hasMoreWork = session.collector.size > 0; + } + + const previousSession = activeHandlerLoadSession; + activeHandlerLoadSession = session; + + try { + return runHandlerSync( + refs, + queryName, + queryDef, + validatedInput, + refs.defaultQueries, + refs.registryApi.getService + ); + } finally { + activeHandlerLoadSession = previousSession; + } +} + +/** + * Creates the default query function exposed on the service runtime as `service.queries.foo`. + * + * The returned function is what consumers call directly **and** what handlers see in + * `ctx.self.queries.foo` (load bodies see a different, wrapped version — see + * {@link buildLoadWrappedQueries}). It behaves as follows: + * + * 1. Validate input synchronously. Throws on validation failure. + * 2. If this query has a `load` hook, decide whether to fire it and where to register the + * promise: + * - If we're inside a `.loaded()` discovery pass (`activeHandlerLoadSession` set) and the + * load key is either on the ancestor chain (cycle protection) **or** already in + * `settledKeys` (already settled this session, do not refire), **skip** entirely. + * - Otherwise, call {@link triggerLoad} to either start a fresh load or join an in-flight one. + * - Then, if a session is active, push the promise into `session.collector` so the outer + * drain loop awaits it. If no session is active (ordinary consumer call), attach a + * `.catch(rethrowAsync)` so the fire-and-forget rejection still surfaces. + * 3. Run the handler synchronously with the runtime's default queries, validate output, return. + * + * The synchronous return is the core API improvement — callers who want "current best" pay no + * latency, callers who want "fully loaded" use `.loaded()`, and subscribers see emissions as + * state changes. + */ +function createDefaultQuery( + refs: QueryRuntimeRefs, + queryName: string, + queryDef: RuntimeQueryDefinition +): Query { + const query = ((input: unknown) => { + const validatedInput = validateQueryInput(refs, queryName, queryDef, input); + const loadKey = makeLoadKey(refs.serviceId, queryName, validatedInput); + + if (queryDef.load) { + const session = activeHandlerLoadSession; + // Three cases where we skip firing/registering: + // - session set and key on ancestor chain: cycle, would deadlock + // - session set and key in settledKeys: already loaded this session, refiring would + // prevent the discovery loop from ever converging + // - (the no-session case is *not* a skip — we still want fire-and-forget below) + const skip = session + ? session.ancestorChain.has(loadKey) || session.settledKeys.has(loadKey) + : false; + + if (!skip) { + const promise = triggerLoad( + refs, + queryName, + queryDef, + validatedInput, + loadKey, + session?.ancestorChain ?? EMPTY_SET + ); + + if (session) { + // Inside a `.loaded()` discovery pass: register so the outer drain awaits us. + session.collector.add({ key: loadKey, promise }); + } else { + // Ordinary consumer call: fire-and-forget. Surface rejections via the global handler + // so a buggy load doesn't fail silently. + promise.catch(rethrowAsync); + } + } + } + + return runHandlerSync( + refs, + queryName, + queryDef, + validatedInput, + refs.defaultQueries, + refs.registryApi.getService + ); + }) as Query; + + query.loaded = (input: unknown) => runLoaded(refs, queryName, queryDef, input); + query.subscribe = (input: unknown, callback: (value: unknown) => void): (() => void) => + subscribeToQuery(refs, queryName, queryDef, input, callback); + + return query; +} + +/** + * Subscribes to a query by running its handler under an alien-signals `computed()` and `effect()`. + * + * The first emission is deferred to a microtask so callers always receive their unsubscribe handle + * before the callback fires. The runtime kicks `load` off in the background but does not wait for + * it — subscribers see the current state immediately and a follow-up emission once the load + * settles and tracked state changes. + */ +function subscribeToQuery( + refs: QueryRuntimeRefs, + queryName: string, + queryDef: RuntimeQueryDefinition, + rawInput: unknown, + callback: (value: unknown) => void +): () => void { + let active = true; + let teardown: (() => void) | undefined; + + Promise.resolve().then(() => { + if (!active) { + return; + } + + let validatedInput: unknown; + try { + validatedInput = validateQueryInput(refs, queryName, queryDef, rawInput); + } catch (error) { + rethrowAsync(error); + return; + } + + if (queryDef.load) { + const loadKey = makeLoadKey(refs.serviceId, queryName, validatedInput); + const pendingLoad = triggerLoad( + refs, + queryName, + queryDef, + validatedInput, + loadKey, + EMPTY_SET + ); + // Subscribers do not block on rejections, but we still want them visible to global handlers. + pendingLoad.catch(rethrowAsync); + } + + const comp = computed(() => + runHandlerSync( + refs, + queryName, + queryDef, + validatedInput, + refs.defaultQueries, + refs.registryApi.getService + ) + ); + teardown = effect(() => { + let value: unknown; + try { + value = comp(); + } catch (error) { + rethrowAsync(error); + return; + } + + if (active) { + callback(value); + } + }); + }); + + return () => { + active = false; + teardown?.(); + }; +} + +/** Builds the runtime query map for one service runtime. */ +function buildQueries( + refs: QueryRuntimeRefs +): Record> { + const result: Record> = {}; + + for (const [name, queryDef] of refs.queryDefinitions) { + result[name] = createDefaultQuery(refs, name, queryDef); + } + + return result; +} + +/** + * Creates the full runtime backing for a service definition. + * + * Callers must supply the registry API that query and command contexts should expose. + */ +export function createServiceRuntime< + TState, + TQueries extends Queries, + TCommands extends Commands, +>( + def: ServiceDefinition, + runtimeOptions: { + registryApi: ServiceRegistryApi; + }, + initialState: TState = def.initialState +): ServiceRuntime { + // The signal is the single source of truth that query computations subscribe to. + const stateSignal = signal(initialState); + const commandSelf = createCommandSelf(stateSignal); + const { registryApi } = runtimeOptions; + const createCommandCtx = (): CommandCtx => ({ + self: commandSelf, + getService: registryApi.getService, + }); + + const commands = buildCommands(def.id, def.commands, createCommandCtx) as ServiceInstance< + TState, + TQueries, + TCommands + >['commands']; + commandSelf.commands = commands as CommandSelf['commands']; + + const queryDefinitions = new Map>( + Object.entries(def.queries) as [string, RuntimeQueryDefinition][] + ); + const defaultQueries: Record> = {}; + const refs: QueryRuntimeRefs = { + serviceId: def.id, + commandSelf, + stateSignal, + registryApi, + queryDefinitions, + defaultQueries, + }; + + // Build queries after commands so handler/load ctx surfaces resolve the same command map. + const builtQueries = buildQueries(refs); + for (const [name, query] of Object.entries(builtQueries)) { + defaultQueries[name] = query; + } + commandSelf.queries = defaultQueries; + + const queries = defaultQueries as ServiceInstance['queries']; + const queryCtxSelf: QuerySelf = { + get state() { + return stateSignal(); + }, + queries: defaultQueries, + }; + const queryCtx: QueryCtx = { self: queryCtxSelf, getService: registryApi.getService }; + const loadCtxForStatic: LoadCtx = { + self: { + get state() { + return stateSignal(); + }, + queries: defaultQueries, + commands: commands as LoadSelf['commands'], + }, + getService: registryApi.getService, + }; + + /** + * Runs one query's `load` body against this runtime instance, drained to completion. + * + * Used by the static build pipeline to populate state for a single input without holding the + * load in the in-flight registry afterwards. + */ + const runLoadOnce = async (queryName: string, validatedInput: unknown): Promise => { + const queryDef = queryDefinitions.get(queryName); + + if (!queryDef || !queryDef.load) { + return; + } + + const loadKey = makeLoadKey(def.id, queryName, validatedInput); + const ancestorChain = new Set([loadKey]) as ReadonlySet; + + // Register this runtime's root load without joining an unrelated in-flight entry from another + // runtime instance (e.g. the live singleton during parallel staticInputs resolution). + const previous = inFlightLoads.get(loadKey); + const promise = Promise.resolve() + .then(() => runLoadBody(refs, queryName, queryDef, validatedInput, ancestorChain)) + .finally(() => { + if (inFlightLoads.get(loadKey) === promise) { + if (previous) { + inFlightLoads.set(loadKey, previous); + } else { + inFlightLoads.delete(loadKey); + } + } + }); + + inFlightLoads.set(loadKey, promise); + await promise; + }; + + return { + stateSignal, + commandSelf, + queryCtx, + loadCtxForStatic, + commands, + queries, + runLoadOnce, + }; +} + +/** Re-export so external modules can address the in-flight load registry for tests if needed. */ +export const __internalInFlightLoads = inFlightLoads; + +/** Type referenced from the registry surface for cross-service callers. */ +export type { RuntimeService }; diff --git a/code/core/src/shared/open-service/service-validation.test.ts b/code/core/src/shared/open-service/service-validation.test.ts new file mode 100644 index 000000000000..2370f31ce488 --- /dev/null +++ b/code/core/src/shared/open-service/service-validation.test.ts @@ -0,0 +1,215 @@ +import * as v from 'valibot'; +import { dedent } from 'ts-dedent'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { z } from 'zod'; + +import { defineService } from './service-definition.ts'; +import { clearRegistry, registerService } from './server.ts'; +import { buildStaticFiles } from './server.ts'; +import { + createInvalidCommandOutputServiceDef, + createInvalidQueryOutputServiceDef, + createInvalidStaticInputServiceDef, + mutableRecordLookupServiceDef, +} from './fixtures.ts'; + +/** + * Asserts the exact validation text we document for callers. + * + * `vi.defineHelper()` keeps failure stacks anchored at the individual test callsite. The helper + * accepts both sync and async producers so it can target sync queries and async commands with the + * same assertion shape. + */ +const expectValidationMessage = vi.defineHelper( + async (run: () => unknown, expectedMessage: string): Promise => { + await expect(async () => { + const result = run(); + if (result instanceof Promise) { + await result; + } + }).rejects.toMatchObject({ + fromStorybook: true, + code: 5, + message: expectedMessage, + }); + } +); + +afterEach(() => { + clearRegistry(); +}); + +describe('service validation', () => { + it('shows the full actionable message for invalid query input', async () => { + const service = registerService(mutableRecordLookupServiceDef); + + await expectValidationMessage( + () => service.queries.getRecordFields({} as unknown as { entryId: string }), + dedent` + Invalid input for query "internal-fixture/mutable-record-lookup.getRecordFields": + entryId: Invalid key: Expected "entryId" but received undefined + ` + ); + }); + + it('shows the full actionable message for invalid query output', async () => { + const service = registerService(createInvalidQueryOutputServiceDef()); + + await expectValidationMessage( + () => service.queries.getBrokenValue(undefined), + dedent` + Invalid output for query "internal-fixture/invalid-query-output.getBrokenValue": + Invalid type: Expected string but received 42 + ` + ); + }); + + it('shows the full actionable message for invalid command input', async () => { + const service = registerService(mutableRecordLookupServiceDef); + + await expectValidationMessage( + () => + service.commands.assignRecordField({ + entryId: 'entry-a', + fieldKey: 'marker', + fieldValue: 1, + } as unknown as { + entryId: string; + fieldKey: string; + fieldValue: string; + }), + dedent` + Invalid input for command "internal-fixture/mutable-record-lookup.assignRecordField": + fieldValue: Invalid type: Expected string but received 1 + ` + ); + }); + + it('shows the full actionable message for invalid command output', async () => { + const service = registerService(createInvalidCommandOutputServiceDef()); + + await expectValidationMessage( + () => service.commands.runBrokenCommand(undefined), + dedent` + Invalid output for command "internal-fixture/invalid-command-output.runBrokenCommand": + Invalid type: Expected string but received 42 + ` + ); + }); + + it('shows the full actionable message for invalid static load input', async () => { + registerService(createInvalidStaticInputServiceDef()); + + await expectValidationMessage( + () => buildStaticFiles(), + dedent` + Invalid input for query "internal-fixture/invalid-static-input.getPreloadedValue": + entryId: Invalid key: Expected "entryId" but received undefined + ` + ); + }); + + it('shows nested field paths for validation issues inside arrays and objects', async () => { + const service = registerService( + defineService({ + id: 'internal-fixture/nested-query-output', + initialState: {} as Record, + queries: { + getBrokenTree: { + input: v.undefined(), + output: v.object({ + items: v.array( + v.object({ + name: v.string(), + }) + ), + }), + handler: () => ({ + items: [{ name: 1 as unknown as string }] as Array<{ name: string }>, + }), + }, + }, + commands: {}, + }) + ); + + await expectValidationMessage( + () => service.queries.getBrokenTree(undefined), + dedent` + Invalid output for query "internal-fixture/nested-query-output.getBrokenTree": + items[0].name: Invalid type: Expected string but received 1 + ` + ); + }); + + it('wraps zod schema issues in the same actionable validation error shape', async () => { + const service = registerService( + defineService({ + id: 'internal-fixture/zod-query-input', + initialState: {} as Record, + queries: { + getGreeting: { + input: z.object({ + name: z.string().min(2, 'Name must be at least 2 characters'), + }), + output: z.string(), + handler: ({ name }) => `Hello ${name}`, + }, + }, + commands: {}, + }) + ); + + await expectValidationMessage( + () => service.queries.getGreeting({ name: 'x' }), + dedent` + Invalid input for query "internal-fixture/zod-query-input.getGreeting": + name: Name must be at least 2 characters + ` + ); + }); + + it('accepts unexpected query input fields when the schema allows them', () => { + const service = registerService(mutableRecordLookupServiceDef); + + expect( + service.queries.getRecordFields({ + entryId: 'entry-a', + unexpected: 'extra', + } as unknown as { entryId: string }) + ).toBeNull(); + }); + + it('accepts unexpected command input fields when the schema allows them', async () => { + const service = registerService(mutableRecordLookupServiceDef); + + await expect( + service.commands.assignRecordField({ + entryId: 'entry-a', + fieldKey: 'marker', + fieldValue: 'match', + unexpected: 'extra', + } as unknown as { + entryId: string; + fieldKey: string; + fieldValue: string; + }) + ).resolves.toBeUndefined(); + + expect(service.queries.getRecordFields({ entryId: 'entry-a' })).toEqual({ + marker: 'match', + }); + }); + + it('stores optional description metadata on services, queries, and commands', () => { + expect(mutableRecordLookupServiceDef.description).toBe( + 'Provides a mutable record lookup keyed by entry id.' + ); + expect(mutableRecordLookupServiceDef.queries.getRecordFields.description).toBe( + 'Returns all stored fields for one entry, or null when absent.' + ); + expect(mutableRecordLookupServiceDef.commands.assignRecordField.description).toBe( + 'Writes one field value onto the selected entry.' + ); + }); +}); diff --git a/code/core/src/shared/open-service/service-validation.ts b/code/core/src/shared/open-service/service-validation.ts new file mode 100644 index 000000000000..d4e7df7e186e --- /dev/null +++ b/code/core/src/shared/open-service/service-validation.ts @@ -0,0 +1,65 @@ +import type { StandardSchemaV1 } from '@standard-schema/spec'; + +import { OpenServiceAsyncSchemaError, OpenServiceValidationError } from '../../server-errors.ts'; +import type { AnySchema } from './types.ts'; +import type { ValidationMeta } from './errors.ts'; + +/** + * Re-throws asynchronous subscription failures on the microtask queue so they are not silently + * swallowed by promise chains started from reactive listeners. + */ +export function rethrowAsync(error: unknown): void { + queueMicrotask(() => { + throw error; + }); +} + +/** + * Validates a value with a Standard Schema and returns the parsed output value. + * + * Any schema issues are wrapped in `OpenServiceValidationError`, which standardizes the operation + * metadata while preserving the schema's own expectation text for the actionable details. + */ +export async function validateSchema( + schema: TSchema, + value: unknown, + meta: Omit +): Promise> { + const validationResult = await schema['~standard'].validate(value); + + if (validationResult.issues) { + throw new OpenServiceValidationError({ ...meta, issues: validationResult.issues }); + } + + return validationResult.value; +} + +/** + * Synchronous variant of `validateSchema` used on the sync query call path. + * + * Query input and output schemas must produce sync validation results so the public `query(input)` + * function can return a value immediately. If a schema accidentally returns a Promise, the runtime + * surfaces a dedicated error instead of silently switching to async behavior. + */ +export function validateSchemaSync( + schema: TSchema, + value: unknown, + meta: Omit +): StandardSchemaV1.InferOutput { + const validationResult = schema['~standard'].validate(value); + + if (validationResult instanceof Promise) { + throw new OpenServiceAsyncSchemaError({ + kind: meta.kind, + serviceId: meta.serviceId, + name: meta.name, + phase: meta.phase, + }); + } + + if (validationResult.issues) { + throw new OpenServiceValidationError({ ...meta, issues: validationResult.issues }); + } + + return validationResult.value; +} diff --git a/code/core/src/shared/open-service/types.ts b/code/core/src/shared/open-service/types.ts new file mode 100644 index 000000000000..3fe77efe6a6f --- /dev/null +++ b/code/core/src/shared/open-service/types.ts @@ -0,0 +1,370 @@ +import type { StandardSchemaV1 } from '@standard-schema/spec'; + +/** File map used by static snapshot building. Each key represents one serialized state snapshot. */ +export type StaticStore = Record; + +/** Generic Standard Schema constraint used across open-service definitions. */ +export type AnySchema = StandardSchemaV1; + +/** Stable alias for service identifiers across definition, runtime, and registration APIs. */ +export type ServiceId = string; + +/** Public schema shape exposed when describing a schema-backed service contract. */ +export type SchemaDescriptor = AnySchema; + +/** Convenience alias for declaring Standard Schema compatible input/output contracts. */ +export type Schema = StandardSchemaV1; + +/** Raw caller-facing value type accepted by a schema-backed operation. */ +export type InferSchemaInput = StandardSchemaV1.InferInput; + +/** Parsed value type produced by a schema after validation. */ +export type InferSchemaOutput = StandardSchemaV1.InferOutput; + +/** + * Named schema maps are the core inference surface for inline open-service authoring. + * + * `defineService()` infers one input-schema map and one output-schema map per operation family + * (queries and commands). Keeping those maps separate gives TypeScript a place to correlate the + * `input` and `output` properties of each inline object before it contextually types sibling + * callbacks like `handler`, `load`, `staticPath`, and `staticInputs`. + */ +export type OperationInputSchemas = Record; + +/** + * Output-schema maps must stay key-aligned with their input-schema map. + * + * The authoring helper uses this alias instead of a plain `Record` so each + * operation key retains its own input/output schema pair during inference. + */ +export type MatchingOutputSchemas = { + [TKey in keyof TInputSchemas]: AnySchema; +}; + +/** + * Internal utility used to keep handler maps assignable without collapsing everything to `unknown`. + */ +type BivariantCallback = { + bivarianceHack(...args: TArgs): TResult; +}['bivarianceHack']; + +/** Runtime shape shared by all command collections after they are built. */ +export type Command = Record Promise>; + +/** + * Runtime command map derived directly from the inferred command schema maps. + * + * Queries only need command-call typing, not the full command definition objects, so this helper + * keeps query contexts readable while still preserving exact input/output types per command. + */ +export type CommandFunctions< + TCommandInputSchemas extends OperationInputSchemas, + TCommandOutputSchemas extends MatchingOutputSchemas, +> = { + [TKey in keyof TCommandInputSchemas]: BivariantCallback< + [input: InferSchemaInput], + Promise> + >; +}; + +/** + * Public runtime shape of a query. + * + * The primary call returns the handler result synchronously. Calling it also triggers `load` in + * the background, deduped while another load for the same input is already in flight. Use + * `.loaded(input)` when the caller wants to await the full load (including transitive dependencies) + * before reading. Use `.subscribe(input, callback)` to receive the current value immediately + * (synchronously) and again after the background `load` settles (deduped while in-flight), plus + * further emissions whenever tracked state changes. + */ +export type Query = { + (input: TInput): TOutput; + loaded(input: TInput): Promise; + subscribe(input: TInput, callback: (value: TOutput) => void): () => void; +}; + +/** + * Read-only service handle exposed to query handlers. + * + * Query handlers are strict readers: they can read state and call sibling queries, but they cannot + * mutate state and cannot invoke commands. Mutations belong in commands; load-side preparation + * belongs in `load`. + */ +export type QuerySelf = { + readonly state: TState; + queries: Record>; +}; + +/** + * Load handle exposed to `load` functions. + * + * `load` may read state and queries, and may invoke declared commands to mutate state. It does + * not receive `setState` directly — all writes must flow through commands so authors keep one + * documented mutation surface per service. + */ +export type LoadSelf< + TState = unknown, + TCommandInputSchemas extends OperationInputSchemas = OperationInputSchemas, + TCommandOutputSchemas extends MatchingOutputSchemas = + MatchingOutputSchemas, +> = QuerySelf & { + commands: CommandFunctions; +}; + +/** + * Mutable service handle exposed to command handlers. + * + * Commands receive both `setState` for direct draft mutation and `commands` so one command can + * delegate to another within the same service. + */ +export type CommandSelf< + TState = unknown, + TCommandInputSchemas extends OperationInputSchemas = OperationInputSchemas, + TCommandOutputSchemas extends MatchingOutputSchemas = + MatchingOutputSchemas, +> = LoadSelf & { + setState(mutate: (draft: TState) => void): void; +}; + +export type ServiceSummary = { + id: ServiceId; + description?: string; + queryNames: string[]; + commandNames: string[]; +}; + +export type OperationDescriptor = { + name: string; + description?: string; + input: SchemaDescriptor; + output: SchemaDescriptor; + /** Present when the query declares `staticPath` at definition time. */ + staticPath?: true; +}; + +export type ServiceDescriptor = { + id: ServiceId; + description?: string; + queries: Record; + commands: Record; +}; + +/** Context passed to query handlers. */ +export type QueryCtx = { + self: QuerySelf; + getService: ServiceRegistryApi['getService']; +}; + +/** Context passed to `load` functions and static-input enumerators. */ +export type LoadCtx< + TState, + TCommandInputSchemas extends OperationInputSchemas = OperationInputSchemas, + TCommandOutputSchemas extends MatchingOutputSchemas = + MatchingOutputSchemas, +> = { + self: LoadSelf; + getService: ServiceRegistryApi['getService']; +}; + +/** Static input enumerator stored on registered definitions; always receives load context. */ +export type RegisteredStaticInputs = BivariantCallback< + [ctx: LoadCtx], + unknown[] | Promise +>; + +/** Context passed to command handlers. */ +export type CommandCtx< + TState, + TCommandInputSchemas extends OperationInputSchemas = OperationInputSchemas, + TCommandOutputSchemas extends MatchingOutputSchemas = + MatchingOutputSchemas, +> = { + self: CommandSelf; + getService: ServiceRegistryApi['getService']; +}; + +/** + * Declarative definition for one query. + * + * Queries validate caller input synchronously, run a synchronous read-only handler, and validate + * the resolved output. The optional `load` hook is fired in the background on each query call + * (deduped while in flight) so subscribers and `.loaded()` callers see fully populated state. + * + * Queries that participate in static JSON generation declare `staticPath` at definition time. + * `staticInputs` may also be declared here when the input list has no runtime dependencies; inputs + * that need registry or story-index context belong in server registration instead. + */ +export type QueryDefinition< + TState, + TInputSchema extends AnySchema, + TOutputSchema extends AnySchema, + TCommandInputSchemas extends OperationInputSchemas = OperationInputSchemas, + TCommandOutputSchemas extends MatchingOutputSchemas = + MatchingOutputSchemas, +> = { + description?: string; + input: TInputSchema; + output: TOutputSchema; + /** Logical path for the serialized state snapshot, relative to this service's output folder. */ + staticPath?: BivariantCallback<[input: InferSchemaOutput], string>; + /** Dependency-free static build inputs declared alongside the public contract. */ + staticInputs?: BivariantCallback< + [], + InferSchemaInput[] | Promise[]> + >; + handler?: BivariantCallback< + [input: InferSchemaOutput, ctx: QueryCtx], + InferSchemaInput + >; + load?: BivariantCallback< + [ + input: InferSchemaOutput, + ctx: LoadCtx, + ], + void | Promise + >; +}; + +/** + * Declarative definition for one command. + * + * Commands validate caller input, run against a mutable context, and validate the resolved output. + */ +export type CommandDefinition< + TState, + TInputSchema extends AnySchema, + TOutputSchema extends AnySchema, +> = { + description?: string; + input: TInputSchema; + output: TOutputSchema; + handler?: BivariantCallback< + [input: InferSchemaOutput, ctx: CommandCtx], + InferSchemaInput | Promise> + >; +}; + +/** Internal structural constraint used to store any query definition in a record. */ +export type AnyQueryDefinition = { + description?: string; + input: AnySchema; + output: AnySchema; + staticPath?: BivariantCallback<[input: unknown], string>; + staticInputs?: RegisteredStaticInputs; + handler?: BivariantCallback<[input: unknown, ctx: QueryCtx], unknown>; + load?: BivariantCallback<[input: unknown, ctx: LoadCtx], void | Promise>; +}; + +/** Internal structural constraint used to store any command definition in a record. */ +export type AnyCommandDefinition = { + description?: string; + input: AnySchema; + output: AnySchema; + handler?: BivariantCallback< + [input: unknown, ctx: CommandCtx], + unknown | Promise + >; +}; + +/** Named query map attached to a service definition. */ +export type Queries = Record>; +/** Named command map attached to a service definition. */ +export type Commands = Record>; + +/** Top-level description of a service: identity, initial state, queries, and commands. */ +export type ServiceDefinition< + TState, + TQueries extends Queries, + TCommands extends Commands, +> = { + id: ServiceId; + description?: string; + initialState: TState; + queries: TQueries; + commands: TCommands; +}; + +/** Structural constraint for any service definition stored in the registry. */ +export type AnyServiceDefinition = ServiceDefinition, Commands>; + +/** Runtime service instance derived from a `ServiceDefinition`. */ +export type ServiceInstance< + TState, + TQueries extends Queries, + TCommands extends Commands, +> = { + queries: { + [TKey in keyof TQueries]: TQueries[TKey] extends { + input: infer TInputSchema extends AnySchema; + output: infer TOutputSchema extends AnySchema; + } + ? Query, InferSchemaOutput> + : never; + }; + commands: { + [TKey in keyof TCommands]: TCommands[TKey] extends { + input: infer TInputSchema extends AnySchema; + output: infer TOutputSchema extends AnySchema; + } + ? (input: InferSchemaInput) => Promise> + : never; + }; +}; + +/** Runtime instance type recovered from one authored service definition. */ +export type ServiceInstanceOf = + TDefinition extends ServiceDefinition + ? ServiceInstance + : never; + +export interface ServiceRegistryApi { + listServices(): Promise; + describeService(serviceId: ServiceId): Promise; + getService(serviceId: ServiceId): RuntimeService; + getService( + serviceId: ServiceId + ): ServiceInstanceOf; +} + +export type RuntimeService = ServiceInstance, Commands> & + ServiceRegistryApi; + +export type ServiceQueryRegistration> = Pick< + TQuery, + 'handler' | 'load' +> & { + /** Static build inputs that may depend on registry or other server context. */ + staticInputs?: RegisteredStaticInputs; +}; + +export type ServiceCommandRegistration< + TState, + TCommand extends AnyCommandDefinition, +> = Pick; + +export type ServiceRegistrationOptions< + TState, + TQueries extends Queries, + TCommands extends Commands, +> = { + queries?: { + [TKey in keyof TQueries]?: ServiceQueryRegistration; + }; + commands?: { + [TKey in keyof TCommands]?: ServiceCommandRegistration; + }; +}; + +export type ServerServiceRegistration< + TState, + TQueries extends Queries, + TCommands extends Commands, +> = { + definition: ServiceDefinition; +} & ServiceRegistrationOptions; + +/** One completed static build task before it is merged into the final store map. */ +export type BuildTaskResult = { + path: string; + state: unknown; +}; diff --git a/code/core/src/types/modules/addons.ts b/code/core/src/types/modules/addons.ts index 31af4e84b326..c2abc0a134c1 100644 --- a/code/core/src/types/modules/addons.ts +++ b/code/core/src/types/modules/addons.ts @@ -2,7 +2,7 @@ import type { FC, PropsWithChildren, ReactElement, ReactNode } from 'react'; import type { RenderData as RouterData } from '../../router/types.ts'; import type { ThemeVars } from '../../theming/types.ts'; -import type { API_LayoutCustomisations, API_SidebarOptions } from './api.ts'; +import type { API_Layout, API_LayoutCustomisations, API_SidebarOptions, API_UI } from './api.ts'; import type { API_HashEntry, API_StoryEntry } from './api-stories.ts'; import type { Args, @@ -479,15 +479,13 @@ export interface Addon_ToolbarConfig { } export interface Addon_Config { theme?: ThemeVars; - layoutCustomisations?: { - showPanel?: API_LayoutCustomisations['showPanel']; - showSidebar?: API_LayoutCustomisations['showSidebar']; - showToolbar?: API_LayoutCustomisations['showToolbar']; - }; + layout?: Partial; + layoutCustomisations?: Partial; toolbar?: { [id: string]: Addon_ToolbarConfig; }; sidebar?: API_SidebarOptions; + ui?: Partial; [key: string]: any; } diff --git a/code/core/src/types/modules/api.ts b/code/core/src/types/modules/api.ts index 0e94d96699b6..2efb562885be 100644 --- a/code/core/src/types/modules/api.ts +++ b/code/core/src/types/modules/api.ts @@ -48,9 +48,10 @@ export interface API_Provider { getConfig(): { sidebar?: API_SidebarOptions; theme?: ThemeVars; + selectedPanel?: string; StoryMapper?: API_StoryMapper; [k: string]: any; - } & Partial; + }; [key: string]: any; } @@ -63,17 +64,6 @@ export type API_IframeRenderer = ( queryParams: Record ) => ReactElement | null; -export interface API_UIOptions { - name?: string; - url?: string; - goFullScreen: boolean; - showStoriesPanel: boolean; - showAddonPanel: boolean; - addonPanelInRight: boolean; - theme?: ThemeVars; - selectedPanel?: string; -} - export type FilterFunction = (entry: API_PreparedIndexEntry, excluded?: boolean) => boolean; export interface API_Layout { @@ -91,6 +81,8 @@ export interface API_Layout { rightPanelWidth: number; }; panelPosition: API_PanelPositions; + showNav: boolean; + showPanel: boolean; showTabs: boolean; showToolbar: boolean; } diff --git a/code/core/src/types/modules/core-common.ts b/code/core/src/types/modules/core-common.ts index 0a24d06dd756..ce9188e48e19 100644 --- a/code/core/src/types/modules/core-common.ts +++ b/code/core/src/types/modules/core-common.ts @@ -113,6 +113,7 @@ export interface Presets { config?: StorybookConfigRaw['staticDirs'], args?: any ): Promise; + apply(extension: 'services', config?: StorybookConfigRaw['services'], args?: any): Promise; /** The second and third parameter are not needed. And make type inference easier. */ apply(extension: T): Promise; @@ -638,6 +639,8 @@ export interface StorybookConfigRaw { managerHead?: string; tags?: TagsOptions; + + services?: void; } /** @@ -743,6 +746,9 @@ export interface StorybookConfig { /** Configure non-standard tag behaviors */ tags?: PresetValue; + + /** Run open-service registration side effects for the server environment. */ + services?: PresetValue; } export type PresetValue = T | ((config: T, options: Options) => T | Promise); diff --git a/code/frameworks/nextjs-vite/template/cli/js/Button.stories.js b/code/frameworks/nextjs-vite/template/cli/js/Button.stories.js index 86aa400d151e..8bcec4c27b19 100644 --- a/code/frameworks/nextjs-vite/template/cli/js/Button.stories.js +++ b/code/frameworks/nextjs-vite/template/cli/js/Button.stories.js @@ -12,7 +12,7 @@ export default { }, // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs tags: ['autodocs'], - // More on argTypes: https://storybook.js.org/docs/api/argtypes + // More on argTypes: https://storybook.js.org/docs/api/arg-types argTypes: { backgroundColor: { control: 'color' }, }, diff --git a/code/frameworks/nextjs-vite/template/cli/ts/Button.stories.ts b/code/frameworks/nextjs-vite/template/cli/ts/Button.stories.ts index 89e55d680165..68360276a4e1 100644 --- a/code/frameworks/nextjs-vite/template/cli/ts/Button.stories.ts +++ b/code/frameworks/nextjs-vite/template/cli/ts/Button.stories.ts @@ -14,7 +14,7 @@ const meta = { }, // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs tags: ['autodocs'], - // More on argTypes: https://storybook.js.org/docs/api/argtypes + // More on argTypes: https://storybook.js.org/docs/api/arg-types argTypes: { backgroundColor: { control: 'color' }, }, diff --git a/code/frameworks/nextjs/template/cli/js/Button.stories.js b/code/frameworks/nextjs/template/cli/js/Button.stories.js index 86aa400d151e..8bcec4c27b19 100644 --- a/code/frameworks/nextjs/template/cli/js/Button.stories.js +++ b/code/frameworks/nextjs/template/cli/js/Button.stories.js @@ -12,7 +12,7 @@ export default { }, // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs tags: ['autodocs'], - // More on argTypes: https://storybook.js.org/docs/api/argtypes + // More on argTypes: https://storybook.js.org/docs/api/arg-types argTypes: { backgroundColor: { control: 'color' }, }, diff --git a/code/frameworks/nextjs/template/cli/ts/Button.stories.ts b/code/frameworks/nextjs/template/cli/ts/Button.stories.ts index 7c193ddd2325..18d6b74ea402 100644 --- a/code/frameworks/nextjs/template/cli/ts/Button.stories.ts +++ b/code/frameworks/nextjs/template/cli/ts/Button.stories.ts @@ -14,7 +14,7 @@ const meta = { }, // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs tags: ['autodocs'], - // More on argTypes: https://storybook.js.org/docs/api/argtypes + // More on argTypes: https://storybook.js.org/docs/api/arg-types argTypes: { backgroundColor: { control: 'color' }, }, diff --git a/code/frameworks/react-vite/template/cli/js/Button.stories.js b/code/frameworks/react-vite/template/cli/js/Button.stories.js index 86aa400d151e..8bcec4c27b19 100644 --- a/code/frameworks/react-vite/template/cli/js/Button.stories.js +++ b/code/frameworks/react-vite/template/cli/js/Button.stories.js @@ -12,7 +12,7 @@ export default { }, // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs tags: ['autodocs'], - // More on argTypes: https://storybook.js.org/docs/api/argtypes + // More on argTypes: https://storybook.js.org/docs/api/arg-types argTypes: { backgroundColor: { control: 'color' }, }, diff --git a/code/frameworks/react-vite/template/cli/ts/Button.stories.ts b/code/frameworks/react-vite/template/cli/ts/Button.stories.ts index b4381b29b832..f74e4b0528d0 100644 --- a/code/frameworks/react-vite/template/cli/ts/Button.stories.ts +++ b/code/frameworks/react-vite/template/cli/ts/Button.stories.ts @@ -14,7 +14,7 @@ const meta = { }, // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs tags: ['autodocs'], - // More on argTypes: https://storybook.js.org/docs/api/argtypes + // More on argTypes: https://storybook.js.org/docs/api/arg-types argTypes: { backgroundColor: { control: 'color' }, }, diff --git a/code/frameworks/react-webpack5/template/cli/js/Button.stories.js b/code/frameworks/react-webpack5/template/cli/js/Button.stories.js index 86aa400d151e..8bcec4c27b19 100644 --- a/code/frameworks/react-webpack5/template/cli/js/Button.stories.js +++ b/code/frameworks/react-webpack5/template/cli/js/Button.stories.js @@ -12,7 +12,7 @@ export default { }, // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs tags: ['autodocs'], - // More on argTypes: https://storybook.js.org/docs/api/argtypes + // More on argTypes: https://storybook.js.org/docs/api/arg-types argTypes: { backgroundColor: { control: 'color' }, }, diff --git a/code/frameworks/react-webpack5/template/cli/ts/Button.stories.ts b/code/frameworks/react-webpack5/template/cli/ts/Button.stories.ts index 7408251f9fe0..844fbfb98501 100644 --- a/code/frameworks/react-webpack5/template/cli/ts/Button.stories.ts +++ b/code/frameworks/react-webpack5/template/cli/ts/Button.stories.ts @@ -14,7 +14,7 @@ const meta = { }, // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs tags: ['autodocs'], - // More on argTypes: https://storybook.js.org/docs/api/argtypes + // More on argTypes: https://storybook.js.org/docs/api/arg-types argTypes: { backgroundColor: { control: 'color' }, }, diff --git a/code/frameworks/tanstack-react/src/export-mocks/react-router.ts b/code/frameworks/tanstack-react/src/export-mocks/react-router.ts index 54205cc200e2..42c89f249188 100644 --- a/code/frameworks/tanstack-react/src/export-mocks/react-router.ts +++ b/code/frameworks/tanstack-react/src/export-mocks/react-router.ts @@ -45,8 +45,6 @@ export const useRouteContext = fn(_useRouteContext).mockName( export const useCanGoBack = fn(_useCanGoBack).mockName('@tanstack/react-router::useCanGoBack'); export const useLinkProps = fn(_useLinkProps).mockName('@tanstack/react-router::useLinkProps'); -export const Outlet = () => null; - export const Navigate: typeof _Navigate = ({ to, href }) => { useEffect(() => { onNavigate({ to: (to as string) || href }); diff --git a/code/frameworks/tanstack-react/src/index.ts b/code/frameworks/tanstack-react/src/index.ts index ceeb49365c6f..224595ea1fa0 100644 --- a/code/frameworks/tanstack-react/src/index.ts +++ b/code/frameworks/tanstack-react/src/index.ts @@ -99,7 +99,7 @@ export type StoryObj = [TMetaOrCmpOrArgs] extends [ Partial> : _StoryObj & Partial; -interface TanStackPreview< +export interface TanStackPreview< T extends AddonTypes, TRoute extends AnyRoute | undefined = undefined, > extends ReactPreview & T> { diff --git a/code/frameworks/tanstack-react/src/plugins/server-code-elimination.test.ts b/code/frameworks/tanstack-react/src/plugins/server-code-elimination.test.ts new file mode 100644 index 000000000000..927609d7700d --- /dev/null +++ b/code/frameworks/tanstack-react/src/plugins/server-code-elimination.test.ts @@ -0,0 +1,300 @@ +import { describe, expect, it } from 'vitest'; + +import { serverCodeEliminationPlugin } from './server-code-elimination.ts'; + +type TransformResult = { code: string; map?: unknown } | null; + +async function transform( + code: string, + id = '/project/src/file.ts', + options?: { excludeFiles?: string[] } +): Promise { + const plugin = serverCodeEliminationPlugin(options); + const transformOpt = plugin.transform as any; + const handler = typeof transformOpt === 'function' ? transformOpt : transformOpt.handler; + // Handler is called with a Rollup PluginContext; the plugin doesn't use `this`. + return (await handler.call({}, code, id)) as TransformResult; +} + +describe('serverCodeEliminationPlugin', () => { + describe('skipping (returns null)', () => { + it('skips non-JS/TS file extensions', async () => { + const code = `import { createServerFn } from '@tanstack/react-start';\ncreateServerFn().handler(() => 1);`; + const result = await transform(code, '/project/src/file.css'); + expect(result).toBeNull(); + }); + + it('skips files matching excludeFiles', async () => { + const code = `import { createServerFn } from '@tanstack/react-start';\ncreateServerFn().handler(() => 1);`; + const result = await transform(code, '/project/src/export-mocks/start.ts', { + excludeFiles: ['export-mocks'], + }); + expect(result).toBeNull(); + }); + + it('skips files that do not match any tanstack pattern', async () => { + const code = `export const x = 1;\nconst y = () => x + 1;`; + const result = await transform(code); + expect(result).toBeNull(); + }); + + it('returns null when nothing actually gets transformed', async () => { + // contains a string that happens to match the regex but no real calls + const code = `const s = "createServerFn was here";`; + const result = await transform(code); + expect(result).toBeNull(); + }); + }); + + describe('createServerOnlyFn', () => { + it('replaces createServerOnlyFn(fn) with a no-op spy', async () => { + const code = [ + `import { createServerOnlyFn } from '@tanstack/react-start';`, + `export const f = createServerOnlyFn(() => 42);`, + ].join('\n'); + const result = await transform(code); + expect(result).not.toBeNull(); + expect(result!.code).toContain(`import { fn as __sb_fn } from "storybook/test"`); + expect(result!.code).toContain('__sb_fn()'); + expect(result!.code).not.toContain('createServerOnlyFn'); + }); + }); + + describe('createClientOnlyFn', () => { + it('wraps original impl in a spy', async () => { + const code = [ + `import { createClientOnlyFn } from '@tanstack/react-start';`, + `export const f = createClientOnlyFn((x) => x + 1);`, + ].join('\n'); + const result = await transform(code); + expect(result).not.toBeNull(); + expect(result!.code).toMatch(/__sb_fn\(\s*\(?x\)?\s*=>\s*x\s*\+\s*1\s*\)/); + expect(result!.code).not.toContain('createClientOnlyFn'); + }); + }); + + describe('createServerFn().handler()', () => { + it('replaces inline handler argument with no-op spy', async () => { + const code = [ + `import { createServerFn } from '@tanstack/react-start';`, + `export const f = createServerFn().handler(async () => ({ ok: true }));`, + ].join('\n'); + const result = await transform(code); + expect(result).not.toBeNull(); + expect(result!.code).toContain('__sb_fn()'); + expect(result!.code).not.toMatch(/async\s*\(\)\s*=>\s*\(\{\s*ok:\s*true/); + }); + + it('removes the dead binding when handler arg is an identifier referenced once', async () => { + const code = [ + `import { createServerFn } from '@tanstack/react-start';`, + `const handler = async () => ({ ok: true });`, + `export const f = createServerFn().handler(handler);`, + ].join('\n'); + const result = await transform(code); + expect(result).not.toBeNull(); + expect(result!.code).toContain('__sb_fn()'); + expect(result!.code).not.toContain('const handler'); + }); + + it('handles chained .middleware().handler()', async () => { + const code = [ + `import { createServerFn } from '@tanstack/react-start';`, + `const mw = {};`, + `export const f = createServerFn().middleware([mw]).handler(() => 1);`, + ].join('\n'); + const result = await transform(code); + expect(result).not.toBeNull(); + expect(result!.code).toContain('__sb_fn()'); + }); + }); + + describe('createMiddleware()', () => { + it('strips .server(fn) from the chain', async () => { + const code = [ + `import { createMiddleware } from '@tanstack/react-start';`, + `export const m = createMiddleware().server(async () => { secret(); });`, + ].join('\n'); + const result = await transform(code); + expect(result).not.toBeNull(); + expect(result!.code).not.toContain('.server('); + expect(result!.code).not.toContain('secret()'); + expect(result!.code).toContain('createMiddleware()'); + }); + + it('strips .inputValidator(fn) from the chain', async () => { + const code = [ + `import { createMiddleware } from '@tanstack/react-start';`, + `export const m = createMiddleware().inputValidator((v) => v);`, + ].join('\n'); + const result = await transform(code); + expect(result).not.toBeNull(); + expect(result!.code).not.toContain('.inputValidator('); + }); + }); + + describe('createIsomorphicFn()', () => { + it('wraps .client(fn) with a spy carrying the original impl', async () => { + const code = [ + `import { createIsomorphicFn } from '@tanstack/react-start';`, + `export const f = createIsomorphicFn().server(() => 's').client(() => 'c');`, + ].join('\n'); + const result = await transform(code); + expect(result).not.toBeNull(); + expect(result!.code).toMatch(/__sb_fn\(\s*\(\)\s*=>\s*['"]c['"]\s*\)/); + }); + + it('replaces .server(fn) with no-op spy when no .client follows', async () => { + const code = [ + `import { createIsomorphicFn } from '@tanstack/react-start';`, + `export const f = createIsomorphicFn().server(() => 's');`, + ].join('\n'); + const result = await transform(code); + expect(result).not.toBeNull(); + expect(result!.code).toContain('__sb_fn()'); + expect(result!.code).not.toMatch(/['"]s['"]/); + }); + }); + + describe('route factories', () => { + it('strips the server property from createFileRoute options', async () => { + const code = [ + `import { createFileRoute } from '@tanstack/react-router';`, + `export const Route = createFileRoute('/users')({`, + ` component: Comp,`, + ` server: { handler: async () => ({}) },`, + `});`, + `function Comp() { return null; }`, + ].join('\n'); + const result = await transform(code); + expect(result).not.toBeNull(); + expect(result!.code).not.toMatch(/\bserver:\s*\{/); + expect(result!.code).toContain('component: Comp'); + }); + + it('strips server from createRootRoute', async () => { + const code = [ + `import { createRootRoute } from '@tanstack/react-router';`, + `export const Route = createRootRoute({ component: C, server: { handler: () => 1 } });`, + `function C() { return null; }`, + ].join('\n'); + const result = await transform(code); + expect(result).not.toBeNull(); + expect(result!.code).not.toMatch(/\bserver:\s*\{/); + }); + + it('strips server from createRootRouteWithContext curried call', async () => { + const code = [ + `import { createRootRouteWithContext } from '@tanstack/react-router';`, + `export const Route = createRootRouteWithContext()({ component: C, server: {} });`, + `function C() { return null; }`, + ].join('\n'); + const result = await transform(code); + expect(result).not.toBeNull(); + expect(result!.code).not.toMatch(/\bserver:\s*\{/); + }); + + it('strips server from createRoute', async () => { + const code = [ + `import { createRoute } from '@tanstack/react-router';`, + `export const Route = createRoute({ component: C, server: { handler: () => 1 } });`, + `function C() { return null; }`, + ].join('\n'); + const result = await transform(code); + expect(result).not.toBeNull(); + expect(result!.code).not.toMatch(/\bserver:\s*\{/); + }); + + it('does not strip computed `server` property', async () => { + const code = [ + `import { createRoute } from '@tanstack/react-router';`, + `const k = 'server';`, + `export const Route = createRoute({ [k]: { handler: () => 1 }, component: C });`, + `function C() { return null; }`, + ].join('\n'); + const result = await transform(code); + // Should be null because no transformation occurred for the computed prop + // and createRoute itself wasn't changed. + expect(result).toBeNull(); + }); + }); + + describe('aliased imports', () => { + it('handles `import { createServerFn as csf }`', async () => { + const code = [ + `import { createServerFn as csf } from '@tanstack/react-start';`, + `export const f = csf().handler(() => 1);`, + ].join('\n'); + const result = await transform(code); + expect(result).not.toBeNull(); + expect(result!.code).toContain('__sb_fn()'); + }); + + it('handles aliased createServerOnlyFn', async () => { + const code = [ + `import { createServerOnlyFn as sOnly } from '@tanstack/react-start';`, + `export const f = sOnly(() => 1);`, + ].join('\n'); + const result = await transform(code); + expect(result).not.toBeNull(); + expect(result!.code).toContain('__sb_fn()'); + }); + }); + + describe('dead import elimination', () => { + it('removes unused tanstack imports after transform', async () => { + const code = [ + `import { createServerOnlyFn } from '@tanstack/react-start';`, + `export const f = createServerOnlyFn(() => 1);`, + ].join('\n'); + const result = await transform(code); + expect(result).not.toBeNull(); + expect(result!.code).not.toContain('@tanstack/react-start'); + }); + + it('preserves side-effect-only imports', async () => { + const code = [ + `import './styles.css';`, + `import { createServerOnlyFn } from '@tanstack/react-start';`, + `export const f = createServerOnlyFn(() => 1);`, + ].join('\n'); + const result = await transform(code); + expect(result).not.toBeNull(); + expect(result!.code).toMatch(/import\s+['"]\.\/styles\.css['"]/); + }); + + it('treeshake unused variables if the var is only referenced in the server-only code that gets stripped', async () => { + const code = [ + `import { createServerFn } from '@tanstack/react-start';`, + `const secret = () => 1;`, + `export const f = createServerFn().handler(secret);`, + ].join('\n'); + const result = await transform(code); + expect(result).not.toBeNull(); + expect(result!.code).not.toContain('secret'); + }); + + it('treeshake unused functions if they are only called in the server-only code that gets stripped', async () => { + const code = [ + `import { createServerFn } from '@tanstack/react-start';`, + `function secret() { return 1; }`, + `export const f = createServerFn().handler(() => secret());`, + ].join('\n'); + const result = await transform(code); + expect(result).not.toBeNull(); + expect(result!.code).not.toContain('secret'); + }); + + it('keeps still-referenced imports from a partially-used declaration', async () => { + const code = [ + `import { createServerOnlyFn, useSomething } from '@tanstack/react-start';`, + `export const f = createServerOnlyFn(() => 1);`, + `export const g = useSomething();`, + ].join('\n'); + const result = await transform(code); + expect(result).not.toBeNull(); + expect(result!.code).toContain('useSomething'); + expect(result!.code).not.toContain('createServerOnlyFn'); + }); + }); +}); diff --git a/code/frameworks/tanstack-react/src/routing/decorator.tsx b/code/frameworks/tanstack-react/src/routing/decorator.tsx index bc9eb8ca10cc..abb15b23a7c0 100644 --- a/code/frameworks/tanstack-react/src/routing/decorator.tsx +++ b/code/frameworks/tanstack-react/src/routing/decorator.tsx @@ -196,13 +196,22 @@ function resolveTree(Story: ComponentType, context: Parameters[1]): R // No route instance — build a synthetic root + child from plain options. const plainOptions = routerParameterRoute ?? {}; + const { + path: plainRoutePath, + id: plainRouteId, + ...plainRouteRest + } = plainOptions as Record; + const syntheticRouteId = plainRoutePath + ? undefined + : ((plainRouteId as string | undefined) ?? 'storybook-story'); const syntheticRoot = createRootRoute( (routeOverrides as Record | undefined)?.__root__ ?? {} ); const syntheticChild = createRoute({ component: () => , - id: 'storybook-story', - ...plainOptions, + id: syntheticRouteId, + path: plainRoutePath as string | undefined, + ...plainRouteRest, getParentRoute: () => syntheticRoot, } as any); syntheticRoot.addChildren([syntheticChild]); diff --git a/code/frameworks/tanstack-react/template/cli/ts/Button.stories.ts b/code/frameworks/tanstack-react/template/cli/ts/Button.stories.ts index c820473dee7e..663280d0017d 100644 --- a/code/frameworks/tanstack-react/template/cli/ts/Button.stories.ts +++ b/code/frameworks/tanstack-react/template/cli/ts/Button.stories.ts @@ -14,7 +14,7 @@ const meta = { }, // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs tags: ['autodocs'], - // More on argTypes: https://storybook.js.org/docs/api/argtypes + // More on argTypes: https://storybook.js.org/docs/api/arg-types argTypes: { backgroundColor: { control: 'color' }, }, diff --git a/code/frameworks/tanstack-react/template/stories/Outlet.stories.tsx b/code/frameworks/tanstack-react/template/stories/Outlet.stories.tsx new file mode 100644 index 000000000000..f4a406b73663 --- /dev/null +++ b/code/frameworks/tanstack-react/template/stories/Outlet.stories.tsx @@ -0,0 +1,73 @@ +import React from 'react'; + +import type { Meta, StoryObj } from '@storybook/tanstack-react'; + +import { Outlet, createRootRoute, createRoute } from '@tanstack/react-router'; +import { expect, within } from 'storybook/test'; + +// Regression coverage for https://github.com/storybookjs/storybook/issues/35007 +// `@tanstack/react-router`'s `Outlet` used to be mocked as `() => null`, which +// silently swallowed any leaf content rendered inside a root layout. These +// stories assert that both the root layout and the leaf content render. + +function LeafContent() { + return

leaf rendered inside root outlet

; +} + +const RootRoute = createRootRoute({ + component: () => ( +
+

root layout

+ +
+ ), +}); + +const LeafRoute = createRoute({ + getParentRoute: () => RootRoute, + path: '/', + component: LeafContent, +}); + +RootRoute.addChildren([LeafRoute]); + +const meta = { + component: LeafContent, + parameters: { layout: 'fullscreen' }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +/** Passing the leaf `Route` should render it inside the root layout's ``. */ +export const RendersLeafInsideRootOutlet: Story = { + parameters: { + tanstack: { router: { route: LeafRoute } }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByTestId('tanstack-outlet-root')).toBeInTheDocument(); + await expect( + canvas.getByRole('heading', { level: 1, name: 'root layout' }) + ).toBeInTheDocument(); + await expect(canvas.getByTestId('tanstack-outlet-leaf')).toBeInTheDocument(); + }, +}; + +/** Passing the whole route tree plus an explicit `path` resolves through the same outlet. */ +export const RendersLeafViaRouteTreePath: Story = { + parameters: { + tanstack: { + router: { route: RootRoute, path: '/' }, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByTestId('tanstack-outlet-root')).toBeInTheDocument(); + await expect( + canvas.getByRole('heading', { level: 1, name: 'root layout' }) + ).toBeInTheDocument(); + await expect(canvas.getByTestId('tanstack-outlet-leaf')).toBeInTheDocument(); + }, +}; diff --git a/code/frameworks/tanstack-react/vitest.config.ts b/code/frameworks/tanstack-react/vitest.config.ts new file mode 100644 index 000000000000..8968b85c56d1 --- /dev/null +++ b/code/frameworks/tanstack-react/vitest.config.ts @@ -0,0 +1,5 @@ +import { defineConfig, mergeConfig } from 'vitest/config'; + +import { vitestCommonConfig } from '../../vitest.shared.ts'; + +export default mergeConfig(vitestCommonConfig, defineConfig({})); diff --git a/code/frameworks/vue3-vite/src/plugins/vue-component-meta.ts b/code/frameworks/vue3-vite/src/plugins/vue-component-meta.ts index 010c33d1ebcf..e2ca1cfffa07 100644 --- a/code/frameworks/vue3-vite/src/plugins/vue-component-meta.ts +++ b/code/frameworks/vue3-vite/src/plugins/vue-component-meta.ts @@ -35,6 +35,7 @@ export async function vueComponentMeta(tsconfigPath = 'tsconfig.json'): Promise< return { name: 'storybook:vue-component-meta-plugin', transform: { + order: 'post', filter: { id: { include, exclude } }, async handler(src, id) { if (!filter(id)) { diff --git a/code/frameworks/vue3-vite/src/plugins/vue-docgen.ts b/code/frameworks/vue3-vite/src/plugins/vue-docgen.ts index 05071693c4d1..5b54dfd5e322 100644 --- a/code/frameworks/vue3-vite/src/plugins/vue-docgen.ts +++ b/code/frameworks/vue3-vite/src/plugins/vue-docgen.ts @@ -11,6 +11,7 @@ export async function vueDocgen(): Promise { return { name: 'storybook:vue-docgen-plugin', transform: { + order: 'post', filter: { id: include }, async handler(src, id) { if (!filter(id)) { diff --git a/code/lib/cli-storybook/src/sandbox-templates.ts b/code/lib/cli-storybook/src/sandbox-templates.ts index dcd313b0db00..79fde0601dcc 100644 --- a/code/lib/cli-storybook/src/sandbox-templates.ts +++ b/code/lib/cli-storybook/src/sandbox-templates.ts @@ -98,7 +98,7 @@ export type Template = { }; /** Additional CI steps in case this template has special needs during CI. */ extraCiSteps?: { - // Some sandboxes (e.g. Angular) rely on Node 22.22.1 as minimum supported version and threfore it needs enforcing, even if the CI image comes with a different node version. + // Some sandboxes (e.g. Angular) rely on Node 22.22.3 as minimum supported version and threfore it needs enforcing, even if the CI image comes with a different node version. ensureMinNodeVersion?: boolean; }; /** Additional options to pass to the initiate command when initializing Storybook. */ diff --git a/code/package.json b/code/package.json index 639b9ec36f48..5e53e9ebf5fa 100644 --- a/code/package.json +++ b/code/package.json @@ -196,5 +196,6 @@ "Dependency Upgrades" ] ] - } + }, + "deferredNextVersion": "10.5.0-alpha.3" } diff --git a/code/renderers/html/template/cli/ts/Header.stories.ts b/code/renderers/html/template/cli/ts/Header.stories.ts index 5a119106099b..3a2cf453490b 100644 --- a/code/renderers/html/template/cli/ts/Header.stories.ts +++ b/code/renderers/html/template/cli/ts/Header.stories.ts @@ -14,7 +14,7 @@ const meta = { // More on how to position stories at: https://storybook.js.org/docs/configure/story-layout layout: 'fullscreen', }, - // More on argTypes: https://storybook.js.org/docs/api/argtypes + // More on argTypes: https://storybook.js.org/docs/api/arg-types args: { onLogin: fn(), onLogout: fn(), diff --git a/code/renderers/react/package.json b/code/renderers/react/package.json index 741f83c6d7e7..d9f44fe45892 100644 --- a/code/renderers/react/package.json +++ b/code/renderers/react/package.json @@ -75,7 +75,7 @@ "expect-type": "^0.15.0", "html-tags": "^3.1.0", "prop-types": "^15.7.2", - "react-element-to-jsx-string": "npm:@7rulnik/react-element-to-jsx-string@15.0.1", + "react-element-to-jsx-string": "patch:react-element-to-jsx-string@npm%3A@7rulnik/react-element-to-jsx-string@15.0.1#~/.yarn/patches/@7rulnik-react-element-to-jsx-string-npm-15.0.1-e53b67c4b3.patch", "require-from-string": "^2.0.2", "ts-dedent": "^2.0.0", "type-fest": "^5.6.0" diff --git a/code/renderers/react/src/docs/jsxDecorator.test.tsx b/code/renderers/react/src/docs/jsxDecorator.test.tsx index be425ae2233b..8010177a9160 100644 --- a/code/renderers/react/src/docs/jsxDecorator.test.tsx +++ b/code/renderers/react/src/docs/jsxDecorator.test.tsx @@ -297,6 +297,18 @@ describe('renderJsx', () => { `); }); + + // Regression for #27127: react-element-to-jsx-string used to omit boolean + // props explicitly set to `false`. Patched via algolia/react-element-to-jsx-string#733 + // so a `false` prop is rendered while a `true` prop keeps the shorthand syntax. + it('should render boolean props set to false', () => { + expect(renderJsx(