Skip to content
Closed
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
10 changes: 6 additions & 4 deletions code/addons/vitest/src/vitest-plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -318,11 +318,13 @@ export const storybookTest = async (options?: UserOptions): Promise<Plugin[]> =>

finalOptions.includeStories = includeStories;
const projectId = oneWayHash(finalOptions.configDir);
const builderName = typeof core?.builder === 'string' ? core.builder : core?.builder?.name;
const supportsAutomaticProjectAnnotations =
builderName?.includes('@storybook/builder-vite') || builderName?.includes('builder-vite');

const areProjectAnnotationRequired = await requiresProjectAnnotations(
nonMutableInputConfig.test,
finalOptions
);
const areProjectAnnotationRequired =
supportsAutomaticProjectAnnotations &&
(await requiresProjectAnnotations(nonMutableInputConfig.test, finalOptions));

const internalSetupFiles = (
[
Expand Down
6 changes: 5 additions & 1 deletion code/core/src/core-server/load.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type { BuilderOptions, CLIOptions, LoadOptions, Options } from 'storybook

import { global } from '@storybook/global';

import { existsSync } from 'node:fs';
import { dirname, isAbsolute, join, relative, resolve } from 'pathe';

import { resolvePackageDir } from '../shared/utils/module.ts';
Expand Down Expand Up @@ -74,7 +75,10 @@ export async function loadStorybook(
*/
const isResolved = builderName.startsWith('file:') || isAbsolute(builderName);
const builderPresetDir = isResolved ? dirname(builderName) : resolvePackageDir(builderName);
corePresets.push(join(builderPresetDir, 'preset.js'));
const builderPreset = join(builderPresetDir, 'preset.js');
if (existsSync(builderPreset)) {
corePresets.push(builderPreset);
}
Comment on lines +78 to +81

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.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Does Node.js fs.existsSync accept a file:///... string path directly, or should file URLs be passed as URL objects / converted with fileURLToPath?

💡 Result:

fs.existsSync does NOT accept a file:///... URL string directly as a path; pass a WHATWG URL object (e.g., new URL('file:///...')) or convert the file URL to a filesystem path with fileURLToPath(). Node’s fs API docs explicitly say the path parameter can be a WHATWG URL object using the file: protocol (not a URL string) for fs functions like fs.access() [1]. An issue discussion in the Node.js repo notes that fs APIs support URL inputs only when passed as URL objects, not plain URL strings [2]. The same issue also reflects the rationale that file systems may contain characters like : that would make treating file://... strings as paths ambiguous, implying Node keeps string inputs as plain paths [2]. Recommended pattern: 1) If you already have a file URL string: - Convert: existsSync(fileURLToPath(urlString)) 2) If you already have a URL object: - Use: existsSync(urlObject) Example: const { existsSync } = require('node:fs'); const { fileURLToPath } = require('node:url'); const fileUrlString = 'file:///C:/path/to/file.txt'; const ok = existsSync(fileURLToPath(fileUrlString)); If you pass 'file:///C:/path/to/file.txt' directly to existsSync as a string, it will be treated as a literal path string (not a URL), which won’t match real filesystem paths.

Citations:


Fix existsSync when builderName is a file:// URL

When builderName is a file://... URL string, the code ends up calling fs.existsSync() with a file://... string (not a filesystem path), which can false-negative and skip loading the builder preset. Convert file: URLs to paths with fileURLToPath() before building the preset.js path / calling existsSync.

Suggested fix
-import { existsSync } from 'node:fs';
+import { existsSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
@@
-    const isResolved = builderName.startsWith('file:') || isAbsolute(builderName);
-    const builderPresetDir = isResolved ? dirname(builderName) : resolvePackageDir(builderName);
+    const isFileUrl = builderName.startsWith('file:');
+    const isResolved = isFileUrl || isAbsolute(builderName);
+    const builderPresetDir = isResolved
+      ? isFileUrl
+        ? dirname(fileURLToPath(builderName))
+        : dirname(builderName)
+      : resolvePackageDir(builderName);
     const builderPreset = join(builderPresetDir, 'preset.js');
     if (existsSync(builderPreset)) {
       corePresets.push(builderPreset);
     }
🤖 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/load.ts` around lines 78 - 81, The existsSync call
is given a file:// URL-derived path, causing false negatives; update the code
that computes builderPresetDir/preset path (where builderPresetDir and
builderPreset are built) to detect file: URLs (e.g., builderName starting with
'file:') and convert them to a filesystem path using fileURLToPath from 'url'
before joining to 'preset.js' and calling existsSync; ensure you import
fileURLToPath and apply it to builderPresetDir (or the builderName source) so
builderPreset becomes a valid local path for existsSync to check.

}

// Load second pass: all presets are applied in order
Expand Down
16 changes: 6 additions & 10 deletions code/lib/cli-storybook/src/ai/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ import { ProjectTypeService } from '../../../create-storybook/src/services/Proje

import { getStorybookData } from '../automigrate/helpers/mainConfigFile.ts';
import { getAiSetupMarkdownOutput } from './setup-prompts/index.ts';
import {
getUnsupportedAiSetupProjectMessage,
isAiSetupSupportedProject,
} from './supported-project.ts';
import type { ProjectInfo, AiSetupOptions } from './types.ts';

export async function aiSetup(options: AiSetupOptions): Promise<void> {
Expand Down Expand Up @@ -67,16 +71,8 @@ export async function aiSetup(options: AiSetupOptions): Promise<void> {
return;
}

if (
projectInfo.rendererPackage !== '@storybook/react' ||
projectInfo.builderPackage !== '@storybook/builder-vite'
) {
logger.log(
'AI-assisted setup is currently only available for projects using the React renderer with Vite builder. Detected renderer: ' +
projectInfo.rendererPackage +
', builder: ' +
projectInfo.builderPackage
);
if (!isAiSetupSupportedProject(projectInfo)) {
logger.log(getUnsupportedAiSetupProjectMessage(projectInfo));
return;
}

Expand Down
57 changes: 57 additions & 0 deletions code/lib/cli-storybook/src/ai/supported-project.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest';

import {
getUnsupportedAiSetupProjectMessage,
isAiSetupSupportedProject,
} from './supported-project.ts';

describe('isAiSetupSupportedProject', () => {
it('allows React projects using the Vite builder', () => {
expect(
isAiSetupSupportedProject({
rendererPackage: '@storybook/react',
builderPackage: '@storybook/builder-vite',
})
).toBe(true);
});

it('allows React projects using the Rsbuild builder', () => {
expect(
isAiSetupSupportedProject({
rendererPackage: '@storybook/react',
builderPackage: 'storybook-builder-rsbuild',
})
).toBe(true);
});

it('rejects non-React projects even when they use a supported builder', () => {
expect(
isAiSetupSupportedProject({
rendererPackage: '@storybook/vue3',
builderPackage: 'storybook-builder-rsbuild',
})
).toBe(false);
});

it('rejects React projects using unsupported builders', () => {
expect(
isAiSetupSupportedProject({
rendererPackage: '@storybook/react',
builderPackage: '@storybook/builder-webpack5',
})
).toBe(false);
});
});

describe('getUnsupportedAiSetupProjectMessage', () => {
it('lists the supported builder families and detected packages', () => {
expect(
getUnsupportedAiSetupProjectMessage({
rendererPackage: '@storybook/react',
builderPackage: '@storybook/builder-webpack5',
})
).toBe(
'AI-assisted setup is currently only available for projects using the React renderer with Vite or Rsbuild builders. Detected renderer: @storybook/react, builder: @storybook/builder-webpack5'
);
});
});
25 changes: 25 additions & 0 deletions code/lib/cli-storybook/src/ai/supported-project.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { ProjectInfo } from './types.ts';

const SUPPORTED_REACT_BUILDERS = ['@storybook/builder-vite', 'storybook-builder-rsbuild'] as const;

type AiSetupProjectSupportInfo = Pick<ProjectInfo, 'rendererPackage' | 'builderPackage'>;

export function isAiSetupSupportedProject(projectInfo: AiSetupProjectSupportInfo): boolean {
return (
projectInfo.rendererPackage === '@storybook/react' &&
SUPPORTED_REACT_BUILDERS.includes(projectInfo.builderPackage as SupportedReactBuilder)
);
}

export function getUnsupportedAiSetupProjectMessage(
projectInfo: AiSetupProjectSupportInfo
): string {
return (
'AI-assisted setup is currently only available for projects using the React renderer with Vite or Rsbuild builders. Detected renderer: ' +
projectInfo.rendererPackage +
', builder: ' +
projectInfo.builderPackage
);
}

type SupportedReactBuilder = (typeof SUPPORTED_REACT_BUILDERS)[number];