feat: convert cypress.json to cypress.config.js (#19748)

Co-authored-by: Barthélémy Ledoux <bart@cypress.io>
Co-authored-by: ElevateBart <ledouxb@gmail.com>
This commit is contained in:
Cesar
2022-01-20 14:43:09 -07:00
committed by GitHub
parent e8e9562982
commit bdc7c01d62
22 changed files with 570 additions and 91 deletions

View File

@@ -0,0 +1,72 @@
exports['migration utils should create a string when passed only a global option 1'] = `
const { defineConfig } = require('cypress')
module.export = defineConfig({
visualViewport: 300,
})
`
exports['migration utils should create a string when passed an empty object 1'] = `
const { defineConfig } = require('cypress')
module.export = defineConfig({
})
`
exports['migration utils should create a string when passed only a e2e options 1'] = `
const { defineConfig } = require('cypress')
module.export = defineConfig({
e2e: {
setupNodeEvents(on, config) {
return require('/cypress/plugins/index.js')
},
baseUrl: 'localhost:3000'
},
})
`
exports['migration utils should create a string for a config with global, component, and e2e options 1'] = `
const { defineConfig } = require('cypress')
module.export = defineConfig({
visualViewport: 300,
e2e: {
setupNodeEvents(on, config) {
return require('/cypress/plugins/index.js')
},
baseUrl: 'localhost:300',
retries: 2
},
component: {
setupNodeEvents(on, config) {
return require('/cypress/plugins/index.js')
},
retries: 1
},
})
`
exports['migration utils should create a string when passed only a component options 1'] = `
const { defineConfig } = require('cypress')
module.export = defineConfig({
component: {
setupNodeEvents(on, config) {
return require('/cypress/plugins/index.js')
},
retries: 2
},
})
`
exports['migration utils should exclude fields that are no longer valid 1'] = `
const { defineConfig } = require('cypress')
module.export = defineConfig({
})
`

View File

@@ -6,6 +6,7 @@ import {
FileActions,
ProjectActions,
WizardActions,
MigrationActions,
} from './actions'
import { AuthActions } from './actions/AuthActions'
import { DevActions } from './actions/DevActions'
@@ -53,4 +54,9 @@ export class DataActions {
get electron () {
return new ElectronActions(this.ctx)
}
@cached
get migration () {
return new MigrationActions(this.ctx)
}
}

View File

@@ -28,6 +28,7 @@ import {
HtmlDataSource,
UtilDataSource,
BrowserApiShape,
MigrationDataSource,
} from './sources/'
import { cached } from './util/cached'
import type { GraphQLSchema } from 'graphql'
@@ -225,6 +226,11 @@ export class DataContext {
return new UtilDataSource(this)
}
@cached
get migration () {
return new MigrationDataSource(this)
}
get projectsList () {
return this.coreData.app.projects
}

View File

@@ -0,0 +1,19 @@
import type { DataContext } from '..'
export class MigrationActions {
constructor (private ctx: DataContext) { }
async createConfigFile () {
const config = await this.ctx.migration.createConfigString()
await this.ctx.actions.file.writeFileInProject('cypress.config.js', config).catch((error) => {
throw error
})
this.ctx.lifecycleManager.refreshMetaState()
await this.ctx.actions.file.removeFileInProject('cypress.json').catch((error) => {
throw error
})
}
}

View File

@@ -8,5 +8,6 @@ export * from './DevActions'
export * from './ElectronActions'
export * from './FileActions'
export * from './LocalSettingsActions'
export * from './MigrationActions'
export * from './ProjectActions'
export * from './WizardActions'

View File

@@ -74,6 +74,16 @@ export class FileDataSource {
}
}
isValidJsFile (absolutePath: string) {
try {
require(absolutePath)
return true
} catch {
return false
}
}
private trackFile () {
// this.watchedFilePaths.clear()
// this.fileLoader.clear()

View File

@@ -0,0 +1,64 @@
import path from 'path'
import type { DataContext } from '..'
import { createConfigString } from '../util/migration'
export class MigrationDataSource {
private _config: Cypress.ConfigOptions | null = null
constructor (private ctx: DataContext) { }
async getConfig () {
const config = await this.parseCypressConfig()
return JSON.stringify(config, null, 2)
}
async createConfigString () {
const config = await this.parseCypressConfig()
return createConfigString(config)
}
async getIntegrationFolder () {
const config = await this.parseCypressConfig()
if (config.e2e?.integrationFolder) {
return config.e2e.integrationFolder
}
if (config.integrationFolder) {
return config.integrationFolder
}
return 'cypress/integration'
}
async getComponentFolder () {
const config = await this.parseCypressConfig()
if (config.component?.componentFolder) {
return config.component.componentFolder
}
if (config.componentFolder) {
return config.componentFolder
}
return 'cypress/component'
}
private async parseCypressConfig (): Promise<Cypress.ConfigOptions> {
if (this._config) {
return this._config
}
if (this.ctx.lifecycleManager.metaState.hasLegacyCypressJson) {
const cfgPath = path.join(this.ctx.lifecycleManager?.projectRoot, 'cypress.json')
this._config = this.ctx.file.readJsonFile(cfgPath) as Cypress.ConfigOptions
return this._config
}
return {}
}
}

View File

@@ -8,6 +8,7 @@ export * from './FileDataSource'
export * from './GitDataSource'
export * from './GraphQLDataSource'
export * from './HtmlDataSource'
export * from './MigrationDataSource'
export * from './ProjectDataSource'
export * from './SettingsDataSource'
export * from './StorybookDataSource'

View File

@@ -6,4 +6,5 @@ export * from './cached'
export * from './config-file-updater'
export * from './config-options'
export * from './file'
export * from './migration'
export * from './urqlCacheKeys'

View File

@@ -0,0 +1,80 @@
import path from 'path'
type ConfigOptions = {
global: Record<string, unknown>
e2e: Record<string, unknown>
component: Record<string, unknown>
}
export async function createConfigString (cfg: Partial<Cypress.ConfigOptions>) {
return createCypressConfigJs(reduceConfig(cfg), getPluginRelativePath(cfg))
}
function getPluginRelativePath (cfg: Partial<Cypress.ConfigOptions>) {
const DEFAULT_PLUGIN_PATH = path.normalize('/cypress/plugins/index.js')
return cfg.pluginsFile ? cfg.pluginsFile : DEFAULT_PLUGIN_PATH
}
function reduceConfig (cfg: Partial<Cypress.ConfigOptions>) {
const excludedFields = ['pluginsFile', '$schema', 'componentFolder']
return Object.entries(cfg).reduce((acc, [key, val]) => {
if (excludedFields.includes(key)) {
return acc
}
if (key === 'e2e' || key === 'component') {
const value = val as Record<string, unknown>
return { ...acc, [key]: { ...acc[key], ...value } }
}
if (key === 'testFiles') {
return {
...acc,
e2e: { ...acc.e2e, specPattern: val },
component: { ...acc.component, specPattern: val },
}
}
if (key === 'baseUrl') {
return {
...acc,
e2e: { ...acc.e2e, [key]: val },
}
}
return { ...acc, global: { ...acc.global, [key]: val } }
}, { global: {}, e2e: {}, component: {} })
}
function createCypressConfigJs (config: ConfigOptions, pluginPath: string) {
const globalString = Object.keys(config.global).length > 0 ? `${formatObjectForConfig(config.global, 2)},` : ''
const componentString = Object.keys(config.component).length > 0 ? createTestingTypeTemplate('component', pluginPath, config.component) : ''
const e2eString = Object.keys(config.e2e).length > 0 ? createTestingTypeTemplate('e2e', pluginPath, config.e2e) : ''
return `const { defineConfig } = require('cypress')
module.export = defineConfig({
${globalString}${e2eString}${componentString}
})`
}
function formatObjectForConfig (obj: Record<string, unknown>, spaces: number) {
return JSON.stringify(obj, null, spaces)
.replace(/"([^"]+)":/g, '$1:') // remove quotes from fields
.replace(/^[{]|[}]$/g, '') // remove opening and closing {}
.replace(/"/g, '\'') // single quotes
.trim()
}
function createTestingTypeTemplate (testingType: 'e2e' | 'component', pluginPath: string, options: Record<string, unknown>) {
return `
${testingType}: {
setupNodeEvents(on, config) {
return require('${pluginPath}')
},
${formatObjectForConfig(options, 4)}
},`
}

View File

@@ -0,0 +1,75 @@
import snapshot from 'snap-shot-it'
import { createConfigString } from '../../src/util/migration'
describe('migration utils', () => {
it('should create a string when passed only a global option', async () => {
const config = {
visualViewport: 300,
}
const generatedConfig = await createConfigString(config)
snapshot(generatedConfig)
})
it('should create a string when passed only a e2e options', async () => {
const config = {
e2e: {
baseUrl: 'localhost:3000',
},
}
const generatedConfig = await createConfigString(config)
snapshot(generatedConfig)
})
it('should create a string when passed only a component options', async () => {
const config = {
component: {
retries: 2,
},
}
const generatedConfig = await createConfigString(config)
snapshot(generatedConfig)
})
it('should create a string for a config with global, component, and e2e options', async () => {
const config = {
visualViewport: 300,
baseUrl: 'localhost:300',
e2e: {
retries: 2,
},
component: {
retries: 1,
},
}
const generatedConfig = await createConfigString(config)
snapshot(generatedConfig)
})
it('should create a string when passed an empty object', async () => {
const config = {}
const generatedConfig = await createConfigString(config)
snapshot(generatedConfig)
})
it('should exclude fields that are no longer valid', async () => {
const config = {
'$schema': 'http://someschema.com',
pluginsFile: 'path/to/plugin/file',
componentFolder: 'path/to/component/folder',
}
const generatedConfig = await createConfigString(config)
snapshot(generatedConfig)
})
})

View File

@@ -29,6 +29,7 @@ export const e2eProjectDirs = [
'kill-child-process',
'launchpad',
'max-listeners',
'migration',
'multiple-task-registrations',
'multiples-config-files-with-json',
'no-scaffolding',

View File

@@ -47,4 +47,6 @@ export const stubMigration: MaybeResolver<Migration> = {
}
},
})`,
integrationFolder: 'cypress/integration',
componentFolder: 'cypress/component',
}

View File

@@ -543,12 +543,18 @@ type LocalSettingsPreferences {
"""Contains all data related to the 9.X to 10.0 migration UI"""
type Migration {
"""the component folder path used to store components tests"""
componentFolder: String!
"""contents of the cypress.json file after conversion"""
configAfterCode: String!
"""contents of the cypress.json file before conversion"""
configBeforeCode: String!
"""the integration folder path used to store e2e tests"""
integrationFolder: String!
"""List of files needing manual conversion"""
manualFiles: [String!]!
@@ -619,6 +625,9 @@ type Mutation {
"""Check if a give spec file will match the project spec pattern"""
matchesSpecPattern(specFile: String!): Boolean!
"""Transforms cypress.json file into cypress.config.js file"""
migrateConfigFile: Boolean
"""Open a path in preferred IDE"""
openDirectoryInIDE(path: String!): Boolean
openExternal(url: String!): Boolean

View File

@@ -14,7 +14,7 @@ export const Migration = objectType({
t.nonNull.field('step', {
type: MigrationStepEnum,
description: 'Step where the migration is right now',
resolve: () => 'renameManual',
resolve: () => 'configFile',
})
t.nonNull.list.nonNull.string('specFilesBefore', {
@@ -40,15 +40,29 @@ export const Migration = objectType({
t.nonNull.string('configBeforeCode', {
description: 'contents of the cypress.json file before conversion',
resolve: () => {
return ``
resolve: (source, args, ctx) => {
return ctx.migration.getConfig()
},
})
t.nonNull.string('configAfterCode', {
description: 'contents of the cypress.json file after conversion',
resolve: () => {
return ``
resolve: (source, args, ctx) => {
return ctx.migration.createConfigString()
},
})
t.nonNull.string('integrationFolder', {
description: 'the integration folder path used to store e2e tests',
resolve: (source, args, ctx) => {
return ctx.migration.getIntegrationFolder()
},
})
t.nonNull.string('componentFolder', {
description: 'the component folder path used to store components tests',
resolve: (source, args, ctx) => {
return ctx.migration.getComponentFolder()
},
})
},

View File

@@ -419,6 +419,20 @@ export const mutation = mutationType({
},
})
t.field('migrateConfigFile', {
description: 'Transforms cypress.json file into cypress.config.js file',
type: 'Boolean',
resolve: async (_, args, ctx) => {
try {
await ctx.actions.migration.createConfigFile()
return true
} catch {
return false
}
},
})
t.field('setProjectIdInConfigFile', {
description: 'Set the projectId field in the config file of the current project',
type: Query,

View File

@@ -0,0 +1,41 @@
describe('Migration', () => {
beforeEach(() => {
cy.scaffoldProject('migration')
cy.openProject('migration')
cy.visitLaunchpad()
})
describe('Configuration', () => {
it('should create the cypress.config.js file and delete old config', () => {
cy.get('[data-cy="convertConfigButton"]').click()
cy.withCtx(async (ctx) => {
const stats = await ctx.actions.file.checkIfFileExists('cypress.config.js')
expect(stats).to.not.be.null.and.not.be.undefined
let doesFileExist = true
try {
await ctx.actions.file.checkIfFileExists('cypress.json')
} catch (error) {
doesFileExist = false
}
expect(doesFileExist).to.be.false
})
})
it('should create a valid js file', () => {
cy.get('[data-cy="convertConfigButton"]').click()
cy.withCtx(async (ctx) => {
const configPath = ctx.path.join(ctx.lifecycleManager.projectRoot, 'cypress.config.js')
const isValidJsFile = ctx.file.isValidJsFile(configPath)
expect(isValidJsFile).to.be.true
})
})
})
})

View File

@@ -13,8 +13,7 @@
:gql="query.data.value"
/>
<MigrationWizard
v-else-if="currentProject?.needsLegacyConfigMigration && query.data.value.migration"
:gql="query.data.value.migration"
v-else-if="currentProject?.needsLegacyConfigMigration"
/>
<template v-else>
<ScaffoldedFiles
@@ -121,9 +120,6 @@ fragment MainLaunchpadQueryData on Query {
isInGlobalMode
...GlobalPage
...ScaffoldedFiles
migration {
...MigrationWizard
}
}
`

