Skip to content
43 changes: 42 additions & 1 deletion .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,47 @@ cd code && yarn storybook:vitest
- TypeScript strict mode is enabled
- Follow existing patterns in the codebase

### Code Quality Checks
After making file changes, always run both formatting and linting checks:
1. **Prettier**: Format code with `yarn prettier --write <file>`
2. **ESLint**: Check for linting issues with `yarn lint:js:cmd <file>`
- The full eslint command is: `cross-env NODE_ENV=production eslint --cache --cache-location=../.cache/eslint --ext .js,.jsx,.json,.html,.ts,.tsx,.mjs --report-unused-disable-directives`
- Use the `lint:js:cmd` script for convenience
- Fix any errors or warnings before committing

### Testing Guidelines
When writing unit tests:
1. **Export functions for testing**: If functions need to be tested, export them from the module
2. **Write meaningful tests**: Tests should actually import and call the functions being tested, not just verify syntax patterns
3. **Use coverage reports**: Run tests with coverage to identify untested code
- Run coverage: `yarn vitest run --coverage <test-file>`
- Aim for high coverage of business logic (75%+ for statements/lines)
- Use coverage reports to identify missing test cases
- Focus on covering:
- All branches and conditions
- Edge cases and error paths
- Different input variations
4. **Mock external dependencies**: Use `vi.mock()` to mock file system, loggers, and other external dependencies
5. **Run tests before committing**: Ensure all tests pass with `yarn test` or `yarn vitest run`

### Logging
When adding logging to code, always use the appropriate logger:
- **Server-side code** (Node.js): Use `logger` from `storybook/internal/node-logger`
```typescript
import { logger } from 'storybook/internal/node-logger';
logger.info('Server message');
logger.warn('Warning message');
logger.error('Error message');
```
- **Client-side code** (browser): Use `logger` from `storybook/internal/client-logger`
```typescript
import { logger } from 'storybook/internal/client-logger';
logger.info('Client message');
logger.warn('Warning message');
logger.error('Error message');
```
- **DO NOT** use `console.log`, `console.warn`, or `console.error` directly unless in isolated files where importing loggers would significantly increase bundle size

### Git Workflow
- Work on feature branches
- Ensure all builds and tests pass before submitting PRs
Expand All @@ -320,4 +361,4 @@ cd code && yarn storybook:vitest
- Include code examples in addon/framework documentation
- Update migration guides for breaking changes

