Skip to content

Commit

Permalink
Merge pull request #319 from chromaui/determine-viewlayer
Browse files Browse the repository at this point in the history
Retrieve viewLayer and version from dependencies and support @web/dev-server-storybook
  • Loading branch information
ghengeveld committed Apr 23, 2021
2 parents 3d3f966 + 289b9b5 commit 575688b
Show file tree
Hide file tree
Showing 6 changed files with 217 additions and 77 deletions.
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.
`);
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');

0 comments on commit 575688b

Please sign in to comment.