diff --git a/index.js b/index.js index 9af0473..22d0089 100644 --- a/index.js +++ b/index.js @@ -1,6 +1,8 @@ const { babelOptions } = require('@folio/stripes-webpack'); +const { getBaseCypressConfig } = require('./lib/test/cypress-service'); module.exports = { babelOptions, + getBaseCypressConfig }; diff --git a/lib/commands/test/cypress.js b/lib/commands/test/cypress.js new file mode 100644 index 0000000..79bfff2 --- /dev/null +++ b/lib/commands/test/cypress.js @@ -0,0 +1,97 @@ +const importLazy = require('import-lazy')(require); +// const fs = require('fs'); +// const child = require('child_process'); + +const { contextMiddleware } = importLazy('../../cli/context-middleware'); +const { stripesConfigMiddleware } = importLazy('../../cli/stripes-config-middleware'); +const { CypressService } = importLazy('../../test/cypress-service'); +const StripesPlatform = importLazy('../../platform/stripes-platform'); +const { serverOptions, okapiOptions, stripesConfigFile, stripesConfigStdin, stripesConfigOptions } = importLazy('../common-options'); +const StripesCore = importLazy('../../cli/stripes-core'); + +function cypressCommand(argv) { + const context = argv.context; + + // pass moduleName up to globals so that it can be accessed for standalone webpack config. + process.env.stripesCLIContextModuleName = context.moduleName; + + // Default test command to test env + if (!process.env.NODE_ENV) { + process.env.NODE_ENV = 'test'; + } + + if (!(context.isUiModule || context.isStripesModule)) { + console.log('Tests are only supported within an app context.'); + return; + } + + const platform = new StripesPlatform(argv.stripesConfig, context, argv); + const webpackOverrides = platform.getWebpackOverrides(context); + + if (context.plugin && context.plugin.beforeBuild) { + webpackOverrides.push(context.plugin.beforeBuild(argv)); + } + + console.log('Starting Cypress tests...'); + const stripes = new StripesCore(context, platform.aliases); + const webpackConfigOptions = { + coverage: argv.coverage, + omitPlatform: context.type === 'component', + bundle: argv.bundle, + webpackOverrides, + }; + const webpackConfig = stripes.getStripesWebpackConfig(platform.getStripesConfig(), webpackConfigOptions, context); + + // This fixes the warnings similar to: + // WARNING in ./node_modules/mocha/mocha-es2018.js 18541:26-55 + // Critical dependency: the request of a dependency is an expression + // https://github.com/mochajs/mocha/issues/2448#issuecomment-355222358 + webpackConfig.module.exprContextCritical = false; + + // This fixes warning: + // WARNING in DefinePlugin + // Conflicting values for 'process.env.NODE_ENV' + // https://webpack.js.org/configuration/mode/#usage + webpackConfig.mode = 'none'; + + const cypressService = new CypressService(context.cwd); + if (argv.cypress?.install) { + cypressService.installAndRun(webpackConfig, Object.assign({}, argv.cypress, { watch: argv.watch, cache: argv.cache }), context); + } else { + cypressService.runCypressTests(webpackConfig, Object.assign({}, argv.cypress, { watch: argv.watch, cache: argv.cache }), context); + } +} + +module.exports = { + command: 'cypress [configFile]', + describe: 'Run the current app module\'s Cypress tests', + builder: (yargs) => { + yargs + .middleware([ + contextMiddleware(), + stripesConfigMiddleware(), + ]) + .positional('configFile', stripesConfigFile.configFile) + .option('coverage', { + describe: 'Enable Cypress coverage reports', + type: 'boolean', + alias: 'cypress.coverage', // this allows --coverage to be passed to Cypress + }) + .option('bundle', { + describe: 'Create and use a production bundle retaining test hooks', + type: 'boolean' + }) + .option('cypress', { + describe: 'Options passed to Cypress using dot-notation and camelCase: --cypress.browsers=Chrome --cypress.singleRun', + }) + .option('cypress.install', { type: 'boolean', describe: 'install cypress manually to account for ignore-scripts' }) + .option('cypress.open', { type: 'boolean', describe: 'run cypress in ui mode' }) + .option('cypress.browsers', { type: 'array', hidden: true }) // defined but hidden so yargs will parse as an array + .option('cypress.reporters', { type: 'array', hidden: true }) + .option('watch', { type: 'boolean', describe: 'Watch test files for changes and run tests automatically when changes are saved.' }) + .option('cache', { type: 'boolean', describe: 'Enable caching of test bundle. Defaults to false.' }) + .options(Object.assign({}, serverOptions, okapiOptions, stripesConfigStdin, stripesConfigOptions)) + .example('$0 test cypress', 'Run tests with Cypress for the current app module'); + }, + handler: cypressCommand, +}; diff --git a/lib/test/cypress-plugin.js b/lib/test/cypress-plugin.js new file mode 100644 index 0000000..2811aca --- /dev/null +++ b/lib/test/cypress-plugin.js @@ -0,0 +1,18 @@ +/// +const webpack = require('@cypress/webpack-preprocessor') + +/** + * @type {Cypress.PluginConfig} + */ +module.exports = (on, config) => { + const options = { + // use the same Webpack options to bundle spec files as your app does "normally" + // which should instrument the spec files in this project + webpackOptions: require('../../webpack.config'), + watchOptions: {} + } + on('file:preprocessor', webpack(options)) + + require('@cypress/code-coverage/task')(on, config) + return config +} \ No newline at end of file diff --git a/lib/test/cypress-service.js b/lib/test/cypress-service.js new file mode 100644 index 0000000..e1c9085 --- /dev/null +++ b/lib/test/cypress-service.js @@ -0,0 +1,228 @@ +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const util = require('util'); +const exec = util.promisify(require('child_process').exec); + +const _pickBy = require('lodash/pickBy'); + +const { defineConfig, run, open } = require('cypress'); +const webpack = require('@cypress/webpack-preprocessor'); +const logger = require('../cli/logger')('cypress'); +const getStripesWebpackConfigStandalone = require('./webpack-config-standalone'); + +function getTestIndex(cwd, dirs) { + let file = path.join(cwd, dirs[0], 'index.js'); + let i = 0; + + while (!fs.existsSync(file) && dirs[++i]) { + file = path.join(cwd, dirs[i], 'index.js'); + } + + return file; +} + +function getBaseCypressConfig(fn = (cfg) => cfg, context) { + const baseConfig = defineConfig({ + browser: 'chrome', + viewportWidth: 800, + viewportHeight: 600, + component: { + devServer: { + framework: 'react', + bundler: 'webpack', + webpackConfig : getStripesWebpackConfigStandalone() + }, + specPattern: '**/*[.-]test.js', + }, + }); + return fn(baseConfig, defineConfig); +} + +class CypressService { + constructor(cwd) { + this.cwd = cwd; + } + + generateCypressConfig(webpackConfig, cypressOptions) { + // TODO: Standardize on test folder, `test/bigtest` vs 'test' vs 'tests' + const testIndex = getTestIndex(this.cwd, ['test/bigtest', 'test', 'tests']); + + // cypress webpack, ignores 'entry' so to keep it from griping, just exclude it. + const { + entry, // eslint-disable-line no-unused-vars + ...webpackConfigRest + } = webpackConfig; + + const output = { + // The path defined here is the same as what cypress-webpack is using by default. + // https://github.com/ryanclark/cypress-webpack/blob/master/lib/webpack/defaults.js#L10 + + // We are redefining it here so we can work around a current limitation + // related to static files (translations) not loading correctly. + // Please see more comments under: + // https://github.com/ryanclark/cypress-webpack/issues/498 + // path: path.join(os.tmpdir(), '_cypress_webpack_') + Math.floor(Math.random() * 1000000), + }; + + // set webpack's watch/cache features via cypress config. + // these features are unnecessary in the CI environment, so we turn them off by default. + // They can be enabled individually via command-line options '--watch' and '--cache'. + let webpackTestConfig = {}; + webpackTestConfig.watch = !!cypressOptions?.watch; + webpackTestConfig.cache = !!cypressOptions?.cache; + // only apply 'false' options as overrides to what cypress-webpack wants to set. + webpackTestConfig = _pickBy(webpackTestConfig, (opt) => !opt); + + // let cypressConfig = { + // frameworks: ['mocha', 'webpack'], + // reporters: ['mocha'], + // port: 9876, + + // browsers: ['Chrome'], + + // customLaunchers: { + // // Custom launcher for CI + // ChromeHeadlessDocker: { + // base: 'ChromeHeadless', + // flags: [ + // '--no-sandbox', + // '--disable-web-security' + // ] + // }, + // ChromeDocker: { + // base: 'Chrome', + // flags: [ + // '--no-sandbox', + // '--disable-web-security' + // ] + // } + // }, + + // junitReporter: { + // outputDir: 'artifacts/runTest', + // useBrowserName: true, + // }, + + // files: [ + // { pattern: testIndex, watched: false }, + // // use output.path to work around the issue with loading + // // static files + // // https://github.com/ryanclark/cypress-webpack/issues/498 + // { + // pattern: `${output.path}/**/*`, + // watched: false, + // included: false, + // served: true, + // }, + // ], + + // preprocessors: { + // [testIndex]: ['webpack'] + // }, + + // webpack: { + // ...webpackConfigRest, + // ...webpackTestConfig, + // output, + // }, + // webpackMiddleware: { + // stats: 'errors-only', + // }, + + // mochaReporter: { + // showDiff: true, + // }, + + // plugins: [ + // 'cypress-chrome-launcher', + // 'cypress-firefox-launcher', + // 'cypress-mocha', + // 'cypress-webpack', + // 'cypress-mocha-reporter', + // ], + + // coverageIstanbulReporter: { + // dir: 'artifacts/coverage', + // reports: ['text-summary', 'lcov'], + // thresholds: { + // // Thresholds under which cypress will return failure + // // Modules are expected to define their own values in cypress.conf.js + // global: {}, + // each: {}, + // } + // }, + // }; + + // Apply user supplied --cypress options to configuration + // Added now so they will be available within app-supplied config function + // if (cypressOptions) { + // logger.log('Applying command-line Cypress options', cypressOptions); + // Object.assign(cypressConfig, cypressOptions); + // } + + // if (cypressOptions && cypressOptions.coverage) { + // logger.log('Enabling coverage'); + // cypressConfig.reporters.push('coverage-istanbul'); + // cypressConfig.plugins.push('cypress-coverage-istanbul-reporter'); + // } + + // if (cypressConfig.reporters.includes('junit')) { + // logger.log('Enabling junit reporter'); + // cypressConfig.plugins.push('cypress-junit-reporter'); + // } + + // Use cypress's parser to prep the base config + let cypressConfig = getBaseCypressConfig(); + + // Check for an app-supplied cypress config and apply it + const localConfig = path.join(this.cwd, 'cypress.config.js'); + if (fs.existsSync(localConfig)) { + const appCypressConfig = require(localConfig); // eslint-disable-line + logger.log('Applying local cypress config', localConfig); + cypressConfig = appCypressConfig; + + // Reapply user options so they take precedence + if (cypressOptions) { + Object.assign(cypressConfig, cypressOptions); + } + } + // console.log(JSON.stringify(cypressConfig, null, 2)); + return cypressConfig; + } + + // Runs the specified integration tests + runCypressTests(webpackConfig, cypressOptions) { + const cypressConfig = this.generateCypressConfig(webpackConfig, cypressOptions); + + try { + const cyCommand = cypressOptions.open ? open : run; + cyCommand(cypressConfig) + .then(res => { + logger.log('Cypress results:', res); + }) + .catch(err => { + logger.log(err.message); + console.log(err.message); + process.exit(1); + }); + } catch (e) { + logger.log('Error running cypress tests:', e); + } + } + + async installAndRun() { + console.log('Attempting cypress install...'); + try { + await exec('npx cypress install'); + this.runCypressTests.apply(this, arguments); // eslint-disable-line + } catch (e) { + console.log(e); + } + } +} + +module.exports = { + getBaseCypressConfig, + CypressService +}; diff --git a/lib/test/cypress-support.js b/lib/test/cypress-support.js new file mode 100644 index 0000000..a7ecd76 --- /dev/null +++ b/lib/test/cypress-support.js @@ -0,0 +1,28 @@ +// *********************************************************** +// This example support/component.js is processed and +// loaded automatically before your test files. +// +// This is a great place to put global configuration and +// behavior that modifies Cypress. +// +// You can change the location of this file or turn off +// automatically serving support files with the +// 'supportFile' configuration option. +// +// You can read more here: +// https://on.cypress.io/configuration +// *********************************************************** +import '@cypress/code-coverage/support'; + +// Import commands.js using ES2015 syntax: +// import './commands' + +// Alternatively you can use CommonJS syntax: +// require('./commands') + +import { mount } from 'cypress/react18'; + +Cypress.Commands.add('mount', mount); + +// Example use: +// cy.mount() diff --git a/lib/test/support/component-index.html b/lib/test/support/component-index.html new file mode 100644 index 0000000..1046822 --- /dev/null +++ b/lib/test/support/component-index.html @@ -0,0 +1,14 @@ + + + + + + + Components App + + + + +
+ + \ No newline at end of file diff --git a/lib/test/webpack-config-standalone.js b/lib/test/webpack-config-standalone.js new file mode 100644 index 0000000..697aded --- /dev/null +++ b/lib/test/webpack-config-standalone.js @@ -0,0 +1,18 @@ +const process = require('process'); +const path = require('path'); +const getStripesWebpackConfig = require('./webpack-config'); +const StripesCore = require('../cli/stripes-core'); + +module.exports = function getStripesWebpackConfigStandalone() { + const context = { + moduleName: process.env.stripesCLIContextModuleName || '', + cwd: process.cwd(), + cliRoot: path.resolve('@folio/stripes-cli') + }; + + const stripesCore = new StripesCore(context, { '@folio/stripes-webpack': '@folio/stripes-webpack' }); + + const componentsStripesConfig = { config: [], modules:[], languages: [] }; + const webpackConfig = getStripesWebpackConfig(stripesCore, componentsStripesConfig, { config: componentsStripesConfig }, context); + return webpackConfig; +}; diff --git a/lib/test/webpack-config.js b/lib/test/webpack-config.js index c3aade1..9f017cc 100644 --- a/lib/test/webpack-config.js +++ b/lib/test/webpack-config.js @@ -54,3 +54,13 @@ module.exports = function getStripesWebpackConfig(stripeCore, stripesConfig, opt config = applyWebpackOverrides(options.webpackOverrides, config); return config; }; + +function getStripesWebpackConfigStandalone() { + const webpackConfig = getStripesWebpackConfig(); + return webpackConfig; +} + +// module.exports = { +// default: getStripesWebpackConfig, +// getStripesWebpackConfigStandalone, +// }; diff --git a/package.json b/package.json index 57aed75..2188644 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "docs": "node ./lib/doc/generator" }, "dependencies": { + "@cypress/code-coverage": "^3.13.6", "@folio/stripes-testing": "^3.0.0", "@folio/stripes-webpack": "^5.0.0", "@formatjs/cli": "^6.1.3", @@ -29,6 +30,7 @@ "@octokit/rest": "^19.0.7", "babel-plugin-istanbul": "^6.0.0", "configstore": "^3.1.1", + "cypress": "12.0.0", "debug": "^4.0.1", "express": "^4.17.1", "fast-glob": "^3.3.1",