import path, { join } from 'path'; import { ensureDir, pathExists, remove } from 'fs-extra'; import prompts from 'prompts'; import program from 'commander'; import { readConfig, writeConfig } from '../code/lib/csf-tools'; import { getInterpretedFile } from '../code/lib/core-common'; import { serve } from './utils/serve'; // @ts-ignore import { filterDataForCurrentCircleCINode } from './utils/concurrency'; import * as configs from '../code/lib/cli/src/repro-generators/configs'; import { Parameters } from '../code/lib/cli/src/repro-generators/configs'; import { exec } from '../code/lib/cli/src/repro-generators/scripts'; const logger = console; let openCypressInUIMode = !process.env.CI; export interface Options { /** CLI repro template to use */ name: string; mainOverrides?: Parameters['mainOverrides']; /** Pre-build hook */ ensureDir?: boolean; cwd?: string; } const rootDir = path.join(__dirname, '..'); const siblingDir = path.join(__dirname, '..', '..', 'storybook-e2e-testing'); program .option('--clean', 'Clean up existing projects before running the tests', false) .option('--pnp', 'Run tests using Yarn 2 PnP instead of Yarn 1 + npx', false) .option( '--use-local-sb-cli', 'Run tests using local @storybook/cli package (โš ๏ธ Be sure @storybook/cli is properly built as it will not be rebuilt before running the tests)', false ) .option( '--skip ', 'Skip a framework, can be used multiple times "--skip angular@latest --skip preact"', (value, previous) => previous.concat([value]), [] ) .option('--test-runner', 'Run Storybook test runner instead of cypress', false) .option('--docs-mode', 'Run Storybook test runner in docs mode', false) .option('--all', `run e2e tests for every framework`, false); program.parse(process.argv); type ProgramOptions = { all?: boolean; pnp?: boolean; useLocalSbCli?: boolean; clean?: boolean; args?: string[]; skip?: string[]; testRunner?: boolean; docsMode?: boolean; }; const { all: shouldRunAllFrameworks, args: frameworkArgs, skip: frameworksToSkip, testRunner: shouldUseTestRunner, docsMode: runTestsInDocsMode, }: ProgramOptions = program; let { useLocalSbCli }: ProgramOptions = program; const { pnp, clean: startWithCleanSlate }: ProgramOptions = program; const typedConfigs: { [key: string]: Parameters } = configs; const prepareDirectory = async ({ cwd }: Options): Promise => { if (!(await pathExists(siblingDir))) { await ensureDir(siblingDir); } return pathExists(cwd); }; const cleanDirectory = async ({ cwd }: Options): Promise => { await remove(cwd); }; const overrideMainConfig = async ({ cwd, mainOverrides }: Options) => { logger.info(`๐Ÿ“ Overwriting main.js with the following configuration:`); const configDir = path.join(cwd, '.storybook'); const mainConfigPath = getInterpretedFile(path.resolve(configDir, 'main')); logger.debug(mainOverrides); const mainConfig = await readConfig(mainConfigPath); Object.keys(mainOverrides).forEach((field) => { mainConfig.setFieldValue([field], mainOverrides[field]); }); await writeConfig(mainConfig); }; const buildStorybook = async ({ cwd }: Options) => { await exec( `yarn build-storybook --quiet`, { cwd }, { startMessage: `๐Ÿ‘ท Building Storybook`, errorMessage: `๐Ÿšจ Storybook build failed`, } ); }; const serveStorybook = async ({ cwd }: Options, port: string) => { const staticDirectory = path.join(cwd, 'storybook-static'); logger.info(`๐ŸŒ Serving ${staticDirectory} on http://localhost:${port}`); return serve(staticDirectory, port); }; const runCypress = async (location: string, name: string) => { const cypressCommand = openCypressInUIMode ? 'open' : 'run'; await exec( `CYPRESS_ENVIRONMENT=${name} yarn cypress ${cypressCommand} --config pageLoadTimeout=4000,execTimeout=4000,taskTimeout=4000,responseTimeout=4000,defaultCommandTimeout=4000,integrationFolder="cypress/generated",videosFolder="/tmp/cypress-record/${name}" --env location="${location}"`, { cwd: join(rootDir, 'code') }, { startMessage: `๐Ÿค– Running Cypress tests`, errorMessage: `๐Ÿšจ E2E tests fails`, } ); }; const runStorybookTestRunner = async (options: Options) => { const viewMode = runTestsInDocsMode ? 'docs' : 'story'; await exec( `VIEW_MODE=${viewMode} yarn test-storybook --url http://localhost:4000`, { cwd: options.cwd }, { startMessage: `๐Ÿค– Running Storybook tests`, errorMessage: `๐Ÿšจ Storybook tests fails`, } ); }; const runTests = async ({ name, ...rest }: Parameters) => { const options = { name, ...rest, cwd: path.join(siblingDir, `${name}`), }; logger.log(); logger.info(`๐Ÿƒ๏ธStarting for ${name}`); logger.log(); logger.debug(options); logger.log(); if (!(await prepareDirectory(options))) { let sbCLICommand = `node ${__dirname}/../code/lib/cli/bin/index.js repro`; if (useLocalSbCli) { sbCLICommand += ' --local'; } const targetFolder = path.join(siblingDir, `${name}`); const commandArgs = [ targetFolder, `--renderer ${options.renderer}`, `--template ${options.name}`, `--registry http://localhost:6000`, '--e2e', ]; if (pnp) { commandArgs.push('--pnp'); } const command = `${sbCLICommand} ${commandArgs.join(' ')}`; await exec( command, { cwd: siblingDir }, { startMessage: `๐Ÿ‘ท Bootstrapping ${options.renderer} project`, errorMessage: `๐Ÿšจ Unable to bootstrap project`, } ); if (options.mainOverrides) { await overrideMainConfig(options); } await buildStorybook(options); logger.log(); } const server = await serveStorybook(options, '4000'); logger.log(); try { if (shouldUseTestRunner) { await runStorybookTestRunner(options); } else { await runCypress('http://localhost:4000', name); } logger.info(`๐ŸŽ‰ Storybook is working great with ${name}!`); } catch (e) { logger.info(`๐Ÿฅบ Storybook has some issues with ${name}!`); throw e; } finally { server.close(); } }; async function postE2ECleanup(cwd: string, parameters: Parameters) { if (!process.env.CI) { const { cleanup } = await prompts({ type: 'toggle', name: 'cleanup', message: 'Should perform cleanup?', initial: false, active: 'yes', inactive: 'no', }); if (cleanup) { logger.log(); logger.info(`๐Ÿ—‘ Cleaning ${cwd}`); await cleanDirectory({ ...parameters, cwd }); } else { logger.log(); logger.info(`๐Ÿšฏ No cleanup happened: ${cwd}`); } } } async function preE2ECleanup(name: string, parameters: Parameters, cwd: string) { if (startWithCleanSlate) { logger.log(); logger.info(`โ™ป๏ธ Starting with a clean slate, removing existing ${name} folder`); await cleanDirectory({ ...parameters, cwd }); } } /** * Execute E2E for input parameters and return true is everything is ok, false * otherwise. * @param parameters */ const runE2E = async (parameters: Parameters): Promise => { const { name } = parameters; const cwd = path.join(siblingDir, `${name}`); return preE2ECleanup(name, parameters, cwd) .then(() => runTests(parameters)) .then(() => postE2ECleanup(cwd, parameters)) .then(() => true) .catch((e) => { logger.error(`๐Ÿ›‘ an error occurred:`); logger.error(e); logger.log(); process.exitCode = 1; return false; }); }; const getConfig = async (): Promise => { let e2eConfigsToRun = Object.values(typedConfigs); if (shouldRunAllFrameworks) { // Nothing to do here } else if (frameworkArgs.length > 0) { e2eConfigsToRun = e2eConfigsToRun.filter((config) => frameworkArgs.includes(config.name)); } else if (!process.env.CI) { const selectedValues = await prompts([ { type: 'toggle', name: 'openCypressInUIMode', message: 'Open cypress in UI mode', initial: false, active: 'yes', inactive: 'no', }, { type: 'toggle', name: 'useLocalSbCli', message: 'Use local Storybook CLI', initial: false, active: 'yes', inactive: 'no', }, { type: 'autocompleteMultiselect', message: 'Select the frameworks to run', name: 'frameworks', min: 1, hint: 'You can also run directly with package name like `test:e2e-framework react`, or `yarn test:e2e-framework --all` for all packages!', choices: Object.keys(configs).map((key) => { // @ts-ignore const { name, version } = configs[key]; return { // @ts-ignore value: configs[key], title: `${name}@${version}`, selected: false, }; }), }, ]); if (!selectedValues.frameworks) { logger.info(`No framework was selected.`); process.exit(process.exitCode || 0); } useLocalSbCli = selectedValues.useLocalSbCli; openCypressInUIMode = selectedValues.openCypressInUIMode; e2eConfigsToRun = selectedValues.frameworks; } // Remove frameworks listed with `--skip` arg e2eConfigsToRun = e2eConfigsToRun.filter((config) => !frameworksToSkip.includes(config.name)); return e2eConfigsToRun; }; const perform = async (): Promise> => { const narrowedConfigs: Parameters[] = await getConfig(); const list = filterDataForCurrentCircleCINode(narrowedConfigs) as Parameters[]; logger.info(`๐Ÿ“‘ Will run E2E tests for:${list.map((c) => `${c.name}`).join(', ')}`); const e2eResult: Record = {}; // Run all e2e tests one after another and fill result map await list.reduce( (previousValue, config) => previousValue .then(() => runE2E(config)) .then((result) => { e2eResult[config.name] = result; }), Promise.resolve() ); return e2eResult; }; perform().then((e2eResult) => { logger.info(`๐Ÿงฎ E2E Results`); Object.entries(e2eResult).forEach(([configName, result]) => { logger.info(`${configName}: ${result ? 'OK' : 'KO'}`); }); process.exit(process.exitCode || 0); });