diff --git a/circle.yml b/circle.yml index 542cc4fb7910..717ac60af267 100644 --- a/circle.yml +++ b/circle.yml @@ -8,6 +8,8 @@ macBuildFilters: &macBuildFilters branches: only: - develop + - issue-17944-remove-source-files + - feat/allow-cypress-js - tgriesser/chore/fix-release defaults: &defaults @@ -42,6 +44,8 @@ onlyMainBranches: &onlyMainBranches branches: only: - develop + - issue-17944-remove-source-files + - feat/allow-cypress-js - tgriesser/chore/fix-release requires: - create-build-artifacts @@ -1346,10 +1350,6 @@ jobs: name: Run component tests command: yarn test:ci:ct working_directory: npm/vue - - run: - name: Run e2e tests - command: yarn test:ci:e2e - working_directory: npm/vue - store_test_results: path: npm/vue/test_results - store_artifacts: @@ -2129,6 +2129,8 @@ linux-workflow: &linux-workflow branches: only: - develop + - issue-17944-remove-source-files + - feat/allow-cypress-js - tgriesser/chore/fix-release requires: - build @@ -2141,6 +2143,8 @@ linux-workflow: &linux-workflow branches: only: - develop + - issue-17944-remove-source-files + - feat/allow-cypress-js - tgriesser/chore/fix-release requires: - build @@ -2191,6 +2195,8 @@ linux-workflow: &linux-workflow branches: only: - develop + - issue-17944-remove-source-files + - feat/allow-cypress-js - tgriesser/chore/fix-release requires: - create-build-artifacts @@ -2199,6 +2205,8 @@ linux-workflow: &linux-workflow branches: only: - develop + - issue-17944-remove-source-files + - feat/allow-cypress-js - tgriesser/chore/fix-release requires: - create-build-artifacts @@ -2208,6 +2216,8 @@ linux-workflow: &linux-workflow branches: only: - develop + - issue-17944-remove-source-files + - feat/allow-cypress-js - tgriesser/chore/fix-release requires: - create-build-artifacts @@ -2233,6 +2243,8 @@ linux-workflow: &linux-workflow branches: only: - develop + - issue-17944-remove-source-files + - feat/allow-cypress-js - tgriesser/chore/fix-release requires: - create-build-artifacts @@ -2305,6 +2317,8 @@ mac-workflow: &mac-workflow branches: only: - develop + - issue-17944-remove-source-files + - feat/allow-cypress-js - tgriesser/chore/fix-release requires: - darwin-create-build-artifacts @@ -2317,6 +2331,8 @@ mac-workflow: &mac-workflow branches: only: - develop + - issue-17944-remove-source-files + - feat/allow-cypress-js - tgriesser/chore/fix-release requires: - darwin-create-build-artifacts diff --git a/cli/lib/cypress.js b/cli/lib/cypress.js index d87e11277c03..84ecbc7d137f 100644 --- a/cli/lib/cypress.js +++ b/cli/lib/cypress.js @@ -68,6 +68,19 @@ const cypressModuleApi = { return cli.parseRunCommand(args) }, }, + + /** + * This function should not be used in pure JavaScript + * By the type of its argument it allows autocompletion and + * type checking of the configuration given by users. + * + * @see ../types/cypress-npm-api.d.ts + * @param {Cypress.ConfigOptions} config + * @returns {Cypress.ConfigOptions} the configuration passed in parameter + */ + defineConfig (config) { + return config + }, } module.exports = cypressModuleApi diff --git a/cli/types/cypress-npm-api.d.ts b/cli/types/cypress-npm-api.d.ts index 1c44e841de8f..864420fa26d9 100644 --- a/cli/types/cypress-npm-api.d.ts +++ b/cli/types/cypress-npm-api.d.ts @@ -138,7 +138,7 @@ declare namespace CypressCommandLine { /** * Specify configuration */ - config: Cypress.ConfigOptions + config: Cypress.ConfigOptionsMergedWithTestingTypes /** * Path to the config file to be used. * @@ -335,8 +335,7 @@ declare namespace CypressCommandLine { } } -declare module 'cypress' { - /** +/** * Cypress NPM module interface. * @see https://on.cypress.io/module-api * @example @@ -345,41 +344,46 @@ declare module 'cypress' { cypress.run().then(results => ...) ``` */ - interface CypressNpmApi { - /** - * Execute a headless Cypress test run. - * @see https://on.cypress.io/module-api#cypress-run - * @example - ``` - const cypress = require('cypress') - // runs all spec files matching a wildcard - cypress.run({ - spec: 'cypress/integration/admin*-spec.js' - }).then(results => { - if (results.status === 'failed') { - // Cypress could not run - } else { - // inspect results object - } - }) - ``` - */ - run(options?: Partial): Promise - /** - * Opens Cypress GUI. Resolves with void when the - * GUI is closed. - * @see https://on.cypress.io/module-api#cypress-open - */ - open(options?: Partial): Promise +declare module 'cypress' { + /** + * Execute a headless Cypress test run. + * @see https://on.cypress.io/module-api#cypress-run + * @example + ``` + const cypress = require('cypress') + // runs all spec files matching a wildcard + cypress.run({ + spec: 'cypress/integration/admin*-spec.js' + }).then(results => { + if (results.status === 'failed') { + // Cypress could not run + } else { + // inspect results object + } + }) + ``` + */ + function run(options?: Partial): Promise + /** + * Opens Cypress GUI. Resolves with void when the + * GUI is closed. + * @see https://on.cypress.io/module-api#cypress-open + */ + function open(options?: Partial): Promise - /** - * Utility functions for parsing CLI arguments the same way - * Cypress does - */ - cli: CypressCommandLine.CypressCliParser - } + /** + * Utility functions for parsing CLI arguments the same way + * Cypress does + */ + const cli: CypressCommandLine.CypressCliParser - // export Cypress NPM module interface - const cypress: CypressNpmApi - export = cypress + /** + * Type helper to make writing `cypress.config.ts` easier + */ + function defineConfig(config: Cypress.ConfigOptions): Cypress.ConfigOptions + + /** + * Types for configuring cypress in cypress.config.ts + */ + type ConfigOptions = Cypress.ConfigOptions } diff --git a/cli/types/cypress.d.ts b/cli/types/cypress.d.ts index 5dd65e6b0525..3b30d3d1a0c6 100644 --- a/cli/types/cypress.d.ts +++ b/cli/types/cypress.d.ts @@ -9,7 +9,7 @@ declare namespace Cypress { type ViewportOrientation = 'portrait' | 'landscape' type PrevSubject = 'optional' | 'element' | 'document' | 'window' type TestingType = 'e2e' | 'component' - type PluginConfig = (on: PluginEvents, config: PluginConfigOptions) => void | ConfigOptions | Promise + type PluginConfig = (on: PluginEvents, config: PluginConfigOptions) => void | ConfigOptionsMergedWithTestingTypes | Promise interface CommandOptions { prevSubject: boolean | PrevSubject | PrevSubject[] @@ -323,7 +323,7 @@ declare namespace Cypress { // 60000 ``` */ - config(key: K): ResolvedConfigOptions[K] + config(key: K): ResolvedConfigOptions[K] /** * Sets one configuration value. * @see https://on.cypress.io/config @@ -332,7 +332,7 @@ declare namespace Cypress { Cypress.config('viewportWidth', 800) ``` */ - config(key: K, value: ResolvedConfigOptions[K]): void + config(key: K, value: ResolvedConfigOptions[K]): void /** * Sets multiple configuration values at once. * @see https://on.cypress.io/config @@ -344,7 +344,7 @@ declare namespace Cypress { }) ``` */ - config(Object: ConfigOptions): void + config(Object: ConfigOptionsMergedWithTestingTypes): void // no real way to type without generics /** @@ -2495,6 +2495,16 @@ declare namespace Cypress { cmdKey: boolean } + type PluginsFunction = ((on: PluginEvents, config: PluginConfigOptions) => Promise | ConfigOptionsMergedWithTestingTypes | undefined | void) + + type TestingTypeConfig = Omit & { setupNodeEvents?: PluginsFunction } + type TestingTypeConfigComponent = TestingTypeConfig & { + /** + * Return the setup of your server + * @param options the dev server options to pass directly to the dev-server + */ + devServer(options: CypressDevServerOptions): Promise | ResolvedDevServerConfig + } interface PEMCert { /** * Path to the certificate file, relative to project root. @@ -2569,7 +2579,7 @@ declare namespace Cypress { reporter: string /** * Some reporters accept [reporterOptions](https://on.cypress.io/reporters) that customize their behavior - * @default "spec" + * @default {} */ reporterOptions: { [key: string]: any } /** @@ -2609,7 +2619,7 @@ declare namespace Cypress { taskTimeout: number /** * Path to folder where application files will attempt to be served from - * @default root project folder + * @default "root project folder" */ fileServerFolder: string /** @@ -2620,6 +2630,7 @@ declare namespace Cypress { /** * Path to folder containing integration test files * @default "cypress/integration" + * @deprecated use the testFiles glob in the e2e object */ integrationFolder: string /** @@ -2630,6 +2641,7 @@ declare namespace Cypress { /** * If set to `system`, Cypress will try to find a `node` executable on your path to use when executing your plugins. Otherwise, Cypress will use the Node version bundled with Cypress. * @default "bundled" + * @deprecated nodeVersion will soon be fixed to "system" to avoid confusion */ nodeVersion: 'system' | 'bundled' /** @@ -2764,6 +2776,7 @@ declare namespace Cypress { blockHosts: null | string | string[] /** * Path to folder containing component test files. + * @deprecated use the testFiles pattern inside the component object instead */ componentFolder: false | string /** @@ -2772,6 +2785,7 @@ declare namespace Cypress { projectId: null | string /** * Path to the support folder. + * @deprecated use supportFile instead */ supportFolder: string /** @@ -2788,17 +2802,16 @@ declare namespace Cypress { experimentalFetchPolyfill: boolean /** - * Override default config options for Component Testing runner. + * Override default config options for E2E Testing runner. * @default {} */ - component: Omit + e2e: TestingTypeConfig /** - * Override default config options for E2E Testing runner. + * Override default config options for Component Testing runner. * @default {} */ - e2e: Omit - + component: TestingTypeConfigComponent /** * An array of objects defining the certificates */ @@ -2876,16 +2889,27 @@ declare namespace Cypress { xhrUrl: string } - interface TestConfigOverrides extends Partial> { + interface TestConfigOverrides extends Partial> { browser?: IsBrowserMatcher | IsBrowserMatcher[] keystrokeDelay?: number } + // here we need to use the `Function` type to type setup functions options properly + // if we don't, they will be typed as any + /* tslint:disable-next-line ban-types */ + type DeepPartial = T extends Function ? T : T extends object ? { [P in keyof T]?: DeepPartial; } : T + + /** + * ConfigOptions after the current testingType has been merget into the root. + */ + type ConfigOptionsMergedWithTestingTypes = DeepPartial + /** - * All configuration items are optional. + * Config model of cypress. To be used in `cypress.config.js` */ - type CoreConfigOptions = Partial> - type ConfigOptions = CoreConfigOptions & { e2e?: CoreConfigOptions, component?: CoreConfigOptions } + type ConfigOptions = Omit + // make devServer required in component + & {component?: {devServer: TestingTypeConfigComponent['devServer'] }} interface PluginConfigOptions extends ResolvedConfigOptions { /** @@ -5314,7 +5338,7 @@ declare namespace Cypress { interface BeforeRunDetails { browser?: Browser - config: ConfigOptions + config: ConfigOptionsMergedWithTestingTypes cypressVersion: string group?: string parallel?: boolean @@ -5325,7 +5349,7 @@ declare namespace Cypress { tag?: string } - interface DevServerConfig { + interface CypressDevServerOptions { specs: Spec[] config: ResolvedConfigOptions & RuntimeConfigOptions devServerEvents: NodeJS.EventEmitter @@ -5344,7 +5368,7 @@ declare namespace Cypress { (action: 'before:spec', fn: (spec: Spec) => void | Promise): void (action: 'before:browser:launch', fn: (browser: Browser, browserLaunchOptions: BrowserLaunchOptions) => void | BrowserLaunchOptions | Promise): void (action: 'file:preprocessor', fn: (file: FileObject) => string | Promise): void - (action: 'dev-server:start', fn: (file: DevServerConfig) => Promise): void + (action: 'dev-server:start', fn: (file: CypressDevServerOptions) => Promise): void (action: 'task', tasks: Tasks): void } diff --git a/npm/angular/cypress.config.ts b/npm/angular/cypress.config.ts new file mode 100644 index 000000000000..76c12d59469c --- /dev/null +++ b/npm/angular/cypress.config.ts @@ -0,0 +1,29 @@ +import { defineConfig } from 'cypress' +import { addMatchImageSnapshotPlugin } from 'cypress-image-snapshot/plugin' +import { startDevServer } from '@cypress/webpack-dev-server' +import webpackConfig from './cypress/plugins/webpack.config' + +export default defineConfig({ + experimentalFetchPolyfill: true, + fixturesFolder: false, + includeShadowDom: true, + fileServerFolder: 'src', + projectId: 'nf7zag', + component: { + componentFolder: 'src', + testFiles: '**/*cy-spec.ts', + devServer (options) { + return startDevServer({ + options, + webpackConfig, + }) + }, + setupNodeEvents (on, config) { + addMatchImageSnapshotPlugin(on, config) + + require('@cypress/code-coverage/task')(on, config) + + return config + }, + }, +}) diff --git a/npm/angular/cypress.json b/npm/angular/cypress.json deleted file mode 100644 index ebe21a31c348..000000000000 --- a/npm/angular/cypress.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "experimentalFetchPolyfill": true, - "fixturesFolder": false, - "includeShadowDom": true, - "fileServerFolder": "src", - "projectId": "nf7zag", - "component": { - "componentFolder": "src/app", - "testFiles": "**/*cy-spec.ts" - } -} diff --git a/npm/angular/cypress/plugins/cy-ts-preprocessor.ts b/npm/angular/cypress/plugins/cy-ts-preprocessor.ts deleted file mode 100644 index def0d07f8b57..000000000000 --- a/npm/angular/cypress/plugins/cy-ts-preprocessor.ts +++ /dev/null @@ -1,134 +0,0 @@ -const wp = require('@cypress/webpack-preprocessor') -import root from './helpers' -import * as webpack from 'webpack' -import * as path from 'path' - -const webpackOptions = { - mode: 'development', - devtool: 'inline-source-map', - resolve: { - extensions: ['.ts', '.js'], - modules: [root('src'), 'node_modules'], - }, - module: { - rules: [ - { - enforce: 'pre', - test: /\.js$/, - loader: 'source-map-loader', - }, - { - test: /\.ts$/, - // loaders: ['ts-loader', 'angular2-template-loader'], - use: [ - { - loader: 'ts-loader', - options: { - transpileOnly: true, - }, - }, - { - loader: 'angular2-template-loader', - }, - ], - exclude: [/node_modules/, /test.ts/, /polyfills.ts/], - }, - { - test: /\.(js|ts)$/, - loader: 'istanbul-instrumenter-loader', - options: { esModules: true }, - enforce: 'post', - include: path.join(__dirname, '../..', 'src'), - exclude: [ - /\.(e2e|spec)\.ts$/, - /node_modules/, - /(ngfactory|ngstyle)\.js/, - ], - }, - { - // Mark files inside `@angular/core` as using SystemJS style dynamic imports. - // Removing this will cause deprecation warnings to appear. - test: /[\/\\]@angular[\/\\]core[\/\\].+\.js$/, - parser: { system: true }, - }, - { - test: /\.css$/, - loader: 'raw-loader', - }, - { - test: /(\.scss|\.sass)$/, - use: ['raw-loader', 'sass-loader'], - }, - { - test: /\.html$/, - loader: 'raw-loader', - exclude: [root('src/index.html')], - }, - { - enforce: 'post', - test: /\.(js|ts)$/, - loader: 'istanbul-instrumenter-loader', - query: { - esModules: true, - }, - include: root('src'), - exclude: [/\.(e2e|spec|cy-spec)\.ts$/, /node_modules/], - }, - { - test: /\.(jpe?g|png|gif)$/i, - loader: 'file-loader?name=assets/images/[name].[ext]', - }, - { - test: /\.(mp4|webm|ogg)$/i, - loader: 'file-loader?name=assets/videos/[name].[ext]', - }, - { - test: /\.svg(\?v=\d+\.\d+\.\d+)?$/, - loader: - 'file-loader?limit=10000&mimetype=image/svg+xml&name=assets/svgs/[name].[ext]', - }, - { - test: /\.eot(\?v=\d+.\d+.\d+)?$/, - loader: - 'file-loader?prefix=font/&limit=5000&name=assets/fonts/[name].[ext]', - }, - { - test: /\.(woff|woff2)$/, - loader: - 'file-loader?prefix=font/&limit=5000&name=assets/fonts/[name].[ext]', - }, - { - test: /\.ttf(\?v=\d+\.\d+\.\d+)?$/, - loader: - 'file-loader?limit=10000&mimetype=application/octet-stream&name=assets/fonts/[name].[ext]', - }, - ], - }, - plugins: [ - new webpack.DefinePlugin({ - 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'test'), - }), - new webpack.ContextReplacementPlugin( - /\@angular(\\|\/)core(\\|\/)f?esm5/, - path.join(__dirname, './src'), - ), - ], - performance: { - hints: false, - }, - node: { - global: true, - crypto: 'empty', - process: false, - module: false, - clearImmediate: false, - setImmediate: false, - fs: 'empty', - }, -} - -const options = { - webpackOptions, -} - -module.exports = wp(options) diff --git a/npm/angular/cypress/plugins/index.ts b/npm/angular/cypress/plugins/index.ts deleted file mode 100644 index 19515cec728b..000000000000 --- a/npm/angular/cypress/plugins/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { addMatchImageSnapshotPlugin } from 'cypress-image-snapshot/plugin' -import * as webpackConfig from './webpack.config' - -module.exports = (on, config) => { - addMatchImageSnapshotPlugin(on, config) - const { startDevServer } = require('@cypress/webpack-dev-server') - - on('dev-server:start', (options) => { - return startDevServer({ - options, - webpackConfig, - }) - }) - - require('@cypress/code-coverage/task')(on, config) - - return config -} diff --git a/npm/angular/cypress/plugins/webpack.config.ts b/npm/angular/cypress/plugins/webpack.config.ts index ee6cfb9eb475..2b6b128d765b 100644 --- a/npm/angular/cypress/plugins/webpack.config.ts +++ b/npm/angular/cypress/plugins/webpack.config.ts @@ -1,7 +1,7 @@ import * as webpack from 'webpack' import * as path from 'path' -module.exports = { +export default { mode: 'development', devtool: 'inline-source-map', resolve: { diff --git a/npm/angular/cypress/snapshots/All Specs/clicked.snap.png b/npm/angular/cypress/snapshots/All Specs/clicked.snap.png deleted file mode 100644 index a15665c967cf..000000000000 Binary files a/npm/angular/cypress/snapshots/All Specs/clicked.snap.png and /dev/null differ diff --git a/npm/angular/cypress/snapshots/All Specs/init.snap.png b/npm/angular/cypress/snapshots/All Specs/init.snap.png deleted file mode 100644 index 36afaac5a836..000000000000 Binary files a/npm/angular/cypress/snapshots/All Specs/init.snap.png and /dev/null differ diff --git a/npm/angular/cypress/snapshots/app/image-snapshot/image-snapshot.component.cy-spec.ts/clicked.snap.png b/npm/angular/cypress/snapshots/app/image-snapshot/image-snapshot.component.cy-spec.ts/clicked.snap.png index 5aa566f65f1a..92292d0151cd 100644 Binary files a/npm/angular/cypress/snapshots/app/image-snapshot/image-snapshot.component.cy-spec.ts/clicked.snap.png and b/npm/angular/cypress/snapshots/app/image-snapshot/image-snapshot.component.cy-spec.ts/clicked.snap.png differ diff --git a/npm/angular/cypress/snapshots/app/image-snapshot/image-snapshot.component.cy-spec.ts/init.snap.png b/npm/angular/cypress/snapshots/app/image-snapshot/image-snapshot.component.cy-spec.ts/init.snap.png index d981051ae056..c88a40c59456 100644 Binary files a/npm/angular/cypress/snapshots/app/image-snapshot/image-snapshot.component.cy-spec.ts/init.snap.png and b/npm/angular/cypress/snapshots/app/image-snapshot/image-snapshot.component.cy-spec.ts/init.snap.png differ diff --git a/npm/angular/cypress/snapshots/image-snapshot/image-snapshot.component.cy-spec.ts/clicked.snap.png b/npm/angular/cypress/snapshots/image-snapshot/image-snapshot.component.cy-spec.ts/clicked.snap.png deleted file mode 100644 index 645a6fde7469..000000000000 Binary files a/npm/angular/cypress/snapshots/image-snapshot/image-snapshot.component.cy-spec.ts/clicked.snap.png and /dev/null differ diff --git a/npm/angular/cypress/snapshots/image-snapshot/image-snapshot.component.cy-spec.ts/init.snap.png b/npm/angular/cypress/snapshots/image-snapshot/image-snapshot.component.cy-spec.ts/init.snap.png deleted file mode 100644 index ab144e4659fe..000000000000 Binary files a/npm/angular/cypress/snapshots/image-snapshot/image-snapshot.component.cy-spec.ts/init.snap.png and /dev/null differ diff --git a/npm/react/cypress/plugins/index.js b/npm/react/cypress.config.js similarity index 65% rename from npm/react/cypress/plugins/index.js rename to npm/react/cypress.config.js index 036b2a068ab5..5a7625a259d9 100644 --- a/npm/react/cypress/plugins/index.js +++ b/npm/react/cypress.config.js @@ -1,7 +1,10 @@ // @ts-check + +const { defineConfig } = require('cypress') + const { startDevServer } = require('@cypress/webpack-dev-server') const path = require('path') -const babelConfig = require('../../babel.config.js') +const babelConfig = require('./babel.config') /** @type import("webpack").Configuration */ const webpackConfig = { @@ -57,17 +60,28 @@ const webpackConfig = { }, } -/** - * @type Cypress.PluginConfig - */ -module.exports = (on, config) => { - if (config.testingType !== 'component') { - throw Error(`This is a component testing project. testingType should be 'component'. Received ${config.testingType}`) - } - - on('dev-server:start', (options) => { - return startDevServer({ options, webpackConfig, disableLazyCompilation: false }) - }) - - return config -} +module.exports = defineConfig({ + viewportWidth: 400, + viewportHeight: 400, + video: false, + projectId: 'z9dxah', + ignoreTestFiles: [ + '**/__snapshots__/*', + '**/__image_snapshots__/*', + ], + experimentalFetchPolyfill: true, + component: { + testFiles: '**/*spec.{js,jsx,ts,tsx}', + env: { + reactDevtools: true, + }, + devServer (options) { + return startDevServer({ options, webpackConfig, disableLazyCompilation: false }) + }, + }, + e2e: { + setupNodeEvents () { + throw Error('This is a component testing project. Please use `cypress open-ct` to run it') + }, + }, +}) diff --git a/npm/react/cypress.json b/npm/react/cypress.json deleted file mode 100644 index a6b5db93e737..000000000000 --- a/npm/react/cypress.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "viewportWidth": 400, - "viewportHeight": 400, - "video": false, - "projectId": "z9dxah", - "testFiles": "**/*spec.{js,jsx,ts,tsx}", - "env": { - "reactDevtools": true - }, - "ignoreTestFiles": [ - "**/__snapshots__/*", - "**/__image_snapshots__/*" - ], - "experimentalFetchPolyfill": true -} \ No newline at end of file diff --git a/npm/react/plugins/utils/get-transpile-folders.js b/npm/react/plugins/utils/get-transpile-folders.js index b0b50dbec5ae..4ecf9e6496a5 100644 --- a/npm/react/plugins/utils/get-transpile-folders.js +++ b/npm/react/plugins/utils/get-transpile-folders.js @@ -7,15 +7,15 @@ function getTranspileFolders (config) { // user can disable folders, so check first if (config.componentFolder) { - folders.push(config.componentFolder) + folders.push(path.resolve(config.projectRoot, config.componentFolder)) } if (config.fixturesFolder) { - folders.push(config.fixturesFolder) + folders.push(path.resolve(config.projectRoot, config.fixturesFolder)) } if (config.supportFolder) { - folders.push(config.supportFolder) + folders.push(path.resolve(config.projectRoot, config.supportFolder)) } return folders diff --git a/npm/vite-dev-server/src/startServer.ts b/npm/vite-dev-server/src/startServer.ts index c57c37ffc0ab..8d46357c850e 100644 --- a/npm/vite-dev-server/src/startServer.ts +++ b/npm/vite-dev-server/src/startServer.ts @@ -10,7 +10,7 @@ export interface StartDevServerOptions { /** * the Cypress dev server configuration object */ - options: Cypress.DevServerConfig + options: Cypress.CypressDevServerOptions /** * By default, vite will use your vite.config file to * Start the server. If you need additional plugins or diff --git a/npm/vue/cypress.config.ts b/npm/vue/cypress.config.ts new file mode 100644 index 000000000000..4724fea58e6b --- /dev/null +++ b/npm/vue/cypress.config.ts @@ -0,0 +1,40 @@ +import { defineConfig } from 'cypress' +import { startDevServer } from '@cypress/webpack-dev-server' +import webpackConfig from './webpack.config' + +export default defineConfig({ + viewportWidth: 500, + viewportHeight: 500, + video: false, + responseTimeout: 2500, + projectId: '134ej7', + experimentalFetchPolyfill: true, + component: { + testFiles: '**/*spec.{js,ts,tsx}', + devServer (options) { + if (!webpackConfig.resolve) { + webpackConfig.resolve = {} + } + + webpackConfig.resolve.alias = { + ...webpackConfig.resolve.alias, + '@vue/compiler-core$': '@vue/compiler-core/dist/compiler-core.cjs.js', + } + + return startDevServer({ options, webpackConfig }) + }, + setupNodeEvents (on, config) { + require('@cypress/code-coverage/task')(on, config) + + return config + }, + }, + e2e: { + setupNodeEvents (on, config) { + if (config.testingType !== 'component') { + throw Error(`This is a component testing project. testingType should be 'component'. Received '${config.testingType}'`) + } + }, + includeShadowDom: true, + }, +}) diff --git a/npm/vue/cypress.json b/npm/vue/cypress.json deleted file mode 100644 index a25995a3f06c..000000000000 --- a/npm/vue/cypress.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "viewportWidth": 500, - "viewportHeight": 500, - "video": false, - "responseTimeout": 2500, - "projectId": "134ej7", - "testFiles": "**/*spec.{js,ts,tsx}", - "experimentalFetchPolyfill": true -} \ No newline at end of file diff --git a/npm/vue/cypress/component/advanced/access-component/Message.vue b/npm/vue/cypress/component/advanced/access-component/Message.vue index 5e258e933413..3989208f29f6 100644 --- a/npm/vue/cypress/component/advanced/access-component/Message.vue +++ b/npm/vue/cypress/component/advanced/access-component/Message.vue @@ -24,7 +24,6 @@ }, methods: { handleClick() { - console.log('lalala') this.$emit('message-clicked', this.message) } } diff --git a/npm/vue/cypress/plugins/index.js b/npm/vue/cypress/plugins/index.js index 9e363ab7e053..59b2bab6e4e6 100644 --- a/npm/vue/cypress/plugins/index.js +++ b/npm/vue/cypress/plugins/index.js @@ -1,26 +1,22 @@ /// -const { startDevServer } = require('@cypress/webpack-dev-server') -const webpackConfig = require('../../webpack.config') +// *********************************************************** +// This example plugins/index.js can be used to load plugins +// +// You can change the location of this file or turn off loading +// the plugins file with the 'pluginsFile' configuration option. +// +// You can read more here: +// https://on.cypress.io/plugins-guide +// *********************************************************** + +// This function is called when a project is opened or re-opened (e.g. due to +// the project's config changing) /** - * @type Cypress.PluginConfig + * @type {Cypress.PluginConfig} */ +// eslint-disable-next-line no-unused-vars module.exports = (on, config) => { - if (config.testingType !== 'component') { - return config - } - - if (!webpackConfig.resolve) { - webpackConfig.resolve = {} - } - - webpackConfig.resolve.alias = { - ...webpackConfig.resolve.alias, - '@vue/compiler-core$': '@vue/compiler-core/dist/compiler-core.cjs.js', - } - - require('@cypress/code-coverage/task')(on, config) - on('dev-server:start', (options) => startDevServer({ options, webpackConfig })) - - return config + // `on` is used to hook into various events Cypress emits + // `config` is the resolved Cypress config } diff --git a/npm/vue/package.json b/npm/vue/package.json index d6605ec8a841..fd5191828874 100644 --- a/npm/vue/package.json +++ b/npm/vue/package.json @@ -11,7 +11,6 @@ "typecheck": "vue-tsc --noEmit", "test": "yarn cy:run", "watch": "yarn build --watch --watch.exclude ./dist/**/*", - "test:ci:e2e": "node ../../scripts/cypress.js run --project ${PWD}", "test:ci:ct": "node ../../scripts/run-ct-examples.js --examplesList=./examples.env" }, "dependencies": { diff --git a/npm/vue/webpack.config.js b/npm/vue/webpack.config.ts similarity index 85% rename from npm/vue/webpack.config.js rename to npm/vue/webpack.config.ts index 4e3fe73893e5..1872d2ebb384 100644 --- a/npm/vue/webpack.config.js +++ b/npm/vue/webpack.config.ts @@ -1,11 +1,13 @@ // A basic webpack configuration // The default for running tests in this project // https://vue-loader.vuejs.org/guide/#manual-setup -const { VueLoaderPlugin } = require('vue-loader') -const path = require('path') -const pkg = require('package.json') +import { VueLoaderPlugin } from 'vue-loader' +import * as path from 'path' +import { Configuration } from 'webpack' -module.exports = { +const pkg = require('./package.json') + +export default { mode: 'development', output: { path: path.join(__dirname, 'dist'), @@ -49,4 +51,4 @@ module.exports = { // make sure to include the plugin for the magic new VueLoaderPlugin(), ], -} +} as Configuration diff --git a/npm/webpack-dev-server/src/startServer.ts b/npm/webpack-dev-server/src/startServer.ts index 4f045d7b66d9..1f59e4d68e98 100644 --- a/npm/webpack-dev-server/src/startServer.ts +++ b/npm/webpack-dev-server/src/startServer.ts @@ -6,7 +6,7 @@ import { webpackDevServerFacts } from './webpackDevServerFacts' export interface StartDevServer extends UserWebpackDevServerOptions { /* this is the Cypress dev server configuration object */ - options: Cypress.DevServerConfig + options: Cypress.CypressDevServerOptions /* support passing a path to the user's webpack config */ webpackConfig?: Record /* base html template to render in AUT */ @@ -79,7 +79,7 @@ export async function start ({ webpackConfig: userWebpackConfig, template, optio hot: false, } - // @ts-expect-error Webpack types are clashing between Webpack and WebpackDevServer + // @ts-ignore ignore webpack-dev-server v3 type errors return new WebpackDevServer(webpackDevServerConfig, compiler) } diff --git a/packages/desktop-gui/src/lib/config-file-formatted.jsx b/packages/desktop-gui/src/lib/config-file-formatted.jsx index d8d57092aa04..be5d9f8e8112 100644 --- a/packages/desktop-gui/src/lib/config-file-formatted.jsx +++ b/packages/desktop-gui/src/lib/config-file-formatted.jsx @@ -10,7 +10,7 @@ const configFileFormatted = (configFile) => { return <>cypress.json file } - if (['cypress.json', 'cypress.config.js'].includes(configFile)) { + if (['cypress.json', 'cypress.config.ts', 'cypress.config.js'].includes(configFile)) { return <>{configFile} file } diff --git a/packages/desktop-gui/src/lib/markdown-renderer.jsx b/packages/desktop-gui/src/lib/markdown-renderer.jsx index 222ae2d639d3..b95b0c7109bf 100644 --- a/packages/desktop-gui/src/lib/markdown-renderer.jsx +++ b/packages/desktop-gui/src/lib/markdown-renderer.jsx @@ -1,44 +1,20 @@ import React from 'react' -import Markdown from 'markdown-it' +import { MarkdownRenderer as UIMarkdownRenderer } from '@packages/ui-components' import ipc from '../lib/ipc' -const md = new Markdown({ - html: true, - linkify: true, -}) +function clickHandler (e) { + if (e.target.href) { + e.preventDefault() -export default class MarkdownRenderer extends React.PureComponent { - componentDidMount () { - this.node.addEventListener('click', this._clickHandler) + return ipc.externalOpen(e.target.href) } +} - componentWillUnmount () { - this.node.removeEventListener('click', this._clickHandler) - } - - _clickHandler (e) { - if (e.target.href) { - e.preventDefault() - - return ipc.externalOpen(e.target.href) - } - } - - render () { - let renderFn = md.render - - if (this.props.noParagraphWrapper) { - // prevent markdown-it from wrapping the output in a

tag - renderFn = md.renderInline - } - - return ( - this.node = node} - dangerouslySetInnerHTML={{ - __html: renderFn.call(md, this.props.markdown), - }}> - - ) - } +const MarkdownRenderer = ({ markdown, noParagraphWrapper }) => { + return ( + + ) } + +export default MarkdownRenderer diff --git a/packages/desktop-gui/src/project/project-model.js b/packages/desktop-gui/src/project/project-model.js index badfb77f87d6..d4076f9e3fcc 100644 --- a/packages/desktop-gui/src/project/project-model.js +++ b/packages/desktop-gui/src/project/project-model.js @@ -42,6 +42,7 @@ export default class Project { // persisted with api @observable id @observable name + @observable configFile @observable public @observable lastBuildStatus @observable lastBuildCreatedAt @@ -59,6 +60,7 @@ export default class Project { @observable newUserBannerOpen = false @observable browserState = 'closed' @observable resolvedConfig + @observable hasE2EFunction @observable error /** @type {{[key: string] : {warning:Error & {dismissed: boolean}}}} */ @observable _warnings = {} @@ -218,6 +220,10 @@ export default class Project { this.resolvedConfig = resolved } + @action setE2EFunction (hasE2EFunction) { + this.hasE2EFunction = hasE2EFunction + } + @action setError (err = {}) { // for some reason, the original `stack` is unavailable on `err` once it is set on the model // `stack2` remains usable though, for some reason @@ -274,7 +280,7 @@ export default class Project { } getConfigValue (key) { - if (!this.resolvedConfig) return + if (!this.resolvedConfig || !this.resolvedConfig[key]) return return toJS(this.resolvedConfig[key]).value } diff --git a/packages/desktop-gui/src/projects/projects-api.js b/packages/desktop-gui/src/projects/projects-api.js index 9d48eb2f6317..645e238f6864 100644 --- a/packages/desktop-gui/src/projects/projects-api.js +++ b/packages/desktop-gui/src/projects/projects-api.js @@ -156,7 +156,18 @@ const openProject = (project) => { project.setError(err) } - const updateConfig = (config) => { + const updateProjectStatus = () => { + return ipc.getProjectStatus(project.clientDetails()) + .then((projectDetails) => { + project.update(projectDetails) + }) + .catch(ipc.isUnauthed, ipc.handleUnauthed) + .catch((err) => { + project.setApiError(err) + }) + } + + const updateConfig = (config, hasE2Eproperty) => { project.update({ id: config.projectId, name: config.projectName, @@ -167,6 +178,7 @@ const openProject = (project) => { project.setOnBoardingConfig(config) project.setBrowsers(config.browsers) project.setResolvedConfig(config.resolved) + project.setE2EFunction(hasE2Eproperty) project.prompts.setPromptStates(config) } @@ -192,7 +204,19 @@ const openProject = (project) => { return ipc.openProject(project.path) .then((config = {}) => { - updateConfig(config) + // In this context we know we are in e2e. + // The configuration in e2e has already been merged with the main. + // It is not useful to display component/e2e fields explicitely. + // + // NOTE: Even if we wanted to, we cannot display them accurately. + // These two parameter could be functions and we + // cannot send/receive functions using ipc. + + config.resolved = _.omit(config.resolved, 'e2e', 'component') + + config = _.omit(config, 'e2e', 'component') + + updateConfig(config, !!config.e2e) const projectIdAndPath = { id: config.projectId, path: project.path } specsStore.setFilter(projectIdAndPath, localData.get(specsStore.getSpecsFilterId(projectIdAndPath))) diff --git a/packages/desktop-gui/src/settings/configuration.jsx b/packages/desktop-gui/src/settings/configuration.jsx index 76453cbf87f4..dc6512823258 100644 --- a/packages/desktop-gui/src/settings/configuration.jsx +++ b/packages/desktop-gui/src/settings/configuration.jsx @@ -85,7 +85,7 @@ ObjectLabel.defaultProps = { data: 'undefined', } -const computeFromValue = (obj, name, path) => { +const computeFromValue = (obj, hasE2EFunction, name, path) => { const normalizedPath = path.replace('$.', '').replace(name, `['${name}']`) let value = _.get(obj, normalizedPath) @@ -99,11 +99,15 @@ const computeFromValue = (obj, name, path) => { return undefined } + if (value.from === 'plugin' && hasE2EFunction) { + return 'function' + } + return value.from ? value.from : undefined } -const ConfigDisplay = ({ data: obj }) => { - const getFromValue = _.partial(computeFromValue, obj) +const ConfigDisplay = ({ data: obj, hasE2EFunction }) => { + const getFromValue = _.partial(computeFromValue, obj, hasE2EFunction) const renderNode = ({ depth, name, data, isNonenumerable, expanded, path }) => { if (depth === 0) { return null @@ -124,6 +128,8 @@ const ConfigDisplay = ({ data: obj }) => { const data = normalizeWithoutMeta(obj) + if (!data) return

+ data.env = normalizeWithoutMeta(obj.env) return ( @@ -175,12 +181,21 @@ const Configuration = observer(({ project }) => ( set from CLI arguments - plugin - set from plugin file + {project.hasE2EFunction ? + <> + function + set in the e2e.setupNodeEvents() function in the {configFileFormatted(project.configFile)} + + : + <> + plugin + set from plugin file + + } - +
)) diff --git a/packages/desktop-gui/src/settings/node-version.jsx b/packages/desktop-gui/src/settings/node-version.jsx index be71ab417d2b..a7e5aea6331f 100644 --- a/packages/desktop-gui/src/settings/node-version.jsx +++ b/packages/desktop-gui/src/settings/node-version.jsx @@ -1,10 +1,10 @@ import _ from 'lodash' import { observer } from 'mobx-react' import React from 'react' +import { configFileFormatted } from '../lib/config-file-formatted' import ipc from '../lib/ipc' import { isFileJSON } from '../lib/utils' -import { configFileFormatted } from '../lib/config-file-formatted' const openHelp = (e) => { e.preventDefault() diff --git a/packages/desktop-gui/src/settings/settings.scss b/packages/desktop-gui/src/settings/settings.scss index 428789e2d035..ee73ccedef45 100644 --- a/packages/desktop-gui/src/settings/settings.scss +++ b/packages/desktop-gui/src/settings/settings.scss @@ -100,7 +100,7 @@ } } - .envFile, .env, .config, .cli, .plugin, .default { + .envFile, .env, .config, .cli, .plugin, .function, .default { font-family: $font-mono; padding: 2px; } @@ -125,7 +125,7 @@ color: #A21313; } - .plugin { + .plugin, .function { background-color: #f0e7fc; color: #134aa2; } diff --git a/packages/runner-ct/cypress.config.js b/packages/runner-ct/cypress.config.js new file mode 100644 index 000000000000..954a84ddc58c --- /dev/null +++ b/packages/runner-ct/cypress.config.js @@ -0,0 +1,42 @@ +/// +const path = require('path') +const { startDevServer } = require('@cypress/webpack-dev-server') + +function injectStylesInlineForPercyInPlace (webpackConfig) { + webpackConfig.module.rules = webpackConfig.module.rules.map((rule) => { + if (rule?.use[0]?.loader.includes('mini-css-extract-plugin')) { + return { + ...rule, + use: [{ + loader: 'style-loader', + }], + } + } + + return rule + }) +} + +module.exports = { + testFiles: '**/*spec.{ts,tsx}', + video: true, + env: { + reactDevtools: true, + }, + component: { + devServer (options) { + const { default: webpackConfig } = require(path.resolve(__dirname, 'webpack.config.ts')) + + injectStylesInlineForPercyInPlace(webpackConfig) + + return startDevServer({ + webpackConfig, + options, + }) + }, + }, + reporter: '../../node_modules/cypress-multi-reporters/index.js', + reporterOptions: { + configFile: '../../mocha-reporter-config.json', + }, +} diff --git a/packages/runner-ct/cypress.json b/packages/runner-ct/cypress.json deleted file mode 100644 index b7a94e30b95a..000000000000 --- a/packages/runner-ct/cypress.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "testFiles": "**/*spec.{ts,tsx}", - "video": true, - "env": { - "reactDevtools": true - }, - "reporter": "../../node_modules/cypress-multi-reporters/index.js", - "reporterOptions": { - "configFile": "../../mocha-reporter-config.json" - } -} diff --git a/packages/runner-ct/cypress/component/RunnerCt.spec.tsx b/packages/runner-ct/cypress/component/RunnerCt.spec.tsx index ccbaa8c41545..bb373bac242b 100644 --- a/packages/runner-ct/cypress/component/RunnerCt.spec.tsx +++ b/packages/runner-ct/cypress/component/RunnerCt.spec.tsx @@ -132,4 +132,42 @@ describe('RunnerCt', () => { cy.get(selectors.noSpecSelectedReporter).should('exist') }) }) + + context('show warning', () => { + it('show warning', () => { + const state = makeState({ spec: null }) + + state.addWarning({ message: 'this is a warning' }) + + mount( + , + ) + + cy.contains('this is a warning').should('exist') + }) + + it('dismiss warning', () => { + const state = makeState({ spec: null }) + + state.addWarning({ message: 'this is a warning' }) + + mount( + , + ) + + cy.contains('this is a warning').should('exist') + cy.get('[aria-label="dismiss"]').click() + cy.contains('this is a warning').should('not.exist') + }) + }) }) diff --git a/packages/runner-ct/cypress/component/utils.ts b/packages/runner-ct/cypress/component/utils.ts index 3a0165ad9573..ebfaa9a833a3 100644 --- a/packages/runner-ct/cypress/component/utils.ts +++ b/packages/runner-ct/cypress/component/utils.ts @@ -19,4 +19,5 @@ export const getPort = (href: string) => { export class FakeEventManager { on (evt: string) {} + start () {} } diff --git a/packages/runner-ct/cypress/plugins/index.js b/packages/runner-ct/cypress/plugins/index.js deleted file mode 100644 index fb59d3d5996a..000000000000 --- a/packages/runner-ct/cypress/plugins/index.js +++ /dev/null @@ -1,36 +0,0 @@ -/// -const path = require('path') -const { startDevServer } = require('@cypress/webpack-dev-server') - -function injectStylesInlineForPercyInPlace (webpackConfig) { - webpackConfig.module.rules = webpackConfig.module.rules.map((rule) => { - if (rule?.use[0]?.loader.includes('mini-css-extract-plugin')) { - return { - ...rule, - use: [{ - loader: 'style-loader', - }], - } - } - - return rule - }) -} -/** - * @type {Cypress.PluginConfig} - */ -module.exports = (on, config) => { - on('dev-server:start', (options) => { - /** @type {import('webpack').Configuration} */ - const { default: webpackConfig } = require(path.resolve(__dirname, '..', '..', 'webpack.config.ts')) - - injectStylesInlineForPercyInPlace(webpackConfig) - - return startDevServer({ - webpackConfig, - options, - }) - }) - - return config -} diff --git a/packages/runner-ct/src/app/RunnerCt.tsx b/packages/runner-ct/src/app/RunnerCt.tsx index 849748194dcb..2d37020b5073 100644 --- a/packages/runner-ct/src/app/RunnerCt.tsx +++ b/packages/runner-ct/src/app/RunnerCt.tsx @@ -20,6 +20,7 @@ import { useGlobalHotKey } from '../lib/useHotKey' import { animationFrameDebounce } from '../lib/debounce' import { LeftNavMenu } from './LeftNavMenu' import { SpecContent } from './SpecContent' +import { WarningMessage } from './WarningMessage' import { hideIfScreenshotting, hideSpecsListIfNecessary } from '../lib/hideGuard' import { NoSpec } from './NoSpec' @@ -168,6 +169,8 @@ const RunnerCt = namedObserver('RunnerCt', window.addEventListener('resize', onWindowResize) window.dispatchEvent(new Event('resize')) + eventManager.start(config) + return () => window.removeEventListener('resize', onWindowResize) // eslint-disable-next-line react-hooks/exhaustive-deps }, []) @@ -177,71 +180,80 @@ const RunnerCt = namedObserver('RunnerCt', } return ( - 50)} - minSize={hideIfScreenshotting(state, () => 50)} - defaultSize={hideIfScreenshotting(state, () => 50)} - > - {state.screenshotting - ? - : ( - - )} + <> + {state.warnings.map((warning, i) => ( + state.removeWarning(warning)} + /> + ))} state.isSpecsListOpen ? 30 : 0)} - maxSize={hideIfScreenshotting(state, () => state.isSpecsListOpen ? 600 : 0)} - defaultSize={hideIfScreenshotting(state, () => state.isSpecsListOpen ? state.specListWidth : 0)} - className={cs('primary', { isSpecsListClosed: !state.isSpecsListOpen })} - pane2Style={{ - borderLeft: '1px solid rgba(230, 232, 234, 1)' /* $metal-20 */, - }} - onDragFinished={persistWidth('ctSpecListWidth')} - onChange={animationFrameDebounce(updateSpecListWidth)} + allowResize={false} + maxSize={hideIfScreenshotting(state, () => 50)} + minSize={hideIfScreenshotting(state, () => 50)} + defaultSize={hideIfScreenshotting(state, () => 50)} > - { - state.specs.length < 1 ? ( - -

+ {state.screenshotting + ? + : ( + + )} + state.isSpecsListOpen ? 30 : 0)} + maxSize={hideIfScreenshotting(state, () => state.isSpecsListOpen ? 600 : 0)} + defaultSize={hideIfScreenshotting(state, () => state.isSpecsListOpen ? state.specListWidth : 0)} + className={cs('primary', { isSpecsListClosed: !state.isSpecsListOpen })} + pane2Style={{ + borderLeft: '1px solid rgba(230, 232, 234, 1)' /* $metal-20 */, + }} + onDragFinished={persistWidth('ctSpecListWidth')} + onChange={animationFrameDebounce(updateSpecListWidth)} + > + { + state.specs.length < 1 ? ( + +

Create a new spec file in - {' '} - - { - props.config.componentFolder - ? props.config.componentFolder.replace(props.config.projectRoot, '') - : 'the component specs folder' - } - - {' '} + {' '} + + { + props.config.componentFolder + ? props.config.componentFolder.replace(props.config.projectRoot, '') + : 'the component specs folder' + } + + {' '} and it will immediately appear here. -

-
- ) : ( - - ) - } - - +

