diff --git a/docs/generated/packages/react.json b/docs/generated/packages/react.json index ba5ff19c5bc84f..bcd174680921a7 100644 --- a/docs/generated/packages/react.json +++ b/docs/generated/packages/react.json @@ -1207,7 +1207,7 @@ "name": "cypress-component-configuration", "factory": "./src/generators/cypress-component-configuration/cypress-component-configuration#cypressComponentConfigGenerator", "schema": { - "$schema": "http://json-schema.org/schema", + "$schema": "https://json-schema.org/schema", "cli": "nx", "$id": "NxReactCypressComponentTestConfiguration", "title": "Add Cypress component testing", @@ -1230,6 +1230,11 @@ "$default": { "$source": "projectName" }, "x-prompt": "What project should we add Cypress component testing to?" }, + "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 from a React app. If not provided we will try to infer it from your projects usage.", + "pattern": "^[^:\\s]+:[^:\\s]+(:\\S+)?$" + }, "generateTests": { "type": "boolean", "description": "Generate default component tests for existing components in the project", diff --git a/e2e/react/src/cypress-component-tests.test.ts b/e2e/react/src/cypress-component-tests.test.ts index b6be9cdab69e2f..25558a06106304 100644 --- a/e2e/react/src/cypress-component-tests.test.ts +++ b/e2e/react/src/cypress-component-tests.test.ts @@ -1,4 +1,4 @@ -import { newProject, runCLI, uniq } from '../../utils'; +import { createFile, newProject, runCLI, uniq, updateFile } from '../../utils'; describe('React Cypress Component Tests', () => { beforeAll(() => newProject()); @@ -17,14 +17,35 @@ describe('React Cypress Component Tests', () => { ); }, 1000000); - it('should successfully test react app', () => { + it('should successfully test react lib', () => { const libName = uniq('cy-react-lib'); + const appName = uniq('cy-react-app-target'); + runCLI(`generate @nrwl/react:app ${appName} --no-interactive`); runCLI(`generate @nrwl/react:lib ${libName} --component --no-interactive`); + runCLI( + `generate @nrwl/react:setup-tailwind --project=${appName} --no-interactive` + ); runCLI( `generate @nrwl/react:component fancy-component --project=${libName} --no-interactive` ); + createFile( + `libs/${libName}/src/styles.css`, + ` +@tailwind components; +@tailwind base; +@tailwind utilities; +` + ); + updateFile( + `libs/${libName}/src/lib/fancy-component/fancy-component.tsx`, + (content) => { + return ` +import '../../styles.css'; +${content}`; + } + ); runCLI( - `generate @nrwl/react:cypress-component-configuration --project=${libName} --generate-tests` + `generate @nrwl/react:cypress-component-configuration --project=${libName} --build-target=${appName}:build --generate-tests` ); expect(runCLI(`component-test ${libName} --no-watch`)).toContain( 'All specs passed!' diff --git a/packages/angular/src/generators/ng-add/utilities/e2e.migrator.ts b/packages/angular/src/generators/ng-add/utilities/e2e.migrator.ts index c925e010452d19..65dccb4be2e856 100644 --- a/packages/angular/src/generators/ng-add/utilities/e2e.migrator.ts +++ b/packages/angular/src/generators/ng-add/utilities/e2e.migrator.ts @@ -562,7 +562,7 @@ export class E2eMigrator extends ProjectMigrator { } private updateCypress10ConfigFile(configFilePath: string): void { - this.cypressPreset = nxE2EPreset(this.project.newRoot); + this.cypressPreset = nxE2EPreset(configFilePath); const fileContent = this.tree.read(configFilePath, 'utf-8'); let sourceFile = tsquery.ast(fileContent); diff --git a/packages/cypress/migrations.json b/packages/cypress/migrations.json index 6e86e147741695..31fc2296778575 100644 --- a/packages/cypress/migrations.json +++ b/packages/cypress/migrations.json @@ -30,6 +30,12 @@ "version": "12.8.0-beta.0", "description": "Remove Typescript Preprocessor Plugin", "factory": "./src/migrations/update-12-8-0/remove-typescript-plugin" + }, + "update-cypress-configs-preset": { + "cli": "nx", + "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" } }, "packageJsonUpdates": { diff --git a/packages/cypress/plugins/cypress-preset.ts b/packages/cypress/plugins/cypress-preset.ts index 0898f6ad5a032a..26b83f1a7dc8f1 100644 --- a/packages/cypress/plugins/cypress-preset.ts +++ b/packages/cypress/plugins/cypress-preset.ts @@ -1,5 +1,6 @@ import { workspaceRoot } from '@nrwl/devkit'; -import { join, relative } from 'path'; +import { dirname, extname, join, relative } from 'path'; +import { lstatSync } from 'fs'; interface BaseCypressPreset { videosFolder: string; @@ -9,8 +10,12 @@ interface BaseCypressPreset { } export function nxBaseCypressPreset(pathToConfig: string): BaseCypressPreset { - const projectPath = relative(workspaceRoot, pathToConfig); - const offset = relative(pathToConfig, workspaceRoot); + // prevent from placing path outside the root of the workspace + // if they pass in a file or directory + const normalizedPath = + extname(pathToConfig) === '' ? pathToConfig : dirname(pathToConfig); + const projectPath = relative(workspaceRoot, normalizedPath); + const offset = relative(normalizedPath, workspaceRoot); const videosFolder = join(offset, 'dist', 'cypress', projectPath, 'videos'); const screenshotsFolder = join( offset, diff --git a/packages/cypress/src/migrations/update-14-6-1/update-cypress-configs-presets.spec.ts b/packages/cypress/src/migrations/update-14-6-1/update-cypress-configs-presets.spec.ts new file mode 100644 index 00000000000000..4e9facbace1f90 --- /dev/null +++ b/packages/cypress/src/migrations/update-14-6-1/update-cypress-configs-presets.spec.ts @@ -0,0 +1,416 @@ +import { updateCypressConfigsPresets } from './update-cypress-configs-presets'; +import { installedCypressVersion } from '../../utils/cypress-version'; +import { + addProjectConfiguration, + DependencyType, + logger, + ProjectGraph, + readJson, + readProjectConfiguration, + Tree, + updateProjectConfiguration, +} from '@nrwl/devkit'; +import { createTreeWithEmptyWorkspace } from '@nrwl/devkit/testing'; +import { cypressProjectGenerator } from '../../generators/cypress-project/cypress-project'; +import { libraryGenerator } from '@nrwl/workspace'; + +let projectGraph: ProjectGraph; +jest.mock('@nrwl/devkit', () => { + return { + ...jest.requireActual('@nrwl/devkit'), + createProjectGraphAsync: jest.fn().mockImplementation(() => projectGraph), + }; +}); +jest.mock('../../utils/cypress-version'); +describe('updateComponentTestingConfig', () => { + let tree: Tree; + let mockedInstalledCypressVersion: jest.Mock< + ReturnType + > = installedCypressVersion as never; + + beforeEach(() => { + tree = createTreeWithEmptyWorkspace(); + }); + it('should update', async () => { + mockedInstalledCypressVersion.mockReturnValue(10); + await setup(tree, { name: 'something' }); + await updateCypressConfigsPresets(tree); + expect( + tree.read('libs/something-lib/cypress.config.ts', 'utf-8') + ).toContain( + `export default defineConfig({ + component: nxComponentTestingPreset(__filename), +}); +` + ); + expect( + tree.read('libs/something-lib/cypress.config-two.ts', 'utf-8') + ).toContain( + `export default defineConfig({ + component: nxComponentTestingPreset(__filename, { ctTargetName: 'ct' }), +}); +` + ); + + expect(tree.read('apps/something-e2e/cypress.config.ts', 'utf-8')) + .toContain(`export default defineConfig({ + e2e: nxE2EPreset(__filename), +}); +`); + expect(tree.read('apps/something-e2e/cypress.storybook-config.ts', 'utf-8')) + .toContain(`export default defineConfig({ + e2e: nxE2EStorybookPreset(__filename), +}); +`); + const libProjectConfig = readProjectConfiguration(tree, 'something-lib'); + expect(libProjectConfig.targets['component-test']).toEqual({ + executor: '@nrwl/cypress:cypress', + options: { + cypressConfig: 'libs/something-lib/cypress.config.ts', + testingType: 'component', + devServerTarget: 'something-app:build', + skipServe: true, + }, + }); + expect(libProjectConfig.targets['ct']).toEqual({ + executor: '@nrwl/cypress:cypress', + options: { + cypressConfig: 'libs/something-lib/cypress.config-two.ts', + testingType: 'component', + devServerTarget: 'something-app:build', + skipServe: true, + }, + configurations: { + prod: { + baseUrl: 'https://example.com', + }, + }, + }); + }); + + it('should list out projects when unable to update config', async () => { + const loggerSpy = jest.spyOn(logger, 'warn'); + await setup(tree, { name: 'something' }); + projectGraph = { + nodes: {}, + dependencies: {}, + }; + await updateCypressConfigsPresets(tree); + + expect(loggerSpy).toHaveBeenNthCalledWith( + 1, + 'Unable to find a build target to add to the component testing target in the following projects:' + ); + expect(loggerSpy).toHaveBeenNthCalledWith(2, '- something-lib'); + expect(loggerSpy).toHaveBeenNthCalledWith( + 3, + `You can manually add the 'devServerTarget' option to the +component testing target to specify the build target to use. +The build configuration should be using @nrwl/web:webpack as the executor. +Usually this is a React app in your workspace. +Component testing will fallback to a default configuration if one isn't provided, +but might require modifications if your projects are more complex.` + ); + }); + + it('should handle already updated config', async () => { + mockedInstalledCypressVersion.mockReturnValue(10); + await setup(tree, { name: 'something' }); + + expect(async () => { + await updateCypressConfigsPresets(tree); + }).not.toThrow(); + expect(tree.read('libs/something-lib/cypress.config.ts', 'utf-8')) + .toContain(`export default defineConfig({ + component: nxComponentTestingPreset(__filename), +}); +`); + expect( + tree.read('libs/something-lib/cypress.config-two.ts', 'utf-8') + ).toContain( + `export default defineConfig({ + component: nxComponentTestingPreset(__filename, { ctTargetName: 'ct' }), +}); +` + ); + + expect(tree.read('apps/something-e2e/cypress.config.ts', 'utf-8')) + .toContain(`export default defineConfig({ + e2e: nxE2EPreset(__filename), +}); +`); + }); + it('should not update if using < v10', async () => { + mockedInstalledCypressVersion.mockReturnValue(9); + await setup(tree, { name: 'something' }); + await updateCypressConfigsPresets(tree); + expect( + tree.read('libs/something-lib/cypress.config.ts', 'utf-8') + ).toContain( + `export default defineConfig({ + component: nxComponentTestingPreset(__dirname), +}); +` + ); + expect( + tree.read('libs/something-lib/cypress.config-two.ts', 'utf-8') + ).toContain( + `export default defineConfig({ + component: nxComponentTestingPreset(__dirname), +}); +` + ); + + expect(tree.read('apps/something-e2e/cypress.config.ts', 'utf-8')) + .toContain(`export default defineConfig({ + e2e: nxE2EPreset(__dirname), +}); +`); + }); + + it('should be idempotent', async () => { + mockedInstalledCypressVersion.mockReturnValue(10); + await setup(tree, { name: 'something' }); + await updateCypressConfigsPresets(tree); + expect( + tree.read('libs/something-lib/cypress.config.ts', 'utf-8') + ).toContain( + `export default defineConfig({ + component: nxComponentTestingPreset(__filename), +}); +` + ); + expect( + tree.read('libs/something-lib/cypress.config-two.ts', 'utf-8') + ).toContain( + `export default defineConfig({ + component: nxComponentTestingPreset(__filename, { ctTargetName: 'ct' }), +}); +` + ); + + expect(tree.read('apps/something-e2e/cypress.config.ts', 'utf-8')) + .toContain(`export default defineConfig({ + e2e: nxE2EPreset(__filename), +}); +`); + const libProjectConfig = readProjectConfiguration(tree, 'something-lib'); + expect(libProjectConfig.targets['component-test']).toEqual({ + executor: '@nrwl/cypress:cypress', + options: { + cypressConfig: 'libs/something-lib/cypress.config.ts', + testingType: 'component', + devServerTarget: 'something-app:build', + skipServe: true, + }, + }); + expect(libProjectConfig.targets['ct']).toEqual({ + executor: '@nrwl/cypress:cypress', + options: { + cypressConfig: 'libs/something-lib/cypress.config-two.ts', + testingType: 'component', + devServerTarget: 'something-app:build', + skipServe: true, + }, + configurations: { + prod: { + baseUrl: 'https://example.com', + }, + }, + }); + + await updateCypressConfigsPresets(tree); + expect( + tree.read('libs/something-lib/cypress.config.ts', 'utf-8') + ).toContain( + `export default defineConfig({ + component: nxComponentTestingPreset(__filename), +}); +` + ); + expect( + tree.read('libs/something-lib/cypress.config-two.ts', 'utf-8') + ).toContain( + `export default defineConfig({ + component: nxComponentTestingPreset(__filename, { ctTargetName: 'ct' }), +}); +` + ); + + expect(tree.read('apps/something-e2e/cypress.config.ts', 'utf-8')) + .toContain(`export default defineConfig({ + e2e: nxE2EPreset(__filename), +}); +`); + const libProjectConfig2 = readProjectConfiguration(tree, 'something-lib'); + expect(libProjectConfig2.targets['component-test']).toEqual({ + executor: '@nrwl/cypress:cypress', + options: { + cypressConfig: 'libs/something-lib/cypress.config.ts', + testingType: 'component', + devServerTarget: 'something-app:build', + skipServe: true, + }, + }); + expect(libProjectConfig2.targets['ct']).toEqual({ + executor: '@nrwl/cypress:cypress', + options: { + cypressConfig: 'libs/something-lib/cypress.config-two.ts', + testingType: 'component', + devServerTarget: 'something-app:build', + skipServe: true, + }, + configurations: { + prod: { + baseUrl: 'https://example.com', + }, + }, + }); + }); +}); + +async function setup(tree: Tree, options: { name: string }) { + const appName = `${options.name}-app`; + const libName = `${options.name}-lib`; + const e2eName = `${options.name}-e2e`; + tree.write( + 'apps/my-app/cypress.config.ts', + `import { defineConfig } from 'cypress'; +import { nxComponentTestingPreset } from '@nrwl/cypress/plugins/component-testing'; + +export default defineConfig({ + component: nxComponentTestingPreset(__dirname), +}); +` + ); + + addProjectConfiguration(tree, appName, { + root: `apps/my-app`, + sourceRoot: `apps/${appName}/src`, + targets: { + build: { + executor: '@nrwl/web:webpack', + outputs: ['{options.outputPath}'], + options: { + compiler: 'babel', + outputPath: `dist/apps/${appName}`, + index: `apps/${appName}/src/index.html`, + baseHref: '/', + main: `apps/${appName}/src/main.tsx`, + polyfills: `apps/${appName}/src/polyfills.ts`, + tsConfig: `apps/${appName}/tsconfig.app.json`, + }, + }, + }, + }); + await cypressProjectGenerator(tree, { project: appName, name: e2eName }); + const e2eProjectConfig = readProjectConfiguration(tree, e2eName); + e2eProjectConfig.targets['e2e'].configurations = { + ...e2eProjectConfig.targets['e2e'].configurations, + sb: { + cypressConfig: `apps/${e2eName}/cypress.storybook-config.ts`, + }, + }; + updateProjectConfiguration(tree, e2eName, e2eProjectConfig); + tree.write( + `apps/${e2eName}/cypress.config.ts`, + `import { defineConfig } from 'cypress'; +import { nxE2EPreset } from '@nrwl/cypress/plugins/cypress-preset'; + +export default defineConfig({ + e2e: nxE2EPreset(__dirname), +}); +` + ); + tree.write( + `apps/${e2eName}/cypress.storybook-config.ts`, + ` + import { defineConfig } from 'cypress'; +import { nxE2EStorybookPreset } from '@nrwl/cypress/plugins/cypress-preset'; + +export default defineConfig({ + e2e: nxE2EStorybookPreset(__dirname), +}); +` + ); + // lib + await libraryGenerator(tree, { name: libName }); + const libProjectConfig = readProjectConfiguration(tree, libName); + libProjectConfig.targets = { + ...libProjectConfig.targets, + 'component-test': { + executor: '@nrwl/cypress:cypress', + options: { + testingType: 'component', + cypressConfig: `libs/${libName}/cypress.config.ts`, + }, + }, + ct: { + executor: '@nrwl/cypress:cypress', + options: { + testingType: 'component', + cypressConfig: `libs/${libName}/cypress.config-two.ts`, + }, + configurations: { + prod: { + baseUrl: 'https://example.com', + }, + }, + }, + }; + updateProjectConfiguration(tree, libName, libProjectConfig); + tree.write( + `libs/${libName}/cypress.config.ts`, + `import { defineConfig } from 'cypress'; +import { nxComponentTestingPreset } from '@nrwl/cypress/plugins/component-testing'; + +export default defineConfig({ + component: nxComponentTestingPreset(__dirname), +}); +` + ); + tree.write( + `libs/${libName}/cypress.config-two.ts`, + `import { defineConfig } from 'cypress'; +import { nxComponentTestingPreset } from '@nrwl/cypress/plugins/component-testing'; + +export default defineConfig({ + component: nxComponentTestingPreset(__dirname), +}); +` + ); + + projectGraph = { + nodes: { + [appName]: { + name: appName, + type: 'app', + data: { + ...readProjectConfiguration(tree, appName), + }, + }, + [e2eName]: { + name: e2eName, + type: 'e2e', + data: { + ...readProjectConfiguration(tree, e2eName), + }, + }, + [libName]: { + name: libName, + type: 'lib', + data: { + ...readProjectConfiguration(tree, libName), + }, + }, + }, + dependencies: { + [appName]: [ + { type: DependencyType.static, source: appName, target: libName }, + ], + [e2eName]: [ + { type: DependencyType.implicit, source: e2eName, target: libName }, + ], + }, + }; +} diff --git a/packages/cypress/src/migrations/update-14-6-1/update-cypress-configs-presets.ts b/packages/cypress/src/migrations/update-14-6-1/update-cypress-configs-presets.ts new file mode 100644 index 00000000000000..2dc7364a6d649a --- /dev/null +++ b/packages/cypress/src/migrations/update-14-6-1/update-cypress-configs-presets.ts @@ -0,0 +1,138 @@ +import { + logger, + readProjectConfiguration, + stripIndents, + Tree, + updateProjectConfiguration, +} from '@nrwl/devkit'; +import { forEachExecutorOptions } from '@nrwl/workspace/src/utilities/executor-options-utils'; +import { tsquery } from '@phenomnomnominal/tsquery'; +import * as ts from 'typescript'; +import { CypressExecutorOptions } from '../../executors/cypress/cypress.impl'; +import { installedCypressVersion } from '../../utils/cypress-version'; +import { findBuildConfig } from '../../utils/find-target-options'; + +export async function updateCypressConfigsPresets(tree: Tree) { + if (installedCypressVersion() < 10) { + return; + } + + const projectsWithoutDevServerTarget = new Set(); + const updateTasks = []; + forEachExecutorOptions( + tree, + '@nrwl/cypress:cypress', + (options, projectName, targetName, configName) => { + if (options.cypressConfig && tree.exists(options.cypressConfig)) { + updatePreset(tree, options, targetName); + } + + const projectConfig = readProjectConfiguration(tree, projectName); + const testingType = + options.testingType || + projectConfig.targets[targetName]?.options?.testingType; + const devServerTarget = + options.devServerTarget || + projectConfig.targets[targetName]?.options?.devServerTarget; + + if (!devServerTarget && testingType === 'component') { + updateTasks.push( + addBuildTargetToConfig( + tree, + projectName, + targetName, + configName + ).then((didUpdate) => { + if (!didUpdate) { + projectsWithoutDevServerTarget.add(projectName); + } + }) + ); + } + } + ); + + await Promise.all(updateTasks); + + if (projectsWithoutDevServerTarget.size > 0) { + logger.warn( + `Unable to find a build target to add to the component testing target in the following projects:` + ); + logger.warn(`- ${Array.from(projectsWithoutDevServerTarget).join('\n- ')}`); + logger.warn(stripIndents` +You can manually add the 'devServerTarget' option to the +component testing target to specify the build target to use. +The build configuration should be using @nrwl/web:webpack as the executor. +Usually this is a React app in your workspace. +Component testing will fallback to a default configuration if one isn't provided, +but might require modifications if your projects are more complex. + `); + } +} + +function updatePreset( + tree: Tree, + options: CypressExecutorOptions, + targetName: string | undefined +) { + let contents = tsquery.replace( + tree.read(options.cypressConfig, 'utf-8'), + 'CallExpression', + (node: ts.CallExpression) => { + // technically someone could have both component and e2e in the same project. + const expression = node.expression.getText(); + if (expression === 'nxE2EPreset') { + return 'nxE2EPreset(__filename)'; + } else if (expression === 'nxE2EStorybookPreset') { + return 'nxE2EStorybookPreset(__filename)'; + } else if (node.expression.getText() === 'nxComponentTestingPreset') { + return targetName && targetName !== 'component-test' // the default + ? `nxComponentTestingPreset(__filename, { ctTargetName: '${targetName}' })` + : 'nxComponentTestingPreset(__filename)'; + } + return; + } + ); + + tree.write(options.cypressConfig, contents); +} + +async function addBuildTargetToConfig( + tree: Tree, + projectName: string, + targetName: string, + configName?: string +): Promise { + const { foundTarget, targetConfig } = await findBuildConfig(tree, { + project: projectName, + validExecutorNames: new Set(['@nrwl/web:webpack']), + }); + // didn't find the config so can't update. consumer should collect list of them and display a warning at the end + // no reason to fail since the preset will fallback to a default config so should still keep working. + if (!foundTarget || !targetConfig) { + return false; + } + + const projectConfig = readProjectConfiguration(tree, projectName); + if ( + configName && + foundTarget !== projectConfig.targets[targetName]?.options?.devServerTarget + ) { + projectConfig.targets[targetName].configurations[configName] = { + ...projectConfig.targets[targetName].configurations[configName], + devServerTarget: foundTarget, + skipServe: true, + }; + } else { + projectConfig.targets[targetName].options = { + ...projectConfig.targets[targetName].options, + devServerTarget: foundTarget, + skipServe: true, + }; + } + + updateProjectConfiguration(tree, projectName, projectConfig); + return true; +} + +export default updateCypressConfigsPresets; diff --git a/packages/cypress/src/utils/find-target-options.ts b/packages/cypress/src/utils/find-target-options.ts new file mode 100644 index 00000000000000..fd4ae0c2c6da0a --- /dev/null +++ b/packages/cypress/src/utils/find-target-options.ts @@ -0,0 +1,130 @@ +import { + createProjectGraphAsync, + parseTargetString, + ProjectGraph, + ProjectGraphDependency, + readProjectConfiguration, + TargetConfiguration, + Tree, +} from '@nrwl/devkit'; + +interface FindTargetOptions { + project: string; + /** + * contains buildable target such as react app or angular app + * :[:] + */ + buildTarget?: string; + validExecutorNames: Set; +} + +export async function findBuildConfig( + tree: Tree, + options: FindTargetOptions +): Promise<{ foundTarget: string; targetConfig: TargetConfiguration }> { + // attempt to use the provided target + if (options.buildTarget) { + return { + foundTarget: options.buildTarget, + targetConfig: findInTarget(tree, options), + }; + } + // check to see if there is a valid config in the given project + const [selfBuildTarget, selfProjectConfig] = findTargetOptionsInProject( + tree, + options.project, + options.validExecutorNames + ); + if (selfBuildTarget && selfProjectConfig) { + return { + targetConfig: selfProjectConfig, + foundTarget: selfBuildTarget, + }; + } + + // attempt to find any projects with the valid config in the graph that consumes this project + const [graphBuildTarget, graphTargetConfig] = await findInGraph( + tree, + options + ); + return { + foundTarget: graphBuildTarget, + targetConfig: graphTargetConfig, + }; +} + +function findInTarget(tree: Tree, options: FindTargetOptions) { + const { project, target, configuration } = parseTargetString( + options.buildTarget + ); + const projectConfig = readProjectConfiguration(tree, project); + const foundConfig = + configuration || projectConfig?.targets?.[target]?.defaultConfiguration; + + if (!foundConfig) { + return projectConfig?.targets?.[target]; + } + projectConfig.targets[target].options = { + ...(projectConfig.targets[target]?.options || {}), + ...projectConfig.targets[target]?.configurations[foundConfig], + }; + return projectConfig.targets[target]; +} + +async function findInGraph( + tree: Tree, + options: FindTargetOptions +): Promise<[targetName: string, config: TargetConfiguration]> { + // TODO(caleb): check with craigory to make sure this isn't going to have the same issues at @nrwl/js:tsc + const graph = await createProjectGraphAsync(); + const parents = findParentsOfProject(graph, options.project); + if (parents.length > 0) { + // TODO(caleb): how do I tell which is the "correct" one to use? i.e. which is the "closest" to the current project if more than 1 with valid config? + for (const parent of parents) { + const [maybeBuildTarget, maybeTargetConfig] = findTargetOptionsInProject( + tree, + parent.source, + options.validExecutorNames + ); + if (maybeBuildTarget && maybeTargetConfig) { + return [maybeBuildTarget, maybeTargetConfig]; + } + } + } + return [null, null]; +} + +function findParentsOfProject( + graph: ProjectGraph, + projectName: string +): ProjectGraphDependency[] { + const parents = []; + for (const dep in graph.dependencies) { + const f = graph.dependencies[dep].filter((d) => d.target === projectName); + parents.push(...f); + } + return parents; +} + +function findTargetOptionsInProject( + tree: Tree, + projectName: string, + includes: Set +): [buildTarget: string, config: TargetConfiguration] { + const projectConfig = readProjectConfiguration(tree, projectName); + + for (const targetName in projectConfig.targets) { + const targetConfig = projectConfig.targets[targetName]; + if (includes.has(targetConfig.executor)) { + targetConfig.options = targetConfig.defaultConfiguration + ? { + ...targetConfig.options, + ...targetConfig.configurations[targetConfig.defaultConfiguration], + } + : targetConfig.options; + + return [`${projectName}:${targetName}`, targetConfig]; + } + } + return [null, null]; +} diff --git a/packages/react/plugins/component-testing/index.ts b/packages/react/plugins/component-testing/index.ts index 130d18c94a4da3..cf36126ea0a41d 100644 --- a/packages/react/plugins/component-testing/index.ts +++ b/packages/react/plugins/component-testing/index.ts @@ -1,7 +1,29 @@ import { nxBaseCypressPreset } from '@nrwl/cypress/plugins/cypress-preset'; -import { getCSSModuleLocalIdent } from '@nrwl/web/src/utils/web.config'; -import { TsconfigPathsPlugin } from 'tsconfig-paths-webpack-plugin'; -import type { Configuration } from 'webpack'; +import { + logger, + parseTargetString, + ProjectConfiguration, + ProjectGraph, + readCachedProjectGraph, + TargetConfiguration, + workspaceRoot, + stripIndents, +} from '@nrwl/devkit'; +import { 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 { 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 @@ -18,9 +40,46 @@ import type { Configuration } from 'webpack'; * } * }) * - * @param pathToConfig will be used to construct the output paths for videos and screenshots + * @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) { +export function nxComponentTestingPreset( + pathToConfig: string, + options?: ReactComponentTestingOptions +) { + let webpackConfig; + try { + const graph = readCachedProjectGraph(); + const { targets, name } = getConfigByPath(graph, pathToConfig); + const targetName = options?.ctTargetName || 'component-test'; + const mergedOptions = targets[targetName].defaultConfiguration + ? { + ...targets[targetName].options, + ...targets[targetName].configurations[ + targets[targetName].defaultConfiguration + ], + } + : targets[targetName].options; + const buildTarget = mergedOptions?.devServerTarget; + + if (!buildTarget) { + throw new Error( + `Unable to find the 'devServerTarget' executor option in the '${targetName}' target of the '${name}' project` + ); + } + + webpackConfig = buildTargetWebpack(graph, buildTarget, name); + } catch (e) { + logger.warn( + stripIndents`Unable to build a webpack config with the project graph. + Falling back to default webpack config.` + ); + logger.warn(e); + webpackConfig = buildBaseWebpackConfig({ + tsConfigPath: 'tsconfig.cy.json', + compiler: 'babel', + }); + } return { ...nxBaseCypressPreset(pathToConfig), devServer: { @@ -28,152 +87,92 @@ export function nxComponentTestingPreset(pathToConfig: string) { // need to use const to prevent typing to string framework: 'react', bundler: 'webpack', - webpackConfig: buildBaseWebpackConfig({ - tsConfigPath: 'tsconfig.cy.json', - compiler: 'babel', - }), + webpackConfig, } as const, }; } -// TODO(caleb): use the webpack utils to build the config -// can't seem to get css modules to play nice when using it 🤔 -function buildBaseWebpackConfig({ - tsConfigPath = 'tsconfig.cy.json', - compiler = 'babel', -}: { - tsConfigPath: string; - compiler: 'swc' | 'babel'; -}): Configuration { - const extensions = ['.ts', '.tsx', '.mjs', '.js', '.jsx']; - const config: Configuration = { - target: 'web', - resolve: { - extensions, - plugins: [ - new TsconfigPathsPlugin({ - configFile: tsConfigPath, - extensions, - }) as never, - ], - }, - mode: 'development', - devtool: false, - output: { - publicPath: '/', - chunkFilename: '[name].bundle.js', - }, - module: { - rules: [ - { - test: /\.(bmp|png|jpe?g|gif|webp|avif)$/, - type: 'asset', - parser: { - dataUrlCondition: { - maxSize: 10_000, // 10 kB - }, - }, - }, - CSS_MODULES_LOADER, - ], - }, - }; +function withSchemaDefaults( + target: TargetConfiguration +) { + const options = target.defaultConfiguration + ? { + ...target.options, + ...target.configurations[target.defaultConfiguration], + } + : target.options; - if (compiler === 'swc') { - config.module.rules.push({ - test: /\.([jt])sx?$/, - loader: require.resolve('swc-loader'), - exclude: /node_modules/, - options: { - jsc: { - parser: { - syntax: 'typescript', - decorators: true, - tsx: true, - }, - transform: { - react: { - runtime: 'automatic', - }, - }, - loose: true, - }, - }, - }); - } + options.scripts = options.scripts || []; + options.fileReplacements = options.fileReplacements || []; + options.styles = options.styles || []; + options.namedChunks = options.namedChunks || true; + return options; +} - if (compiler === 'babel') { - config.module.rules.push({ - test: /\.(js|jsx|mjs|ts|tsx)$/, - loader: require.resolve('babel-loader'), - options: { - presets: [`@nrwl/react/babel`], - rootMode: 'upward', - babelrc: true, - }, - }); +function buildTargetWebpack( + graph: ProjectGraph, + buildTarget: string, + componentTestingProjectName: string +) { + const { project, target, configuration } = parseTargetString(buildTarget); + + const appProjectConfig = graph.nodes[project]?.data; + const thisProjectConfig = graph.nodes[componentTestingProjectName]?.data; + + if (!appProjectConfig || !thisProjectConfig) { + throw new Error(stripIndents`Unable to load project configs from graph. + Has build config? ${!!appProjectConfig} + Has component config? ${!!thisProjectConfig} + `); } - return config; -} -const loaderModulesOptions = { - modules: { - mode: 'local', - getLocalIdent: getCSSModuleLocalIdent, - }, - importLoaders: 1, -}; + const options = normalizeWebBuildOptions( + withSchemaDefaults(appProjectConfig?.targets?.[target]), + workspaceRoot, + appProjectConfig.sourceRoot! + ); -const commonLoaders = [ - { - loader: require.resolve('style-loader'), - }, - { - loader: require.resolve('css-loader'), - options: loaderModulesOptions, - }, -]; + const isScriptOptimizeOn = + typeof options.optimization === 'boolean' + ? options.optimization + : options.optimization && options.optimization.scripts + ? options.optimization.scripts + : false; + return getWebConfig( + workspaceRoot, + thisProjectConfig.root, + thisProjectConfig.sourceRoot, + options, + true, + isScriptOptimizeOn, + configuration + ); +} -const CSS_MODULES_LOADER = { - test: /\.css$|\.scss$|\.sass$|\.less$|\.styl$/, - oneOf: [ - { - test: /\.module\.css$/, - use: commonLoaders, - }, - { - test: /\.module\.(scss|sass)$/, - use: [ - ...commonLoaders, - { - loader: require.resolve('sass-loader'), - options: { - implementation: require('sass'), - sassOptions: { - fiber: false, - precision: 8, - }, - }, - }, - ], - }, - { - test: /\.module\.less$/, - use: [ - ...commonLoaders, - { - loader: require.resolve('less-loader'), - }, - ], - }, - { - test: /\.module\.styl$/, - use: [ - ...commonLoaders, - { - loader: require.resolve('stylus-loader'), - }, - ], - }, - ], -}; +function getConfigByPath( + graph: ProjectGraph, + configPath: string +): ProjectConfiguration { + const configFileFromWorkspaceRoot = relative( + workspaceRoot, + configPath + ).replace(extname(configPath), ''); + const mappedGraph = mapProjectGraphFiles(graph); + const componentTestingProjectName = + mappedGraph.allFiles[configFileFromWorkspaceRoot]; + if ( + !componentTestingProjectName || + !graph.nodes[componentTestingProjectName]?.data + ) { + throw new Error( + stripIndents`Unable to find the project configuration that includes ${configFileFromWorkspaceRoot}. + 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 = + graph.nodes[componentTestingProjectName].data.name || + componentTestingProjectName; + return graph.nodes[componentTestingProjectName].data; +} diff --git a/packages/react/plugins/component-testing/webpack-fallback.ts b/packages/react/plugins/component-testing/webpack-fallback.ts new file mode 100644 index 00000000000000..adba6fd7a13867 --- /dev/null +++ b/packages/react/plugins/component-testing/webpack-fallback.ts @@ -0,0 +1,143 @@ +import { getCSSModuleLocalIdent } from '@nrwl/web/src/utils/web.config'; +import { TsconfigPathsPlugin } from 'tsconfig-paths-webpack-plugin'; +import { Configuration } from 'webpack'; + +export function buildBaseWebpackConfig({ + tsConfigPath = 'tsconfig.cy.json', + compiler = 'babel', +}: { + tsConfigPath: string; + compiler: 'swc' | 'babel'; +}): Configuration { + const extensions = ['.ts', '.tsx', '.mjs', '.js', '.jsx']; + const config: Configuration = { + target: 'web', + resolve: { + extensions, + plugins: [ + new TsconfigPathsPlugin({ + configFile: tsConfigPath, + extensions, + }) as never, + ], + }, + mode: 'development', + devtool: false, + output: { + publicPath: '/', + chunkFilename: '[name].bundle.js', + }, + module: { + rules: [ + { + test: /\.(bmp|png|jpe?g|gif|webp|avif)$/, + type: 'asset', + parser: { + dataUrlCondition: { + maxSize: 10_000, // 10 kB + }, + }, + }, + CSS_MODULES_LOADER, + ], + }, + }; + + if (compiler === 'swc') { + config.module.rules.push({ + test: /\.([jt])sx?$/, + loader: require.resolve('swc-loader'), + exclude: /node_modules/, + options: { + jsc: { + parser: { + syntax: 'typescript', + decorators: true, + tsx: true, + }, + transform: { + react: { + runtime: 'automatic', + }, + }, + loose: true, + }, + }, + }); + } + + if (compiler === 'babel') { + config.module.rules.push({ + test: /\.(js|jsx|mjs|ts|tsx)$/, + loader: require.resolve('babel-loader'), + options: { + presets: [`@nrwl/react/babel`], + rootMode: 'upward', + babelrc: true, + }, + }); + } + return config; +} + +const loaderModulesOptions = { + modules: { + mode: 'local', + getLocalIdent: getCSSModuleLocalIdent, + }, + importLoaders: 1, +}; + +const commonLoaders = [ + { + loader: require.resolve('style-loader'), + }, + { + loader: require.resolve('css-loader'), + options: loaderModulesOptions, + }, +]; + +const CSS_MODULES_LOADER = { + test: /\.css$|\.scss$|\.sass$|\.less$|\.styl$/, + oneOf: [ + { + test: /\.module\.css$/, + use: commonLoaders, + }, + { + test: /\.module\.(scss|sass)$/, + use: [ + ...commonLoaders, + { + loader: require.resolve('sass-loader'), + options: { + implementation: require('sass'), + sassOptions: { + fiber: false, + precision: 8, + }, + }, + }, + ], + }, + { + test: /\.module\.less$/, + use: [ + ...commonLoaders, + { + loader: require.resolve('less-loader'), + }, + ], + }, + { + test: /\.module\.styl$/, + use: [ + ...commonLoaders, + { + loader: require.resolve('stylus-loader'), + }, + ], + }, + ], +}; diff --git a/packages/react/src/generators/cypress-component-configuration/cypress-component-configuration.spec.ts b/packages/react/src/generators/cypress-component-configuration/cypress-component-configuration.spec.ts index 35498e9b36dbac..513eeb0e4cef2e 100644 --- a/packages/react/src/generators/cypress-component-configuration/cypress-component-configuration.spec.ts +++ b/packages/react/src/generators/cypress-component-configuration/cypress-component-configuration.spec.ts @@ -1,11 +1,25 @@ import { assertMinimumCypressVersion } from '@nrwl/cypress/src/utils/cypress-version'; -import { readJson, Tree } from '@nrwl/devkit'; +import { + DependencyType, + ProjectGraph, + readJson, + readProjectConfiguration, + Tree, +} from '@nrwl/devkit'; import { createTreeWithEmptyV1Workspace } from '@nrwl/devkit/testing'; import { Linter } from '@nrwl/linter'; -import componentGenerator from '../component/component'; -import libraryGenerator from '../library/library'; +import { applicationGenerator } from '../application/application'; +import { componentGenerator } from '../component/component'; +import { libraryGenerator } from '../library/library'; import { cypressComponentConfigGenerator } from './cypress-component-configuration'; +let projectGraph: ProjectGraph; +jest.mock('@nrwl/devkit', () => ({ + ...jest.requireActual('@nrwl/devkit'), + createProjectGraphAsync: jest + .fn() + .mockImplementation(async () => projectGraph), +})); jest.mock('@nrwl/cypress/src/utils/cypress-version'); describe('React:CypressComponentTestConfiguration', () => { let tree: Tree; @@ -15,9 +29,17 @@ describe('React:CypressComponentTestConfiguration', () => { beforeEach(() => { tree = createTreeWithEmptyV1Workspace(); }); - it('should generate cypress component test config', async () => { + it('should generate cypress component test config with --build-target', async () => { mockedAssertCypressVersion.mockReturnValue(); + await applicationGenerator(tree, { + e2eTestRunner: 'none', + linter: Linter.EsLint, + skipFormat: true, + style: 'scss', + unitTestRunner: 'none', + name: 'my-app', + }); await libraryGenerator(tree, { linter: Linter.EsLint, name: 'some-lib', @@ -31,13 +53,16 @@ describe('React:CypressComponentTestConfiguration', () => { await cypressComponentConfigGenerator(tree, { project: 'some-lib', generateTests: false, + buildTarget: 'my-app:build', }); const config = tree.read('libs/some-lib/cypress.config.ts', 'utf-8'); expect(config).toContain( "import { nxComponentTestingPreset } from '@nrwl/react/plugins/component-testing" ); - expect(config).toContain('component: nxComponentTestingPreset(__dirname),'); + expect(config).toContain( + 'component: nxComponentTestingPreset(__filename),' + ); const cyTsConfig = readJson(tree, 'libs/some-lib/tsconfig.cy.json'); expect(cyTsConfig.include).toEqual([ @@ -63,11 +88,123 @@ describe('React:CypressComponentTestConfiguration', () => { expect(baseTsConfig.references).toEqual( expect.arrayContaining([{ path: './tsconfig.cy.json' }]) ); + expect( + readProjectConfiguration(tree, 'some-lib').targets['component-test'] + ).toEqual({ + executor: '@nrwl/cypress:cypress', + options: { + cypressConfig: 'libs/some-lib/cypress.config.ts', + devServerTarget: 'my-app:build', + skipServe: true, + testingType: 'component', + }, + }); }); - it('should generate tests for existing tsx components', async () => { + it('should generate cypress component test config with project graph', async () => { mockedAssertCypressVersion.mockReturnValue(); + await applicationGenerator(tree, { + e2eTestRunner: 'none', + linter: Linter.EsLint, + skipFormat: true, + style: 'scss', + unitTestRunner: 'none', + name: 'my-app', + }); + await libraryGenerator(tree, { + linter: Linter.EsLint, + name: 'some-lib', + skipFormat: true, + skipTsConfig: false, + style: 'scss', + unitTestRunner: 'none', + component: true, + }); + + projectGraph = { + nodes: { + 'my-app': { + name: 'my-app', + type: 'app', + data: { + ...readProjectConfiguration(tree, 'my-app'), + }, + }, + 'some-lib': { + name: 'some-lib', + type: 'lib', + data: { + ...readProjectConfiguration(tree, 'some-lib'), + }, + }, + }, + dependencies: { + 'my-app': [ + { type: DependencyType.static, source: 'my-app', target: 'some-lib' }, + ], + }, + }; + + await cypressComponentConfigGenerator(tree, { + project: 'some-lib', + generateTests: false, + }); + + const config = tree.read('libs/some-lib/cypress.config.ts', 'utf-8'); + expect(config).toContain( + "import { nxComponentTestingPreset } from '@nrwl/react/plugins/component-testing" + ); + expect(config).toContain( + 'component: nxComponentTestingPreset(__filename),' + ); + + const cyTsConfig = readJson(tree, 'libs/some-lib/tsconfig.cy.json'); + expect(cyTsConfig.include).toEqual([ + 'cypress.config.ts', + '**/*.cy.ts', + '**/*.cy.tsx', + '**/*.cy.js', + '**/*.cy.jsx', + '**/*.d.ts', + ]); + const libTsConfig = readJson(tree, 'libs/some-lib/tsconfig.lib.json'); + expect(libTsConfig.exclude).toEqual( + expect.arrayContaining([ + 'cypress/**/*', + 'cypress.config.ts', + '**/*.cy.ts', + '**/*.cy.js', + '**/*.cy.tsx', + '**/*.cy.jsx', + ]) + ); + const baseTsConfig = readJson(tree, 'libs/some-lib/tsconfig.json'); + expect(baseTsConfig.references).toEqual( + expect.arrayContaining([{ path: './tsconfig.cy.json' }]) + ); + expect( + readProjectConfiguration(tree, 'some-lib').targets['component-test'] + ).toEqual({ + executor: '@nrwl/cypress:cypress', + options: { + cypressConfig: 'libs/some-lib/cypress.config.ts', + devServerTarget: 'my-app:build', + skipServe: true, + testingType: 'component', + }, + }); + }); + it('should generate tests for existing tsx components', async () => { + mockedAssertCypressVersion.mockReturnValue(); + await applicationGenerator(tree, { + e2eTestRunner: 'none', + linter: Linter.EsLint, + skipFormat: true, + style: 'scss', + unitTestRunner: 'none', + name: 'my-app', + }); await libraryGenerator(tree, { linter: Linter.EsLint, name: 'some-lib', @@ -86,6 +223,7 @@ describe('React:CypressComponentTestConfiguration', () => { await cypressComponentConfigGenerator(tree, { project: 'some-lib', generateTests: true, + buildTarget: 'my-app:build', }); expect(tree.exists('libs/some-lib/src/lib/some-lib.cy.tsx')).toBeTruthy(); @@ -106,7 +244,14 @@ describe('React:CypressComponentTestConfiguration', () => { }); it('should generate tests for existing js components', async () => { mockedAssertCypressVersion.mockReturnValue(); - + await applicationGenerator(tree, { + e2eTestRunner: 'none', + linter: Linter.EsLint, + skipFormat: true, + style: 'scss', + unitTestRunner: 'none', + name: 'my-app', + }); await libraryGenerator(tree, { linter: Linter.EsLint, name: 'some-lib', @@ -133,6 +278,7 @@ describe('React:CypressComponentTestConfiguration', () => { await cypressComponentConfigGenerator(tree, { project: 'some-lib', generateTests: true, + buildTarget: 'my-app:build', }); expect(tree.exists('libs/some-lib/src/lib/some-cmp.cy.js')).toBeTruthy(); diff --git a/packages/react/src/generators/cypress-component-configuration/cypress-component-configuration.ts b/packages/react/src/generators/cypress-component-configuration/cypress-component-configuration.ts index 203cee67565b55..f27aabadac8df7 100644 --- a/packages/react/src/generators/cypress-component-configuration/cypress-component-configuration.ts +++ b/packages/react/src/generators/cypress-component-configuration/cypress-component-configuration.ts @@ -1,21 +1,8 @@ import { cypressComponentProject } from '@nrwl/cypress'; -import { - formatFiles, - generateFiles, - joinPathFragments, - ProjectConfiguration, - readProjectConfiguration, - Tree, - updateJson, - visitNotIgnoredFiles, -} from '@nrwl/devkit'; -import * as ts from 'typescript'; -import { getComponentNode } from '../../utils/ast-utils'; -import componentTestGenerator from '../component-test/component-test'; -import { CypressComponentConfigurationSchema } from './schema'; - -const allowedFileExt = new RegExp(/\.[jt]sx?/g); -const isSpecFile = new RegExp(/(spec|test)\./g); +import { formatFiles, readProjectConfiguration, Tree } from '@nrwl/devkit'; +import { addFiles } from './lib/add-files'; +import { updateProjectConfig, updateTsConfig } from './lib/update-configs'; +import { CypressComponentConfigurationSchema } from './schema.d'; /** * This is for using cypresses own Component testing, if you want to use test @@ -32,6 +19,7 @@ export async function cypressComponentConfigGenerator( skipFormat: true, }); + await updateProjectConfig(tree, options); addFiles(tree, projectConfig, options); updateTsConfig(tree, projectConfig); if (options.skipFormat) { @@ -43,110 +31,4 @@ export async function cypressComponentConfigGenerator( }; } -function addFiles( - tree: Tree, - projectConfig: ProjectConfiguration, - options: CypressComponentConfigurationSchema -) { - 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) { - visitNotIgnoredFiles(tree, projectConfig.sourceRoot, (filePath) => { - if (isComponent(tree, filePath)) { - componentTestGenerator(tree, { - project: options.project, - componentPath: filePath, - }); - } - }); - } -} - -function updateTsConfig(tree: Tree, projectConfig: ProjectConfiguration) { - const tsConfigPath = joinPathFragments( - projectConfig.root, - projectConfig.projectType === 'library' - ? 'tsconfig.lib.json' - : 'tsconfig.app.json' - ); - if (tree.exists(tsConfigPath)) { - updateJson(tree, tsConfigPath, (json) => { - const excluded = new Set([ - ...(json.exclude || []), - 'cypress/**/*', - 'cypress.config.ts', - '**/*.cy.ts', - '**/*.cy.js', - '**/*.cy.tsx', - '**/*.cy.jsx', - ]); - - json.exclude = Array.from(excluded); - return json; - }); - } - - const projectBaseTsConfig = joinPathFragments( - projectConfig.root, - 'tsconfig.json' - ); - if (tree.exists(projectBaseTsConfig)) { - updateJson(tree, projectBaseTsConfig, (json) => { - if (json.references) { - const hasCyTsConfig = json.references.some( - (r) => r.path === './tsconfig.cy.json' - ); - if (!hasCyTsConfig) { - json.references.push({ path: './tsconfig.cy.json' }); - } - } else { - const excluded = new Set([ - ...(json.exclude || []), - 'cypress/**/*', - 'cypress.config.ts', - '**/*.cy.ts', - '**/*.cy.js', - '**/*.cy.tsx', - '**/*.cy.jsx', - ]); - - json.exclude = Array.from(excluded); - } - return json; - }); - } -} - -function isComponent(tree: Tree, filePath: string): boolean { - if (isSpecFile.test(filePath) || !allowedFileExt.test(filePath)) { - return false; - } - - const content = tree.read(filePath, 'utf-8'); - const sourceFile = ts.createSourceFile( - filePath, - content, - ts.ScriptTarget.Latest, - true - ); - - const cmpDeclaration = getComponentNode(sourceFile); - return !!cmpDeclaration; -} - export default cypressComponentConfigGenerator; diff --git a/packages/react/src/generators/cypress-component-configuration/files/cypress.config.ts__tpl__ b/packages/react/src/generators/cypress-component-configuration/files/cypress.config.ts__tpl__ index e8ebe4454b4304..6fa4db13856846 100644 --- a/packages/react/src/generators/cypress-component-configuration/files/cypress.config.ts__tpl__ +++ b/packages/react/src/generators/cypress-component-configuration/files/cypress.config.ts__tpl__ @@ -2,5 +2,5 @@ import { defineConfig } from 'cypress'; import { nxComponentTestingPreset } from '@nrwl/react/plugins/component-testing'; export default defineConfig({ - component: nxComponentTestingPreset(__dirname), + component: nxComponentTestingPreset(__filename), }); diff --git a/packages/react/src/generators/cypress-component-configuration/lib/add-files.ts b/packages/react/src/generators/cypress-component-configuration/lib/add-files.ts new file mode 100644 index 00000000000000..2c5ddf3588f12f --- /dev/null +++ b/packages/react/src/generators/cypress-component-configuration/lib/add-files.ts @@ -0,0 +1,65 @@ +import { + generateFiles, + joinPathFragments, + ProjectConfiguration, + Tree, + visitNotIgnoredFiles, +} from '@nrwl/devkit'; +import * as ts from 'typescript'; +import { getComponentNode } from '../../../utils/ast-utils'; +import { componentTestGenerator } from '../../component-test/component-test'; +import { CypressComponentConfigurationSchema } from '../schema'; + +const allowedFileExt = new RegExp(/\.[jt]sx?/g); +const isSpecFile = new RegExp(/(spec|test)\./g); + +export function addFiles( + tree: Tree, + projectConfig: ProjectConfiguration, + options: CypressComponentConfigurationSchema +) { + 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) { + visitNotIgnoredFiles(tree, projectConfig.sourceRoot, (filePath) => { + if (isComponent(tree, filePath)) { + componentTestGenerator(tree, { + project: options.project, + componentPath: filePath, + }); + } + }); + } +} + +function isComponent(tree: Tree, filePath: string): boolean { + if (isSpecFile.test(filePath) || !allowedFileExt.test(filePath)) { + return false; + } + + const content = tree.read(filePath, 'utf-8'); + const sourceFile = ts.createSourceFile( + filePath, + content, + ts.ScriptTarget.Latest, + true + ); + + const cmpDeclaration = getComponentNode(sourceFile); + return !!cmpDeclaration; +} diff --git a/packages/react/src/generators/cypress-component-configuration/lib/update-configs.ts b/packages/react/src/generators/cypress-component-configuration/lib/update-configs.ts new file mode 100644 index 00000000000000..064c6199df6558 --- /dev/null +++ b/packages/react/src/generators/cypress-component-configuration/lib/update-configs.ts @@ -0,0 +1,97 @@ +import { findBuildConfig } from '@nrwl/cypress/src/utils/find-target-options'; +import { + joinPathFragments, + ProjectConfiguration, + readProjectConfiguration, + Tree, + updateJson, + updateProjectConfiguration, +} from '@nrwl/devkit'; +import { CypressComponentConfigurationSchema } from '../schema'; + +export function updateTsConfig( + tree: Tree, + projectConfig: ProjectConfiguration +) { + const tsConfigPath = joinPathFragments( + projectConfig.root, + projectConfig.projectType === 'library' + ? 'tsconfig.lib.json' + : 'tsconfig.app.json' + ); + if (tree.exists(tsConfigPath)) { + updateJson(tree, tsConfigPath, (json) => { + const excluded = new Set([ + ...(json.exclude || []), + 'cypress/**/*', + 'cypress.config.ts', + '**/*.cy.ts', + '**/*.cy.js', + '**/*.cy.tsx', + '**/*.cy.jsx', + ]); + + json.exclude = Array.from(excluded); + return json; + }); + } + + const projectBaseTsConfig = joinPathFragments( + projectConfig.root, + 'tsconfig.json' + ); + if (tree.exists(projectBaseTsConfig)) { + updateJson(tree, projectBaseTsConfig, (json) => { + if (json.references) { + const hasCyTsConfig = json.references.some( + (r) => r.path === './tsconfig.cy.json' + ); + if (!hasCyTsConfig) { + json.references.push({ path: './tsconfig.cy.json' }); + } + } else { + const excluded = new Set([ + ...(json.exclude || []), + 'cypress/**/*', + 'cypress.config.ts', + '**/*.cy.ts', + '**/*.cy.js', + '**/*.cy.tsx', + '**/*.cy.jsx', + ]); + + json.exclude = Array.from(excluded); + } + return json; + }); + } +} + +export async function updateProjectConfig( + tree: Tree, + options: CypressComponentConfigurationSchema +) { + const found = await findBuildConfig(tree, { + project: options.project, + buildTarget: options.buildTarget, + validExecutorNames: new Set(['@nrwl/web:webpack']), + }); + + assetValidConfig(found.targetConfig); + + const projectConfig = readProjectConfiguration(tree, options.project); + projectConfig.targets['component-test'].options = { + ...projectConfig.targets['component-test'].options, + devServerTarget: found.foundTarget, + skipServe: true, + }; + updateProjectConfiguration(tree, options.project, projectConfig); +} + +function assetValidConfig(config: unknown) { + if (!config) { + throw new Error( + 'Unable to find a valid build configuration. Try passing in a target for a React app. --build-target=:[:]' + ); + } +} diff --git a/packages/react/src/generators/cypress-component-configuration/schema.ts b/packages/react/src/generators/cypress-component-configuration/schema.d.ts similarity index 84% rename from packages/react/src/generators/cypress-component-configuration/schema.ts rename to packages/react/src/generators/cypress-component-configuration/schema.d.ts index 220000b1622720..bc2c652495c82d 100644 --- a/packages/react/src/generators/cypress-component-configuration/schema.ts +++ b/packages/react/src/generators/cypress-component-configuration/schema.d.ts @@ -2,4 +2,5 @@ export interface CypressComponentConfigurationSchema { project: string; generateTests: boolean; skipFormat?: boolean; + buildTarget?: string; } diff --git a/packages/react/src/generators/cypress-component-configuration/schema.json b/packages/react/src/generators/cypress-component-configuration/schema.json index 4a3aa2db8e0d61..771f1e2b04ffb7 100644 --- a/packages/react/src/generators/cypress-component-configuration/schema.json +++ b/packages/react/src/generators/cypress-component-configuration/schema.json @@ -1,5 +1,5 @@ { - "$schema": "http://json-schema.org/schema", + "$schema": "https://json-schema.org/schema", "cli": "nx", "$id": "NxReactCypressComponentTestConfiguration", "title": "Add Cypress component testing", @@ -24,6 +24,11 @@ }, "x-prompt": "What project should we add Cypress component testing to?" }, + "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 from a React app. If not provided we will try to infer it from your projects usage.", + "pattern": "^[^:\\s]+:[^:\\s]+(:\\S+)?$" + }, "generateTests": { "type": "boolean", "description": "Generate default component tests for existing components in the project",