Skip to content

Commit 1d84ef6

Browse files
authored
Merge pull request #34706 from storybookjs/yann/ai-feature-fixes
UI: Fix showing and hiding copy prompt in the correct scenarios
2 parents 3fd56d3 + 92c9ab5 commit 1d84ef6

7 files changed

Lines changed: 410 additions & 198 deletions

File tree

code/core/src/core-server/presets/common-preset.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -273,7 +273,7 @@ export const experimental_serverChannel = async (
273273
options: OptionsWithRequiredCache
274274
) => {
275275
initAIAnalyticsChannel(channel, options, () => storyIndexGeneratorPromise);
276-
initializeChecklist(channel);
276+
initializeChecklist(channel, () => storyIndexGeneratorPromise, options.configDir);
277277
initializeWhatsNew(channel, options);
278278
initializeSaveStory(channel, options);
279279
initFileSearchChannel(channel, options);
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { resolve } from 'node:path';
2+
3+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4+
5+
const { mockCacheStore, mockCache } = vi.hoisted(() => {
6+
const store = new Map<string, unknown>();
7+
return {
8+
mockCacheStore: store,
9+
mockCache: {
10+
get: async (key: string) => store.get(key),
11+
set: async (key: string, value: unknown) => {
12+
store.set(key, value);
13+
},
14+
},
15+
};
16+
});
17+
18+
vi.mock('storybook/internal/common', () => ({
19+
cache: mockCache,
20+
}));
21+
22+
describe('ai-checklist-flags', () => {
23+
beforeEach(() => {
24+
mockCacheStore.clear();
25+
});
26+
27+
afterEach(() => {
28+
vi.resetModules();
29+
});
30+
31+
describe('hasAiInitOptIn', () => {
32+
it('returns false when nothing is cached', async () => {
33+
const { hasAiInitOptIn } = await import('./ai-checklist-flags.ts');
34+
expect(await hasAiInitOptIn('/some/project/.storybook')).toBe(false);
35+
});
36+
37+
it('returns true when the cached configDir matches the resolved input', async () => {
38+
mockCacheStore.set('ai-init-opt-in', {
39+
timestamp: Date.now(),
40+
configDir: resolve('/repo/apps/web/.storybook'),
41+
});
42+
const { hasAiInitOptIn } = await import('./ai-checklist-flags.ts');
43+
expect(await hasAiInitOptIn('/repo/apps/web/.storybook')).toBe(true);
44+
});
45+
46+
it('returns false when the cached configDir is for a different project', async () => {
47+
mockCacheStore.set('ai-init-opt-in', {
48+
timestamp: Date.now(),
49+
configDir: resolve('/repo/apps/web/.storybook'),
50+
});
51+
const { hasAiInitOptIn } = await import('./ai-checklist-flags.ts');
52+
expect(await hasAiInitOptIn('/repo/packages/ui/.storybook')).toBe(false);
53+
});
54+
55+
it('returns false when the cached entry lacks a configDir field', async () => {
56+
// Defensive — should never happen in practice because the CLI always
57+
// writes configDir, but a corrupted cache shouldn't unlock this flag.
58+
mockCacheStore.set('ai-init-opt-in', { timestamp: Date.now() });
59+
const { hasAiInitOptIn } = await import('./ai-checklist-flags.ts');
60+
expect(await hasAiInitOptIn('/any/project/.storybook')).toBe(false);
61+
});
62+
});
63+
64+
describe('hasAiSetupRun', () => {
65+
it('returns false when nothing is cached', async () => {
66+
const { hasAiSetupRun } = await import('./ai-checklist-flags.ts');
67+
expect(await hasAiSetupRun('/some/project/.storybook')).toBe(false);
68+
});
69+
70+
it('returns true when the cached configDir matches', async () => {
71+
mockCacheStore.set('ai-setup-ran', {
72+
timestamp: Date.now(),
73+
configDir: resolve('/repo/apps/web/.storybook'),
74+
});
75+
const { hasAiSetupRun } = await import('./ai-checklist-flags.ts');
76+
expect(await hasAiSetupRun('/repo/apps/web/.storybook')).toBe(true);
77+
});
78+
79+
it('returns false when the cached configDir is for a sibling monorepo project', async () => {
80+
// Regression: running `storybook ai setup` in one repo must not flip
81+
// another repo's checklist to "done".
82+
mockCacheStore.set('ai-setup-ran', {
83+
timestamp: Date.now(),
84+
configDir: resolve('/repo/apps/web/.storybook'),
85+
});
86+
const { hasAiSetupRun } = await import('./ai-checklist-flags.ts');
87+
expect(await hasAiSetupRun('/repo/packages/ui/.storybook')).toBe(false);
88+
});
89+
90+
it('treats relative input as resolved against cwd', async () => {
91+
mockCacheStore.set('ai-setup-ran', {
92+
timestamp: Date.now(),
93+
configDir: resolve('.storybook'),
94+
});
95+
const { hasAiSetupRun } = await import('./ai-checklist-flags.ts');
96+
expect(await hasAiSetupRun('.storybook')).toBe(true);
97+
});
98+
99+
it('returns false when the cached entry lacks a configDir field', async () => {
100+
mockCacheStore.set('ai-setup-ran', { timestamp: Date.now() });
101+
const { hasAiSetupRun } = await import('./ai-checklist-flags.ts');
102+
expect(await hasAiSetupRun('/any/project/.storybook')).toBe(false);
103+
});
104+
});
105+
});
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { resolve } from 'node:path';
2+
3+
import { cache } from 'storybook/internal/common';
4+
5+
/**
6+
* Flags persisted to the regular fs cache by the CLI to drive AI-related UI in
7+
* the dev server. They live OUTSIDE the telemetry event cache on purpose:
8+
* Storybook's UI behavior must not depend on whether telemetry happens to be
9+
* enabled. Both flags are tiny local files containing no PII.
10+
*
11+
* Both flags are scoped to a Storybook project via `configDir`. In monorepos
12+
* with hoisted `node_modules`, multiple Storybook projects share the same
13+
* `node_modules/.cache/storybook/...` directory — without scoping, running
14+
* `storybook ai setup` (or `storybook init` with AI accepted) in package A
15+
* would falsely flip package B's checklist or copy-prompt UI.
16+
*
17+
* The CLI writes `{ timestamp, configDir }` (absolute, resolved). The dev
18+
* server compares the cached `configDir` against its own resolved
19+
* `options.configDir` and only honors the flag on a match.
20+
*/
21+
22+
interface ProjectScopedFlag {
23+
timestamp: number;
24+
configDir: string;
25+
}
26+
27+
function isProjectScopedFlag(value: unknown): value is ProjectScopedFlag {
28+
return (
29+
typeof value === 'object' &&
30+
value !== null &&
31+
'configDir' in value &&
32+
typeof (value as ProjectScopedFlag).configDir === 'string'
33+
);
34+
}
35+
36+
async function readProjectScopedFlag(key: string, configDir: string): Promise<boolean> {
37+
try {
38+
const value = await cache.get(key);
39+
if (!isProjectScopedFlag(value)) {
40+
return false;
41+
}
42+
return value.configDir === resolve(configDir);
43+
} catch {
44+
return false;
45+
}
46+
}
47+
48+
/** Written by `storybook init` when the user accepted the AI feature. */
49+
export async function hasAiInitOptIn(configDir: string): Promise<boolean> {
50+
return readProjectScopedFlag('ai-init-opt-in', configDir);
51+
}
52+
53+
/** Written by `storybook ai setup` when the prompt CLI ran in this project. */
54+
export async function hasAiSetupRun(configDir: string): Promise<boolean> {
55+
return readProjectScopedFlag('ai-setup-ran', configDir);
56+
}

0 commit comments

Comments
 (0)