+ + ) : ( + + ) + } + + +
- + ) }) diff --git a/packages/runner-ct/src/app/WarningMessage.module.scss b/packages/runner-ct/src/app/WarningMessage.module.scss new file mode 100644 index 000000000000..8f3fa1284070 --- /dev/null +++ b/packages/runner-ct/src/app/WarningMessage.module.scss @@ -0,0 +1,21 @@ +.warning{ + border-bottom: 2px solid; + color:#8a6d3b; + background-color:#fcf8e3; + border-color:#faebcc; + padding:15px 20px; + white-space: pre-wrap; +} + +.close{ + float: right; + background: none; + border: none; + padding: 5px; + cursor: pointer; +} + +.title{ + font-weight:bold; + margin-bottom:10px +} \ No newline at end of file diff --git a/packages/runner-ct/src/app/WarningMessage.tsx b/packages/runner-ct/src/app/WarningMessage.tsx new file mode 100644 index 000000000000..e9f3bf3f29f4 --- /dev/null +++ b/packages/runner-ct/src/app/WarningMessage.tsx @@ -0,0 +1,31 @@ +import * as React from 'react' +import { MarkdownRenderer } from '@packages/ui-components' +import { Warning } from '../lib/state' +import styles from './WarningMessage.module.scss' +import { eventManager } from '@packages/runner-shared' + +interface WarningMessageProps{ + warning: Warning + onDismissWarning: () => void +} + +function clickHandler (e) { + if (e.target.href) { + e.preventDefault() + + return eventManager.externalOpen(e.target.href) + } +} + +export const WarningMessage: React.FC = + ({ warning, onDismissWarning }) => { + return ( +
+ +

Warning

+ +
+ ) + } diff --git a/packages/runner-ct/src/lib/state.ts b/packages/runner-ct/src/lib/state.ts index ab8a2bf5eeaf..461702197088 100644 --- a/packages/runner-ct/src/lib/state.ts +++ b/packages/runner-ct/src/lib/state.ts @@ -55,6 +55,10 @@ const _defaults: Defaults = { callbackAfterUpdate: null, } +export interface Warning{ + message: string +} + export default class State extends BaseStore { defaults = _defaults @@ -106,6 +110,8 @@ export default class State extends BaseStore { @observable activePlugin: string | null = null @observable plugins: UIPlugin[] = [] + @observable warnings: Warning[] = [] + constructor ({ spec, specs = [], @@ -192,6 +198,16 @@ export default class State extends BaseStore { } } + @action addWarning (warning: Warning) { + this.warnings.push(warning) + } + + @action removeWarning (warning: Warning) { + const index = this.warnings.findIndex((warn) => warn.message === warning.message) + + this.warnings.splice(index, 1) + } + @action setScreenshotting (screenshotting: boolean) { this.screenshotting = screenshotting } diff --git a/packages/runner-shared/src/event-manager.js b/packages/runner-shared/src/event-manager.js index 714e691ccd61..d2efb5e91800 100644 --- a/packages/runner-shared/src/event-manager.js +++ b/packages/runner-shared/src/event-manager.js @@ -100,6 +100,10 @@ export const eventManager = { rerun() }) + ws.on('project:warning', (warning) => { + state.addWarning(warning) + }) + ws.on('specs:changed', ({ specs, testingType }) => { // do not emit the event if e2e runner is not displaying an inline spec list. if (testingType === 'e2e' && state.useInlineSpecList === false) { @@ -126,7 +130,7 @@ export const eventManager = { _.each(socketToDriverEvents, (event) => { ws.on(event, (...args) => { - Cypress.emit(event, ...args) + Cypress && Cypress.emit(event, ...args) }) }) @@ -632,4 +636,8 @@ export const eventManager = { saveState (state) { ws.emit('save:app:state', state) }, + + externalOpen (href) { + ws.emit('external:open', href) + }, } diff --git a/packages/server/__snapshots__/3_new_project_spec.js b/packages/server/__snapshots__/3_new_project_spec.js index 1ea1143e1544..005b892a244d 100644 --- a/packages/server/__snapshots__/3_new_project_spec.js +++ b/packages/server/__snapshots__/3_new_project_spec.js @@ -1,4 +1,4 @@ -exports['e2e new project passes 1'] = ` +exports['e2e new project creates a sample supportFile & a sample pluginsFile 1'] = ` ==================================================================================================== diff --git a/packages/server/__snapshots__/3_plugins_spec.js b/packages/server/__snapshots__/3_plugins_spec.js index 248834c535d2..92b9abd1b481 100644 --- a/packages/server/__snapshots__/3_plugins_spec.js +++ b/packages/server/__snapshots__/3_plugins_spec.js @@ -510,3 +510,215 @@ exports['e2e plugins does not report more screenshots than exist if user overwri ` + +exports['e2e-plugins fails when there is an async error inside an event handler 1'] = ` + +==================================================================================================== + + (Run Starting) + + ┌────────────────────────────────────────────────────────────────────────────────────────────────┐ + │ Cypress: 1.2.3 │ + │ Browser: FooBrowser 88 │ + │ Specs: 1 found (app_spec.js) │ + │ Searched: cypress/integration/app_spec.js │ + └────────────────────────────────────────────────────────────────────────────────────────────────┘ + + +──────────────────────────────────────────────────────────────────────────────────────────────────── + + Running: app_spec.js (1 of 1) + +The following error was thrown by a plugin. We stopped running your tests because a plugin crashed. Please check your plugins file (\`/foo/bar/.projects/plugins-async-error/cypress/plugins/index.js\`) + + Error: Async error from plugins file + [stack trace lines] + + (Results) + + ┌────────────────────────────────────────────────────────────────────────────────────────────────┐ + │ Tests: 0 │ + │ Passing: 0 │ + │ Failing: 1 │ + │ Pending: 0 │ + │ Skipped: 0 │ + │ Screenshots: 0 │ + │ Video: false │ + │ Duration: X seconds │ + │ Spec Ran: app_spec.js │ + └────────────────────────────────────────────────────────────────────────────────────────────────┘ + + +==================================================================================================== + + (Run Finished) + + + Spec Tests Passing Failing Pending Skipped + ┌────────────────────────────────────────────────────────────────────────────────────────────────┐ + │ ✖ app_spec.js XX:XX - - 1 - - │ + └────────────────────────────────────────────────────────────────────────────────────────────────┘ + ✖ 1 of 1 failed (100%) XX:XX - - 1 - - + + +` + +exports['e2e-plugins can modify config from plugins 1'] = ` + +==================================================================================================== + + (Run Starting) + + ┌────────────────────────────────────────────────────────────────────────────────────────────────┐ + │ Cypress: 1.2.3 │ + │ Browser: FooBrowser 88 │ + │ Specs: 1 found (app_spec.js) │ + │ Searched: cypress/integration/app_spec.js │ + └────────────────────────────────────────────────────────────────────────────────────────────────┘ + + +──────────────────────────────────────────────────────────────────────────────────────────────────── + + Running: app_spec.js (1 of 1) + + + ✓ overrides config + ✓ overrides env + + 2 passing + + + (Results) + + ┌────────────────────────────────────────────────────────────────────────────────────────────────┐ + │ Tests: 2 │ + │ Passing: 2 │ + │ Failing: 0 │ + │ Pending: 0 │ + │ Skipped: 0 │ + │ Screenshots: 0 │ + │ Video: true │ + │ Duration: X seconds │ + │ Spec Ran: app_spec.js │ + └────────────────────────────────────────────────────────────────────────────────────────────────┘ + + + (Video) + + - Started processing: Compressing to 20 CRF + - Finished processing: /XXX/XXX/XXX/cypress/videos/app_spec.js.mp4 (X second) + + +==================================================================================================== + + (Run Finished) + + + Spec Tests Passing Failing Pending Skipped + ┌────────────────────────────────────────────────────────────────────────────────────────────────┐ + │ ✔ app_spec.js XX:XX 2 2 - - - │ + └────────────────────────────────────────────────────────────────────────────────────────────────┘ + ✔ All specs passed! XX:XX 2 2 - - - + + +` + +exports['e2e-plugins catches invalid viewportWidth returned from plugins 1'] = ` +An invalid configuration value returned from the plugins file: \`cypress/plugins/index.js\` + +Expected \`viewportWidth\` to be a number. Instead the value was: \`"foo"\` + +` + +exports['e2e-plugins catches invalid browsers list returned from plugins 1'] = ` +An invalid configuration value returned from the plugins file: \`cypress/plugins/index.js\` + +Expected at least one browser + +` + +exports['e2e-plugins catches invalid browser returned from plugins 1'] = ` +An invalid configuration value returned from the plugins file: \`cypress/plugins/index.js\` + +Found an error while validating the \`browsers\` list. Expected \`displayName\` to be a non-empty string. Instead the value was: \`{"name":"browser name","family":"chromium"}\` + +` + +exports['e2e-plugins can filter browsers from config 1'] = ` +Can't run because you've entered an invalid browser name. + +Browser: 'chrome' was not found on your system or is not supported by Cypress. + +Cypress supports the following browsers: +- chrome +- chromium +- edge +- electron +- firefox + +You can also use a custom browser: https://on.cypress.io/customize-browsers + +Available browsers found on your system are: +- electron + +` + +exports['e2e plugins can config plugins directly in the cypress.config.js 1'] = ` + +==================================================================================================== + + (Run Starting) + + ┌────────────────────────────────────────────────────────────────────────────────────────────────┐ + │ Cypress: 1.2.3 │ + │ Browser: FooBrowser 88 │ + │ Specs: 1 found (app_spec.js) │ + │ Searched: cypress/integration/app_spec.js │ + └────────────────────────────────────────────────────────────────────────────────────────────────┘ + + +──────────────────────────────────────────────────────────────────────────────────────────────────── + + Running: app_spec.js (1 of 1) + + + ✓ overrides config + ✓ overrides env + + 2 passing + + + (Results) + + ┌────────────────────────────────────────────────────────────────────────────────────────────────┐ + │ Tests: 2 │ + │ Passing: 2 │ + │ Failing: 0 │ + │ Pending: 0 │ + │ Skipped: 0 │ + │ Screenshots: 0 │ + │ Video: true │ + │ Duration: X seconds │ + │ Spec Ran: app_spec.js │ + └────────────────────────────────────────────────────────────────────────────────────────────────┘ + + + (Video) + + - Started processing: Compressing to 20 CRF + - Finished processing: /XXX/XXX/XXX/cypress/videos/app_spec.js.mp4 (X second) + + +==================================================================================================== + + (Run Finished) + + + Spec Tests Passing Failing Pending Skipped + ┌────────────────────────────────────────────────────────────────────────────────────────────────┐ + │ ✔ app_spec.js XX:XX 2 2 - - - │ + └────────────────────────────────────────────────────────────────────────────────────────────────┘ + ✔ All specs passed! XX:XX 2 2 - - - + + +` diff --git a/packages/server/lib/browsers/utils.ts b/packages/server/lib/browsers/utils.ts index 283f6a04b55e..8b4bf7b9a39d 100644 --- a/packages/server/lib/browsers/utils.ts +++ b/packages/server/lib/browsers/utils.ts @@ -132,6 +132,22 @@ async function executeBeforeBrowserLaunch (browser, launchOptions: typeof defaul return launchOptions } +function removeFunctionsInObject (pluginConfigResult) { + if (pluginConfigResult) { + Object.keys(pluginConfigResult).forEach((key) => { + if (typeof pluginConfigResult[key] === 'function') { + delete pluginConfigResult[key] + } else if (typeof pluginConfigResult[key] === 'object') { + if (Array.isArray(pluginConfigResult[key])) { + pluginConfigResult[key] = pluginConfigResult[key].filter((obj) => typeof obj !== 'function') + } else { + removeFunctionsInObject(pluginConfigResult[key]) + } + } + }) + } +} + function extendLaunchOptionsFromPlugins (launchOptions, pluginConfigResult, options) { // if we returned an array from the plugin // then we know the user is using the deprecated @@ -149,6 +165,10 @@ function extendLaunchOptionsFromPlugins (launchOptions, pluginConfigResult, opti extensions: [], }) } else { + // First, remove all dummy functions created on the plugins thread, + // they don't need to be checked + removeFunctionsInObject(pluginConfigResult) + // either warn about the array or potentially error on invalid props, but not both // strip out all the known launch option properties from the resulting object diff --git a/packages/server/lib/config.ts b/packages/server/lib/config.ts index 17aad0a1a06c..66e8b45b9cdd 100644 --- a/packages/server/lib/config.ts +++ b/packages/server/lib/config.ts @@ -62,6 +62,7 @@ const breakingKeys = _.map(breakingOptions, 'name') const folders = _(options).filter({ isFolder: true }).map('name').value() const validationRules = createIndex(options, 'name', 'validation') const defaultValues: Record = createIndex(options, 'name', 'defaultValue') +const onlyInOverrideValues = createIndex(options, 'name', 'onlyInOverride') const convertRelativeToAbsolutePaths = (projectRoot, obj, defaults = {}) => { return _.reduce(folders, (memo, folder) => { @@ -88,10 +89,26 @@ const validateNoBreakingConfig = (cfg) => { }) } -const validate = (cfg, onErr) => { +/** + * validate a root config object + * @param {object} cfg config object to validate + * @param {(errMsg:string) => void} onErr function run when invalid config is found + * @param {boolean} bypassRootLimitations skip checks related to position when we are working with merged configs + * @returns + */ +function validate (cfg, onErr, + { bypassRootLimitations } = { bypassRootLimitations: false }) { return _.each(cfg, (value, key) => { const validationFn = validationRules[key] + if (!bypassRootLimitations && onlyInOverrideValues[key]) { + if (onlyInOverrideValues[key] === true) { + return onErr(`key \`${key}\` is only valid in a testingType object, it is invalid to use it in the root`) + } + + return onErr(`key \`${key}\` is only valid in the \`${onlyInOverrideValues[key]}\` object, it is invalid to use it in the root`) + } + // does this key have a validation rule? if (validationFn) { // and is the value different from the default? @@ -107,8 +124,13 @@ const validate = (cfg, onErr) => { } const validateFile = (file) => { - return (settings) => { - return validate(settings, (errMsg) => { + return (configObject) => { + // disallow use of pluginFile in evaluated configuration files + if (/\.(ts|js)$/.test(file) && configObject.pluginsFile) { + errors.throw('CONFLICT_PLUGINSFILE_CONFIGJS', file) + } + + return validate(configObject, (errMsg) => { return errors.throw('SETTINGS_VALIDATION_ERROR', file, errMsg) }) } @@ -122,6 +144,10 @@ const hideSpecialVals = function (val, key) { return val } +function isComponentTesting (options: Record = {}) { + return options.testingType === 'component' +} + // an object with a few utility methods // for easy stubbing from unit tests export const utils = { @@ -204,15 +230,17 @@ export function allowed (obj = {}) { } export function get (projectRoot, options = {}) { + const configFilename = settings.configFile(options) + return Promise.all([ - settings.read(projectRoot, options).then(validateFile('cypress.json')), + settings.read(projectRoot, options).then(validateFile(configFilename)), settings.readEnv(projectRoot).then(validateFile('cypress.env.json')), ]) - .spread((settings, envFile) => { + .spread((configObject, envFile) => { return set({ projectName: getNameFromRoot(projectRoot), projectRoot, - config: _.cloneDeep(settings), + config: _.cloneDeep(configObject), envFile: _.cloneDeep(envFile), options, }) @@ -238,15 +266,19 @@ export function set (obj: Record = {}) { config.projectRoot = projectRoot config.projectName = projectName - return mergeDefaults(config, options) + return mergeAllConfigs(config, options).then((configObject = {}) => { + debug('merged config is %o', configObject) + + return configObject + }) } -export function mergeDefaults (config: Record = {}, options: Record = {}) { +function mergeCLIOptions (config, options) { const resolved = {} config.rawJson = _.cloneDeep(config) - _.extend(config, _.pick(options, 'configFile', 'morgan', 'isTextTerminal', 'socketId', 'report', 'browsers')) + _.extend(config, _.pick(options, 'configFile', 'morgan', 'isTextTerminal', 'socketId', 'report', 'browsers', 'testingType')) debug('merged config with options, got %o', config) _ @@ -258,6 +290,10 @@ export function mergeDefaults (config: Record = {}, options: Record config[key] = val }).value() + return resolved +} + +function cleanUpConfig (config, options, resolved) { let url = config.baseUrl if (url) { @@ -267,8 +303,6 @@ export function mergeDefaults (config: Record = {}, options: Record config.baseUrl = url.replace(/\/\/+$/, '/') } - _.defaults(config, defaultValues) - // split out our own app wide env from user env variables // and delete envFile config.env = parseEnv(config, options.env, resolved) @@ -290,6 +324,25 @@ export function mergeDefaults (config: Record = {}, options: Record // to zero config.numTestsKeptInMemory = 0 } +} + +export function mergeAllConfigs (config: Record = {}, options: Record = {}) { + const resolved = mergeCLIOptions(config, options) + + cleanUpConfig(config, options, resolved) + + validate(config, (errMsg) => { + return errors.throw('CONFIG_VALIDATION_ERROR', errMsg) + }) + + const testingType = isComponentTesting(options) ? 'component' : 'e2e' + + if (testingType in config) { + config = { ...config, ...config[testingType] } + } + + // 3 - merge the defaults + _.defaults(config, defaultValues) config = setResolvedConfigValues(config, defaultValues, resolved) @@ -301,13 +354,6 @@ export function mergeDefaults (config: Record = {}, options: Record config = setParentTestsPaths(config) - // validate config again here so that we catch - // configuration errors coming from the CLI overrides - // or env var overrides - validate(config, (errMsg) => { - return errors.throw('CONFIG_VALIDATION_ERROR', errMsg) - }) - validateNoBreakingConfig(config) return setSupportFileAndFolder(config) diff --git a/packages/server/lib/configFiles.ts b/packages/server/lib/configFiles.ts index 938003e636ec..56556205b345 100644 --- a/packages/server/lib/configFiles.ts +++ b/packages/server/lib/configFiles.ts @@ -1,2 +1 @@ -// the first file is the default created file -export const CYPRESS_CONFIG_FILES = ['cypress.json', 'cypress.config.js'] +export const CYPRESS_CONFIG_FILES = ['cypress.config.js', 'cypress.config.ts', 'cypress.json'] diff --git a/packages/server/lib/config_options.ts b/packages/server/lib/config_options.ts index 2c02b10dada7..7e829bdf0b3c 100644 --- a/packages/server/lib/config_options.ts +++ b/packages/server/lib/config_options.ts @@ -43,8 +43,8 @@ export const options = [ }, { name: 'component', // runner-ct overrides - defaultValue: {}, - validation: v.isValidConfig, + defaultValue: null, + validation: v.isValidTestingTypeConfig, }, { name: 'componentFolder', defaultValue: 'cypress/component', @@ -52,7 +52,7 @@ export const options = [ isFolder: true, }, { name: 'configFile', - defaultValue: 'cypress.json', + defaultValue: null, validation: v.isStringOrFalse, // not truly internal, but can only be set via cli, // so we don't consider it a "public" option @@ -73,8 +73,8 @@ export const options = [ }, { name: 'e2e', // e2e runner overrides - defaultValue: {}, - validation: v.isValidConfig, + defaultValue: null, + validation: v.isValidTestingTypeConfig, }, { name: 'env', validation: v.isPlainObject, @@ -216,6 +216,16 @@ export const options = [ defaultValue: 'cypress/screenshots', validation: v.isStringOrFalse, isFolder: true, + }, { + name: 'setupNodeEvents', + defaultvalue: null, + validation: v.isFunction, + onlyInOverride: true, + }, { + name: 'devServer', + defaultvalue: null, + validation: v.isFunction, + onlyInOverride: 'component', }, { name: 'socketId', defaultValue: null, diff --git a/packages/server/lib/errors.js b/packages/server/lib/errors.js index 8574c0bd1984..a70f49c78323 100644 --- a/packages/server/lib/errors.js +++ b/packages/server/lib/errors.js @@ -559,6 +559,19 @@ const getMsgByType = function (type, arg1 = {}, arg2, arg3) { Or you might have renamed the extension of your \`supportFile\` to \`.ts\`. If that's the case, restart the test runner. Learn more at https://on.cypress.io/support-file-missing-or-invalid` + case 'CONFIG_FILE_ERROR': + msg = stripIndent`\ + Error when loading the config file at the following location: + + \`${arg1}\` + + If you renamed the extension of your config file, restart the test runner.`.trim() + + if (arg2) { + return { msg, details: arg2 } + } + + return msg case 'PLUGINS_FILE_ERROR': msg = stripIndent`\ The plugins file is missing or invalid. @@ -932,7 +945,7 @@ const getMsgByType = function (type, arg1 = {}, arg2, arg3) { You can safely remove this option from your config.` case 'EXPERIMENTAL_COMPONENT_TESTING_REMOVED': return stripIndent`\ - The ${chalk.yellow(`\`experimentalComponentTesting\``)} configuration option was removed in Cypress version \`7.0.0\`. Please remove this flag from \`cypress.json\`. + The ${chalk.yellow(`\`experimentalComponentTesting\``)} configuration option was removed in Cypress version \`7.0.0\`. Please remove this flag from \`${arg1}\`. Cypress Component Testing is now a standalone command. You can now run your component tests with: @@ -1001,6 +1014,37 @@ const getMsgByType = function (type, arg1 = {}, arg2, arg3) { https://on.cypress.io/component-testing ` + // TODO: update with vetted cypress language + case 'CT_NO_DEV_START_FUNCTION': + return stripIndent`\ + To run component-testing, cypress needs the \`devServer\` function to be implemented. + + Add a \`devServer()\` in the component object of the ${arg1} file. + + Learn how to set up component testing: + + https://on.cypress.io/component-testing + ` + // TODO: update with vetted cypress language + case 'CONFLICT_PLUGINSFILE_CONFIGJS': + return stripIndent` + \`pluginsFile\` cannot be set in a \`${arg1}\` file. + + \`pluginsFile\` is deprecated and will error in a future version of Cypress, prefer using the \`setupNodeEvents\` function instead. + + https://on.cypress.io/setupNodeEvents + ` + + // TODO: update with vetted cypress language + case 'DEPRECATED_CYPRESS_JSON': + return stripIndent` + \`cypress.json\` will not be supported in a future version of Cypress. + + Please see the docs to migrate it to \`cypress.config.js\` + + Learn more: https://on.cypress.io/migrate-configjs + ` + case 'UNSUPPORTED_BROWSER_VERSION': return arg1 case 'WIN32_DEPRECATION': diff --git a/packages/server/lib/gui/events.js b/packages/server/lib/gui/events.js index 3508c3ff1156..f7b35261be74 100644 --- a/packages/server/lib/gui/events.js +++ b/packages/server/lib/gui/events.js @@ -323,7 +323,8 @@ const handleEvent = function (options, bus, event, id, type, arg) { onError, onWarning, }) - }).call('getConfig') + }) + .then((project) => project.getConfig()) .then((config) => { if (config.configFile && path.isAbsolute(config.configFile)) { config.configFile = path.relative(arg, config.configFile) diff --git a/packages/server/lib/gui/files.ts b/packages/server/lib/gui/files.ts index 40c344ad65e3..15f6c2f02f4d 100644 --- a/packages/server/lib/gui/files.ts +++ b/packages/server/lib/gui/files.ts @@ -1,7 +1,10 @@ +import Debug from 'debug' import { openProject } from '../open_project' import { createFile } from '../util/spec_writer' import { showSaveDialog } from './dialog' +const debug = Debug('cypress:server:gui:files') + export const showDialogAndCreateSpec = async () => { const cfg = openProject.getConfig() @@ -19,6 +22,15 @@ export const showDialogAndCreateSpec = async () => { await createFile(path) } + debug('file potentially created', path) + + if (!path) { + return { + specs: null, + path, + } + } + // reload specs now that we've added a new file // we reload here so we can update ui immediately instead of // waiting for file watching to send updated spec list diff --git a/packages/server/lib/modes/record.js b/packages/server/lib/modes/record.js index cf38fe303ff0..c0420d312b0a 100644 --- a/packages/server/lib/modes/record.js +++ b/packages/server/lib/modes/record.js @@ -280,7 +280,7 @@ const createRun = Promise.method((options = {}) => { ciBuildId: null, }) - let { projectId, recordKey, platform, git, specPattern, specs, parallel, ciBuildId, group, tags, testingType } = options + let { projectId, recordKey, platform, git, specPattern, specs, parallel, ciBuildId, group, tags, projectRoot, testingType } = options if (recordKey == null) { recordKey = env.get('CYPRESS_RECORD_KEY') @@ -444,7 +444,7 @@ const createRun = Promise.method((options = {}) => { } } case 404: - return errors.throw('DASHBOARD_PROJECT_NOT_FOUND', projectId, settings.configFile(options)) + return errors.throw('DASHBOARD_PROJECT_NOT_FOUND', projectId, settings.configFile(projectRoot, options)) case 412: return errors.throw('DASHBOARD_INVALID_RUN_REQUEST', err.error) case 422: { @@ -612,6 +612,7 @@ const createRunAndRecordSpecs = (options = {}) => { git, specs, group, + projectRoot, tags, parallel, platform, diff --git a/packages/server/lib/modes/run.js b/packages/server/lib/modes/run.js index 145847dffd99..bbdcb12f8ae0 100644 --- a/packages/server/lib/modes/run.js +++ b/packages/server/lib/modes/run.js @@ -461,7 +461,7 @@ const iterateThroughSpecs = function (options = {}) { return serial() } -const getProjectId = Promise.method((project, id) => { +const getProjectId = Promise.method(async (project, id) => { if (id == null) { id = env.get('CYPRESS_PROJECT_ID') } @@ -471,7 +471,9 @@ const getProjectId = Promise.method((project, id) => { return id } - return project.getProjectId() + const conf = project.getConfig() + + return project.getProjectId(conf) .catch(() => { // no id no problem return null @@ -1527,7 +1529,7 @@ module.exports = { recordMode.throwIfRecordParamsWithoutRecording(record, ciBuildId, parallel, group, tag) if (record) { - recordMode.throwIfNoProjectId(projectId, settings.configFile(options)) + recordMode.throwIfNoProjectId(projectId, settings.configFile(projectRoot, options)) recordMode.throwIfIncorrectCiBuildIdUsage(ciBuildId, parallel, group) recordMode.throwIfIndeterminateCiBuildId(ciBuildId, parallel, group) } @@ -1609,7 +1611,7 @@ module.exports = { } if (record) { - const { projectName } = config + const { projectName, projectRoot } = config return recordMode.createRunAndRecordSpecs({ tag, diff --git a/packages/server/lib/plugins/child/index.js b/packages/server/lib/plugins/child/index.js index 9687e288194a..4f9bb5e5356e 100644 --- a/packages/server/lib/plugins/child/index.js +++ b/packages/server/lib/plugins/child/index.js @@ -3,6 +3,6 @@ require('graceful-fs').gracefulify(require('fs')) require('../../util/suppress_warnings').suppress() const ipc = require('../util').wrapIpc(process) -const { file: pluginsFile, projectRoot } = require('minimist')(process.argv.slice(2)) +const { file: pluginsFile, projectRoot, testingType } = require('minimist')(process.argv.slice(2)) -require('./run_plugins')(ipc, pluginsFile, projectRoot) +require('./run_plugins')(ipc, pluginsFile, projectRoot, testingType) diff --git a/packages/server/lib/plugins/child/run_plugins.js b/packages/server/lib/plugins/child/run_plugins.js index 5b0d68abd28f..f62d6a3d6cf4 100644 --- a/packages/server/lib/plugins/child/run_plugins.js +++ b/packages/server/lib/plugins/child/run_plugins.js @@ -7,11 +7,11 @@ const Promise = require('bluebird') const preprocessor = require('./preprocessor') const devServer = require('./dev-server') const resolve = require('../../util/resolve') +const tsNodeUtil = require('../../util/ts_node') const browserLaunch = require('./browser_launch') const task = require('./task') const util = require('../util') const validateEvent = require('./validate_event') -const tsNodeUtil = require('./ts_node') let registeredEventsById = {} let registeredEventsByName = {} @@ -36,6 +36,7 @@ const getDefaultPreprocessor = function (config) { } let plugins +let devServerFunction const load = (ipc, config, pluginsFile) => { debug('run plugins function') @@ -87,7 +88,15 @@ const load = (ipc, config, pluginsFile) => { .try(() => { debug('run plugins function') - return plugins(register, config) + if (devServerFunction) { + register('dev-server:start', devServerFunction) + } + + if (plugins) { + return plugins(register, config) + } + + return }) .tap(() => { if (!registeredEventsByName['file:preprocessor']) { @@ -138,10 +147,36 @@ const execute = (ipc, event, ids, args = []) => { } } +function interopRequire (pluginsFile) { + const exp = require(pluginsFile) + + return exp && exp.default ? exp.default : exp +} + +/** + * the plugins function can be either at the top level + * in a pluginsFile or in a non-dedicated cypress.config.js file + * This functions normalizes where the function is and runs it. + * + * @param {string} pluginsFile absolute path of the targeted file conatining the plugins function + * @param {string} functionName path to the function in the exported object like `"e2e.plugins"` + * @returns the plugins function found + */ +function getPluginsFunction (resolvedExport, testingType) { + if (testingType) { + if (resolvedExport[testingType]) { + return resolvedExport[testingType].setupNodeEvents + } + } + + return resolvedExport +} + let tsRegistered = false -const runPlugins = (ipc, pluginsFile, projectRoot) => { - debug('pluginsFile:', pluginsFile) +const runPlugins = (ipc, pluginsFile, projectRoot, testingType) => { + debug('plugins file:', pluginsFile) + debug('testingType:', testingType) debug('project root:', projectRoot) if (!projectRoot) { throw new Error('Unexpected: projectRoot should be a string') @@ -171,13 +206,17 @@ const runPlugins = (ipc, pluginsFile, projectRoot) => { } try { - debug('require pluginsFile') - plugins = require(pluginsFile) + debug('require pluginsFile "%s"', pluginsFile) + const pluginsObject = interopRequire(pluginsFile) + + plugins = getPluginsFunction(pluginsObject, testingType) - // Handle export default () => {} - if (plugins && typeof plugins.default === 'function') { - plugins = plugins.default + if (testingType === 'component' && pluginsObject.component) { + devServerFunction = pluginsObject.component.devServer } + + debug('plugins %o', plugins) + debug('devServerFunction %o', devServerFunction) } catch (err) { debug('failed to require pluginsFile:\n%s', err.stack) ipc.send('load:error', 'PLUGINS_FILE_ERROR', pluginsFile, err.stack) @@ -185,7 +224,7 @@ const runPlugins = (ipc, pluginsFile, projectRoot) => { return } - if (typeof plugins !== 'function') { + if (typeof plugins !== 'function' && !devServerFunction) { debug('not a function') ipc.send('load:error', 'PLUGINS_DIDNT_EXPORT_FUNCTION', pluginsFile, plugins) diff --git a/packages/server/lib/plugins/dev-server.js b/packages/server/lib/plugins/dev-server.js index 385e1b1bb89e..8eac100f48f5 100644 --- a/packages/server/lib/plugins/dev-server.js +++ b/packages/server/lib/plugins/dev-server.js @@ -33,6 +33,10 @@ const API = { start ({ specs, config }) { if (!plugins.has('dev-server:start')) { + if (config.configFile && !/\.json$/.test(config.configFile)) { + return errors.throw('CT_NO_DEV_START_FUNCTION', config.configFile) + } + return errors.throw('CT_NO_DEV_START_EVENT', config.pluginsFile) } diff --git a/packages/server/lib/plugins/index.js b/packages/server/lib/plugins/index.js index 24d12a5f9c2a..a041f01a8300 100644 --- a/packages/server/lib/plugins/index.js +++ b/packages/server/lib/plugins/index.js @@ -79,9 +79,11 @@ const init = (config, options) => { registeredEvents = {} - const pluginsFile = config.pluginsFile || path.join(__dirname, 'child', 'default_plugins_file.js') + const pluginsFile = typeof config.pluginsFile === 'string' + ? config.pluginsFile + : path.join(__dirname, 'child', 'default_plugins_file.js') const childIndexFilename = path.join(__dirname, 'child', 'index.js') - const childArguments = ['--file', pluginsFile, '--projectRoot', options.projectRoot] + const childArguments = ['--projectRoot', options.projectRoot] const childOptions = { stdio: 'pipe', env: { @@ -90,6 +92,20 @@ const init = (config, options) => { }, } + const testingType = options.testingType || 'e2e' + + if (/\.json$/.test(options.configFile) || options.configFile === false) { + childArguments.push('--file', pluginsFile) + } else if (config[testingType] && (config[testingType].setupNodeEvents || (testingType === 'component' && config.component.devServer))) { + childArguments.push( + '--testingType', testingType, + '--file', options.configFile, + ) + } else { + // if the config file is evaluated but there is no plugins function, fall back on default plugins + childArguments.push('--file', path.join(__dirname, 'child', 'default_plugins_file.js')) + } + if (config.resolvedNodePath) { debug('launching using custom node version %o', _.pick(config, ['resolvedNodePath', 'resolvedNodeVersion'])) childOptions.execPath = config.resolvedNodePath @@ -137,8 +153,6 @@ const init = (config, options) => { ipc.send('load', config) ipc.on('loaded', (newCfg, registrations) => { - _.omit(config, 'projectRoot', 'configFile') - _.each(registrations, (registration) => { debug('register plugins process event', registration.event, 'with id', registration.eventId) diff --git a/packages/server/lib/plugins/util.js b/packages/server/lib/plugins/util.js index eb78eaa57c7f..0b84df127ada 100644 --- a/packages/server/lib/plugins/util.js +++ b/packages/server/lib/plugins/util.js @@ -4,11 +4,64 @@ const debug = require('debug')('cypress:server:plugins') const Promise = require('bluebird') const UNDEFINED_SERIALIZED = '__cypress_undefined__' +const FUNCTION_SERIALIZED = '__cypress_function__' const serializeError = (err) => { return _.pick(err, 'name', 'message', 'stack', 'code', 'annotated', 'type') } +function serializeArgument (arg) { + if (arg === null || arg === undefined) { + return null + } + + if (typeof arg === 'function') { + return FUNCTION_SERIALIZED + } + + if (typeof arg === 'object') { + if (_.isArray(arg)) { + return arg.map(serializeArgument) + } + + const serializedObject = {} + + for (const [key, val] of Object.entries(arg)) { + serializedObject[key] = serializeArgument(val) + } + + return serializedObject + } + + return arg +} + +function deserializeArgument (arg) { + if (arg === null || arg === undefined) { + return null + } + + if (arg === FUNCTION_SERIALIZED) { + return function () { + throw Error('this function is not meant to be used on it\'s own. It is the result of a deserialization and can be used to check the type of a returned object.') + } + } + + if (typeof arg === 'object') { + if (_.isArray(arg)) { + return arg.map((argElement) => deserializeArgument(argElement)) + } + + return Object.keys(arg).reduce(function (acc, key) { + acc[key] = deserializeArgument(arg[key]) + + return acc + }, {}) + } + + return arg +} + module.exports = { serializeError, @@ -24,19 +77,30 @@ module.exports = { emitter.setMaxListeners(Infinity) return { - send (event, ...args) { + send (event, ...rawArgs) { if (aProcess.killed) { return } + const args = rawArgs.map((arg) => serializeArgument(arg)) + return aProcess.send({ event, args, }) }, - on: emitter.on.bind(emitter), - removeListener: emitter.removeListener.bind(emitter), + on (event, handler) { + function wrappedHandler (...rawArgs) { + return handler(...rawArgs.map((arg) => deserializeArgument(arg))) + } + handler.__realHandlerFunction__ = wrappedHandler + emitter.on(event, wrappedHandler) + }, + + removeListener (event, handler) { + emitter.removeListener(event, handler.__realHandlerFunction__) + }, } }, diff --git a/packages/server/lib/project-base.ts b/packages/server/lib/project-base.ts index 7ccf66ded546..7b7f9a33b9a3 100644 --- a/packages/server/lib/project-base.ts +++ b/packages/server/lib/project-base.ts @@ -91,6 +91,7 @@ export class ProjectBase extends EE { public spec: Cypress.Cypress['spec'] | null private generatedProjectIdTimestamp: any projectRoot: string + isOpen: boolean = false constructor ({ projectRoot, @@ -184,13 +185,20 @@ export class ProjectBase extends EE { let cfg = this.getConfig() + if (typeof cfg.configFile === 'string' + && /\.json$/.test(cfg.configFile) + // don't show the deprecation warning in e2e tests to avoid polluting snapshots + && process.env.CYPRESS_INTERNAL_ENV !== 'test') { + this.options.onWarning(errors.get('DEPRECATED_CYPRESS_JSON', cfg.configFile)) + } + process.chdir(this.projectRoot) // TODO: we currently always scaffold the plugins file // even when headlessly or else it will cause an error when // we try to load it and it's not there. We must do this here // else initialing the plugins will instantly fail. - if (cfg.pluginsFile) { + if (cfg.pluginsFile && (!cfg.configFile || /\.json$/.test(cfg.configFile))) { debug('scaffolding with plugins file %s', cfg.pluginsFile) await scaffold.plugins(path.dirname(cfg.pluginsFile), cfg) @@ -266,6 +274,7 @@ export class ProjectBase extends EE { onReloadBrowser: this.options.onReloadBrowser, onFocusTests: this.options.onFocusTests, onSpecChanged: this.options.onSpecChanged, + onConnect: this.options.onChange, }, { socketIoCookie: cfg.socketIoCookie, namespace: cfg.namespace, @@ -308,6 +317,8 @@ export class ProjectBase extends EE { system: _.pick(sys, 'osName', 'osVersion'), } + this.isOpen = true + return runEvents.execute('before:run', cfg, beforeRunDetails) } @@ -338,6 +349,10 @@ export class ProjectBase extends EE { } async close () { + if (!this.isOpen) { + return Promise.resolve() + } + debug('closing project instance %s', this.projectRoot) this.spec = null @@ -352,7 +367,7 @@ export class ProjectBase extends EE { await Promise.all([ this.server?.close(), this.watchers?.close(), - closePreprocessor?.(), + typeof closePreprocessor === 'function' ? closePreprocessor() : Promise.resolve(), ]) this._isServerOpen = false @@ -363,6 +378,8 @@ export class ProjectBase extends EE { if (config.isTextTerminal || !config.experimentalInteractiveRunEvents) return + this.isOpen = false + return runEvents.execute('after:run', config) } @@ -410,9 +427,11 @@ export class ProjectBase extends EE { // internals and breaking cypress const allowedCfg = config.allowed(cfg) + const configFile = settings.pathToConfigFile(this.projectRoot, options) + const modifiedCfg = await plugins.init(allowedCfg, { projectRoot: this.projectRoot, - configFile: settings.pathToConfigFile(this.projectRoot, options), + configFile, testingType: options.testingType, onError: (err: Error) => this._onError(err, options), onWarning: options.onWarning, @@ -420,7 +439,16 @@ export class ProjectBase extends EE { debug('plugin config yielded: %o', modifiedCfg) - return config.updateWithPluginValues(cfg, modifiedCfg) + const finalConfig = config.updateWithPluginValues(cfg, modifiedCfg) + + // desktop-gui receives the proper config file, even if it is one of the defaults + // we can't know in advance which one json, js or ts file will be present + // but we need it to be forwarded to the gui for display and help + if (configFile !== false) { + finalConfig.configFile = path.relative(this.projectRoot, finalConfig.configFile ?? configFile) + } + + return finalConfig } async startCtDevServer (specs: Cypress.Cypress['spec'][], config: any) { @@ -515,9 +543,9 @@ export class ProjectBase extends EE { configFile, projectRoot, }: { - projectRoot: string - configFile?: string | false onSettingsChanged?: false | (() => void) + configFile?: string | false + projectRoot: string }) { // bail if we havent been told to // watch anything (like in run mode) @@ -529,6 +557,8 @@ export class ProjectBase extends EE { const obj = { onChange: () => { + debug('settings files have changed') + // dont fire change events if we generated // a project id less than 1 second ago if (this.generatedProjectIdTimestamp && @@ -536,6 +566,8 @@ export class ProjectBase extends EE { return } + debug('run onSettingsChanged') + // call our callback function // when settings change! onSettingsChanged() @@ -609,8 +641,8 @@ export class ProjectBase extends EE { this.emit('capture:video:frames', data) }, - onConnect: (id: string) => { - debug('socket:connected') + onConnect: (id: string, socket, cySocket) => { + options.onConnect?.(id, socket, cySocket) this.emit('socket:connected', id) }, @@ -838,15 +870,15 @@ export class ProjectBase extends EE { // These methods are not related to start server/sockets/runners - async getProjectId () { + async getProjectId (config?: Cfg): Promise { await this.verifyExistence() - const readSettings = await settings.read(this.projectRoot, this.options) + const readSettings = config || await settings.read(this.projectRoot, this.options) if (readSettings && readSettings.projectId) { return readSettings.projectId } - errors.throw('NO_PROJECT_ID', settings.configFile(this.options), this.projectRoot) + return errors.throw('NO_PROJECT_ID', settings.configFile(this.options), this.projectRoot) } async verifyExistence () { diff --git a/packages/server/lib/saved_state.js b/packages/server/lib/saved_state.js index abef16ff4590..77689d078a09 100644 --- a/packages/server/lib/saved_state.js +++ b/packages/server/lib/saved_state.js @@ -2,6 +2,7 @@ const _ = require('lodash') const debug = require('debug')('cypress:server:saved_state') const path = require('path') const Promise = require('bluebird') +const { CYPRESS_CONFIG_FILES } = require('./configFiles') const appData = require('./util/app_data') const cwd = require('./cwd') const FileUtil = require('./util/file') @@ -49,17 +50,23 @@ const formStatePath = (projectRoot) => { debug('missing project path, looking for project here') - const cypressJsonPath = cwd('cypress.json') + return CYPRESS_CONFIG_FILES.reduce((acc, filename) => { + return acc.then((projectRoot) => { + if (projectRoot) { + return projectRoot + } - return fs.pathExistsAsync(cypressJsonPath) - .then((found) => { - if (found) { - debug('found cypress file %s', cypressJsonPath) - projectRoot = cwd() - } + const cypressConfigPath = cwd(filename) - return projectRoot - }) + return fs.pathExistsAsync(cypressConfigPath).then((found) => { + if (found) { + debug('found cypress file %s', cypressConfigPath) + + return cwd() + } + }) + }) + }, Promise.resolve()) }).then((projectRoot) => { const fileName = 'state.json' diff --git a/packages/server/lib/socket-base.ts b/packages/server/lib/socket-base.ts index 87e0556e5566..44daf903c5e4 100644 --- a/packages/server/lib/socket-base.ts +++ b/packages/server/lib/socket-base.ts @@ -288,7 +288,7 @@ export class SocketBase { }) socket.on('app:connect', (socketId) => { - return options.onConnect(socketId, socket) + return options.onConnect(socketId, socket, this) }) socket.on('set:runnables:and:maybe:record:tests', async (runnables, cb) => { diff --git a/packages/server/lib/util/require_async.ts b/packages/server/lib/util/require_async.ts index 6f7d077cd192..427500f7effa 100644 --- a/packages/server/lib/util/require_async.ts +++ b/packages/server/lib/util/require_async.ts @@ -4,7 +4,6 @@ import * as cp from 'child_process' import * as inspector from 'inspector' import * as util from '../plugins/util' import * as errors from '../errors' -import { fs } from '../util/fs' import Debug from 'debug' const debug = Debug('cypress:server:require_async') @@ -33,10 +32,6 @@ export async function requireAsync (filePath: string, options: RequireAsyncOptio killChildProcess() } - if (/\.json$/.test(filePath)) { - fs.readJson(path.resolve(options.projectRoot, filePath)).then((result) => resolve(result)).catch(reject) - } - const childOptions: ChildOptions = { stdio: 'inherit', } @@ -48,7 +43,7 @@ export async function requireAsync (filePath: string, options: RequireAsyncOptio .value() } - const childArguments = ['--projectRoot', options.projectRoot, '--file', filePath] + const childArguments = ['--projectRoot', options.projectRoot, '--file', filePath, '--loadErrorCode', options.loadErrorCode] debug('fork child process', path.join(__dirname, 'require_async_child.js'), childArguments, childOptions) requireProcess = cp.fork(path.join(__dirname, 'require_async_child.js'), childArguments, childOptions) @@ -70,17 +65,7 @@ export async function requireAsync (filePath: string, options: RequireAsyncOptio debug('load:error %s, rejecting', type) killChildProcess() - const err = errors.get(type, ...args) - - // if it's a non-cypress error, restore the initial error - if (!(err.message?.length)) { - err.isCypressErr = false - err.message = args[1] - err.code = type - err.name = type - } - - reject(err) + reject(errors.get(type, ...args)) }) debug('trigger the load of the file') diff --git a/packages/server/lib/util/require_async_child.js b/packages/server/lib/util/require_async_child.js index 179fda770144..2a2a8203ec30 100644 --- a/packages/server/lib/util/require_async_child.js +++ b/packages/server/lib/util/require_async_child.js @@ -1,12 +1,15 @@ require('graceful-fs').gracefulify(require('fs')) const stripAnsi = require('strip-ansi') const debug = require('debug')('cypress:server:require_async:child') +const tsNodeUtil = require('./ts_node') const util = require('../plugins/util') const ipc = util.wrapIpc(process) require('./suppress_warnings').suppress() -const { file, projectRoot } = require('minimist')(process.argv.slice(2)) +const { file, projectRoot, loadErrorCode } = require('minimist')(process.argv.slice(2)) + +let tsRegistered = false run(ipc, file, projectRoot) @@ -24,6 +27,14 @@ function run (ipc, requiredFile, projectRoot) { throw new Error('Unexpected: projectRoot should be a string') } + if (!tsRegistered) { + debug('register typescript for required file') + tsNodeUtil.register(projectRoot, requiredFile) + + // ensure typescript is only registered once + tsRegistered = true + } + process.on('uncaughtException', (err) => { debug('uncaught exception:', util.serializeError(err)) ipc.send('error', util.serializeError(err)) @@ -58,13 +69,15 @@ function run (ipc, requiredFile, projectRoot) { // replace the first line with better text (remove potentially misleading word TypeScript for example) .replace(/^.*\n/g, 'Error compiling file\n') - ipc.send('load:error', err.name, requiredFile, cleanMessage) + ipc.send('load:error', loadErrorCode, requiredFile, cleanMessage) } else { - const realErrorCode = err.code || err.name + debug('failed to load file:%s\n%s: %s', requiredFile, err.code, err.message) - debug('failed to load file:%s\n%s: %s', requiredFile, realErrorCode, err.message) + if (err.code === 'ENOENT' || err.code === 'MODULE_NOT_FOUND') { + ipc.send('load:error', err.code, requiredFile, err) + } - ipc.send('load:error', realErrorCode, requiredFile, err.message) + ipc.send('load:error', loadErrorCode, requiredFile, err.message) } } }) diff --git a/packages/server/lib/util/resolve.js b/packages/server/lib/util/resolve.js index 1a0eaea16455..f54059d6088d 100644 --- a/packages/server/lib/util/resolve.js +++ b/packages/server/lib/util/resolve.js @@ -1,5 +1,5 @@ const env = require('./env') -const debug = require('debug')('cypress:server:plugins') +const debug = require('debug')('cypress:server:util:resolve') module.exports = { /** diff --git a/packages/server/lib/plugins/child/ts_node.js b/packages/server/lib/util/ts_node.js similarity index 71% rename from packages/server/lib/plugins/child/ts_node.js rename to packages/server/lib/util/ts_node.js index 49992c8a2856..cc9306dce336 100644 --- a/packages/server/lib/plugins/child/ts_node.js +++ b/packages/server/lib/util/ts_node.js @@ -1,9 +1,9 @@ const debug = require('debug')('cypress:server:ts-node') const path = require('path') const tsnode = require('ts-node') -const resolve = require('../../util/resolve') +const resolve = require('./resolve') -const getTsNodeOptions = (tsPath, pluginsFile) => { +const getTsNodeOptions = (tsPath, registeredFile) => { return { compiler: tsPath, // use the user's installed typescript compilerOptions: { @@ -11,20 +11,23 @@ const getTsNodeOptions = (tsPath, pluginsFile) => { }, // resolves tsconfig.json starting from the plugins directory // instead of the cwd (the project root) - dir: path.dirname(pluginsFile), + dir: path.dirname(registeredFile), transpileOnly: true, // transpile only (no type-check) for speed } } -const register = (projectRoot, pluginsFile) => { +const register = (projectRoot, registeredFile) => { try { + debug('projectRoot path: %s', projectRoot) + debug('registeredFile: %s', registeredFile) const tsPath = resolve.typescript(projectRoot) if (!tsPath) return - const tsOptions = getTsNodeOptions(tsPath, pluginsFile) - debug('typescript path: %s', tsPath) + + const tsOptions = getTsNodeOptions(tsPath, registeredFile) + debug('registering project TS with options %o', tsOptions) require('tsconfig-paths/register') diff --git a/packages/server/lib/util/validation.js b/packages/server/lib/util/validation.js index 14830fe80218..7743a65e85d3 100644 --- a/packages/server/lib/util/validation.js +++ b/packages/server/lib/util/validation.js @@ -129,7 +129,7 @@ const isPlainObject = (key, value) => { return errMsg(key, value, 'a plain object') } -const isValidConfig = (key, config) => { +const isValidTestingTypeConfig = (key, config) => { const status = isPlainObject(key, config) if (status !== true) { @@ -137,11 +137,17 @@ const isValidConfig = (key, config) => { } for (const rule of configOptions.options) { - if (rule.name in config && rule.validation) { - const status = rule.validation(`${key}.${rule.name}`, config[rule.name]) + if (rule.name in config) { + if (typeof rule.onlyInOverride === 'string' && rule.onlyInOverride !== key) { + return `key \`${rule.name}\` is only valid in the \`${rule.onlyInOverride}\` object, invalid use of this key in the \`${key}\` object` + } + + if (rule.validation) { + const status = rule.validation(`${key}.${rule.name}`, config[rule.name]) - if (status !== true) { - return status + if (status !== true) { + return status + } } } } @@ -266,7 +272,7 @@ module.exports = { isValidRetriesConfig, - isValidConfig, + isValidTestingTypeConfig, isPlainObject, diff --git a/packages/server/test/cache_helper.ts b/packages/server/test/cache_helper.ts new file mode 100644 index 000000000000..1a20675c2dbb --- /dev/null +++ b/packages/server/test/cache_helper.ts @@ -0,0 +1,14 @@ +import { CYPRESS_CONFIG_FILES } from '../lib/configFiles' + +/** + * Since we load the cypress.json via `require`, + * we need to clear the `require.cache` before/after some tests + * to ensure we are not using a cached configuration file. + */ +export function clearCypressJsonCache () { + Object.keys(require.cache).forEach((key) => { + if (CYPRESS_CONFIG_FILES.some((file) => key.includes(file))) { + delete require.cache[key] + } + }) +} diff --git a/packages/server/test/e2e/3_new_project_spec.js b/packages/server/test/e2e/3_new_project_spec.js index 4d577f7eab62..bd6be97f6c24 100644 --- a/packages/server/test/e2e/3_new_project_spec.js +++ b/packages/server/test/e2e/3_new_project_spec.js @@ -5,11 +5,12 @@ const { fs } = require('../../lib/util/fs') const noScaffoldingPath = Fixtures.projectPath('no-scaffolding') const supportPath = path.join(noScaffoldingPath, 'cypress', 'support') +const pluginsPath = path.join(noScaffoldingPath, 'cypress', 'plugins') describe('e2e new project', () => { e2e.setup() - it('passes', function () { + it('creates a sample supportFile & a sample pluginsFile', function () { return fs .statAsync(supportPath) .then(() => { @@ -21,7 +22,7 @@ describe('e2e new project', () => { snapshot: true, }) .then(() => { - return fs.statAsync(supportPath) + return Promise.all([fs.statAsync(supportPath), fs.statAsync(pluginsPath)]) }) }) }) diff --git a/packages/server/test/e2e/3_plugins_spec.js b/packages/server/test/e2e/3_plugins_spec.js index cd81cfa510f8..9e8cbab8e559 100644 --- a/packages/server/test/e2e/3_plugins_spec.js +++ b/packages/server/test/e2e/3_plugins_spec.js @@ -2,6 +2,7 @@ const path = require('path') const e2e = require('../support/helpers/e2e').default const Fixtures = require('../support/helpers/fixtures') +const { fs } = require(`${root}lib/util/fs`) const e2eProject = Fixtures.projectPath('e2e') @@ -49,6 +50,33 @@ describe('e2e plugins', function () { }) }) + it('can config plugins directly in the cypress.config.js', function () { + return e2e.exec(this, { + spec: 'app_spec.js', + env: 'foo=foo,bar=bar', + config: { pageLoadTimeout: 10000 }, + project: Fixtures.projectPath('plugin-config-js'), + sanitizeScreenshotDimensions: true, + snapshot: true, + }) + }) + + it('does not create a new pluginsFile if cypress.config.js is properly configured', function () { + const jsProjectPath = Fixtures.projectPath('plugin-config-js') + + return e2e.exec(this, { + spec: 'app_spec.js', + env: 'foo=foo,bar=bar', + config: { pageLoadTimeout: 10000 }, + project: jsProjectPath, + sanitizeScreenshotDimensions: true, + }).then(() => { + return fs.exists(path.join(jsProjectPath, 'cypress/plugins/index.js')) + }).then((newPluginsFileExists) => { + expect(newPluginsFileExists).to.be.false + }) + }) + it('passes version correctly', function () { return e2e.exec(this, { project: Fixtures.projectPath('plugin-config-version'), diff --git a/packages/server/test/e2e/7_record_spec.js b/packages/server/test/e2e/7_record_spec.js index c46b2b5586d4..baf870b4b203 100644 --- a/packages/server/test/e2e/7_record_spec.js +++ b/packages/server/test/e2e/7_record_spec.js @@ -16,6 +16,7 @@ const { postInstanceTestsResponse, } = require('../support/helpers/serverStub') const { expectRunsToHaveCorrectTimings } = require('../support/helpers/resultsUtils') +const { clearCypressJsonCache } = require('../cache_helper') const e2ePath = Fixtures.projectPath('e2e') const outputPath = path.join(e2ePath, 'output.json') @@ -30,6 +31,10 @@ describe('e2e record', () => { requests = getRequests() }) + afterEach(() => { + return clearCypressJsonCache() + }) + context('passing', () => { setupStubbedServer(createRoutes()) diff --git a/packages/server/test/integration/http_requests_spec.js b/packages/server/test/integration/http_requests_spec.js index ced0ba35c9d1..d1da95f0de0d 100644 --- a/packages/server/test/integration/http_requests_spec.js +++ b/packages/server/test/integration/http_requests_spec.js @@ -167,6 +167,7 @@ describe('Routes', () => { pluginsModule.init(cfg, { projectRoot: cfg.projectRoot, + configFile: 'cypress.json', }), ]) } diff --git a/packages/server/test/integration/plugins_spec.js b/packages/server/test/integration/plugins_spec.js index b32418a05823..cb7c1e5284ee 100644 --- a/packages/server/test/integration/plugins_spec.js +++ b/packages/server/test/integration/plugins_spec.js @@ -27,6 +27,7 @@ describe('lib/plugins', () => { const options = { onWarning, testingType: 'e2e', + configFile: 'cypress.json', } return plugins.init(projectConfig, options) diff --git a/packages/server/test/support/fixtures/projects/config-with-empty-cypress-config/cypress.config.js b/packages/server/test/support/fixtures/projects/config-with-empty-cypress-config/cypress.config.js new file mode 100644 index 000000000000..4ba52ba2c8df --- /dev/null +++ b/packages/server/test/support/fixtures/projects/config-with-empty-cypress-config/cypress.config.js @@ -0,0 +1 @@ +module.exports = {} diff --git a/packages/server/test/support/fixtures/projects/config-with-empty-cypress-config/cypress/integration/test.js b/packages/server/test/support/fixtures/projects/config-with-empty-cypress-config/cypress/integration/test.js new file mode 100644 index 000000000000..92e444116c98 --- /dev/null +++ b/packages/server/test/support/fixtures/projects/config-with-empty-cypress-config/cypress/integration/test.js @@ -0,0 +1,3 @@ +it('works', () => { + expect(true).to.be.true +}) diff --git a/packages/server/test/support/fixtures/projects/config-with-json-and-empty-cypress-config/cypress.config.js b/packages/server/test/support/fixtures/projects/config-with-json-and-empty-cypress-config/cypress.config.js new file mode 100644 index 000000000000..4ba52ba2c8df --- /dev/null +++ b/packages/server/test/support/fixtures/projects/config-with-json-and-empty-cypress-config/cypress.config.js @@ -0,0 +1 @@ +module.exports = {} diff --git a/packages/server/test/support/fixtures/projects/config-with-json-and-empty-cypress-config/cypress.json b/packages/server/test/support/fixtures/projects/config-with-json-and-empty-cypress-config/cypress.json new file mode 100644 index 000000000000..9dd38efd5217 --- /dev/null +++ b/packages/server/test/support/fixtures/projects/config-with-json-and-empty-cypress-config/cypress.json @@ -0,0 +1,5 @@ +{ + "defaultCommandTimeout": 1000, + "pluginsFile": false, + "supportFile": false +} diff --git a/packages/server/test/support/fixtures/projects/config-with-json-and-empty-cypress-config/cypress/integration/test.js b/packages/server/test/support/fixtures/projects/config-with-json-and-empty-cypress-config/cypress/integration/test.js new file mode 100644 index 000000000000..92e444116c98 --- /dev/null +++ b/packages/server/test/support/fixtures/projects/config-with-json-and-empty-cypress-config/cypress/integration/test.js @@ -0,0 +1,3 @@ +it('works', () => { + expect(true).to.be.true +}) diff --git a/packages/server/test/support/fixtures/projects/plugin-config-js/cypress.config.js b/packages/server/test/support/fixtures/projects/plugin-config-js/cypress.config.js new file mode 100644 index 000000000000..abed01437463 --- /dev/null +++ b/packages/server/test/support/fixtures/projects/plugin-config-js/cypress.config.js @@ -0,0 +1,17 @@ +module.exports = { + e2e: { + setupNodeEvents (on, config) { + return new Promise((resolve) => { + setTimeout(resolve, 100) + }) + .then(() => { + config.defaultCommandTimeout = 500 + config.videoCompression = 20 + config.env = config.env || {} + config.env.foo = 'bar' + + return config + }) + }, + }, +} diff --git a/packages/server/test/support/fixtures/projects/plugin-config-js/cypress/integration/app_spec.js b/packages/server/test/support/fixtures/projects/plugin-config-js/cypress/integration/app_spec.js new file mode 100644 index 000000000000..83c0712595fd --- /dev/null +++ b/packages/server/test/support/fixtures/projects/plugin-config-js/cypress/integration/app_spec.js @@ -0,0 +1,16 @@ +it('overrides config', () => { + // overrides come from plugins + expect(Cypress.config('defaultCommandTimeout')).to.eq(500) + expect(Cypress.config('videoCompression')).to.eq(20) + + // overrides come from CLI + expect(Cypress.config('pageLoadTimeout')).to.eq(10000) +}) + +it('overrides env', () => { + // overrides come from plugins + expect(Cypress.env('foo')).to.eq('bar') + + // overrides come from CLI + expect(Cypress.env('bar')).to.eq('bar') +}) diff --git a/packages/server/test/unit/config_spec.js b/packages/server/test/unit/config_spec.js index 0d35983df67d..94a853889406 100644 --- a/packages/server/test/unit/config_spec.js +++ b/packages/server/test/unit/config_spec.js @@ -31,7 +31,7 @@ describe('lib/config', () => { } const options = {} - config.mergeDefaults(cfg, options) + config.mergeAllConfigs(cfg, options) expect(errors.throw).have.been.calledOnce }) @@ -44,7 +44,7 @@ describe('lib/config', () => { } const options = {} - config.mergeDefaults(cfg, options) + config.mergeAllConfigs(cfg, options) expect(errors.throw).not.to.be.called }) @@ -54,7 +54,8 @@ describe('lib/config', () => { beforeEach(function () { this.projectRoot = '/_test-output/path/to/project' - this.setup = (cypressJson = {}, cypressEnvJson = {}) => { + this.setup = (cypressJson = {}, cypressEnvJson = {}, fileName = 'cypress.config.js') => { + sinon.stub(settings, 'configFile').withArgs(this.projectRoot).returns(fileName) sinon.stub(settings, 'read').withArgs(this.projectRoot).resolves(cypressJson) sinon.stub(settings, 'readEnv').withArgs(this.projectRoot).resolves(cypressEnvJson) } @@ -142,10 +143,10 @@ describe('lib/config', () => { return this.expectValidationPasses() }) - it('validates cypress.json', function () { + it('validates cypress.config.js', function () { this.setup({ reporter: 5 }) - return this.expectValidationFails('cypress.json') + return this.expectValidationFails('cypress.config.js') }) it('validates cypress.env.json', function () { @@ -243,7 +244,7 @@ describe('lib/config', () => { return this.expectValidationPasses() }) - it('fails if not a plain object', function () { + it('fails if not a plain object or a function', function () { this.setup({ component: false }) this.expectValidationFails('to be a plain object') @@ -256,6 +257,60 @@ describe('lib/config', () => { return this.expectValidationFails('the value was: `false`') }) + + it('merges component specific config in the root object', function () { + this.setup({ + viewportWidth: 1024, + component: { + viewportWidth: 300, + }, + }) + + return config.get(this.projectRoot, { testingType: 'component' }) + .then((obj) => { + expect(obj.viewportWidth).to.eq(300) + }) + }) + + describe('devServer', function () { + it('allows devServer in component', function () { + this.setup({ + viewportWidth: 1024, + component: { + devServer () { + // noop + }, + }, + }) + + return config.get(this.projectRoot, { testingType: 'component' }) + .then((obj) => { + expect(obj.component.devServer).to.be.a('function') + }) + }) + + it('disallows devServer in root', function () { + this.setup({ + devServer () { + // noop + }, + }) + + return this.expectValidationFails('\`component\`') + }) + + it('disallows devServer in e2e', function () { + this.setup({ + e2e: { + devServer () { + // noop + }, + }, + }) + + return this.expectValidationFails('\`component\`') + }) + }) }) context('e2e', () => { @@ -288,6 +343,34 @@ describe('lib/config', () => { return this.expectValidationFails('the value was: `false`') }) + + it('merges e2e specific config in the root object', function () { + this.setup({ + viewportWidth: 300, + e2e: { + viewportWidth: 1024, + }, + }) + + return config.get(this.projectRoot, { testingType: 'e2e' }) + .then((obj) => { + expect(obj.viewportWidth).to.eq(1024) + }) + }) + + it('default to e2e config if none is specified', function () { + this.setup({ + viewportWidth: 300, + e2e: { + viewportWidth: 1024, + }, + }) + + return config.get(this.projectRoot) + .then((obj) => { + expect(obj.viewportWidth).to.eq(1024) + }) + }) }) context('defaultCommandTimeout', () => { @@ -481,22 +564,28 @@ describe('lib/config', () => { context('pluginsFile', () => { it('passes if a string', function () { - this.setup({ pluginsFile: 'cypress/plugins' }) + this.setup({ pluginsFile: 'cypress/plugins' }, {}, 'cypress.json') return this.expectValidationPasses() }) it('passes if false', function () { - this.setup({ pluginsFile: false }) + this.setup({ pluginsFile: false }, {}, 'cypress.json') return this.expectValidationPasses() }) it('fails if not a string or false', function () { - this.setup({ pluginsFile: 42 }) + this.setup({ pluginsFile: 42 }, {}, 'cypress.json') return this.expectValidationFails('be a string') }) + + it('fails if used in a js file', function () { + this.setup({ pluginsFile: 'cypress/plugins' }, {}, 'cypress.config.js') + + return this.expectValidationFails('cannot be set in a `cypress.config.js`') + }) }) context('port', () => { @@ -1064,12 +1153,12 @@ describe('lib/config', () => { }) }) - context('.mergeDefaults', () => { + context('.mergeAllConfigs', () => { beforeEach(function () { this.defaults = (prop, value, cfg = {}, options = {}) => { cfg.projectRoot = '/foo/bar/' - return config.mergeDefaults(cfg, options) + return config.mergeAllConfigs(cfg, options) .then(R.prop(prop)) .then((result) => { expect(result).to.deep.eq(value) @@ -1266,35 +1355,35 @@ describe('lib/config', () => { }) it('resets numTestsKeptInMemory to 0 when runMode', () => { - return config.mergeDefaults({ projectRoot: '/foo/bar/' }, { isTextTerminal: true }) + return config.mergeAllConfigs({ projectRoot: '/foo/bar/' }, { isTextTerminal: true }) .then((cfg) => { expect(cfg.numTestsKeptInMemory).to.eq(0) }) }) it('resets watchForFileChanges to false when runMode', () => { - return config.mergeDefaults({ projectRoot: '/foo/bar/' }, { isTextTerminal: true }) + return config.mergeAllConfigs({ projectRoot: '/foo/bar/' }, { isTextTerminal: true }) .then((cfg) => { expect(cfg.watchForFileChanges).to.be.false }) }) it('can override morgan in options', () => { - return config.mergeDefaults({ projectRoot: '/foo/bar/' }, { morgan: false }) + return config.mergeAllConfigs({ projectRoot: '/foo/bar/' }, { morgan: false }) .then((cfg) => { expect(cfg.morgan).to.be.false }) }) it('can override isTextTerminal in options', () => { - return config.mergeDefaults({ projectRoot: '/foo/bar/' }, { isTextTerminal: true }) + return config.mergeAllConfigs({ projectRoot: '/foo/bar/' }, { isTextTerminal: true }) .then((cfg) => { expect(cfg.isTextTerminal).to.be.true }) }) it('can override socketId in options', () => { - return config.mergeDefaults({ projectRoot: '/foo/bar/' }, { socketId: 1234 }) + return config.mergeAllConfigs({ projectRoot: '/foo/bar/' }, { socketId: 1234 }) .then((cfg) => { expect(cfg.socketId).to.eq(1234) }) @@ -1313,7 +1402,7 @@ describe('lib/config', () => { }, } - return config.mergeDefaults(obj) + return config.mergeAllConfigs(obj) .then((cfg) => { expect(cfg.env).to.deep.eq({ foo: 'bar', @@ -1344,7 +1433,7 @@ describe('lib/config', () => { }, } - return config.mergeDefaults(obj, options) + return config.mergeAllConfigs(obj, options) .then((cfg) => { expect(cfg.env).to.deep.eq({ host: 'localhost', @@ -1418,7 +1507,7 @@ describe('lib/config', () => { port: 1234, } - return config.mergeDefaults(obj, options) + return config.mergeAllConfigs(obj, options) .then((cfg) => { expect(cfg.resolved).to.deep.eq({ animationDistanceThreshold: { value: 5, from: 'default' }, @@ -1427,11 +1516,11 @@ describe('lib/config', () => { browsers: { value: [], from: 'default' }, chromeWebSecurity: { value: true, from: 'default' }, clientCertificates: { value: [], from: 'default' }, - component: { from: 'default', value: {} }, + component: { from: 'default', value: null }, componentFolder: { value: 'cypress/component', from: 'default' }, defaultCommandTimeout: { value: 4000, from: 'default' }, downloadsFolder: { value: 'cypress/downloads', from: 'default' }, - e2e: { from: 'default', value: {} }, + e2e: { from: 'default', value: null }, env: {}, execTimeout: { value: 60000, from: 'default' }, experimentalFetchPolyfill: { value: false, from: 'default' }, @@ -1503,7 +1592,7 @@ describe('lib/config', () => { }, } - return config.mergeDefaults(obj, options) + return config.mergeAllConfigs(obj, options) .then((cfg) => { expect(cfg.resolved).to.deep.eq({ animationDistanceThreshold: { value: 5, from: 'default' }, @@ -1511,12 +1600,12 @@ describe('lib/config', () => { blockHosts: { value: null, from: 'default' }, browsers: { value: [], from: 'default' }, chromeWebSecurity: { value: true, from: 'default' }, - component: { from: 'default', value: {} }, + component: { from: 'default', value: null }, clientCertificates: { value: [], from: 'default' }, componentFolder: { value: 'cypress/component', from: 'default' }, defaultCommandTimeout: { value: 4000, from: 'default' }, downloadsFolder: { value: 'cypress/downloads', from: 'default' }, - e2e: { from: 'default', value: {} }, + e2e: { from: 'default', value: null }, execTimeout: { value: 60000, from: 'default' }, experimentalFetchPolyfill: { value: false, from: 'default' }, experimentalInteractiveRunEvents: { value: false, from: 'default' }, diff --git a/packages/server/test/unit/gui/files_spec.ts b/packages/server/test/unit/gui/files_spec.ts index 2f6d17f90c84..e8f6e67c8520 100644 --- a/packages/server/test/unit/gui/files_spec.ts +++ b/packages/server/test/unit/gui/files_spec.ts @@ -1,4 +1,4 @@ -import '../../spec_helper' +import { sinon } from '../../spec_helper' import { expect } from 'chai' import 'sinon-chai' @@ -7,6 +7,7 @@ import { showDialogAndCreateSpec } from '../../../lib/gui/files' import { openProject } from '../../../lib/open_project' import { ProjectBase } from '../../../lib/project-base' import * as dialog from '../../../lib/gui/dialog' +import { fs } from '../../../lib/util/fs' import * as specWriter from '../../../lib/util/spec_writer' describe('gui/files', () => { @@ -30,6 +31,8 @@ describe('gui/files', () => { ], } + sinon.stub(fs, 'readdirSync').returns(['cypress.json']) + this.err = new Error('foo') sinon.stub(ProjectBase.prototype, 'initializeConfig').resolves() diff --git a/packages/server/test/unit/modes/run_spec.js b/packages/server/test/unit/modes/run_spec.js index 1821baa7b33b..7cdacf4748ba 100644 --- a/packages/server/test/unit/modes/run_spec.js +++ b/packages/server/test/unit/modes/run_spec.js @@ -46,6 +46,7 @@ describe('lib/modes/run', () => { it('is null when no projectId', () => { const project = { getProjectId: sinon.stub().rejects(new Error), + getConfig: () => Promise.resolve({}), } return runMode.getProjectId(project) diff --git a/packages/server/test/unit/plugins/child/run_plugins_spec.js b/packages/server/test/unit/plugins/child/run_plugins_spec.js index f4b088cbc1b8..6323777f78ba 100644 --- a/packages/server/test/unit/plugins/child/run_plugins_spec.js +++ b/packages/server/test/unit/plugins/child/run_plugins_spec.js @@ -10,7 +10,7 @@ const util = require(`${root}../../lib/plugins/util`) const resolve = require(`${root}../../lib/util/resolve`) const browserUtils = require(`${root}../../lib/browsers/utils`) const Fixtures = require(`${root}../../test/support/helpers/fixtures`) -const tsNodeUtil = require(`${root}../../lib/plugins/child/ts_node`) +const tsNodeUtil = require(`${root}../../lib/util/ts_node`) const runPlugins = require(`${root}../../lib/plugins/child/run_plugins`) diff --git a/packages/server/test/unit/plugins/child/ts_node_spec.js b/packages/server/test/unit/plugins/child/ts_node_spec.js index e5a5ef7c6c12..84b261f1ceff 100644 --- a/packages/server/test/unit/plugins/child/ts_node_spec.js +++ b/packages/server/test/unit/plugins/child/ts_node_spec.js @@ -4,7 +4,7 @@ const tsnode = require('ts-node') const resolve = require(`${root}../../lib/util/resolve`) -const tsNodeUtil = require(`${root}../../lib/plugins/child/ts_node`) +const tsNodeUtil = require(`${root}../../lib/util/ts_node`) describe('lib/plugins/child/ts_node', () => { beforeEach(() => { diff --git a/packages/server/test/unit/plugins/index_spec.js b/packages/server/test/unit/plugins/index_spec.js index e76df148fb9f..f3a6bc1be15a 100644 --- a/packages/server/test/unit/plugins/index_spec.js +++ b/packages/server/test/unit/plugins/index_spec.js @@ -59,10 +59,10 @@ describe('lib/plugins/index', () => { const args = cp.fork.lastCall.args[1] - expect(args[0]).to.equal('--file') - expect(args[1]).to.include('plugins/child/default_plugins_file.js') - expect(args[2]).to.equal('--projectRoot') - expect(args[3]).to.equal('/path/to/project/root') + expect(args[0]).to.equal('--projectRoot') + expect(args[1]).to.equal('/path/to/project/root') + expect(args[2]).to.equal('--file') + expect(args[3]).to.include('plugins/child/default_plugins_file.js') }) }) @@ -75,7 +75,7 @@ describe('lib/plugins/index', () => { expect(cp.fork).to.be.called expect(cp.fork.lastCall.args[0]).to.contain('plugins/child/index.js') - expect(cp.fork.lastCall.args[1]).to.eql(['--file', 'cypress-plugin', '--projectRoot', '/path/to/project/root']) + expect(cp.fork.lastCall.args[1]).to.eql(['--projectRoot', '/path/to/project/root', '--file', 'cypress-plugin']) }) }) diff --git a/packages/server/test/unit/plugins/util_spec.js b/packages/server/test/unit/plugins/util_spec.js index af64da26c552..cb7b6f43a892 100644 --- a/packages/server/test/unit/plugins/util_spec.js +++ b/packages/server/test/unit/plugins/util_spec.js @@ -43,7 +43,7 @@ describe('lib/plugins/util', () => { expect(handler).to.be.calledWith('arg1', 'arg2') }) - it('#removeListener emoves handler', function () { + it('#removeListener removes handler', function () { const handler = sinon.spy() this.ipc.on('event-name', handler) @@ -55,6 +55,115 @@ describe('lib/plugins/util', () => { expect(handler).not.to.be.called }) + + context('#arguments-serialization', () => { + it('#send should send functions arguments as a serialized string', function () { + this.ipc.send('event-singe-arg', function () {}) + + expect(this.theProcess.send).to.be.calledWith({ + event: 'event-singe-arg', + args: ['__cypress_function__'], + }) + }) + + it('#send should send functions in object arguments as a serialized string', function () { + this.ipc.send('event-obj-arg', { e2e () {} }) + + expect(this.theProcess.send).to.be.calledWith({ + event: 'event-obj-arg', + args: [{ e2e: '__cypress_function__' }], + }) + }) + + it('#send should send functions in nested object arguments as a serialized string', function () { + this.ipc.send('event-obj-arg', { config: { e2e () {} } }) + + expect(this.theProcess.send).to.be.calledWith({ + event: 'event-obj-arg', + args: [{ config: { e2e: '__cypress_function__' } }], + }) + }) + + it('#send should not crash with null values', function () { + this.ipc.send('event-arg-null', { config: null }) + + expect(this.theProcess.send).to.be.calledWith({ + event: 'event-arg-null', + args: [{ config: null }], + }) + }) + + it('#send should work with arrays', function () { + this.ipc.send('event-arg-array', { browsers: ['chrome', 'firefox'], handlers: [function () {}, function () {}] }) + + expect(this.theProcess.send).to.be.calledWith({ + event: 'event-arg-array', + args: [{ browsers: ['chrome', 'firefox'], handlers: ['__cypress_function__', '__cypress_function__'] }], + }) + }) + }) + + context('#arguments-deserialization', () => { + it('#on should deserialise function strings into function', function () { + const handler = sinon.spy() + + this.ipc.on('event-obj-arg', handler) + this.theProcess.on.yield({ + event: 'event-obj-arg', + args: ['__cypress_function__'], + }) + + expect(handler).to.be.calledWith(sinon.match.func) + }) + + it('#on should deserialise function strings in object into function', function () { + const handler = sinon.spy() + + this.ipc.on('event-obj-arg', handler) + this.theProcess.on.yield({ + event: 'event-obj-arg', + args: [{ e2e: '__cypress_function__' }], + }) + + expect(handler).to.be.calledWith({ e2e: sinon.match.func }) + }) + + it('#on should deserialise function strings in nested object into function', function () { + const handler = sinon.spy() + + this.ipc.on('event-obj-arg', handler) + this.theProcess.on.yield({ + event: 'event-obj-arg', + args: [{ config: { e2e: '__cypress_function__' } }], + }) + + expect(handler).to.be.calledWith({ config: { e2e: sinon.match.func } }) + }) + + it('#on should not crash with null values', function () { + const handler = sinon.spy() + + this.ipc.on('event-arg-null', handler) + this.theProcess.on.yield({ + event: 'event-arg-null', + args: [{ config: null }], + }) + + expect(handler).to.be.calledWith({ config: null }) + }) + + it('#on should work with arrays', function () { + const handler = sinon.spy() + + this.ipc.on('event-arg-array', handler) + this.theProcess.on.yield({ + event: 'event-arg-array', + args: [{ browsers: ['chrome', 'firefox'], handlers: ['__cypress_function__', '__cypress_function__'] }], + }) + + expect(handler).to.be.calledWith({ browsers: ['chrome', 'firefox'], handlers: [sinon.match.func, sinon.match.func] }) + }) + }) }) context('#wrapChildPromise', () => { diff --git a/packages/server/test/unit/project_spec.js b/packages/server/test/unit/project_spec.js index d05e445fe43c..63da686ef87b 100644 --- a/packages/server/test/unit/project_spec.js +++ b/packages/server/test/unit/project_spec.js @@ -52,6 +52,7 @@ describe('lib/project-base', () => { }) sinon.stub(runEvents, 'execute').resolves() + sinon.stub(fs, 'readdirSync').returns(['cypress.json']) return settings.read(this.todosPath).then((obj = {}) => { ({ projectId: this.projectId } = obj) @@ -70,6 +71,7 @@ describe('lib/project-base', () => { Fixtures.remove() if (this.project) { + this.project.isOpen = true this.project.close() } }) @@ -100,6 +102,8 @@ describe('lib/project-base', () => { expect(projectCt._cfg.viewportWidth).to.eq(500) expect(projectCt._cfg.baseUrl).to.eq('http://localhost:9999') expect(projectCt.startCtDevServer).to.have.beenCalled + projectCt.isOpen = true + projectCt.close() }) }) @@ -324,6 +328,7 @@ This option will not have an effect in Some-other-name. Tests that rely on web s onReloadBrowser: undefined, onFocusTests, onSpecChanged: undefined, + onConnect: undefined, }, { socketIoCookie: '__socket.io', namespace: '__cypress', @@ -532,6 +537,8 @@ This option will not have an effect in Some-other-name. Tests that rely on web s beforeEach(function () { this.project = new ProjectBase({ projectRoot: '/_test-output/path/to/project-e2e', testingType: 'e2e' }) + this.project.isOpen = true + this.project._server = { close () {} } this.project._isServerOpen = true diff --git a/packages/server/test/unit/project_utils_spec.ts b/packages/server/test/unit/project_utils_spec.ts index dbf69ea14fd7..f4e2f7c4f766 100644 --- a/packages/server/test/unit/project_utils_spec.ts +++ b/packages/server/test/unit/project_utils_spec.ts @@ -99,6 +99,10 @@ describe('lib/project_utils', () => { }) describe('checkSupportFile', () => { + beforeEach(() => { + sinon.stub(settings, 'configFile').returns({}) + }) + it('does nothing when {supportFile: false}', async () => { const ret = await checkSupportFile({ configFile: 'cypress.json', diff --git a/packages/server/test/unit/util/settings_spec.js b/packages/server/test/unit/util/settings_spec.js index a7c4f2c2aaeb..706c12cf85df 100644 --- a/packages/server/test/unit/util/settings_spec.js +++ b/packages/server/test/unit/util/settings_spec.js @@ -1,251 +1,15 @@ -const path = require('path') - require('../../spec_helper') -const { fs } = require('../../../lib/util/fs') +const { fs } = require(`../../../lib/util/fs`) const settings = require(`../../../lib/util/settings`) -const projectRoot = process.cwd() -const defaultOptions = { - configFile: 'cypress.json', -} +let readStub describe('lib/util/settings', () => { - context('with default configFile option', () => { - beforeEach(function () { - this.setup = (obj = {}) => { - return fs.writeJsonAsync('cypress.json', obj) - } - }) - - afterEach(() => { - return fs.removeAsync('cypress.json') - }) - - context('nested cypress object', () => { - it('flattens object on read', function () { - return this.setup({ cypress: { foo: 'bar' } }) - .then(() => { - return settings.read(projectRoot, defaultOptions) - }).then((obj) => { - expect(obj).to.deep.eq({ foo: 'bar' }) - - return fs.readJsonAsync('cypress.json') - }).then((obj) => { - expect(obj).to.deep.eq({ foo: 'bar' }) - }) - }) + describe('pathToConfigFile', () => { + beforeEach(() => { + readStub = sinon.stub(fs, 'readdirSync').returns(['cypress.json']) }) - context('.readEnv', () => { - afterEach(() => { - return fs.removeAsync('cypress.env.json') - }) - - it('parses json', () => { - const json = { foo: 'bar', baz: 'quux' } - - fs.writeJsonSync('cypress.env.json', json) - - return settings.readEnv(projectRoot) - .then((obj) => { - expect(obj).to.deep.eq(json) - }) - }) - - it('throws when invalid json', () => { - fs.writeFileSync('cypress.env.json', '{\'foo;: \'bar}') - - return settings.readEnv(projectRoot) - .catch((err) => { - expect(err.type).to.eq('ERROR_READING_FILE') - expect(err.message).to.include('SyntaxError') - - expect(err.message).to.include(projectRoot) - }) - }) - - it('does not write initial file', () => { - return settings.readEnv(projectRoot) - .then((obj) => { - expect(obj).to.deep.eq({}) - }).then(() => { - return fs.pathExists('cypress.env.json') - }).then((found) => { - expect(found).to.be.false - }) - }) - }) - - context('.id', () => { - beforeEach(function () { - this.projectRoot = path.join(projectRoot, '_test-output/path/to/project/') - - return fs.ensureDirAsync(this.projectRoot) - }) - - afterEach(function () { - return fs.removeAsync(`${this.projectRoot}cypress.json`) - }) - - it('returns project id for project', function () { - return fs.writeJsonAsync(`${this.projectRoot}cypress.json`, { - projectId: 'id-123', - }) - .then(() => { - return settings.id(this.projectRoot, defaultOptions) - }).then((id) => { - expect(id).to.equal('id-123') - }) - }) - }) - - context('.read', () => { - it('promises cypress.json', function () { - return this.setup({ foo: 'bar' }) - .then(() => { - return settings.read(projectRoot, defaultOptions) - }).then((obj) => { - expect(obj).to.deep.eq({ foo: 'bar' }) - }) - }) - - it('promises cypress.json and merges CT specific properties for via testingType: component', function () { - return this.setup({ a: 'b', component: { a: 'c' } }) - .then(() => { - return settings.read(projectRoot, { ...defaultOptions, testingType: 'component' }) - }).then((obj) => { - expect(obj).to.deep.eq({ a: 'c', component: { a: 'c' } }) - }) - }) - - it('promises cypress.json and merges e2e specific properties', function () { - return this.setup({ a: 'b', e2e: { a: 'c' } }) - .then(() => { - return settings.read(projectRoot, defaultOptions) - }).then((obj) => { - expect(obj).to.deep.eq({ a: 'c', e2e: { a: 'c' } }) - }) - }) - - it('renames commandTimeout -> defaultCommandTimeout', function () { - return this.setup({ commandTimeout: 30000, foo: 'bar' }) - .then(() => { - return settings.read(projectRoot, defaultOptions) - }).then((obj) => { - expect(obj).to.deep.eq({ defaultCommandTimeout: 30000, foo: 'bar' }) - }) - }) - - it('renames supportFolder -> supportFile', function () { - return this.setup({ supportFolder: 'foo', foo: 'bar' }) - .then(() => { - return settings.read(projectRoot, defaultOptions) - }).then((obj) => { - expect(obj).to.deep.eq({ supportFile: 'foo', foo: 'bar' }) - }) - }) - - it('renames visitTimeout -> pageLoadTimeout', function () { - return this.setup({ visitTimeout: 30000, foo: 'bar' }) - .then(() => { - return settings.read(projectRoot, defaultOptions) - }).then((obj) => { - expect(obj).to.deep.eq({ pageLoadTimeout: 30000, foo: 'bar' }) - }) - }) - - it('renames visitTimeout -> pageLoadTimeout on nested cypress obj', function () { - return this.setup({ cypress: { visitTimeout: 30000, foo: 'bar' } }) - .then(() => { - return settings.read(projectRoot, defaultOptions) - }).then((obj) => { - expect(obj).to.deep.eq({ pageLoadTimeout: 30000, foo: 'bar' }) - }) - }) - - it('errors if in run mode and can\'t find file', function () { - return settings.read(projectRoot, { ...defaultOptions, args: { runProject: 'path' } }) - .then(() => { - throw Error('read should have failed with no config file in run mode') - }).catch((err) => { - expect(err.type).to.equal('CONFIG_FILE_NOT_FOUND') - - return fs.access(path.join(projectRoot, 'cypress.json')) - .then(() => { - throw Error('file should not have been created here') - }).catch((err) => { - expect(err.code).to.equal('ENOENT') - }) - }) - }) - }) - - context('.write', () => { - it('promises cypress.json updates', function () { - return this.setup().then(() => { - return settings.write(projectRoot, { foo: 'bar' }, defaultOptions) - }).then((obj) => { - expect(obj).to.deep.eq({ foo: 'bar' }) - }) - }) - - it('only writes over conflicting keys', function () { - return this.setup({ projectId: '12345', autoOpen: true }) - .then(() => { - return settings.write(projectRoot, { projectId: 'abc123' }, defaultOptions) - }).then((obj) => { - expect(obj).to.deep.eq({ projectId: 'abc123', autoOpen: true }) - }) - }) - }) - }) - - context('with configFile: false', () => { - beforeEach(function () { - this.projectRoot = path.join(projectRoot, '_test-output/path/to/project/') - - this.options = { - configFile: false, - } - }) - - it('.write does not create a file', function () { - return settings.write(this.projectRoot, {}, this.options) - .then(() => { - return fs.access(path.join(this.projectRoot, 'cypress.json')) - .then(() => { - throw Error('file shuold not have been created here') - }).catch((err) => { - expect(err.code).to.equal('ENOENT') - }) - }) - }) - - it('.read returns empty object', function () { - return settings.read(this.projectRoot, this.options) - .then((settings) => { - expect(settings).to.deep.equal({}) - }) - }) - }) - - context('with js files', () => { - it('.read returns from configFile when its a JavaScript file', function () { - this.projectRoot = path.join(projectRoot, '_test-output/path/to/project/') - - return fs.writeFile(path.join(this.projectRoot, 'cypress.custom.js'), `module.exports = { baz: 'lurman' }`) - .then(() => { - return settings.read(this.projectRoot, { configFile: 'cypress.custom.js' }) - .then((settings) => { - expect(settings).to.deep.equal({ baz: 'lurman' }) - }).then(() => { - return fs.remove(path.join(this.projectRoot, 'cypress.custom.js')) - }) - }) - }) - }) - - describe('.pathToConfigFile', () => { it('supports relative path', () => { const path = settings.pathToConfigFile('/users/tony/cypress', { configFile: 'e2e/config.json', @@ -261,5 +25,20 @@ describe('lib/util/settings', () => { expect(path).to.equal('/users/pepper/cypress/e2e/cypress.config.json') }) + + it('errors if there is json & js', () => { + readStub.returns(['cypress.json', 'cypress.config.js']) + expect(() => settings.pathToConfigFile('/cypress')).to.throw('`cypress.config.js` and a `cypress.json`') + }) + + it('errors if there is ts & js', () => { + readStub.returns(['cypress.config.ts', 'cypress.config.js']) + expect(() => settings.pathToConfigFile('/cypress')).to.throw('`cypress.config.js` and a `cypress.config.ts`') + }) + + it('errors if all three are there', () => { + readStub.returns(['cypress.config.ts', 'cypress.json', 'cypress.config.js']) + expect(() => settings.pathToConfigFile('/cypress')).to.throw('`cypress.config.js` and a `cypress.config.ts`') + }) }) }) diff --git a/packages/ui-components/package.json b/packages/ui-components/package.json index 56bb5a0f9149..37c4081c28d3 100644 --- a/packages/ui-components/package.json +++ b/packages/ui-components/package.json @@ -31,6 +31,7 @@ "cypress-multi-reporters": "1.4.0", "file-loader": "4.3.0", "lodash": "4.17.19", + "markdown-it": "11.0.0", "mobx": "5.15.4", "mobx-react": "6.1.7", "prop-types": "15.7.2", diff --git a/packages/ui-components/src/index.tsx b/packages/ui-components/src/index.tsx index 09dc96e528fc..2d70046a50c6 100644 --- a/packages/ui-components/src/index.tsx +++ b/packages/ui-components/src/index.tsx @@ -10,6 +10,8 @@ export { default as EditorPickerModal } from './file-opener/editor-picker-modal' export { default as FileOpener } from './file-opener/file-opener' +export { default as MarkdownRenderer } from './markdown-renderer' + export * from './file-opener/file-model' export * from './select' diff --git a/packages/ui-components/src/markdown-renderer.jsx b/packages/ui-components/src/markdown-renderer.jsx new file mode 100644 index 000000000000..2740fe0c3069 --- /dev/null +++ b/packages/ui-components/src/markdown-renderer.jsx @@ -0,0 +1,34 @@ +import React from 'react' +import Markdown from 'markdown-it' + +const md = new Markdown({ + html: true, + linkify: true, +}) + +export default class MarkdownRenderer extends React.PureComponent { + componentDidMount () { + this.node.addEventListener('click', this.props.clickHandler) + } + + componentWillUnmount () { + this.node.removeEventListener('click', this.props.clickHandler) + } + + render () { + let renderFn = md.render + + if (this.props.noParagraphWrapper) { + // prevent markdown-it from wrapping the output in a

tag + renderFn = md.renderInline + } + + return ( +

this.node = node} + dangerouslySetInnerHTML={{ + __html: renderFn.call(md, this.props.markdown), + }}> +
+ ) + } +} diff --git a/yarn.lock b/yarn.lock index 9cb3aee5bc87..34b31425debc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -31549,7 +31549,7 @@ pretty-error@^2.0.2, pretty-error@^2.1.1: pretty-format@26.4.0, pretty-format@^24.9.0, pretty-format@^26.6.2: version "26.4.0" - resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-26.4.0.tgz#c08073f531429e9e5024049446f42ecc9f933a3b" + resolved "https://registry.npmjs.org/pretty-format/-/pretty-format-26.4.0.tgz#c08073f531429e9e5024049446f42ecc9f933a3b" integrity sha512-mEEwwpCseqrUtuMbrJG4b824877pM5xald3AkilJ47Po2YLr97/siejYQHqj2oDQBeJNbu+Q0qUuekJ8F0NAPg== dependencies: "@jest/types" "^26.3.0" @@ -38338,7 +38338,7 @@ typescript@4.1.5: resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.1.5.tgz#123a3b214aaff3be32926f0d8f1f6e704eb89a72" integrity sha512-6OSu9PTIzmn9TCDiovULTnET6BgXtDYL4Gg4szY+cGsc3JP1dQL8qvE8kShTRx1NIw4Q9IBHlwODjkjWEtMUyA== -typescript@4.2.4, typescript@^4.2.3, typescript@~4.2.4: +typescript@4.2.4, typescript@~4.2.4: version "4.2.4" resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.2.4.tgz#8610b59747de028fda898a8aef0e103f156d0961" integrity sha512-V+evlYHZnQkaz8TRBuxTA92yZBPotr5H+WhQ7bD3hZUndx5tGOa1fuCgeSjxAzM1RiN5IzvadIXTVefuuwZCRg== @@ -38348,6 +38348,11 @@ typescript@^3.9.7: resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.9.9.tgz#e69905c54bc0681d0518bd4d587cc6f2d0b1a674" integrity sha512-kdMjTiekY+z/ubJCATUPlRDl39vXYiMV9iyeMuEuXZh2we6zz80uovNN2WlAxmmdE/Z/YQe+EbOEXB5RHEED3w== +typescript@^4.2.3: + version "4.3.4" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.3.4.tgz#3f85b986945bcf31071decdd96cf8bfa65f9dcbc" + integrity sha512-uauPG7XZn9F/mo+7MrsRjyvbxFpzemRjKEZXS4AK83oP2KKOJPvb+9cO/gmnv8arWZvhnjVOXz7B49m1l0e9Ew== + ua-parser-js@^0.7.18: version "0.7.24" resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.24.tgz#8d3ecea46ed4f1f1d63ec25f17d8568105dc027c"