diff --git a/npm/angular/package.json b/npm/angular/package.json index bd35918c57ce..9ebd6c020c74 100644 --- a/npm/angular/package.json +++ b/npm/angular/package.json @@ -5,7 +5,7 @@ "main": "dist/index.js", "scripts": { "prebuild": "rimraf dist", - "build": "tsc || echo 'built, with type errors'", + "build": "rollup -c rollup.config.js", "postbuild": "node ../../scripts/sync-exported-npm-with-cli.js", "build-prod": "yarn build", "check-ts": "tsc --noEmit" @@ -15,6 +15,8 @@ "@angular/common": "^14.0.6", "@angular/core": "^14.0.6", "@angular/platform-browser-dynamic": "^14.0.6", + "@rollup/plugin-node-resolve": "^11.1.1", + "rollup-plugin-typescript2": "^0.29.0", "typescript": "~4.2.3", "zone.js": "~0.11.4" }, diff --git a/npm/angular/rollup.config.js b/npm/angular/rollup.config.js new file mode 100644 index 000000000000..975310fc9131 --- /dev/null +++ b/npm/angular/rollup.config.js @@ -0,0 +1,61 @@ +import ts from 'rollup-plugin-typescript2' +import resolve from '@rollup/plugin-node-resolve' + +import pkg from './package.json' + +const banner = ` +/** + * ${pkg.name} v${pkg.version} + * (c) ${new Date().getFullYear()} Cypress.io + * Released under the MIT License + */ +` + +function createEntry () { + const input = 'src/index.ts' + const format = 'es' + + const config = { + input, + external: [ + '@angular/core', + '@angular/core/testing', + '@angular/common', + '@angular/platform-browser-dynamic/testing', + 'zone.js', + 'zone.js/testing', + ], + plugins: [ + resolve(), + ], + output: { + banner, + name: 'CypressAngular', + file: pkg.module, + format, + exports: 'auto', + }, + } + + console.log(`Building ${format}: ${config.output.file}`) + + config.plugins.push( + ts({ + check: true, + tsconfigOverride: { + compilerOptions: { + declaration: true, + target: 'es6', // not sure what this should be? + module: 'esnext', + }, + exclude: [], + }, + }), + ) + + return config +} + +export default [ + createEntry(), +] diff --git a/npm/angular/src/mount.ts b/npm/angular/src/mount.ts index 15a41bdca9d7..35b9c2f6917c 100644 --- a/npm/angular/src/mount.ts +++ b/npm/angular/src/mount.ts @@ -8,7 +8,7 @@ window.Mocha['__zone_patch__'] = false import 'zone.js/testing' import { CommonModule } from '@angular/common' -import { Type } from '@angular/core' +import { Component, EventEmitter, Type } from '@angular/core' import { ComponentFixture, getTestBed, @@ -19,24 +19,55 @@ import { BrowserDynamicTestingModule, platformBrowserDynamicTesting, } from '@angular/platform-browser-dynamic/testing' +import { + setupHooks, +} from '@cypress/mount-utils' /** * Additional module configurations needed while mounting the component, like * providers, declarations, imports and even component @Inputs() * * - * @interface TestBedConfig + * @interface MountConfig * @see https://angular.io/api/core/testing/TestModuleMetadata */ -export interface TestBedConfig extends TestModuleMetadata { +export interface MountConfig extends TestModuleMetadata { + /** + * @memberof MountConfig + * @description flag to automatically create a cy.spy() for every component @Output() property + * @example + * export class ButtonComponent { + * @Output clicked = new EventEmitter() + * } + * + * cy.mount(ButtonComponent, { autoSpyOutputs: true }) + * cy.get('@clickedSpy).should('have.been.called') + */ + autoSpyOutputs?: boolean + + /** + * @memberof MountConfig + * @description flag defaulted to true to automatically detect changes in your components + */ + autoDetectChanges?: boolean /** - * @memberof TestBedConfig + * @memberof MountConfig * @example * import { ButtonComponent } from 'button/button.component' * it('renders a button with Save text', () => { * cy.mount(ButtonComponent, { componentProperties: { text: 'Save' }}) * cy.get('button').contains('Save') * }) + * + * it('renders a button with a cy.spy() replacing EventEmitter', () => { + * cy.mount(ButtonComponent, { + * componentProperties: { + * clicked: cy.spy().as('mySpy) + * } + * }) + * cy.get('button').click() + * cy.get('@mySpy').should('have.been.called') + * }) */ componentProperties?: Partial<{ [P in keyof T]: T[P] }> } @@ -45,7 +76,7 @@ export interface TestBedConfig extends TestModuleMetadata { * Type that the `mount` function returns * @type MountResponse */ -export type MountResponse = { +export type MountResponse = { /** * Fixture for debugging and testing a component. * @@ -54,14 +85,6 @@ export type MountResponse = { */ fixture: ComponentFixture - /** - * Configures and initializes environment and provides methods for creating components and services. - * - * @memberof MountResponse - * @see https://angular.io/api/core/testing/TestBed - */ - testBed: TestBed - /** * The instance of the root component class * @@ -75,13 +98,13 @@ export type MountResponse = { * Bootstraps the TestModuleMetaData passed to the TestBed * * @param {Type} component Angular component being mounted - * @param {TestBedConfig} config TestBed configuration passed into the mount function - * @returns {TestBedConfig} TestBedConfig + * @param {MountConfig} config TestBed configuration passed into the mount function + * @returns {MountConfig} MountConfig */ -function bootstrapModule ( +function bootstrapModule ( component: Type, - config: TestBedConfig, -): TestBedConfig { + config: MountConfig, +): MountConfig { const { componentProperties, ...testModuleMetaData } = config if (!testModuleMetaData.declarations) { @@ -92,7 +115,12 @@ function bootstrapModule ( testModuleMetaData.imports = [] } - testModuleMetaData.declarations.push(component) + // check if the component is a standalone component + if ((component as any).ɵcmp.standalone) { + testModuleMetaData.imports.push(component) + } else { + testModuleMetaData.declarations.push(component) + } if (!testModuleMetaData.imports.includes(CommonModule)) { testModuleMetaData.imports.push(CommonModule) @@ -104,74 +132,84 @@ function bootstrapModule ( /** * Initializes the TestBed * - * @param {Type} component Angular component being mounted - * @param {TestBedConfig} config TestBed configuration passed into the mount function - * @returns {TestBed} TestBed + * @param {Type | string} component Angular component being mounted or its template + * @param {MountConfig} config TestBed configuration passed into the mount function + * @returns {Type} componentFixture */ -function initTestBed ( - component: Type, - config: TestBedConfig, -): TestBed { +function initTestBed ( + component: Type | string, + config: MountConfig, +): Type { const { providers, ...configRest } = config - const testBed: TestBed = getTestBed() + const componentFixture = createComponentFixture(component) as Type - testBed.resetTestEnvironment() - - testBed.initTestEnvironment( - BrowserDynamicTestingModule, - platformBrowserDynamicTesting(), - { - teardown: { destroyAfterEach: false }, - }, - ) - - testBed.configureTestingModule({ - ...bootstrapModule(component, configRest), + TestBed.configureTestingModule({ + ...bootstrapModule(componentFixture, configRest), }) if (providers != null) { - testBed.overrideComponent(component, { + TestBed.overrideComponent(componentFixture, { add: { providers, }, }) } - return testBed + return componentFixture +} + +@Component({ selector: 'cy-wrapper-component', template: '' }) +class WrapperComponent { } + +/** + * Returns the Component if Type or creates a WrapperComponent + * + * @param {Type | string} component The component you want to create a fixture of + * @returns {Type | WrapperComponent} + */ +function createComponentFixture ( + component: Type | string, +): Type { + if (typeof component === 'string') { + TestBed.overrideTemplate(WrapperComponent, component) + + return WrapperComponent + } + + return component } /** * Creates the ComponentFixture * - * @param component Angular component being mounted - * @param testBed TestBed - * @param autoDetectChanges boolean flag defaulted to true that turns on change detection automatically + * @param {Type} component Angular component being mounted + * @param {MountConfig} config MountConfig + * @returns {ComponentFixture} ComponentFixture */ -function setupFixture ( +function setupFixture ( component: Type, - testBed: TestBed, - autoDetectChanges: boolean, + config: MountConfig, ): ComponentFixture { - const fixture = testBed.createComponent(component) + const fixture = TestBed.createComponent(component) fixture.whenStable().then(() => { - fixture.autoDetectChanges(autoDetectChanges) + fixture.autoDetectChanges(config.autoDetectChanges ?? true) }) return fixture } /** - * Gets the componentInstance and Object.assigns any componentProperties() passed in the TestBedConfig + * Gets the componentInstance and Object.assigns any componentProperties() passed in the MountConfig * - * @param {TestBedConfig} config TestBed configuration passed into the mount function + * @param {MountConfig} config TestBed configuration passed into the mount function * @param {ComponentFixture} fixture Fixture for debugging and testing a component. * @returns {T} Component being mounted */ -function setupComponent ( - config: TestBedConfig, +function setupComponent ( + config: MountConfig, fixture: ComponentFixture, ): T { let component: T = fixture.componentInstance @@ -180,15 +218,24 @@ function setupComponent ( component = Object.assign(component, config.componentProperties) } + if (config.autoSpyOutputs) { + Object.keys(component).forEach((key: string, index: number, keys: string[]) => { + const property = component[key] + + if (property instanceof EventEmitter) { + component[key] = createOutputSpy(`${key}Spy`) + } + }) + } + return component } /** * Mounts an Angular component inside Cypress browser * - * @param {Type} component imported from angular file - * @param {TestBedConfig} config configuration used to configure the TestBed - * @param {boolean} autoDetectChanges boolean flag defaulted to true that turns on change detection automatically + * @param {Type | string} component Angular component being mounted or its template + * @param {MountConfig} config configuration used to configure the TestBed * @example * import { HelloWorldComponent } from 'hello-world/hello-world.component' * import { MyService } from 'services/my.service' @@ -201,28 +248,67 @@ function setupComponent ( * }) * cy.get('h1').contains('Hello World') * }) + * + * or + * + * it('can mount with template', () => { + * mount('', { + * declarations: [HelloWorldComponent], + * providers: [MyService], + * imports: [SharedModule] + * }) + * }) * @returns Cypress.Chainable> */ -export function mount ( - component: Type, - config: TestBedConfig = {}, - autoDetectChanges = true, +export function mount ( + component: Type | string, + config: MountConfig = { }, ): Cypress.Chainable> { - const testBed: TestBed = initTestBed(component, config) - const fixture = setupFixture(component, testBed, autoDetectChanges) + const componentFixture = initTestBed(component, config) + const fixture = setupFixture(componentFixture, config) const componentInstance = setupComponent(config, fixture) const mountResponse: MountResponse = { - testBed, fixture, component: componentInstance, } + const logMessage = typeof component === 'string' ? 'Component' : componentFixture.name + Cypress.log({ name: 'mount', - message: component.name, + message: logMessage, consoleProps: () => ({ result: mountResponse }), }) return cy.wrap(mountResponse, { log: false }) } + +/** + * Creates a new Event Emitter and then spies on it's `emit` method + * + * @param {string} alias name you want to use for your cy.spy() alias + * @returns EventEmitter + */ +export const createOutputSpy = (alias: string) => { + const emitter = new EventEmitter() + + cy.spy(emitter, 'emit').as(alias) + + return emitter as any +} + +// Only needs to run once, we reset before each test +getTestBed().initTestEnvironment( + BrowserDynamicTestingModule, + platformBrowserDynamicTesting(), + { + teardown: { destroyAfterEach: false }, + }, +) + +setupHooks(() => { + // Not public, we need to call this to remove the last component from the DOM + TestBed['tearDownTestingModule']() + TestBed.resetTestingModule() +}) diff --git a/npm/angular/tsconfig.json b/npm/angular/tsconfig.json index d09e57c203ce..b21ac64dacea 100644 --- a/npm/angular/tsconfig.json +++ b/npm/angular/tsconfig.json @@ -1,5 +1,6 @@ { "compilerOptions": { + "experimentalDecorators": true, "target": "es2020", "module": "es2020", "skipLibCheck": true, diff --git a/npm/webpack-dev-server/cypress/e2e/angular.cy.ts b/npm/webpack-dev-server/cypress/e2e/angular.cy.ts index 7731aba900c4..312ac07c8161 100644 --- a/npm/webpack-dev-server/cypress/e2e/angular.cy.ts +++ b/npm/webpack-dev-server/cypress/e2e/angular.cy.ts @@ -69,13 +69,5 @@ for (const project of WEBPACK_REACT) { cy.waitForSpecToFinish() cy.get('.passed > .num').should('contain', 1) }) - - it('proves out mount API', () => { - cy.visitApp() - - cy.contains('mount.cy.ts').click() - cy.waitForSpecToFinish() - cy.get('.passed > .num').should('contain', 6) - }) }) } diff --git a/system-tests/project-fixtures/angular/src/app/components/button-output.component.ts b/system-tests/project-fixtures/angular/src/app/components/button-output.component.ts index e8228a5e7528..891dc8761a92 100644 --- a/system-tests/project-fixtures/angular/src/app/components/button-output.component.ts +++ b/system-tests/project-fixtures/angular/src/app/components/button-output.component.ts @@ -1,6 +1,7 @@ import { Component, EventEmitter, Output } from "@angular/core"; @Component({ + selector: 'app-button-output', template: `` }) export class ButtonOutputComponent { diff --git a/system-tests/project-fixtures/angular/src/app/components/projection.component.ts b/system-tests/project-fixtures/angular/src/app/components/projection.component.ts new file mode 100644 index 000000000000..69956687d92b --- /dev/null +++ b/system-tests/project-fixtures/angular/src/app/components/projection.component.ts @@ -0,0 +1,7 @@ +import { Component } from '@angular/core' + +@Component({ + selector: 'app-projection', + template: `

` +}) +export class ProjectionComponent {} \ No newline at end of file diff --git a/system-tests/project-fixtures/angular/src/app/mount.cy.ts b/system-tests/project-fixtures/angular/src/app/mount.cy.ts index f13c8e2ef981..57ecc3c41c3e 100644 --- a/system-tests/project-fixtures/angular/src/app/mount.cy.ts +++ b/system-tests/project-fixtures/angular/src/app/mount.cy.ts @@ -5,6 +5,14 @@ import { CounterService } from "./components/counter.service"; import { ChildComponent } from "./components/child.component"; import { WithDirectivesComponent } from "./components/with-directives.component"; import { ButtonOutputComponent } from "./components/button-output.component"; +import { createOutputSpy } from 'cypress/angular'; +import { EventEmitter, Component } from '@angular/core'; +import { ProjectionComponent } from "./components/projection.component"; + +@Component({ + template: `Hello World` +}) +class WrapperComponent {} describe("angular mount", () => { it("pushes CommonModule into component", () => { @@ -44,6 +52,31 @@ describe("angular mount", () => { }); }); + it('can bind the spy to the componentProperties bypassing types', () => { + cy.mount(ButtonOutputComponent, { + componentProperties: { + clicked: { + emit: cy.spy().as('onClickedSpy') + } as any + } + }) + cy.get('button').click() + cy.get('@onClickedSpy').should('have.been.calledWith', true) + }) + + it('can bind the spy to the componentProperties bypassing types using template', () => { + cy.mount('', { + declarations: [ButtonOutputComponent], + componentProperties: { + clicked: { + emit: cy.spy().as('onClickedSpy') + } as any + } + }) + cy.get('button').click() + cy.get('@onClickedSpy').should('have.been.calledWith', true) + }) + it('can spy on EventEmitters', () => { cy.mount(ButtonOutputComponent).then(({ component }) => { cy.spy(component.clicked, 'emit').as('mySpy') @@ -51,4 +84,94 @@ describe("angular mount", () => { cy.get('@mySpy').should('have.been.calledWith', true) }) }) + + it('can use a template string instead of Type for component', () => { + cy.mount('', { + declarations: [ButtonOutputComponent], + componentProperties: { + login: cy.spy().as('myClickedSpy') + } + }) + cy.get('button').click() + cy.get('@myClickedSpy').should('have.been.calledWith', true) + }) + + it('can spy on EventEmitter for mount using template', () => { + cy.mount('', { + declarations: [ButtonOutputComponent], + componentProperties: { + handleClick: new EventEmitter() + } + }).then(({ component }) => { + cy.spy(component.handleClick, 'emit').as('handleClickSpy') + cy.get('button').click() + cy.get('@handleClickSpy').should('have.been.calledWith', true) + }) + }) + + it('can accept a createOutputSpy for an Output property', () => { + cy.mount(ButtonOutputComponent, { + componentProperties: { + clicked: createOutputSpy('mySpy') + } + }) + cy.get('button').click(); + cy.get('@mySpy').should('have.been.calledWith', true) + }) + + it('can accept a createOutputSpy for an Output property with a template', () => { + cy.mount('', { + declarations: [ButtonOutputComponent], + componentProperties: { + clicked: createOutputSpy('mySpy') + } + }) + cy.get('button').click() + cy.get('@mySpy').should('have.been.called') + }) + + it('can reference the autoSpyOutput alias on component @Outputs()', () => { + cy.mount(ButtonOutputComponent, { + autoSpyOutputs: true, + }) + cy.get('button').click() + cy.get('@clickedSpy').should('have.been.calledWith', true) + }) + + + it('can reference the autoSpyOutput alias on component @Outputs() with a template', () => { + cy.mount('', { + declarations: [ButtonOutputComponent], + autoSpyOutputs: true, + componentProperties: { + clicked: new EventEmitter() + } + }) + cy.get('button').click() + cy.get('@clickedSpy').should('have.been.calledWith', true) + }) + + it('can handle content projection with a WrapperComponent', () => { + cy.mount(WrapperComponent, { + declarations: [ProjectionComponent] + }) + cy.get('h3').contains('Hello World') + }) + + it('can handle content projection using template', () => { + cy.mount('Hello World', { + declarations: [ProjectionComponent] + }) + cy.get('h3').contains('Hello World') + }) + + describe("teardown", () => { + beforeEach(() => { + cy.get("[id^=root]").should("not.exist"); + }); + + it("should mount", () => { + cy.mount(ButtonOutputComponent); + }); + }); }); diff --git a/system-tests/projects/angular-14/src/app/components/standalone.component.cy.ts b/system-tests/projects/angular-14/src/app/components/standalone.component.cy.ts new file mode 100644 index 000000000000..74bf96cd41e6 --- /dev/null +++ b/system-tests/projects/angular-14/src/app/components/standalone.component.cy.ts @@ -0,0 +1,21 @@ +import { StandaloneComponent } from './standalone.component' + +describe('StandaloneComponent', () => { + it('can mount a standalone component', () => { + cy.mount(StandaloneComponent, { + componentProperties: { + name: 'Angular', + }, + }) + + cy.get('h1').contains('Hello Angular') + }) + + it('can mount a standalone component using template', () => { + cy.mount('', { + imports: [StandaloneComponent], + }) + + cy.get('h1').contains('Hello Angular') + }) +}) diff --git a/system-tests/projects/angular-14/src/app/components/standalone.component.ts b/system-tests/projects/angular-14/src/app/components/standalone.component.ts new file mode 100644 index 000000000000..bf2355b493da --- /dev/null +++ b/system-tests/projects/angular-14/src/app/components/standalone.component.ts @@ -0,0 +1,10 @@ +import { Component, Input } from '@angular/core' + +@Component({ + standalone: true, + selector: 'app-standalone', + template: `

Hello {{ name }}

`, +}) +export class StandaloneComponent { + @Input() name!: string +} diff --git a/system-tests/test/component_testing_spec.ts b/system-tests/test/component_testing_spec.ts index 2ca13e360723..f66340f65e6f 100644 --- a/system-tests/test/component_testing_spec.ts +++ b/system-tests/test/component_testing_spec.ts @@ -106,3 +106,21 @@ describe(`React major versions with Webpack`, function () { }) } }) + +const ANGULAR_MAJOR_VERSIONS = ['13', '14'] + +describe(`Angular CLI major versions`, () => { + systemTests.setup() + + for (const majorVersion of ANGULAR_MAJOR_VERSIONS) { + const spec = `${majorVersion === '14' ? 'src/app/components/standalone.component.cy.ts,src/app/mount.cy.ts' : 'src/app/mount.cy.ts'}` + + systemTests.it(`v${majorVersion} with mount tests`, { + project: `angular-${majorVersion}`, + spec, + testingType: 'component', + browser: 'chrome', + expectedExitCode: 0, + }) + } +})