Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

CLI: Automigration for upgrading storybook related dependencies #26377

Merged
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
2 changes: 2 additions & 0 deletions code/lib/cli/src/automigrate/fixes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { removeJestTestingLibrary } from './remove-jest-testing-library';
import { addonsAPI } from './addons-api';
import { mdx1to3 } from './mdx-1-to-3';
import { addonPostCSS } from './addon-postcss';
import { upgradeStorybookRelatedDependencies } from './upgrade-storybook-related-dependencies';

export * from '../types';

Expand Down Expand Up @@ -56,6 +57,7 @@ export const allFixes: Fix[] = [
removeLegacyMDX1,
webpack5CompilerSetup,
mdx1to3,
upgradeStorybookRelatedDependencies,
];

export const initFixes: Fix[] = [eslintPlugin];
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { describe, afterEach, it, expect, vi } from 'vitest';
import type { StorybookConfig } from '@storybook/types';
import type { JsPackageManager } from '@storybook/core-common';
import * as docsUtils from '../../doctor/getIncompatibleStorybookPackages';

import { upgradeStorybookRelatedDependencies } from './upgrade-storybook-related-dependencies';

vi.mock('../../doctor/getIncompatibleStorybookPackages');

const check = async ({
packageManager,
main: mainConfig = {},
storybookVersion = '8.0.0',
}: {
packageManager: Partial<JsPackageManager>;
main?: Partial<StorybookConfig> & Record<string, unknown>;
storybookVersion?: string;
}) => {
return upgradeStorybookRelatedDependencies.check({
packageManager: packageManager as any,
configDir: '',
mainConfig: mainConfig as any,
storybookVersion,
});
};

