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
101 changes: 97 additions & 4 deletions code/frameworks/angular-vite/src/preset.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { findConfigFile } from 'storybook/internal/common';

import { existsSync } from 'node:fs';
import { resolve } from 'node:path';

Expand All @@ -12,10 +14,7 @@ import type { StandaloneOptions } from './builders/utils/standalone-options.ts';

// The plugin's `config` hook looks up the preview file on disk before reading
// style options; stub just that lookup so the test stays hermetic.
vi.mock(import('storybook/internal/common'), async (importOriginal) => ({
...(await importOriginal()),
findConfigFile: () => undefined,
}));
vi.mock('storybook/internal/common', { spy: true });
vi.mock('node:fs', { spy: true });
vi.mock('./builders/utils/run-compodoc.ts', { spy: true });
vi.mock('vite', { spy: true });
Expand All @@ -26,6 +25,7 @@ vi.mock('@analogjs/vite-plugin-angular', () => ({ default: (): unknown[] => [] }
beforeEach(async () => {
vol.reset();
const memfs = await vi.importActual<typeof import('memfs')>('memfs');
vi.mocked(findConfigFile).mockReturnValue(null);
vi.mocked(existsSync).mockImplementation(memfs.fs.existsSync as typeof existsSync);
vi.mocked(runCompodoc).mockResolvedValue(undefined);
vi.mocked(mergeConfig).mockImplementation(
Expand All @@ -40,6 +40,8 @@ afterEach(() => {
});

const WORKSPACE_ROOT = resolve('/workspace');
const VITE_ROOT = resolve('/workspace/projects/lib');
const PREVIEW_PATH = resolve('/workspace/.storybook/preview.ts');

function runConfig(stylePreprocessorOptions: Record<string, unknown> | undefined) {
const options = {
Expand Down Expand Up @@ -146,3 +148,94 @@ describe('viteFinal Compodoc generation', () => {
expect(runCompodoc).not.toHaveBeenCalled();
});
});

async function runTransform(
styles: unknown,
{ zoneless = true, withBuilderContext = true } = {}
): Promise<string> {
vi.mocked(findConfigFile).mockReturnValue(PREVIEW_PATH);

const options = {
configDir: resolve(WORKSPACE_ROOT, '.storybook'),
angularBuilderContext: withBuilderContext ? ({ workspaceRoot: WORKSPACE_ROOT } as any) : null,
angularBuilderOptions: { styles },
} as unknown as StandaloneOptions;

const plugin = angularOptionsPlugin(options, { normalizePath, zoneless });
(plugin.config as (userConfig: unknown) => unknown)({ root: VITE_ROOT });

const result = await (
plugin.transform as (code: string, id: string) => Promise<{ code: string } | undefined>
)('export const parameters = {};', PREVIEW_PATH);

return result?.code ?? '';
}

const styleImport = (root: string, input: string) =>
`import '${normalizePath(resolve(root, input))}';`;

describe('angularOptionsPlugin global styles', () => {
it('resolves workspace-root-relative style paths against the workspace root', async () => {
const code = await runTransform([
'projects/theme/global.scss',
'node_modules/@example/theme/index.css',
]);

expect(code).toContain(styleImport(WORKSPACE_ROOT, 'projects/theme/global.scss'));
expect(code).toContain(styleImport(WORKSPACE_ROOT, 'node_modules/@example/theme/index.css'));
});

it('resolves `src`-prefixed and dot-relative paths against the workspace root', async () => {
const code = await runTransform(['src/styles.css', './.storybook/preview.css']);

expect(code).toContain(styleImport(WORKSPACE_ROOT, 'src/styles.css'));
expect(code).toContain(styleImport(WORKSPACE_ROOT, './.storybook/preview.css'));
});

it('accepts the expanded `{ input }` form from the builder schema', async () => {
const code = await runTransform([{ input: 'src/styles.scss', bundleName: 'theme' }]);

expect(code).toContain(styleImport(WORKSPACE_ROOT, 'src/styles.scss'));
});

it('skips entries marked `inject: false`, which are emitted as standalone bundles', async () => {
const code = await runTransform([
'src/injected.css',
{ input: 'src/standalone.css', inject: false },
]);

expect(code).toContain(styleImport(WORKSPACE_ROOT, 'src/injected.css'));
expect(code).not.toContain('standalone.css');
});

it('falls back to the Vite root when there is no Angular builder context', async () => {
const code = await runTransform(['src/styles.css'], { withBuilderContext: false });

expect(code).toContain(styleImport(VITE_ROOT, 'src/styles.css'));
});

it('appends zone.js as a bare import only when not zoneless', async () => {
await expect(runTransform(['src/styles.css'], { zoneless: false })).resolves.toContain(
`import 'zone.js';`
);
await expect(runTransform(['src/styles.css'])).resolves.not.toContain(`import 'zone.js';`);
});

it('leaves modules other than the preview untouched', async () => {
vi.mocked(findConfigFile).mockReturnValue(PREVIEW_PATH);
const options = {
configDir: resolve(WORKSPACE_ROOT, '.storybook'),
angularBuilderContext: { workspaceRoot: WORKSPACE_ROOT } as any,
angularBuilderOptions: { styles: ['src/styles.css'] },
} as unknown as StandaloneOptions;

const plugin = angularOptionsPlugin(options, { normalizePath, zoneless: true });
(plugin.config as (userConfig: unknown) => unknown)({ root: VITE_ROOT });

const result = await (
plugin.transform as (code: string, id: string) => Promise<{ code: string } | undefined>
)('export const x = 1;', resolve(WORKSPACE_ROOT, 'src/some-other-file.ts'));

expect(result).toBeUndefined();
});
});
40 changes: 12 additions & 28 deletions code/frameworks/angular-vite/src/preset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,13 +232,15 @@ export function angularOptionsPlugin(
options: StandaloneOptions,
{ normalizePath, zoneless }: any
): Plugin {
let resolvedConfig: UserConfig;
let workspaceRoot: string;
let resolvedPreviewPath: string | undefined;
return {
name: 'storybook-angular-vite-options-plugin',
config(userConfig: UserConfig) {
resolvedConfig = userConfig;
resolvedPreviewPath = findConfigFile('preview', options.configDir) ?? undefined;
// No builder context when running via Vitest instead of the Angular builders.
workspaceRoot =
options.angularBuilderContext?.workspaceRoot ?? userConfig?.root ?? process.cwd();
const stylePreprocessorOptions =
options?.angularBuilderOptions?.stylePreprocessorOptions ?? {};
// Angular's builder schema (and this framework's builder schema) names the
Expand All @@ -250,8 +252,6 @@ export function angularOptionsPlugin(
const sassOptions = stylePreprocessorOptions.sass;

if (Array.isArray(loadPaths)) {
const workspaceRoot =
options.angularBuilderContext?.workspaceRoot ?? userConfig?.root ?? process.cwd();
return {
css: {
preprocessorOptions: {
Expand All @@ -268,37 +268,21 @@ export function angularOptionsPlugin(
},
async transform(code, id) {
if (resolvedPreviewPath && normalizePath(id).endsWith(normalizePath(resolvedPreviewPath))) {
const imports = [];
const styles = options?.angularBuilderOptions?.styles;

if (Array.isArray(styles)) {
styles.forEach((style) => {
imports.push(style);
});
}
const imports = (Array.isArray(styles) ? styles : [])
.map((style) => (typeof style === 'string' ? { input: style } : style))
// `inject: false` entries are emitted as standalone bundles, not preview styles.
.filter((style) => typeof style?.input === 'string' && style.inject !== false)
// Normalize to forward slashes so the specifier is valid on Windows.
.map(({ input }) => `import '${normalizePath(resolve(workspaceRoot, input))}';`);

if (!zoneless) {
imports.push('zone.js');
imports.push(`import 'zone.js';`);
}

// Use vite config root when angularBuilderContext is not available
// (e.g., when running via Vitest instead of Angular builders)
const projectRoot = resolvedConfig?.root ?? process.cwd();

return {
code: `
${imports
.map((extraImport) => {
if (extraImport.startsWith('.') || extraImport.startsWith('src')) {
// relative to root — normalize to forward slashes so the
// generated import specifier is valid on Windows.
return `import '${normalizePath(resolve(projectRoot, extraImport))}';`;
}

// absolute import
return `import '${extraImport}';`;
})
.join('\n')}
${imports.join('\n')}
${code}
`,
};
Expand Down
Loading