diff --git a/bin/lib/getStorybookInfo.js b/bin/lib/getStorybookInfo.js index dcf5b5be1..9e72c82a3 100644 --- a/bin/lib/getStorybookInfo.js +++ b/bin/lib/getStorybookInfo.js @@ -1,110 +1,131 @@ // Figure out the Storybook version and view layer import fs from 'fs-extra'; +import semver from 'semver'; -const viewLayers = [ - 'react', - 'vue', - 'vue3', - 'angular', - 'html', - 'web-components', - 'polymer', - 'ember', - 'marko', - 'mithril', - 'riot', - 'svelte', - 'preact', - 'rax', -]; - -const supportedAddons = [ - 'a11y', - 'actions', - 'backgrounds', - 'centered', - 'contexts', - 'cssresources', - 'design-assets', - 'docs', - 'events', - 'google-analytics', - 'graphql', - 'info', - 'jest', - 'knobs', - 'links', - 'notes', - 'ondevice-actions', - 'ondevice-backgrounds', - 'ondevice-knobs', - 'ondevice-notes', - 'options', - 'queryparams', - 'storyshots', - 'storysource', - 'viewport', -]; - -const resolve = (name) => { +import noViewLayerDependency from '../ui/messages/errors/noViewLayerDependency'; +import noViewLayerPackage from '../ui/messages/errors/noViewLayerPackage'; + +const viewLayers = { + '@storybook/react': 'react', + '@storybook/vue': 'vue', + '@storybook/vue3': 'vue3', + '@storybook/angular': 'angular', + '@storybook/html': 'html', + '@storybook/web-components': 'web-components', + '@storybook/polymer': 'polymer', + '@storybook/ember': 'ember', + '@storybook/marko': 'marko', + '@storybook/mithril': 'mithril', + '@storybook/riot': 'riot', + '@storybook/svelte': 'svelte', + '@storybook/preact': 'preact', + '@storybook/rax': 'rax', + '@web/dev-server-storybook': 'web-components', +}; + +const supportedAddons = { + '@storybook/addon-a11y': 'a11y', + '@storybook/addon-actions': 'actions', + '@storybook/addon-backgrounds': 'backgrounds', + '@storybook/addon-centered': 'centered', + '@storybook/addon-contexts': 'contexts', + '@storybook/addon-cssresources': 'cssresources', + '@storybook/addon-design-assets': 'design-assets', + '@storybook/addon-docs': 'docs', + '@storybook/addon-events': 'events', + '@storybook/addon-google-analytics': 'google-analytics', + '@storybook/addon-graphql': 'graphql', + '@storybook/addon-info': 'info', + '@storybook/addon-jest': 'jest', + '@storybook/addon-knobs': 'knobs', + '@storybook/addon-links': 'links', + '@storybook/addon-notes': 'notes', + '@storybook/addon-ondevice-actions': 'ondevice-actions', + '@storybook/addon-ondevice-backgrounds': 'ondevice-backgrounds', + '@storybook/addon-ondevice-knobs': 'ondevice-knobs', + '@storybook/addon-ondevice-notes': 'ondevice-notes', + '@storybook/addon-options': 'options', + '@storybook/addon-queryparams': 'queryparams', + '@storybook/addon-storyshots': 'storyshots', + '@storybook/addon-storysource': 'storysource', + '@storybook/addon-viewport': 'viewport', +}; + +const resolve = (pkg) => { try { - const path = require.resolve(`@storybook/${name}/package.json`, { paths: [process.cwd()] }); + const path = require.resolve(`${pkg}/package.json`, { paths: [process.cwd()] }); return Promise.resolve(path); } catch (error) { return Promise.reject(error); } }; -const read = async (filepath) => JSON.parse(await fs.readFile(filepath, 'utf8')); - const timeout = (count) => new Promise((_, rej) => { setTimeout(() => rej(new Error('The attempt to find the Storybook version timed out')), count); }); -const neverResolve = new Promise(() => {}); -const disregard = () => neverResolve; - -const findViewlayer = async ({ env }) => { - // Allow setting Storybook version via CHROMATIC_STORYBOOK_VERSION='react@4.0-alpha.8' for unusual cases +const findViewlayer = async ({ env, log, options, packageJson }) => { + // Allow setting Storybook version via CHROMATIC_STORYBOOK_VERSION='@storybook/react@4.0-alpha.8' for unusual cases if (env.CHROMATIC_STORYBOOK_VERSION) { - const [viewLayer, version] = env.CHROMATIC_STORYBOOK_VERSION.split('@'); - if (!viewLayer || !version) { - throw new Error('CHROMATIC_STORYBOOK_VERSION was provided but could not be used'); + const [, p, v] = env.CHROMATIC_STORYBOOK_VERSION.match(/(.+)@(.+)$/) || []; + const version = semver.valid(v); // ensures we get a specific version, not a range + if (!p || !version) { + throw new Error( + 'Invalid CHROMATIC_STORYBOOK_VERSION; expecting something like "@storybook/react@6.2.0".' + ); + } + const viewLayer = viewLayers[p] || viewLayers[`@storybook/${p}`]; + if (!viewLayer) { + throw new Error(`Unsupported viewlayer specified in CHROMATIC_STORYBOOK_VERSION: ${p}`); } return { viewLayer, version }; } - // Try to find the Storybook viewlayer package - const findings = viewLayers.map((v) => resolve(v)); - const rejectedFindings = findings.map((p) => p.then(disregard, () => true)); - const allFailed = Promise.all(rejectedFindings).then(() => { - throw new Error( - 'Could not find a supported Storybook viewlayer package. Make sure one is installed, or set CHROMATIC_STORYBOOK_VERSION.' + // Pull the viewlayer from dependencies in package.json + const dep = Object.entries(packageJson.dependencies || {}).find(([p]) => viewLayers[p]); + const devDep = Object.entries(packageJson.devDependencies || {}).find(([p]) => viewLayers[p]); + const peerDep = Object.entries(packageJson.peerDependencies || {}).find(([p]) => viewLayers[p]); + const dependency = dep || devDep || peerDep; + + if (!dependency) { + throw new Error(noViewLayerDependency()); + } + if (dep && devDep && dep[0] === devDep[0]) { + log.warn( + `Found "${dep[0]}" in both "dependencies" and "devDependencies". This is probably a mistake.` ); - }); + } + if (dep && peerDep && dep[0] === peerDep[0]) { + log.warn( + `Found "${dep[0]}" in both "dependencies" and "peerDependencies". This is probably a mistake.` + ); + } + + const [pkg, version] = dependency; + const viewLayer = pkg.replace('@storybook/', ''); + + // If we won't need the Storybook CLI, we can exit early + // Note that `version` can be a semver range in this case. + if (options.storybookBuildDir) return { viewLayer, version }; + // Try to find the viewlayer package in node_modules so we know it's installed return Promise.race([ - ...findings.map((p, i) => - p.then( - (l) => read(l).then((r) => ({ viewLayer: viewLayers[i], ...r })), - disregard // keep it pending forever - ) - ), - allFailed, + resolve(pkg) + .then(fs.readJson) + .then((json) => ({ viewLayer, version: json.version })) + .catch(() => Promise.reject(new Error(noViewLayerPackage(pkg)))), timeout(10000), ]); }; const findAddons = async () => { const result = await Promise.all( - supportedAddons.map((name) => - resolve(`addon-${name}`) - .then((l) => - read(l).then((r) => ({ name, packageName: r.name, packageVersion: r.version })) - ) - .catch((e) => false) + Object.entries(supportedAddons).map(([pkg, name]) => + resolve(pkg) + .then(fs.readJson, () => false) + .then((pkgJson) => ({ name, packageName: pkgJson.name, packageVersion: pkgJson.version })) ) ); diff --git a/bin/lib/getStorybookInfo.test.js b/bin/lib/getStorybookInfo.test.js new file mode 100644 index 000000000..36dbd9b1c --- /dev/null +++ b/bin/lib/getStorybookInfo.test.js @@ -0,0 +1,81 @@ +import getStorybookInfo from './getStorybookInfo'; + +const log = { warn: jest.fn() }; +const context = { env: {}, log, options: {}, packageJson: {} }; + +const REACT = { '@storybook/react': '1.2.3' }; +const VUE = { '@storybook/vue': '1.2.3' }; + +describe('getStorybookInfo', () => { + it('returns viewLayer and version', async () => { + const ctx = { ...context, packageJson: { dependencies: REACT } }; + await expect(getStorybookInfo(ctx)).resolves.toEqual( + // We're getting the result of tracing chromatic-cli's node_modules here. + expect.objectContaining({ viewLayer: 'react', version: expect.any(String) }) + ); + }); + + it('throws on missing dependency', async () => { + await expect(getStorybookInfo(context)).rejects.toThrow('Storybook dependency not found'); + }); + + it('warns on duplicate devDependency', async () => { + const ctx = { ...context, packageJson: { dependencies: REACT, devDependencies: REACT } }; + await getStorybookInfo(ctx); + expect(log.warn).toHaveBeenCalledWith( + expect.stringContaining('both "dependencies" and "devDependencies"') + ); + }); + + it('warns on duplicate peerDependency', async () => { + const ctx = { ...context, packageJson: { dependencies: REACT, peerDependencies: REACT } }; + await getStorybookInfo(ctx); + expect(log.warn).toHaveBeenCalledWith( + expect.stringContaining('both "dependencies" and "peerDependencies"') + ); + }); + + it('throws on missing package', async () => { + const ctx = { ...context, packageJson: { dependencies: VUE } }; + await expect(getStorybookInfo(ctx)).rejects.toThrow('Storybook package not installed'); + }); + + describe('with CHROMATIC_STORYBOOK_VERSION', () => { + it('returns viewLayer and version from env', async () => { + const ctx = { ...context, env: { CHROMATIC_STORYBOOK_VERSION: '@storybook/react@3.2.1' } }; + await expect(getStorybookInfo(ctx)).resolves.toEqual( + expect.objectContaining({ viewLayer: 'react', version: '3.2.1' }) + ); + }); + + it('supports unscoped package name', async () => { + const ctx = { ...context, env: { CHROMATIC_STORYBOOK_VERSION: 'react@3.2.1' } }; + await expect(getStorybookInfo(ctx)).resolves.toEqual( + expect.objectContaining({ viewLayer: 'react', version: '3.2.1' }) + ); + }); + + it('throws on invalid value', async () => { + const ctx = { ...context, env: { CHROMATIC_STORYBOOK_VERSION: '3.2.1' } }; + await expect(getStorybookInfo(ctx)).rejects.toThrow('Invalid'); + }); + + it('throws on unsupported viewlayer', async () => { + const ctx = { ...context, env: { CHROMATIC_STORYBOOK_VERSION: '@storybook/native@3.2.1' } }; + await expect(getStorybookInfo(ctx)).rejects.toThrow('Unsupported'); + }); + }); + + describe('with --storybook-build-dir', () => { + it('returns viewLayer and version from packageJson', async () => { + const ctx = { + ...context, + options: { storybookBuildDir: 'storybook-static' }, + packageJson: { dependencies: REACT }, + }; + await expect(getStorybookInfo(ctx)).resolves.toEqual( + expect.objectContaining({ viewLayer: 'react', version: '1.2.3' }) + ); + }); + }); +}); diff --git a/bin/ui/messages/errors/noViewLayerDependency.js b/bin/ui/messages/errors/noViewLayerDependency.js new file mode 100644 index 000000000..0716673a6 --- /dev/null +++ b/bin/ui/messages/errors/noViewLayerDependency.js @@ -0,0 +1,13 @@ +import chalk from 'chalk'; +import dedent from 'ts-dedent'; + +import { error, info } from '../../components/icons'; +import link from '../../components/link'; + +export default () => + dedent(chalk` + ${error} {bold Storybook dependency not found} + Could not find a supported Storybook viewlayer dependency in your {bold package.json}. + Make sure you have setup Storybook and are running Chromatic from the same directory. + ${info} New to Storybook? Read ${link('https://www.chromatic.com/docs/storybook')} + `); diff --git a/bin/ui/messages/errors/noViewLayerDependency.stories.js b/bin/ui/messages/errors/noViewLayerDependency.stories.js new file mode 100644 index 000000000..6e331ce45 --- /dev/null +++ b/bin/ui/messages/errors/noViewLayerDependency.stories.js @@ -0,0 +1,7 @@ +import noViewLayerDependency from './noViewLayerDependency'; + +export default { + title: 'CLI/Messages/Errors', +}; + +export const NoViewLayerDependency = () => noViewLayerDependency(); diff --git a/bin/ui/messages/errors/noViewLayerPackage.js b/bin/ui/messages/errors/noViewLayerPackage.js new file mode 100644 index 000000000..9821bcff4 --- /dev/null +++ b/bin/ui/messages/errors/noViewLayerPackage.js @@ -0,0 +1,11 @@ +import chalk from 'chalk'; +import dedent from 'ts-dedent'; + +import { error } from '../../components/icons'; + +export default (pkg) => + dedent(chalk` + ${error} {bold Storybook package not installed} + Could not find {bold ${pkg}} in {bold node_modules}. + Most likely, you forgot to run {bold npm install} or {bold yarn} before running Chromatic. + `); diff --git a/bin/ui/messages/errors/noViewLayerPackage.stories.js b/bin/ui/messages/errors/noViewLayerPackage.stories.js new file mode 100644 index 000000000..fa04c2f0e --- /dev/null +++ b/bin/ui/messages/errors/noViewLayerPackage.stories.js @@ -0,0 +1,7 @@ +import noViewLayerPackage from './noViewLayerPackage'; + +export default { + title: 'CLI/Messages/Errors', +}; + +export const NoViewLayerPackage = () => noViewLayerPackage('@storybook/vue');