mirror of
https://github.com/storybookjs/storybook.git
synced 2025-03-20 05:02:37 +08:00
380 lines
11 KiB
TypeScript
380 lines
11 KiB
TypeScript
/* eslint-disable no-await-in-loop */
|
|
import { AbortController } from 'node-abort-controller';
|
|
import { getJunitXml } from 'junit-xml';
|
|
import { outputFile } from 'fs-extra';
|
|
import { join, resolve } from 'path';
|
|
import { prompt } from 'prompts';
|
|
|
|
import { createOptions, getOptionsOrPrompt, Option, OptionValues } from './utils/options';
|
|
import { installRepo } from './tasks/install-repo';
|
|
import { bootstrapRepo } from './tasks/bootstrap-repo';
|
|
import { publishRepo } from './tasks/publish-repo';
|
|
import { runRegistryRepo } from './tasks/run-registry-repo';
|
|
import { create } from './tasks/create';
|
|
import { install } from './tasks/install';
|
|
import { sandbox } from './tasks/sandbox';
|
|
import { start } from './tasks/start';
|
|
import { smokeTest } from './tasks/smoke-test';
|
|
import { build } from './tasks/build';
|
|
import { serve } from './tasks/serve';
|
|
import { testRunner } from './tasks/test-runner';
|
|
import { chromatic } from './tasks/chromatic';
|
|
import { e2eTests } from './tasks/e2e-tests';
|
|
|
|
import TEMPLATES from '../code/lib/cli/src/repro-templates';
|
|
import { addons } from './sandbox';
|
|
|
|
const sandboxDir = resolve(__dirname, '../sandbox');
|
|
const codeDir = resolve(__dirname, '../code');
|
|
const junitDir = resolve(__dirname, '../code/test-results');
|
|
|
|
export type TemplateKey = keyof typeof TEMPLATES;
|
|
export type Template = typeof TEMPLATES[TemplateKey];
|
|
export type Path = string;
|
|
export type TemplateDetails = {
|
|
key: TemplateKey;
|
|
template: Template;
|
|
codeDir: Path;
|
|
sandboxDir: Path;
|
|
builtSandboxDir: Path;
|
|
junitFilename: Path;
|
|
};
|
|
|
|
type MaybePromise<T> = T | Promise<T>;
|
|
|
|
export type Task = {
|
|
/**
|
|
* Which tasks run before this task
|
|
*/
|
|
before?: TaskKey[] | ((options: PassedOptionValues) => TaskKey[]);
|
|
/**
|
|
* Is this task already "ready", and potentially not required?
|
|
*/
|
|
ready: (details: TemplateDetails) => MaybePromise<boolean>;
|
|
/**
|
|
* Reset the previous version of the task that ran
|
|
* (it may not be necessary if running the task overwrites prior versions)
|
|
*/
|
|
reset?: (details: TemplateDetails, options: PassedOptionValues) => MaybePromise<void>;
|
|
/**
|
|
* Run the task
|
|
*/
|
|
run: (
|
|
details: TemplateDetails,
|
|
options: PassedOptionValues
|
|
) => MaybePromise<void | AbortController>;
|
|
/**
|
|
* Does this task handle its own junit results?
|
|
*/
|
|
junit?: boolean;
|
|
};
|
|
|
|
export const tasks = {
|
|
// These tasks pertain to the whole monorepo, rather than an
|
|
// individual template/sandbox
|
|
'install-repo': installRepo,
|
|
'bootstrap-repo': bootstrapRepo,
|
|
'publish-repo': publishRepo,
|
|
'run-registry-repo': runRegistryRepo,
|
|
// These tasks pertain to a single sandbox in the ../sandboxes dir
|
|
create,
|
|
install,
|
|
sandbox,
|
|
start,
|
|
'smoke-test': smokeTest,
|
|
build,
|
|
serve,
|
|
'test-runner': testRunner,
|
|
chromatic,
|
|
'e2e-tests': e2eTests,
|
|
};
|
|
|
|
type TaskKey = keyof typeof tasks;
|
|
|
|
export const sandboxOptions = createOptions({
|
|
template: {
|
|
type: 'string',
|
|
description: 'What template are you running against?',
|
|
values: Object.keys(TEMPLATES) as TemplateKey[],
|
|
},
|
|
// TODO -- feature flags
|
|
sandboxDir: {
|
|
type: 'string',
|
|
description: 'What is the name of the directory the sandbox runs in?',
|
|
promptType: false,
|
|
},
|
|
addon: {
|
|
type: 'string[]',
|
|
description: 'Which extra addons (beyond the CLI defaults) would you like installed?',
|
|
values: addons,
|
|
},
|
|
});
|
|
|
|
export const runOptions = createOptions({
|
|
link: {
|
|
type: 'boolean',
|
|
description: 'Link the storybook to the local code?',
|
|
inverse: true,
|
|
},
|
|
fromLocalRepro: {
|
|
type: 'boolean',
|
|
description: 'Create the template from a local repro (rather than degitting it)?',
|
|
},
|
|
dryRun: {
|
|
type: 'boolean',
|
|
description: "Don't execute commands, just list them (dry run)?",
|
|
promptType: false,
|
|
},
|
|
debug: {
|
|
type: 'boolean',
|
|
description: 'Print all the logs to the console',
|
|
promptType: false,
|
|
},
|
|
});
|
|
|
|
export const taskOptions = createOptions({
|
|
task: {
|
|
type: 'string',
|
|
description: 'What task are you performing (corresponds to CI job)?',
|
|
values: Object.keys(tasks) as TaskKey[],
|
|
required: true,
|
|
},
|
|
reset: {
|
|
type: 'string',
|
|
description: 'Which task should we reset back to?',
|
|
values: [...(Object.keys(tasks) as TaskKey[]), 'never', 'as-needed'] as const,
|
|
},
|
|
junit: {
|
|
type: 'boolean',
|
|
description: 'Store results in junit format?',
|
|
},
|
|
});
|
|
|
|
type PassedOptionValues = OptionValues<typeof sandboxOptions & typeof runOptions>;
|
|
|
|
const logger = console;
|
|
|
|
function getJunitFilename(taskKey: TaskKey) {
|
|
return join(junitDir, `${taskKey}.xml`);
|
|
}
|
|
|
|
async function writeJunitXml(
|
|
taskKey: TaskKey,
|
|
templateKey: TemplateKey,
|
|
startTime: Date,
|
|
err?: Error
|
|
) {
|
|
const name = `${taskKey} - ${templateKey}`;
|
|
const time = (Date.now() - +startTime) / 1000;
|
|
const testCase = { name, assertions: 1, time, ...(err && { errors: [err] }) };
|
|
const suite = { name, timestamp: startTime, time, testCases: [testCase] };
|
|
const junitXml = getJunitXml({ time, name, suites: [suite] });
|
|
const path = getJunitFilename(taskKey);
|
|
await outputFile(path, junitXml);
|
|
logger.log(`Test results written to ${resolve(path)}`);
|
|
}
|
|
|
|
function getTaskKey(task: Task): TaskKey {
|
|
return (Object.entries(tasks) as [TaskKey, Task][]).find(([_, t]) => t === task)[0];
|
|
}
|
|
|
|
/**
|
|
*
|
|
* Get a list of tasks that need to be (possibly) run, in order, to
|
|
* be able to run `finalTask`.
|
|
*/
|
|
function getTaskList(finalTask: Task, optionValues: PassedOptionValues) {
|
|
const taskDeps = new Map<Task, Task[]>();
|
|
// Which tasks depend on a given task
|
|
const tasksThatDepend = new Map<Task, Task[]>();
|
|
|
|
const addTask = (task: Task, dependent?: Task) => {
|
|
if (tasksThatDepend.has(task)) {
|
|
if (!dependent) throw new Error('Unexpected task without dependent seen a second time');
|
|
tasksThatDepend.set(task, tasksThatDepend.get(task).concat(dependent));
|
|
return;
|
|
}
|
|
|
|
// This is the first time we've seen this task
|
|
tasksThatDepend.set(task, dependent ? [dependent] : []);
|
|
|
|
const beforeTaskNames =
|
|
typeof task.before === 'function' ? task.before(optionValues) : task.before || [];
|
|
const beforeTasks = beforeTaskNames.map((n) => tasks[n]);
|
|
taskDeps.set(task, beforeTasks);
|
|
|
|
beforeTasks.forEach((t) => addTask(t, task));
|
|
};
|
|
addTask(finalTask);
|
|
|
|
// We need to sort the tasks topologically so we run each task before the tasks that
|
|
// depend on it. This is Kahn's algorithm :shrug:
|
|
const sortedTasks = [] as Task[];
|
|
const tasksWithoutDependencies = [finalTask];
|
|
|
|
while (taskDeps.size !== sortedTasks.length) {
|
|
const task = tasksWithoutDependencies.pop();
|
|
if (!task) throw new Error('Topological sort failed, is there a cyclic task dependency?');
|
|
|
|
sortedTasks.unshift(task);
|
|
taskDeps.get(task).forEach((depTask) => {
|
|
const remainingTasksThatDepend = tasksThatDepend
|
|
.get(depTask)
|
|
.filter((t) => !sortedTasks.includes(t));
|
|
if (remainingTasksThatDepend.length === 0) tasksWithoutDependencies.push(depTask);
|
|
});
|
|
}
|
|
|
|
return sortedTasks;
|
|
}
|
|
|
|
type TaskStatus = 'ready' | 'unready' | 'running' | 'complete' | 'failed';
|
|
const statusToEmoji: Record<TaskStatus, string> = {
|
|
ready: '🟢',
|
|
unready: '🟡',
|
|
running: '🔄',
|
|
complete: '✅',
|
|
failed: '❌',
|
|
};
|
|
function writeTaskList(taskAndStatus: [Task, TaskStatus][]) {
|
|
logger.info(
|
|
taskAndStatus
|
|
.map(([task, status]) => `${statusToEmoji[status]} ${getTaskKey(task)}`)
|
|
.join(' > ')
|
|
);
|
|
logger.info();
|
|
}
|
|
|
|
const controllers: AbortController[] = [];
|
|
|
|
async function runTask(task: Task, details: TemplateDetails, optionValues: PassedOptionValues) {
|
|
const startTime = new Date();
|
|
try {
|
|
const controller = await task.run(details, optionValues);
|
|
if (controller) controllers.push(controller);
|
|
|
|
if (details.junitFilename && !task.junit)
|
|
await writeJunitXml(getTaskKey(task), details.key, startTime);
|
|
|
|
return controller;
|
|
} catch (err) {
|
|
if (details.junitFilename && !task.junit)
|
|
await writeJunitXml(getTaskKey(task), details.key, startTime, err);
|
|
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
async function run() {
|
|
const {
|
|
task: taskKey,
|
|
reset,
|
|
junit,
|
|
...optionValues
|
|
} = await getOptionsOrPrompt('yarn task', {
|
|
...sandboxOptions,
|
|
...runOptions,
|
|
...taskOptions,
|
|
});
|
|
|
|
const task = tasks[taskKey];
|
|
const { template: templateKey } = optionValues;
|
|
const template = TEMPLATES[templateKey];
|
|
const templateSandboxDir = templateKey && join(sandboxDir, templateKey.replace('/', '-'));
|
|
const details = {
|
|
key: templateKey,
|
|
template,
|
|
codeDir,
|
|
sandboxDir: templateSandboxDir,
|
|
builtSandboxDir: templateKey && join(templateSandboxDir, 'storybook-static'),
|
|
junitFilename: junit && getJunitFilename(taskKey),
|
|
};
|
|
|
|
const sortedTasks = getTaskList(task, optionValues);
|
|
const sortedTasksReady = await Promise.all(sortedTasks.map((t) => t.ready(details)));
|
|
const firstUnready = sortedTasks.find((_, index) => !sortedTasksReady[index]);
|
|
|
|
logger.info(`Task readiness up to ${taskKey}`);
|
|
const sortedTasksStatus: [Task, TaskStatus][] = sortedTasks.map((sortedTask, index) => [
|
|
sortedTask,
|
|
sortedTasksReady[index] ? 'ready' : 'unready',
|
|
]);
|
|
writeTaskList(sortedTasksStatus);
|
|
|
|
let firstTask: Task;
|
|
if (reset === 'as-needed') {
|
|
if (!firstUnready) {
|
|
logger.info('All tasks already ready, no task needed');
|
|
return;
|
|
}
|
|
|
|
firstTask = firstUnready;
|
|
} else if (reset === 'never') {
|
|
if (!firstUnready) throw new Error(`Task ${taskKey} is ready`);
|
|
if (firstUnready !== task) throw new Error(`Task ${getTaskKey(firstUnready)} was not ready`);
|
|
firstTask = task;
|
|
} else if (reset) {
|
|
// set to reset back to a specific task
|
|
if (sortedTasks.indexOf(tasks[reset]) > sortedTasks.indexOf(firstUnready)) {
|
|
throw new Error(
|
|
`Task ${getTaskKey(firstUnready)} was not ready, earlier than your request ${reset}.`
|
|
);
|
|
}
|
|
firstTask = tasks[reset];
|
|
} else if (firstUnready === sortedTasks[0]) {
|
|
// We need to do everything, no need to ask
|
|
firstTask = firstUnready;
|
|
} else {
|
|
// We don't know what to do! Let's ask
|
|
({ firstTask } = await prompt({
|
|
type: 'select',
|
|
message: 'Which task would you like to start at?',
|
|
name: 'firstTask',
|
|
choices: sortedTasks.slice(0, sortedTasks.indexOf(firstUnready) + 1).map((t) => ({
|
|
title: getTaskKey(t),
|
|
value: t,
|
|
})),
|
|
}));
|
|
}
|
|
|
|
for (let i = sortedTasks.indexOf(firstTask); i <= sortedTasks.length; i += 1) {
|
|
sortedTasksStatus[i][1] = 'running';
|
|
writeTaskList(sortedTasksStatus);
|
|
const taskController = await runTask(sortedTasks[i], details, {
|
|
...optionValues,
|
|
// Always debug the final task so we can see it's output fully
|
|
debug: sortedTasks[i] === task ? true : optionValues.debug,
|
|
});
|
|
sortedTasksStatus[i][1] = 'complete';
|
|
|
|
// If the task has it's own controller, it is going to remain
|
|
// open until the user ctrl-c's which will have the side effect
|
|
// of stopping everything.
|
|
if (sortedTasks[i] === task && taskController) {
|
|
await new Promise(() => {});
|
|
}
|
|
}
|
|
|
|
// TODO -- is this necessary??
|
|
|
|
// // If the task has it's own controller, it is going to remain
|
|
// // open until the user ctrl-c's which will have the side effect
|
|
// // of stopping everything.
|
|
// if (taskController) {
|
|
// await new Promise(() => {});
|
|
// } else {
|
|
// controllers.forEach((c) => c.abort());
|
|
// }
|
|
}
|
|
|
|
if (require.main === module) {
|
|
run()
|
|
.then(() => process.exit(0))
|
|
.catch((err) => {
|
|
logger.error();
|
|
logger.error(err.message);
|
|
// logger.error(err);
|
|
process.exit(1);
|
|
});
|
|
}
|