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

Retrieve viewLayer and version from dependencies and support @web/dev-server-storybook #319

Merged
merged 7 commits into from Apr 23, 2021
175 changes: 98 additions & 77 deletions 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 }))
)
);

Expand Down
81 changes: 81 additions & 0 deletions 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' })
);
});
});
});
13 changes: 13 additions & 0 deletions 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')}
`);
7 changes: 7 additions & 0 deletions 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();
11 changes: 11 additions & 0 deletions 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.
ghengeveld marked this conversation as resolved.
Show resolved Hide resolved
`);
7 changes: 7 additions & 0 deletions 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');