-
-
Notifications
You must be signed in to change notification settings - Fork 10.1k
UI: Fix showing and hiding copy prompt in the correct scenarios #34706
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
d572075
fix showing and hiding copy prompt in the correct scenarios
yannbf f3db93e
consider monorepos for checklist logic
yannbf 8b6950f
Simplify code a little bit
Sidnioulz 6b59848
Merge branch 'next' into yann/ai-feature-fixes
Sidnioulz 8114355
Update tests
Sidnioulz eca7fa5
Merge branch 'yann/ai-feature-fixes' of github.com:storybookjs/storyb…
yannbf 115a0a5
bring back fire and forget logic
yannbf 1f23401
fix type issues
yannbf 92c9ab5
Merge branch 'next' into yann/ai-feature-fixes
yannbf File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
105 changes: 105 additions & 0 deletions
105
code/core/src/core-server/utils/ai-checklist-flags.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,105 @@ | ||
| import { resolve } from 'node:path'; | ||
|
|
||
| import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; | ||
|
|
||
| const { mockCacheStore, mockCache } = vi.hoisted(() => { | ||
| const store = new Map<string, unknown>(); | ||
| return { | ||
| mockCacheStore: store, | ||
| mockCache: { | ||
| get: async (key: string) => store.get(key), | ||
| set: async (key: string, value: unknown) => { | ||
| store.set(key, value); | ||
| }, | ||
| }, | ||
| }; | ||
| }); | ||
|
|
||
| vi.mock('storybook/internal/common', () => ({ | ||
| cache: mockCache, | ||
| })); | ||
|
|
||
| describe('ai-checklist-flags', () => { | ||
| beforeEach(() => { | ||
| mockCacheStore.clear(); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| vi.resetModules(); | ||
| }); | ||
|
|
||
| describe('hasAiInitOptIn', () => { | ||
| it('returns false when nothing is cached', async () => { | ||
| const { hasAiInitOptIn } = await import('./ai-checklist-flags.ts'); | ||
| expect(await hasAiInitOptIn('/some/project/.storybook')).toBe(false); | ||
| }); | ||
|
|
||
| it('returns true when the cached configDir matches the resolved input', async () => { | ||
| mockCacheStore.set('ai-init-opt-in', { | ||
| timestamp: Date.now(), | ||
| configDir: resolve('/repo/apps/web/.storybook'), | ||
| }); | ||
| const { hasAiInitOptIn } = await import('./ai-checklist-flags.ts'); | ||
| expect(await hasAiInitOptIn('/repo/apps/web/.storybook')).toBe(true); | ||
| }); | ||
|
|
||
| it('returns false when the cached configDir is for a different project', async () => { | ||
| mockCacheStore.set('ai-init-opt-in', { | ||
| timestamp: Date.now(), | ||
| configDir: resolve('/repo/apps/web/.storybook'), | ||
| }); | ||
| const { hasAiInitOptIn } = await import('./ai-checklist-flags.ts'); | ||
| expect(await hasAiInitOptIn('/repo/packages/ui/.storybook')).toBe(false); | ||
| }); | ||
|
|
||
| it('returns false when the cached entry lacks a configDir field', async () => { | ||
| // Defensive — should never happen in practice because the CLI always | ||
| // writes configDir, but a corrupted cache shouldn't unlock this flag. | ||
| mockCacheStore.set('ai-init-opt-in', { timestamp: Date.now() }); | ||
| const { hasAiInitOptIn } = await import('./ai-checklist-flags.ts'); | ||
| expect(await hasAiInitOptIn('/any/project/.storybook')).toBe(false); | ||
| }); | ||
| }); | ||
|
|
||
| describe('hasAiSetupRun', () => { | ||
| it('returns false when nothing is cached', async () => { | ||
| const { hasAiSetupRun } = await import('./ai-checklist-flags.ts'); | ||
| expect(await hasAiSetupRun('/some/project/.storybook')).toBe(false); | ||
| }); | ||
|
|
||
| it('returns true when the cached configDir matches', async () => { | ||
| mockCacheStore.set('ai-setup-ran', { | ||
| timestamp: Date.now(), | ||
| configDir: resolve('/repo/apps/web/.storybook'), | ||
| }); | ||
| const { hasAiSetupRun } = await import('./ai-checklist-flags.ts'); | ||
| expect(await hasAiSetupRun('/repo/apps/web/.storybook')).toBe(true); | ||
| }); | ||
|
|
||
| it('returns false when the cached configDir is for a sibling monorepo project', async () => { | ||
| // Regression: running `storybook ai setup` in one repo must not flip | ||
| // another repo's checklist to "done". | ||
| mockCacheStore.set('ai-setup-ran', { | ||
| timestamp: Date.now(), | ||
| configDir: resolve('/repo/apps/web/.storybook'), | ||
| }); | ||
| const { hasAiSetupRun } = await import('./ai-checklist-flags.ts'); | ||
| expect(await hasAiSetupRun('/repo/packages/ui/.storybook')).toBe(false); | ||
| }); | ||
|
|
||
| it('treats relative input as resolved against cwd', async () => { | ||
| mockCacheStore.set('ai-setup-ran', { | ||
| timestamp: Date.now(), | ||
| configDir: resolve('.storybook'), | ||
| }); | ||
| const { hasAiSetupRun } = await import('./ai-checklist-flags.ts'); | ||
| expect(await hasAiSetupRun('.storybook')).toBe(true); | ||
| }); | ||
|
|
||
| it('returns false when the cached entry lacks a configDir field', async () => { | ||
| mockCacheStore.set('ai-setup-ran', { timestamp: Date.now() }); | ||
| const { hasAiSetupRun } = await import('./ai-checklist-flags.ts'); | ||
| expect(await hasAiSetupRun('/any/project/.storybook')).toBe(false); | ||
| }); | ||
| }); | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,56 @@ | ||
| import { resolve } from 'node:path'; | ||
|
|
||
| import { cache } from 'storybook/internal/common'; | ||
|
|
||
| /** | ||
| * Flags persisted to the regular fs cache by the CLI to drive AI-related UI in | ||
| * the dev server. They live OUTSIDE the telemetry event cache on purpose: | ||
| * Storybook's UI behavior must not depend on whether telemetry happens to be | ||
| * enabled. Both flags are tiny local files containing no PII. | ||
| * | ||
| * Both flags are scoped to a Storybook project via `configDir`. In monorepos | ||
| * with hoisted `node_modules`, multiple Storybook projects share the same | ||
| * `node_modules/.cache/storybook/...` directory — without scoping, running | ||
| * `storybook ai setup` (or `storybook init` with AI accepted) in package A | ||
| * would falsely flip package B's checklist or copy-prompt UI. | ||
| * | ||
| * The CLI writes `{ timestamp, configDir }` (absolute, resolved). The dev | ||
| * server compares the cached `configDir` against its own resolved | ||
| * `options.configDir` and only honors the flag on a match. | ||
| */ | ||
|
|
||
| interface ProjectScopedFlag { | ||
| timestamp: number; | ||
| configDir: string; | ||
| } | ||
|
|
||
| function isProjectScopedFlag(value: unknown): value is ProjectScopedFlag { | ||
| return ( | ||
| typeof value === 'object' && | ||
| value !== null && | ||
| 'configDir' in value && | ||
| typeof (value as ProjectScopedFlag).configDir === 'string' | ||
| ); | ||
| } | ||
|
|
||
| async function readProjectScopedFlag(key: string, configDir: string): Promise<boolean> { | ||
| try { | ||
| const value = await cache.get(key); | ||
| if (!isProjectScopedFlag(value)) { | ||
| return false; | ||
| } | ||
| return value.configDir === resolve(configDir); | ||
| } catch { | ||
| return false; | ||
| } | ||
| } | ||
|
|
||
| /** Written by `storybook init` when the user accepted the AI feature. */ | ||
| export async function hasAiInitOptIn(configDir: string): Promise<boolean> { | ||
| return readProjectScopedFlag('ai-init-opt-in', configDir); | ||
| } | ||
|
|
||
| /** Written by `storybook ai setup` when the prompt CLI ran in this project. */ | ||
| export async function hasAiSetupRun(configDir: string): Promise<boolean> { | ||
| return readProjectScopedFlag('ai-setup-ran', configDir); | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🌐 Web query:
In Vitest (current docs), what is the recommended pattern for module mocks usingvi.mock(..., { spy: true })and typed access viavi.mocked()?💡 Result:
The recommended pattern in current Vitest documentation (as of 2026) for module mocks using
vi.mock(..., { spy: true })combined with typed access viavi.mocked()is as follows: 1. Callvi.mock('./modulePath', { spy: true })at the top of your test file. This hoists the mock, spies on all exports (replacing them with trackable spies), but preserves their original implementations. This is particularly useful in browser mode where directvi.spyOnon imports isn't possible, or when you want to track calls across the entire module without replacing behavior [1][2]. Example setup:import { vi, expect, test } from 'vitest' import { calculator } from './src/calculator.ts' // Assume calculator is a function export vi.mock('./src/calculator.ts', { spy: true })2. Import the module exports as usual. The spies are automatically applied due to hoisting. 3. For TypeScript typed access to mock methods (enabling.mockXassertions and configurations), wrap the import or specific export withvi.mocked(). Example test:test('verifies calculator behavior', () => { const result = calculator(1, 2) expect(result).toBe(3) expect(vi.mocked(calculator)).toHaveBeenCalledWith(1, 2)[10][23] expect(vi.mocked(calculator)).toHaveReturnedWith(3)[10][23] })This pattern allows asserting call details while the original logic runs, unlike full mocks with factories that replace implementations [3][1]. For partial mocks preserving some original behavior, use factory functions withimportOriginalinstead [4][5]. Usevi.mocked(export, { partial: true })or{ deep: true }for complex objects/classes [6][1][7]. Note: There was a Vitest 4 bug with deep spying (fixed later), but current docs confirm the pattern works [8].Citations:
🏁 Script executed:
Repository: storybookjs/storybook
Length of output: 121
🏁 Script executed:
Repository: storybookjs/storybook
Length of output: 1593
Align Vitest mocks with repo spy-mocking rules.
This test file must switch to the required
spy: true+vi.mocked()pattern. The coding guidelines explicitly mandate usingvi.mock()with thespy: trueoption for all package and file mocks, accessing mocks viavi.mocked(), and implementing mock behaviors inbeforeEachblocks—all three are currently violated.Required refactor
import { resolve } from 'node:path'; +import * as common from 'storybook/internal/common'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const { mockCacheStore } = vi.hoisted(() => { const store = new Map<string, unknown>(); - return { - mockCacheStore: store, - mockCache: { - get: async (key: string) => store.get(key), - set: async (key: string, value: unknown) => { - store.set(key, value); - }, - }, - }; + return { mockCacheStore: store }; }); -vi.mock('storybook/internal/common', () => ({ - cache: mockCache, -})); +vi.mock('storybook/internal/common', { spy: true }); describe('ai-checklist-flags', () => { beforeEach(() => { mockCacheStore.clear(); + vi.mocked(common.cache.get).mockImplementation(async (key: string) => mockCacheStore.get(key)); + vi.mocked(common.cache.set).mockImplementation(async (key: string, value: unknown) => { + mockCacheStore.set(key, value); + }); }); afterEach(() => { + vi.clearAllMocks(); vi.resetModules(); });🤖 Prompt for AI Agents