describe('upgrade-storybook-related-dependencies fix', () => {
afterEach(() => {
vi.restoreAllMocks();
});

it('should detect storyshots registered in main.js', async () => {
const analyzedPackages = [
{
packageName: '@chromatic-com/storybook',
packageVersion: '1.2.9',
availableUpgrade: '2.0.0',
hasIncompatibleDependencies: false,
},
{
packageName: '@storybook/jest',
packageVersion: '0.2.3',
availableUpgrade: '1.0.0',
hasIncompatibleDependencies: false,
},
{
packageName: '@storybook/preset-create-react-app',
packageVersion: '3.2.0',
availableUpgrade: '8.0.0',
hasIncompatibleDependencies: true,
},
{
packageName: 'storybook',
packageVersion: '8.0.0',
availableUpgrade: undefined,
hasIncompatibleDependencies: true,
},
];
vi.mocked(docsUtils.getIncompatibleStorybookPackages).mockResolvedValue(analyzedPackages);
await expect(
check({
packageManager: {
getAllDependencies: async () => ({
'@chromatic-com/storybook': '1.2.9',
'@storybook/jest': '0.2.3',
'@storybook/preset-create-react-app': '3.2.0',
storybook: '8.0.0',
}),
latestVersion: async (pkgName) =>
analyzedPackages.find((pkg) => pkg.packageName === pkgName)?.availableUpgrade || '',
},
})
).resolves.toMatchInlineSnapshot(`
{
"upgradable": [
{
"afterVersion": "2.0.0",
"beforeVersion": "1.2.9",
"packageName": "@chromatic-com/storybook",
},
{
"afterVersion": "1.0.0",
"beforeVersion": "0.2.3",
"packageName": "@storybook/jest",
},
{
"afterVersion": "8.0.0",
"beforeVersion": "3.2.0",
"packageName": "@storybook/preset-create-react-app",
},
],
}
`);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { dedent } from 'ts-dedent';
import { cyan, yellow } from 'chalk';
import { valid, coerce } from 'semver';
import type { JsPackageManager } from '@storybook/core-common';
import { isCorePackage } from '@storybook/core-common';
import type { Fix } from '../types';
import { getIncompatibleStorybookPackages } from '../../doctor/getIncompatibleStorybookPackages';

type PackageMetadata = {
packageName: string;
beforeVersion: string | null;
afterVersion: string | null;
};

interface Options {
upgradable: PackageMetadata[];
}

async function getLatestVersions(
packageManager: JsPackageManager,
packages: [string, string][]
): Promise<PackageMetadata[]> {
return Promise.all(
packages.map(async ([packageName, beforeVersion]) => ({
packageName,
beforeVersion: coerce(beforeVersion)?.toString() || null,
afterVersion: await packageManager.latestVersion(packageName).catch(() => null),
}))
);
}

function isPackageUpgradable(
afterVersion: string,
packageName: string,
allDependencies: Record<string, string>
) {
const installedVersion = coerce(allDependencies[packageName])?.toString();

return valid(afterVersion) && afterVersion !== installedVersion;
}

/**
* Is the user upgrading to the `latest` version of Storybook?
* Let's try to pull along some of the storybook related dependencies to `latest` as well!
*
* We communicate clearly that this migration is a helping hand, but not a complete solution.
* The user should still manually check for other dependencies that might be incompatible.
*
* see: https://github.com/storybookjs/storybook/issues/25731#issuecomment-1977346398
*/
export const upgradeStorybookRelatedDependencies = {
ndelangen marked this conversation as resolved.
Show resolved Hide resolved
id: 'upgradeStorybookRelatedDependencies',
versionRange: ['*.*.*', '*.*.*'],
promptType: 'auto',
promptDefaultValue: false,

async check({ packageManager, storybookVersion }) {
const analyzedPackages = await getIncompatibleStorybookPackages({
currentStorybookVersion: storybookVersion,
packageManager,
skipErrors: true,
});

const allDependencies = (await packageManager.getAllDependencies()) as Record<string, string>;
const storybookDependencies = Object.keys(allDependencies)
.filter((dep) => dep.includes('storybook'))
.filter((dep) => !isCorePackage(dep));
const incompatibleDependencies = analyzedPackages
.filter((pkg) => pkg.hasIncompatibleDependencies)
.map((pkg) => pkg.packageName);

const uniquePackages = Array.from(
new Set([...storybookDependencies, ...incompatibleDependencies])
).map((packageName) => [packageName, allDependencies[packageName]]) as [string, string][];

const packageVersions = await getLatestVersions(packageManager, uniquePackages);

const upgradablePackages = packageVersions.filter(
({ packageName, afterVersion, beforeVersion }) => {
if (beforeVersion === null || afterVersion === null) {
return false;
}

return isPackageUpgradable(afterVersion, packageName, allDependencies);
}
);

return upgradablePackages.length > 0 ? { upgradable: upgradablePackages } : null;
},

prompt({ upgradable }) {
return dedent`
You're upgrading to the latest version of Storybook. We recommend upgrading the following packages:
${upgradable
.map(({ packageName, afterVersion, beforeVersion }) => {
return `- ${cyan(packageName)}: ${cyan(beforeVersion)} => ${cyan(afterVersion)}`;
})
.join('\n')}

After upgrading, we will run the dedupe command, which could possibly have effects on dependencies that are not Storybook related.
see: https://docs.npmjs.com/cli/commands/npm-dedupe

Do you want to proceed (upgrade the detected packages)?
`;
},

async run({ result: { upgradable }, packageManager, dryRun }) {
if (dryRun) {
console.log(dedent`
We would have upgrade the following:
${upgradable
.map(
({ packageName, afterVersion, beforeVersion }) =>
`${packageName}: ${beforeVersion} => ${afterVersion}`
)
.join('\n')}
`);
return;
}

if (upgradable.length > 0) {
const packageJson = await packageManager.readPackageJson();

upgradable.forEach((item) => {
if (!item) {
return;
}

const { packageName, afterVersion: version } = item;
const prefixed = `^${version}`;

if (packageJson.dependencies?.[packageName]) {
packageJson.dependencies[packageName] = prefixed;
}
if (packageJson.devDependencies?.[packageName]) {
packageJson.devDependencies[packageName] = prefixed;
}
if (packageJson.peerDependencies?.[packageName]) {
packageJson.peerDependencies[packageName] = prefixed;
}
});

await packageManager.writePackageJson(packageJson);
await packageManager.installDependencies();

await packageManager
.executeCommand({ command: 'dedupe', args: [], stdio: 'ignore' })
ndelangen marked this conversation as resolved.
Show resolved Hide resolved
.catch(() => {});

console.log();
console.log(dedent`
We upgraded ${yellow(upgradable.length)} packages:
${upgradable
.map(({ packageName, afterVersion, beforeVersion }) => {
return `- ${cyan(packageName)}: ${cyan(beforeVersion)} => ${cyan(afterVersion)}`;
})
.join('\n')}
`);
}
console.log();
},
} satisfies Fix<Options>;
42 changes: 34 additions & 8 deletions code/lib/cli/src/automigrate/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ import { getMigrationSummary } from './helpers/getMigrationSummary';
import { getStorybookData } from './helpers/mainConfigFile';
import { doctor } from '../doctor';

import { upgradeStorybookRelatedDependencies } from './fixes/upgrade-storybook-related-dependencies';
import dedent from 'ts-dedent';

const logger = console;
const LOG_FILE_NAME = 'migration-storybook.log';
const LOG_FILE_PATH = join(process.cwd(), LOG_FILE_NAME);
Expand Down Expand Up @@ -56,8 +59,16 @@ const cleanup = () => {
};

const logAvailableMigrations = () => {
const availableFixes = allFixes.map((f) => chalk.yellow(f.id)).join(', ');
logger.info(`\nThe following migrations are available: ${availableFixes}`);
const availableFixes = allFixes
.map((f) => chalk.yellow(f.id))
.map((x) => `- ${x}`)
.join('\n');

console.log();
logger.info(dedent`
The following migrations are available:
${availableFixes}
`);
};

export const doAutomigrate = async (options: AutofixOptionsFromCLI) => {
Expand All @@ -84,7 +95,7 @@ export const doAutomigrate = async (options: AutofixOptionsFromCLI) => {
throw new Error('Could not determine main config path');
}

await automigrate({
const outcome = await automigrate({
...options,
packageManager,
storybookVersion,
Expand All @@ -94,7 +105,9 @@ export const doAutomigrate = async (options: AutofixOptionsFromCLI) => {
isUpgrade: false,
});

await doctor({ configDir, packageManager: options.packageManager });
if (outcome) {
ndelangen marked this conversation as resolved.
Show resolved Hide resolved
await doctor({ configDir, packageManager: options.packageManager });
}
};

export const automigrate = async ({
Expand All @@ -121,8 +134,21 @@ export const automigrate = async ({
return null;
}

const selectedFixes = inputFixes || allFixes;
const fixes = fixId ? selectedFixes.filter((f) => f.id === fixId) : selectedFixes;
const selectedFixes: Fix[] =
shilman marked this conversation as resolved.
Show resolved Hide resolved
inputFixes ||
allFixes.filter((fix) => {
// we only allow this automigration when the user explicitly asks for it, or they are upgrading to the latest version of storybook
if (
fix.id === upgradeStorybookRelatedDependencies.id &&
isUpgrade !== 'latest' &&
fixId !== upgradeStorybookRelatedDependencies.id
) {
return false;
}

return true;
});
const fixes: Fix[] = fixId ? selectedFixes.filter((f) => f.id === fixId) : selectedFixes;

if (fixId && fixes.length === 0) {
logger.info(`📭 No migrations found for ${chalk.magenta(fixId)}.`);
Expand All @@ -143,7 +169,7 @@ export const automigrate = async ({
mainConfigPath,
storybookVersion,
beforeVersion,
isUpgrade,
isUpgrade: !!isUpgrade,
dryRun,
yes,
});
Expand Down Expand Up @@ -314,7 +340,7 @@ export async function runFixes({
type: 'confirm',
name: 'fix',
message: `Do you want to run the '${chalk.cyan(f.id)}' migration on your project?`,
initial: true,
initial: f.promptDefaultValue ?? true,
},
{
onCancel: () => {
Expand Down
3 changes: 2 additions & 1 deletion code/lib/cli/src/automigrate/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ type BaseFix<ResultType = any> = {
versionRange: [from: string, to: string];
check: (options: CheckOptions) => Promise<ResultType | null>;
prompt: (result: ResultType) => string;
promptDefaultValue?: boolean;
};

type PromptType<ResultType = any, T = Prompt> =
Expand Down Expand Up @@ -74,7 +75,7 @@ export interface AutofixOptions extends Omit<AutofixOptionsFromCLI, 'packageMana
/**
* Whether the migration is part of an upgrade.
*/
isUpgrade: boolean;
isUpgrade: false | true | 'latest';
}
export interface AutofixOptionsFromCLI {
fixId?: FixId;
Expand Down
2 changes: 1 addition & 1 deletion code/lib/cli/src/upgrade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,7 @@ export const doUpgrade = async ({
mainConfigPath,
beforeVersion,
storybookVersion: currentVersion,
isUpgrade: true,
isUpgrade: isOutdated ? true : 'latest',
});
}

Expand Down
Loading