Merge remote-tracking branch 'origin/10.0-release' into UNIFY-1302-slowTestThreshold-by-testing-type

This commit is contained in:
BlueWinds
2022-03-15 15:42:50 -07:00
85 changed files with 1265 additions and 776 deletions
+3
View File
@@ -9,6 +9,9 @@ _test-output
cypress.zip
.babel-cache
# from config, compiled .js files
packages/config/src/*.js
# from extension
Cached Theme.pak
Cached Theme Material Design.pak
+1 -1
View File
@@ -1,4 +1,4 @@
{
"chrome:beta": "100.0.4896.20",
"chrome:beta": "100.0.4896.30",
"chrome:stable": "99.0.4844.51"
}
-13
View File
@@ -116,14 +116,6 @@
"default": false,
"description": "Path to folder containing component test files (Pass false to disable)"
},
"pluginsFile": {
"type": [
"string",
"boolean"
],
"default": "cypress/plugins/index.js",
"description": "Path to plugins file. (Pass false to disable)"
},
"screenshotOnRunFailure": {
"type": "boolean",
"default": true,
@@ -260,11 +252,6 @@
"default": false,
"description": "Polyfills `window.fetch` to enable Network spying and stubbing"
},
"experimentalStudio": {
"type": "boolean",
"default": false,
"description": "Generate and save commands directly to your test suite by interacting with your app as an end user would."
},
"retries": {
"type": [
"object",
-10
View File
@@ -2713,11 +2713,6 @@ declare namespace Cypress {
* @default "bundled"
*/
nodeVersion: 'system' | 'bundled'
/**
* Path to plugins file. (Pass false to disable)
* @default "cypress/plugins/index.js"
*/
pluginsFile: string | false
/**
* The application under test cannot redirect more than this limit.
* @default 20
@@ -2818,11 +2813,6 @@ declare namespace Cypress {
* @default false
*/
experimentalSourceRewriting: boolean
/**
* Generate and save commands directly to your test suite by interacting with your app as an end user would.
* @default false
*/
experimentalStudio: boolean
/**
* Number of times to retry a failed test.
* If a number is set, tests will retry in both runMode and openMode.
-1
View File
@@ -9,7 +9,6 @@ const pluginConfig2: Cypress.PluginConfig = (on, config) => {
config.baseUrl // $ExpectType string | null
config.configFile // $ExpectType string | false
config.fixturesFolder // $ExpectType string | false
config.pluginsFile // $ExpectType string | false
config.screenshotsFolder // $ExpectType string | false
config.videoCompression // $ExpectType number | false
config.projectRoot // $ExpectType string
+12 -6
View File
@@ -156,15 +156,21 @@ module.exports = {
`npm install -D @cypress/code-coverage`
- Then add the code below to your supportFile and pluginsFile
- Then add the code below to your component support file
```javascript
// cypress/support/component.js
import '@cypress/code-coverage/support';
// cypress/plugins/index.js
module.exports = (on, config) => {
require('@cypress/code-coverage/task')(on, config);
return config;
```
- Then add the code below to your cypress configuration
```js
{
...
component: {
setupNodeEvents(on, config) {
require('@cypress/code-coverage/task')(on, config);
return config;
}
}
};
```
+7
View File
@@ -1,3 +1,10 @@
# [@cypress/react-v5.12.4](https://github.com/cypress-io/cypress/compare/@cypress/react-v5.12.3...@cypress/react-v5.12.4) (2022-03-03)
### Bug Fixes
* avoid nextjs unsafeCache and watchOptions ([#20440](https://github.com/cypress-io/cypress/issues/20440)) ([9f60901](https://github.com/cypress-io/cypress/commit/9f6090170b0675d25b26b98cd0f987a5e395ab78))
# [@cypress/react-v5.12.3](https://github.com/cypress-io/cypress/compare/@cypress/react-v5.12.2...@cypress/react-v5.12.3) (2022-02-10)
@@ -41,6 +41,15 @@ async function getNextWebpackConfig (config) {
checkSWC(nextWebpackConfig, config)
if (nextWebpackConfig.watchOptions && Array.isArray(nextWebpackConfig.watchOptions.ignored)) {
nextWebpackConfig.watchOptions = {
...nextWebpackConfig.watchOptions,
ignored: [...nextWebpackConfig.watchOptions.ignored.filter((pattern) => !/node_modules/.test(pattern)), '**/node_modules/!(@cypress/webpack-dev-server/dist/browser.js)**'],
}
debug('found options next.js watchOptions.ignored %O', nextWebpackConfig.watchOptions.ignored)
}
return nextWebpackConfig
}
@@ -0,0 +1,17 @@
import { defineConfig } from 'cypress'
import { startDevServer } from './dist'
export default defineConfig({
'video': false,
'fixturesFolder': false,
'component': {
'supportFile': './cypress/support.js',
specPattern: '**/smoke.cy.ts',
// startDevServer is the legacy distribution that was renamed
// to devServer to align with Cypress 10.0 configuration pitons.
// This configuration verifying backwards compatibility.
devServer (options) {
return startDevServer({ options })
},
},
})
+2 -9
View File
@@ -2,17 +2,10 @@ import { defineConfig } from 'cypress'
import { devServer } from './dist'
export default defineConfig({
'pluginsFile': 'cypress/plugins.js',
'video': false,
'fixturesFolder': false,
'component': {
'supportFile': 'cypress/support.js',
devServer (cypressDevServerConfig) {
const path = require('path')
return devServer(cypressDevServerConfig, {
configFile: path.resolve(__dirname, 'vite.config.ts'),
})
},
'supportFile': './cypress/support.js',
devServer,
},
})
@@ -1,23 +0,0 @@
/**
* This file is intended to test the new normalized signature
* of devServers. To make the test shorter we only test
* the smkoke test here
*/
const path = require('path')
const { devServer } = require('../../dist')
module.exports = (on, config) => {
on('dev-server:start', async (options) => {
return devServer(
options,
{
configFile: path.resolve(__dirname, '..', '..', 'vite.config.ts'),
},
)
})
config.component.specPattern = '**/smoke.spec.ts'
return config
}
+2 -2
View File
@@ -9,8 +9,8 @@
"check-ts": "tsc --noEmit",
"cy:open": "node ../../scripts/cypress.js open --component --project ${PWD}",
"cy:run": "node ../../scripts/cypress.js run --component --project ${PWD}",
"cy:run-signature": "yarn cy:run --config=\"{\\\"pluginsFile\\\":\\\"cypress/new-signature/plugins.js\\\"}\"",
"test": "yarn cy:run && yarn cy:run-signature",
"cy:run-legacy": "yarn cy:run --config-file ./cypress.config.legacy.ts",
"test": "yarn cy:run && yarn cy:run-legacy",
"watch": "tsc -w"
},
"dependencies": {
+8
View File
@@ -1,3 +1,11 @@
# [@cypress/webpack-dev-server-v1.8.2](https://github.com/cypress-io/cypress/compare/@cypress/webpack-dev-server-v1.8.1...@cypress/webpack-dev-server-v1.8.2) (2022-03-03)
### Bug Fixes
* avoid nextjs unsafeCache and watchOptions ([#20440](https://github.com/cypress-io/cypress/issues/20440)) ([9f60901](https://github.com/cypress-io/cypress/commit/9f6090170b0675d25b26b98cd0f987a5e395ab78))
* error regression - strip ansi colors out of cy.fixture() error message ([#20335](https://github.com/cypress-io/cypress/issues/20335)) ([e0bd6ac](https://github.com/cypress-io/cypress/commit/e0bd6ac2aaf8d00b9233fffefed8f6ed2484bf45))
# [@cypress/webpack-dev-server-v1.8.1](https://github.com/cypress-io/cypress/compare/@cypress/webpack-dev-server-v1.8.0...@cypress/webpack-dev-server-v1.8.1) (2022-02-08)
@@ -96,6 +96,14 @@ export async function makeWebpackConfig (userWebpackConfig: webpack.Configuratio
})
}
if (typeof userWebpackConfig?.module?.unsafeCache === 'function') {
const originalCachePredicate = userWebpackConfig.module.unsafeCache
userWebpackConfig.module.unsafeCache = (module: any) => {
return originalCachePredicate(module) && !/[\\/]webpack-dev-server[\\/]dist[\\/]browser\.js/.test(module.resource)
}
}
const mergedConfig = merge<webpack.Configuration>(
userWebpackConfig,
makeDefaultWebpackConfig(indexHtmlFile),
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "cypress",
"version": "9.5.1",
"version": "9.5.2",
"description": "Cypress.io end to end testing tool",
"private": true,
"scripts": {
+71 -9
View File
@@ -10,15 +10,80 @@ This is the front-end for the Cypress App.
3. Open chrome (or another browser)
4. It will show the new Vite powered app
## How it works
## How the App works
Cypress has two modes: `run` and `open`. We want run mode to be as light and fast as possible, since this is the mode used to run on CI machines, etc. Open mode is the interactive experience, with the command log, snapshots, selector playground, etc.
Cypress has two modes: `run` and `open`. We want run mode to be as light and fast as possible, since this is the mode used to run on CI machines, etc. Run mode has minimal UI showing only what is necessary. Open mode is the interactive experience.
- **`open`** mode is driven using GraphQL and urql. It shows the full Cypress app, include the top nav, side nav, spec list, etc. You can change between testing types, check your latest runs on the Cypress dashboard, updating settings, etc.
- **`run`** mode is does not rely on GraphQL. This is so we can be as performant as possible. It only renders the "runner" part of the UI, which is comprised of the command log and AUT iframe.
- **`open`** mode is driven using GraphQL and urql. It shows the full Cypress app, include the top nav, side nav, spec list, etc. You can change between testing types, check your latest runs on the Cypress dashboard, update settings, etc.
- **`run`** mode is does not rely on GraphQL. This is so we can be as performant as possible. It only renders the "runner" part of the UI, which is comprised of the command log, Spec Runner header, and AUT iframe.
The two modes are composed using the same logic, but have slightly different components. You can see where the differences are in `Runner.vue`(src/pages/Specs/Runner.vue). Notice that `<SpecRunnerOpenMode>` receives a `gql` prop, since it uses GraphQL, and `<SpecRunnerRunMode>` does not.
## Router
The App's routing doesn't need to be touched often, because it is almost all auto-generated based on the file structure. There's so little code that it helps to describe the approach here so that when we do want to modify a route or do some other route-specific behavior, we can get our bearings.
[`vite-plugin-pages`](https://github.com/hannoeru/vite-plugin-pages) is used to generate routes based on the page-level Vue components contained in `src/pages`. These generated routes are pulled into a standard [Vue Router](https://router.vuejs.org/) setup using `createRouter()` in [`router.ts`](src/router/router.ts).
Route configuration that might typically appear in `router.ts` can be set in a `<route>` block in these page components, for example `name` and `meta` properties (documented in Vue Router docs):
```ts
<route>
{
name: 'SpecsRunner',
meta: {
header: false,
navBarExpandedAllowed: false
}
}
</route>
```
The advantage here is that the route definition is co-located with the component. The route block will be parsed as JSON5 by default, which means that certain Vue Router properties aren't valid in the `<route>` block, such as if a function is needed to be the value.
So far, we haven't needed to use any such properties, but if we do, [`extendRoute`](https://github.com/hannoeru/vite-plugin-pages#extendroute) can be used where the `Pages` plugin is added in `vite.config.ts` to extend the generated route objects, using regular TypeScript.
### Some gotchas if you are new to Vue Router
#### `name` vs `path`
With the implicit setup of the router, routes are named based on the file name. This is not always ideal as the route's name is used in the UI as the title. The `name` property can be overridden in the `<route>` block of a single file component, so that we can have a more appropriate user-facing name, or something that's more explicit and stable when we intend to refer to it in code.
In addition to sometimes showing a route's name in the UI, we also use the `name` value to push a new route object to the router (either in code directly, or using `RouterLink`s), if that object includes `params`. Otherwise we usually use `path` when pushing to the router.
Example of `name` + `params` in a route object:
```ts
router.push({
name: 'Specs',
params: {
unrunnable: router.currentRoute.value.query.file,
}
})
```
Example of pushing with `path` + `query`:
```ts
router.push({
path: '/specs/runner',
query: { file: row.data.relative }
})
```
These objects in this format can be used either with `push` to the router directly or in a `router-link`'s `to` prop.
#### `params` vs `query`
More details on all of this are at https://router.vuejs.org/ - these are just some examples of things we are using here:
We use `params` when there is temporary state that should be present when a user gets to a route from somewhere else in the App, but shouldn't be present after page refresh or when using the `back` button. An example of this in use is the notice that appears over the AUT if you visit a spec from the "View this Spec" link right after creating it. This banner is useful only in that narrow situation, so a route param is a nice way to have it open when needed, and then ignored at other times, without needing to put it in a store and clean it up.
For values that should survive a page refresh, we use `query` in the route object to put the values into the URL's query parameters. This is the most common way to attach state to a particular route, we usually want that state to be present if we refresh the page or copy and paste a link.
The terminology can get a bit confusing as Vue Router's `params` are not the query parameters in the url. Vue Router's `params` can be accessed and used to form dynamic parts of a URL's path, but we are not currently using that feature, so for us `params` are used as described above. The option to interpolate values from `params` into a `path` is why the `to` property needs to be the route's `name` in order for `params` to be passed. Vue will warn, not error, if we try to do specify both `path` and `params` in a `to` or a router push, and our `params` will be ignored.
## Using existing, Vite-incompatible modules
Some of our modules, like `@packages/reporter`, `@packages/driver` and `@packages/runner-shared` cannot be easily
@@ -59,10 +124,10 @@ Cy has a very custom icon library.
*/
<i-cy-book_x16 class="
hover:icon-dark-pink-500
hover:icon-light-purple-300
icon-dark-pink-300
icon-dark-purple-50
hover:icon-dark-pink-500
hover:icon-light-purple-300
" />
```
@@ -79,7 +144,4 @@ If an icon path doesn't define a class, nothing bad will happen, it just won't g
2. Finally, you don't need to expose anything. `./src/assets/icons` is automatically watched and loaded 😮
## Diagram
![](./unified-runner-diagram.png)]
-1
View File
@@ -38,7 +38,6 @@ export default defineConfig({
},
'e2e': {
baseUrl: 'http://localhost:5555',
pluginsFile: 'cypress/e2e/plugins/index.ts',
supportFile: 'cypress/e2e/support/e2eSupport.ts',
async setupNodeEvents (on, config) {
if (!process.env.HTTP_PROXY_TARGET_FOR_ORIGIN_REQUESTS) {
+105 -1
View File
@@ -16,7 +16,7 @@ function getRunnerHref (specPath: string) {
describe('App: Index', () => {
describe('Testing Type: E2E', () => {
context('project with default spec pattern', () => {
context('js project with default spec pattern', () => {
beforeEach(() => {
cy.scaffoldProject('no-specs-no-storybook')
cy.openProject('no-specs-no-storybook')
@@ -219,6 +219,110 @@ describe('App: Index', () => {
})
})
context('ts project with default spec pattern', () => {
beforeEach(() => {
cy.scaffoldProject('no-specs-no-storybook')
cy.openProject('no-specs-no-storybook')
cy.withCtx(async (ctx) => {
await ctx.actions.file.writeFileInProject('tsconfig.json', '{}')
})
cy.openProject('no-specs-no-storybook')
cy.startAppServer('e2e')
cy.__incorrectlyVisitAppWithIntercept()
// With no specs present, the page renders two cards, one for scaffolding example specs,
// another for creating a new blank spec.
cy.findAllByTestId('card').eq(0).as('ScaffoldCard')
.within(() => {
cy.findByRole('button', {
name: defaultMessages.createSpec.e2e.importFromScaffold.header,
}).should('be.visible')
.and('not.be.disabled')
cy.contains(defaultMessages.createSpec.e2e.importFromScaffold.description)
.should('be.visible')
})
cy.findAllByTestId('card').eq(1).as('EmptySpecCard')
.within(() => {
cy.findByRole('button', {
name: defaultMessages.createSpec.e2e.importEmptySpec.header,
}).should('be.visible')
.and('not.be.disabled')
cy.contains(defaultMessages.createSpec.e2e.importEmptySpec.description)
.should('be.visible')
})
})
context('scaffold empty spec', () => {
it('should generate empty spec for a TS project', () => {
// Verify the modal can be closed
cy.get('@EmptySpecCard').click()
cy.get('body').click(0, 0)
cy.get('[data-cy="create-spec-modal"]').should('not.exist')
cy.get('@EmptySpecCard').click()
cy.get('[aria-label="Close"]').click()
cy.get('[data-cy="create-spec-modal"]').should('not.exist')
cy.get('@EmptySpecCard').click()
cy.contains('button', defaultMessages.components.button.back).click()
cy.get('[data-cy="create-spec-modal"]').within(() => {
cy.get('[data-cy="card"]').contains(defaultMessages.createSpec.e2e.importEmptySpec.header).click()
})
cy.percySnapshot('Default')
cy.findAllByLabelText(defaultMessages.createSpec.e2e.importEmptySpec.inputPlaceholder)
.as('enterSpecInput')
cy.get('@enterSpecInput').invoke('val').should('eq', getPathForPlatform('cypress/e2e/filename.cy.ts'))
cy.contains(defaultMessages.createSpec.e2e.importEmptySpec.invalidSpecWarning).should('not.exist')
cy.get('@enterSpecInput').clear()
cy.contains(defaultMessages.createSpec.e2e.importEmptySpec.invalidSpecWarning).should('not.exist')
// Shows entered file does not match spec pattern
cy.get('@enterSpecInput').type(getPathForPlatform('cypress/e2e/no-match'))
cy.contains(defaultMessages.createSpec.e2e.importEmptySpec.invalidSpecWarning)
cy.contains('button', defaultMessages.createSpec.createSpec).should('be.disabled')
cy.percySnapshot('Invalid spec error')
//Shows extension warning
cy.get('@enterSpecInput').clear().type(getPathForPlatform('cypress/e2e/MyTest.spec.t'))
cy.intercept('mutation-EmptyGenerator_MatchSpecFile', (req) => {
if (req.body.variables.specFile === getPathForPlatform('cypress/e2e/MyTest.spec.tx')) {
req.on('before:response', (res) => {
res.body.data.matchesSpecPattern = true
})
}
})
cy.get('@enterSpecInput').type('x')
cy.contains(defaultMessages.createSpec.e2e.importEmptySpec.specExtensionWarning)
cy.percySnapshot('Non-recommended spec pattern warning')
cy.contains('span', '{filename}.cy.tx')
// Create spec
cy.get('@enterSpecInput').clear().type(getPathForPlatform('cypress/e2e/MyTest.cy.ts'))
cy.contains('button', defaultMessages.createSpec.createSpec).should('not.be.disabled').click()
cy.contains('h2', defaultMessages.createSpec.successPage.header)
cy.get('[data-cy="file-row"]').contains(getPathForPlatform('cypress/e2e/MyTest.cy.ts')).click()
cy.percySnapshot('Generator success')
cy.get('pre').should('contain', 'describe(\'MyTest.cy.ts\'')
cy.get('[aria-label="Close"]').click()
cy.visitApp().get('[data-cy="specs-list-row"]').contains('MyTest.cy.ts')
})
})
})
context('project with custom spec pattern', () => {
beforeEach(() => {
cy.scaffoldProject('no-specs-custom-pattern')
+96 -54
View File
@@ -1,5 +1,5 @@
import type { SinonStub } from 'sinon'
import defaultMessages from '@packages/frontend-shared/src/locales/en-US.json'
import type { AuthStateShape } from '@packages/data-context/src/data'
const pkg = require('@packages/root')
@@ -366,12 +366,14 @@ describe('App Top Nav Workflows', () => {
const mockLogInActionsForUser = (user) => {
cy.withCtx((ctx, options) => {
options.sinon.stub(ctx._apis.authApi, 'logIn').callsFake(async (onMessage) => {
onMessage({ browserOpened: true } as AuthStateShape)
setTimeout(() => {
onMessage({ browserOpened: true })
}, 500)
return new Promise((resolve) => {
setTimeout(() => {
resolve(options.user)
}, 2000) // timeout ensures full auth browser lifecycle is testable
}, 1000)
})
})
}, { user })
@@ -445,12 +447,16 @@ describe('App Top Nav Workflows', () => {
})
it('shows correct error when browser cannot launch', () => {
cy.withCtx((ctx) => {
ctx.coreData.authState = {
name: 'AUTH_COULD_NOT_LAUNCH_BROWSER',
message: 'http://127.0.0.1:0000/redirect-to-auth',
browserOpened: false,
}
cy.withCtx((ctx, o) => {
o.sinon.stub(ctx._apis.authApi, 'logIn').callsFake(async (onMessage) => {
onMessage({
name: 'AUTH_COULD_NOT_LAUNCH_BROWSER',
message: 'http://127.0.0.1:0000/redirect-to-auth',
browserOpened: false,
})
throw new Error()
})
})
cy.findByTestId('app-header-bar').within(() => {
@@ -458,23 +464,31 @@ describe('App Top Nav Workflows', () => {
cy.findByRole('button', { name: 'Log In' }).click()
})
cy.contains('http://127.0.0.1:0000/redirect-to-auth').should('be.visible')
cy.contains(loginText.titleBrowserError).should('be.visible')
cy.contains(loginText.bodyBrowserError).should('be.visible')
cy.contains(loginText.bodyBrowserErrorDetails).should('be.visible')
cy.findByRole('dialog', { name: 'Log in to Cypress' }).within(() => {
cy.findByRole('button', { name: 'Log In' }).click()
// in this state, there is no retry UI, we ask the user to visit the auth url on their own
cy.contains('button', loginText.actionTryAgain).should('not.exist')
cy.contains('button', loginText.actionCancel).should('not.exist')
cy.contains('http://127.0.0.1:0000/redirect-to-auth').should('be.visible')
cy.contains(loginText.titleBrowserError).should('be.visible')
cy.contains(loginText.bodyBrowserError).should('be.visible')
cy.contains(loginText.bodyBrowserErrorDetails).should('be.visible')
// in this state, there is no retry UI, we ask the user to visit the auth url on their own
cy.contains('button', loginText.actionTryAgain).should('not.be.visible')
cy.contains('button', loginText.actionCancel).should('not.be.visible')
})
})
it('shows correct error when error other than browser-launch happens', () => {
cy.withCtx((ctx) => {
ctx.coreData.authState = {
name: 'AUTH_ERROR_DURING_LOGIN',
message: 'An unexpected error occurred',
browserOpened: false,
}
cy.withCtx((ctx, o) => {
o.sinon.stub(ctx._apis.authApi, 'logIn').callsFake(async (onMessage) => {
onMessage({
name: 'AUTH_ERROR_DURING_LOGIN',
message: 'An unexpected error occurred',
browserOpened: false,
})
throw new Error()
})
})
cy.findByTestId('app-header-bar').within(() => {
@@ -482,34 +496,49 @@ describe('App Top Nav Workflows', () => {
cy.findByRole('button', { name: 'Log In' }).click()
})
cy.contains(loginText.titleFailed).should('be.visible')
cy.contains(loginText.bodyError).should('be.visible')
cy.contains('An unexpected error occurred').should('be.visible')
cy.findByRole('dialog', { name: 'Log in to Cypress' }).within(() => {
cy.findByRole('button', { name: 'Log In' }).click()
cy.contains('button', loginText.actionTryAgain).should('be.visible').as('tryAgain')
cy.contains('button', loginText.actionCancel).should('be.visible')
cy.contains(loginText.titleFailed).should('be.visible')
cy.contains(loginText.bodyError).should('be.visible')
cy.contains('An unexpected error occurred').should('be.visible')
cy.contains('button', loginText.actionTryAgain).should('be.visible').as('tryAgain')
cy.contains('button', loginText.actionCancel).should('be.visible')
})
cy.percySnapshot()
cy.withCtx((ctx) => {
ctx.coreData.authState = {
name: 'AUTH_BROWSER_LAUNCHED',
message: '',
browserOpened: true,
}
(ctx._apis.authApi.logIn as SinonStub).callsFake(async (onMessage) => {
onMessage({
name: 'AUTH_BROWSER_LAUNCHED',
message: '',
browserOpened: true,
})
return Promise.resolve()
})
})
cy.get('@tryAgain').click()
cy.contains(loginText.titleInitial).should('be.visible')
cy.findByRole('dialog', { name: loginText.titleInitial }).within(() => {
cy.contains(loginText.actionWaiting).should('be.visible')
})
})
it('cancel button correctly clears error state', () => {
cy.withCtx((ctx) => {
ctx.coreData.authState = {
name: 'AUTH_ERROR_DURING_LOGIN',
message: 'An unexpected error occurred',
browserOpened: false,
}
cy.withCtx((ctx, o) => {
o.sinon.stub(ctx._apis.authApi, 'logIn').callsFake(async (onMessage) => {
onMessage({
name: 'AUTH_ERROR_DURING_LOGIN',
message: 'An unexpected error occurred',
browserOpened: false,
})
throw new Error()
})
})
cy.findByTestId('app-header-bar').within(() => {
@@ -517,26 +546,36 @@ describe('App Top Nav Workflows', () => {
cy.findByRole('button', { name: 'Log In' }).as('loginButton').click()
})
cy.contains(loginText.titleFailed).should('be.visible')
cy.contains(loginText.bodyError).should('be.visible')
cy.contains('An unexpected error occurred').should('be.visible')
cy.findByRole('dialog', { name: 'Log in to Cypress' }).within(() => {
cy.findByRole('button', { name: 'Log In' }).click()
cy.contains(loginText.titleFailed).should('be.visible')
cy.contains(loginText.bodyError).should('be.visible')
cy.contains('An unexpected error occurred').should('be.visible')
})
cy.percySnapshot()
cy.contains('button', loginText.actionTryAgain).should('be.visible')
cy.contains('button', loginText.actionCancel).click()
cy.findByRole('dialog', { name: loginText.titleFailed }).within(() => {
cy.contains('button', loginText.actionTryAgain).should('be.visible')
cy.contains('button', loginText.actionCancel).click()
})
cy.get('@loginButton').click()
cy.contains(loginText.titleInitial).should('be.visible')
})
it('closing modal correctly clears error state', () => {
cy.withCtx((ctx) => {
ctx.coreData.authState = {
name: 'AUTH_ERROR_DURING_LOGIN',
message: 'An unexpected error occurred',
browserOpened: false,
}
cy.withCtx((ctx, o) => {
o.sinon.stub(ctx._apis.authApi, 'logIn').callsFake(async (onMessage) => {
onMessage({
name: 'AUTH_ERROR_DURING_LOGIN',
message: 'An unexpected error occurred',
browserOpened: false,
})
throw new Error()
})
})
cy.findByTestId('app-header-bar').within(() => {
@@ -544,11 +583,14 @@ describe('App Top Nav Workflows', () => {
cy.findByRole('button', { name: 'Log In' }).as('loginButton').click()
})
cy.contains(loginText.titleFailed).should('be.visible')
cy.contains(loginText.bodyError).should('be.visible')
cy.contains('An unexpected error occurred').should('be.visible')
cy.findByRole('dialog', { name: 'Log in to Cypress' }).within(() => {
cy.findByRole('button', { name: 'Log In' }).click()
cy.contains(loginText.titleFailed).should('be.visible')
cy.contains(loginText.bodyError).should('be.visible')
cy.contains('An unexpected error occurred').should('be.visible')
cy.findByLabelText(defaultMessages.actions.close).click()
cy.findByLabelText(defaultMessages.actions.close).click()
})
cy.get('@loginButton').click()
cy.contains(loginText.titleInitial).should('be.visible')
+6
View File
@@ -2,6 +2,12 @@ import { createRouter as _createRouter, createWebHashHistory } from 'vue-router'
import generatedRoutes from 'virtual:generated-pages'
import { setupLayouts } from 'virtual:generated-layouts'
/**
* Generated Routes are created via https://github.com/hannoeru/vite-plugin-pages
* The generates are based on the files contained in src/pages.
* See README.md this package for more details
*/
export const createRouter = () => {
const routes = setupLayouts(generatedRoutes)
+2 -2
View File
@@ -8,7 +8,7 @@
<div
v-show="showPanel1"
data-cy="specs-list-panel"
class="h-full flex-shrink-0 relative"
class="h-full flex-shrink-0 z-10 relative"
:style="{width: `${panel1Width}px`}"
>
<slot
@@ -26,7 +26,7 @@
<div
v-show="showPanel2"
data-cy="reporter-panel"
class="h-full flex-shrink-0 relative"
class="h-full flex-shrink-0 z-10 relative"
:style="{width: `${panel2Width}px`}"
>
<slot name="panel2" />
@@ -36,6 +36,7 @@ describe('<CreateSpecModal />', () => {
},
},
specs: [],
fileExtensionToUse: 'js',
},
}}
show={show.value}
@@ -104,6 +105,7 @@ describe('playground', () => {
},
},
specs: [],
fileExtensionToUse: 'js',
},
}}
show={show.value}
+6 -1
View File
@@ -69,6 +69,7 @@ fragment CreateSpecModal on Query {
...CreateSpecCards
currentProject {
id
fileExtensionToUse
...EmptyGenerator
...ComponentGeneratorStepOne_codeGenGlob
...StoryGeneratorStepOne_codeGenGlob
@@ -95,7 +96,11 @@ const helpLink = computed(() => {
return ''
})
const specFileName = computed(() => getPathForPlatform('cypress/e2e/filename.cy.js'))
const specFileName = computed(() => {
const extension = props.gql.currentProject?.fileExtensionToUse ?? 'js'
return getPathForPlatform(`cypress/e2e/filename.cy.${extension}`)
})
const codeGenGlob = computed(() => {
if (!generator.value) {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

@@ -5,9 +5,11 @@ exports['src/index .getBreakingKeys returns list of breaking config keys 1'] = [
"experimentalNetworkStubbing",
"experimentalRunEvents",
"experimentalShadowDomSupport",
"experimentalStudio",
"firefoxGcInterval",
"nodeVersion",
"nodeVersion",
"pluginsFile",
"testFiles"
]
@@ -40,7 +42,6 @@ exports['src/index .getDefaultValues returns list of public config keys 1'] = {
"modifyObstructiveCode": true,
"numTestsKeptInMemory": 50,
"pageLoadTimeout": 60000,
"pluginsFile": "cypress/plugins",
"port": null,
"projectId": null,
"redirectionLimit": 20,
@@ -116,7 +117,6 @@ exports['src/index .getPublicConfigKeys returns list of public config keys 1'] =
"numTestsKeptInMemory",
"platform",
"pageLoadTimeout",
"pluginsFile",
"port",
"projectId",
"redirectionLimit",
+5
View File
@@ -0,0 +1,5 @@
if (process.env.CYPRESS_INTERNAL_ENV !== 'production') {
require('@packages/ts/register')
}
module.exports = require('./src')
-144
View File
@@ -1,144 +0,0 @@
const _ = require('lodash')
const debug = require('debug')('cypress:config:validator')
const { options, breakingOptions, breakingRootOptions, testingTypeBreakingOptions } = require('./options')
const dashesOrUnderscoresRe = /^(_-)+/
// takes an array and creates an index object of [keyKey]: [valueKey]
const createIndex = (arr, keyKey, valueKey) => {
return _.reduce(arr, (memo, item) => {
if (item[valueKey] !== undefined) {
memo[item[keyKey]] = item[valueKey]
}
return memo
}, {})
}
const breakingKeys = _.map(breakingOptions, 'name')
const defaultValues = createIndex(options, 'name', 'defaultValue')
const publicConfigKeys = _(options).reject({ isInternal: true }).map('name').value()
const validationRules = createIndex(options, 'name', 'validation')
const testConfigOverrideOptions = createIndex(options, 'name', 'canUpdateDuringTestTime')
const issuedWarnings = new Set()
const validateNoBreakingOptions = (breakingCfgOptions, cfg, onWarning, onErr) => {
breakingCfgOptions.forEach(({ name, errorKey, newName, isWarning, value }) => {
if (_.has(cfg, name)) {
if (value && cfg[name] !== value) {
// Bail if a value is specified but the config does not have that value.
return
}
if (isWarning) {
if (issuedWarnings.has(errorKey)) {
return
}
// avoid re-issuing the same warning more than once
issuedWarnings.add(errorKey)
return onWarning(errorKey, {
name,
newName,
value,
configFile: cfg.configFile,
})
}
return onErr(errorKey, {
name,
newName,
value,
configFile: cfg.configFile,
})
}
})
}
module.exports = {
allowed: (obj = {}) => {
const propertyNames = publicConfigKeys.concat(breakingKeys)
return _.pick(obj, propertyNames)
},
getBreakingKeys: () => {
return breakingKeys
},
getBreakingRootKeys: () => {
return breakingRootOptions
},
getDefaultValues: (runtimeOptions = {}) => {
// Default values can be functions, in which case they are evaluated
// at runtime - for example, slowTestThreshold where the default value
// varies between e2e and component testing.
return _.mapValues(defaultValues, (value) => (typeof value === 'function' ? value(runtimeOptions) : value))
},
getPublicConfigKeys: () => {
return publicConfigKeys
},
matchesConfigKey: (key) => {
if (_.has(defaultValues, key)) {
return key
}
key = key.toLowerCase().replace(dashesOrUnderscoresRe, '')
key = _.camelCase(key)
if (_.has(defaultValues, key)) {
return key
}
},
options,
validate: (cfg, onErr) => {
debug('validating configuration', cfg)
return _.each(cfg, (value, key) => {
const validationFn = validationRules[key]
// key has a validation rule & value different from the default
if (validationFn && value !== defaultValues[key]) {
const result = validationFn(key, value)
if (result !== true) {
return onErr(result)
}
}
})
},
validateNoBreakingConfigRoot: (cfg, onWarning, onErr) => {
return validateNoBreakingOptions(breakingRootOptions, cfg, onWarning, onErr)
},
validateNoBreakingConfig: (cfg, onWarning, onErr) => {
return validateNoBreakingOptions(breakingOptions, cfg, onWarning, onErr)
},
validateNoBreakingTestingTypeConfig: (cfg, testingType, onWarning, onErr) => {
const options = testingTypeBreakingOptions[testingType]
return validateNoBreakingOptions(options, cfg, onWarning, onErr)
},
validateNoReadOnlyConfig: (config, onErr) => {
let errProperty
Object.keys(config).some((option) => {
return errProperty = testConfigOverrideOptions[option] === false ? option : undefined
})
if (errProperty) {
return onErr(errProperty)
}
},
}
-36
View File
@@ -1,36 +0,0 @@
// TODO: Remove this file when we land type-safe @packages/config
type ErrResult = {
key: string
value: any
type: string
}
export default {
isValidClientCertificatesSet(_key: string, certsForUrls: any[]): ErrResult | true {},
isValidBrowser(browser: any): ErrResult | true {},
isValidBrowserList(key: string, browsers: any[]): ErrResult | true {},
isValidRetriesConfig(key: string, value: any): ErrResult | true {},
isPlainObject(key: string, value: any): ErrResult | true {},
isNumber(key: string, value: any): ErrResult | true {},
isNumberOrFalse(key: string, value: any): ErrResult | true {},
isFullyQualifiedUrl(key: string, value: string): ErrResult | true {},
isBoolean(key: string, value: any): ErrResult | true {},
isString(key: string, value: any): ErrResult | true {},
isArray(key: string, value: any): ErrResult | true {},
isStringOrFalse(key: string, value: any): ErrResult | true {},
isStringOrArrayOfStrings(key: string, value: any): ErrResult | true {},
isOneOf(...any: any[]): (key: any, value: any) => boolean {},
}
+13 -11
View File
@@ -3,13 +3,16 @@
"version": "0.0.0-development",
"description": "Config contains the configuration types and validation function used in the cypress electron application.",
"private": true,
"main": "lib/index.js",
"main": "index.js",
"browser": "src/index.ts",
"scripts": {
"build-prod": "tsc --project .",
"clean": "rm lib/options.js || echo 'ok'",
"build-prod": "tsc || echo 'built, with errors'",
"check-ts": "tsc --noEmit",
"clean-deps": "rm -rf node_modules",
"clean": "rm -f ./src/*.js ./src/**/*.js ./src/**/**/*.js ./test/**/*.js || echo 'cleaned'",
"test": "yarn test-unit",
"test-debug": "yarn test-unit --inspect-brk=5566",
"test-unit": "mocha --configFile=../../mocha-reporter-config.json -r @packages/ts/register -extension=.js,.ts test/unit/*spec.* --exit"
"test-unit": "mocha --configFile=../../mocha-reporter-config.json -r @packages/ts/register test/unit/**/*.spec.ts --exit"
},
"dependencies": {
"check-more-types": "2.24.0",
@@ -20,13 +23,12 @@
"devDependencies": {
"@packages/root": "0.0.0-development",
"@packages/ts": "0.0.0-development",
"chai": "1.10.0",
"mocha": "7.0.1",
"sinon": "7.3.1",
"sinon-chai": "3.3.0",
"snap-shot-it": "7.9.3"
"@packages/types": "0.0.0-development",
"chai": "4.2.0",
"mocha": "7.0.1"
},
"files": [
"lib"
]
"src"
],
"types": "src/index.ts"
}
+171
View File
@@ -0,0 +1,171 @@
import _ from 'lodash'
import Debug from 'debug'
import { options, breakingOptions, breakingRootOptions, testingTypeBreakingOptions } from './options'
import type { BreakingOption, BreakingOptionErrorKey } from './options'
// this export has to be done in 2 lines because of a bug in babel typescript
import * as validation from './validation'
export {
validation,
options,
breakingOptions,
BreakingOption,
BreakingOptionErrorKey,
}
const debug = Debug('cypress:config:validator')
const dashesOrUnderscoresRe = /^(_-)+/
// takes an array and creates an index object of [keyKey]: [valueKey]
function createIndex<T extends Record<string, any>> (arr: Array<T>, keyKey: keyof T, valueKey: keyof T) {
return _.reduce(arr, (memo: Record<string, any>, item) => {
if (item[valueKey] !== undefined) {
memo[item[keyKey] as string] = item[valueKey]
}
return memo
}, {})
}
const breakingKeys = _.map(breakingOptions, 'name')
const defaultValues = createIndex(options, 'name', 'defaultValue')
const publicConfigKeys = _(options).reject({ isInternal: true }).map('name').value()
const validationRules = createIndex(options, 'name', 'validation')
const testConfigOverrideOptions = createIndex(options, 'name', 'canUpdateDuringTestTime')
const issuedWarnings = new Set()
export type BreakingErrResult = {
name: string
newName?: string
value?: any
configFile: string
}
type ErrorHandler = (
key: BreakingOptionErrorKey,
options: BreakingErrResult
) => void
const validateNoBreakingOptions = (breakingCfgOptions: BreakingOption[], cfg: any, onWarning: ErrorHandler, onErr: ErrorHandler) => {
breakingCfgOptions.forEach(({ name, errorKey, newName, isWarning, value }) => {
if (_.has(cfg, name)) {
if (value && cfg[name] !== value) {
// Bail if a value is specified but the config does not have that value.
return
}
if (isWarning) {
if (issuedWarnings.has(errorKey)) {
return
}
// avoid re-issuing the same warning more than once
issuedWarnings.add(errorKey)
return onWarning(errorKey, {
name,
newName,
value,
configFile: cfg.configFile,
})
}
return onErr(errorKey, {
name,
newName,
value,
configFile: cfg.configFile,
})
}
})
}
export const allowed = (obj = {}) => {
const propertyNames = publicConfigKeys.concat(breakingKeys)
return _.pick(obj, propertyNames)
}
export const getBreakingKeys = () => {
return breakingKeys
}
export const getBreakingRootKeys = () => {
return breakingRootOptions
}
export const getDefaultValues = (runtimeOptions = {}) => {
// Default values can be functions, in which case they are evaluated
// at runtime - for example, slowTestThreshold where the default value
// varies between e2e and component testing.
return _.mapValues(defaultValues, (value) => (typeof value === 'function' ? value(runtimeOptions) : value))
}
export const getPublicConfigKeys = () => {
return publicConfigKeys
}
export const matchesConfigKey = (key: string) => {
if (_.has(defaultValues, key)) {
return key
}
key = key.toLowerCase().replace(dashesOrUnderscoresRe, '')
key = _.camelCase(key)
if (_.has(defaultValues, key)) {
return key
}
return
}
export const validate = (cfg: any, onErr: (property: string) => void) => {
debug('validating configuration', cfg)
return _.each(cfg, (value, key) => {
const validationFn = validationRules[key]
// key has a validation rule & value different from the default
if (validationFn && value !== defaultValues[key]) {
const result = validationFn(key, value)
if (result !== true) {
return onErr(result)
}
}
})
}
export const validateNoBreakingConfigRoot = (cfg: any, onWarning: ErrorHandler, onErr: ErrorHandler) => {
return validateNoBreakingOptions(breakingRootOptions, cfg, onWarning, onErr)
}
export const validateNoBreakingConfig = (cfg: any, onWarning: ErrorHandler, onErr: ErrorHandler) => {
return validateNoBreakingOptions(breakingOptions, cfg, onWarning, onErr)
}
export const validateNoBreakingConfigLaunchpad = (cfg: any, onWarning: ErrorHandler, onErr: ErrorHandler) => {
return validateNoBreakingOptions(breakingOptions.filter((option) => option.showInLaunchpad), cfg, onWarning, onErr)
}
export const validateNoBreakingTestingTypeConfig = (cfg: any, testingType: keyof typeof testingTypeBreakingOptions, onWarning: ErrorHandler, onErr: ErrorHandler) => {
const options = testingTypeBreakingOptions[testingType]
return validateNoBreakingOptions(options, cfg, onWarning, onErr)
}
export const validateNoReadOnlyConfig = (config: any, onErr: (property: string) => void) => {
let errProperty
Object.keys(config).some((option) => {
return errProperty = testConfigOverrideOptions[option] === false ? option : undefined
})
if (errProperty) {
return onErr(errProperty)
}
}
@@ -1,8 +1,25 @@
import os from 'os'
import validate from './validation'
import * as validate from './validation'
// @ts-ignore
import pkg from '@packages/root'
export type BreakingOptionErrorKey =
| 'CONFIG_FILE_INVALID_ROOT_CONFIG'
| 'CONFIG_FILE_INVALID_ROOT_CONFIG_E2E'
| 'CONFIG_FILE_INVALID_TESTING_TYPE_CONFIG_COMPONENT'
| 'EXPERIMENTAL_COMPONENT_TESTING_REMOVED'
| 'EXPERIMENTAL_SAMESITE_REMOVED'
| 'EXPERIMENTAL_NETWORK_STUBBING_REMOVED'
| 'EXPERIMENTAL_RUN_EVENTS_REMOVED'
| 'EXPERIMENTAL_SHADOW_DOM_REMOVED'
| 'EXPERIMENTAL_STUDIO_REMOVED'
| 'FIREFOX_GC_INTERVAL_REMOVED'
| 'NODE_VERSION_DEPRECATION_SYSTEM'
| 'NODE_VERSION_DEPRECATION_BUNDLED'
| 'PLUGINS_FILE_CONFIG_OPTION_REMOVED'
| 'RENAMED_CONFIG_OPTION'
| 'TEST_FILES_DEPRECATION'
type TestingType = 'e2e' | 'component'
interface ResolvedConfigOption {
@@ -29,7 +46,7 @@ interface RuntimeConfigOption {
canUpdateDuringTestTime?: boolean
}
interface BreakingOption {
export interface BreakingOption {
/**
* The non-passive configuration option.
*/
@@ -37,7 +54,7 @@ interface BreakingOption {
/**
* String to summarize the error messaging that is logged.
*/
errorKey: string
errorKey: BreakingOptionErrorKey
/**
* Array of testing types this config option is valid for
*/
@@ -54,6 +71,10 @@ interface BreakingOption {
* Whether to log the error message as a warning instead of throwing an error.
*/
isWarning?: boolean
/**
* Whether to show the error message in the launchpad
*/
showInLaunchpad?: boolean
}
const isValidConfig = (key: string, config: any) => {
@@ -233,12 +254,6 @@ const resolvedOptions: Array<ResolvedConfigOption> = [
defaultValue: 60000,
validation: validate.isNumber,
canUpdateDuringTestTime: true,
}, {
name: 'pluginsFile',
defaultValue: 'cypress/plugins',
validation: validate.isStringOrFalse,
isFolder: true,
canUpdateDuringTestTime: false,
}, {
name: 'port',
defaultValue: null,
@@ -493,6 +508,9 @@ export const options: Array<ResolvedConfigOption | RuntimeConfigOption> = [
...runtimeOptions,
]
/**
* Values not allowed in 10.X+ in the root, e2e and component config
*/
export const breakingOptions: Array<BreakingOption> = [
{
name: 'blacklistHosts',
@@ -518,6 +536,11 @@ export const breakingOptions: Array<BreakingOption> = [
name: 'experimentalShadowDomSupport',
errorKey: 'EXPERIMENTAL_SHADOW_DOM_REMOVED',
isWarning: true,
}, {
name: 'experimentalStudio',
errorKey: 'EXPERIMENTAL_STUDIO_REMOVED',
isWarning: true,
showInLaunchpad: true,
}, {
name: 'firefoxGcInterval',
errorKey: 'FIREFOX_GC_INTERVAL_REMOVED',
@@ -532,8 +555,10 @@ export const breakingOptions: Array<BreakingOption> = [
value: 'bundled',
errorKey: 'NODE_VERSION_DEPRECATION_BUNDLED',
isWarning: true,
},
{
}, {
name: 'pluginsFile',
errorKey: 'PLUGINS_FILE_CONFIG_OPTION_REMOVED',
}, {
name: 'testFiles',
errorKey: 'TEST_FILES_DEPRECATION',
isWarning: false,
@@ -559,6 +584,12 @@ export const breakingRootOptions: Array<BreakingOption> = [
isWarning: false,
testingTypes: ['component', 'e2e'],
},
{
name: 'experimentalStudio',
errorKey: 'EXPERIMENTAL_STUDIO_REMOVED',
isWarning: true,
testingTypes: ['component', 'e2e'],
},
{
name: 'baseUrl',
errorKey: 'CONFIG_FILE_INVALID_ROOT_CONFIG_E2E',
@@ -1,17 +1,26 @@
const _ = require('lodash')
const debug = require('debug')('cypress:server:validation')
const is = require('check-more-types')
const { commaListsOr } = require('common-tags')
const path = require('path')
import { URL } from 'url'
import path from 'path'
import * as _ from 'lodash'
import * as is from 'check-more-types'
import { commaListsOr } from 'common-tags'
import Debug from 'debug'
const debug = Debug('cypress:server:validation')
// validation functions take a key and a value and should:
// - return true if it passes validation
// - return a error message if it fails validation
const str = JSON.stringify
const { isArray, isString, isFinite: isNumber } = _
const errMsg = (key, value, type) => {
type ErrResult = {
key: string
value: any
type: string
list?: string
}
const errMsg = (key: string, value: any, type: string): ErrResult => {
return {
key,
value,
@@ -19,15 +28,15 @@ const errMsg = (key, value, type) => {
}
}
const isFullyQualifiedUrl = (value) => {
return isString(value) && /^https?\:\/\//.test(value)
const _isFullyQualifiedUrl = (value: any): ErrResult | boolean => {
return _.isString(value) && /^https?\:\/\//.test(value)
}
const isArrayOfStrings = (value) => {
return isArray(value) && _.every(value, isString)
const isArrayOfStrings = (value: any): ErrResult | boolean => {
return _.isArray(value) && _.every(value, _.isString)
}
const isFalse = (value) => {
const isFalse = (value: any): boolean => {
return value === false
}
@@ -35,7 +44,7 @@ const isFalse = (value) => {
* Validates a single browser object.
* @returns {string|true} Returns `true` if the object is matching browser object schema. Returns an error message if it does not.
*/
const isValidBrowser = (browser) => {
export const isValidBrowser = (browser: any): ErrResult | true => {
if (!is.unemptyString(browser.name)) {
return errMsg('name', browser, 'a non-empty string')
}
@@ -55,11 +64,11 @@ const isValidBrowser = (browser) => {
return errMsg('version', browser, 'a non-empty string')
}
if (!is.string(browser.path)) {
if (typeof browser.path !== 'string') {
return errMsg('path', browser, 'a string')
}
if (!is.string(browser.majorVersion) && !is.positive(browser.majorVersion)) {
if (typeof browser.majorVersion !== 'string' && !(is.number(browser.majorVersion) && browser.majorVersion > 0)) {
return errMsg('majorVersion', browser, 'a string or a positive number')
}
@@ -69,7 +78,7 @@ const isValidBrowser = (browser) => {
/**
* Validates the list of browsers.
*/
const isValidBrowserList = (key, browsers) => {
export const isValidBrowserList = (key: string, browsers: any): ErrResult | true | string => {
debug('browsers %o', browsers)
if (!browsers) {
return 'Missing browsers list'
@@ -86,7 +95,7 @@ const isValidBrowserList = (key, browsers) => {
}
for (let k = 0; k < browsers.length; k += 1) {
const validationResult = isValidBrowser(browsers[k])
const validationResult: boolean | {key: string, value: string, type: string, list?: string} = isValidBrowser(browsers[k])
if (validationResult !== true) {
validationResult.list = 'browsers'
@@ -98,10 +107,10 @@ const isValidBrowserList = (key, browsers) => {
return true
}
const isValidRetriesConfig = (key, value) => {
export const isValidRetriesConfig = (key: string, value: any): ErrResult | true => {
const optionalKeys = ['runMode', 'openMode']
const isValidRetryValue = (val) => _.isNull(val) || (Number.isInteger(val) && val >= 0)
const optionalKeysAreValid = (val, k) => optionalKeys.includes(k) && isValidRetryValue(val)
const isValidRetryValue = (val: any) => _.isNull(val) || (Number.isInteger(val) && val >= 0)
const optionalKeysAreValid = (val: any, k: string) => optionalKeys.includes(k) && isValidRetryValue(val)
if (isValidRetryValue(value)) {
return true
@@ -114,15 +123,16 @@ const isValidRetriesConfig = (key, value) => {
return errMsg(key, value, 'a positive number or null or an object with keys "openMode" and "runMode" with values of numbers or nulls')
}
const isPlainObject = (key, value) => {
if (value == null || _.isPlainObject(value)) {
return true
}
return errMsg(key, value, 'a plain object')
}
const isOneOf = (...values) => {
/**
* Checks if given value for a key is equal to one of the provided values.
* @example
```
validate = v.isOneOf("foo", "bar")
validate("example", "foo") // true
validate("example", "else") // error message string
```
*/
export const isOneOf = (...values: any[]): ((key: string, value: any) => ErrResult | true) => {
return (key, value) => {
if (values.some((v) => {
if (typeof value === 'function') {
@@ -134,7 +144,7 @@ const isOneOf = (...values) => {
return true
}
const strings = values.map(str).join(', ')
const strings = values.map((a) => str(a)).join(', ')
return errMsg(key, value, `one of these values: ${strings}`)
}
@@ -144,19 +154,32 @@ const isOneOf = (...values) => {
* Validates whether the supplied set of cert information is valid
* @returns {string|true} Returns `true` if the information set is valid. Returns an error message if it is not.
*/
const isValidClientCertificatesSet = (_key, certsForUrls) => {
// _key: string, certsForUrls: any[]): ErrResult | true {}
export const isValidClientCertificatesSet = (_key: string, certsForUrls: Array<{
name?: string
url?: string
ca?: string[]
certs?: Array<{
key: string
cert: string
pfx: string
}>}>): ErrResult | true | string => {
debug('clientCerts: %o', certsForUrls)
if (!Array.isArray(certsForUrls)) {
return errMsg(`clientCertificates.certs`, certsForUrls, 'an array of certs for URLs')
}
let urls = []
let urls: string[] = []
for (let i = 0; i < certsForUrls.length; i++) {
debug(`Processing clientCertificates: ${i}`)
let certsForUrl = certsForUrls[i]
if (!certsForUrl) {
continue
}
if (!certsForUrl.url) {
return errMsg(`clientCertificates[${i}].url`, certsForUrl.url, 'a URL matcher')
}
@@ -188,7 +211,7 @@ const isValidClientCertificatesSet = (_key, certsForUrls) => {
}
for (let j = 0; j < certsForUrl.certs.length; j++) {
let certInfo = certsForUrl.certs[j]
let certInfo = certsForUrl.certs[j]!
// Only one of PEM or PFX cert allowed
if (certInfo.cert && certInfo.pfx) {
@@ -220,9 +243,11 @@ const isValidClientCertificatesSet = (_key, certsForUrls) => {
}
}
for (let k = 0; k < certsForUrl.ca.length; k++) {
if (path.isAbsolute(certsForUrl.ca[k])) {
return errMsg(`clientCertificates[${k}].ca[${k}]`, certsForUrl.ca[k], 'a relative filepath')
if (certsForUrl.ca) {
for (let k = 0; k < certsForUrl.ca.length; k++) {
if (path.isAbsolute(certsForUrl.ca[k] || '')) {
return errMsg(`clientCertificates[${k}].ca[${k}]`, certsForUrl.ca[k], 'a relative filepath')
}
}
}
}
@@ -230,93 +255,78 @@ const isValidClientCertificatesSet = (_key, certsForUrls) => {
return true
}
module.exports = {
isValidClientCertificatesSet,
export const isPlainObject = (key: string, value: any) => {
if (value == null || _.isPlainObject(value)) {
return true
}
isValidBrowser,
isValidBrowserList,
isValidRetriesConfig,
isPlainObject,
isNumber (key, value) {
if (value == null || isNumber(value)) {
return true
}
return errMsg(key, value, 'a number')
},
isNumberOrFalse (key, value) {
if (isNumber(value) || isFalse(value)) {
return true
}
return errMsg(key, value, 'a number or false')
},
isFullyQualifiedUrl (key, value) {
if (value == null || isFullyQualifiedUrl(value)) {
return true
}
return errMsg(
key,
value,
'a fully qualified URL (starting with `http://` or `https://`)',
)
},
isBoolean (key, value) {
if (value == null || _.isBoolean(value)) {
return true
}
return errMsg(key, value, 'a boolean')
},
isString (key, value) {
if (value == null || isString(value)) {
return true
}
return errMsg(key, value, 'a string')
},
isArray (key, value) {
if (value == null || isArray(value)) {
return true
}
return errMsg(key, value, 'an array')
},
isStringOrFalse (key, value) {
if (isString(value) || isFalse(value)) {
return true
}
return errMsg(key, value, 'a string or false')
},
isStringOrArrayOfStrings (key, value) {
if (isString(value) || isArrayOfStrings(value)) {
return true
}
return errMsg(key, value, 'a string or an array of strings')
},
/**
* Checks if given value for a key is equal to one of the provided values.
* @example
```
validate = v.isOneOf("foo", "bar")
validate("example", "foo") // true
validate("example", "else") // error message string
```
*/
isOneOf,
return errMsg(key, value, 'a plain object')
}
export function isBoolean (key: string, value: any): ErrResult | true {
if (value == null || _.isBoolean(value)) {
return true
}
return errMsg(key, value, 'a boolean')
}
export function isNumber (key: string, value: any): ErrResult | true {
if (value == null || _.isNumber(value)) {
return true
}
return errMsg(key, value, 'a number')
}
export function isString (key: string, value: any): ErrResult | true {
if (value == null || _.isString(value)) {
return true
}
return errMsg(key, value, 'a string')
}
export function isArray (key: string, value: any) {
if (value == null || _.isArray(value)) {
return true
}
return errMsg(key, value, 'an array')
}
export function isNumberOrFalse (key: string, value: any): ErrResult | true {
if (_.isNumber(value) || isFalse(value)) {
return true
}
return errMsg(key, value, 'a number or false')
}
export function isStringOrFalse (key: string, value: any): ErrResult | true {
if (_.isString(value) || isFalse(value)) {
return true
}
return errMsg(key, value, 'a string or false')
}
export function isFullyQualifiedUrl (key: string, value: any): ErrResult | true {
if (value == null || _isFullyQualifiedUrl(value)) {
return true
}
return errMsg(
key,
value,
'a fully qualified URL (starting with `http://` or `https://`)',
)
}
export function isStringOrArrayOfStrings (key: string, value: any): ErrResult | true {
if (_.isString(value) || isArrayOfStrings(value)) {
return true
}
return errMsg(key, value, 'a string or an array of strings')
}
@@ -1,9 +1,9 @@
const chai = require('chai')
const snapshot = require('snap-shot-it')
const sinon = require('sinon')
const sinonChai = require('sinon-chai')
import chai from 'chai'
import snapshot from 'snap-shot-it'
import sinon from 'sinon'
import sinonChai from 'sinon-chai'
const configUtil = require('../../lib/index')
import * as configUtil from '../../src/index'
chai.use(sinonChai)
const { expect } = chai
@@ -145,6 +145,27 @@ describe('src/index', () => {
})
})
describe('.validateNoBreakingConfigLaunchpad', () => {
it('calls warning callback if config contains breaking option that should be shown in launchpad', () => {
const warningFn = sinon.spy()
const errorFn = sinon.spy()
configUtil.validateNoBreakingConfigLaunchpad({
'experimentalStudio': 'should break',
configFile: 'config.js',
}, warningFn, errorFn)
expect(warningFn).to.have.been.calledOnceWith('EXPERIMENTAL_STUDIO_REMOVED', {
name: 'experimentalStudio',
newName: undefined,
value: undefined,
configFile: 'config.js',
})
expect(errorFn).to.have.callCount(0)
})
})
describe('.validateNoReadOnlyConfig', () => {
it('returns an error if validation fails', () => {
const errorFn = sinon.spy()
@@ -1,7 +1,7 @@
const snapshot = require('snap-shot-it')
const { expect } = require('chai')
import snapshot from 'snap-shot-it'
import { expect } from 'chai'
const validation = require('../../lib/validation')
import * as validation from '../../src/validation'
describe('src/validation', () => {
const mockKey = 'mockConfigKey'
@@ -84,13 +84,13 @@ describe('src/validation', () => {
// data-driven testing - computers snapshot value for each item in the list passed through the function
// https://github.com/bahmutov/snap-shot-it#data-driven-testing
return snapshot.apply(null, [validation.isValidBrowser].concat(browsers))
return snapshot.apply(null, [validation.isValidBrowser].concat(browsers as any))
})
})
describe('.isValidBrowserList', () => {
it('does not allow empty or not browsers', () => {
snapshot('undefined browsers', validation.isValidBrowserList('browsers'))
snapshot('undefined browsers', validation.isValidBrowserList('browsers', undefined))
snapshot('empty list of browsers', validation.isValidBrowserList('browsers', []))
return snapshot('browsers list with a string', validation.isValidBrowserList('browsers', ['foo']))
+20 -2
View File
@@ -1,6 +1,24 @@
{
"extends": "../ts/tsconfig.json",
"include": [
"lib/*",
"src/*.ts"
],
}
"exclude": [
"test",
"script"
],
"compilerOptions": {
"strict": true,
"allowJs": false,
"rootDir": "src",
"noImplicitAny": true,
"resolveJsonModule": true,
"experimentalDecorators": true,
"noUncheckedIndexedAccess": true,
"importsNotUsedAsValues": "error",
"types": ["node"],
"typeRoots": [
"../../node_modules/@types"
],
}
}
@@ -5,7 +5,7 @@ export interface AuthApiShape {
getUser(): Promise<Partial<AuthenticatedUserShape>>
logIn(onMessage: (message: AuthStateShape) => void): Promise<AuthenticatedUserShape>
logOut(): Promise<void>
resetAuthState(): Promise<void>
resetAuthState(): void
}
export class AuthActions {
@@ -44,19 +44,62 @@ export class AuthActions {
}
async login () {
this.setAuthenticatedUser(await this.authApi.logIn((authState) => {
const loginPromise = new Promise<AuthenticatedUserShape | null>((resolve, reject) => {
// A resolver is exposed to the instance so that we can
// resolve this promise and the original mutation promise
// if a reset occurs
this.ctx.update((coreData) => {
coreData.authState = authState
coreData.cancelActiveLogin = () => resolve(null)
})
}))
this.ctx.emitter.authChange()
this.authApi.logIn((authState) => {
this.ctx.update((coreData) => {
coreData.authState = authState
})
// Ensure auth state changes during the login lifecycle
// are propagated to the clients
this.ctx.emitter.authChange()
}).then(resolve, reject)
})
const user = await loginPromise
if (!user) {
// if the user is null, this promise is resolving due to a
// login mutation cancellation. the state should already
// be reset, so abort early.
return
}
this.setAuthenticatedUser(user as AuthenticatedUserShape)
this.ctx.update((coreData) => {
coreData.cancelActiveLogin = null
})
this.resetAuthState()
}
resetAuthState () {
// closes the express server opened during login, if it's still open
this.authApi.resetAuthState()
// if a login mutation is still in progress, we
// forcefully resolve it so that the mutation does not persist
if (this.ctx.coreData.cancelActiveLogin) {
this.ctx.coreData.cancelActiveLogin()
this.ctx.update((coreData) => {
coreData.cancelActiveLogin = null
})
}
this.ctx.update((coreData) => {
coreData.authState = { browserOpened: false }
})
this.ctx.emitter.authChange()
}
async logout () {
@@ -76,7 +119,9 @@ export class AuthActions {
}
private setAuthenticatedUser (authUser: AuthenticatedUserShape | null) {
this.ctx.coreData.user = authUser
this.ctx.update((coreData) => {
coreData.user = authUser
})
return this
}
@@ -154,7 +154,7 @@ export class MigrationActions {
}
get configFileNameAfterMigration () {
return this.ctx.lifecycleManager.legacyConfigFile.replace('.json', `.config.${this.ctx.lifecycleManager.metaState.hasTypescript ? 'ts' : 'js'}`)
return this.ctx.lifecycleManager.legacyConfigFile.replace('.json', `.config.${this.ctx.lifecycleManager.fileExtensionToUse}`)
}
async createConfigFile () {
@@ -3,6 +3,7 @@ import { isBinaryFile } from 'isbinaryfile'
import * as path from 'path'
import * as ejs from 'ejs'
import fm from 'front-matter'
import _ from 'lodash'
export interface Action {
templateDir: string
@@ -36,7 +37,7 @@ export async function codeGenerator (
const templateFiles = await allFilesInDir(action.templateDir)
const codeGenResults: CodeGenResults = { files: [], failed: [] }
for (const file of templateFiles) {
const scaffoldResults = await Promise.all(templateFiles.map(async (file) => {
const isBinary = await isBinaryFile(file)
const parsedFile = path.parse(file)
@@ -85,18 +86,26 @@ export async function codeGenerator (
await fs.outputFile(computedPath, content)
}
codeGenResults.files.push({
return {
file: computedPath,
type,
status,
content: content.toString(),
})
} as const
} catch (e) {
codeGenResults.failed.push(e as Error)
return e instanceof Error ? e : new Error(String(e))
}
}
}))
return codeGenResults
return scaffoldResults.reduce((accum, result) => {
if (result instanceof Error) {
accum.failed.push(result)
} else {
accum.files.push(result)
}
return accum
}, codeGenResults)
}
function computePath (
@@ -116,20 +125,16 @@ function computePath (
}
async function allFilesInDir (parent: string): Promise<string[]> {
let res: string[] = []
const dirs = await fs.readdir(parent)
for (const dir of await fs.readdir(parent)) {
const result = await Promise.all(dirs.map(async (dir) => {
const child = path.join(parent, dir)
const isDir = (await fs.stat(child)).isDirectory()
if (!isDir) {
res.push(child)
} else {
res = [...res, ...(await allFilesInDir(child))]
}
}
return isDir ? await allFilesInDir(child) : child
}))
return res
return _.flatten(result)
}
function frontMatter (content: string, args: { [key: string]: any }) {
@@ -20,7 +20,8 @@ import { getError, CypressError, ConfigValidationFailureInfo } from '@packages/e
import type { DataContext } from '..'
import { LoadConfigReply, SetupNodeEventsReply, ProjectConfigIpc, IpcHandler } from './ProjectConfigIpc'
import assert from 'assert'
import type { AllModeOptions, BreakingErrResult, BreakingOption, FoundBrowser, FullConfig, TestingType } from '@packages/types'
import type { AllModeOptions, FoundBrowser, FullConfig, TestingType } from '@packages/types'
import type { BreakingErrResult, BreakingOptionErrorKey } from '@packages/config'
import { autoBindDebug } from '../util/autoBindDebug'
import type { LegacyCypressConfigJson } from '../sources'
@@ -46,7 +47,7 @@ export interface SetupFullConfigOptions {
options: Partial<AllModeOptions>
}
type BreakingValidationFn<T> = (type: BreakingOption, val: BreakingErrResult) => T
type BreakingValidationFn<T> = (type: BreakingOptionErrorKey, val: BreakingErrResult) => T
/**
* All of the APIs injected from @packages/server & @packages/config
@@ -60,6 +61,7 @@ export interface InjectedConfigApi {
updateWithPluginValues(config: FullConfig, modifiedConfig: Partial<Cypress.ConfigOptions>): FullConfig
setupFullConfigWithDefaults(config: SetupFullConfigOptions): Promise<FullConfig>
validateRootConfigBreakingChanges<T extends Cypress.ConfigOptions>(config: Partial<T>, onWarning: BreakingValidationFn<CypressError>, onErr: BreakingValidationFn<never>): void
validateLaunchpadConfigBreakingChanges<T extends Cypress.ConfigOptions>(config: Partial<T>, onWarning: BreakingValidationFn<CypressError>, onErr: BreakingValidationFn<never>): void
validateTestingTypeConfigBreakingChanges<T extends Cypress.ConfigOptions>(config: Partial<T>, testingType: Cypress.TestingType, onWarning: BreakingValidationFn<CypressError>, onErr: BreakingValidationFn<never>): void
}
@@ -179,7 +181,7 @@ export class ProjectLifecycleManager {
}
get configFile () {
return this.ctx.modeOptions.configFile ?? 'cypress.config.js'
return this.ctx.modeOptions.configFile ?? path.basename(this.configFilePath) ?? 'cypress.config.js'
}
get configFilePath () {
@@ -226,6 +228,10 @@ export class ProjectLifecycleManager {
return path.basename(this.projectRoot)
}
get fileExtensionToUse () {
return this.metaState.hasTypescript ? 'ts' : 'js'
}
async checkIfLegacyConfigFileExist () {
const legacyConfigFileExist = await this.ctx.deref.actions.file.checkIfFileExists(this.legacyConfigFile)
@@ -622,6 +628,24 @@ export class ProjectLifecycleManager {
throw getError('CONFIG_VALIDATION_ERROR', 'configFile', file || null, errMsg)
})
return this.ctx._apis.configApi.validateLaunchpadConfigBreakingChanges(
config,
(type, obj) => {
const error = getError(type, obj)
this.ctx.onWarning(error)
return error
},
(type, obj) => {
const error = getError(type, obj)
this.ctx.onError(error)
throw error
},
)
}
/**
@@ -138,6 +138,7 @@ export interface CoreDataShape {
warnings: ErrorWrapperSource[]
packageManager: typeof PACKAGE_MANAGERS[number]
forceReconfigureProject: ForceReconfigureProjectDataShape | null
cancelActiveLogin: (() => void) | null
}
/**
@@ -209,5 +210,6 @@ export function makeCoreData (modeOptions: Partial<AllModeOptions> = {}): CoreDa
scaffoldedFiles: null,
packageManager: 'npm',
forceReconfigureProject: null,
cancelActiveLogin: null,
}
}
@@ -187,6 +187,6 @@ export class MigrationDataSource {
}
get configFileNameAfterMigration () {
return this.ctx.lifecycleManager.legacyConfigFile.replace('.json', `.config.${this.ctx.lifecycleManager.metaState.hasTypescript ? 'ts' : 'js'}`)
return this.ctx.lifecycleManager.legacyConfigFile.replace('.json', `.config.${this.ctx.lifecycleManager.fileExtensionToUse}`)
}
}
@@ -1,4 +1,4 @@
import { options } from '@packages/config/lib/options'
import { options } from '@packages/config'
export const getDefaultSpecPatterns = () => {
return {
@@ -585,16 +585,16 @@ describe('src/cy/commands/actions/type - #type events', () => {
describe('triggers', () => {
const targets = [
'#target-button-tag',
'#target-input-button',
'#target-input-image',
'#target-input-reset',
'#target-input-submit',
'button-tag',
'input-button',
'input-image',
'input-reset',
'input-submit',
]
targets.forEach((target) => {
it(target, () => {
cy.get(target).focus().type('{enter}')
cy.get(`#target-${target}`).focus().type('{enter}')
cy.get('li').should('have.length', 4)
cy.get('li').eq(0).should('have.text', 'keydown')
@@ -603,17 +603,31 @@ describe('src/cy/commands/actions/type - #type events', () => {
cy.get('li').eq(3).should('have.text', 'keyup')
})
})
describe('keydown triggered on another element', () => {
targets.forEach((target) => {
it(target, () => {
cy.get('#focus-options').select(target)
cy.get('#input-text').focus().type('{enter}')
cy.get('li').should('have.length', 3)
cy.get('li').eq(0).should('have.text', 'keypress')
cy.get('li').eq(1).should('have.text', 'click')
cy.get('li').eq(2).should('have.text', 'keyup')
})
})
})
})
describe('does not trigger', () => {
const targets = [
'#target-input-checkbox',
'#target-input-radio',
'input-checkbox',
'input-radio',
]
targets.forEach((target) => {
it(target, () => {
cy.get(target).focus().type('{enter}')
cy.get(`#target-${target}`).focus().type('{enter}')
cy.get('li').should('have.length', 3)
cy.get('li').eq(0).should('have.text', 'keydown')
@@ -621,6 +635,19 @@ describe('src/cy/commands/actions/type - #type events', () => {
cy.get('li').eq(2).should('have.text', 'keyup')
})
})
describe('keydown triggered on another element', () => {
targets.forEach((target) => {
it(target, () => {
cy.get('#focus-options').select(target)
cy.get('#input-text').focus().type('{enter}')
cy.get('li').should('have.length', 2)
cy.get('li').eq(0).should('have.text', 'keypress')
cy.get('li').eq(1).should('have.text', 'keyup')
})
})
})
})
})
@@ -770,5 +797,28 @@ describe('src/cy/commands/actions/type - #type events', () => {
cy.get('#target-input-radio').should('be.checked')
})
})
describe('keydown on another element does not trigger click', () => {
const targets = [
'button-tag',
'input-button',
'input-image',
'input-reset',
'input-submit',
'input-checkbox',
'input-radio',
]
targets.forEach((target) => {
it(target, () => {
cy.get('#focus-options').select('button-tag')
cy.get('#input-text').focus().type(' ')
cy.get('li').should('have.length', 2)
cy.get('li').eq(0).should('have.text', 'keypress')
cy.get('li').eq(1).should('have.text', 'keyup')
})
})
})
})
})
@@ -6,14 +6,33 @@
</head>
<body>
<button id="reset">clear log</button>
<button id="target-button-tag">button tag</button>
<input id="target-input-button" type="button" value="input button" />
<input id="target-input-image" type="image" value="input image" />
<input id="target-input-reset" type="reset" value="input reset" />
<input id="target-input-submit" type="submit" value="input submit" />
<input id="target-input-checkbox" type="checkbox" value="input checkbox" />
<input id="target-input-radio" type="radio" value="input radio" />
<div>
<button id="reset">clear log</button>
</div>
<div>
<input id="input-text" type="text" />
<select id="focus-options">
<option value="clear">clear</option>
<option value="button-tag">button tag</option>
<option value="input-button">input button</option>
<option value="input-image">input image</option>
<option value="input-reset">input reset</option>
<option value="input-submit">input submit</option>
<option value="input-checkbox">input checkbox</option>
<option value="input-radio">input radio</option>
</select>
</div>
<div>
<button id="target-button-tag">button tag</button>
<input id="target-input-button" type="button" value="input button" />
<input id="target-input-image" type="image" value="input image" />
<input id="target-input-reset" type="reset" value="input reset" />
<input id="target-input-submit" type="submit" value="input submit" />
<input id="target-input-checkbox" type="checkbox" value="input checkbox" />
<input id="target-input-radio" type="radio" value="input radio" />
</div>
<div id="log"></div>
@@ -70,6 +89,32 @@
updateLog("keyup");
});
});
let handler = null
const focusOptions = document.getElementById("focus-options");
focusOptions.addEventListener('change', (event) => {
const val = event.target.value;
const target = document.getElementById('input-text');
if (handler) {
target.removeEventListener('keydown', handler);
}
if (val === 'clear') {
handler = null
return
}
handler = (e) => {
const focusEl = document.getElementById(`target-${val}`);
focusEl.focus()
}
target.addEventListener('keydown', handler);
})
</script>
</body>
</html>
+1 -1
View File
@@ -7,7 +7,7 @@
"cypress:open": "node ../../scripts/cypress open",
"cypress:run": "node ../../scripts/cypress run",
"postinstall": "patch-package",
"start": "node -e 'console.log(require(`chalk`).red(`\nError:\n\tRunning \\`yarn start\\` is no longer needed for driver/cypress tests.\n\tWe now automatically spawn the server in the pluginsFile.\n\tChanges to the server will be watched and reloaded automatically.`))'"
"start": "node -e 'console.log(require(`chalk`).red(`\nError:\n\tRunning \\`yarn start\\` is no longer needed for driver/cypress tests.\n\tWe now automatically spawn the server in e2e.setupNodeEvents config.\n\tChanges to the server will be watched and reloaded automatically.`))'"
},
"dependencies": {},
"devDependencies": {
@@ -275,11 +275,6 @@ export default function (Commands, Cypress, cy, state, config) {
const isContentEditable = $elements.isContentEditable(options.$el.get(0))
const isTextarea = $elements.isTextarea(options.$el.get(0))
// click event is only fired on button, image, submit, reset elements.
// That's why we cannot use $elements.isButtonLike() here.
const type = (type) => $elements.isInputType(options.$el.get(0), type)
const sendClickEvent = type('button') || type('image') || type('submit') || type('reset')
const fireClickEvent = (el) => {
const ctor = $dom.getDocumentFromElement(el).defaultView!.PointerEvent
const event = new ctor('click')
@@ -287,6 +282,8 @@ export default function (Commands, Cypress, cy, state, config) {
el.dispatchEvent(event)
}
let keydownEvents: any[] = []
return keyboard.type({
$el: options.$el,
chars,
@@ -332,21 +329,29 @@ export default function (Commands, Cypress, cy, state, config) {
updateTable(id, key, event, value)
}
if (event.type === 'keydown') {
keydownEvents.push(event.target)
}
if (
// Firefox sends a click event when the Space key is pressed.
// We don't want send it twice.
// We don't want to send it twice.
!Cypress.isBrowser('firefox') &&
// Click event is sent after keyup event with space key.
event.type === 'keyup' && event.code === 'Space' &&
// When event is prevented, the click event should not be emitted
!event.defaultPrevented &&
// Click events should be only sent to button-like elements.
// event.target is null when used with shadow DOM.
(event.target && $elements.isButtonLike(event.target)) &&
// When a space key is pressed for input radio elements, the click event is only fired when it's not checked.
!(event.target.tagName === 'INPUT' && event.target.type === 'radio' && event.target.checked === true) &&
// When event is prevented, the click event should not be emitted
!event.defaultPrevented
// When a space key is pressed on another element, the click event should not be fired.
keydownEvents.includes(event.target)
) {
fireClickEvent(event.target)
keydownEvents = []
}
},
@@ -391,6 +396,11 @@ export default function (Commands, Cypress, cy, state, config) {
return
}
// click event is only fired on button, image, submit, reset elements.
// That's why we cannot use $elements.isButtonLike() here.
const type = (type) => $elements.isInputType(el, type)
const sendClickEvent = type('button') || type('image') || type('submit') || type('reset')
// https://github.com/cypress-io/cypress/issues/19541
// Send click event on type('{enter}')
if (sendClickEvent) {
@@ -0,0 +1,51 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Courier+Prime&display=swap" rel="stylesheet">
<style>
body {
font-family: "Courier Prime", Courier, monospace;
padding: 0 1em;
line-height: 1.4;
color: #eee;
background-color: #111;
}
pre {
padding: 0 0;
margin: 0 0;
font-family: "Courier Prime", Courier, monospace;
}
body {
margin: 5px;
padding: 0;
overflow: hidden;
}
pre {
white-space: pre-wrap;
word-break: break-word;
-webkit-font-smoothing: antialiased;
}
</style>
</head>
<body><pre><span style="color:#e05561">The <span style="color:#e5e510">pluginsFile<span style="color:#e05561"> configuration option you have supplied has been replaced with <span style="color:#de73ff">setupNodeEvents<span style="color:#e05561">.<span style="color:#e6e6e6">
<span style="color:#e05561"><span style="color:#e6e6e6">
<span style="color:#e05561">This new option is not a one-to-one correlation and it must be configured separately as a testing type property: <span style="color:#de73ff">e2e.setupNodeEvents<span style="color:#e05561"> and <span style="color:#de73ff">component.setupNodeEvents<span style="color:#e05561"><span style="color:#e6e6e6">
<span style="color:#e05561"><span style="color:#e6e6e6">
<span style="color:#e05561"><span style="color:#4ec4ff">{<span style="color:#e05561"><span style="color:#e6e6e6">
<span style="color:#e05561"><span style="color:#4ec4ff"> e2e: {<span style="color:#e05561"><span style="color:#e6e6e6">
<span style="color:#e05561"><span style="color:#4ec4ff"> setupNodeEvents()<span style="color:#e05561"><span style="color:#e6e6e6">
<span style="color:#e05561"><span style="color:#4ec4ff"> },<span style="color:#e05561"><span style="color:#e6e6e6">
<span style="color:#e05561"><span style="color:#4ec4ff"> component: {<span style="color:#e05561"><span style="color:#e6e6e6">
<span style="color:#e05561"><span style="color:#4ec4ff"> setupNodeEvents()<span style="color:#e05561"><span style="color:#e6e6e6">
<span style="color:#e05561"><span style="color:#4ec4ff"> },<span style="color:#e05561"><span style="color:#e6e6e6">
<span style="color:#e05561"><span style="color:#4ec4ff">}<span style="color:#e05561"><span style="color:#e6e6e6">
<span style="color:#e05561"><span style="color:#e6e6e6">
<span style="color:#e05561">https://on.cypress.io/migration-guide<span style="color:#e6e6e6"></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span>
</pre></body></html>
@@ -45,8 +45,10 @@
<span style="color:#e05561"><span style="color:#4f5666"> - <span style="color:#e05561"><span style="color:#4ec4ff">foo<span style="color:#e05561"><span style="color:#e6e6e6">
<span style="color:#e05561"><span style="color:#4f5666"> - <span style="color:#e05561"><span style="color:#4ec4ff">bar<span style="color:#e05561"><span style="color:#e6e6e6">
<span style="color:#e05561"><span style="color:#4f5666"> - <span style="color:#e05561"><span style="color:#4ec4ff">baz<span style="color:#e05561"><span style="color:#e6e6e6">
<span style="color:#e05561"><span style="color:#e6e6e6">
<span style="color:#e05561">Learn more at https://docs.cypress.io/api/plugins/writing-a-plugin#config<span style="color:#e6e6e6">
<span style="color:#c062de"><span style="color:#e6e6e6">
<span style="color:#c062de">Error: fail whale<span style="color:#e6e6e6">
<span style="color:#c062de"> at makeErr (cypress/packages/errors/test/unit/visualSnapshotErrors_spec.ts)<span style="color:#e6e6e6">
<span style="color:#c062de"> at PLUGINS_INVALID_EVENT_NAME_ERROR (cypress/packages/errors/test/unit/visualSnapshotErrors_spec.ts)<span style="color:#e6e6e6"></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span>
<span style="color:#c062de"> at SETUP_NODE_EVENTS_INVALID_EVENT_NAME_ERROR (cypress/packages/errors/test/unit/visualSnapshotErrors_spec.ts)<span style="color:#e6e6e6"></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span>
</pre></body></html>
+2
View File
@@ -21,7 +21,9 @@
"strip-ansi": "6.0.0"
},
"devDependencies": {
"@packages/config": "0.0.0-development",
"@packages/ts": "0.0.0-development",
"@packages/types": "0.0.0-development",
"@types/chai": "4.2.15",
"@types/mocha": "8.2.2",
"@types/node": "14.14.31",
+35 -5
View File
@@ -4,7 +4,8 @@ import chalk from 'chalk'
import _ from 'lodash'
import path from 'path'
import stripAnsi from 'strip-ansi'
import type { BreakingErrResult, TestingType } from '@packages/types'
import type { TestingType } from '@packages/types'
import type { BreakingErrResult } from '@packages/config'
import { humanTime, logError, parseResolvedPattern, pluralize } from './errorUtils'
import { errPartial, errTemplate, fmt, theme, PartialErr } from './errTemplate'
@@ -645,9 +646,9 @@ export const AllCypressErrors = {
`
},
// TODO: make this relative path, not absolute
PLUGINS_INVALID_EVENT_NAME_ERROR: (pluginsFilePath: string, invalidEventName: string, validEventNames: string[], err: Error) => {
SETUP_NODE_EVENTS_INVALID_EVENT_NAME_ERROR: (configFilePath: string, invalidEventName: string, validEventNames: string[], err: Error) => {
return errTemplate`
Your ${fmt.highlightSecondary(`configFile`)} threw a validation error from: ${fmt.path(pluginsFilePath)}
Your ${fmt.highlightSecondary(`configFile`)} threw a validation error from: ${fmt.path(configFilePath)}
You must pass a valid event name when registering a plugin.
@@ -657,6 +658,8 @@ export const AllCypressErrors = {
${fmt.listItems(validEventNames)}
Learn more at https://docs.cypress.io/api/plugins/writing-a-plugin#config
${fmt.stackTrace(err)}
`
},
@@ -681,7 +684,7 @@ export const AllCypressErrors = {
},
// happens when there is an error in configuration file like "cypress.json"
// TODO: make this relative path, not absolute
CONFIG_VALIDATION_MSG_ERROR: (fileType: 'configFile' | 'pluginsFile' | null, fileName: string | null, validationMsg: string) => {
CONFIG_VALIDATION_MSG_ERROR: (fileType: 'configFile' | null, fileName: string | null, validationMsg: string) => {
if (!fileType) {
return errTemplate`
An invalid configuration value was set:
@@ -695,7 +698,7 @@ export const AllCypressErrors = {
${fmt.highlight(validationMsg)}`
},
// TODO: make this relative path, not absolute
CONFIG_VALIDATION_ERROR: (fileType: 'configFile' | 'pluginsFile' | null, filePath: string | null, validationResult: ConfigValidationFailureInfo) => {
CONFIG_VALIDATION_ERROR: (fileType: 'configFile' | null, filePath: string | null, validationResult: ConfigValidationFailureInfo) => {
const { key, type, value, list } = validationResult
if (!fileType) {
@@ -1060,6 +1063,12 @@ export const AllCypressErrors = {
You can safely remove this option from your config.`
},
EXPERIMENTAL_STUDIO_REMOVED: () => {
return errTemplate`\
We're ending the experimental phase of Cypress Studio in ${fmt.cypressVersion(`10.0.0`)} and have learned a lot. Stay tuned for updates on Studio's official release in the future. You can leave feedback here: http://on.cypress.io/studio-beta.
You can safely remove the ${fmt.highlight(`experimentalStudio`)} configuration option from your config.`
},
FIREFOX_GC_INTERVAL_REMOVED: () => {
return errTemplate`\
The ${fmt.highlight(`firefoxGcInterval`)} configuration option was removed in ${fmt.cypressVersion(`8.0.0`)}. It was introduced to work around a bug in Firefox 79 and below.
@@ -1210,6 +1219,27 @@ export const AllCypressErrors = {
`
},
PLUGINS_FILE_CONFIG_OPTION_REMOVED: (_errShape: BreakingErrResult) => {
const code = errPartial`
{
e2e: {
setupNodeEvents()
},
component: {
setupNodeEvents()
},
}`
return errTemplate`\
The ${fmt.highlight('pluginsFile')} configuration option you have supplied has been replaced with ${fmt.highlightSecondary('setupNodeEvents')}.
This new option is not a one-to-one correlation and it must be configured separately as a testing type property: ${fmt.highlightSecondary('e2e.setupNodeEvents')} and ${fmt.highlightSecondary('component.setupNodeEvents')}
${fmt.code(code)}
https://on.cypress.io/migration-guide`
},
CONFIG_FILE_INVALID_ROOT_CONFIG: (errShape: BreakingErrResult) => {
const code = errPartial`
{
@@ -683,7 +683,7 @@ describe('visual error templates', () => {
default: ['/path/to/cypress.config.js', err],
}
},
PLUGINS_INVALID_EVENT_NAME_ERROR: () => {
SETUP_NODE_EVENTS_INVALID_EVENT_NAME_ERROR: () => {
const err = makeErr()
return {
@@ -730,11 +730,6 @@ describe('visual error templates', () => {
type: 'a number',
value: [1, 2, 3],
}],
pluginsFile: ['pluginsFile', 'cypress/plugins/index.js', {
key: 'defaultCommandTimeout',
type: 'a number',
value: false,
}],
noFileType: [null, null, {
key: 'defaultCommandTimeout',
type: 'a number',
@@ -1006,6 +1001,11 @@ describe('visual error templates', () => {
default: [],
}
},
EXPERIMENTAL_STUDIO_REMOVED: () => {
return {
default: [],
}
},
FIREFOX_GC_INTERVAL_REMOVED: () => {
return {
default: [],
@@ -1067,6 +1067,11 @@ describe('visual error templates', () => {
default: ['spec.{ts,js}', ['support.ts', 'support.js']],
}
},
PLUGINS_FILE_CONFIG_OPTION_REMOVED: () => {
return {
default: [{ name: 'pluginsFile', configFile: '/path/to/cypress.config.js.ts' }],
}
},
CONFIG_FILE_INVALID_ROOT_CONFIG: () => {
return {
default: [{ name: 'specPattern', configFile: '/path/to/cypress.config.js.ts' }],
@@ -26,6 +26,7 @@ export const e2eProjectDirs = [
'downloads',
'e2e',
'empty-folders',
'experimental-studio',
'failures',
'firefox-memory',
'fixture-subfolder-of-integration',
@@ -158,11 +158,6 @@
"from": "default",
"field": "pageLoadTimeout"
},
{
"value": "cypress/e2e/plugins/index.ts",
"from": "config",
"field": "pluginsFile"
},
{
"value": null,
"from": "default",
@@ -286,8 +281,7 @@
{
"value": {
"specPattern": "cypress/e2e/**/*.cy.{js,jsx,ts,tsx}",
"supportFile": "cypress/e2e/support/e2eSupport.ts",
"pluginsFile": "cypress/e2e/plugins/index.ts"
"supportFile": "cypress/e2e/support/e2eSupport.ts"
},
"from": "config",
"field": "e2e"
@@ -296,7 +290,6 @@
"value": {
"specPattern": "**/*.cy.{js,jsx,ts,tsx}",
"supportFile": "cypress/component/support/index.ts",
"pluginsFile": "cypress/component/plugins/index.js",
"devServerConfig": {
"viteConfig": {
"optimizeDeps": {
@@ -34,37 +34,45 @@
</div>
<div v-else>
<Button
ref="loginButtonRef"
v-if="loginMutationIsPending"
size="lg"
:variant="buttonVariant"
:disabled="isLoggingIn || !isOnline"
variant="pending"
aria-live="polite"
@click="handleAuth"
:disabled="true"
>
<template
v-if="isLoggingIn"
#prefix
>
<i-cy-loading_x16
class="animate-spin icon-dark-white icon-light-gray-400"
/>
</template>
{{ buttonMessage }}
{{ browserOpened ? t('topNav.login.actionWaiting') : t('topNav.login.actionOpening') }}
</Button>
<Button
v-else
ref="loginButtonRef"
size="lg"
variant="primary"
aria-live="polite"
:disabled="!cloudViewer && !isOnline"
@click="handleLoginOrContinue"
>
{{ cloudViewer ? t('topNav.login.actionContinue') : t('topNav.login.actionLogin') }}
</Button>
</div>
</template>
<script lang="ts" setup>
import { computed, ref, onMounted } from 'vue'
import { computed, ref, onMounted, onBeforeUnmount } from 'vue'
import { gql } from '@urql/core'
import { useMutation, useQuery } from '@urql/vue'
import { useMutation } from '@urql/vue'
import { useOnline } from '@vueuse/core'
import {
Auth_LoginDocument,
Auth_LogoutDocument,
Auth_ResetAuthStateDocument,
Auth_BrowserOpenedDocument,
} from '../generated/graphql'
import type {
AuthFragment,
@@ -119,99 +127,76 @@ mutation Auth_ResetAuthState {
}
`
gql`
query Auth_BrowserOpened {
authState {
browserOpened
name
message
}
}
`
const login = useMutation(Auth_LoginDocument)
const logout = useMutation(Auth_LogoutDocument)
const reset = useMutation(Auth_ResetAuthStateDocument)
const loginButtonRef = ref(Button)
const loginInitiated = ref(false)
onMounted(() => {
loginButtonRef?.value?.$el?.focus()
})
const clickedOnce = ref(false)
onBeforeUnmount(() => {
// If a log in was initiated from this component instance, then the auth state
// may be polluted, due to the mutation still being fetched or due to
// errors returned during the login process. So a reset occurs when
// this instance unmounts to cover all scenarios where the LoginModal may be dismissed.
//
// We only perform the reset for the component that triggered a login
// to prevent state conflicts when LoginModals are presented within the launchpad
// and app simultaneously.
if (loginInitiated.value) {
reset.executeMutation({})
}
})
const emit = defineEmits<{
(event: 'continue', value: boolean): void
}>()
const viewer = computed(() => props.gql.cloudViewer)
const isBrowserOpened = computed(() => props.gql.authState.browserOpened)
const isLoggingIn = computed(() => clickedOnce.value && !viewer.value)
const query = useQuery({
query: Auth_BrowserOpenedDocument,
requestPolicy: 'cache-and-network',
const cloudViewer = computed(() => {
return props.gql.cloudViewer
})
const handleAuth = async () => {
if (viewer.value) {
const browserOpened = computed(() => {
return props.gql.authState.browserOpened
})
// We determine that a login is pending if there is no current cloudViewer and
// either a login has been initiated from this component, or the browser has been
// successfully opened.
//
// It is possible for the browser to be open but not due to actions by this component,
// particularly when LoginModals are presented in both the launchpad and app simultaneously.
const loginMutationIsPending = computed(() => {
return !cloudViewer.value && (loginInitiated.value || browserOpened.value)
})
const handleLoginOrContinue = async () => {
if (cloudViewer.value) {
emit('continue', true)
return
}
clickedOnce.value = true
loginInitiated.value = true
const browserCheckInterval = setInterval(async () => {
await query.executeQuery({})
if (isBrowserOpened.value) {
clearInterval(browserCheckInterval)
}
}, 1500)
await login.executeMutation({})
login.executeMutation({})
}
const handleLogout = async () => {
await logout.executeMutation({})
const handleLogout = () => {
logout.executeMutation({})
}
const buttonMessage = computed(() => {
if (!isBrowserOpened.value && isLoggingIn.value) {
return t('topNav.login.actionOpening')
}
const handleTryAgain = async () => {
await reset.executeMutation({})
if (!clickedOnce.value && !viewer.value) {
return t('topNav.login.actionLogin')
}
if (isLoggingIn.value) {
return t('topNav.login.actionWaiting')
}
if (viewer.value) {
return t('topNav.login.actionContinue')
}
// default
return t('topNav.login.actionLogin')
})
const buttonVariant = computed(() => {
if (clickedOnce.value && !viewer.value) {
return 'pending'
}
return 'primary'
})
const handleTryAgain = () => {
reset.executeMutation({})
login.executeMutation({})
}
const handleCancel = () => {
reset.executeMutation({})
emit('continue', true)
}
@@ -62,8 +62,8 @@ describe('<LoginModal />', { viewportWidth: 1000, viewportHeight: 750 }, () => {
},
})
cy.findByRole('button', { name: text.login.actionLogin }).click()
// The LoginModal immediately shows the "Waiting..." button
// if the browser already opened
cy.findByRole('button', { name: text.login.actionWaiting })
.should('be.visible')
.and('be.disabled')
@@ -93,8 +93,8 @@ describe('<LoginModal />', { viewportWidth: 1000, viewportHeight: 750 }, () => {
</div>),
})
cy.contains('button', text.login.actionTryAgain).should('not.exist')
cy.contains('button', text.login.actionCancel).should('not.exist')
cy.contains('button', text.login.actionTryAgain).should('not.be.visible')
cy.contains('button', text.login.actionCancel).should('not.be.visible')
cy.contains(text.login.titleBrowserError).should('be.visible')
cy.contains(text.login.bodyBrowserError).should('be.visible')
cy.contains(text.login.bodyBrowserErrorDetails).should('be.visible')
@@ -72,10 +72,9 @@
</div>
</div>
</DialogDescription>
<div
v-if="showFooter"
class="bg-gray-50 border-t-1px py-16px px-24px"
:class="{ 'hidden': !showFooter }"
>
<Auth
:gql="props.gql"
@@ -97,8 +96,6 @@ import { useOnline } from '@vueuse/core'
import NoInternetConnection from '../../components/NoInternetConnection.vue'
import CopyText from '@cy/components/CopyText.vue'
import StandardModalHeader from '@cy/components/StandardModalHeader.vue'
import { useMutation } from '@urql/vue'
import { LoginModal_ResetAuthStateDocument } from '../../generated/graphql'
import {
Dialog,
@@ -125,21 +122,8 @@ fragment LoginModal on Query {
}
`
gql`
mutation LoginModal_ResetAuthState {
resetAuthState {
...Auth
}
}
`
const resetAuth = useMutation(LoginModal_ResetAuthStateDocument)
const setIsOpen = (value: boolean) => {
emit('update:modelValue', value)
if (!value) {
resetAuth.executeMutation({})
}
}
const { t } = useI18n()
+11 -1
View File
@@ -388,6 +388,9 @@ type CurrentProject implements Node & ProjectLike {
"""The mode the interactive runner was launched in"""
currentTestingType: TestingTypeEnum
"""File extension to use based on if the project has typescript or not"""
fileExtensionToUse: FileExtensionEnum
"""Whether the project has Typescript"""
hasTypescript: Boolean
@@ -532,6 +535,7 @@ enum ErrorTypeEnum {
EXPERIMENTAL_RUN_EVENTS_REMOVED
EXPERIMENTAL_SAMESITE_REMOVED
EXPERIMENTAL_SHADOW_DOM_REMOVED
EXPERIMENTAL_STUDIO_REMOVED
EXTENSION_NOT_LOADED
FIREFOX_COULD_NOT_CONNECT
FIREFOX_GC_INTERVAL_REMOVED
@@ -565,7 +569,7 @@ enum ErrorTypeEnum {
PARALLEL_FEATURE_NOT_AVAILABLE_IN_PLAN
PLAN_EXCEEDS_MONTHLY_TESTS
PLAN_IN_GRACE_PERIOD_RUN_GROUPING_FEATURE_USED
PLUGINS_INVALID_EVENT_NAME_ERROR
PLUGINS_FILE_CONFIG_OPTION_REMOVED
PLUGINS_RUN_EVENT_ERROR
PORT_IN_USE_LONG
PORT_IN_USE_SHORT
@@ -577,6 +581,7 @@ enum ErrorTypeEnum {
RENDERER_CRASHED
RUN_GROUPING_FEATURE_NOT_AVAILABLE_IN_PLAN
SETUP_NODE_EVENTS_DO_NOT_SUPPORT_DEV_SERVER
SETUP_NODE_EVENTS_INVALID_EVENT_NAME_ERROR
SETUP_NODE_EVENTS_IS_NOT_FUNCTION
SUPPORT_FILE_NOT_FOUND
TESTS_DID_NOT_START_FAILED
@@ -628,6 +633,11 @@ input FileDetailsInput {
line: Int
}
enum FileExtensionEnum {
js
ts
}
"""Represents a file on the file system"""
type FileParts implements Node {
"""
@@ -0,0 +1,6 @@
import { enumType } from 'nexus'
export const FileExtensionEnum = enumType({
name: 'FileExtensionEnum',
members: ['js', 'ts'],
})
@@ -5,6 +5,7 @@ export * from './gql-BrowserFamilyEnum'
export * from './gql-BrowserStatus'
export * from './gql-CodeGenTypeEnum'
export * from './gql-ErrorTypeEnum'
export * from './gql-FileExtensionEnum'
export * from './gql-ProjectEnums'
export * from './gql-SpecEnum'
export * from './gql-WizardEnums'
@@ -1,7 +1,7 @@
import { PACKAGE_MANAGERS } from '@packages/types'
import { enumType, nonNull, objectType, stringArg } from 'nexus'
import path from 'path'
import { BrowserStatusEnum } from '..'
import { BrowserStatusEnum, FileExtensionEnum } from '..'
import { cloudProjectBySlug } from '../../stitching/remoteGraphQLCalls'
import { TestingTypeEnum } from '../enumTypes/gql-WizardEnums'
import { Browser } from './gql-Browser'
@@ -121,6 +121,14 @@ export const CurrentProject = objectType({
},
})
t.field('fileExtensionToUse', {
type: FileExtensionEnum,
description: 'File extension to use based on if the project has typescript or not',
resolve: (source, args, ctx) => {
return ctx.lifecycleManager.fileExtensionToUse
},
})
t.nonNull.list.nonNull.field('specs', {
description: 'A list of specs for the currently open testing type of a project',
type: Spec,
@@ -346,7 +346,7 @@ export const mutation = mutationType({
resolve (_, args, ctx) {
ctx.actions.auth.resetAuthState()
return ctx.appData
return {}
},
})
@@ -15,3 +15,15 @@ describe('baseUrl', () => {
cy.get('[data-cy="alert"]').should('not.exist')
})
})
describe('experimentalStudio', () => {
it('should show experimentalStudio warning if Cypress detects experimentalStudio config has been set', () => {
cy.scaffoldProject('experimental-studio')
cy.openProject('experimental-studio')
cy.visitLaunchpad()
cy.get('[data-cy="warning-alert"]').contains('Warning: Experimental Studio Removed')
cy.get('[data-cy-testingtype="e2e"]').click()
cy.get('[data-cy="warning-alert"]').contains('Warning: Experimental Studio Removed')
})
})
+3
View File
@@ -30,6 +30,7 @@
<Spinner />
</template>
<template v-else-if="!currentProject?.currentTestingType">
<WarningList :gql="query.data.value" />
<LaunchpadHeader
:title="t('welcomePage.title')"
description=""
@@ -79,6 +80,7 @@ import Wizard from './setup/Wizard.vue'
import ScaffoldLanguageSelect from './setup/ScaffoldLanguageSelect.vue'
import GlobalPage from './global/GlobalPage.vue'
import BaseError from './error/BaseError.vue'
import WarningList from './warning/WarningList.vue'
import StandardModal from '@cy/components/StandardModal.vue'
import HeaderBar from '@cy/gql-components/HeaderBar.vue'
import Spinner from '@cy/components/Spinner.vue'
@@ -114,6 +116,7 @@ fragment MainLaunchpadQueryData on Query {
isInGlobalMode
...GlobalPage
...ScaffoldedFiles
...WarningList
}
`
+3 -5
View File
@@ -4,7 +4,7 @@ import _ from 'lodash'
import path from 'path'
import deepDiff from 'return-deep-diff'
import type { ResolvedFromConfig, ResolvedConfigurationOptionSource, AllModeOptions, FullConfig } from '@packages/types'
import configUtils from '@packages/config'
import * as configUtils from '@packages/config'
import * as errors from './errors'
import { getProcessEnvVars, CYPRESS_SPECIAL_ENV_VARS } from './util/config'
import { fs } from './util/fs'
@@ -128,7 +128,7 @@ export function mergeDefaults (
.chain(configUtils.allowed({ ...cliConfig, ...options }))
.omit('env')
.omit('browsers')
.each((val, key) => {
.each((val: any, key) => {
// If users pass in testing-type specific keys (eg, specPattern),
// we want to merge this with what we've read from the config file,
// rather than override it entirely.
@@ -511,9 +511,7 @@ export function parseEnv (cfg: Record<string, any>, envCLI: Record<string, any>,
envCLI = envCLI != null ? envCLI : {}
const configFromEnv = _.reduce(envProc, (memo: string[], val, key) => {
let cfgKey: string
cfgKey = configUtils.matchesConfigKey(key)
const cfgKey = configUtils.matchesConfigKey(key)
if (cfgKey) {
// only change the value if it hasn't been
+1
View File
@@ -228,5 +228,6 @@ const start = (onMessage, utmCode, onLoginFlowComplete) => {
export = {
start,
stopServer,
_internal,
}
+3 -2
View File
@@ -1,7 +1,7 @@
import { DataContext, getCtx, clearCtx, setCtx } from '@packages/data-context'
import electron, { OpenDialogOptions, SaveDialogOptions, BrowserWindow } from 'electron'
import pkg from '@packages/root'
import configUtils from '@packages/config'
import * as configUtils from '@packages/config'
import { isListening } from './util/ensure-url'
import type {
@@ -63,6 +63,7 @@ export function makeDataContext (options: MakeDataContextOptions): DataContext {
updateWithPluginValues: config.updateWithPluginValues,
setupFullConfigWithDefaults: config.setupFullConfigWithDefaults,
validateRootConfigBreakingChanges: configUtils.validateNoBreakingConfigRoot,
validateLaunchpadConfigBreakingChanges: configUtils.validateNoBreakingConfigLaunchpad,
validateTestingTypeConfigBreakingChanges: configUtils.validateNoBreakingTestingTypeConfig,
},
appApi: {
@@ -92,7 +93,7 @@ export function makeDataContext (options: MakeDataContextOptions): DataContext {
return user.logOut()
},
resetAuthState () {
return ctx.actions.auth.resetAuthState()
auth.stopServer()
},
},
projectApi: {
@@ -66,7 +66,7 @@ class RunPlugins {
if (!isValid) {
const err = userEvents
? require('@packages/errors').getError('PLUGINS_INVALID_EVENT_NAME_ERROR', this.requiredFile, event, userEvents, error)
? require('@packages/errors').getError('SETUP_NODE_EVENTS_INVALID_EVENT_NAME_ERROR', this.requiredFile, event, userEvents, error)
: require('@packages/errors').getError('CONFIG_FILE_SETUP_NODE_EVENTS_ERROR', this.requiredFile, initialConfig.testingType, error)
this.ipc.send('setupTestingType:error', util.serializeError(err))
+2 -2
View File
@@ -11,10 +11,10 @@ const throwKnownError = function (message, props = {}) {
}
module.exports = {
run (pluginsFilePath, options) {
run (configFilePath, options) {
debug('run task', options.task, 'with arg', options.arg)
const fileText = pluginsFilePath ? `\n\nFix this in your setupNodeEvents method here:\n${pluginsFilePath}` : ''
const fileText = configFilePath ? `\n\nFix this in your setupNodeEvents method here:\n${configFilePath}` : ''
return Promise
.try(() => {
@@ -14,7 +14,7 @@ const pkg = require('@packages/root')
const detect = require('@packages/launcher/lib/detect')
const launch = require('@packages/launcher/lib/browsers')
const extension = require('@packages/extension')
const v = require('@packages/config/lib/validation')
const { validation: v } = require('@packages/config')
const argsUtil = require(`../../lib/util/args`)
const { fs } = require(`../../lib/util/fs`)
+11 -24
View File
@@ -517,26 +517,6 @@ describe('lib/config', () => {
})
})
context('pluginsFile', () => {
it('passes if a string', function () {
this.setup({ pluginsFile: 'cypress/plugins' })
return this.expectValidationPasses()
})
it('passes if false', function () {
this.setup({ pluginsFile: false })
return this.expectValidationPasses()
})
it('fails if not a string or false', function () {
this.setup({ pluginsFile: 42 })
return this.expectValidationFails('be a string')
})
})
context('port', () => {
it('passes if a number', function () {
this.setup({ port: 10 })
@@ -1441,6 +1421,16 @@ describe('lib/config', () => {
expect(warning).to.be.calledWith('EXPERIMENTAL_RUN_EVENTS_REMOVED')
})
it('warns if experimentalStudio is passed', async function () {
const warning = sinon.spy(errors, 'warning')
await this.defaults('experimentalStudio', true, {
experimentalStudio: true,
})
expect(warning).to.be.calledWith('EXPERIMENTAL_STUDIO_REMOVED')
})
// @see https://github.com/cypress-io/cypress/pull/9185
it('warns if experimentalNetworkStubbing is passed', async function () {
const warning = sinon.spy(errors, 'warning')
@@ -1506,7 +1496,6 @@ describe('lib/config', () => {
modifyObstructiveCode: { value: true, from: 'default' },
numTestsKeptInMemory: { value: 50, from: 'default' },
pageLoadTimeout: { value: 60000, from: 'default' },
pluginsFile: { value: 'cypress/plugins', from: 'default' },
port: { value: 1234, from: 'cli' },
projectId: { value: null, from: 'default' },
redirectionLimit: { value: 20, from: 'default' },
@@ -1617,7 +1606,6 @@ describe('lib/config', () => {
modifyObstructiveCode: { value: true, from: 'default' },
numTestsKeptInMemory: { value: 50, from: 'default' },
pageLoadTimeout: { value: 60000, from: 'default' },
pluginsFile: { value: 'cypress/plugins', from: 'default' },
port: { value: 2020, from: 'config' },
projectId: { value: 'projectId123', from: 'env' },
redirectionLimit: { value: 20, from: 'default' },
@@ -1875,7 +1863,6 @@ describe('lib/config', () => {
const cfg = {
projectRoot: '/foo/bar',
pluginsFile: '/foo/bar/cypress/plugins/index.js',
browsers: [browser],
resolved: {
browsers: {
@@ -2218,7 +2205,7 @@ describe('lib/config', () => {
expect(config.setAbsolutePaths(obj)).to.deep.eq(obj)
})
return ['fileServerFolder', 'fixturesFolder', 'supportFile', 'pluginsFile'].forEach((folder) => {
return ['fileServerFolder', 'fixturesFolder', 'supportFile'].forEach((folder) => {
it(`converts relative ${folder} to absolute path`, () => {
const obj = {
projectRoot: '/_test-output/path/to/project',
@@ -216,7 +216,7 @@ describe.skip('lib/plugins/child/run_plugins', () => {
})
})
it('sends error if pluginsFile function rejects the promise', function (done) {
it('sends error if setupNodeEvents function rejects the promise', function (done) {
const err = new Error('foo')
const setupNodeEventsFn = sinon.stub().rejects(err)
+13 -13
View File
@@ -6,20 +6,20 @@ const task = require(`../../lib/task`)
describe('lib/task', () => {
beforeEach(function () {
this.pluginsFile = 'cypress/plugins'
this.configFilePath = 'cypress.config.js'
sinon.stub(plugins, 'execute').resolves('result')
return sinon.stub(plugins, 'has').returns(true)
})
it('executes the \'task\' plugin', function () {
return task.run(this.pluginsFile, { task: 'some:task', arg: 'some:arg', timeout: 1000 }).then(() => {
return task.run(this.configFilePath, { task: 'some:task', arg: 'some:arg', timeout: 1000 }).then(() => {
expect(plugins.execute).to.be.calledWith('task', 'some:task', 'some:arg')
})
})
it('resolves the result of the \'task\' plugin', function () {
return task.run(this.pluginsFile, { task: 'some:task', arg: 'some:arg', timeout: 1000 }).then((result) => {
return task.run(this.configFilePath, { task: 'some:task', arg: 'some:arg', timeout: 1000 }).then((result) => {
expect(result).to.equal('result')
})
})
@@ -27,8 +27,8 @@ describe('lib/task', () => {
it('throws if \'task\' event is not registered', function () {
plugins.has.returns(false)
return task.run(this.pluginsFile, { timeout: 1000 }).catch((err) => {
expect(err.message).to.equal(`The 'task' event has not been registered in the setupNodeEvents method. You must register it before using cy.task()\n\nFix this in your setupNodeEvents method here:\n${this.pluginsFile}`)
return task.run(this.configFilePath, { timeout: 1000 }).catch((err) => {
expect(err.message).to.equal(`The 'task' event has not been registered in the setupNodeEvents method. You must register it before using cy.task()\n\nFix this in your setupNodeEvents method here:\n${this.configFilePath}`)
})
})
@@ -36,8 +36,8 @@ describe('lib/task', () => {
plugins.execute.withArgs('task').resolves('__cypress_unhandled__')
plugins.execute.withArgs('_get:task:keys').resolves(['foo', 'bar'])
return task.run(this.pluginsFile, { task: 'some:task', arg: 'some:arg', timeout: 1000 }).catch((err) => {
expect(err.message).to.equal(`The task 'some:task' was not handled in the setupNodeEvents method. The following tasks are registered: foo, bar\n\nFix this in your setupNodeEvents method here:\n${this.pluginsFile}`)
return task.run(this.configFilePath, { task: 'some:task', arg: 'some:arg', timeout: 1000 }).catch((err) => {
expect(err.message).to.equal(`The task 'some:task' was not handled in the setupNodeEvents method. The following tasks are registered: foo, bar\n\nFix this in your setupNodeEvents method here:\n${this.configFilePath}`)
})
})
@@ -45,8 +45,8 @@ describe('lib/task', () => {
plugins.execute.withArgs('task').resolves(undefined)
plugins.execute.withArgs('_get:task:body').resolves('function () {}')
return task.run(this.pluginsFile, { task: 'some:task', arg: 'some:arg', timeout: 1000 }).catch((err) => {
expect(err.message).to.equal(`The task 'some:task' returned undefined. You must return a value, null, or a promise that resolves to a value or null to indicate that the task was handled.\n\nThe task handler was:\n\nfunction () {}\n\nFix this in your setupNodeEvents method here:\n${this.pluginsFile}`)
return task.run(this.configFilePath, { task: 'some:task', arg: 'some:arg', timeout: 1000 }).catch((err) => {
expect(err.message).to.equal(`The task 'some:task' returned undefined. You must return a value, null, or a promise that resolves to a value or null to indicate that the task was handled.\n\nThe task handler was:\n\nfunction () {}\n\nFix this in your setupNodeEvents method here:\n${this.configFilePath}`)
})
})
@@ -54,8 +54,8 @@ describe('lib/task', () => {
plugins.execute.withArgs('task').resolves(undefined)
plugins.execute.withArgs('_get:task:body').resolves('')
return task.run(this.pluginsFile, { task: 'some:task', arg: 'some:arg', timeout: 1000 }).catch((err) => {
expect(err.message).to.equal(`The task 'some:task' returned undefined. You must return a value, null, or a promise that resolves to a value or null to indicate that the task was handled.\n\nFix this in your setupNodeEvents method here:\n${this.pluginsFile}`)
return task.run(this.configFilePath, { task: 'some:task', arg: 'some:arg', timeout: 1000 }).catch((err) => {
expect(err.message).to.equal(`The task 'some:task' returned undefined. You must return a value, null, or a promise that resolves to a value or null to indicate that the task was handled.\n\nFix this in your setupNodeEvents method here:\n${this.configFilePath}`)
})
})
@@ -63,8 +63,8 @@ describe('lib/task', () => {
plugins.execute.withArgs('task').resolves(Promise.delay(250))
plugins.execute.withArgs('_get:task:body').resolves('function () {}')
return task.run(this.pluginsFile, { task: 'some:task', arg: 'some:arg', timeout: 10 }).catch((err) => {
expect(err.message).to.equal(`The task handler was:\n\nfunction () {}\n\nFix this in your setupNodeEvents method here:\n${this.pluginsFile}`)
return task.run(this.configFilePath, { task: 'some:task', arg: 'some:arg', timeout: 10 }).catch((err) => {
expect(err.message).to.equal(`The task handler was:\n\nfunction () {}\n\nFix this in your setupNodeEvents method here:\n${this.configFilePath}`)
})
})
})
+2 -1
View File
@@ -29,7 +29,7 @@ export interface FullConfig extends Partial<Cypress.RuntimeConfigOptions & Cypre
// and are required when creating a project.
export type ReceivedCypressOptions =
Pick<Cypress.RuntimeConfigOptions, 'hosts' | 'projectName' | 'clientRoute' | 'devServerPublicPathRoute' | 'namespace' | 'report' | 'socketIoCookie' | 'configFile' | 'isTextTerminal' | 'isNewProject' | 'proxyUrl' | 'browsers' | 'browserUrl' | 'socketIoRoute' | 'arch' | 'platform' | 'spec' | 'specs' | 'browser' | 'version' | 'remote'>
& Pick<Cypress.ResolvedConfigOptions, 'chromeWebSecurity' | 'supportFolder' | 'experimentalSourceRewriting' | 'fixturesFolder' | 'reporter' | 'reporterOptions' | 'screenshotsFolder' | 'pluginsFile' | 'supportFile' | 'baseUrl' | 'viewportHeight' | 'viewportWidth' | 'port' | 'experimentalInteractiveRunEvents' | 'userAgent' | 'downloadsFolder' | 'env' | 'excludeSpecPattern' | 'specPattern'> // TODO: Figure out how to type this better.
& Pick<Cypress.ResolvedConfigOptions, 'chromeWebSecurity' | 'supportFolder' | 'experimentalSourceRewriting' | 'fixturesFolder' | 'reporter' | 'reporterOptions' | 'screenshotsFolder' | 'supportFile' | 'baseUrl' | 'viewportHeight' | 'viewportWidth' | 'port' | 'experimentalInteractiveRunEvents' | 'userAgent' | 'downloadsFolder' | 'env' | 'excludeSpecPattern' | 'specPattern'> // TODO: Figure out how to type this better.
export interface SampleConfigFile{
status: 'changes' | 'valid' | 'skipped' | 'error'
@@ -54,6 +54,7 @@ export type BreakingOption =
| 'EXPERIMENTAL_NETWORK_STUBBING_REMOVED'
| 'EXPERIMENTAL_RUN_EVENTS_REMOVED'
| 'EXPERIMENTAL_SHADOW_DOM_REMOVED'
| 'EXPERIMENTAL_STUDIO_REMOVED'
| 'FIREFOX_GC_INTERVAL_REMOVED'
| 'NODE_VERSION_DEPRECATION_SYSTEM'
| 'NODE_VERSION_DEPRECATION_BUNDLED'
+2 -59
View File
@@ -218,65 +218,6 @@ exports['e2e plugins / works with user extensions'] = `
All specs passed! XX:XX 1 1 - - -
`
exports['e2e plugins handles absolute path to pluginsFile 1'] = `
====================================================================================================
(Run Starting)
Cypress: 1.2.3
Browser: FooBrowser 88
Specs: 1 found (absolute.cy.js)
Searched: cypress/e2e/absolute.cy.js
Running: absolute.cy.js (1 of 1)
uses the plugins file
1 passing
(Results)
Tests: 1
Passing: 1
Failing: 0
Pending: 0
Skipped: 0
Screenshots: 0
Video: true
Duration: X seconds
Spec Ran: absolute.cy.js
(Video)
- Started processing: Compressing to 32 CRF
- Finished processing: /XXX/XXX/XXX/cypress/videos/absolute.cy.js.mp4 (X second)
====================================================================================================
(Run Finished)
Spec Tests Passing Failing Pending Skipped
absolute.cy.js XX:XX 1 1 - - -
All specs passed! XX:XX 1 1 - - -
`
exports['e2e plugins calls after:screenshot for cy.screenshot() and failure screenshots 1'] = `
@@ -437,6 +378,8 @@ The following are valid events:
- file:preprocessor
- task
Learn more at https://docs.cypress.io/api/plugins/writing-a-plugin#config
InvalidEventNameError: invalid event name registered: invalid:event
[stack trace lines]
`
@@ -1,6 +1,5 @@
module.exports = {
'defaultCommandTimeout': 1000,
'pluginsFile': false,
'e2e': {
'supportFile': false,
},
@@ -1,6 +1,5 @@
module.exports = {
'fixturesFolder': false,
'pluginsFile': false,
'e2e': {
'supportFile': false,
},
@@ -0,0 +1,6 @@
module.exports = {
experimentalStudio: true,
e2e: {
supportFile: false,
},
}
@@ -1,5 +1,4 @@
module.exports = {
'pluginsFile': false,
'e2e': {
'fixturesFolder': 'cypress/fixtures',
'specPattern': 'cypress/**/*.cy.js',
@@ -1,5 +1,4 @@
module.exports = {
pluginsFile: false,
e2e: {
setupNodeEvents: (on, config) => config,
supportFile: false,
@@ -1,5 +1,4 @@
module.exports = {
'pluginsFile': false,
'fixturesFolder': 'cypress/e2e',
'e2e': {
'supportFile': false,
@@ -1,6 +1,5 @@
module.exports = {
'includeShadowDom': true,
'pluginsFile': false,
'e2e': {
'supportFile': false,
},
@@ -2,7 +2,6 @@ module.exports = {
'fixturesFolder': 'tests/_fixtures',
'port': 8888,
'projectId': 'abc123',
'pluginsFile': false,
'component': {
'specFilePattern': 'src/**/*.spec.cy.js',
'supportFile': 'tests/_support/spec_helper.js',
+1 -1
View File
@@ -23,7 +23,7 @@ describe('e2e downloads', () => {
systemTests.it('allows changing the downloads folder', {
project: 'downloads',
spec: '*',
spec: 'downloads.cy.ts',
config: {
downloadsFolder: 'cypress/dls',
video: false,
-17
View File
@@ -106,23 +106,6 @@ describe('e2e plugins', function () {
snapshot: true,
})
it('handles absolute path to pluginsFile', function () {
const pluginsAbsolutePath = Fixtures.projectPath('plugins-absolute-path')
return systemTests.exec(this, {
spec: 'absolute.cy.js',
config: {
pluginsFile: path.join(
pluginsAbsolutePath,
'cypress/plugins/index.js',
),
},
project: 'plugins-absolute-path',
sanitizeScreenshotDimensions: true,
snapshot: true,
})
})
const pluginAfterScreenshot = 'plugin-after-screenshot'
it('calls after:screenshot for cy.screenshot() and failure screenshots', function () {