Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion code/core/src/core-server/presets/common-preset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,7 @@ export const experimental_serverChannel = async (
options: OptionsWithRequiredCache
) => {
initAIAnalyticsChannel(channel, options, () => storyIndexGeneratorPromise);
initializeChecklist(channel);
initializeChecklist(channel, () => storyIndexGeneratorPromise, options.configDir);
initializeWhatsNew(channel, options);
initializeSaveStory(channel, options);
initFileSearchChannel(channel, options);
Expand Down
105 changes: 105 additions & 0 deletions code/core/src/core-server/utils/ai-checklist-flags.test.ts
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,
}));
Comment on lines +5 to +20

Copy link
Copy Markdown
Contributor

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 using vi.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 via vi.mocked() is as follows: 1. Call vi.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 direct vi.spyOn on 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 .mockX assertions and configurations), wrap the import or specific export with vi.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 with importOriginal instead [4][5]. Use vi.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:

cd /repo && find . -path "*/code/core/src/core-server/utils/ai-checklist-flags.test.ts" -type f

Repository: storybookjs/storybook

Length of output: 121


🏁 Script executed:

head -50 code/core/src/core-server/utils/ai-checklist-flags.test.ts

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 using vi.mock() with the spy: true option for all package and file mocks, accessing mocks via vi.mocked(), and implementing mock behaviors in beforeEach blocks—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
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@code/core/src/core-server/utils/ai-checklist-flags.test.ts` around lines 5 -
20, Replace the current vi.hoisted + direct vi.mock usage with the repo's
spy-mocking pattern: call vi.mock('storybook/internal/common', () => ({ cache:
{} }), { spy: true }) at top-level, keep a top-level let mockCacheStore:
Map<string, unknown> and let mockedCache reference (uninitialized), then in
beforeEach initialize mockCacheStore = new Map() and obtain the mock via
mockedCache = vi.mocked(require('storybook/internal/common').cache) (or import
and then vi.mocked(imported.cache)); set mockedCache.get = vi.fn(async
(key:string) => mockCacheStore.get(key)) and mockedCache.set = vi.fn(async
(key:string, value:unknown) => mockCacheStore.set(key, value)); reset the map
and call mockedCache.mockReset() as needed in afterEach. Ensure all tests access
the mocked cache via vi.mocked(...) and that no behavior is defined outside
beforeEach.


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);
});
});
});
56 changes: 56 additions & 0 deletions code/core/src/core-server/utils/ai-checklist-flags.ts
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);
}
Loading
Loading