chromatic
Version:
Automate visual testing across browsers. Gather UI feedback. Versioned documentation.
178 lines (159 loc) • 5.53 kB
JavaScript
/* eslint-disable no-param-reassign */
import fs from 'fs-extra';
import { join } from 'path';
import slash from 'slash';
import { URL } from 'url';
import { getDependentStoryFiles } from '../lib/getDependentStoryFiles';
import { createTask, transitionTo } from '../lib/tasks';
import uploadFiles from '../lib/uploadFiles';
import deviatingOutputDir from '../ui/messages/warnings/deviatingOutputDir';
import noStatsFile from '../ui/messages/warnings/noStatsFile';
import {
failed,
initial,
skipped,
validating,
invalid,
preparing,
tracing,
traced,
starting,
success,
uploading,
} from '../ui/tasks/upload';
const TesterGetUploadUrlsMutation = `
mutation TesterGetUploadUrlsMutation($paths: [String!]!) {
getUploadUrls(paths: $paths) {
domain
urls {
path
url
contentType
}
}
}
`;
// Get all paths in rootDir, starting at dirname.
// We don't want the paths to include rootDir -- so if rootDir = storybook-static,
// paths will be like iframe.html rather than storybook-static/iframe.html
function getPathsInDir(ctx, rootDir, dirname = '.') {
try {
return fs
.readdirSync(join(rootDir, dirname))
.map((p) => join(dirname, p))
.map((pathname) => {
const stats = fs.statSync(join(rootDir, pathname));
if (stats.isDirectory()) {
return getPathsInDir(ctx, rootDir, pathname);
}
return [{ pathname, contentLength: stats.size }];
})
.reduce((a, b) => [...a, ...b], []); // flatten
} catch (e) {
ctx.log.debug(e);
throw new Error(invalid({ sourceDir: rootDir }, e).output);
}
}
function getOutputDir(buildLog) {
const outputString = 'Output directory: ';
const outputIndex = buildLog.lastIndexOf(outputString);
if (outputIndex === -1) return undefined;
const remainingLog = buildLog.substr(outputIndex + outputString.length);
const newlineIndex = remainingLog.indexOf('\n');
const outputDir = newlineIndex === -1 ? remainingLog : remainingLog.substr(0, newlineIndex);
return outputDir.trim();
}
function getFileInfo(ctx, sourceDir) {
const lengths = getPathsInDir(ctx, sourceDir).map((o) => ({ ...o, knownAs: slash(o.pathname) }));
const total = lengths.map(({ contentLength }) => contentLength).reduce((a, b) => a + b, 0);
const paths = [];
let statsPath;
// eslint-disable-next-line no-restricted-syntax
for (const { knownAs } of lengths) {
if (knownAs.endsWith('preview-stats.json')) statsPath = knownAs;
else if (!knownAs.endsWith('manager-stats.json')) paths.push(knownAs);
}
return { lengths, paths, statsPath, total };
}
const isValidStorybook = ({ paths, total }) =>
total > 0 && paths.includes('iframe.html') && paths.includes('index.html');
export const validateFiles = async (ctx, task) => {
ctx.fileInfo = getFileInfo(ctx, ctx.sourceDir);
if (!isValidStorybook(ctx.fileInfo) && ctx.buildLogFile) {
try {
const buildLog = fs.readFileSync(ctx.buildLogFile, 'utf8');
const outputDir = getOutputDir(buildLog);
if (outputDir && outputDir !== ctx.sourceDir) {
ctx.log.warn(deviatingOutputDir(ctx, outputDir));
ctx.sourceDir = outputDir;
ctx.fileInfo = getFileInfo(ctx, ctx.sourceDir);
}
} catch (e) {
ctx.log.debug(e);
}
}
if (!isValidStorybook(ctx.fileInfo)) {
throw new Error(invalid(ctx).output);
}
};
export const traceChangedFiles = async (ctx, task) => {
if (!ctx.git.changedFiles) return;
if (!ctx.fileInfo.statsPath) {
ctx.log.warn(noStatsFile());
return;
}
transitionTo(tracing)(ctx, task);
const statsPath = join(ctx.sourceDir, ctx.fileInfo.statsPath);
const { changedFiles } = ctx.git;
try {
const stats = await fs.readJson(statsPath);
ctx.onlyStoryFiles = await getDependentStoryFiles(ctx, stats, changedFiles);
ctx.log.debug(
`Found affected story files:\n${Object.entries(ctx.onlyStoryFiles)
.map(([id, f]) => ` ${f} [${id}]`)
.join('\n')}`
);
transitionTo(traced)(ctx, task);
} catch (e) {
ctx.log.warn('Failed to retrieve dependent story files', { statsPath, changedFiles });
}
};
export const uploadStorybook = async (ctx, task) => {
if (ctx.skip) return;
transitionTo(preparing)(ctx, task);
const { lengths, paths, total } = ctx.fileInfo;
const { getUploadUrls } = await ctx.client.runQuery(TesterGetUploadUrlsMutation, { paths });
const { domain, urls } = getUploadUrls;
const files = urls.map(({ path, url, contentType }) => ({
path: join(ctx.sourceDir, path),
url,
contentType,
contentLength: lengths.find(({ knownAs }) => knownAs === path).contentLength,
}));
task.output = starting(ctx).output;
try {
await uploadFiles(ctx, files, (progress) => {
if (ctx.options.interactive) {
const percentage = Math.round((progress / total) * 100);
task.output = uploading({ percentage }).output;
}
});
} catch (e) {
if (files.find(({ path }) => path === e.message)) {
throw new Error(failed({ path: e.message }).output);
}
throw e;
}
ctx.uploadedBytes = total;
ctx.isolatorUrl = new URL('/iframe.html', domain).toString();
transitionTo(success, true)(ctx, task);
};
export default createTask({
title: initial.title,
skip: (ctx) => {
if (ctx.skip) return true;
if (ctx.options.storybookUrl) return skipped(ctx).output;
return false;
},
steps: [transitionTo(validating), validateFiles, traceChangedFiles, uploadStorybook],
});