Skip to content
Merged
Original file line number Diff line number Diff line change
@@ -0,0 +1,299 @@
import { vi, expect, describe, it, beforeEach } from 'vitest';

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

import type { BuilderContext } from '@angular-devkit/architect';
import { logging } from '@angular-devkit/core';

import { getBuilderOptions } from './framework-preset-angular-cli';
import type { PresetOptions } from './preset-options';

// Mock all dependencies
vi.mock('storybook/internal/node-logger', () => ({
logger: {
info: vi.fn(),
},
}));

vi.mock('storybook/internal/server-errors', () => ({
AngularLegacyBuildOptionsError: class AngularLegacyBuildOptionsError extends Error {
constructor() {
super('AngularLegacyBuildOptionsError');
this.name = 'AngularLegacyBuildOptionsError';
}
},
}));

vi.mock('storybook/internal/common', () => ({
getProjectRoot: vi.fn(),
}));

vi.mock('@angular-devkit/architect', () => ({
targetFromTargetString: vi.fn(),
}));

vi.mock('find-up', () => ({
findUp: vi.fn(),
}));

vi.mock('./utils/module-is-available', () => ({
moduleIsAvailable: vi.fn(),
}));

vi.mock('./angular-cli-webpack', () => ({
getWebpackConfig: vi.fn(),
}));

vi.mock('./preset-options', () => ({
PresetOptions: {},
}));

// Mock require.resolve for @angular/animations
vi.mock('@angular/animations', () => ({}));

const mockedLogger = vi.mocked(logger);

const mockedTargetFromTargetString = vi.mocked(
await import('@angular-devkit/architect')
).targetFromTargetString;
const mockedFindUp = vi.mocked(await import('find-up')).findUp;
const mockedGetProjectRoot = vi.mocked(await import('storybook/internal/common')).getProjectRoot;

