Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { describe, expect, it } from 'vitest';

import { deriveStorybookPlatformScripts } from './generateScripts.ts';

describe('deriveStorybookPlatformScripts', () => {
it('derives storybook platform scripts from ios and android scripts', () => {
const inputScripts = {
ios: 'react-native run-ios',
android: 'react-native run-android',
start: 'react-native start',
};

const result = deriveStorybookPlatformScripts(inputScripts);

expect(result.scriptsToAdd).toEqual({
'storybook:ios': 'cross-env STORYBOOK_ENABLED=true react-native run-ios',
'storybook:android': 'cross-env STORYBOOK_ENABLED=true react-native run-android',
});
expect(result.missingBaseScripts).toEqual([]);
});

it('reports missing source scripts and only emits available platform scripts', () => {
const result = deriveStorybookPlatformScripts({
ios: 'react-native run-ios',
});

expect(result.scriptsToAdd).toEqual({
'storybook:ios': 'cross-env STORYBOOK_ENABLED=true react-native run-ios',
});
expect(result.missingBaseScripts).toEqual(['android']);
});

it('does not double-prefix when the base script already sets STORYBOOK_ENABLED=true', () => {
const result = deriveStorybookPlatformScripts({
ios: 'cross-env STORYBOOK_ENABLED=true react-native run-ios',
android: 'STORYBOOK_ENABLED=true react-native run-android',
});

expect(result.scriptsToAdd).toEqual({
'storybook:ios': 'cross-env STORYBOOK_ENABLED=true react-native run-ios',
'storybook:android': 'STORYBOOK_ENABLED=true react-native run-android',
});
expect(result.missingBaseScripts).toEqual([]);
});

it('respects an explicit STORYBOOK_ENABLED=false in the base script and does not override it', () => {
const result = deriveStorybookPlatformScripts({
ios: 'cross-env STORYBOOK_ENABLED=false react-native run-ios',
});

expect(result.scriptsToAdd).toEqual({
'storybook:ios': 'cross-env STORYBOOK_ENABLED=false react-native run-ios',
});
});

it('injects STORYBOOK_ENABLED into an existing cross-env prefix instead of nesting cross-env', () => {
const result = deriveStorybookPlatformScripts({
ios: 'cross-env FOO=bar react-native run-ios',
android: 'cross-env FOO=bar BAZ=qux react-native run-android',
});

expect(result.scriptsToAdd).toEqual({
'storybook:ios': 'cross-env STORYBOOK_ENABLED=true FOO=bar react-native run-ios',
'storybook:android':
'cross-env STORYBOOK_ENABLED=true FOO=bar BAZ=qux react-native run-android',
});
});

it('injects STORYBOOK_ENABLED into an existing cross-env-shell prefix', () => {
const result = deriveStorybookPlatformScripts({
ios: 'cross-env-shell FOO=bar "react-native run-ios && echo done"',
});

expect(result.scriptsToAdd).toEqual({
'storybook:ios':
'cross-env-shell STORYBOOK_ENABLED=true FOO=bar "react-native run-ios && echo done"',
});
});

it('trims surrounding whitespace before applying the prefix', () => {
const result = deriveStorybookPlatformScripts({
ios: ' react-native run-ios ',
});

expect(result.scriptsToAdd).toEqual({
'storybook:ios': 'cross-env STORYBOOK_ENABLED=true react-native run-ios',
});
});

it('does not mutate unrelated scripts', () => {
const inputScripts = {
ios: 'react-native run-ios',
android: 'react-native run-android',
storybook: 'cross-env STORYBOOK_ENABLED=true react-native start',
test: 'jest',
lint: 'eslint .',
'storybook:web': 'storybook dev -p 6006',
'build-storybook': 'storybook build',
};
const snapshot = { ...inputScripts };

deriveStorybookPlatformScripts(inputScripts);

expect(inputScripts).toEqual(snapshot);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
type PlatformScriptName = 'ios' | 'android';

export interface StorybookPlatformScriptDerivationResult {
scriptsToAdd: Partial<Record<'storybook:ios' | 'storybook:android', string>>;
missingBaseScripts: PlatformScriptName[];
}

const STORYBOOK_ENV_ASSIGNMENT = 'STORYBOOK_ENABLED=true';
const STORYBOOK_ENV_PREFIX = `cross-env ${STORYBOOK_ENV_ASSIGNMENT}`;
const STORYBOOK_ENV_PATTERN = /(?:^|\s)STORYBOOK_ENABLED=/;
const CROSS_ENV_PREFIX_PATTERN = /^(cross-env(?:-shell)?\s+)/;

const withStorybookEnv = (scriptValue: string) => {
const normalizedScriptValue = scriptValue.trim();

if (STORYBOOK_ENV_PATTERN.test(normalizedScriptValue)) {
return normalizedScriptValue;
}

if (CROSS_ENV_PREFIX_PATTERN.test(normalizedScriptValue)) {
return normalizedScriptValue.replace(
CROSS_ENV_PREFIX_PATTERN,
`$1${STORYBOOK_ENV_ASSIGNMENT} `
);
}

return `${STORYBOOK_ENV_PREFIX} ${normalizedScriptValue}`.trim();
};

export const deriveStorybookPlatformScripts = (
scripts: Record<string, unknown> | undefined
): StorybookPlatformScriptDerivationResult => {
const scriptsToAdd: StorybookPlatformScriptDerivationResult['scriptsToAdd'] = {};
const missingBaseScripts: PlatformScriptName[] = [];

const iosScript = typeof scripts?.ios === 'string' ? scripts.ios.trim() : '';
if (iosScript) {
scriptsToAdd['storybook:ios'] = withStorybookEnv(iosScript);
} else {
missingBaseScripts.push('ios');
}

const androidScript = typeof scripts?.android === 'string' ? scripts.android.trim() : '';
if (androidScript) {
scriptsToAdd['storybook:android'] = withStorybookEnv(androidScript);
} else {
missingBaseScripts.push('android');
}

return { scriptsToAdd, missingBaseScripts };
};
174 changes: 173 additions & 1 deletion code/lib/create-storybook/src/generators/REACT_NATIVE/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,179 @@
import { describe, expect, it } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import { copyTemplateFiles, getBabelDependencies } from 'storybook/internal/cli';
import { logger } from 'storybook/internal/node-logger';
import { SupportedLanguage } from 'storybook/internal/types';

import { DependencyCollector } from '../../dependency-collector.ts';
import reactNativeGenerator from './index.ts';
import { generateReactNativeEntrypoint } from './generateEntrypoint.ts';
import { runMetroCodemodOrFallback } from './metroConfig.ts';
import { detectReactNativeEntrypointTemplateVariant } from './index.ts';

vi.mock('storybook/internal/cli', { spy: true });
vi.mock('storybook/internal/node-logger', { spy: true });
vi.mock('./generateEntrypoint', { spy: true });
vi.mock('./metroConfig', { spy: true });

describe('REACT_NATIVE generator module', () => {
const createPackageManager = (scripts?: Record<string, string>) =>
({
getDependencyVersion: vi.fn().mockReturnValue(null),
getAllDependencies: vi.fn().mockReturnValue({}),
getVersionedPackages: vi.fn().mockResolvedValue([]),
addScripts: vi.fn(),
getRunCommand: vi.fn((scriptName: string) => `npm run ${scriptName}`),
primaryPackageJson: {
packageJson: {
scripts,
},
},
}) as any;

beforeEach(() => {
vi.mocked(getBabelDependencies).mockResolvedValue([]);
vi.mocked(copyTemplateFiles).mockResolvedValue();
vi.mocked(generateReactNativeEntrypoint).mockResolvedValue({
targetPath: '.rnstorybook/index.js',
extension: 'js',
});
vi.mocked(runMetroCodemodOrFallback).mockResolvedValue({
status: 'updated',
});
vi.mocked(logger.log).mockImplementation(() => {});
});

it('generates RFC entrypoint and platform scripts based on detected language', async () => {
const packageManager = createPackageManager({
ios: 'react-native run-ios',
android: 'react-native run-android',
start: 'react-native start',
test: 'jest',
});

await reactNativeGenerator.configure(packageManager, {
framework: null,
renderer: reactNativeGenerator.metadata.renderer,
builder: reactNativeGenerator.metadata.builderOverride as any,
language: SupportedLanguage.JAVASCRIPT,
features: new Set(),
dependencyCollector: new DependencyCollector(),
yes: true,
});

expect(generateReactNativeEntrypoint).toHaveBeenCalledWith({
language: SupportedLanguage.JAVASCRIPT,
});
expect(packageManager.getVersionedPackages).toHaveBeenCalledWith(
expect.arrayContaining(['cross-env'])
);
expect(packageManager.addScripts).toHaveBeenCalledWith({
'storybook:ios': 'cross-env STORYBOOK_ENABLED=true react-native run-ios',
'storybook:android': 'cross-env STORYBOOK_ENABLED=true react-native run-android',
});
expect(runMetroCodemodOrFallback).toHaveBeenCalled();
});

it('overwrites existing storybook platform scripts when deriving new values', async () => {
const packageManager = createPackageManager({
ios: 'react-native run-ios',
android: 'react-native run-android',
'storybook:ios': 'echo old-ios',
'storybook:android': 'echo old-android',
});

await reactNativeGenerator.configure(packageManager, {
framework: null,
renderer: reactNativeGenerator.metadata.renderer,
builder: reactNativeGenerator.metadata.builderOverride as any,
language: SupportedLanguage.TYPESCRIPT,
features: new Set(),
dependencyCollector: new DependencyCollector(),
yes: true,
});

expect(packageManager.addScripts).toHaveBeenCalledWith(
expect.objectContaining({
'storybook:ios': 'cross-env STORYBOOK_ENABLED=true react-native run-ios',
'storybook:android': 'cross-env STORYBOOK_ENABLED=true react-native run-android',
})
);
});

it('postConfigure removes legacy entry replacement copy', async () => {
const packageManager = createPackageManager({
ios: 'react-native run-ios',
android: 'react-native run-android',
});

await reactNativeGenerator.configure(packageManager, {
framework: null,
renderer: reactNativeGenerator.metadata.renderer,
builder: reactNativeGenerator.metadata.builderOverride as any,
language: SupportedLanguage.JAVASCRIPT,
features: new Set(),
dependencyCollector: new DependencyCollector(),
yes: true,
});
await reactNativeGenerator.postConfigure?.({ packageManager });

const logged = String(vi.mocked(logger.log).mock.calls.at(-1)?.[0] ?? '');
expect(logged).not.toContain('Replace the contents of your app entry');
expect(logged).toContain('npm run storybook:ios');
expect(logged).toContain('npm run storybook:android');
});

it('postConfigure shows env fallback warning when scripts are missing', async () => {
const packageManager = createPackageManager({
start: 'react-native start',
});

await reactNativeGenerator.configure(packageManager, {
framework: null,
renderer: reactNativeGenerator.metadata.renderer,
builder: reactNativeGenerator.metadata.builderOverride as any,
language: SupportedLanguage.JAVASCRIPT,
features: new Set(),
dependencyCollector: new DependencyCollector(),
yes: true,
});
await reactNativeGenerator.postConfigure?.({ packageManager });

expect(packageManager.addScripts).toHaveBeenCalledWith({});
expect(packageManager.getVersionedPackages).toHaveBeenCalledWith(
expect.not.arrayContaining(['cross-env'])
);

const logged = String(vi.mocked(logger.log).mock.calls.at(-1)?.[0] ?? '');
expect(logged).toContain('STORYBOOK_ENABLED=true');
expect(logged).toContain('Could not infer');
});

it('does not add cross-env when it is already a dependency', async () => {
const packageManager = createPackageManager({
ios: 'react-native run-ios',
android: 'react-native run-android',
});
packageManager.getDependencyVersion = vi.fn((dep: string) =>
dep === 'cross-env' ? '^7.0.3' : null
);

await reactNativeGenerator.configure(packageManager, {
framework: null,
renderer: reactNativeGenerator.metadata.renderer,
builder: reactNativeGenerator.metadata.builderOverride as any,
language: SupportedLanguage.JAVASCRIPT,
features: new Set(),
dependencyCollector: new DependencyCollector(),
yes: true,
});

expect(packageManager.getVersionedPackages).toHaveBeenCalledWith(
expect.not.arrayContaining(['cross-env'])
);
});
});

describe('detectReactNativeEntrypointTemplateVariant', () => {
it('returns expo when expo dependency is present', () => {
expect(
Expand Down
Loading
Loading