diff --git a/docs/generated/packages/angular.json b/docs/generated/packages/angular.json index 5df6a79ce0..78a8821c7a 100644 --- a/docs/generated/packages/angular.json +++ b/docs/generated/packages/angular.json @@ -460,7 +460,7 @@ "name": "component-story", "factory": "./src/generators/component-story/component-story", "schema": { - "$schema": "http://json-schema.org/schema", + "$schema": "https://json-schema.org/schema", "$id": "NxAngularComponentStoryGenerator", "type": "object", "cli": "nx", @@ -507,6 +507,58 @@ "hidden": false, "path": "/packages/angular/src/generators/component-story/schema.json" }, + { + "name": "component-test", + "factory": "./src/generators/component-test/component-test", + "schema": { + "$schema": "http://json-schema.org/schema", + "$id": "NxAngularComponentTestGenerator", + "type": "object", + "cli": "nx", + "description": "Create a `*.cy.ts` file for Cypress component testing for an Angular component.", + "properties": { + "project": { + "type": "string", + "description": "The name of the project where the component is located.", + "x-dropdown": "projects", + "x-prompt": "What project is the component located in?" + }, + "componentName": { + "type": "string", + "description": "Class name of the component to create a test for.", + "examples": ["MyFancyButtonComponent"] + }, + "componentDir": { + "type": "string", + "description": "Relative path to the folder that contains the component from the project root.", + "example": ["src/lib/my-fancy-button"] + }, + "componentFileName": { + "type": "string", + "description": "File name that contains the component without the `.ts` extension.", + "examples": ["my-fancy-button.component"] + }, + "skipFormat": { + "description": "Skip formatting files.", + "type": "boolean", + "default": false + } + }, + "additionalProperties": false, + "required": [ + "projectPath", + "componentName", + "componentPath", + "componentFileName" + ], + "presets": [] + }, + "description": "Creates a cypress component test file for a component.", + "implementation": "/packages/angular/src/generators/component-test/component-test.ts", + "aliases": [], + "hidden": false, + "path": "/packages/angular/src/generators/component-test/schema.json" + }, { "name": "convert-tslint-to-eslint", "factory": "./src/generators/convert-tslint-to-eslint/convert-tslint-to-eslint#conversionGenerator", @@ -2070,6 +2122,48 @@ "hidden": false, "path": "/packages/angular/src/generators/storybook-configuration/schema.json" }, + { + "name": "cypress-component-configuration", + "factory": "./src/generators/cypress-component-configuration/cypress-component-configuration", + "schema": { + "$schema": "https://json-schema.org/schema", + "$id": "NxAngularCypressComponentConfigurationGenerator", + "type": "object", + "cli": "nx", + "title": "Add Cypress component testing", + "description": "Add a Cypress component testing configuration to an existing project. Cypress v10.7.0 or higher is required.", + "properties": { + "project": { + "type": "string", + "description": "The name of the project to add cypress component testing configuration to", + "$default": { "$source": "projectName" }, + "x-prompt": "What project should we add Cypress component testing to?" + }, + "generateTests": { + "type": "boolean", + "description": "Generate default component tests for existing components in the project", + "default": false + }, + "buildTarget": { + "type": "string", + "description": "A build target used to configure Cypress component testing in the format of `project:target[:configuration]`. The build target should be an angular app. If not provided we will try to infer it from your projects usage.", + "pattern": "^[^:\\s]+:[^:\\s]+(:\\S+)?$" + }, + "skipFormat": { + "type": "boolean", + "description": "Skip formatting files", + "default": false + } + }, + "required": ["project"], + "presets": [] + }, + "description": "Setup Cypress component testing for a project.", + "implementation": "/packages/angular/src/generators/cypress-component-configuration/cypress-component-configuration.ts", + "aliases": [], + "hidden": false, + "path": "/packages/angular/src/generators/cypress-component-configuration/schema.json" + }, { "name": "upgrade-module", "factory": "./src/generators/upgrade-module/upgrade-module", diff --git a/docs/generated/packages/cypress.json b/docs/generated/packages/cypress.json index 9ef0717afd..690a20be69 100644 --- a/docs/generated/packages/cypress.json +++ b/docs/generated/packages/cypress.json @@ -17,7 +17,7 @@ "name": "Component Testing", "id": "cypress-component-testing", "file": "shared/cypress-component-testing", - "content": "# Cypress Component Testing\n\n> Component testing is in a early preview and requires Cypress v10 and above.\n> See our [guide for more information](/cypress/v10-migration-guide) to migrate to Cypress v10.\n\nUnlike [E2E testing](/packages/cypress), component testing does not create a new project. Instead, Cypress component testing is added\ndirectly to a project.\n\n```bash\nnx g @nrwl/react:cypress-component-configuration --project=your-react-lib\n```\n\nYou can optionally pass in `--generate-tests` to create component tests for all components within the library.\n\n## Testing Projects\n\nRun `nx component-test your-lib` to execute the component tests with Cypress.\n\nBy default, Cypress will run in headless mode. You will have the result of all the tests and errors (if any) in your\nterminal. Screenshots and videos will be accessible in `dist/cypress/libs/your-lib/screenshots` and `dist/cypress/libs/your-lib/videos`.\n\n## Watching for Changes (Headed Mode)\n\nWith, `nx component-test your-lib --watch` Cypress will start in headed mode. Where you can see your component being tested.\n\nRunning Cypress with `--watch` is a great way to iterate on your components since cypress will rerun your tests as you make those changes validating the new behavior.\n\n## More Information\n\nYou can read more on component testing in the [Cypress documentation](https://docs.cypress.io/guides/component-testing/writing-your-first-component-test).\n" + "content": "# Cypress Component Testing\n\n> Component testing requires Cypress v10 and above.\n> See our [guide for more information](/cypress/v10-migration-guide) to migrate to Cypress v10.\n\nUnlike [E2E testing](/packages/cypress), component testing does not create a new project. Instead, Cypress component testing is added\ndirectly to a project, like [Jest](/packages/jest)\n\n## Add Component Testing to a Project\n\n> Currently only [@nrwl/react](/packages/react/generators/cypress-component-configuration) and [@nrwl/angular](/packages/angular/generators/cypress-component-configuration) plugins support component testing\n\nUse the `cypress-component-configuration` generator from the respective plugin to add component testing to a project.\n\n```bash\nnx g @nrwl/react:cypress-component-configuration --project=your-project\n\nnx g @nrwl/angular:cypress-component-configuration --project=your-project\n```\n\nYou can optionally pass in `--generate-tests` to create component tests for all components within the library.\n\nComponent testing leverages a build target within your workspace as the base for running the tests. The build target is usually an app within the workspace. By default, the generator attempts to find the build target for you based on the project usage, but you can manually specify the build target to use via the `--build-target` option.\n\n```bash\nnx g @nrwl/react:cypress-component-configuration --project=your-project --build-target=my-react-app:build\n\nnx g @nrwl/angular:cypress-component-configuration --project=your-project --build-target=my-ng-app:build\n```\n\nThe build target option can be changed later via updating the `devServerTarget` option in the `component-test` target.\n\n{% callout type=\"warning\" title=\"Executor Options\" %}\nWhen using component testing make sure to set `skipServe: true` in the component test target options, otherwise `@nrwl/cypress` will attempt to run the build first which can slow down your component tests. `skipServe: true` is automatically set when using the `cypress-component-configuration` generator.\n{% /callout %}\n\n## Testing Projects\n\nRun `nx component-test your-lib` to execute the component tests with Cypress.\n\nBy default, Cypress will run in headless mode. You will have the result of all the tests and errors (if any) in your\nterminal. Screenshots and videos will be accessible in `dist/cypress/libs/your-lib/screenshots` and `dist/cypress/libs/your-lib/videos`.\n\n## Watching for Changes (Headed Mode)\n\nWith, `nx component-test your-lib --watch` Cypress will start in headed mode. Where you can see your component being tested.\n\nRunning Cypress with `--watch` is a great way to iterate on your components since cypress will rerun your tests as you make those changes validating the new behavior.\n\n## More Information\n\nYou can read more on component testing in the [Cypress documentation](https://docs.cypress.io/guides/component-testing/writing-your-first-component-test).\n" }, { "name": "v10 Migration Guide", diff --git a/docs/packages.json b/docs/packages.json index 2edfb11eb0..3b478c4c55 100644 --- a/docs/packages.json +++ b/docs/packages.json @@ -25,6 +25,7 @@ "component", "component-cypress-spec", "component-story", + "component-test", "convert-tslint-to-eslint", "downgrade-module", "init", @@ -46,6 +47,7 @@ "setup-tailwind", "stories", "storybook-configuration", + "cypress-component-configuration", "upgrade-module", "web-worker", "change-storybook-targets" diff --git a/docs/shared/cypress-component-testing.md b/docs/shared/cypress-component-testing.md index 005e836cea..346ba0377d 100644 --- a/docs/shared/cypress-component-testing.md +++ b/docs/shared/cypress-component-testing.md @@ -1,17 +1,39 @@ # Cypress Component Testing -> Component testing is in a early preview and requires Cypress v10 and above. +> Component testing requires Cypress v10 and above. > See our [guide for more information](/cypress/v10-migration-guide) to migrate to Cypress v10. Unlike [E2E testing](/packages/cypress), component testing does not create a new project. Instead, Cypress component testing is added -directly to a project. +directly to a project, like [Jest](/packages/jest) + +## Add Component Testing to a Project + +> Currently only [@nrwl/react](/packages/react/generators/cypress-component-configuration) and [@nrwl/angular](/packages/angular/generators/cypress-component-configuration) plugins support component testing + +Use the `cypress-component-configuration` generator from the respective plugin to add component testing to a project. ```bash -nx g @nrwl/react:cypress-component-configuration --project=your-react-lib +nx g @nrwl/react:cypress-component-configuration --project=your-project + +nx g @nrwl/angular:cypress-component-configuration --project=your-project ``` You can optionally pass in `--generate-tests` to create component tests for all components within the library. +Component testing leverages a build target within your workspace as the base for running the tests. The build target is usually an app within the workspace. By default, the generator attempts to find the build target for you based on the project usage, but you can manually specify the build target to use via the `--build-target` option. + +```bash +nx g @nrwl/react:cypress-component-configuration --project=your-project --build-target=my-react-app:build + +nx g @nrwl/angular:cypress-component-configuration --project=your-project --build-target=my-ng-app:build +``` + +The build target option can be changed later via updating the `devServerTarget` option in the `component-test` target. + +{% callout type="warning" title="Executor Options" %} +When using component testing make sure to set `skipServe: true` in the component test target options, otherwise `@nrwl/cypress` will attempt to run the build first which can slow down your component tests. `skipServe: true` is automatically set when using the `cypress-component-configuration` generator. +{% /callout %} + ## Testing Projects Run `nx component-test your-lib` to execute the component tests with Cypress. diff --git a/e2e/angular-extensions/src/cypress-component-tests.test.ts b/e2e/angular-extensions/src/cypress-component-tests.test.ts new file mode 100644 index 0000000000..192b836d27 --- /dev/null +++ b/e2e/angular-extensions/src/cypress-component-tests.test.ts @@ -0,0 +1,253 @@ +import { + checkFilesDoNotExist, + createFile, + newProject, + runCLI, + uniq, + updateFile, +} from '../../utils'; +import { names } from '@nrwl/devkit'; +describe('Angular Cypress Component Tests', () => { + let projectName: string; + const appName = uniq('cy-angular-app'); + const usedInAppLibName = uniq('cy-angular-lib'); + const buildableLibName = uniq('cy-angular-buildable-lib'); + + beforeAll(() => { + projectName = newProject({ name: uniq('cy-ng') }); + runCLI(`generate @nrwl/angular:app ${appName} --no-interactive`); + runCLI( + `generate @nrwl/angular:component fancy-component --project=${appName} --no-interactive` + ); + runCLI(`generate @nrwl/angular:lib ${usedInAppLibName} --no-interactive`); + runCLI( + `generate @nrwl/angular:component btn --project=${usedInAppLibName} --inlineTemplate --inlineStyle --export --no-interactive` + ); + runCLI( + `generate @nrwl/angular:component btn-standalone --project=${usedInAppLibName} --inlineTemplate --inlineStyle --export --standalone --no-interactive` + ); + updateFile( + `libs/${usedInAppLibName}/src/lib/btn/btn.component.ts`, + ` +import { Component, Input } from '@angular/core'; + +@Component({ + selector: '${projectName}-btn', + template: '', + styles: [] +}) +export class BtnComponent { + @Input() text = 'something'; +} +` + ); + updateFile( + `libs/${usedInAppLibName}/src/lib/btn-standalone/btn-standalone.component.ts`, + ` +import { Component, Input } from '@angular/core'; +import { CommonModule } from '@angular/common'; +@Component({ + selector: '${projectName}-btn-standalone', + standalone: true, + imports: [CommonModule], + template: '', + styles: [], +}) +export class BtnStandaloneComponent { + @Input() text = 'something'; +} +` + ); + // use lib in the app + createFile( + `apps/${appName}/src/app/app.component.html`, + ` +<${projectName}-btn> +<${projectName}-btn-standalone> +<${projectName}-nx-welcome> +` + ); + const btnModuleName = names(usedInAppLibName).className; + + updateFile( + `apps/${appName}/src/app/app.module.ts`, + ` +import { NgModule } from '@angular/core'; +import { BrowserModule } from '@angular/platform-browser'; +import {${btnModuleName}Module} from "@${projectName}/${usedInAppLibName}"; + +import { AppComponent } from './app.component'; +import { NxWelcomeComponent } from './nx-welcome.component'; + +@NgModule({ + declarations: [AppComponent, NxWelcomeComponent], + imports: [BrowserModule, ${btnModuleName}Module], + providers: [], + bootstrap: [AppComponent], +}) +export class AppModule {} +` + ); + + runCLI( + `generate @nrwl/angular:lib ${buildableLibName} --buildable --no-interactive` + ); + runCLI( + `generate @nrwl/angular:component input --project=${buildableLibName} --inlineTemplate --inlineStyle --export --no-interactive` + ); + runCLI( + `generate @nrwl/angular:component input-standalone --project=${buildableLibName} --inlineTemplate --inlineStyle --export --standalone --no-interactive` + ); + updateFile( + `libs/${buildableLibName}/src/lib/input/input.component.ts`, + ` +import {Component, Input} from '@angular/core'; + +@Component({ + selector: '${projectName}-input', + template: \`\`, + styles : [] + }) + export class InputComponent{ + @Input() readOnly = false; + } + ` + ); + updateFile( + `libs/${buildableLibName}/src/lib/input-standalone/input-standalone.component.ts`, + ` +import {Component, Input} from '@angular/core'; +import {CommonModule} from '@angular/common'; +@Component({ + selector: '${projectName}-input-standalone', + standalone: true, + imports: [CommonModule], + template: \`\`, + styles : [] + }) + export class InputStandaloneComponent{ + @Input() readOnly = false; + } + ` + ); + }); + + it('should test app', () => { + runCLI( + `generate @nrwl/angular:cypress-component-configuration --project=${appName} --generate-tests` + ); + expect(runCLI(`component-test ${appName} --no-watch`)).toContain( + 'All specs passed!' + ); + }, 1000000); + + it('should successfully component test lib being used in app', () => { + runCLI( + `generate @nrwl/angular:cypress-component-configuration --project=${usedInAppLibName} --generate-tests` + ); + expect(runCLI(`component-test ${usedInAppLibName} --no-watch`)).toContain( + 'All specs passed!' + ); + }, 1000000); + + it('should test buildable lib not being used in app', () => { + expect(() => { + // should error since no edge in graph between lib and app + runCLI( + `generate @nrwl/angular:cypress-component-configuration --project=${buildableLibName} --generate-tests` + ); + }).toThrow(); + createFile( + `libs/${buildableLibName}/src/lib/input/input.component.cy.ts`, + ` +import { MountConfig, mount } from 'cypress/angular'; +import { InputComponent } from './input.component'; + +describe(InputComponent.name, () => { + const config: MountConfig = { + declarations: [], + imports: [], + providers: [], + }; + + it('renders', () => { + mount(InputComponent, config); + // make sure tailwind isn't getting applied + cy.get('label').should('have.css', 'color', 'rgb(0, 0, 0)'); + }); + it('should be readonly', () => { + mount(InputComponent, { + ...config, + componentProperties: { + readOnly: true, + }, + }); + cy.get('input').should('have.attr', 'readonly'); + }); +}); +` + ); + + createFile( + `libs/${buildableLibName}/src/lib/input-standalone/input-standalone.component.cy.ts`, + ` +import { MountConfig, mount } from 'cypress/angular'; +import { InputStandaloneComponent } from './input-standalone.component'; + +describe(InputStandaloneComponent.name, () => { + const config: MountConfig = { + declarations: [], + imports: [], + providers: [], + }; + + it('renders', () => { + mount(InputStandaloneComponent, config); + // make sure tailwind isn't getting applied + cy.get('label').should('have.css', 'color', 'rgb(0, 0, 0)'); + }); + it('should be readonly', () => { + mount(InputStandaloneComponent, { + ...config, + componentProperties: { + readOnly: true, + }, + }); + cy.get('input').should('have.attr', 'readonly'); + }); +}); +` + ); + + runCLI( + `generate @nrwl/angular:cypress-component-configuration --project=${buildableLibName} --generate-tests --build-target=${appName}:build` + ); + expect(runCLI(`component-test ${buildableLibName} --no-watch`)).toContain( + 'All specs passed!' + ); + + // add tailwind + runCLI( + `generate @nrwl/angular:setup-tailwind --project=${buildableLibName}` + ); + updateFile( + `libs/${buildableLibName}/src/lib/input/input.component.cy.ts`, + (content) => { + // text-green-500 should now apply + return content.replace('rgb(0, 0, 0)', 'rgb(34, 197, 94)'); + } + ); + updateFile( + `libs/${buildableLibName}/src/lib/input-standalone/input-standalone.component.cy.ts`, + (content) => { + // text-green-500 should now apply + return content.replace('rgb(0, 0, 0)', 'rgb(34, 197, 94)'); + } + ); + + expect(runCLI(`component-test ${buildableLibName} --no-watch`)).toContain( + 'All specs passed!' + ); + checkFilesDoNotExist(`tmp/libs/${buildableLibName}/ct-styles.css`); + }, 1000000); +}); diff --git a/packages/angular/generators.json b/packages/angular/generators.json index ff2d3c9929..face17054b 100644 --- a/packages/angular/generators.json +++ b/packages/angular/generators.json @@ -33,6 +33,11 @@ "schema": "./src/generators/component-story/schema.json", "description": "Creates a `stories.ts` file for a component." }, + "component-test": { + "factory": "./src/generators/component-test/compat", + "schema": "./src/generators/component-test/schema.json", + "description": "Creates a cypress component test file for a component." + }, "convert-tslint-to-eslint": { "factory": "./src/generators/convert-tslint-to-eslint/convert-tslint-to-eslint#conversionSchematic", "schema": "./src/generators/convert-tslint-to-eslint/schema.json", @@ -146,6 +151,11 @@ "schema": "./src/generators/storybook-configuration/schema.json", "description": "Adds Storybook configuration to a project." }, + "cypress-component-configuration": { + "factory": "./src/generators/cypress-component-configuration/compat", + "schema": "./src/generators/cypress-component-configuration/schema.json", + "description": "Setup Cypress component testing for a project." + }, "upgrade-module": { "factory": "./src/generators/upgrade-module/compat", "schema": "./src/generators/upgrade-module/schema.json", @@ -194,6 +204,11 @@ "schema": "./src/generators/component-story/schema.json", "description": "Creates a stories.ts file for a component." }, + "component-test": { + "factory": "./src/generators/component-test/component-test", + "schema": "./src/generators/component-test/schema.json", + "description": "Creates a cypress component test file for a component." + }, "convert-tslint-to-eslint": { "factory": "./src/generators/convert-tslint-to-eslint/convert-tslint-to-eslint#conversionGenerator", "schema": "./src/generators/convert-tslint-to-eslint/schema.json", @@ -307,6 +322,11 @@ "schema": "./src/generators/storybook-configuration/schema.json", "description": "Adds Storybook configuration to a project." }, + "cypress-component-configuration": { + "factory": "./src/generators/cypress-component-configuration/cypress-component-configuration", + "schema": "./src/generators/cypress-component-configuration/schema.json", + "description": "Setup Cypress component testing for a project." + }, "upgrade-module": { "factory": "./src/generators/upgrade-module/upgrade-module", "schema": "./src/generators/upgrade-module/schema.json", diff --git a/packages/angular/generators.ts b/packages/angular/generators.ts index ad0a4eb927..911054ded6 100644 --- a/packages/angular/generators.ts +++ b/packages/angular/generators.ts @@ -25,4 +25,6 @@ export * from './src/generators/stories/stories'; export * from './src/generators/storybook-configuration/storybook-configuration'; export * from './src/generators/upgrade-module/upgrade-module'; export * from './src/generators/web-worker/web-worker'; +export * from './src/generators/cypress-component-configuration/cypress-component-configuration'; +export * from './src/generators/component-test/component-test'; export * from './src/utils/test-runners'; diff --git a/packages/angular/package.json b/packages/angular/package.json index 3af78dc203..62226d005c 100644 --- a/packages/angular/package.json +++ b/packages/angular/package.json @@ -22,7 +22,8 @@ "./executors": "./executors.js", "./tailwind": "./tailwind.js", "./src/generators/utils": "./src/generators/utils/index.js", - "./module-federation": "./module-federation/index.js" + "./module-federation": "./module-federation/index.js", + "./plugins/component-testing": "./plugins/component-testing.js" }, "author": "Victor Savkin", "license": "MIT", diff --git a/packages/angular/plugins/component-testing.ts b/packages/angular/plugins/component-testing.ts new file mode 100644 index 0000000000..58adc38424 --- /dev/null +++ b/packages/angular/plugins/component-testing.ts @@ -0,0 +1,294 @@ +import { + createExecutorContext, + getProjectConfigByPath, + nxBaseCypressPreset, + NxComponentTestingOptions, +} from '@nrwl/cypress/plugins/cypress-preset'; +import { + getTempTailwindPath, + isCtProjectUsingBuildProject, +} from '@nrwl/cypress/src/utils/ct-helpers'; +import { + ExecutorContext, + joinPathFragments, + logger, + offsetFromRoot, + parseTargetString, + ProjectConfiguration, + ProjectGraph, + readCachedProjectGraph, + readTargetOptions, + stripIndents, + workspaceRoot, +} from '@nrwl/devkit'; +import { mapProjectGraphFiles } from '@nrwl/workspace/src/utils/runtime-lint-utils'; +import { lstatSync, mkdirSync, writeFileSync } from 'fs'; +import { dirname, join, relative } from 'path'; +import type { BrowserBuilderSchema } from '../src/builders/webpack-browser/webpack-browser.impl'; + +/** + * Angular nx preset for Cypress Component Testing + * + * This preset contains the base configuration + * for your component tests that nx recommends. + * including a devServer that supports nx workspaces. + * you can easily extend this within your cypress config via spreading the preset + * @example + * export default defineConfig({ + * component: { + * ...nxComponentTestingPreset(__filename) + * // add your own config here + * } + * }) + * + * @param pathToConfig will be used for loading project options and to construct the output paths for videos and screenshots + * @param options override options + */ +export function nxComponentTestingPreset( + pathToConfig: string, + options?: NxComponentTestingOptions +) { + let graph: ProjectGraph; + try { + graph = readCachedProjectGraph(); + } catch (e) { + throw new Error( + // don't want to strip indents so error stack has correct indentation + `Unable to read the project graph for component testing. +This is likely due to not running via nx. i.e. 'nx component-test my-project'. +Please open an issue if this error persists. +${e.stack ? e.stack : e}` + ); + } + + const ctProjectConfig = getProjectConfigByPath(graph, pathToConfig); + const ctConfigurationName = process.env.NX_CYPRESS_TARGET_CONFIGURATION; + const ctContext = createExecutorContext( + graph, + ctProjectConfig.targets, + ctProjectConfig.name, + options?.ctTargetName || 'component-test', + ctConfigurationName + ); + + const buildTarget = getBuildableTarget(ctContext); + + if (!buildTarget.project && !graph.nodes?.[buildTarget.project]?.data) { + throw new Error(stripIndents`Unable to find project configuration for build target. + Project Name? ${buildTarget.project} + Has project config? ${!!graph.nodes?.[buildTarget.project]?.data}`); + } + + const fromWorkspaceRoot = relative(workspaceRoot, pathToConfig); + const normalizedFromWorkspaceRootPath = lstatSync(pathToConfig).isFile() + ? dirname(fromWorkspaceRoot) + : fromWorkspaceRoot; + const offset = offsetFromRoot(normalizedFromWorkspaceRootPath); + const buildContext = createExecutorContext( + graph, + graph.nodes[buildTarget.project].data.targets, + buildTarget.project, + buildTarget.target, + buildTarget.configuration + ); + + const buildableProjectConfig = normalizeBuildTargetOptions( + buildContext, + ctContext, + offset + ); + + return { + ...nxBaseCypressPreset(pathToConfig), + // NOTE: cannot use a glob pattern since it will break cypress generated tsconfig. + specPattern: ['**/*.cy.ts', '**/*.cy.js'], + devServer: { + // cypress uses string union type, + // need to use const to prevent typing to string + ...({ + framework: 'angular', + bundler: 'webpack', + } as const), + options: { + projectConfig: buildableProjectConfig, + }, + }, + }; +} + +function getBuildableTarget(ctContext: ExecutorContext) { + const targets = + ctContext.projectGraph.nodes[ctContext.projectName].data?.targets; + const targetConfig = targets?.[ctContext.targetName]; + + if (!targetConfig) { + throw new Error( + stripIndents`Unable to find component testing target configuration in project '${ + ctContext.projectName + }'. + Has targets? ${!!targets} + Has target name? ${ctContext.targetName} + Has ct project name? ${ctContext.projectName} + ` + ); + } + + const cypressCtOptions = readTargetOptions( + { + project: ctContext.projectName, + target: ctContext.targetName, + configuration: ctContext.configurationName, + }, + ctContext + ); + + if (!cypressCtOptions.devServerTarget) { + throw new Error( + `Unable to find the 'devServerTarget' executor option in the '${ctContext.targetName}' target of the '${ctContext.projectName}' project` + ); + } + + return parseTargetString(cypressCtOptions.devServerTarget); +} + +function normalizeBuildTargetOptions( + buildContext: ExecutorContext, + ctContext: ExecutorContext, + offset: string +): { root: string; sourceRoot: string; buildOptions: BrowserBuilderSchema } { + const options = readTargetOptions( + { + project: buildContext.projectName, + target: buildContext.targetName, + configuration: buildContext.configurationName, + }, + buildContext + ); + const buildOptions = withSchemaDefaults(options); + + // paths need to be unix paths for angular devkit + buildOptions.polyfills = joinPathFragments(offset, buildOptions.polyfills); + buildOptions.main = joinPathFragments(offset, buildOptions.main); + buildOptions.index = + typeof buildOptions.index === 'string' + ? joinPathFragments(offset, buildOptions.index) + : (buildOptions.index.input = joinPathFragments( + offset, + buildOptions.index.input + )); + buildOptions.tsConfig = joinPathFragments(offset, buildOptions.tsConfig); + buildOptions.fileReplacements = buildOptions.fileReplacements.map((fr) => { + fr.replace = joinPathFragments(offset, fr.replace); + fr.with = joinPathFragments(offset, fr.with); + return fr; + }); + + // if the ct project isn't being used in the build project + // then we don't want to have the assets/scripts/styles be included to + // prevent inclusion of unintended stuff like tailwind + if ( + isCtProjectUsingBuildProject( + ctContext.projectGraph, + buildContext.projectName, + ctContext.projectName + ) + ) { + buildOptions.assets = buildOptions.assets.map((asset) => { + return typeof asset === 'string' + ? joinPathFragments(offset, asset) + : (asset.input = joinPathFragments(offset, asset.input)); + }); + buildOptions.styles = buildOptions.styles.map((style) => { + return typeof style === 'string' + ? joinPathFragments(offset, style) + : (style.input = joinPathFragments(offset, style.input)); + }); + buildOptions.scripts = buildOptions.scripts.map((script) => { + return typeof script === 'string' + ? joinPathFragments(offset, script) + : (script.input = joinPathFragments(offset, script.input)); + }); + } else { + const stylePath = getTempStylesForTailwind(ctContext); + buildOptions.styles = stylePath ? [stylePath] : []; + buildOptions.assets = []; + buildOptions.scripts = []; + } + const { root, sourceRoot } = + buildContext.projectGraph.nodes[buildContext.projectName].data; + return { + root: joinPathFragments(offset, root), + sourceRoot: joinPathFragments(offset, sourceRoot), + buildOptions, + }; +} + +function withSchemaDefaults(options: any): BrowserBuilderSchema { + if (!options.main) { + throw new Error('Missing executor options "main"'); + } + if (!options.index) { + throw new Error('Missing executor options "index"'); + } + if (!options.tsConfig) { + throw new Error('Missing executor options "tsConfig"'); + } + + // cypress defaults aot to false so we cannot use buildOptimizer + // otherwise the 'buildOptimizer' cannot be used without 'aot' error is thrown + options.buildOptimizer = false; + options.aot = false; + options.assets ??= []; + options.allowedCommonJsDependencies ??= []; + options.budgets ??= []; + options.commonChunk ??= true; + options.crossOrigin ??= 'none'; + options.deleteOutputPath ??= true; + options.extractLicenses ??= true; + options.fileReplacements ??= []; + options.inlineStyleLanguage ??= 'css'; + options.i18nDuplicateTranslation ??= 'warning'; + options.outputHashing ??= 'none'; + options.progress ??= true; + options.scripts ??= []; + + return options; +} + +/** + * @returns a path from the workspace root to a temp file containing the base tailwind setup + * if tailwind is being used in the project root or workspace root + * this file should get cleaned up via the cypress executor + */ +function getTempStylesForTailwind(ctExecutorContext: ExecutorContext) { + const mappedGraph = mapProjectGraphFiles(ctExecutorContext.projectGraph); + const ctProjectConfig = ctExecutorContext.projectGraph.nodes[ + ctExecutorContext.projectName + ].data as ProjectConfiguration; + // angular only supports `tailwind.config.{js,cjs}` + const ctProjectTailwindConfig = join(ctProjectConfig.root, 'tailwind.config'); + const isTailWindInCtProject = !!mappedGraph.allFiles[ctProjectTailwindConfig]; + const isTailWindInRoot = !!mappedGraph.allFiles['tailwind.config']; + + if (isTailWindInRoot || isTailWindInCtProject) { + const pathToStyle = getTempTailwindPath(ctExecutorContext); + try { + mkdirSync(dirname(pathToStyle), { recursive: true }); + writeFileSync( + pathToStyle, + ` +@tailwind base; +@tailwind components; +@tailwind utilities; +`, + { encoding: 'utf-8' } + ); + + return pathToStyle; + } catch (makeTmpFileError) { + logger.warn(stripIndents`Issue creating a temp file for tailwind styles. Defaulting to no tailwind setup. + Temp file path? ${pathToStyle}`); + logger.error(makeTmpFileError); + } + } +} diff --git a/packages/angular/src/generators/component-cypress-spec/component-cypress-spec.spec.ts b/packages/angular/src/generators/component-cypress-spec/component-cypress-spec.spec.ts index 9a78cab9ff..4dba2c4e1d 100644 --- a/packages/angular/src/generators/component-cypress-spec/component-cypress-spec.spec.ts +++ b/packages/angular/src/generators/component-cypress-spec/component-cypress-spec.spec.ts @@ -4,7 +4,7 @@ import * as devkit from '@nrwl/devkit'; import { wrapAngularDevkitSchematic } from '@nrwl/devkit/ngcli-adapter'; import { createTreeWithEmptyV1Workspace } from '@nrwl/devkit/testing'; import { applicationGenerator } from '../application/application'; -import * as storybookUtils from '../utils/storybook'; +import * as storybookUtils from '../utils/storybook-ast/storybook-inputs'; import { componentCypressSpecGenerator } from './component-cypress-spec'; // need to mock cypress otherwise it'll use the nx installed version from package.json // which is v9 while we are testing for the new v10 version diff --git a/packages/angular/src/generators/component-cypress-spec/component-cypress-spec.ts b/packages/angular/src/generators/component-cypress-spec/component-cypress-spec.ts index 8eab2125ac..dbee3c2d86 100644 --- a/packages/angular/src/generators/component-cypress-spec/component-cypress-spec.ts +++ b/packages/angular/src/generators/component-cypress-spec/component-cypress-spec.ts @@ -5,7 +5,7 @@ import { joinPathFragments, readProjectConfiguration, } from '@nrwl/devkit'; -import { getComponentProps } from '../utils/storybook'; +import { getComponentProps } from '../utils/storybook-ast/storybook-inputs'; import { getArgsDefaultValue } from './lib/get-args-default-value'; import { getComponentSelector } from './lib/get-component-selector'; import type { ComponentCypressSpecGeneratorOptions } from './schema'; diff --git a/packages/angular/src/generators/component-story/component-story.spec.ts b/packages/angular/src/generators/component-story/component-story.spec.ts index ab3d721850..0d81b920c6 100644 --- a/packages/angular/src/generators/component-story/component-story.spec.ts +++ b/packages/angular/src/generators/component-story/component-story.spec.ts @@ -3,7 +3,7 @@ import * as devkit from '@nrwl/devkit'; import { wrapAngularDevkitSchematic } from '@nrwl/devkit/ngcli-adapter'; import { createTreeWithEmptyV1Workspace } from '@nrwl/devkit/testing'; import { libraryGenerator } from '../library/library'; -import * as storybookUtils from '../utils/storybook'; +import * as storybookUtils from '../utils/storybook-ast/storybook-inputs'; import { componentStoryGenerator } from './component-story'; describe('componentStory generator', () => { diff --git a/packages/angular/src/generators/component-story/component-story.ts b/packages/angular/src/generators/component-story/component-story.ts index b0ec733088..ae80a8b6e9 100644 --- a/packages/angular/src/generators/component-story/component-story.ts +++ b/packages/angular/src/generators/component-story/component-story.ts @@ -1,7 +1,6 @@ import type { Tree } from '@nrwl/devkit'; import { formatFiles, generateFiles, joinPathFragments } from '@nrwl/devkit'; -import { getComponentProps } from '../utils/storybook'; -import { getArgsDefaultValue } from './lib/get-args-default-value'; +import { getComponentProps } from '../utils/storybook-ast/storybook-inputs'; import type { ComponentStoryGeneratorOptions } from './schema'; export function componentStoryGenerator( @@ -24,8 +23,7 @@ export function componentStoryGenerator( const props = getComponentProps( tree, - joinPathFragments(destinationDir, `${componentFileName}.ts`), - getArgsDefaultValue + joinPathFragments(destinationDir, `${componentFileName}.ts`) ); generateFiles(tree, templatesDir, destinationDir, { diff --git a/packages/angular/src/generators/component-story/lib/get-args-default-value.ts b/packages/angular/src/generators/component-story/lib/get-args-default-value.ts deleted file mode 100644 index ae63054e0a..0000000000 --- a/packages/angular/src/generators/component-story/lib/get-args-default-value.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { PropertyDeclaration } from 'typescript'; - -export function getArgsDefaultValue(property: PropertyDeclaration): string { - const typeNameToDefault = { - string: "''", - number: '0', - boolean: 'false', - }; - return property.initializer - ? property.initializer.getText() - : property.type - ? typeNameToDefault[property.type.getText()] - : "''"; -} diff --git a/packages/angular/src/generators/component-story/schema.json b/packages/angular/src/generators/component-story/schema.json index 440c1095a3..5ea4cf17d0 100644 --- a/packages/angular/src/generators/component-story/schema.json +++ b/packages/angular/src/generators/component-story/schema.json @@ -1,5 +1,5 @@ { - "$schema": "http://json-schema.org/schema", + "$schema": "https://json-schema.org/schema", "$id": "NxAngularComponentStoryGenerator", "type": "object", "cli": "nx", diff --git a/packages/angular/src/generators/component-test/__snapshots__/component-test.spec.ts.snap b/packages/angular/src/generators/component-test/__snapshots__/component-test.spec.ts.snap new file mode 100644 index 0000000000..e3f31f3c36 --- /dev/null +++ b/packages/angular/src/generators/component-test/__snapshots__/component-test.spec.ts.snap @@ -0,0 +1,71 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Angular Cypress Component Test Generator should generate a component test 1`] = ` +"import { MountConfig, mount } from 'cypress/angular'; +import { MyLibComponent } from './my-lib.component'; + +describe(MyLibComponent.name, () => { + const config: MountConfig = { + declarations: [], + imports: [], + providers: [] + } + + it('renders', () => { + mount(MyLibComponent, { + ...config, + componentProperties: { + type: 'button', + style: 'default', + age: 0, + isOn: false, + } + }); + }) +}) +" +`; + +exports[`Angular Cypress Component Test Generator should handle component w/o inputs 1`] = ` +"import { MountConfig, mount } from 'cypress/angular'; +import { MyLibComponent } from './my-lib.component'; + +describe(MyLibComponent.name, () => { + const config: MountConfig = { + declarations: [], + imports: [], + providers: [] + } + + it('renders', () => { + mount(MyLibComponent, config); + }) +}) +" +`; + +exports[`Angular Cypress Component Test Generator should work with standalone components 1`] = ` +"import { MountConfig, mount } from 'cypress/angular'; +import { MyLibComponent } from './my-lib.component'; + +describe(MyLibComponent.name, () => { + const config: MountConfig = { + declarations: [], + imports: [], + providers: [] + } + + it('renders', () => { + mount(MyLibComponent, { + ...config, + componentProperties: { + type: 'button', + style: 'default', + age: 0, + isOn: false, + } + }); + }) +}) +" +`; diff --git a/packages/angular/src/generators/component-test/compat.ts b/packages/angular/src/generators/component-test/compat.ts new file mode 100644 index 0000000000..8245c03054 --- /dev/null +++ b/packages/angular/src/generators/component-test/compat.ts @@ -0,0 +1,4 @@ +import { convertNxGenerator } from '@nrwl/devkit'; +import { componentTestGenerator } from './component-test'; + +export default convertNxGenerator(componentTestGenerator); diff --git a/packages/angular/src/generators/component-test/component-test.spec.ts b/packages/angular/src/generators/component-test/component-test.spec.ts new file mode 100644 index 0000000000..a31a040c1d --- /dev/null +++ b/packages/angular/src/generators/component-test/component-test.spec.ts @@ -0,0 +1,216 @@ +import { assertMinimumCypressVersion } from '@nrwl/cypress/src/utils/cypress-version'; +import { Tree } from '@nrwl/devkit'; +import { createTreeWithEmptyWorkspace } from '@nrwl/devkit/testing'; +import { Linter } from '@nrwl/linter'; +import { UnitTestRunner } from '../../utils/test-runners'; +import { componentGenerator } from '../component/component'; +import { libraryGenerator } from '../library/library'; +import { componentTestGenerator } from './component-test'; +jest.mock('@nrwl/cypress/src/utils/cypress-version'); +describe('Angular Cypress Component Test Generator', () => { + let tree: Tree; + let mockedAssertMinimumCypressVersion: jest.Mock< + ReturnType + > = assertMinimumCypressVersion as never; + beforeEach(() => { + tree = createTreeWithEmptyWorkspace(); + // silence warnings about missing .gitignore file + tree.write('.gitignore', ''); + mockedAssertMinimumCypressVersion.mockReturnValue(); + }); + + it('should handle component w/o inputs', async () => { + await libraryGenerator(tree, { + name: 'my-lib', + unitTestRunner: UnitTestRunner.None, + linter: Linter.None, + }); + await componentGenerator(tree, { + project: 'my-lib', + name: 'my-lib', + }); + componentTestGenerator(tree, { + componentName: 'MyLibComponent', + componentFileName: './my-lib.component', + project: 'my-lib', + componentDir: 'src/lib/my-lib', + }); + expect( + tree.read('libs/my-lib/src/lib/my-lib/my-lib.component.cy.ts', 'utf-8') + ).toMatchSnapshot(); + }); + + it('should generate a component test', async () => { + await libraryGenerator(tree, { + name: 'my-lib', + unitTestRunner: UnitTestRunner.None, + linter: Linter.None, + }); + await componentGenerator(tree, { + project: 'my-lib', + name: 'my-lib', + }); + + tree.write( + 'libs/my-lib/src/lib/my-lib/my-lib.component.ts', + ` +import { Component, OnInit, Input } from '@angular/core'; + +export type ButtonStyle = 'default' | 'primary' | 'accent'; + +@Component({ + selector: 'proj-my-lib', + templateUrl: './my-lib.component.html', + styleUrls: ['./my-lib.component.css'] +}) +export class MyLibComponent implements OnInit { + @Input('buttonType') type = 'button'; + @Input() style: ButtonStyle = 'default'; + @Input() age?: number; + @Input() isOn = false; + @Input() message: string | undefined; + @Input() anotherProp: any; + @Input() anotherNeverProp: never; + + constructor() { } + + ngOnInit(): void { + } + +}` + ); + + componentTestGenerator(tree, { + componentName: 'MyLibComponent', + componentFileName: './my-lib.component', + project: 'my-lib', + componentDir: 'src/lib/my-lib', + }); + + expect( + tree.read('libs/my-lib/src/lib/my-lib/my-lib.component.cy.ts', 'utf-8') + ).toMatchSnapshot(); + }); + + it('should work with standalone components', async () => { + await libraryGenerator(tree, { + name: 'my-lib', + unitTestRunner: UnitTestRunner.None, + linter: Linter.None, + }); + await componentGenerator(tree, { + project: 'my-lib', + name: 'my-lib', + standalone: true, + }); + tree.write( + 'libs/my-lib/src/lib/my-lib/my-lib.component.ts', + ` +import { Component, OnInit, Input } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +@Component({ + selector: 'proj-my-lib', + standalone: true, + imports: [CommonModule], + templateUrl: './my-lib.component.html', + styleUrls: ['./my-lib.component.css'] +}) +export class MyLibComponent implements OnInit { + @Input('buttonType') type = 'button'; + @Input() style: ButtonStyle = 'default'; + @Input() age?: number; + @Input() isOn = false; + @Input() message: string | undefined; + @Input() anotherProp: any; + @Input() anotherNeverProp: never; + constructor() { } + + ngOnInit(): void { + } + +} +` + ); + componentTestGenerator(tree, { + componentName: 'MyLibComponent', + componentFileName: './my-lib.component', + project: 'my-lib', + componentDir: 'src/lib/my-lib', + }); + expect( + tree.read('libs/my-lib/src/lib/my-lib/my-lib.component.cy.ts', 'utf-8') + ).toMatchSnapshot(); + }); + + it('should not overwrite an existing component test', async () => { + await libraryGenerator(tree, { + name: 'my-lib', + unitTestRunner: UnitTestRunner.None, + linter: Linter.None, + }); + + await componentGenerator(tree, { name: 'my-lib', project: 'my-lib' }); + tree.write( + 'libs/my-lib/src/lib/my-lib/my-lib.component.cy.ts', + `should not overwrite` + ); + + componentTestGenerator(tree, { + componentName: 'MyLibComponent', + componentFileName: './my-lib.component', + project: 'my-lib', + componentDir: 'src/lib/my-lib', + }); + + expect( + tree.read('libs/my-lib/src/lib/my-lib/my-lib.component.cy.ts', 'utf-8') + ).toEqual('should not overwrite'); + }); + + it('should be idempotent', async () => { + await libraryGenerator(tree, { + name: 'my-lib', + unitTestRunner: UnitTestRunner.None, + linter: Linter.None, + }); + + await componentGenerator(tree, { name: 'my-lib', project: 'my-lib' }); + + const expected = `import { MountConfig, mount } from 'cypress/angular'; +import { MyLibComponent } from './my-lib.component'; + +describe(MyLibComponent.name, () => { + const config: MountConfig = { + declarations: [], + imports: [], + providers: [] + } + + it('renders', () => { + mount(MyLibComponent, config); + }) +}) +`; + + componentTestGenerator(tree, { + componentName: 'MyLibComponent', + componentFileName: './my-lib.component', + project: 'my-lib', + componentDir: 'src/lib/my-lib', + }); + expect( + tree.read('libs/my-lib/src/lib/my-lib/my-lib.component.cy.ts', 'utf-8') + ).toEqual(expected); + + componentTestGenerator(tree, { + componentName: 'MyLibComponent', + componentFileName: './my-lib.component', + project: 'my-lib', + componentDir: 'src/lib/my-lib', + }); + expect( + tree.read('libs/my-lib/src/lib/my-lib/my-lib.component.cy.ts', 'utf-8') + ).toEqual(expected); + }); +}); diff --git a/packages/angular/src/generators/component-test/component-test.ts b/packages/angular/src/generators/component-test/component-test.ts new file mode 100644 index 0000000000..f0ca2240af --- /dev/null +++ b/packages/angular/src/generators/component-test/component-test.ts @@ -0,0 +1,51 @@ +import { assertMinimumCypressVersion } from '@nrwl/cypress/src/utils/cypress-version'; +import { + generateFiles, + joinPathFragments, + readProjectConfiguration, + Tree, +} from '@nrwl/devkit'; +import { + getArgsDefaultValue, + getComponentProps, +} from '../utils/storybook-ast/storybook-inputs'; +import { ComponentTestSchema } from './schema'; + +export function componentTestGenerator( + tree: Tree, + options: ComponentTestSchema +) { + assertMinimumCypressVersion(10); + const { root } = readProjectConfiguration(tree, options.project); + const componentDirPath = joinPathFragments(root, options.componentDir); + const componentFilePath = joinPathFragments( + componentDirPath, + `${options.componentFileName}.ts` + ); + const componentTestFilePath = joinPathFragments( + componentDirPath, + `${options.componentFileName}.cy.ts` + ); + + if (tree.exists(componentFilePath) && !tree.exists(componentTestFilePath)) { + const props = getComponentProps( + tree, + componentFilePath, + getArgsDefaultValue, + false + ); + generateFiles( + tree, + joinPathFragments(__dirname, 'files'), + componentDirPath, + { + componentName: options.componentName, + componentFileName: options.componentFileName.startsWith('./') + ? options.componentFileName.slice(2) + : options.componentFileName, + props: props.filter((p) => typeof p.defaultValue !== 'undefined'), + tpl: '', + } + ); + } +} diff --git a/packages/angular/src/generators/component-test/files/__componentFileName__.cy.ts__tpl__ b/packages/angular/src/generators/component-test/files/__componentFileName__.cy.ts__tpl__ new file mode 100644 index 0000000000..13fc0096d1 --- /dev/null +++ b/packages/angular/src/generators/component-test/files/__componentFileName__.cy.ts__tpl__ @@ -0,0 +1,19 @@ +import { MountConfig, mount } from 'cypress/angular'; +import { <%= componentName %> } from './<%= componentFileName %>'; + +describe(<%= componentName %>.name, () => { + const config: MountConfig<<%= componentName %>> = { + declarations: [], + imports: [], + providers: [] + } + + it('renders', () => { + mount(<%= componentName %>,<% if(props.length > 0) { %> { + ...config, + componentProperties: {<% for (let prop of props) { %> + <%= prop.name %>: <%- prop.defaultValue %>,<% } %> + } + }<% } else { %> config<% } %>); + }) +}) diff --git a/packages/angular/src/generators/component-test/schema.d.ts b/packages/angular/src/generators/component-test/schema.d.ts new file mode 100644 index 0000000000..c229053e21 --- /dev/null +++ b/packages/angular/src/generators/component-test/schema.d.ts @@ -0,0 +1,12 @@ +export interface ComponentTestSchema { + project: string; + // SomethingComponent + componentName: string; + // path from source root to component dir + // ./src/lib/something + componentDir: string; + // file name without ext + // something.component + componentFileName: string; + skipFormat?: boolean; +} diff --git a/packages/angular/src/generators/component-test/schema.json b/packages/angular/src/generators/component-test/schema.json new file mode 100644 index 0000000000..5ea9939c2a --- /dev/null +++ b/packages/angular/src/generators/component-test/schema.json @@ -0,0 +1,42 @@ +{ + "$schema": "http://json-schema.org/schema", + "$id": "NxAngularComponentTestGenerator", + "type": "object", + "cli": "nx", + "description": "Create a `*.cy.ts` file for Cypress component testing for an Angular component.", + "properties": { + "project": { + "type": "string", + "description": "The name of the project where the component is located.", + "x-dropdown": "projects", + "x-prompt": "What project is the component located in?" + }, + "componentName": { + "type": "string", + "description": "Class name of the component to create a test for.", + "examples": ["MyFancyButtonComponent"] + }, + "componentDir": { + "type": "string", + "description": "Relative path to the folder that contains the component from the project root.", + "example": ["src/lib/my-fancy-button"] + }, + "componentFileName": { + "type": "string", + "description": "File name that contains the component without the `.ts` extension.", + "examples": ["my-fancy-button.component"] + }, + "skipFormat": { + "description": "Skip formatting files.", + "type": "boolean", + "default": false + } + }, + "additionalProperties": false, + "required": [ + "projectPath", + "componentName", + "componentPath", + "componentFileName" + ] +} diff --git a/packages/angular/src/generators/cypress-component-configuration/__snapshots__/cypress-component-configuration.spec.ts.snap b/packages/angular/src/generators/cypress-component-configuration/__snapshots__/cypress-component-configuration.spec.ts.snap new file mode 100644 index 0000000000..d7f6a74ade --- /dev/null +++ b/packages/angular/src/generators/cypress-component-configuration/__snapshots__/cypress-component-configuration.spec.ts.snap @@ -0,0 +1,301 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Cypress Component Testing Configuration should work with complex component 1`] = ` +"import { MountConfig, mount } from 'cypress/angular'; +import { SomethingOneComponent } from './something-one.component'; + +describe(SomethingOneComponent.name, () => { + const config: MountConfig = { + declarations: [], + imports: [], + providers: [] + } + + it('renders', () => { + mount(SomethingOneComponent, { + ...config, + componentProperties: { + type: 'button', + style: 'default', + age: 0, + isOn: false, + } + }); + }) +}) +" +`; + +exports[`Cypress Component Testing Configuration should work with complex component 2`] = ` +"import { MountConfig, mount } from 'cypress/angular'; +import { SomethingTwoComponent } from './something-two.component'; + +describe(SomethingTwoComponent.name, () => { + const config: MountConfig = { + declarations: [], + imports: [], + providers: [] + } + + it('renders', () => { + mount(SomethingTwoComponent, { + ...config, + componentProperties: { + type: 'button', + style: 'default', + age: 0, + isOn: false, + } + }); + }) +}) +" +`; + +exports[`Cypress Component Testing Configuration should work with complex component 3`] = ` +"import { MountConfig, mount } from 'cypress/angular'; +import { SomethingThreeComponent } from './something-three.component'; + +describe(SomethingThreeComponent.name, () => { + const config: MountConfig = { + declarations: [], + imports: [], + providers: [] + } + + it('renders', () => { + mount(SomethingThreeComponent, { + ...config, + componentProperties: { + type: 'button', + style: 'default', + age: 0, + isOn: false, + } + }); + }) +}) +" +`; + +exports[`Cypress Component Testing Configuration should work with complex standalone component 1`] = ` +"import { MountConfig, mount } from 'cypress/angular'; +import { SomethingOneComponent } from './something-one.component'; + +describe(SomethingOneComponent.name, () => { + const config: MountConfig = { + declarations: [], + imports: [], + providers: [] + } + + it('renders', () => { + mount(SomethingOneComponent, { + ...config, + componentProperties: { + type: 'button', + style: 'default', + age: 0, + isOn: false, + } + }); + }) +}) +" +`; + +exports[`Cypress Component Testing Configuration should work with complex standalone component 2`] = ` +"import { MountConfig, mount } from 'cypress/angular'; +import { SomethingTwoComponent } from './something-two.component'; + +describe(SomethingTwoComponent.name, () => { + const config: MountConfig = { + declarations: [], + imports: [], + providers: [] + } + + it('renders', () => { + mount(SomethingTwoComponent, { + ...config, + componentProperties: { + type: 'button', + style: 'default', + age: 0, + isOn: false, + } + }); + }) +}) +" +`; + +exports[`Cypress Component Testing Configuration should work with complex standalone component 3`] = ` +"import { MountConfig, mount } from 'cypress/angular'; +import { SomethingThreeComponent } from './something-three.component'; + +describe(SomethingThreeComponent.name, () => { + const config: MountConfig = { + declarations: [], + imports: [], + providers: [] + } + + it('renders', () => { + mount(SomethingThreeComponent, { + ...config, + componentProperties: { + type: 'button', + style: 'default', + age: 0, + isOn: false, + } + }); + }) +}) +" +`; + +exports[`Cypress Component Testing Configuration should work with secondary entry point libs 1`] = ` +"import { MountConfig, mount } from 'cypress/angular'; +import { FancyButtonComponent } from './fancy-button.component'; + +describe(FancyButtonComponent.name, () => { + const config: MountConfig = { + declarations: [], + imports: [], + providers: [] + } + + it('renders', () => { + mount(FancyButtonComponent, config); + }) +}) +" +`; + +exports[`Cypress Component Testing Configuration should work with secondary entry point libs 2`] = ` +"import { MountConfig, mount } from 'cypress/angular'; +import { StandaloneFancyButtonComponent } from './standalone-fancy-button.component'; + +describe(StandaloneFancyButtonComponent.name, () => { + const config: MountConfig = { + declarations: [], + imports: [], + providers: [] + } + + it('renders', () => { + mount(StandaloneFancyButtonComponent, config); + }) +}) +" +`; + +exports[`Cypress Component Testing Configuration should work with simple components 1`] = ` +"import { MountConfig, mount } from 'cypress/angular'; +import { SomethingOneComponent } from './something-one.component'; + +describe(SomethingOneComponent.name, () => { + const config: MountConfig = { + declarations: [], + imports: [], + providers: [] + } + + it('renders', () => { + mount(SomethingOneComponent, config); + }) +}) +" +`; + +exports[`Cypress Component Testing Configuration should work with simple components 2`] = ` +"import { MountConfig, mount } from 'cypress/angular'; +import { SomethingTwoComponent } from './something-two.component'; + +describe(SomethingTwoComponent.name, () => { + const config: MountConfig = { + declarations: [], + imports: [], + providers: [] + } + + it('renders', () => { + mount(SomethingTwoComponent, config); + }) +}) +" +`; + +exports[`Cypress Component Testing Configuration should work with simple components 3`] = ` +"import { MountConfig, mount } from 'cypress/angular'; +import { SomethingThreeComponent } from './something-three.component'; + +describe(SomethingThreeComponent.name, () => { + const config: MountConfig = { + declarations: [], + imports: [], + providers: [] + } + + it('renders', () => { + mount(SomethingThreeComponent, config); + }) +}) +" +`; + +exports[`Cypress Component Testing Configuration should work with standalone component 1`] = ` +"import { MountConfig, mount } from 'cypress/angular'; +import { SomethingOneComponent } from './something-one.component'; + +describe(SomethingOneComponent.name, () => { + const config: MountConfig = { + declarations: [], + imports: [], + providers: [] + } + + it('renders', () => { + mount(SomethingOneComponent, config); + }) +}) +" +`; + +exports[`Cypress Component Testing Configuration should work with standalone component 2`] = ` +"import { MountConfig, mount } from 'cypress/angular'; +import { SomethingTwoComponent } from './something-two.component'; + +describe(SomethingTwoComponent.name, () => { + const config: MountConfig = { + declarations: [], + imports: [], + providers: [] + } + + it('renders', () => { + mount(SomethingTwoComponent, config); + }) +}) +" +`; + +exports[`Cypress Component Testing Configuration should work with standalone component 3`] = ` +"import { MountConfig, mount } from 'cypress/angular'; +import { SomethingThreeComponent } from './something-three.component'; + +describe(SomethingThreeComponent.name, () => { + const config: MountConfig = { + declarations: [], + imports: [], + providers: [] + } + + it('renders', () => { + mount(SomethingThreeComponent, config); + }) +}) +" +`; diff --git a/packages/angular/src/generators/cypress-component-configuration/compat.ts b/packages/angular/src/generators/cypress-component-configuration/compat.ts new file mode 100644 index 0000000000..c6857c6aa1 --- /dev/null +++ b/packages/angular/src/generators/cypress-component-configuration/compat.ts @@ -0,0 +1,4 @@ +import { convertNxGenerator } from '@nrwl/devkit'; +import { cypressComponentConfiguration } from './cypress-component-configuration'; + +export default convertNxGenerator(cypressComponentConfiguration); diff --git a/packages/angular/src/generators/cypress-component-configuration/cypress-component-configuration.spec.ts b/packages/angular/src/generators/cypress-component-configuration/cypress-component-configuration.spec.ts new file mode 100644 index 0000000000..a13d8ea44a --- /dev/null +++ b/packages/angular/src/generators/cypress-component-configuration/cypress-component-configuration.spec.ts @@ -0,0 +1,767 @@ +import { installedCypressVersion } from '@nrwl/cypress/src/utils/cypress-version'; +import { + DependencyType, + joinPathFragments, + ProjectGraph, + readProjectConfiguration, + Tree, +} from '@nrwl/devkit'; +import { createTreeWithEmptyWorkspace } from '@nrwl/devkit/testing'; +import { applicationGenerator } from '../application/application'; +import { componentGenerator } from '../component/component'; +import librarySecondaryEntryPointGenerator from '../library-secondary-entry-point/library-secondary-entry-point'; +import { libraryGenerator } from '../library/library'; +import { cypressComponentConfiguration } from './cypress-component-configuration'; + +let projectGraph: ProjectGraph; +jest.mock('@nrwl/cypress/src/utils/cypress-version'); +jest.mock('@nrwl/devkit', () => ({ + ...jest.requireActual('@nrwl/devkit'), + createProjectGraphAsync: jest + .fn() + .mockImplementation(async () => projectGraph), +})); +describe('Cypress Component Testing Configuration', () => { + let tree: Tree; + let mockedInstalledCypressVersion: jest.Mock< + ReturnType + > = installedCypressVersion as never; + + beforeEach(() => { + tree = createTreeWithEmptyWorkspace(); + tree.write('.gitignore', ''); + mockedInstalledCypressVersion.mockReturnValue(10); + }); + + describe('updateProjectConfig', () => { + it('should add project config with --target=:', async () => { + await applicationGenerator(tree, { + name: 'fancy-app', + }); + await libraryGenerator(tree, { + name: 'fancy-lib', + }); + await componentGenerator(tree, { + name: 'fancy-cmp', + project: 'fancy-lib', + export: true, + }); + projectGraph = { + nodes: { + 'fancy-app': { + name: 'fancy-app', + type: 'app', + data: { + ...readProjectConfiguration(tree, 'fancy-app'), + }, + }, + 'fancy-lib': { + name: 'fancy-lib', + type: 'lib', + data: { + ...readProjectConfiguration(tree, 'fancy-lib'), + }, + }, + }, + dependencies: { + 'fancy-app': [ + { + type: DependencyType.static, + source: 'fancy-app', + target: 'fancy-lib', + }, + ], + }, + }; + await cypressComponentConfiguration(tree, { + project: 'fancy-lib', + buildTarget: 'fancy-app:build', + generateTests: false, + }); + expect( + tree.exists( + 'libs/fancy-lib/src/lib/fancy-cmp/fancy-cmp.component.cy.ts' + ) + ).toBeFalsy(); + expect( + readProjectConfiguration(tree, 'fancy-lib').targets['component-test'] + ).toEqual({ + executor: '@nrwl/cypress:cypress', + options: { + cypressConfig: 'libs/fancy-lib/cypress.config.ts', + devServerTarget: 'fancy-app:build', + skipServe: true, + testingType: 'component', + }, + }); + }); + + it('should add project config with --target=::', async () => { + await applicationGenerator(tree, { + name: 'fancy-app', + }); + await libraryGenerator(tree, { + name: 'fancy-lib', + }); + await componentGenerator(tree, { + name: 'fancy-cmp', + project: 'fancy-lib', + export: true, + }); + projectGraph = { + nodes: { + 'fancy-app': { + name: 'fancy-app', + type: 'app', + data: { + ...readProjectConfiguration(tree, 'fancy-app'), + }, + }, + 'fancy-lib': { + name: 'fancy-lib', + type: 'lib', + data: { + ...readProjectConfiguration(tree, 'fancy-lib'), + }, + }, + }, + dependencies: { + 'fancy-app': [ + { + type: DependencyType.static, + source: 'fancy-app', + target: 'fancy-lib', + }, + ], + }, + }; + await cypressComponentConfiguration(tree, { + project: 'fancy-lib', + buildTarget: 'fancy-app:build:development', + generateTests: false, + }); + expect( + tree.exists( + 'libs/fancy-lib/src/lib/fancy-cmp/fancy-cmp.component.cy.ts' + ) + ).toBeFalsy(); + expect( + readProjectConfiguration(tree, 'fancy-lib').targets['component-test'] + ).toEqual({ + executor: '@nrwl/cypress:cypress', + options: { + cypressConfig: 'libs/fancy-lib/cypress.config.ts', + devServerTarget: 'fancy-app:build:development', + skipServe: true, + testingType: 'component', + }, + }); + }); + + it('should throw if --build-target is invalid', async () => { + await libraryGenerator(tree, { + name: 'fancy-lib', + }); + await expect( + cypressComponentConfiguration(tree, { + project: 'fancy-lib', + buildTarget: 'fancy-app:build:development', + generateTests: false, + }) + ).rejects + .toThrow(`Error trying to find build configuration. Try manually specifying the build target with the --build-target flag. +Provided project? fancy-lib +Provided build target? fancy-app:build:development +Provided Executors? @nrwl/angular:webpack-browser, @angular-devkit/build-angular:browser`); + }); + it('should use own project config', async () => { + await applicationGenerator(tree, { + name: 'fancy-app', + }); + await componentGenerator(tree, { + name: 'fancy-cmp', + project: 'fancy-app', + export: true, + }); + projectGraph = { + nodes: { + 'fancy-app': { + name: 'fancy-app', + type: 'app', + data: { + ...readProjectConfiguration(tree, 'fancy-app'), + }, + }, + }, + dependencies: {}, + }; + await cypressComponentConfiguration(tree, { + project: 'fancy-app', + generateTests: false, + }); + expect( + readProjectConfiguration(tree, 'fancy-app').targets['component-test'] + ).toEqual({ + executor: '@nrwl/cypress:cypress', + options: { + cypressConfig: 'apps/fancy-app/cypress.config.ts', + devServerTarget: 'fancy-app:build', + skipServe: true, + testingType: 'component', + }, + }); + }); + + it('should use the project graph to find the correct project config', async () => { + await applicationGenerator(tree, { + name: 'fancy-app', + }); + await libraryGenerator(tree, { + name: 'fancy-lib', + }); + await componentGenerator(tree, { + name: 'fancy-cmp', + project: 'fancy-lib', + export: true, + }); + tree.write( + 'apps/fancy-app/src/app/blah.component.ts', + `import {FancyCmpComponent} from '@something/fancy-lib'` + ); + projectGraph = { + nodes: { + 'fancy-app': { + name: 'fancy-app', + type: 'app', + data: { + ...readProjectConfiguration(tree, 'fancy-app'), + }, + }, + 'fancy-lib': { + name: 'fancy-lib', + type: 'lib', + data: { + ...readProjectConfiguration(tree, 'fancy-lib'), + }, + }, + }, + dependencies: { + 'fancy-app': [ + { + type: DependencyType.static, + source: 'fancy-app', + target: 'fancy-lib', + }, + ], + }, + }; + await cypressComponentConfiguration(tree, { + project: 'fancy-lib', + generateTests: false, + }); + expect( + readProjectConfiguration(tree, 'fancy-lib').targets['component-test'] + ).toEqual({ + executor: '@nrwl/cypress:cypress', + options: { + cypressConfig: 'libs/fancy-lib/cypress.config.ts', + devServerTarget: 'fancy-app:build', + skipServe: true, + testingType: 'component', + }, + }); + }); + }); + + it('should work with simple components', async () => { + await libraryGenerator(tree, { + name: 'my-lib', + }); + + await setup(tree, { + project: 'my-lib', + name: 'something', + standalone: false, + }); + projectGraph = { + nodes: { + something: { + name: 'something', + type: 'app', + data: { + ...readProjectConfiguration(tree, 'something'), + }, + }, + 'my-lib': { + name: 'my-lib', + type: 'lib', + data: { + ...readProjectConfiguration(tree, 'my-lib'), + }, + }, + }, + dependencies: { + 'my-lib': [ + { + type: DependencyType.static, + source: 'my-lib', + target: 'something', + }, + ], + }, + }; + await cypressComponentConfiguration(tree, { + project: 'my-lib', + buildTarget: 'something:build', + generateTests: true, + }); + + const [one, two, three] = getCmpsFromTree(tree, { + basePath: 'libs/my-lib/src/lib', + name: 'something', + }); + expect(one.cy).toMatchSnapshot(); + expect(two.cy).toMatchSnapshot(); + expect(three.cy).toMatchSnapshot(); + }); + + it('should work with standalone component', async () => { + await libraryGenerator(tree, { + name: 'my-lib-standalone', + }); + + await setup(tree, { + project: 'my-lib-standalone', + name: 'something', + standalone: true, + }); + projectGraph = { + nodes: { + something: { + name: 'something', + type: 'app', + data: { + ...readProjectConfiguration(tree, 'something'), + }, + }, + 'my-lib-standalone': { + name: 'my-lib-standalone', + type: 'lib', + data: { + ...readProjectConfiguration(tree, 'my-lib-standalone'), + }, + }, + }, + dependencies: { + 'my-lib-standalone': [ + { + type: DependencyType.static, + source: 'my-lib-standalone', + target: 'something', + }, + ], + }, + }; + await cypressComponentConfiguration(tree, { + project: 'my-lib-standalone', + buildTarget: 'something:build', + generateTests: true, + }); + + const [one, two, three] = getCmpsFromTree(tree, { + basePath: 'libs/my-lib-standalone/src/lib', + name: 'something', + }); + expect(one.cy).toMatchSnapshot(); + expect(two.cy).toMatchSnapshot(); + expect(three.cy).toMatchSnapshot(); + }); + + it('should work with complex component', async () => { + await libraryGenerator(tree, { + name: 'with-inputs-cmp', + }); + + await setup(tree, { + project: 'with-inputs-cmp', + name: 'something', + standalone: false, + withInputs: true, + basePath: 'libs/with-inputs-cmp/src/lib', + }); + projectGraph = { + nodes: { + something: { + name: 'something', + type: 'app', + data: { + ...readProjectConfiguration(tree, 'something'), + }, + }, + 'with-inputs-cmp': { + name: 'with-inputs-cmp', + type: 'lib', + data: { + ...readProjectConfiguration(tree, 'with-inputs-cmp'), + }, + }, + }, + dependencies: { + 'with-inputs-cmp': [ + { + type: DependencyType.static, + source: 'with-inputs-cmp', + target: 'something', + }, + ], + }, + }; + await cypressComponentConfiguration(tree, { + project: 'with-inputs-cmp', + buildTarget: 'something:build', + generateTests: true, + }); + + const [one, two, three] = getCmpsFromTree(tree, { + basePath: 'libs/with-inputs-cmp/src/lib', + name: 'something', + }); + expect(one.cy).toMatchSnapshot(); + expect(two.cy).toMatchSnapshot(); + expect(three.cy).toMatchSnapshot(); + }); + + it('should work with complex standalone component', async () => { + await libraryGenerator(tree, { + name: 'with-inputs-standalone-cmp', + }); + + await setup(tree, { + project: 'with-inputs-standalone-cmp', + name: 'something', + standalone: true, + withInputs: true, + basePath: 'libs/with-inputs-standalone-cmp/src/lib', + }); + projectGraph = { + nodes: { + something: { + name: 'something', + type: 'app', + data: { + ...readProjectConfiguration(tree, 'something'), + }, + }, + 'with-inputs-standalone-cmp': { + name: 'with-inputs-standalone-cmp', + type: 'lib', + data: { + ...readProjectConfiguration(tree, 'with-inputs-standalone-cmp'), + }, + }, + }, + dependencies: { + 'with-inputs-standalone-cmp': [ + { + type: DependencyType.static, + source: 'with-inputs-standalone-cmp', + target: 'something', + }, + ], + }, + }; + await cypressComponentConfiguration(tree, { + project: 'with-inputs-standalone-cmp', + buildTarget: 'something:build', + generateTests: true, + }); + + const [one, two, three] = getCmpsFromTree(tree, { + basePath: 'libs/with-inputs-standalone-cmp/src/lib', + name: 'something', + }); + expect(one.cy).toMatchSnapshot(); + expect(two.cy).toMatchSnapshot(); + expect(three.cy).toMatchSnapshot(); + }); + + it('should work with secondary entry point libs', async () => { + await applicationGenerator(tree, { + name: 'my-cool-app', + }); + await libraryGenerator(tree, { + name: 'secondary', + buildable: true, + }); + await librarySecondaryEntryPointGenerator(tree, { + name: 'button', + library: 'secondary', + }); + await componentGenerator(tree, { + name: 'fancy-button', + path: 'libs/secondary/src/lib/button', + project: 'secondary', + flat: true, + }); + await componentGenerator(tree, { + name: 'standalone-fancy-button', + path: 'libs/secondary/src/lib/button', + project: 'secondary', + standalone: true, + flat: true, + }); + projectGraph = { + nodes: { + 'my-cool-app': { + name: 'my-cool-app', + type: 'app', + data: { + ...readProjectConfiguration(tree, 'my-cool-app'), + }, + }, + secondary: { + name: 'secondary', + type: 'lib', + data: { + ...readProjectConfiguration(tree, 'secondary'), + }, + }, + }, + dependencies: {}, + }; + + await cypressComponentConfiguration(tree, { + generateTests: true, + project: 'secondary', + buildTarget: 'my-cool-app:build', + }); + expect( + tree.read( + 'libs/secondary/src/lib/button/fancy-button.component.cy.ts', + 'utf-8' + ) + ).toMatchSnapshot(); + expect( + tree.read( + 'libs/secondary/src/lib/button/standalone-fancy-button.component.cy.ts', + 'utf-8' + ) + ).toMatchSnapshot(); + }); + + it('should not overwrite existing component test', async () => { + await libraryGenerator(tree, { + name: 'cool-lib', + flat: true, + }); + await setup(tree, { project: 'cool-lib', name: 'abc', standalone: false }); + tree.write( + 'libs/cool-lib/src/lib/abc-one/abc-one.component.cy.ts', + 'should not overwrite abc-one' + ); + tree.write( + 'libs/cool-lib/src/lib/abc-two/abc-two.component.cy.ts', + 'should not overwrite abc-two' + ); + tree.write( + 'libs/cool-lib/src/lib/abc-three/abc-three.component.cy.ts', + 'should not overwrite abc-three' + ); + projectGraph = { + nodes: { + abc: { + name: 'abc', + type: 'app', + data: { + ...readProjectConfiguration(tree, 'abc'), + }, + }, + 'cool-lib': { + name: 'cool-lib', + type: 'lib', + data: { + ...readProjectConfiguration(tree, 'cool-lib'), + }, + }, + }, + dependencies: {}, + }; + await cypressComponentConfiguration(tree, { + project: 'cool-lib', + buildTarget: 'abc:build', + generateTests: true, + }); + + const [one, two, three] = getCmpsFromTree(tree, { + name: 'abc', + basePath: 'libs/cool-lib/src/lib', + }); + + expect(one.cy).toEqual('should not overwrite abc-one'); + expect(two.cy).toEqual('should not overwrite abc-two'); + expect(three.cy).toEqual('should not overwrite abc-three'); + }); + + // TODO: should we support this? + it.skip('should handle multiple components per file', async () => { + await libraryGenerator(tree, { + name: 'multiple-components', + flat: true, + }); + + await componentGenerator(tree, { + name: 'cmp-one', + project: 'multiple-components', + flat: true, + }); + await componentGenerator(tree, { + name: 'cmp-two', + project: 'multiple-components', + flat: true, + }); + tree.write( + `libs/multiple-components/src/lib/cmp-one.component.ts`, + ` +import { Component, OnInit } from '@angular/core'; + +@Component({ + selector: 'proj-cmp-one', + templateUrl: './cmp-one.component.html', + styleUrls: ['./cmp-one.component.css'] +}) +export class CmpOneComponent implements OnInit { + + constructor() { } + + ngOnInit(): void { + } + +} + +@Component({ + selector: 'proj-cmp-one', + template: '

Hello World, {{abc}}

', + styles: [] +}) +export class CmpMultiComponent implements OnInit { + @Input() name: string = 'abc' + constructor() { } + ngOnInit(): void {} +} +` + ); + + tree.write( + '', + ` +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { CmpOneComponent, CmpMultiComponent } from './cmp-one.component'; +import { CmpTwoComponent } from './cmp-two.component'; + +@NgModule({ + imports: [ + CommonModule + ], + declarations: [ + CmpOneComponent, + CmpTwoComponent + ] +}) +export class MultipleComponentsModule { } +` + ); + + await cypressComponentConfiguration(tree, { + project: 'multiple-components', + generateTests: true, + }); + expect( + tree.read( + 'libs/multiple-components/src/lib/cmp-one.component.cy.ts', + 'utf-8' + ) + ).toEqual(''); + }); +}); + +async function setup( + tree: Tree, + options: { + name: string; + project: string; + standalone?: boolean; + withInputs?: boolean; + basePath?: string; + } +) { + await applicationGenerator(tree, { + name: options.name, + standalone: options.standalone, + }); + for (const name of [ + `${options.name}-one`, + `${options.name}-two`, + `${options.name}-three`, + ]) { + await componentGenerator(tree, { project: options.project, name }); + + if (options.withInputs) { + const cmpPath = joinPathFragments( + options.basePath, + name, + `${name}.component.ts` + ); + const oldContent = tree.read( + cmpPath, + + 'utf-8' + ); + + const newContent = oldContent.replace( + 'constructor()', + ` + @Input('buttonType') type = 'button'; + @Input() style: 'default' | 'fancy' | 'link' = 'default'; + @Input() age?: number; + @Input() isOn = false; + @Input() message: string | undefined; + @Input() anotherProp: any; + @Input() anotherNeverProp: never; + + constructor()` + ); + + tree.write(cmpPath, newContent); + } + } +} +function getCmpsFromTree( + tree: Tree, + options: { basePath: string; name: string } +) { + return [ + `${options.name}-one`, + `${options.name}-two`, + `${options.name}-three`, + ].map((n) => { + expect( + tree.exists(joinPathFragments(options.basePath, n, `${n}.component.ts`)) + ).toBeTruthy(); + expect( + tree.exists( + joinPathFragments(options.basePath, n, `${n}.component.cy.ts`) + ) + ).toBeTruthy(); + return { + cmp: tree.read( + joinPathFragments(options.basePath, n, `${n}.component.ts`), + 'utf-8' + ), + cy: tree.read( + joinPathFragments(options.basePath, n, `${n}.component.cy.ts`), + 'utf-8' + ), + }; + }); +} diff --git a/packages/angular/src/generators/cypress-component-configuration/cypress-component-configuration.ts b/packages/angular/src/generators/cypress-component-configuration/cypress-component-configuration.ts new file mode 100644 index 0000000000..4e139b617b --- /dev/null +++ b/packages/angular/src/generators/cypress-component-configuration/cypress-component-configuration.ts @@ -0,0 +1,137 @@ +import { cypressComponentProject } from '@nrwl/cypress'; +import { findBuildConfig } from '@nrwl/cypress/src/utils/find-target-options'; +import { + formatFiles, + generateFiles, + joinPathFragments, + logger, + ProjectConfiguration, + readProjectConfiguration, + Tree, + updateProjectConfiguration, +} from '@nrwl/devkit'; +import { relative } from 'path'; +import { componentTestGenerator } from '../component-test/component-test'; +import { + getComponentsInfo, + getStandaloneComponentsInfo, +} from '../utils/storybook-ast/component-info'; +import { getProjectEntryPoints } from '../utils/storybook-ast/entry-point'; +import { getModuleFilePaths } from '../utils/storybook-ast/module-info'; +import { CypressComponentConfigSchema } from './schema'; + +/** + * This is for cypress built in component testing, if you want to test with + * storybook + cypress then use the componentCypressGenerator instead. + */ +export async function cypressComponentConfiguration( + tree: Tree, + options: CypressComponentConfigSchema +) { + const projectConfig = readProjectConfiguration(tree, options.project); + const installTask = await cypressComponentProject(tree, { + project: options.project, + skipFormat: true, + }); + + await updateProjectConfig(tree, options); + addFiles(tree, projectConfig, options); + + if (options.skipFormat) { + await formatFiles(tree); + } + return () => { + installTask(); + }; +} +function addFiles( + tree: Tree, + projectConfig: ProjectConfiguration, + options: CypressComponentConfigSchema +) { + const cypressConfigPath = joinPathFragments( + projectConfig.root, + 'cypress.config.ts' + ); + + if (tree.exists(cypressConfigPath)) { + tree.delete(cypressConfigPath); + } + generateFiles( + tree, + joinPathFragments(__dirname, 'files'), + projectConfig.root, + { + tpl: '', + } + ); + + if (options.generateTests) { + const entryPoints = getProjectEntryPoints(tree, options.project); + + const componentInfo = []; + for (const entryPoint of entryPoints) { + const moduleFilePaths = getModuleFilePaths(tree, entryPoint); + componentInfo.push( + ...getComponentsInfo( + tree, + entryPoint, + moduleFilePaths, + options.project + ), + ...getStandaloneComponentsInfo(tree, entryPoint) + ); + } + + for (const info of componentInfo) { + if (info === undefined) { + continue; + } + const componentDirFromProjectRoot = relative( + projectConfig.root, + joinPathFragments(info.moduleFolderPath, info.path) + ); + componentTestGenerator(tree, { + project: options.project, + componentName: info.name, + componentDir: componentDirFromProjectRoot, + componentFileName: info.componentFileName, + skipFormat: true, + }); + } + } +} + +async function updateProjectConfig( + tree: Tree, + options: CypressComponentConfigSchema +) { + const found = await findBuildConfig(tree, { + project: options.project, + buildTarget: options.buildTarget, + validExecutorNames: new Set([ + '@nrwl/angular:webpack-browser', + '@angular-devkit/build-angular:browser', + ]), + }); + + assertValidConfig(found?.config); + + const projectConfig = readProjectConfiguration(tree, options.project); + projectConfig.targets['component-test'].options = { + ...projectConfig.targets['component-test'].options, + skipServe: true, + devServerTarget: found.target, + }; + updateProjectConfiguration(tree, options.project, projectConfig); +} + +function assertValidConfig(config: unknown) { + if (!config) { + throw new Error( + 'Unable to find a valid build configuration. Try passing in a target for an Angular app. --build-target=:[:]' + ); + } +} + +export default cypressComponentConfiguration; diff --git a/packages/angular/src/generators/cypress-component-configuration/files/cypress.config.ts__tpl__ b/packages/angular/src/generators/cypress-component-configuration/files/cypress.config.ts__tpl__ new file mode 100644 index 0000000000..ade9ea3c0e --- /dev/null +++ b/packages/angular/src/generators/cypress-component-configuration/files/cypress.config.ts__tpl__ @@ -0,0 +1,6 @@ +import { defineConfig } from 'cypress'; +import { nxComponentTestingPreset } from '@nrwl/angular/plugins/component-testing'; + +export default defineConfig({ + component: nxComponentTestingPreset(__filename), +}); diff --git a/packages/angular/src/generators/cypress-component-configuration/schema.d.ts b/packages/angular/src/generators/cypress-component-configuration/schema.d.ts new file mode 100644 index 0000000000..97ded9123d --- /dev/null +++ b/packages/angular/src/generators/cypress-component-configuration/schema.d.ts @@ -0,0 +1,6 @@ +export interface CypressComponentConfigSchema { + project: string; + generateTests: boolean; + skipFormat?: boolean; + buildTarget?: string; +} diff --git a/packages/angular/src/generators/cypress-component-configuration/schema.json b/packages/angular/src/generators/cypress-component-configuration/schema.json new file mode 100644 index 0000000000..4d2c760ded --- /dev/null +++ b/packages/angular/src/generators/cypress-component-configuration/schema.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://json-schema.org/schema", + "$id": "NxAngularCypressComponentConfigurationGenerator", + "type": "object", + "cli": "nx", + "title": "Add Cypress component testing", + "description": "Add a Cypress component testing configuration to an existing project. Cypress v10.7.0 or higher is required.", + "properties": { + "project": { + "type": "string", + "description": "The name of the project to add cypress component testing configuration to", + "$default": { + "$source": "projectName" + }, + "x-prompt": "What project should we add Cypress component testing to?" + }, + "generateTests": { + "type": "boolean", + "description": "Generate default component tests for existing components in the project", + "default": false + }, + "buildTarget": { + "type": "string", + "description": "A build target used to configure Cypress component testing in the format of `project:target[:configuration]`. The build target should be an angular app. If not provided we will try to infer it from your projects usage.", + "pattern": "^[^:\\s]+:[^:\\s]+(:\\S+)?$" + }, + "skipFormat": { + "type": "boolean", + "description": "Skip formatting files", + "default": false + } + }, + "required": ["project"] +} diff --git a/packages/angular/src/generators/stories/stories.ts b/packages/angular/src/generators/stories/stories.ts index 79c80b544c..64df1d9151 100644 --- a/packages/angular/src/generators/stories/stories.ts +++ b/packages/angular/src/generators/stories/stories.ts @@ -7,14 +7,14 @@ import { } from '@nrwl/devkit'; import componentCypressSpecGenerator from '../component-cypress-spec/component-cypress-spec'; import componentStoryGenerator from '../component-story/component-story'; -import type { ComponentInfo } from './lib/component-info'; +import type { ComponentInfo } from '../utils/storybook-ast/component-info'; import { getComponentsInfo, getStandaloneComponentsInfo, -} from './lib/component-info'; -import { getProjectEntryPoints } from './lib/entry-point'; +} from '../utils/storybook-ast/component-info'; +import { getProjectEntryPoints } from '../utils/storybook-ast/entry-point'; import { getE2EProject } from './lib/get-e2e-project'; -import { getModuleFilePaths } from './lib/module-info'; +import { getModuleFilePaths } from '../utils/storybook-ast/module-info'; import type { StoriesGeneratorOptions } from './schema'; import minimatch = require('minimatch'); diff --git a/packages/angular/src/generators/stories/lib/component-info.ts b/packages/angular/src/generators/utils/storybook-ast/component-info.ts similarity index 100% rename from packages/angular/src/generators/stories/lib/component-info.ts rename to packages/angular/src/generators/utils/storybook-ast/component-info.ts diff --git a/packages/angular/src/generators/stories/lib/entry-point.ts b/packages/angular/src/generators/utils/storybook-ast/entry-point.ts similarity index 100% rename from packages/angular/src/generators/stories/lib/entry-point.ts rename to packages/angular/src/generators/utils/storybook-ast/entry-point.ts diff --git a/packages/angular/src/generators/stories/lib/module-info.ts b/packages/angular/src/generators/utils/storybook-ast/module-info.ts similarity index 100% rename from packages/angular/src/generators/stories/lib/module-info.ts rename to packages/angular/src/generators/utils/storybook-ast/module-info.ts diff --git a/packages/angular/src/generators/utils/storybook.ts b/packages/angular/src/generators/utils/storybook-ast/storybook-inputs.ts similarity index 73% rename from packages/angular/src/generators/utils/storybook.ts rename to packages/angular/src/generators/utils/storybook-ast/storybook-inputs.ts index fa7f668268..21198856af 100644 --- a/packages/angular/src/generators/utils/storybook.ts +++ b/packages/angular/src/generators/utils/storybook-ast/storybook-inputs.ts @@ -5,7 +5,7 @@ import { } from '@nrwl/workspace/src/utilities/typescript'; import type { PropertyDeclaration } from 'typescript'; import { SyntaxKind } from 'typescript'; -import { getTsSourceFile } from '../../utils/nx-devkit/ast-utils'; +import { getTsSourceFile } from '../../../utils/nx-devkit/ast-utils'; export type KnobType = 'text' | 'boolean' | 'number' | 'select'; export interface InputDescriptor { @@ -36,7 +36,10 @@ export function getInputPropertyDeclarations( export function getComponentProps( tree: Tree, componentPath: string, - getArgsDefaultValueFn: (property: PropertyDeclaration) => string | undefined + getArgsDefaultValueFn: ( + property: PropertyDeclaration + ) => string | undefined = getArgsDefaultValue, + useDecoratorName = true ): InputDescriptor[] { const props = getInputPropertyDeclarations(tree, componentPath).map( (node) => { @@ -46,11 +49,12 @@ export function getComponentProps( ), SyntaxKind.StringLiteral ); - const name = decoratorContent.length - ? !decoratorContent[0].getText().includes('.') - ? decoratorContent[0].getText().slice(1, -1) - : node.name.getText() - : node.name.getText(); + const name = + useDecoratorName && decoratorContent.length + ? !decoratorContent[0].getText().includes('.') + ? decoratorContent[0].getText().slice(1, -1) + : node.name.getText() + : node.name.getText(); const type = getKnobType(node); const defaultValue = getArgsDefaultValueFn(node); @@ -87,3 +91,16 @@ export function getKnobType(property: PropertyDeclaration): KnobType { } return 'text'; } + +export function getArgsDefaultValue(property: PropertyDeclaration): string { + const typeNameToDefault = { + string: "''", + number: '0', + boolean: 'false', + }; + return property.initializer + ? property.initializer.getText() + : property.type + ? typeNameToDefault[property.type.getText()] + : "''"; +} diff --git a/packages/angular/src/generators/stories/lib/tree-utilities.ts b/packages/angular/src/generators/utils/storybook-ast/tree-utilities.ts similarity index 100% rename from packages/angular/src/generators/stories/lib/tree-utilities.ts rename to packages/angular/src/generators/utils/storybook-ast/tree-utilities.ts diff --git a/packages/cypress/migrations.json b/packages/cypress/migrations.json index 31fc229677..f4a13b3e8d 100644 --- a/packages/cypress/migrations.json +++ b/packages/cypress/migrations.json @@ -36,6 +36,12 @@ "version": "14.6.1-beta.0", "description": "Change Cypress e2e and component testing presets to use __filename instead of __dirname and include a devServerTarget for component testing.", "factory": "./src/migrations/update-14-6-1/update-cypress-configs-presets" + }, + "update-cypress-if-v10": { + "cli": "nx", + "version": "14.7.0-beta.0", + "description": "Update Cypress if using v10 to support latest component testing features", + "factory": "./src/migrations/update-14-7-0/update-cypress-version-if-10" } }, "packageJsonUpdates": { diff --git a/packages/cypress/plugins/cypress-preset.ts b/packages/cypress/plugins/cypress-preset.ts index 74c1a8eef6..ee06800621 100644 --- a/packages/cypress/plugins/cypress-preset.ts +++ b/packages/cypress/plugins/cypress-preset.ts @@ -1,5 +1,15 @@ -import { workspaceRoot } from '@nrwl/devkit'; -import { dirname, join, relative } from 'path'; +import { + ExecutorContext, + ProjectConfiguration, + ProjectGraph, + readNxJson, + stripIndents, + TargetConfiguration, + workspaceRoot, +} from '@nrwl/devkit'; +import { mapProjectGraphFiles } from '@nrwl/workspace/src/utils/runtime-lint-utils'; +import { readProjectsConfigurationFromProjectGraph } from 'nx/src/project-graph/project-graph'; +import { dirname, extname, join, relative } from 'path'; import { lstatSync } from 'fs'; interface BaseCypressPreset { @@ -9,6 +19,14 @@ interface BaseCypressPreset { chromeWebSecurity: boolean; } +export interface NxComponentTestingOptions { + /** + * the component testing target name. + * this is only when customized away from the default value of `component-test` + * @example 'component-test' + */ + ctTargetName: string; +} export function nxBaseCypressPreset(pathToConfig: string): BaseCypressPreset { // prevent from placing path outside the root of the workspace // if they pass in a file or directory @@ -59,3 +77,55 @@ export function nxE2EPreset(pathToConfig: string) { fixturesFolder: 'src/fixtures', }; } + +export function getProjectConfigByPath( + graph: ProjectGraph, + configPath: string +): ProjectConfiguration { + const configFileFromWorkspaceRoot = relative(workspaceRoot, configPath); + const normalizedPathFromWorkspaceRoot = lstatSync(configPath).isFile() + ? configFileFromWorkspaceRoot.replace(extname(configPath), '') + : configFileFromWorkspaceRoot; + + const mappedGraph = mapProjectGraphFiles(graph); + const componentTestingProjectName = + mappedGraph.allFiles[normalizedPathFromWorkspaceRoot]; + if ( + !componentTestingProjectName || + !graph.nodes[componentTestingProjectName]?.data + ) { + throw new Error( + stripIndents`Unable to find the project configuration that includes ${normalizedPathFromWorkspaceRoot}. + Found project name? ${componentTestingProjectName}. + Graph has data? ${!!graph.nodes[componentTestingProjectName]?.data}` + ); + } + // make sure name is set since it can be undefined + graph.nodes[componentTestingProjectName].data.name ??= + componentTestingProjectName; + return graph.nodes[componentTestingProjectName].data; +} + +export function createExecutorContext( + graph: ProjectGraph, + targets: Record, + projectName: string, + targetName: string, + configurationName: string +): ExecutorContext { + const projectConfigs = readProjectsConfigurationFromProjectGraph(graph); + return { + cwd: process.cwd(), + projectGraph: graph, + target: targets[targetName], + targetName, + configurationName, + root: workspaceRoot, + isVerbose: false, + projectName, + workspace: { + ...readNxJson(), + ...projectConfigs, + }, + }; +} diff --git a/packages/cypress/src/executors/cypress/cypress.impl.spec.ts b/packages/cypress/src/executors/cypress/cypress.impl.spec.ts index 6878e06c35..f55a705bbd 100644 --- a/packages/cypress/src/executors/cypress/cypress.impl.spec.ts +++ b/packages/cypress/src/executors/cypress/cypress.impl.spec.ts @@ -1,3 +1,4 @@ +import { getTempTailwindPath } from '../../utils/ct-helpers'; import { stripIndents } from '@nrwl/devkit'; import * as path from 'path'; import { installedCypressVersion } from '../../utils/cypress-version'; @@ -7,7 +8,7 @@ jest.mock('@nrwl/devkit'); let devkit = require('@nrwl/devkit'); jest.mock('../../utils/cypress-version'); - +jest.mock('../../utils/ct-helpers'); const Cypress = require('cypress'); describe('Cypress builder', () => { @@ -33,6 +34,8 @@ describe('Cypress builder', () => { watch: true, }); let runExecutor: any; + let mockGetTailwindPath: jest.Mock> = + getTempTailwindPath as any; beforeEach(async () => { runExecutor = (devkit as any).runExecutor = jest.fn().mockReturnValue([ @@ -409,6 +412,9 @@ A generator to migrate from v8 to v10 is provided. See https://nx.dev/cypress/v1 }); describe('Component Testing', () => { + beforeEach(() => { + mockGetTailwindPath.mockReturnValue(undefined); + }); it('should forward testingType', async () => { const { success } = await cypressExecutor( { diff --git a/packages/cypress/src/executors/cypress/cypress.impl.ts b/packages/cypress/src/executors/cypress/cypress.impl.ts index 7f3e8c524d..fd3ef2f3b9 100644 --- a/packages/cypress/src/executors/cypress/cypress.impl.ts +++ b/packages/cypress/src/executors/cypress/cypress.impl.ts @@ -7,7 +7,9 @@ import { stripIndents, } from '@nrwl/devkit'; import 'dotenv/config'; +import { existsSync, unlinkSync } from 'fs'; import { basename, dirname, join } from 'path'; +import { getTempTailwindPath } from '../../utils/ct-helpers'; import { installedCypressVersion } from '../../utils/cypress-version'; const Cypress = require('cypress'); // @NOTE: Importing via ES6 messes the whole test dependencies. @@ -40,6 +42,9 @@ export interface CypressExecutorOptions extends Json { tag?: string; } +interface NormalizedCypressExecutorOptions extends CypressExecutorOptions { + ctTailwindPath?: string; +} export default async function cypressExecutor( options: CypressExecutorOptions, context: ExecutorContext @@ -66,13 +71,19 @@ export default async function cypressExecutor( function normalizeOptions( options: CypressExecutorOptions, context: ExecutorContext -) { +): NormalizedCypressExecutorOptions { options.env = options.env || {}; if (options.tsConfig) { const tsConfigPath = join(context.root, options.tsConfig); options.env.tsConfig = tsConfigPath; process.env.TS_NODE_PROJECT = tsConfigPath; } + if (options.testingType === 'component') { + const project = context?.projectGraph?.nodes?.[context.projectName]; + if (project?.data?.root) { + options.ctTailwindPath = getTempTailwindPath(context); + } + } checkSupportedBrowser(options); warnDeprecatedHeadless(options); warnDeprecatedCypressVersion(); @@ -175,8 +186,11 @@ async function* startDevServer( * By default, Cypress will run tests from the CLI without the GUI and provide directly the results in the console output. * If `watch` is `true`: Open Cypress in the interactive GUI to interact directly with the application. */ -async function runCypress(baseUrl: string, opts: CypressExecutorOptions) { - // Cypress expects the folder where a `cypress.json` is present +async function runCypress( + baseUrl: string, + opts: NormalizedCypressExecutorOptions +) { + // Cypress expects the folder where a cypress config is present const projectFolderPath = dirname(opts.cypressConfig); const options: any = { project: projectFolderPath, @@ -227,6 +241,9 @@ async function runCypress(baseUrl: string, opts: CypressExecutorOptions) { ? Cypress.open(options) : Cypress.run(options)); + if (opts.ctTailwindPath && existsSync(opts.ctTailwindPath)) { + unlinkSync(opts.ctTailwindPath); + } /** * `cypress.open` is returning `0` and is not of the same type as `cypress.run`. * `cypress.open` is the graphical UI, so it will be obvious to know what wasn't diff --git a/packages/cypress/src/generators/cypress-component-project/files/cypress/support/commands.ts__ext__ b/packages/cypress/src/generators/cypress-component-project/files/cypress/support/commands.ts__ext__ index 89d43df2e5..461548759d 100644 --- a/packages/cypress/src/generators/cypress-component-project/files/cypress/support/commands.ts__ext__ +++ b/packages/cypress/src/generators/cypress-component-project/files/cypress/support/commands.ts__ext__ @@ -1,3 +1,4 @@ +/// // *********************************************** // This example commands.ts shows you how to // create various custom commands and overwrite diff --git a/packages/cypress/src/migrations/update-14-7-0/update-cypress-version-if-10.spec.ts b/packages/cypress/src/migrations/update-14-7-0/update-cypress-version-if-10.spec.ts new file mode 100644 index 0000000000..723b1d6650 --- /dev/null +++ b/packages/cypress/src/migrations/update-14-7-0/update-cypress-version-if-10.spec.ts @@ -0,0 +1,78 @@ +import { updateCypressVersionIf10 } from './update-cypress-version-if-10'; +import { installedCypressVersion } from '../../utils/cypress-version'; +import { readJson, Tree, updateJson } from '@nrwl/devkit'; +import { createTreeWithEmptyWorkspace } from '@nrwl/devkit/testing'; + +describe('Update Cypress if v10 migration', () => { + let tree: Tree; + + beforeEach(() => { + tree = createTreeWithEmptyWorkspace(); + tree.write( + 'package.json', + JSON.stringify({ + dependencies: {}, + devDependencies: {}, + }) + ); + }); + + it('should update the version if the installed version is v10', () => { + updateJson(tree, 'package.json', (json) => { + json.devDependencies['cypress'] = '^10.5.0'; + return json; + }); + updateCypressVersionIf10(tree); + const pkgJson = readJson(tree, 'package.json'); + expect(pkgJson.devDependencies['cypress']).toBe('^10.7.0'); + }); + + it('should not update the version < v10', () => { + updateJson(tree, 'package.json', (json) => { + json.devDependencies['cypress'] = '9.0.0'; + return json; + }); + updateCypressVersionIf10(tree); + const pkgJson = readJson(tree, 'package.json'); + expect(pkgJson.devDependencies['cypress']).toBe('9.0.0'); + }); + + it('should not update if the version > v10', () => { + updateJson(tree, 'package.json', (json) => { + json.devDependencies['cypress'] = '11.0.0'; + return json; + }); + updateCypressVersionIf10(tree); + const pkgJson = readJson(tree, 'package.json'); + expect(pkgJson.devDependencies['cypress']).toBe('11.0.0'); + }); + + it('should not update if the version is not defined', () => { + updateCypressVersionIf10(tree); + const pkgJson = readJson(tree, 'package.json'); + expect(pkgJson.devDependencies['cypress']).toBe(undefined); + }); + + it('should not update if v10.7.0 < version < v11', () => { + updateJson(tree, 'package.json', (json) => { + json.devDependencies['cypress'] = '^10.8.0'; + return json; + }); + updateCypressVersionIf10(tree); + const pkgJson1 = readJson(tree, 'package.json'); + expect(pkgJson1.devDependencies['cypress']).toBe('^10.8.0'); + }); + + it('should be idempotent', () => { + updateJson(tree, 'package.json', (json) => { + json.devDependencies['cypress'] = '^10.3.0'; + return json; + }); + updateCypressVersionIf10(tree); + const pkgJson1 = readJson(tree, 'package.json'); + expect(pkgJson1.devDependencies['cypress']).toBe('^10.7.0'); + updateCypressVersionIf10(tree); + const pkgJson2 = readJson(tree, 'package.json'); + expect(pkgJson2.devDependencies['cypress']).toBe('^10.7.0'); + }); +}); diff --git a/packages/cypress/src/migrations/update-14-7-0/update-cypress-version-if-10.ts b/packages/cypress/src/migrations/update-14-7-0/update-cypress-version-if-10.ts new file mode 100644 index 0000000000..11af914bb3 --- /dev/null +++ b/packages/cypress/src/migrations/update-14-7-0/update-cypress-version-if-10.ts @@ -0,0 +1,45 @@ +import { + GeneratorCallback, + installPackagesTask, + readJson, + Tree, + updateJson, +} from '@nrwl/devkit'; +import { checkAndCleanWithSemver } from '@nrwl/workspace'; +import { gte, lt } from 'semver'; + +export function updateCypressVersionIf10(tree: Tree): GeneratorCallback { + const installedVersion = readJson(tree, 'package.json').devDependencies?.[ + 'cypress' + ]; + + if (!installedVersion) { + return; + } + const normalizedInstalledCypressVersion = checkAndCleanWithSemver( + 'cypress', + installedVersion + ); + + // not using v10 + if ( + lt(normalizedInstalledCypressVersion, '10.0.0') || + gte(normalizedInstalledCypressVersion, '11.0.0') + ) { + return; + } + + const ngComponentTestingVersion = '10.7.0'; + + if (lt(normalizedInstalledCypressVersion, ngComponentTestingVersion)) { + updateJson(tree, 'package.json', (json) => { + json.devDependencies['cypress'] = `^${ngComponentTestingVersion}`; + return json; + }); + return () => { + installPackagesTask(tree); + }; + } +} + +export default updateCypressVersionIf10; diff --git a/packages/cypress/src/utils/ct-helpers.ts b/packages/cypress/src/utils/ct-helpers.ts new file mode 100644 index 0000000000..b1d6ff1eab --- /dev/null +++ b/packages/cypress/src/utils/ct-helpers.ts @@ -0,0 +1,42 @@ +import type { ExecutorContext } from 'nx/src/config/misc-interfaces'; +import { ProjectGraph } from 'nx/src/config/project-graph'; +import { join } from 'path'; + +/** + * return a path to a temp css file + * temp file is scoped to the project root + * i.e. /tmp//ct-styles.css + */ +export function getTempTailwindPath(context: ExecutorContext) { + if (!context.projectName) { + throw new Error('No project name found in context'); + } + const project = context?.projectGraph.nodes[context.projectName]; + + if (!project) { + throw new Error( + `No project found in project graph for ${context.projectName}` + ); + } + + if (project?.data?.root) { + return join(context.root, 'tmp', project.data.root, 'ct-styles.css'); + } +} + +/** + * also returns true if the ct project and build project are the same. + * i.e. component testing inside an app. + */ +export function isCtProjectUsingBuildProject( + graph: ProjectGraph, + parentProjectName: string, + childProjectName: string +) { + return ( + parentProjectName === childProjectName || + graph.dependencies[parentProjectName].some( + (p) => p.target === childProjectName + ) + ); +} diff --git a/packages/cypress/src/utils/find-target-options.ts b/packages/cypress/src/utils/find-target-options.ts index 06893852d8..6238037d83 100644 --- a/packages/cypress/src/utils/find-target-options.ts +++ b/packages/cypress/src/utils/find-target-options.ts @@ -27,7 +27,7 @@ interface FindTargetOptions { } interface FoundTarget { - config: TargetConfiguration; + config?: TargetConfiguration; target: string; } @@ -35,27 +35,34 @@ export async function findBuildConfig( tree: Tree, options: FindTargetOptions ): Promise { - // attempt to use the provided target - const graph = await createProjectGraphAsync(); - if (options.buildTarget) { - return { - target: options.buildTarget, - config: findInTarget(tree, graph, options), - }; - } - // check to see if there is a valid config in the given project - const selfProject = findTargetOptionsInProject( - tree, - graph, - options.project, - options.validExecutorNames - ); - if (selfProject) { - return selfProject; - } + try { + // attempt to use the provided target + const graph = await createProjectGraphAsync(); + if (options.buildTarget) { + return { + target: options.buildTarget, + config: findInTarget(tree, graph, options), + }; + } + // check to see if there is a valid config in the given project + const selfProject = findTargetOptionsInProject( + tree, + graph, + options.project, + options.validExecutorNames + ); + if (selfProject) { + return selfProject; + } - // attempt to find any projects with the valid config in the graph that consumes this project - return await findInGraph(tree, graph, options); + // attempt to find any projects with the valid config in the graph that consumes this project + return await findInGraph(tree, graph, options); + } catch (e) { + throw new Error(stripIndents`Error trying to find build configuration. Try manually specifying the build target with the --build-target flag. + Provided project? ${options.project} + Provided build target? ${options.buildTarget} + Provided Executors? ${[...options.validExecutorNames].join(', ')}`); + } } function findInTarget( @@ -103,7 +110,7 @@ async function findInGraph( } if (potentialTargets.length > 1) { - logger.warn(stripIndents`Multiple potential targets found for ${options.project}. Found ${potentialTargets.length}. + logger.warn(stripIndents`Multiple potential targets found for ${options.project}. Found ${potentialTargets.length}. Using ${potentialTargets[0].target}. To specify a different target use the --build-target flag. `); diff --git a/packages/cypress/src/utils/versions.ts b/packages/cypress/src/utils/versions.ts index 4d653e20a4..8aa2956d20 100644 --- a/packages/cypress/src/utils/versions.ts +++ b/packages/cypress/src/utils/versions.ts @@ -1,6 +1,6 @@ export const nxVersion = require('../../package.json').version; export const eslintPluginCypressVersion = '^2.10.3'; export const typesNodeVersion = '16.11.7'; -export const cypressVersion = '^10.2.0'; +export const cypressVersion = '^10.7.0'; export const cypressWebpackVersion = '^2.0.0'; export const webpackHttpPluginVersion = '^5.5.0'; diff --git a/packages/react/plugins/component-testing/index.ts b/packages/react/plugins/component-testing/index.ts index d1a75a9134..4b6f1d4c02 100644 --- a/packages/react/plugins/component-testing/index.ts +++ b/packages/react/plugins/component-testing/index.ts @@ -1,37 +1,26 @@ -import { nxBaseCypressPreset } from '@nrwl/cypress/plugins/cypress-preset'; +import { + createExecutorContext, + getProjectConfigByPath, + nxBaseCypressPreset, + NxComponentTestingOptions, +} from '@nrwl/cypress/plugins/cypress-preset'; import type { CypressExecutorOptions } from '@nrwl/cypress/src/executors/cypress/cypress.impl'; import { ExecutorContext, logger, parseTargetString, - ProjectConfiguration, ProjectGraph, readCachedProjectGraph, - readNxJson, readTargetOptions, stripIndents, Target, - TargetConfiguration, workspaceRoot, } from '@nrwl/devkit'; import type { WebWebpackExecutorOptions } from '@nrwl/web/src/executors/webpack/webpack.impl'; import { normalizeWebBuildOptions } from '@nrwl/web/src/utils/normalize'; import { getWebConfig } from '@nrwl/web/src/utils/web.config'; -import { mapProjectGraphFiles } from '@nrwl/workspace/src/utils/runtime-lint-utils'; -import { lstatSync } from 'fs'; -import { readProjectsConfigurationFromProjectGraph } from 'nx/src/project-graph/project-graph'; -import { extname, relative } from 'path'; import { buildBaseWebpackConfig } from './webpack-fallback'; -export interface ReactComponentTestingOptions { - /** - * the component testing target name. - * this is only when customized away from the default value of `component-test` - * @example 'component-test' - */ - ctTargetName: string; -} - /** * React nx preset for Cypress Component Testing * @@ -52,12 +41,12 @@ export interface ReactComponentTestingOptions { */ export function nxComponentTestingPreset( pathToConfig: string, - options?: ReactComponentTestingOptions + options?: NxComponentTestingOptions ) { let webpackConfig; try { const graph = readCachedProjectGraph(); - const { targets: ctTargets, name: ctProjectName } = getConfigByPath( + const { targets: ctTargets, name: ctProjectName } = getProjectConfigByPath( graph, pathToConfig ); @@ -192,55 +181,3 @@ function buildTargetWebpack( parsed.configuration ); } - -function getConfigByPath( - graph: ProjectGraph, - configPath: string -): ProjectConfiguration { - const configFileFromWorkspaceRoot = relative(workspaceRoot, configPath); - const normalizedPathFromWorkspaceRoot = lstatSync(configPath).isFile() - ? configFileFromWorkspaceRoot.replace(extname(configPath), '') - : configFileFromWorkspaceRoot; - - const mappedGraph = mapProjectGraphFiles(graph); - const componentTestingProjectName = - mappedGraph.allFiles[normalizedPathFromWorkspaceRoot]; - if ( - !componentTestingProjectName || - !graph.nodes[componentTestingProjectName]?.data - ) { - throw new Error( - stripIndents`Unable to find the project configuration that includes ${normalizedPathFromWorkspaceRoot}. - Found project name? ${componentTestingProjectName}. - Graph has data? ${!!graph.nodes[componentTestingProjectName]?.data}` - ); - } - // make sure name is set since it can be undefined - graph.nodes[componentTestingProjectName].data.name ??= - componentTestingProjectName; - return graph.nodes[componentTestingProjectName].data; -} - -function createExecutorContext( - graph: ProjectGraph, - targets: Record, - projectName: string, - targetName: string, - configurationName: string -): ExecutorContext { - const projectConfigs = readProjectsConfigurationFromProjectGraph(graph); - return { - cwd: process.cwd(), - projectGraph: graph, - target: targets[targetName], - targetName, - configurationName, - root: workspaceRoot, - isVerbose: false, - projectName, - workspace: { - ...readNxJson(), - ...projectConfigs, - }, - }; -}