diff --git a/e2e/angular-extensions/src/cypress-component-tests.test.ts b/e2e/angular-extensions/src/cypress-component-tests.test.ts index 8b025003f60cc..3d52b7ed70e08 100644 --- a/e2e/angular-extensions/src/cypress-component-tests.test.ts +++ b/e2e/angular-extensions/src/cypress-component-tests.test.ts @@ -8,6 +8,7 @@ import { uniq, updateFile, updateProjectConfig, + removeFile, } from '../../utils'; import { names } from '@nrwl/devkit'; @@ -19,20 +20,120 @@ describe('Angular Cypress Component Tests', () => { beforeAll(async () => { projectName = newProject({ name: uniq('cy-ng') }); - runCLI(`generate @nrwl/angular:app ${appName} --no-interactive`); + + createApp(appName); + + createLib(projectName, appName, usedInAppLibName); + useLibInApp(projectName, appName, usedInAppLibName); + + createBuildableLib(projectName, buildableLibName); + + useWorkspaceAssetsInApp(appName); + }); + + afterAll(() => cleanupProject()); + + it('should test app', () => { runCLI( - `generate @nrwl/angular:component fancy-component --project=${appName} --no-interactive` + `generate @nrwl/angular:cypress-component-configuration --project=${appName} --generate-tests --no-interactive` ); - runCLI(`generate @nrwl/angular:lib ${usedInAppLibName} --no-interactive`); + if (runCypressTests()) { + expect(runCLI(`component-test ${appName} --no-watch`)).toContain( + 'All specs passed!' + ); + } + }, 300_000); + + it('should successfully component test lib being used in app', () => { runCLI( - `generate @nrwl/angular:component btn --project=${usedInAppLibName} --inlineTemplate --inlineStyle --export --no-interactive` + `generate @nrwl/angular:cypress-component-configuration --project=${usedInAppLibName} --generate-tests --no-interactive` ); + if (runCypressTests()) { + expect(runCLI(`component-test ${usedInAppLibName} --no-watch`)).toContain( + 'All specs passed!' + ); + } + }, 300_000); + + 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 --no-interactive` + ); + }).toThrow(); + + updateTestToAssertTailwindIsNotApplied(buildableLibName); + + runCLI( + `generate @nrwl/angular:cypress-component-configuration --project=${buildableLibName} --generate-tests --build-target=${appName}:build --no-interactive` + ); + if (runCypressTests()) { + expect(runCLI(`component-test ${buildableLibName} --no-watch`)).toContain( + 'All specs passed!' + ); + } + // add tailwind runCLI( - `generate @nrwl/angular:component btn-standalone --project=${usedInAppLibName} --inlineTemplate --inlineStyle --export --standalone --no-interactive` + `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/${usedInAppLibName}/src/lib/btn/btn.component.ts`, - ` + `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)'); + } + ); + + if (runCypressTests()) { + expect(runCLI(`component-test ${buildableLibName} --no-watch`)).toContain( + 'All specs passed!' + ); + checkFilesDoNotExist(`tmp/libs/${buildableLibName}/ct-styles.css`); + } + }, 300_000); + + it('should test lib with implicit dep on buildTarget', () => { + // creates graph like buildableLib -> lib -> app + // updates the apps styles and they should apply to the buildableLib + // even though app is not directly connected to buildableLib + useBuildableLibInLib(projectName, buildableLibName, usedInAppLibName); + + updateBuilableLibTestsToAssertAppStyles(appName, buildableLibName); + + if (runCypressTests()) { + expect(runCLI(`component-test ${buildableLibName} --no-watch`)).toContain( + 'All specs passed!' + ); + } + }); +}); + +function createApp(appName: string) { + runCLI(`generate @nrwl/angular:app ${appName} --no-interactive`); + runCLI( + `generate @nrwl/angular:component fancy-component --project=${appName} --no-interactive` + ); +} + +function createLib(projectName: string, appName: string, libName: string) { + runCLI(`generate @nrwl/angular:lib ${libName} --no-interactive`); + runCLI( + `generate @nrwl/angular:component btn --project=${libName} --inlineTemplate --inlineStyle --export --no-interactive` + ); + runCLI( + `generate @nrwl/angular:component btn-standalone --project=${libName} --inlineTemplate --inlineStyle --export --standalone --no-interactive` + ); + updateFile( + `libs/${libName}/src/lib/btn/btn.component.ts`, + ` import { Component, Input } from '@angular/core'; @Component({ @@ -44,10 +145,10 @@ export class BtnComponent { @Input() text = 'something'; } ` - ); - updateFile( - `libs/${usedInAppLibName}/src/lib/btn-standalone/btn-standalone.component.ts`, - ` + ); + updateFile( + `libs/${libName}/src/lib/btn-standalone/btn-standalone.component.ts`, + ` import { Component, Input } from '@angular/core'; import { CommonModule } from '@angular/common'; @Component({ @@ -61,58 +162,24 @@ 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.component.scss`, - ` -@use 'styleguide' as *; - -h1 { - @include headline; -}` - ); - 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`, - ` +function createBuildableLib(projectName: string, libName: string) { + // create lib + runCLI(`generate @nrwl/angular:lib ${libName} --buildable --no-interactive`); + // create cmp for lib + runCLI( + `generate @nrwl/angular:component input --project=${libName} --inlineTemplate --inlineStyle --export --no-interactive` + ); + // create standlone cmp for lib + runCLI( + `generate @nrwl/angular:component input-standalone --project=${libName} --inlineTemplate --inlineStyle --export --standalone --no-interactive` + ); + // update cmp implmentation to use tailwind clasasserting in tests + updateFile( + `libs/${libName}/src/lib/input/input.component.ts`, + ` import {Component, Input} from '@angular/core'; @Component({ @@ -124,10 +191,10 @@ import {Component, Input} from '@angular/core'; @Input() readOnly = false; } ` - ); - updateFile( - `libs/${buildableLibName}/src/lib/input-standalone/input-standalone.component.ts`, - ` + ); + updateFile( + `libs/${libName}/src/lib/input-standalone/input-standalone.component.ts`, + ` import {Component, Input} from '@angular/core'; import {CommonModule} from '@angular/common'; @Component({ @@ -141,13 +208,55 @@ import {CommonModule} from '@angular/common'; @Input() readOnly = false; } ` - ); + ); +} - // make sure assets from the workspace root work. - createFile('libs/assets/data.json', JSON.stringify({ data: 'data' })); - createFile( - 'assets/styles/styleguide.scss', - ` +function useLibInApp(projectName: string, appName: string, libName: string) { + createFile( + `apps/${appName}/src/app/app.component.html`, + ` +<${projectName}-btn> +<${projectName}-btn-standalone> +<${projectName}-nx-welcome> +` + ); + const btnModuleName = names(libName).className; + updateFile( + `apps/${appName}/src/app/app.component.scss`, + ` +@use 'styleguide' as *; + +h1 { + @include headline; +}` + ); + updateFile( + `apps/${appName}/src/app/app.module.ts`, + ` +import { NgModule } from '@angular/core'; +import { BrowserModule } from '@angular/platform-browser'; +import {${btnModuleName}Module} from "@${projectName}/${libName}"; + +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 {} +` + ); +} + +function useWorkspaceAssetsInApp(appName: string) { + // make sure assets from the workspace root work. + createFile('libs/assets/data.json', JSON.stringify({ data: 'data' })); + createFile( + 'assets/styles/styleguide.scss', + ` @mixin headline { font-weight: bold; color: darkkhaki; @@ -155,54 +264,24 @@ import {CommonModule} from '@angular/common'; font-weight: 24px; } ` - ); - updateProjectConfig(appName, (config) => { - config.targets['build'].options.stylePreprocessorOptions = { - includePaths: ['assets/styles'], - }; - config.targets['build'].options.assets.push({ - glob: '**/*', - input: 'libs/assets', - output: 'assets', - }); - return config; + ); + updateProjectConfig(appName, (config) => { + config.targets['build'].options.stylePreprocessorOptions = { + includePaths: ['assets/styles'], + }; + config.targets['build'].options.assets.push({ + glob: '**/*', + input: 'libs/assets', + output: 'assets', }); + return config; }); +} - afterAll(() => cleanupProject()); - - it('should test app', () => { - runCLI( - `generate @nrwl/angular:cypress-component-configuration --project=${appName} --generate-tests --no-interactive` - ); - if (runCypressTests()) { - expect(runCLI(`component-test ${appName} --no-watch`)).toContain( - 'All specs passed!' - ); - } - }, 300_000); - - it('should successfully component test lib being used in app', () => { - runCLI( - `generate @nrwl/angular:cypress-component-configuration --project=${usedInAppLibName} --generate-tests --no-interactive` - ); - if (runCypressTests()) { - expect(runCLI(`component-test ${usedInAppLibName} --no-watch`)).toContain( - 'All specs passed!' - ); - } - }, 300_000); - - 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 --no-interactive` - ); - }).toThrow(); - createFile( - `libs/${buildableLibName}/src/lib/input/input.component.cy.ts`, - ` +function updateTestToAssertTailwindIsNotApplied(libName: string) { + createFile( + `libs/${libName}/src/lib/input/input.component.cy.ts`, + ` import { MountConfig } from 'cypress/angular'; import { InputComponent } from './input.component'; @@ -229,11 +308,11 @@ describe(InputComponent.name, () => { }); }); ` - ); + ); - createFile( - `libs/${buildableLibName}/src/lib/input-standalone/input-standalone.component.cy.ts`, - ` + createFile( + `libs/${libName}/src/lib/input-standalone/input-standalone.component.cy.ts`, + ` import { MountConfig } from 'cypress/angular'; import { InputStandaloneComponent } from './input-standalone.component'; @@ -260,39 +339,50 @@ describe(InputStandaloneComponent.name, () => { }); }); ` - ); - - runCLI( - `generate @nrwl/angular:cypress-component-configuration --project=${buildableLibName} --generate-tests --build-target=${appName}:build --no-interactive` - ); - if (runCypressTests()) { - expect(runCLI(`component-test ${buildableLibName} --no-watch`)).toContain( - 'All specs passed!' - ); - } + ); +} +function useBuildableLibInLib( + projectName: string, + buildableLibName: string, + libName: string +) { + const buildLibNames = names(buildableLibName); + // use the buildable lib in lib so now buildableLib has an indirect dep on app + updateFile( + `libs/${libName}/src/lib/btn-standalone/btn-standalone.component.ts`, + ` +import { Component, Input } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { InputStandaloneComponent } from '@${projectName}/${buildLibNames.fileName}'; +@Component({ + selector: '${projectName}-btn-standalone', + standalone: true, + imports: [CommonModule, InputStandaloneComponent], + template: '${projectName} <${projectName}-input-standalone>', + styles: [], +}) +export class BtnStandaloneComponent { + @Input() text = 'something'; +} +` + ); +} - // 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)'); - } - ); +function updateBuilableLibTestsToAssertAppStyles( + appName: string, + buildableLibName: string +) { + updateFile( + `apps/${appName}/src/styles.css`, + `label {color: pink !important;}` + ); - expect(runCLI(`component-test ${buildableLibName} --no-watch`)).toContain( - 'All specs passed!' - ); - checkFilesDoNotExist(`tmp/libs/${buildableLibName}/ct-styles.css`); - }, 300_000); -}); + removeFile(`libs/${buildableLibName}/src/lib/input/input.component.cy.ts`); + updateFile( + `libs/${buildableLibName}/src/lib/input-standalone/input-standalone.component.cy.ts`, + (content) => { + // app styles should now apply + return content.replace('rgb(34, 197, 94)', 'rgb(255, 192, 203)'); + } + ); +} diff --git a/e2e/utils/index.ts b/e2e/utils/index.ts index 53c7028962836..4ed9976e7cc7f 100644 --- a/e2e/utils/index.ts +++ b/e2e/utils/index.ts @@ -535,6 +535,10 @@ export function runCypressTests() { if (process.env.NX_E2E_RUN_CYPRESS === 'true') { ensureCypressInstallation(); return true; + } else { + console.warn( + 'Not running Cypress because NX_E2E_RUN_CYPRESS is not set to true.' + ); } return false; } diff --git a/packages/angular/plugins/component-testing.ts b/packages/angular/plugins/component-testing.ts index 9554cbe679ae7..56f3c63a78511 100644 --- a/packages/angular/plugins/component-testing.ts +++ b/packages/angular/plugins/component-testing.ts @@ -22,7 +22,7 @@ import { workspaceRoot, } from '@nrwl/devkit'; import { existsSync, lstatSync, mkdirSync, writeFileSync } from 'fs'; -import { dirname, join, relative } from 'path'; +import { dirname, join, relative, sep } from 'path'; import type { BrowserBuilderSchema } from '../src/builders/webpack-browser/webpack-browser.impl'; /** @@ -168,13 +168,22 @@ function normalizeBuildTargetOptions( ); const buildOptions = withSchemaDefaults(options); + // polyfill entries might be local files or files that are resolved from node_modules + // like zone.js. + // prevents error from webpack saying can't find /zone.js. + const handlePolyfillPath = (polyfill: string) => { + const maybeFullPath = join(workspaceRoot, polyfill.split('/').join(sep)); + if (existsSync(maybeFullPath)) { + return joinPathFragments(offset, polyfill); + } + return polyfill; + }; // paths need to be unix paths for angular devkit buildOptions.polyfills = Array.isArray(buildOptions.polyfills) && buildOptions.polyfills.length > 0 - ? (buildOptions.polyfills as string[]).map((p) => - joinPathFragments(offset, p) - ) - : joinPathFragments(offset, buildOptions.polyfills as string); + ? (buildOptions.polyfills as string[]).map((p) => handlePolyfillPath(p)) + : handlePolyfillPath(buildOptions.polyfills as string); + buildOptions.main = joinPathFragments(offset, buildOptions.main); buildOptions.index = typeof buildOptions.index === 'string' @@ -197,6 +206,7 @@ function normalizeBuildTargetOptions( // then we don't want to have the assets/scripts/styles be included to // prevent inclusion of unintended stuff like tailwind if ( + buildContext.projectName === ctContext.projectName || isCtProjectUsingBuildProject( ctContext.projectGraph, buildContext.projectName, diff --git a/packages/cypress/src/utils/ct-helpers.ts b/packages/cypress/src/utils/ct-helpers.ts index ec2c0eef326df..e0c85d16f8115 100644 --- a/packages/cypress/src/utils/ct-helpers.ts +++ b/packages/cypress/src/utils/ct-helpers.ts @@ -39,20 +39,36 @@ export function getTempTailwindPath(context: ExecutorContext) { } /** - * also returns true if the ct project and build project are the same. - * i.e. component testing inside an app. - */ + * Checks if the childProjectName is a decendent of the parentProjectName + * in the project graph + **/ export function isCtProjectUsingBuildProject( graph: ProjectGraph, parentProjectName: string, childProjectName: string -) { - return ( - parentProjectName === childProjectName || - graph.dependencies[parentProjectName].some( - (p) => p.target === childProjectName - ) +): boolean { + const isProjectDirectDep = graph.dependencies[parentProjectName].some( + (p) => p.target === childProjectName ); + if (isProjectDirectDep) { + return true; + } + const maybeIntermediateProjects = graph.dependencies[ + parentProjectName + ].filter((p) => !graph.externalNodes[p.target]); + + for (const maybeIntermediateProject of maybeIntermediateProjects) { + if ( + isCtProjectUsingBuildProject( + graph, + maybeIntermediateProject.target, + childProjectName + ) + ) { + return true; + } + } + return false; } export function getProjectConfigByPath(