From 910f88b328ec20dca50ea349408d0fdd45a87fb6 Mon Sep 17 00:00:00 2001 From: Alejandro Estrada Date: Fri, 28 Jan 2022 09:34:15 -0500 Subject: [PATCH] fix: component spec creation with spec pattern (#19862) * fix: component spec creation with spec pattern * Update graphql type * Create spec from app * Update fetch policy * Update spec creation * Fix TS * Add tests * Remove flaky code Co-authored-by: Tim Griesser --- packages/app/cypress/e2e/index.cy.ts | 85 +++++++++- packages/app/src/pages/Specs/Runner.vue | 2 +- packages/app/src/specs/CreateSpecModal.vue | 6 +- ...atorCardStepOne.vue => EmptyGenerator.vue} | 52 +++--- .../src/specs/generators/GeneratorSuccess.vue | 26 ++- .../component/ComponentGeneratorStepOne.vue | 149 +++++++++++------- .../specs/generators/empty/EmptyGenerator.tsx | 2 +- .../story/StoryGeneratorStepOne.vue | 149 +++++++++++------- .../src/actions/ProjectActions.ts | 73 ++++++++- .../data-context/src/codegen/spec-options.ts | 49 +++++- .../data-context/src/util/urqlCacheKeys.ts | 2 + .../support/mock-graphql/stubgql-Mutation.ts | 30 ++-- .../frontend-shared/src/locales/en-US.json | 4 + packages/graphql/schemas/schema.graphql | 19 ++- packages/graphql/src/schemaTypes/index.ts | 1 + .../objectTypes/gql-GenerateSpecResponse.ts | 38 +++++ .../objectTypes/gql-GeneratedSpecError.ts | 10 ++ .../schemaTypes/objectTypes/gql-Mutation.ts | 11 +- .../src/schemaTypes/objectTypes/index.ts | 2 + .../unions/gql-GeneratedSpecResult.ts | 19 +++ .../graphql/src/schemaTypes/unions/index.ts | 4 + .../no-specs-custom-pattern/cypress.config.js | 2 +- 22 files changed, 559 insertions(+), 176 deletions(-) rename packages/app/src/specs/generators/{empty/EmptyGeneratorCardStepOne.vue => EmptyGenerator.vue} (72%) create mode 100644 packages/graphql/src/schemaTypes/objectTypes/gql-GenerateSpecResponse.ts create mode 100644 packages/graphql/src/schemaTypes/objectTypes/gql-GeneratedSpecError.ts create mode 100644 packages/graphql/src/schemaTypes/unions/gql-GeneratedSpecResult.ts create mode 100644 packages/graphql/src/schemaTypes/unions/index.ts diff --git a/packages/app/cypress/e2e/index.cy.ts b/packages/app/cypress/e2e/index.cy.ts index fc0f168c83db..1dd652b9c141 100644 --- a/packages/app/cypress/e2e/index.cy.ts +++ b/packages/app/cypress/e2e/index.cy.ts @@ -156,7 +156,7 @@ describe('App: Index', () => { //Shows extension warning cy.get('@enterSpecInput').clear().type('cypress/e2e/MyTest.spec.j') - cy.intercept('mutation-EmptyGeneratorCardStepOne_MatchSpecFile', (req) => { + cy.intercept('mutation-EmptyGenerator_MatchSpecFile', (req) => { if (req.body.variables.specFile === 'cypress/e2e/MyTest.spec.jx') { req.on('before:response', (res) => { res.body.data.matchesSpecPattern = true @@ -652,7 +652,7 @@ describe('App: Index', () => { cy.findByTestId('file-match-indicator').should('contain', '0 Matches') cy.findByRole('button', { name: 'cypress.config.js' }) - cy.findByTestId('spec-pattern').should('contain', 'src/**/*.cy.{js,jsx}') + cy.findByTestId('spec-pattern').should('contain', 'src/specs-folder/*.cy.{js,jsx}') cy.contains('button', defaultMessages.createSpec.updateSpecPattern) cy.findByRole('button', { name: 'New Spec', exact: false }) @@ -687,6 +687,87 @@ describe('App: Index', () => { .and('contain', defaultMessages.createSpec.component.importFromStory.description) }) }) + + it('shows create first spec page with create from component option and goes back if it is cancel', () => { + cy.findByRole('button', { name: 'New Spec', exact: false }).click() + + cy.findByRole('dialog', { name: defaultMessages.createSpec.newSpecModalTitle }).within(() => { + cy.findAllByTestId('card').eq(0) + .should('have.attr', 'tabindex', '0') + .and('contain', defaultMessages.createSpec.component.importFromComponent.description).click() + }) + + cy.get('[data-cy=file-list-row]').first().click() + + cy.get('input').invoke('val').should('eq', 'src/App.cy.jsx') + cy.contains(defaultMessages.createSpec.component.importEmptySpec.header) + + cy.contains(defaultMessages.components.button.cancel).click() + + cy.contains(defaultMessages.createSpec.newSpecModalTitle) + }) + + it('shows create first spec page with create from component option', () => { + cy.findByRole('button', { name: 'New Spec', exact: false }).click() + + cy.findByRole('dialog', { name: defaultMessages.createSpec.newSpecModalTitle }).within(() => { + cy.findAllByTestId('card').eq(0) + .should('have.attr', 'tabindex', '0') + .and('contain', defaultMessages.createSpec.component.importFromComponent.description).click() + }) + + cy.get('[data-cy=file-list-row]').first().click() + + cy.get('input').invoke('val').should('eq', 'src/App.cy.jsx') + cy.contains(defaultMessages.createSpec.component.importEmptySpec.header) + cy.contains(defaultMessages.createSpec.component.importEmptySpec.invalidComponentWarning) + cy.get('input').clear() + cy.contains(defaultMessages.createSpec.component.importEmptySpec.invalidComponentWarning).should('not.exist') + cy.contains('button', defaultMessages.createSpec.createSpec).should('be.disabled') + + cy.get('input').clear().type('src/specs-folder/MyTest.cy.jsx') + 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('src/specs-folder/MyTest.cy.jsx').click() + + cy.findByRole('dialog', { name: defaultMessages.createSpec.successPage.header }).as('SuccessDialog').within(() => { + cy.findByRole('link', { + name: 'Okay, run the spec', + }).should('have.attr', 'href', '#/specs/runner?file=src/specs-folder/MyTest.cy.jsx') + }) + }) + + it('shows create first spec page with create from story option', () => { + cy.findByRole('button', { name: 'New Spec', exact: false }).click() + + cy.findByRole('dialog', { name: defaultMessages.createSpec.newSpecModalTitle }).within(() => { + cy.findAllByTestId('card').eq(1) + .should('have.attr', 'tabindex', '0') + .and('contain', defaultMessages.createSpec.component.importFromStory.description).click() + }) + + cy.get('[data-cy=file-list-row]').first().click() + + cy.get('input').invoke('val').should('eq', 'src/stories/Button.stories.cy.jsx') + cy.contains(defaultMessages.createSpec.component.importEmptySpec.header) + cy.contains(defaultMessages.createSpec.component.importEmptySpec.invalidComponentWarning) + cy.get('input').clear() + cy.contains(defaultMessages.createSpec.component.importEmptySpec.invalidComponentWarning).should('not.exist') + cy.contains('button', defaultMessages.createSpec.createSpec).should('be.disabled') + + cy.get('input').clear().type('src/specs-folder/Button.stories.cy.jsx') + 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('src/specs-folder/Button.stories.cy.jsx').click() + + cy.findByRole('dialog', { name: defaultMessages.createSpec.successPage.header }).as('SuccessDialog').within(() => { + cy.findByRole('link', { + name: 'Okay, run the spec', + }).should('have.attr', 'href', '#/specs/runner?file=src/specs-folder/Button.stories.cy.jsx') + }) + }) }) describe('Code Generation', () => { diff --git a/packages/app/src/pages/Specs/Runner.vue b/packages/app/src/pages/Specs/Runner.vue index 78aa6735c074..8b05b223af10 100644 --- a/packages/app/src/pages/Specs/Runner.vue +++ b/packages/app/src/pages/Specs/Runner.vue @@ -47,7 +47,7 @@ const isRunMode = window.__CYPRESS_MODE__ === 'run' // requests, which is what we want. const query = useQuery({ query: SpecPageContainerDocument, - requestPolicy: 'cache-and-network', + requestPolicy: 'cache-only', pause: isRunMode && window.top === window, }) diff --git a/packages/app/src/specs/CreateSpecModal.vue b/packages/app/src/specs/CreateSpecModal.vue index 2f0397feac1a..6d614ac80b1b 100644 --- a/packages/app/src/specs/CreateSpecModal.vue +++ b/packages/app/src/specs/CreateSpecModal.vue @@ -20,7 +20,9 @@ :key="generator.id" v-model:title="title" :code-gen-glob="codeGenGlob" - :gql="props.gql" + :gql="props.gql.currentProject" + type="e2e" + spec-file-name="cypress/e2e/filename.cy.js" @restart="currentGeneratorId = undefined" @close="close" /> @@ -61,9 +63,9 @@ const emits = defineEmits<{ gql` fragment CreateSpecModal on Query { ...CreateSpecCards - ...EmptyGeneratorCardStepOne currentProject { id + ...EmptyGenerator ...ComponentGeneratorStepOne_codeGenGlob ...StoryGeneratorStepOne_codeGenGlob } diff --git a/packages/app/src/specs/generators/empty/EmptyGeneratorCardStepOne.vue b/packages/app/src/specs/generators/EmptyGenerator.vue similarity index 72% rename from packages/app/src/specs/generators/empty/EmptyGeneratorCardStepOne.vue rename to packages/app/src/specs/generators/EmptyGenerator.vue index 800fdedf4fc4..2e70fd83047a 100644 --- a/packages/app/src/specs/generators/empty/EmptyGeneratorCardStepOne.vue +++ b/packages/app/src/specs/generators/EmptyGenerator.vue @@ -14,23 +14,23 @@
- {{ t('createSpec.e2e.importEmptySpec.invalidSpecWarning') }}specPattern. + {{ invalidSpecWarning }}specPattern.
@@ -98,39 +98,40 @@ import Input from '@packages/frontend-shared/src/components/Input.vue' import Button from '@packages/frontend-shared/src/components/Button.vue' import { useVModels, whenever } from '@vueuse/core' import { gql, useMutation } from '@urql/vue' -import SpecPatterns from '../../../components/SpecPatterns.vue' -import { EmptyGeneratorCardStepOneFragment, EmptyGeneratorCardStepOne_MatchSpecFileDocument, EmptyGeneratorCardStepOne_GenerateSpecDocument, GeneratorSuccessFragment } from '../../../generated/graphql' +import SpecPatterns from '../../components/SpecPatterns.vue' +import { EmptyGeneratorFragment, EmptyGenerator_MatchSpecFileDocument, EmptyGenerator_GenerateSpecDocument, GeneratorSuccessFileFragment } from '../../generated/graphql' import StandardModalFooter from '@packages/frontend-shared/src/components/StandardModalFooter.vue' -import GeneratorSuccess from '../GeneratorSuccess.vue' +import GeneratorSuccess from './GeneratorSuccess.vue' import TestResultsIcon from '~icons/cy/test-results_x24.svg' import PlusButtonIcon from '~icons/cy/add-large_x16.svg' const props = defineProps<{ title: string, - gql: EmptyGeneratorCardStepOneFragment + gql: EmptyGeneratorFragment + type: 'e2e' | 'component' | 'story' + specFileName: string + erroredCodegenCandidate?: string }>() const { t } = useI18n() gql` -fragment EmptyGeneratorCardStepOne on Query { - currentProject { - id - config - ...SpecPatterns - } +fragment EmptyGenerator on CurrentProject { + id + config + ...SpecPatterns } ` gql` -mutation EmptyGeneratorCardStepOne_MatchSpecFile($specFile: String!) { +mutation EmptyGenerator_MatchSpecFile($specFile: String!) { matchesSpecPattern (specFile: $specFile) } ` gql` -mutation EmptyGeneratorCardStepOne_generateSpec($codeGenCandidate: String!, $type: CodeGenType!) { - generateSpecFromSource(codeGenCandidate: $codeGenCandidate, type: $type) { +mutation EmptyGenerator_generateSpec($codeGenCandidate: String!, $type: CodeGenType!, $erroredCodegenCandidate: String) { + generateSpecFromSource(codeGenCandidate: $codeGenCandidate, type: $type, erroredCodegenCandidate: $erroredCodegenCandidate) { ...GeneratorSuccess } }` @@ -140,28 +141,30 @@ const emits = defineEmits<{ (event: 'update:description', value: string): void (event: 'restart'): void (event: 'close'): void + (event: 'updateTitle', value: string): void }>() const { title } = useVModels(props, emits) -const specFile = ref('cypress/e2e/filename.cy.js') +const specFile = ref(props.specFileName) -const matches = useMutation(EmptyGeneratorCardStepOne_MatchSpecFileDocument) -const writeFile = useMutation(EmptyGeneratorCardStepOne_GenerateSpecDocument) +const matches = useMutation(EmptyGenerator_MatchSpecFileDocument) +const writeFile = useMutation(EmptyGenerator_GenerateSpecDocument) const isValidSpecFile = ref(true) const hasError = computed(() => !isValidSpecFile.value && !!specFile.value) -const result = ref(null) +const result = ref(null) whenever(result, () => { title.value = t('createSpec.successPage.header') + emits('updateTitle', t('createSpec.successPage.header')) }) const createSpec = async () => { - const { data } = await writeFile.executeMutation({ codeGenCandidate: specFile.value, type: 'e2e' }) + const { data } = await writeFile.executeMutation({ codeGenCandidate: specFile.value, type: props.type, erroredCodegenCandidate: props.erroredCodegenCandidate ?? null }) - result.value = data?.generateSpecFromSource ?? null + result.value = data?.generateSpecFromSource?.generatedSpecResult?.__typename === 'ScaffoldedFile' ? data?.generateSpecFromSource?.generatedSpecResult : null } watch(specFile, async (value) => { @@ -178,4 +181,7 @@ const recommendedFileName = computed(() => { return `{filename}.cy.${split[split.length - 1]}` }) + +const invalidSpecWarning = computed(() => props.type === 'e2e' ? t('createSpec.e2e.importEmptySpec.invalidSpecWarning') : t('createSpec.component.importEmptySpec.invalidComponentWarning')) + diff --git a/packages/app/src/specs/generators/GeneratorSuccess.vue b/packages/app/src/specs/generators/GeneratorSuccess.vue index 6c78a0343df0..d7948fd9a2bc 100644 --- a/packages/app/src/specs/generators/GeneratorSuccess.vue +++ b/packages/app/src/specs/generators/GeneratorSuccess.vue @@ -32,10 +32,11 @@ import ShikiHighlight from '@cy/components/ShikiHighlight.vue' import Collapsible from '@cy/components/Collapsible.vue' import { gql } from '@urql/core' -import type { GeneratorSuccessFragment } from '../../generated/graphql' +import type { GeneratorSuccessFileFragment } from '../../generated/graphql' +import { ref } from 'vue' gql` -fragment GeneratorSuccess on ScaffoldedFile { +fragment GeneratorSuccessFile on ScaffoldedFile { file { id fileName @@ -47,7 +48,26 @@ fragment GeneratorSuccess on ScaffoldedFile { } ` +gql` +fragment GeneratorSuccess on GenerateSpecResponse { + # Used to update the cache after a spec is created, so when the user tries to + # run it, it already exists + currentProject { + id + specs { + id + ...SpecNode_InlineSpecList + } + } + generatedSpecResult { + ... on ScaffoldedFile { + ...GeneratorSuccessFile + } + } +} +` + defineProps<{ - file: GeneratorSuccessFragment['file'] + file: GeneratorSuccessFileFragment['file'] }>() diff --git a/packages/app/src/specs/generators/component/ComponentGeneratorStepOne.vue b/packages/app/src/specs/generators/component/ComponentGeneratorStepOne.vue index 9e45116f3b15..ff6c5f043bc6 100644 --- a/packages/app/src/specs/generators/component/ComponentGeneratorStepOne.vue +++ b/packages/app/src/specs/generators/component/ComponentGeneratorStepOne.vue @@ -1,57 +1,73 @@ @@ -62,11 +78,12 @@ import FileChooser from '../FileChooser.vue' import GeneratorSuccess from '../GeneratorSuccess.vue' import { computed, ref } from 'vue' import { gql, useQuery, useMutation } from '@urql/vue' -import { ComponentGeneratorStepOneDocument, ComponentGeneratorStepOne_GenerateSpecDocument, GeneratorSuccessFragment } from '../../../generated/graphql' +import { ComponentGeneratorStepOneDocument, ComponentGeneratorStepOne_GenerateSpecDocument, GeneratorSuccessFileFragment } from '../../../generated/graphql' import StandardModalFooter from '@cy/components/StandardModalFooter.vue' import Button from '@cy/components/Button.vue' import PlusButtonIcon from '~icons/cy/add-large_x16.svg' import TestResultsIcon from '~icons/cy/test-results_x24.svg' +import EmptyGenerator from '../EmptyGenerator.vue' const props = defineProps<{ title: string, @@ -116,6 +133,16 @@ gql` mutation ComponentGeneratorStepOne_generateSpec($codeGenCandidate: String!, $type: CodeGenType!) { generateSpecFromSource(codeGenCandidate: $codeGenCandidate, type: $type) { ...GeneratorSuccess + currentProject { + id + ...EmptyGenerator + } + generatedSpecResult { + ... on GeneratedSpecError { + fileName + erroredCodegenCandidate + } + } } }` @@ -138,19 +165,31 @@ const allFiles = computed((): any => { return [] }) -const result = ref(null) +const result = ref(null) +const generatedSpecError = ref() +const generateSpecFromSource = ref() whenever(result, () => { title.value = t('createSpec.successPage.header') }) +whenever(generatedSpecError, () => { + title.value = t('createSpec.component.importEmptySpec.header') +}) + const makeSpec = async (file) => { const { data } = await mutation.executeMutation({ codeGenCandidate: file.absolute, type: 'component', }) - result.value = data?.generateSpecFromSource ?? null + generateSpecFromSource.value = data?.generateSpecFromSource + result.value = data?.generateSpecFromSource?.generatedSpecResult?.__typename === 'ScaffoldedFile' ? data?.generateSpecFromSource?.generatedSpecResult : null + generatedSpecError.value = data?.generateSpecFromSource?.generatedSpecResult?.__typename === 'GeneratedSpecError' ? data?.generateSpecFromSource?.generatedSpecResult : null +} + +const cancelSpecNameCreation = () => { + generatedSpecError.value = null } diff --git a/packages/app/src/specs/generators/empty/EmptyGenerator.tsx b/packages/app/src/specs/generators/empty/EmptyGenerator.tsx index 3ee747319034..b39fde8189e2 100644 --- a/packages/app/src/specs/generators/empty/EmptyGenerator.tsx +++ b/packages/app/src/specs/generators/empty/EmptyGenerator.tsx @@ -1,7 +1,7 @@ import type { SpecGenerator } from '../types' import { filters } from '../GeneratorsCommon' import EmptyGeneratorCard from './EmptyGeneratorCard.vue' -import EmptyGeneratorCardStepOne from './EmptyGeneratorCardStepOne.vue' +import EmptyGeneratorCardStepOne from '../EmptyGenerator.vue' export const EmptyGenerator: SpecGenerator = { card: EmptyGeneratorCard, diff --git a/packages/app/src/specs/generators/story/StoryGeneratorStepOne.vue b/packages/app/src/specs/generators/story/StoryGeneratorStepOne.vue index 92db8913e169..6e0ddad6f55f 100644 --- a/packages/app/src/specs/generators/story/StoryGeneratorStepOne.vue +++ b/packages/app/src/specs/generators/story/StoryGeneratorStepOne.vue @@ -1,57 +1,73 @@ @@ -62,11 +78,12 @@ import FileChooser from '../FileChooser.vue' import GeneratorSuccess from '../GeneratorSuccess.vue' import { computed, ref } from 'vue' import { gql, useQuery, useMutation } from '@urql/vue' -import { GeneratorSuccessFragment, StoryGeneratorStepOneDocument, StoryGeneratorStepOne_GenerateSpecDocument } from '../../../generated/graphql' +import { GeneratorSuccessFileFragment, StoryGeneratorStepOneDocument, StoryGeneratorStepOne_GenerateSpecDocument } from '../../../generated/graphql' import StandardModalFooter from '@cy/components/StandardModalFooter.vue' import Button from '@cy/components/Button.vue' import PlusButtonIcon from '~icons/cy/add-large_x16.svg' import TestResultsIcon from '~icons/cy/test-results_x24.svg' +import EmptyGenerator from '../EmptyGenerator.vue' const props = defineProps<{ title: string, @@ -116,6 +133,16 @@ gql` mutation StoryGeneratorStepOne_generateSpec($codeGenCandidate: String!, $type: CodeGenType!) { generateSpecFromSource(codeGenCandidate: $codeGenCandidate, type: $type) { ...GeneratorSuccess + currentProject { + id + ...EmptyGenerator + } + generatedSpecResult { + ... on GeneratedSpecError { + fileName + erroredCodegenCandidate + } + } } }` @@ -138,19 +165,31 @@ const allFiles = computed(() => { return [] }) as any -const result = ref(null) +const result = ref(null) +const generatedSpecError = ref() +const generateSpecFromSource = ref() whenever(result, () => { title.value = t('createSpec.successPage.header') }) +whenever(generatedSpecError, () => { + title.value = t('createSpec.component.importEmptySpec.header') +}) + const makeSpec = async (file) => { const { data } = await mutation.executeMutation({ codeGenCandidate: file.absolute, type: 'story', }) - result.value = data?.generateSpecFromSource ?? null + generateSpecFromSource.value = data?.generateSpecFromSource + result.value = data?.generateSpecFromSource?.generatedSpecResult?.__typename === 'ScaffoldedFile' ? data?.generateSpecFromSource?.generatedSpecResult : null + generatedSpecError.value = data?.generateSpecFromSource?.generatedSpecResult?.__typename === 'GeneratedSpecError' ? data?.generateSpecFromSource?.generatedSpecResult : null +} + +const cancelSpecNameCreation = () => { + generatedSpecError.value = null } diff --git a/packages/data-context/src/actions/ProjectActions.ts b/packages/data-context/src/actions/ProjectActions.ts index 8fa2416ae57d..184d23d30d7d 100644 --- a/packages/data-context/src/actions/ProjectActions.ts +++ b/packages/data-context/src/actions/ProjectActions.ts @@ -1,4 +1,4 @@ -import type { CodeGenType, MutationSetProjectPreferencesArgs, NexusGenObjects, TestingTypeEnum } from '@packages/graphql/src/gen/nxs.gen' +import type { CodeGenType, MutationSetProjectPreferencesArgs, NexusGenObjects, NexusGenUnions, TestingTypeEnum } from '@packages/graphql/src/gen/nxs.gen' import type { InitializeProjectOptions, FoundBrowser, FoundSpec, LaunchOpts, OpenProjectLaunchOptions, Preferences, TestingType, ReceivedCypressOptions, AddProject } from '@packages/types' import execa from 'execa' import path from 'path' @@ -305,7 +305,7 @@ export class ProjectActions { this.api.insertProjectPreferencesToCache(this.ctx.lifecycleManager.projectTitle, args) } - async codeGenSpec (codeGenCandidate: string, codeGenType: CodeGenType): Promise { + async codeGenSpec (codeGenCandidate: string, codeGenType: CodeGenType, erroredCodegenCandidate?: string | null): Promise { const project = this.ctx.currentProject if (!project) { @@ -314,10 +314,15 @@ export class ProjectActions { const parsed = path.parse(codeGenCandidate) + const defaultCText = '.cy' + const possibleExtensions = ['.cy', '.spec', '.test', '-spec', '-test', '_spec'] + const getFileExtension = () => { - if (codeGenType === 'e2e') { - const possibleExtensions = ['.spec', '.test', '-spec', '-test', '.cy', '_spec'] + if (erroredCodegenCandidate) { + return '' + } + if (codeGenType === 'e2e') { return ( possibleExtensions.find((ext) => { return codeGenCandidate.endsWith(ext + parsed.ext) @@ -325,11 +330,11 @@ export class ProjectActions { ) } - return '.cy' + return defaultCText } const getCodeGenPath = () => { - return codeGenType === 'e2e' + return codeGenType === 'e2e' || erroredCodegenCandidate ? this.ctx.path.join( project, codeGenCandidate, @@ -344,9 +349,41 @@ export class ProjectActions { codeGenPath, codeGenType, specFileExtension, + erroredCodegenCandidate, }) - const codeGenOptions = await newSpecCodeGenOptions.getCodeGenOptions() + let codeGenOptions = await newSpecCodeGenOptions.getCodeGenOptions() + + if ((codeGenType === 'component' || codeGenType === 'story') && !erroredCodegenCandidate) { + const filePathAbsolute = path.join(path.parse(codeGenPath).dir, codeGenOptions.fileName) + const filePathRelative = path.relative(this.ctx.currentProject || '', filePathAbsolute) + + let foundExt + + for await (const ext of possibleExtensions) { + const file = filePathRelative.replace(defaultCText, ext) + + const matchesSpecPattern = await this.ctx.project.matchesSpecPattern(file) + + if (matchesSpecPattern) { + foundExt = ext + break + } + } + + if (!foundExt) { + return { + fileName: filePathRelative, + erroredCodegenCandidate: codeGenPath, + } + } + + codeGenOptions = { + ...codeGenOptions, + fileName: codeGenOptions.fileName.replace(defaultCText, foundExt), + } + } + const codeGenResults = await codeGenerator( { templateDir: templates[codeGenType], target: path.parse(codeGenPath).dir }, codeGenOptions, @@ -358,6 +395,28 @@ export class ProjectActions { const [newSpec] = codeGenResults.files + const cfg = this.ctx.project.getConfig() + + if (cfg) { + const toArray = (v: string | string[] | undefined) => Array.isArray(v) ? v : v ? [v] : undefined + + const testingType = (codeGenType === 'component' || codeGenType === 'story') ? 'component' : 'e2e' + + const specPattern = toArray(cfg[testingType]?.specPattern) + const ignoreSpecPattern = toArray(cfg[testingType]?.ignoreSpecPattern) ?? [] + const additionalIgnore = toArray(testingType === 'component' ? cfg?.e2e?.specPattern : undefined) ?? [] + + if (this.ctx.currentProject && specPattern) { + const specs = await this.ctx.project.findSpecs(this.ctx.currentProject, testingType, specPattern, ignoreSpecPattern, additionalIgnore) + + this.ctx.project.setSpecs(specs) + + if (testingType === 'component') { + this.api.getDevServer().updateSpecs(specs) + } + } + } + return { status: 'valid', file: { absolute: newSpec.file, contents: newSpec.content }, diff --git a/packages/data-context/src/codegen/spec-options.ts b/packages/data-context/src/codegen/spec-options.ts index 18449a221f31..31b5cb433eaa 100644 --- a/packages/data-context/src/codegen/spec-options.ts +++ b/packages/data-context/src/codegen/spec-options.ts @@ -10,11 +10,15 @@ interface CodeGenOptions { codeGenPath: string codeGenType: CodeGenType specFileExtension: string + erroredCodegenCandidate?: string | null } +type PossiblesExtension = '.js' | '.ts' | '.jsx' | '.tsx' + export class SpecOptions { private parsedPath: ParsedPath; private projectRoot: string; + private parsedErroredCodegenCandidate?: ParsedPath private getFrontendFramework () { return this.ctx.project.frameworkLoader.load(this.projectRoot) @@ -23,6 +27,10 @@ export class SpecOptions { constructor (private ctx: DataContext, private options: CodeGenOptions) { assert(this.ctx.currentProject) this.parsedPath = this.ctx.path.parse(options.codeGenPath) + if (options.erroredCodegenCandidate) { + this.parsedErroredCodegenCandidate = this.ctx.path.parse(options.erroredCodegenCandidate) + } + // Should always be defined this.projectRoot = this.ctx.currentProject } @@ -58,29 +66,44 @@ export class SpecOptions { return frameworkOptions } + private relativePath () { + if (!this.parsedErroredCodegenCandidate?.base) { + return `./${this.parsedPath.base}` + } + + const componentPathRelative = this.ctx.path.relative(this.parsedPath.dir, this.parsedErroredCodegenCandidate.dir) + + const componentPath = this.ctx.path.join(componentPathRelative, this.parsedErroredCodegenCandidate.base) + + return componentPath.startsWith('.') ? componentPath : `./${componentPath}` + } + private async getFrameworkComponentOptions (framework: CodeGenFramework) { - const componentName = capitalize(camelCase(this.parsedPath.name)) + const componentName = capitalize(camelCase(this.parsedErroredCodegenCandidate?.name ?? this.parsedPath.name)) + + const componentPath = this.relativePath() + const frameworkOptions = { react: { - imports: ['import React from "react"', 'import { mount } from "@cypress/react"', `import ${componentName} from "./${this.parsedPath.name}"`], + imports: ['import React from "react"', 'import { mount } from "@cypress/react"', `import ${componentName} from "${componentPath}"`], componentName, docsLink: '// see: https://reactjs.org/docs/test-utils.html', mount: `mount(<${componentName} />)`, fileName: this.getFilename(this.parsedPath.ext), }, vue: { - imports: ['import { mount } from "@cypress/vue"', `import ${componentName} from "./${this.parsedPath.base}"`], + imports: ['import { mount } from "@cypress/vue"', `import ${componentName} from "${componentPath}"`], componentName, docsLink: '// see: https://vue-test-utils.vuejs.org/', mount: `mount(${componentName}, { props: {} })`, - fileName: this.getFilename(await this.getVueExtenstion()), + fileName: this.getFilename(await this.getVueExtension()), }, } as const return frameworkOptions[framework] } - private async getVueExtenstion (): Promise<'.js' | '.ts'> { + private async getVueExtension (): Promise { try { const fileContent = await this.ctx.fs .readFile(this.options.codeGenPath) @@ -88,6 +111,13 @@ export class SpecOptions { return fileContent.includes('lang="ts"') ? '.ts' : '.js' } catch (e) { + const validExtensions = ['.js', '.jsx', '.ts', '.tsx'] + const possibleExtension = this.parsedPath.ext + + if (validExtensions.includes(possibleExtension)) { + return possibleExtension as PossiblesExtension + } + return '.js' } } @@ -100,7 +130,8 @@ export class SpecOptions { } const defaultTitle = this.parsedPath.name.split('.')[0] || '' - const csf = await readCsfOrMdx(this.options.codeGenPath, { + + const csf = await readCsfOrMdx(this.options.erroredCodegenCandidate ?? this.options.codeGenPath, { defaultTitle, }).then((res) => res.parse()) @@ -122,13 +153,15 @@ export class SpecOptions { } private getFrameworkStoryOptions (framework: CodeGenFramework, csf: CsfFile) { + const storyPath = this.relativePath() + const frameworkOptions = { react: { imports: [ 'import React from "react"', 'import { mount } from "@cypress/react"', 'import { composeStories } from "@storybook/testing-react"', - `import * as stories from "./${this.parsedPath.name}"`, + `import * as stories from "${storyPath}"`, ], stories: csf.stories.map((story) => { const component = story.name.replace(/\s+/, '') @@ -144,7 +177,7 @@ export class SpecOptions { imports: [ 'import { mount } from "@cypress/vue"', 'import { composeStories } from "@storybook/testing-vue3"', - `import * as stories from "./${this.parsedPath.name}"`, + `import * as stories from "${storyPath}"`, ], stories: csf.stories.map((story) => { const component = story.name.replace(/\s+/, '') diff --git a/packages/data-context/src/util/urqlCacheKeys.ts b/packages/data-context/src/util/urqlCacheKeys.ts index 34af9ce0bafc..05858c0384b0 100644 --- a/packages/data-context/src/util/urqlCacheKeys.ts +++ b/packages/data-context/src/util/urqlCacheKeys.ts @@ -26,5 +26,7 @@ export const urqlCacheKeys: Partial = { LocalSettingsPreferences: () => null, CloudProjectNotFound: (data) => data.__typename, CloudProjectUnauthorized: (data) => data.__typename, + GeneratedSpecError: () => null, + GenerateSpecResponse: (data) => data.__typename, }, } diff --git a/packages/frontend-shared/cypress/support/mock-graphql/stubgql-Mutation.ts b/packages/frontend-shared/cypress/support/mock-graphql/stubgql-Mutation.ts index e6cdec4261b0..f46f5fbef776 100644 --- a/packages/frontend-shared/cypress/support/mock-graphql/stubgql-Mutation.ts +++ b/packages/frontend-shared/cypress/support/mock-graphql/stubgql-Mutation.ts @@ -47,20 +47,24 @@ export const stubMutation: MaybeResolver = { } return { - __typename: 'ScaffoldedFile', - status: 'valid', - description: 'Generated Spec', - file: { - __typename: 'FileParts', - id: 'U3BlYzovVXNlcnMvbGFjaGxhbi9jb2RlL3dvcmsvY3lwcmVzczUvcGFja2FnZXMvYXBwL3NyYy9CYXNpYy5zcGVjLnRzeA==', - absolute: '/Users/lachlan/code/work/cypress5/packages/app/src/Basic.spec.tsx', - relative: 'app/src/Basic.spec.tsx', - name: 'Basic', - fileName: 'Basic.spec.tsx', - baseName: 'Basic', - fileExtension: 'tsx', - contents: `it('should do stuff', () => {})`, + currentProject: ctx.currentProject, + generatedSpecResult: { + __typename: 'ScaffoldedFile', + status: 'valid', + description: 'Generated Spec', + file: { + __typename: 'FileParts', + id: 'U3BlYzovVXNlcnMvbGFjaGxhbi9jb2RlL3dvcmsvY3lwcmVzczUvcGFja2FnZXMvYXBwL3NyYy9CYXNpYy5zcGVjLnRzeA==', + absolute: '/Users/lachlan/code/work/cypress5/packages/app/src/Basic.spec.tsx', + relative: 'app/src/Basic.spec.tsx', + name: 'Basic', + fileName: 'Basic.spec.tsx', + baseName: 'Basic', + fileExtension: 'tsx', + contents: `it('should do stuff', () => {})`, + }, }, + } }, reconfigureProject (src, args, ctx) { diff --git a/packages/frontend-shared/src/locales/en-US.json b/packages/frontend-shared/src/locales/en-US.json index 0684d3dd2933..cb5347048c42 100644 --- a/packages/frontend-shared/src/locales/en-US.json +++ b/packages/frontend-shared/src/locales/en-US.json @@ -111,6 +111,10 @@ "header": "Create from component", "description": "We'll generate an empty spec file which can be used to import and test any component in this project.", "chooseAComponentHeader": "Choose a component" + }, + "importEmptySpec": { + "header": "Create a new spec", + "invalidComponentWarning": "We couldn't generate a valid filename matching your custom " } } }, diff --git a/packages/graphql/schemas/schema.graphql b/packages/graphql/schemas/schema.graphql index 8d34dad29a24..61bbe5fceaf8 100644 --- a/packages/graphql/schemas/schema.graphql +++ b/packages/graphql/schemas/schema.graphql @@ -489,6 +489,23 @@ enum FrontendFrameworkEnum { vuecli } +"""Error from generated spec""" +type GenerateSpecResponse { + """The currently opened project""" + currentProject: CurrentProject + + """The file that have just been scaffolded or the fileName that errored""" + generatedSpecResult: GeneratedSpecResult +} + +"""Error from generated spec""" +type GeneratedSpecError { + erroredCodegenCandidate: String! + fileName: String! +} + +union GeneratedSpecResult = GeneratedSpecError | ScaffoldedFile + """Git information about a spec file""" type GitInfo { """Last person to change the file in git""" @@ -697,7 +714,7 @@ type Mutation { focusActiveBrowserWindow: Boolean! """Generate spec from source""" - generateSpecFromSource(codeGenCandidate: String!, type: CodeGenType!): ScaffoldedFile + generateSpecFromSource(codeGenCandidate: String!, erroredCodegenCandidate: String, type: CodeGenType!): GenerateSpecResponse """Hides the launchpad windows""" hideBrowserWindow: Boolean! diff --git a/packages/graphql/src/schemaTypes/index.ts b/packages/graphql/src/schemaTypes/index.ts index 8271aa0416d0..9e2665df8ba6 100644 --- a/packages/graphql/src/schemaTypes/index.ts +++ b/packages/graphql/src/schemaTypes/index.ts @@ -6,3 +6,4 @@ export * from './inputTypes/' export * from './interfaceTypes/' export * from './objectTypes/' export * from './scalarTypes/' +export * from './unions/' diff --git a/packages/graphql/src/schemaTypes/objectTypes/gql-GenerateSpecResponse.ts b/packages/graphql/src/schemaTypes/objectTypes/gql-GenerateSpecResponse.ts new file mode 100644 index 000000000000..b46a413913bd --- /dev/null +++ b/packages/graphql/src/schemaTypes/objectTypes/gql-GenerateSpecResponse.ts @@ -0,0 +1,38 @@ +import { objectType } from 'nexus' +import { GeneratedSpecResult } from '../unions' +import { CurrentProject } from './gql-CurrentProject' +import type { FilePartsShape } from './gql-FileParts' + +export type ScaffoldedFileSource = { + status: 'changes' | 'valid' | 'skipped' | 'error' + description: string + file: FilePartsShape +} | { fileName: string, erroredCodegenCandidate: string } + +export const GenerateSpecResponse = objectType({ + name: 'GenerateSpecResponse', + description: 'Error from generated spec', + definition (t) { + t.field('currentProject', { + type: CurrentProject, + description: 'The currently opened project', + resolve: (root, args, ctx) => { + if (ctx.coreData.currentProject) { + return ctx.lifecycleManager + } + + return null + }, + }) + + t.field('generatedSpecResult', { + type: GeneratedSpecResult, + description: 'The file that have just been scaffolded or the fileName that errored', + resolve: (root, args, ctx) => root, + }) + }, + sourceType: { + module: __filename, + export: 'ScaffoldedFileSource', + }, +}) diff --git a/packages/graphql/src/schemaTypes/objectTypes/gql-GeneratedSpecError.ts b/packages/graphql/src/schemaTypes/objectTypes/gql-GeneratedSpecError.ts new file mode 100644 index 000000000000..3f7043b0d6cf --- /dev/null +++ b/packages/graphql/src/schemaTypes/objectTypes/gql-GeneratedSpecError.ts @@ -0,0 +1,10 @@ +import { objectType } from 'nexus' + +export const GeneratedSpecError = objectType({ + name: 'GeneratedSpecError', + description: 'Error from generated spec', + definition (t) { + t.nonNull.string('fileName') + t.nonNull.string('erroredCodegenCandidate') + }, +}) diff --git a/packages/graphql/src/schemaTypes/objectTypes/gql-Mutation.ts b/packages/graphql/src/schemaTypes/objectTypes/gql-Mutation.ts index 5f7b620bc74a..132084071f89 100644 --- a/packages/graphql/src/schemaTypes/objectTypes/gql-Mutation.ts +++ b/packages/graphql/src/schemaTypes/objectTypes/gql-Mutation.ts @@ -5,7 +5,9 @@ import { TestingTypeEnum } from '../enumTypes/gql-WizardEnums' import { FileDetailsInput } from '../inputTypes/gql-FileDetailsInput' import { WizardUpdateInput } from '../inputTypes/gql-WizardUpdateInput' import { CurrentProject } from './gql-CurrentProject' -import { Query, ScaffoldedFile } from '.' +import { GenerateSpecResponse } from './gql-GenerateSpecResponse' +import { Query } from './gql-Query' +import { ScaffoldedFile } from './gql-ScaffoldedFile' export const mutation = mutationType({ definition (t) { @@ -189,14 +191,15 @@ export const mutation = mutationType({ }) t.field('generateSpecFromSource', { - type: ScaffoldedFile, + type: GenerateSpecResponse, description: 'Generate spec from source', args: { codeGenCandidate: nonNull(stringArg()), type: nonNull(CodeGenTypeEnum), + erroredCodegenCandidate: stringArg(), }, - resolve: async (_, args, ctx) => { - return ctx.actions.project.codeGenSpec(args.codeGenCandidate, args.type) + resolve: (_, args, ctx) => { + return ctx.actions.project.codeGenSpec(args.codeGenCandidate, args.type, args.erroredCodegenCandidate) }, }) diff --git a/packages/graphql/src/schemaTypes/objectTypes/index.ts b/packages/graphql/src/schemaTypes/objectTypes/index.ts index bf78e5383888..5d826e90fe02 100644 --- a/packages/graphql/src/schemaTypes/objectTypes/index.ts +++ b/packages/graphql/src/schemaTypes/objectTypes/index.ts @@ -8,6 +8,8 @@ export * from './gql-CurrentProject' export * from './gql-DevState' export * from './gql-Editor' export * from './gql-FileParts' +export * from './gql-GenerateSpecResponse' +export * from './gql-GeneratedSpecError' export * from './gql-GitInfo' export * from './gql-GlobalProject' export * from './gql-LocalSettings' diff --git a/packages/graphql/src/schemaTypes/unions/gql-GeneratedSpecResult.ts b/packages/graphql/src/schemaTypes/unions/gql-GeneratedSpecResult.ts new file mode 100644 index 000000000000..5a38a52a40f6 --- /dev/null +++ b/packages/graphql/src/schemaTypes/unions/gql-GeneratedSpecResult.ts @@ -0,0 +1,19 @@ +import { unionType } from 'nexus' + +export const GeneratedSpecResult = unionType({ + name: 'GeneratedSpecResult', + definition (t) { + t.members( + 'ScaffoldedFile', + 'GeneratedSpecError', + ) + }, + resolveType: (obj) => { + // @ts-expect-error + if (obj.fileName) { + return 'GeneratedSpecError' + } + + return 'ScaffoldedFile' + }, +}) diff --git a/packages/graphql/src/schemaTypes/unions/index.ts b/packages/graphql/src/schemaTypes/unions/index.ts new file mode 100644 index 000000000000..66f93711cb1b --- /dev/null +++ b/packages/graphql/src/schemaTypes/unions/index.ts @@ -0,0 +1,4 @@ +/* eslint-disable padding-line-between-statements */ +// created by autobarrel, do not modify directly + +export * from './gql-GeneratedSpecResult' diff --git a/system-tests/projects/no-specs-custom-pattern/cypress.config.js b/system-tests/projects/no-specs-custom-pattern/cypress.config.js index 087ef384d7ab..2fb171bc6a9f 100644 --- a/system-tests/projects/no-specs-custom-pattern/cypress.config.js +++ b/system-tests/projects/no-specs-custom-pattern/cypress.config.js @@ -1,7 +1,7 @@ module.exports = { component: { supportFile: false, - specPattern: 'src/**/*.cy.{js,jsx}', + specPattern: 'src/specs-folder/*.cy.{js,jsx}', devServer: require('@cypress/react/plugins/load-webpack'), devServerConfig: { webpackFilename: 'webpack.config.js',