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

Fix determining viewLayer when using transitive dependency #344

Merged
merged 4 commits into from May 18, 2021
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
116 changes: 48 additions & 68 deletions bin/lib/getStorybookInfo.js
@@ -1,11 +1,8 @@
// Figure out the Storybook version and view layer

import fs from 'fs-extra';
import meow from 'meow';
import argv from 'string-argv';
import semver from 'semver';
import argv from 'string-argv';

import noViewLayerDependency from '../ui/messages/errors/noViewLayerDependency';
import noViewLayerPackage from '../ui/messages/errors/noViewLayerPackage';

const viewLayers = {
Expand All @@ -23,59 +20,36 @@ const viewLayers = {
'@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-essentials': 'essentials',
'@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',
};
// Double inversion on Promise.all means fulfilling with the first fulfilled promise, or rejecting
// when _everything_ rejects. This is different from Promise.race, which immediately rejects on the
// first rejection.
const invert = (promise) => new Promise((resolve, reject) => promise.then(reject, resolve));
const raceFulfilled = (promises) => invert(Promise.all(promises.map(invert)).then((arr) => arr[0]));
ghengeveld marked this conversation as resolved.
Show resolved Hide resolved

const resolve = (pkg) => {
const timeout = (count) =>
new Promise((_, rej) => {
setTimeout(() => rej(new Error('Timeout while resolving Storybook view layer package')), count);
});

const resolvePackageJson = (pkg) => {
try {
const path = require.resolve(`${pkg}/package.json`, { paths: [process.cwd()] });
return Promise.resolve(path);
return fs.readJson(path);
} catch (error) {
return Promise.reject(error);
}
};

const timeout = (count) =>
new Promise((_, rej) => {
setTimeout(() => rej(new Error('The attempt to find the Storybook version timed out')), count);
});

const findDependency = ({ dependencies, devDependencies, peerDependencies }, predicate) => [
Object.entries(dependencies || {}).find(predicate),
Object.entries(devDependencies || {}).find(predicate),
Object.entries(peerDependencies || {}).find(predicate),
];

const findViewlayer = async ({ env, log, options, packageJson }) => {
// Retrieves Storybook version and viewLayer
export const getViewLayer = 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 [, p, v] = env.CHROMATIC_STORYBOOK_VERSION.match(/(.+)@(.+)$/) || [];
Expand All @@ -94,11 +68,9 @@ const findViewlayer = async ({ env, log, options, packageJson }) => {

// Pull the viewlayer from dependencies in package.json
const [dep, devDep, peerDep] = findDependency(packageJson, ([key]) => viewLayers[key]);
const dependency = dep || devDep || peerDep;
const [pkg, version] = dep || devDep || peerDep || [];
const viewLayer = viewLayers[pkg];

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.`
Expand All @@ -110,34 +82,40 @@ const findViewlayer = async ({ env, log, options, packageJson }) => {
);
}

const [pkg, version] = dependency;
const viewLayer = pkg.replace('@storybook/', '');
if (viewLayer) {
if (options.storybookBuildDir) {
// If we aren't going to invoke the Storybook CLI later, we can exit early.
// Note that `version` can be a semver range in this case.
return { viewLayer, version };
}

// 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 };
// Verify that the viewlayer package is actually present in node_modules.
return Promise.race([
resolvePackageJson(pkg)
.then((json) => ({ viewLayer, version: json.version }))
.catch(() => Promise.reject(new Error(noViewLayerPackage(pkg)))),
timeout(10000),
]);
}

log.debug(`No viewlayer package listed in dependencies. Checking transitive dependencies.`);

// Try to find the viewlayer package in node_modules so we know it's installed
// We might have a transitive dependency (e.g. through `@nuxtjs/storybook` which depends on
// `@storybook/vue`). In this case we look for any viewlayer package present in node_modules,
// and return the first one we find.
return Promise.race([
resolve(pkg)
.then(fs.readJson)
.then((json) => ({ viewLayer, version: json.version }))
.catch(() => Promise.reject(new Error(noViewLayerPackage(pkg)))),
raceFulfilled(
Object.entries(viewLayers).map(async ([key, value]) => {
const json = await resolvePackageJson(key);
return { viewLayer: value, version: json.version };
})
).catch(() => Promise.reject(new Error(noViewLayerPackage(pkg)))),
timeout(10000),
]);
};

const findAddons = async ({ packageJson }) => ({
addons: Object.entries(supportedAddons)
.map(([pkg, name]) => {
const [dep, devDep, peerDep] = findDependency(packageJson, ([key]) => key === pkg);
const [packageName, packageVersion] = dep || devDep || peerDep || [];
return packageName && packageVersion && { name, packageName, packageVersion };
})
.filter(Boolean),
});

const findConfigFlags = async ({ options, packageJson }) => {
// Retrieves relevant config flags from the `build-storybook` script
export const getConfigFlags = ({ options, packageJson }) => {
const { scripts = {} } = packageJson;
if (!options.buildScriptName || !scripts[options.buildScriptName]) return {};

Expand All @@ -156,6 +134,8 @@ const findConfigFlags = async ({ options, packageJson }) => {
};

export default async function getStorybookInfo(ctx) {
const info = await Promise.all([findAddons(ctx), findConfigFlags(ctx), findViewlayer(ctx)]);
return info.reduce((acc, obj) => Object.assign(acc, obj), {});
return {
...(await getViewLayer(ctx)),
...getConfigFlags(ctx),
};
}
16 changes: 11 additions & 5 deletions bin/lib/getStorybookInfo.test.js
@@ -1,6 +1,6 @@
import getStorybookInfo from './getStorybookInfo';

const log = { warn: jest.fn() };
const log = { warn: jest.fn(), debug: jest.fn() };
const context = { env: {}, log, options: {}, packageJson: {} };

const REACT = { '@storybook/react': '1.2.3' };
Expand All @@ -15,10 +15,6 @@ describe('getStorybookInfo', () => {
);
});

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);
Expand All @@ -40,6 +36,16 @@ describe('getStorybookInfo', () => {
await expect(getStorybookInfo(ctx)).rejects.toThrow('Storybook package not installed');
});

it('looks up package in node_modules on missing dependency', async () => {
await expect(getStorybookInfo(context)).resolves.toEqual(
// We're getting the result of tracing chromatic-cli's node_modules here.
expect.objectContaining({ viewLayer: 'react', version: expect.any(String) })
);
expect(log.debug).toHaveBeenCalledWith(
expect.stringContaining('No viewlayer package listed in dependencies')
);
});

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' } };
Expand Down
6 changes: 1 addition & 5 deletions bin/main.test.js
Expand Up @@ -143,11 +143,7 @@ jest.mock('./git/git', () => ({
}));

jest.mock('./lib/startStorybook');
jest.mock('./lib/getStorybookInfo', () => () => ({
version: '5.1.0',
viewLayer: 'viewLayer',
addons: [],
}));
jest.mock('./lib/getStorybookInfo', () => () => ({ version: '5.1.0', viewLayer: 'viewLayer' }));
jest.mock('./lib/tunnel');
jest.mock('./lib/uploadFiles');

Expand Down
2 changes: 1 addition & 1 deletion bin/tasks/storybookInfo.test.js
Expand Up @@ -5,7 +5,7 @@ jest.mock('../lib/getStorybookInfo');

describe('startStorybook', () => {
it('starts the app and sets the isolatorUrl on context', async () => {
const storybook = { version: '1.0.0', viewLayer: 'react', addons: [] };
const storybook = { version: '1.0.0', viewLayer: 'react' };
getStorybookInfo.mockReturnValue(storybook);

const ctx = {};
Expand Down
1 change: 0 additions & 1 deletion bin/tasks/verify.js
Expand Up @@ -99,7 +99,6 @@ export const createBuild = async (ctx, task) => {
packageVersion: ctx.pkg.version,
storybookVersion: ctx.storybook.version,
viewLayer: ctx.storybook.viewLayer,
addons: ctx.storybook.addons,
},
isolatorUrl,
});
Expand Down
3 changes: 1 addition & 2 deletions bin/tasks/verify.test.js
Expand Up @@ -27,7 +27,7 @@ describe('createBuild', () => {
environment: ':environment',
git: { version: 'whatever', matchesBranch: () => false },
pkg: { version: '1.0.0' },
storybook: { version: '2.0.0', viewLayer: 'react', addons: [] },
storybook: { version: '2.0.0', viewLayer: 'react' },
isolatorUrl: 'https://tunnel.chromaticqa.com/',
};

Expand All @@ -52,7 +52,6 @@ describe('createBuild', () => {
packageVersion: ctx.pkg.version,
storybookVersion: ctx.storybook.version,
viewLayer: ctx.storybook.viewLayer,
addons: ctx.storybook.addons,
},
isolatorUrl: ctx.isolatorUrl,
}
Expand Down
13 changes: 0 additions & 13 deletions bin/ui/messages/errors/noViewLayerDependency.js

This file was deleted.

7 changes: 0 additions & 7 deletions bin/ui/messages/errors/noViewLayerDependency.stories.js

This file was deleted.

8 changes: 2 additions & 6 deletions bin/ui/tasks/storybookInfo.js
Expand Up @@ -4,12 +4,8 @@ const capitalize = (string) =>
.map((str) => str.charAt(0).toUpperCase() + str.slice(1))
.join(' ');

const infoMessage = ({ addons, version, viewLayer }) => {
const info = `Storybook v${version} for ${capitalize(viewLayer)}`;
return addons.length
? `${info}; supported addons found: ${addons.map((addon) => capitalize(addon.name)).join(', ')}`
: `${info}; no supported addons found`;
};
const infoMessage = ({ version, viewLayer }) =>
`Storybook v${version} for ${capitalize(viewLayer)}`;

export const initial = {
status: 'initial',
Expand Down
9 changes: 1 addition & 8 deletions bin/ui/tasks/storybookInfo.stories.js
Expand Up @@ -6,17 +6,10 @@ export default {
decorators: [(storyFn) => task(storyFn())],
};

const storybook = {
version: '5.3.0',
viewLayer: 'web-components',
addons: [],
};
const addons = [{ name: 'actions' }, { name: 'docs' }, { name: 'design-assets' }];
const storybook = { version: '5.3.0', viewLayer: 'web-components' };

export const Initial = () => initial;

export const Pending = () => pending();

export const Success = () => success({ storybook });

export const WithAddons = () => success({ storybook: { ...storybook, addons } });