describe('framework-preset-angular-cli', () => {
beforeEach(() => {
vi.clearAllMocks();
});

describe('getBuilderOptions', () => {
const mockBuilderContext: BuilderContext = {
target: { project: 'test-project', builder: 'test-builder', options: {} },
workspaceRoot: '/test/workspace',
getProjectMetadata: vi.fn().mockResolvedValue({}),
getTargetOptions: vi.fn().mockResolvedValue({}),
logger: new logging.Logger('Test'),
} as unknown as BuilderContext;

beforeEach(() => {
mockedGetProjectRoot.mockReturnValue('/test/project');
mockedFindUp.mockResolvedValue('/test/tsconfig.json');
});

it('should get browser target options when angularBrowserTarget is provided', async () => {
const mockTarget = { project: 'test-project', target: 'build', configuration: 'development' };
mockedTargetFromTargetString.mockReturnValue(mockTarget);

const options: PresetOptions = {
configType: 'DEVELOPMENT',
configDir: '/test/config',
presets: {
apply: vi.fn(),
} as any,
angularBrowserTarget: 'test-project:build:development',
};

await getBuilderOptions(options, mockBuilderContext);

expect(mockedTargetFromTargetString).toHaveBeenCalledWith('test-project:build:development');
expect(mockedLogger.info).toHaveBeenCalledWith(
'=> Using angular browser target options from "test-project:build:development"'
);
expect(mockBuilderContext.getTargetOptions).toHaveBeenCalledWith(mockTarget);
});

it('should merge browser target options with storybook options', async () => {
const mockTarget = { project: 'test-project', target: 'build' };
mockedTargetFromTargetString.mockReturnValue(mockTarget);

const browserTargetOptions = { a: 1, nested: { x: 10 } };
const storybookOptions = { b: 2, nested: { y: 20 } };

vi.mocked(mockBuilderContext.getTargetOptions).mockResolvedValue(browserTargetOptions);

const options: PresetOptions = {
configType: 'DEVELOPMENT',
configDir: '/test/config',
presets: {
apply: vi.fn(),
} as any,
angularBrowserTarget: 'test-project:build',
angularBuilderOptions: storybookOptions,
};

const result = await getBuilderOptions(options, mockBuilderContext);

expect(result).toEqual({
a: 1,
b: 2,
nested: { x: 10, y: 20 },
tsConfig: '/test/tsconfig.json',
});
});

it('should use provided tsConfig when available', async () => {
const options: PresetOptions = {
configType: 'DEVELOPMENT',
configDir: '/test/config',
presets: {
apply: vi.fn(),
} as any,
tsConfig: '/custom/tsconfig.json',
};

const result = await getBuilderOptions(options, mockBuilderContext);

expect(result.tsConfig).toBe('/custom/tsconfig.json');
expect(mockedLogger.info).toHaveBeenCalledWith(
'=> Using angular project with "tsConfig:/custom/tsconfig.json"'
);
});

it('should find tsconfig.json when not provided', async () => {
const options: PresetOptions = {
configType: 'DEVELOPMENT',
configDir: '/test/config',
presets: {
apply: vi.fn(),
} as any,
};

const result = await getBuilderOptions(options, mockBuilderContext);

expect(mockedFindUp).toHaveBeenCalledWith('tsconfig.json', {
cwd: '/test/config',
stopAt: '/test/project',
});
expect(result.tsConfig).toBe('/test/tsconfig.json');
});

it('should use browser target tsConfig when no other tsConfig is available', async () => {
const mockTarget = { project: 'test-project', target: 'build' };
mockedTargetFromTargetString.mockReturnValue(mockTarget);
mockedFindUp.mockResolvedValue(null);

const browserTargetOptions = { tsConfig: '/browser/tsconfig.json' };
vi.mocked(mockBuilderContext.getTargetOptions).mockResolvedValue(browserTargetOptions);

const options: PresetOptions = {
configType: 'DEVELOPMENT',
configDir: '/test/config',
presets: {
apply: vi.fn(),
} as any,
angularBrowserTarget: 'test-project:build',
};

const result = await getBuilderOptions(options, mockBuilderContext);

expect(result.tsConfig).toBe('/browser/tsconfig.json');
});

it('should handle case when no angularBrowserTarget is provided', async () => {
const options: PresetOptions = {
configType: 'DEVELOPMENT',
configDir: '/test/config',
presets: {
apply: vi.fn(),
} as any,
};

const result = await getBuilderOptions(options, mockBuilderContext);

expect(mockedTargetFromTargetString).not.toHaveBeenCalled();
expect(mockBuilderContext.getTargetOptions).not.toHaveBeenCalled();
expect(result).toEqual({
tsConfig: '/test/tsconfig.json',
});
});

it('should handle browser target without configuration', async () => {
const mockTarget = { project: 'test-project', target: 'build' };
mockedTargetFromTargetString.mockReturnValue(mockTarget);

const options: PresetOptions = {
configType: 'DEVELOPMENT',
configDir: '/test/config',
presets: {
apply: vi.fn(),
} as any,
angularBrowserTarget: 'test-project:build',
};

const result = await getBuilderOptions(options, mockBuilderContext);

expect(mockedLogger.info).toHaveBeenCalledWith(
'=> Using angular browser target options from "test-project:build"'
);
});

it('should handle browser target with configuration', async () => {
const mockTarget = { project: 'test-project', target: 'build', configuration: 'production' };
mockedTargetFromTargetString.mockReturnValue(mockTarget);

const options: PresetOptions = {
configType: 'DEVELOPMENT',
configDir: '/test/config',
presets: {
apply: vi.fn(),
} as any,
angularBrowserTarget: 'test-project:build:production',
};

const result = await getBuilderOptions(options, mockBuilderContext);

expect(mockedLogger.info).toHaveBeenCalledWith(
'=> Using angular browser target options from "test-project:build:production"'
);
});

it('should handle empty angularBuilderOptions', async () => {
const mockTarget = { project: 'test-project', target: 'build' };
mockedTargetFromTargetString.mockReturnValue(mockTarget);

const browserTargetOptions = { a: 1, b: 2 };
vi.mocked(mockBuilderContext.getTargetOptions).mockResolvedValue(browserTargetOptions);

const options: PresetOptions = {
configType: 'DEVELOPMENT',
configDir: '/test/config',
presets: {
apply: vi.fn(),
} as any,
angularBrowserTarget: 'test-project:build',
angularBuilderOptions: {},
};

const result = await getBuilderOptions(options, mockBuilderContext);

expect(result).toEqual({
a: 1,
b: 2,
tsConfig: '/test/tsconfig.json',
});
});

it('should handle undefined angularBuilderOptions', async () => {
const mockTarget = { project: 'test-project', target: 'build' };
mockedTargetFromTargetString.mockReturnValue(mockTarget);

const browserTargetOptions = { a: 1, b: 2 };
vi.mocked(mockBuilderContext.getTargetOptions).mockResolvedValue(browserTargetOptions);

const options: PresetOptions = {
configType: 'DEVELOPMENT',
configDir: '/test/config',
presets: {
apply: vi.fn(),
} as any,
angularBrowserTarget: 'test-project:build',
};

const result = await getBuilderOptions(options, mockBuilderContext);

expect(result).toEqual({
a: 1,
b: 2,
tsConfig: '/test/tsconfig.json',
});
});
});
});
54 changes: 44 additions & 10 deletions code/frameworks/angular/src/server/framework-preset-angular-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,38 @@ function getBuilderContext(options: PresetOptions): BuilderContext {
);
}