View File

@@ -1,41 +1,66 @@
import { MigrationWizardFragmentDoc } from '../generated/graphql-test'
import { MigrationWizardDataFragmentDoc } from '../generated/graphql-test'
import MigrationWizard from './MigrationWizard.vue'
describe('<MigrationWizard/>', { viewportWidth: 1280, viewportHeight: 1100 }, () => {
it('renders Automatic rename', () => {
cy.mountFragment(MigrationWizardFragmentDoc, {
cy.mountFragment(MigrationWizardDataFragmentDoc, {
onResult (res) {
res.step = 'renameAuto'
res.migration = {
__typename: 'Migration',
step: 'renameAuto',
specFilesAfter: ['test.cy.tsx'],
specFilesBefore: ['test.spec.tsx'],
manualFiles: ['test.cy.tsx'],
configAfterCode: '{}',
configBeforeCode: '{}',
}
},
render (gql) {
render () {
return (<div class="p-16px">
<MigrationWizard gql={gql} />
<MigrationWizard />
</div>)
},
})
})
it('renders Manual rename', () => {
cy.mountFragment(MigrationWizardFragmentDoc, {
cy.mountFragment(MigrationWizardDataFragmentDoc, {
onResult (res) {
res.step = 'renameManual'
res.migration = {
__typename: 'Migration',
step: 'renameManual',
specFilesAfter: ['test.cy.tsx'],
specFilesBefore: ['test.spec.tsx'],
manualFiles: ['test.cy.tsx'],
configAfterCode: '{}',
configBeforeCode: '{}',
}
},
render (gql) {
render () {
return (<div class="p-16px">
<MigrationWizard gql={gql} />
<MigrationWizard />
</div>)
},
})
})
it('renders Config File migration', () => {
cy.mountFragment(MigrationWizardFragmentDoc, {
cy.mountFragment(MigrationWizardDataFragmentDoc, {
onResult (res) {
res.step = 'configFile'
res.migration = {
...res.migration,
__typename: 'Migration',
step: 'configFile',
specFilesAfter: ['test.cy.tsx'],
specFilesBefore: ['test.spec.tsx'],
manualFiles: ['test.cy.tsx'],
configAfterCode: '{}',
configBeforeCode: '{}',
}
},
render (gql) {
render () {
return (<div class="p-16px">
<MigrationWizard gql={gql} />
<MigrationWizard />
</div>)
},
})

View File

@@ -7,69 +7,71 @@
>
{{ t('migration.wizard.description') }}
</p>
<MigrationStep
:open="props.gql.step === 'renameAuto'"
:checked="props.gql.step !== 'renameAuto'"
:step="1"
:title="t('migration.wizard.step1.title')"
:description="t('migration.wizard.step1.description')"
>
<RenameSpecsAuto :gql="props.gql" />
<template #footer>
<Button
@click="renameSpecs"
>
{{ t('migration.wizard.step1.button') }}
</Button>
</template>
</MigrationStep>
<MigrationStep
:open="props.gql.step === 'renameManual'"
:checked="props.gql.step === 'configFile'"
:step="2"
:title="t('migration.wizard.step2.title')"
:description="t('migration.wizard.step2.description')"
>
<RenameSpecsManual :gql="props.gql" />
<template #footer>
<div class="flex gap-16px">
<template v-if="query.data.value?.migration">
<MigrationStep
:open="migration.step === 'renameAuto'"
:checked="migration.step !== 'renameAuto'"
:step="1"
:title="t('migration.wizard.step1.title')"
:description="t('migration.wizard.step1.description')"
>
<RenameSpecsAuto :gql="query.data.value?.migration" />
<template #footer>
<Button
disabled
variant="pending"
@click="renameSpecs"
>
<template #prefix>
<i-cy-loading_x16
{{ t('migration.wizard.step1.button') }}
</Button>
</template>
</MigrationStep>
<MigrationStep
:open="migration.step === 'renameManual'"
:checked="migration.step === 'configFile'"
:step="2"
:title="t('migration.wizard.step2.title')"
:description="t('migration.wizard.step2.description')"
>
<RenameSpecsManual :gql="query.data.value?.migration" />
<template #footer>
<div class="flex gap-16px">
<Button
disabled
variant="pending"
>
<template #prefix>
<i-cy-loading_x16
class="animate-spin icon-dark-white icon-light-gray-400"
/>
</template>
{{ t('migration.wizard.step2.buttonWait') }}
</Button>
class="animate-spin icon-dark-white icon-light-gray-400"
/>
</template>
{{ t('migration.wizard.step2.buttonWait') }}
</Button>
<Button
variant="outline"
@click="skipStep2"
>
{{ t('migration.wizard.step2.button') }}
</Button>
</div>
</template>
</MigrationStep>
<MigrationStep
:open="migration.step === 'configFile'"
:step="3"
:title="t('migration.wizard.step3.title')"
:description="t('migration.wizard.step3.description')"
>
<ConvertConfigFile :gql="query.data.value?.migration" />
<template #footer>
<Button
variant="outline"
@click="skipStep2"
data-cy="convertConfigButton"
@click="convertConfig"
>
{{ t('migration.wizard.step2.button') }}
{{ t('migration.wizard.step3.button') }}
</Button>
</div>
</template>
</MigrationStep>
<MigrationStep
:open="props.gql.step === 'configFile'"
:step="3"
:title="t('migration.wizard.step3.title')"
:description="t('migration.wizard.step3.description')"
>
<ConvertConfigFile :gql="props.gql" />
<template #footer>
<Button
@click="convertConfig"
>
{{ t('migration.wizard.step3.button') }}
</Button>
</template>
</MigrationStep>
</template>
</MigrationStep>
</template>
</template>
<script setup lang="ts">
@@ -79,22 +81,39 @@ import RenameSpecsAuto from './RenameSpecsAuto.vue'
import RenameSpecsManual from './RenameSpecsManual.vue'
import ConvertConfigFile from './ConvertConfigFile.vue'
import { useI18n } from '@cy/i18n'
import { gql } from '@urql/vue'
import type { MigrationWizardFragment } from '../generated/graphql'
import { gql, useMutation, useQuery } from '@urql/vue'
import { MigrationWizardQueryDocument } from '../generated/graphql'
import { computed } from 'vue'
const { t } = useI18n()
gql`
fragment MigrationWizard on Migration {
step
...RenameSpecsAuto
...RenameSpecsManual
...ConvertConfigFile
fragment MigrationWizardData on Query {
migration {
step
...RenameSpecsAuto
...RenameSpecsManual
...ConvertConfigFile
}
}`
const props = defineProps<{
gql: MigrationWizardFragment
}>()
gql`
query MigrationWizardQuery {
...MigrationWizardData
}
`
const convertConfigMutation = gql`
mutation MigrationWizard_ConvertFile {
migrateConfigFile
}
`
const query = useQuery({ query: MigrationWizardQueryDocument })
const configMutation = useMutation(convertConfigMutation)
const migration = computed(() => query.data.value?.migration ?? { step: 'renameAuto' })
function renameSpecs () {
@@ -105,6 +124,6 @@ function skipStep2 () {
}
function convertConfig () {
configMutation.executeMutation({})
}
</script>

View File

@@ -0,0 +1,18 @@
{
"$schema": "https://on.cypress.io/cypress.schema.json",
"baseUrl": "http://localhost:3000",
"retries": 2,
"defaultCommandTimeout": 5000,
"fixturesFolder": false,
"componentFolder": "src",
"testFiles": "**/*.spec.{tsx,js}",
"pluginsFile": "cypress/plugins/index.ts",
"e2e": {
"defaultCommandTimeout": 10000,
"slowTestThreshold": 5000
},
"component": {
"slowTestThreshold": 5000,
"retries": 1
}
}

View File

@@ -0,0 +1,5 @@
module.exports = (on, config) => {
return {
test: 'value',
}
}