This document should be updated as the repository evolves and new build requirements or limitations are discovered.
This document should be updated as the repository evolves and new build requirements or limitations are discovered.
19 changes: 19 additions & 0 deletions MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
- [Require `tsconfig.json` `moduleResolution` set to value that supports `types` condition](#require-tsconfigjson-moduleresolution-set-to-value-that-supports-types-condition)
- [`core.builder` configuration must be a fully resolved path](#corebuilder-configuration-must-be-a-fully-resolved-path)
- [Removed x-only builtin tags](#removed-x-only-builtin-tags)
- [Extensionless imports in JS-based preset files are no longer supported](#extensionless-imports-in-js-based-preset-files-are-no-longer-supported)
- [From version 8.x to 9.0.0](#from-version-8x-to-900)
- [Core Changes and Removals](#core-changes-and-removals)
- [Dropped support for legacy packages](#dropped-support-for-legacy-packages)
Expand Down Expand Up @@ -585,6 +586,24 @@ export const core = {
During development of Storybook [Tags](https://storybook.js.org/docs/writing-stories/tags), we created `dev-only`, `docs-only`, and `test-only` built-in tags. These tags were never documented and superseded by the currently-documented `dev`, `autodocs`, and `test` tags which provide more precise control. The outdated `x-only` tags are removed in 10.0.
During development of Storybook [Tags](https://storybook.js.org/docs/writing-stories/tags), we created `dev-only`, `docs-only`, and `test-only` built-in tags. These tags were never documented and superceded by the currently-documented `dev`, `autodocs`, and `test` tags which provide more precise control. The outdated `x-only` tags are removed in 10.0.

#### Extensionless imports in JS-based preset files are no longer supported

Storybook 10 no longer supports extensionless relative imports in JavaScript-based preset and configuration files (e.g., `.storybook/main.js`). All relative imports must now include explicit file extensions.

**Before (no longer works):**
```js
// .storybook/main.js
import myPreset from './my-file';
```

**After:**
```js
// .storybook/main.js
import myPreset from './my-file.js';
```

This change aligns with Node.js ESM requirements, where relative imports must specify the full file extension. While TypeScript-based files (`.storybook/main.ts`) will continue to work with extensionless imports for now through automatic resolution, we recommend migrating to explicit extensions for consistency and better compatibility.

## From version 8.x to 9.0.0

### Core Changes and Removals
Expand Down
288 changes: 288 additions & 0 deletions code/core/src/bin/loader.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,288 @@
import { existsSync } from 'node:fs';

import { beforeEach, describe, expect, it, vi } from 'vitest';

import { deprecate } from 'storybook/internal/node-logger';

import { addExtensionsToRelativeImports, resolveWithExtension } from './loader';

// Mock dependencies
vi.mock('node:fs');
vi.mock('storybook/internal/node-logger');

describe('loader', () => {
describe('resolveWithExtension', () => {
it('should return the path as-is if it already has an extension', () => {
const result = resolveWithExtension('./test.js', '/project/src/file.ts');

expect(result).toBe('./test.js');
expect(deprecate).not.toHaveBeenCalled();
});

it('should resolve extensionless import to .ts extension when file exists', () => {
vi.mocked(existsSync).mockImplementation((filePath) => {
return filePath === '/project/src/utils.ts';
});

const result = resolveWithExtension('./utils', '/project/src/file.ts');

expect(result).toBe('./utils.ts');

Check failure on line 29 in code/core/src/bin/loader.test.ts

View workflow job for this annotation

GitHub Actions / Core Unit Tests, windows-latest

src/bin/loader.test.ts > loader > resolveWithExtension > should resolve extensionless import to .ts extension when file exists

AssertionError: expected './utils' to be './utils.ts' // Object.is equality Expected: "./utils.ts" Received: "./utils" ❯ src/bin/loader.test.ts:29:22
expect(deprecate).toHaveBeenCalledWith(
expect.stringContaining('One or more extensionless imports detected: "./utils"')
);
expect(deprecate).toHaveBeenCalledWith(
expect.stringContaining(
'For maximum compatibility, you should add an explicit file extension'
)
);
});

it('should resolve extensionless import to .js extension when file exists', () => {
vi.mocked(existsSync).mockImplementation((filePath) => {
return filePath === '/project/src/utils.js';
});

const result = resolveWithExtension('./utils', '/project/src/file.ts');

expect(result).toBe('./utils.js');

Check failure on line 47 in code/core/src/bin/loader.test.ts

View workflow job for this annotation

GitHub Actions / Core Unit Tests, windows-latest

src/bin/loader.test.ts > loader > resolveWithExtension > should resolve extensionless import to .js extension when file exists

AssertionError: expected './utils' to be './utils.js' // Object.is equality Expected: "./utils.js" Received: "./utils" ❯ src/bin/loader.test.ts:47:22
expect(deprecate).toHaveBeenCalledWith(
expect.stringContaining('One or more extensionless imports detected: "./utils"')
);
});

it('should show deprecation message when encountering an extensionless import', () => {
vi.mocked(existsSync).mockReturnValue(true);

resolveWithExtension('./utils', '/project/src/file.ts');

expect(deprecate).toHaveBeenCalledWith(
expect.stringContaining('One or more extensionless imports detected: "./utils"')
);
expect(deprecate).toHaveBeenCalledWith(
expect.stringContaining('in file "/project/src/file.ts"')
);
});

it('should return original path when file cannot be resolved', () => {
vi.mocked(existsSync).mockReturnValue(false);

const result = resolveWithExtension('./missing', '/project/src/file.ts');

expect(result).toBe('./missing');
expect(deprecate).toHaveBeenCalledWith(
expect.stringContaining('One or more extensionless imports detected: "./missing"')
);
});

it('should resolve relative to parent directory', () => {
vi.mocked(existsSync).mockImplementation((filePath) => {
return filePath === '/project/utils.ts';
});

const result = resolveWithExtension('../utils', '/project/src/file.ts');

expect(result).toBe('../utils.ts');

Check failure on line 84 in code/core/src/bin/loader.test.ts

View workflow job for this annotation

GitHub Actions / Core Unit Tests, windows-latest

src/bin/loader.test.ts > loader > resolveWithExtension > should resolve relative to parent directory

AssertionError: expected '../utils' to be '../utils.ts' // Object.is equality Expected: "../utils.ts" Received: "../utils" ❯ src/bin/loader.test.ts:84:22
expect(deprecate).toHaveBeenCalledWith(
expect.stringContaining('One or more extensionless imports detected: "../utils"')
);
});
});

describe('addExtensionsToRelativeImports', () => {
beforeEach(() => {
// Default: all files exist with .ts extension
vi.mocked(existsSync).mockImplementation((filePath) => {
return (filePath as string).endsWith('.ts');
});
});

it('should not modify imports that already have extensions', () => {
const testCases = [
{ input: `import foo from './test.js';`, expected: `import foo from './test.js';` },
{ input: `import foo from './test.ts';`, expected: `import foo from './test.ts';` },
{ input: `import foo from '../utils.mjs';`, expected: `import foo from '../utils.mjs';` },
{
input: `export { bar } from './module.tsx';`,
expected: `export { bar } from './module.tsx';`,
},
];

testCases.forEach(({ input, expected }) => {
const result = addExtensionsToRelativeImports(input, '/project/src/file.ts');
expect(result).toBe(expected);
expect(deprecate).not.toHaveBeenCalled();
});
});

it('should add extension to static import statements', () => {
const source = `import { foo } from './utils';`;
const result = addExtensionsToRelativeImports(source, '/project/src/file.ts');

expect(result).toBe(`import { foo } from './utils.ts';`);
});

it('should add extension to static export statements', () => {
const source = `export { foo } from './utils';`;
const result = addExtensionsToRelativeImports(source, '/project/src/file.ts');

expect(result).toBe(`export { foo } from './utils.ts';`);
});

it('should add extension to dynamic import statements', () => {
const source = `const module = await import('./utils');`;
const result = addExtensionsToRelativeImports(source, '/project/src/file.ts');

expect(result).toBe(`const module = await import('./utils.ts');`);
});

it('should handle default imports', () => {
const source = `import foo from './module';`;
const result = addExtensionsToRelativeImports(source, '/project/src/file.ts');

expect(result).toBe(`import foo from './module.ts';`);
});

it('should handle named imports', () => {
const source = `import { foo, bar } from './module';`;
const result = addExtensionsToRelativeImports(source, '/project/src/file.ts');

expect(result).toBe(`import { foo, bar } from './module.ts';`);
});

it('should handle namespace imports', () => {
const source = `import * as utils from './module';`;
const result = addExtensionsToRelativeImports(source, '/project/src/file.ts');

expect(result).toBe(`import * as utils from './module.ts';`);
});

it('should handle side-effect imports', () => {
const source = `import './styles';`;
const result = addExtensionsToRelativeImports(source, '/project/src/file.ts');

expect(result).toBe(`import './styles.ts';`);
});

it('should handle export all statements', () => {
const source = `export * from './module';`;
const result = addExtensionsToRelativeImports(source, '/project/src/file.ts');

expect(result).toBe(`export * from './module.ts';`);
});

it('should not modify absolute imports', () => {
const testCases = [
`import foo from 'react';`,
`import bar from '@storybook/react';`,
`import baz from 'node:fs';`,
];

testCases.forEach((source) => {
const result = addExtensionsToRelativeImports(source, '/project/src/file.ts');
expect(result).toBe(source);
});
});

it('should not modify imports that match the pattern but are not relative paths', () => {
// Edge case: a path that starts with a dot but not ./ or ../
// This tests the condition that returns 'match' unchanged
const source = `import foo from '.config';`;
const result = addExtensionsToRelativeImports(source, '/project/src/file.ts');

// Should not be modified since it doesn't start with ./ or ../
expect(result).toBe(source);
expect(deprecate).not.toHaveBeenCalled();
});

it('should handle single quotes', () => {
const source = `import foo from './utils';`;
const result = addExtensionsToRelativeImports(source, '/project/src/file.ts');

expect(result).toBe(`import foo from './utils.ts';`);
});

it('should handle double quotes', () => {
const source = `import foo from "./utils";`;
const result = addExtensionsToRelativeImports(source, '/project/src/file.ts');

expect(result).toBe(`import foo from "./utils.ts";`);
});

it('should handle paths starting with ./', () => {
const source = `import foo from './utils';`;
const result = addExtensionsToRelativeImports(source, '/project/src/file.ts');

expect(result).toBe(`import foo from './utils.ts';`);
});

it('should handle paths starting with ../', () => {
const source = `import foo from '../utils';`;
const result = addExtensionsToRelativeImports(source, '/project/src/file.ts');

expect(result).toBe(`import foo from '../utils.ts';`);
});

it('should handle multiple imports in the same file', () => {
const source = `import foo from './foo';\nimport bar from './bar';\nexport { baz } from '../baz';`;
const result = addExtensionsToRelativeImports(source, '/project/src/file.ts');

expect(result).toBe(
`import foo from './foo.ts';\nimport bar from './bar.ts';\nexport { baz } from '../baz.ts';`
);
});

it('should preserve the import structure after adding extensions', () => {
const source = `import { foo, bar } from './utils';`;
const result = addExtensionsToRelativeImports(source, '/project/src/file.ts');

expect(result).toContain('{ foo, bar }');
expect(result).toBe(`import { foo, bar } from './utils.ts';`);
});

it('should handle imports with comments', () => {
const source = `// This is a comment\nimport foo from './utils'; // inline comment`;
const result = addExtensionsToRelativeImports(source, '/project/src/file.ts');

expect(result).toBe(`// This is a comment\nimport foo from './utils.ts'; // inline comment`);
});

it('should handle multi-line imports with named exports on separate lines', () => {
const source = `import {
foo,
bar,
baz
} from './utils';`;
const result = addExtensionsToRelativeImports(source, '/project/src/file.ts');

expect(result).toBe(`import {
foo,
bar,
baz
} from './utils.ts';`);
});

it('should handle multi-line exports with named exports on separate lines', () => {
const source = `export {
foo,
bar
} from '../module';`;
const result = addExtensionsToRelativeImports(source, '/project/src/file.ts');

expect(result).toBe(`export {
foo,
bar
} from '../module.ts';`);
});

it('should handle multi-line dynamic imports', () => {
const source = `const module = await import(
'./utils'
);`;
const result = addExtensionsToRelativeImports(source, '/project/src/file.ts');

expect(result).toBe(`const module = await import(
'./utils.ts'
);`);
});
Comment on lines +287 to +296
Copy link
Member

@ndelangen ndelangen Oct 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should there be a little bit more coverage on off patterns with import()?

import(`./foo`); // backticks

import('./foo' + foo);

import(foo + 'bar');

import(`${foo}/bar`);

import(`./${foo}/bar`);

const [] = [
  import('./foo'),
  import('./bar'),
]

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've added support for backticks, but supporting interpolation with runtime values doesn't make much sense, since we'd now have to start looking up those runtime values - probably with AST parsing - do figure out what the final file path is to lookup. that's not worth it imo.

});
});
Loading
Loading