/**
* Deep merge function that properly handles nested objects. Preserves arrays and objects from
* source when they exist in target
*
* @internal - exported for testing purposes
*/
export function deepMerge(target: JsonObject, source: JsonObject): JsonObject {
const result = { ...target };

for (const key in source) {
if (source[key] !== undefined && source[key] !== null) {
if (
typeof source[key] === 'object' &&
!Array.isArray(source[key]) &&
typeof target[key] === 'object' &&
!Array.isArray(target[key]) &&
target[key] !== null
) {
// Deep merge nested objects
result[key] = deepMerge(target[key] as JsonObject, source[key] as JsonObject);
} else {
// Override with source value
result[key] = source[key];
}
}
}

return result;
}

/** Get builder options Merge target options from browser target and from storybook options */
async function getBuilderOptions(options: PresetOptions, builderContext: BuilderContext) {
export async function getBuilderOptions(options: PresetOptions, builderContext: BuilderContext) {
/** Get Browser Target options */
let browserTargetOptions: JsonObject = {};
if (options.angularBrowserTarget) {
Expand All @@ -95,15 +125,19 @@ async function getBuilderOptions(options: PresetOptions, builderContext: Builder
browserTargetOptions = await builderContext.getTargetOptions(browserTarget);
}

/** Merge target options from browser target options and from storybook options */
const builderOptions = {
...browserTargetOptions,
...options.angularBuilderOptions,
tsConfig:
options.tsConfig ??
(await findUp('tsconfig.json', { cwd: options.configDir, stopAt: getProjectRoot() })) ??
browserTargetOptions.tsConfig,
};
/**
* Merge target options from browser target options and from storybook options Use deep merge to
* preserve nested properties like stylePreprocessorOptions.includePaths when they exist in
* browserTarget but not in storybook options
*/
const builderOptions = deepMerge(browserTargetOptions, options.angularBuilderOptions || {});

// Handle tsConfig separately to maintain existing logic
builderOptions.tsConfig =
options.tsConfig ??
(await findUp('tsconfig.json', { cwd: options.configDir, stopAt: getProjectRoot() })) ??
browserTargetOptions.tsConfig;

logger.info(`=> Using angular project with "tsConfig:${builderOptions.tsConfig}"`);

return builderOptions;
Expand Down
Loading