diff --git a/.circleci/cache-version.txt b/.circleci/cache-version.txt index ca1773b1afc..80c8cb28adc 100644 --- a/.circleci/cache-version.txt +++ b/.circleci/cache-version.txt @@ -1,2 +1,2 @@ # Bump this version to force CI to re-create the cache from scratch. -11-25-2025 +12-02-2025 diff --git a/.circleci/src/pipeline/@pipeline.yml b/.circleci/src/pipeline/@pipeline.yml index bb08ae754dc..c19484df9d1 100644 --- a/.circleci/src/pipeline/@pipeline.yml +++ b/.circleci/src/pipeline/@pipeline.yml @@ -121,7 +121,7 @@ commands: name: Set environment variable to determine whether or not to persist artifacts command: | echo "Setting SHOULD_PERSIST_ARTIFACTS variable" - echo 'if ! [[ "$CIRCLE_BRANCH" != "develop" && "$CIRCLE_BRANCH" != "release/"* && "$CIRCLE_BRANCH" != "feat/support_angular_21" ]]; then + echo 'if ! [[ "$CIRCLE_BRANCH" != "develop" && "$CIRCLE_BRANCH" != "release/"* && "$CIRCLE_BRANCH" != "feat/add_zoneless_angular_harness" ]]; then export SHOULD_PERSIST_ARTIFACTS=true fi' >> "$BASH_ENV" # You must run `setup_should_persist_artifacts` command and be using bash before running this command @@ -2301,6 +2301,14 @@ jobs: name: Build command: yarn lerna run build --scope=@cypress/angular + npm-angular-zoneless: + <<: *defaults + steps: + - restore_cached_workspace + - run: + name: Build + command: yarn lerna run build --scope @cypress/angular-zoneless + npm-puppeteer-unit-tests: <<: *defaults steps: diff --git a/.circleci/src/pipeline/workflows/@main.yml b/.circleci/src/pipeline/workflows/@main.yml index 54b1bfbc5d0..8587b00e919 100644 --- a/.circleci/src/pipeline/workflows/@main.yml +++ b/.circleci/src/pipeline/workflows/@main.yml @@ -4,7 +4,7 @@ linux-x64: - equal: [ develop, << pipeline.git.branch >> ] # use the following branch as well to ensure that v8 snapshot cache updates are fully tested - equal: [ 'update-v8-snapshot-cache-on-develop', << pipeline.git.branch >> ] - - equal: [ 'feat/support_angular_21', << pipeline.git.branch >> ] + - equal: [ 'feat/add_zoneless_angular_harness', << pipeline.git.branch >> ] - matches: pattern: /^release\/\d+\.\d+\.\d+$/ value: << pipeline.git.branch >> @@ -236,6 +236,9 @@ linux-x64: - npm-angular: requires: - build + - npm-angular-zoneless: + requires: + - build - npm-mount-utils: requires: - build @@ -258,6 +261,7 @@ linux-x64: - check-ts - health-check - npm-angular + - npm-angular-zoneless - npm-eslint-plugin-dev - npm-puppeteer-unit-tests - npm-puppeteer-cypress-tests diff --git a/.circleci/src/pipeline/workflows/pull-request.yml b/.circleci/src/pipeline/workflows/pull-request.yml index 62b1417cb0b..1d18cd2b36b 100644 --- a/.circleci/src/pipeline/workflows/pull-request.yml +++ b/.circleci/src/pipeline/workflows/pull-request.yml @@ -290,6 +290,11 @@ jobs: - internal-pr-build - external-pr-build - approve-contributor-pr + - npm-angular-zoneless: + requires: + - internal-pr-build + - external-pr-build + - approve-contributor-pr - npm-mount-utils: requires: - internal-pr-build diff --git a/CHANGELOG.md b/CHANGELOG.md index ccf4888e233..00e2c849ae1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ - [Cypress App](https://on.cypress.io/changelog) - [`@cypress/angular`](https://github.com/cypress-io/cypress/blob/develop/npm/angular/CHANGELOG.md) +- [`@cypress/angular-zoneless`](https://github.com/cypress-io/cypress/blob/develop/npm/angular-zoneless/CHANGELOG.md) - [`@cypress/eslint-plugin-dev`](https://github.com/cypress-io/cypress/blob/develop/npm/eslint-plugin-dev/CHANGELOG.md) - [`@cypress/mount-utils`](https://github.com/cypress-io/cypress/blob/develop/npm/mount-utils/CHANGELOG.md) - [`@cypress/react`](https://github.com/cypress-io/cypress/blob/develop/npm/react/CHANGELOG.md) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 10bbd5c8289..4c2bc6538fe 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -215,6 +215,7 @@ Here is a list of the npm packages in this repository: | Folder Name | Package Name | Purpose | | :----------------------------------------------------- | :--------------------------------- | :--------------------------------------------------------------------------- | | [angular](./npm/angular) | `@cypress/angular` | Cypress component testing for Angular. | + | [angular-zoneless](./npm/angular-zoneless) | `@cypress/angular-zoneless` | Cypress component testing for Angular using zoneless change detection. | | [eslint-plugin-dev](./npm/eslint-plugin-dev) | `@cypress/eslint-plugin-dev` | Eslint plugin for internal development. | | [grep](./npm/grep) | `@cypress/grep` | Filter tests using substring | | [mount-utils](./npm/mount-utils) | `@cypress/mount-utils` | Common functionality for Vue/React/Angular adapters. | diff --git a/cli/.gitignore b/cli/.gitignore index fa52bfb6d3b..230ae8d6556 100644 --- a/cli/.gitignore +++ b/cli/.gitignore @@ -19,6 +19,7 @@ vue react* mount-utils angular +angular-zoneless svelte index.js lib/**/*.js diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index e2e7925cdfc..fe5030ed4d4 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -6,6 +6,7 @@ _Released 12/16/2025 (PENDING)_ **Features:** - `Angular` version 21 is now supported within component testing. Addressed in [#33004](https://github.com/cypress-io/cypress/pull/33004). +- Adds zoneless support for `Angular` Component Testing through the `angular-zoneless` mount function. Addresses [#31504](https://github.com/cypress-io/cypress/issues/31504) and [#30070](https://github.com/cypress-io/cypress/issues/30070). ## 15.7.1 diff --git a/cli/eslint.config.ts b/cli/eslint.config.ts index 3ebd9ebd4c3..f17e713cf26 100644 --- a/cli/eslint.config.ts +++ b/cli/eslint.config.ts @@ -36,6 +36,7 @@ export default [ '**/build/**/*', 'package.json', '**/angular/**/*', + '**/angular-zoneless/**/*', '**/react/**/*', '**/vue/**/*', '**/svelte/**/*', diff --git a/cli/package.json b/cli/package.json index 385458c610f..4e5bcdb6f71 100644 --- a/cli/package.json +++ b/cli/package.json @@ -62,6 +62,7 @@ }, "devDependencies": { "@cypress/angular": "0.0.0-development", + "@cypress/angular-zoneless": "0.0.0-development", "@cypress/grep": "0.0.0-development", "@cypress/mount-utils": "0.0.0-development", "@cypress/react": "0.0.0-development", @@ -102,6 +103,7 @@ "vue", "react", "angular", + "angular-zoneless", "svelte" ], "bin": { @@ -143,6 +145,11 @@ "import": "./angular/dist/index.js", "require": "./angular/dist/index.js" }, + "./angular-zoneless": { + "types": "./angular-zoneless/dist/index.d.ts", + "import": "./angular-zoneless/dist/index.js", + "require": "./angular-zoneless/dist/index.js" + }, "./svelte": { "types": "./svelte/dist/index.d.ts", "import": "./svelte/dist/cypress-svelte.esm-bundler.js", diff --git a/cli/scripts/post-build.ts b/cli/scripts/post-build.ts index 07acd143c7e..12b5bc0c4fd 100644 --- a/cli/scripts/post-build.ts +++ b/cli/scripts/post-build.ts @@ -11,6 +11,7 @@ const npmModulesToCopy: string[] = [ 'react', 'vue', 'angular', + 'angular-zoneless', 'svelte', ] diff --git a/npm/angular-zoneless/.eslintignore b/npm/angular-zoneless/.eslintignore new file mode 100644 index 00000000000..79afe972da7 --- /dev/null +++ b/npm/angular-zoneless/.eslintignore @@ -0,0 +1,5 @@ +**/dist +**/*.d.ts +**/package-lock.json +**/tsconfig.json +**/cypress/fixtures \ No newline at end of file diff --git a/npm/angular-zoneless/.eslintrc b/npm/angular-zoneless/.eslintrc new file mode 100644 index 00000000000..f044f320923 --- /dev/null +++ b/npm/angular-zoneless/.eslintrc @@ -0,0 +1,8 @@ +{ + "plugins": [ + "cypress" + ], + "extends": [ + "plugin:@cypress/dev/tests" + ] +} diff --git a/npm/angular-zoneless/.npmignore b/npm/angular-zoneless/.npmignore new file mode 100644 index 00000000000..d4372c984ed --- /dev/null +++ b/npm/angular-zoneless/.npmignore @@ -0,0 +1,3 @@ +examples +src +cypress \ No newline at end of file diff --git a/npm/angular-zoneless/.releaserc.js b/npm/angular-zoneless/.releaserc.js new file mode 100644 index 00000000000..17d3bb87147 --- /dev/null +++ b/npm/angular-zoneless/.releaserc.js @@ -0,0 +1,3 @@ +module.exports = { + ...require('../../.releaserc'), +} diff --git a/npm/angular-zoneless/CHANGELOG.md b/npm/angular-zoneless/CHANGELOG.md new file mode 100644 index 00000000000..e69de29bb2d diff --git a/npm/angular-zoneless/README.md b/npm/angular-zoneless/README.md new file mode 100644 index 00000000000..733a6e77908 --- /dev/null +++ b/npm/angular-zoneless/README.md @@ -0,0 +1,15 @@ +# @cypress/angular-zoneless + +Mount Angular components in the open source [Cypress.io](https://www.cypress.io/) test runner without leveraging zone.js for change detection + +> **Note:** This package is bundled with the `cypress` package and should not need to be installed separately. See the [Angular Component Testing Docs](https://docs.cypress.io/guides/component-testing/angular/overview) for mounting Angular components. Installing and importing `mount` from `@cypress/angular-zoneless` should only be done for advanced use-cases. + +## Requirements + +- Angular 21.0.0 or Angular 20.0.0 with development preview of zoneless change detection + +## Development + +Run `yarn build` to compile and sync packages to the `cypress` cli package. + +## [Changelog](./CHANGELOG.md) diff --git a/npm/angular-zoneless/package.json b/npm/angular-zoneless/package.json new file mode 100644 index 00000000000..a2c650ef091 --- /dev/null +++ b/npm/angular-zoneless/package.json @@ -0,0 +1,74 @@ +{ + "name": "@cypress/angular-zoneless", + "version": "0.0.0-development", + "description": "Test Angular Components with Cypress without zone.js", + "main": "dist/index.js", + "scripts": { + "prebuild": "rimraf dist", + "build": "rollup -c rollup.config.mjs", + "postbuild": "node ../../scripts/sync-exported-npm-with-cli.js", + "check-ts": "tsc --noEmit", + "dev": "rollup -c rollup.config.mjs -w", + "lint": "eslint --ext .js,.ts,.json, ." + }, + "dependencies": {}, + "devDependencies": { + "@angular/common": "^21.0.0", + "@angular/core": "^21.0.0", + "@angular/platform-browser-dynamic": "^21.0.0", + "@cypress/mount-utils": "0.0.0-development", + "rollup": "^4.24.4", + "typescript": "~5.9.2" + }, + "peerDependencies": { + "@angular/common": ">=20.0.0", + "@angular/core": ">=20.0.0", + "@angular/platform-browser-dynamic": ">=20.0.0", + "rxjs": ">=7.8.0" + }, + "files": [ + "dist" + ], + "types": "dist/index.d.ts", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/cypress-io/cypress.git" + }, + "homepage": "https://github.com/cypress-io/cypress/blob/develop/npm/angular-zoneless/#readme", + "bugs": "https://github.com/cypress-io/cypress/issues/new?assignees=&labels=npm%3A%20%40cypress%2Fangular-zoneless&template=1-bug-report.md&title=", + "keywords": [ + "angular", + "cypress", + "cypress-io", + "test", + "testing", + "zoneless" + ], + "contributors": [ + { + "name": "Bill Glesias", + "social": "@atofstryker" + } + ], + "module": "dist/index.js", + "publishConfig": { + "access": "public" + }, + "nx": { + "targets": { + "build": { + "outputs": [ + "{workspaceRoot}/cli/angular-zoneless" + ] + } + } + }, + "standard": { + "globals": [ + "Cypress", + "cy", + "expect" + ] + } +} diff --git a/npm/angular-zoneless/rollup.config.mjs b/npm/angular-zoneless/rollup.config.mjs new file mode 100644 index 00000000000..9042fd6d8ad --- /dev/null +++ b/npm/angular-zoneless/rollup.config.mjs @@ -0,0 +1,13 @@ +import { createEntries } from '@cypress/mount-utils/create-rollup-entry.mjs' + +const config = { + external: [ + '@angular/core', + '@angular/core/testing', + '@angular/common', + '@angular/platform-browser-dynamic/testing', + '@angular/core/rxjs-interop', + ], +} + +export default createEntries({ formats: ['es'], input: 'src/index.ts', config }) diff --git a/npm/angular-zoneless/src/index.ts b/npm/angular-zoneless/src/index.ts new file mode 100644 index 00000000000..1af962e1d53 --- /dev/null +++ b/npm/angular-zoneless/src/index.ts @@ -0,0 +1 @@ +export * from './mount' diff --git a/npm/angular-zoneless/src/mount.ts b/npm/angular-zoneless/src/mount.ts new file mode 100644 index 00000000000..8d532b75afb --- /dev/null +++ b/npm/angular-zoneless/src/mount.ts @@ -0,0 +1,564 @@ +import { CommonModule } from '@angular/common' +import { Component, ErrorHandler, EventEmitter, Injectable, SimpleChange, SimpleChanges, Type, OnChanges, Injector, InputSignal, WritableSignal, provideZonelessChangeDetection } from '@angular/core' +import { toObservable } from '@angular/core/rxjs-interop' +import { + ComponentFixture, + getTestBed, + TestModuleMetadata, + TestBed, + TestComponentRenderer, +} from '@angular/core/testing' +// NOTE: @angular/platform-browser-dynamic is deprecated and needs to be updated to @angular/platform-browser in a breaking change of Cypress. +// @see https://github.com/cypress-io/cypress/issues/33006 +import { + BrowserDynamicTestingModule, + platformBrowserDynamicTesting, +} from '@angular/platform-browser-dynamic/testing' +import { + setupHooks, + getContainerEl, +} from '@cypress/mount-utils' +import type { Subscription } from 'rxjs' + +/** + * Additional module configurations needed while mounting the component, like + * providers, declarations, imports and even component @Inputs() + * + * @interface MountConfig + * @see https://angular.io/api/core/testing/TestModuleMetadata + */ +export interface MountConfig extends TestModuleMetadata { + /** + * @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') + * }) + */ + // allow InputSignals to be type primitive and WritableSignal for type compliance + componentProperties?: Partial<{ [P in keyof T]: T[P] extends InputSignal ? InputSignal | WritableSignal | V : T[P]}> +} + +let activeFixture: ComponentFixture | null = null +let activeInternalSubscriptions: Subscription[] = [] + +function cleanup () { + // Not public, we need to call this to remove the last component from the DOM + try { + (getTestBed() as any).tearDownTestingModule() + } catch (e) { + const notSupportedError = new Error(`Failed to teardown component. The version of Angular you are using may not be officially supported.`) + + ;(notSupportedError as any).docsUrl = 'https://on.cypress.io/frameworks' + throw notSupportedError + } + + // clean up internal subscriptions if any exist. We use this for two-way data binding for + // signal() models + activeInternalSubscriptions.forEach((subscription) => { + subscription.unsubscribe() + }) + + getTestBed().resetTestingModule() + activeFixture = null + activeInternalSubscriptions = [] +} + +/** + * Type that the `mount` function returns + * @type MountResponse + */ +export type MountResponse = { + /** + * Fixture for debugging and testing a component. + * + * @memberof MountResponse + * @see https://angular.io/api/core/testing/ComponentFixture + */ + fixture: ComponentFixture + + /** + * The instance of the root component class + * + * @memberof MountResponse + * @see https://angular.io/api/core/testing/ComponentFixture#componentInstance + */ + component: T +}; + +@Injectable() +class CypressAngularErrorHandler implements ErrorHandler { + handleError (error: Error): void { + throw error + } +} + +/** + * Bootstraps the TestModuleMetaData passed to the TestBed + * + * @param {Type} component Angular component being mounted + * @param {MountConfig} config TestBed configuration passed into the mount function + * @returns {MountConfig} MountConfig + */ +function bootstrapModule ( + component: Type, + config: MountConfig, +): MountConfig { + const { componentProperties, ...testModuleMetaData } = config + + if (!testModuleMetaData.declarations) { + testModuleMetaData.declarations = [] + } + + if (!testModuleMetaData.imports) { + testModuleMetaData.imports = [] + } + + if (!testModuleMetaData.providers) { + testModuleMetaData.providers = [] + } + + // Replace default error handler since it will swallow uncaught exceptions. + // We want these to be uncaught so Cypress catches it and fails the test + testModuleMetaData.providers.push({ + provide: ErrorHandler, + useClass: CypressAngularErrorHandler, + }) + + // allow for zoneless change detection inside the testing module. + // @see https://angular.dev/guide/zoneless#using-zoneless-in-testbed + testModuleMetaData.providers.push(provideZonelessChangeDetection()) + + // 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) + } + + return testModuleMetaData +} + +@Injectable() +export class CypressTestComponentRenderer extends TestComponentRenderer { + override insertRootElement (rootElId: string) { + this.removeAllRootElements() + + const rootElement = getContainerEl() + + rootElement.setAttribute('id', rootElId) + } + + override removeAllRootElements () { + getContainerEl().innerHTML = '' + } +} + +/** + * Initializes the 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 | string, + config: MountConfig, +): Type { + const componentFixture = createComponentFixture(component) as Type + + getTestBed().configureTestingModule({ + ...bootstrapModule(componentFixture, config), + }) + + getTestBed().overrideProvider(TestComponentRenderer, { useValue: new CypressTestComponentRenderer() }) + + return componentFixture +} + +// if using the Wrapper Component (template strings), the component itself cannot be +// a standalone component +@Component({ selector: 'cy-wrapper-component', template: '', standalone: false }) +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') { + // getTestBed().overrideTemplate is available in v14+ + // The static TestBed.overrideTemplate is available across versions + TestBed.overrideTemplate(WrapperComponent, component) + + return WrapperComponent + } + + return component +} + +/** + * Creates the ComponentFixture + * + * @param {Type} component Angular component being mounted + * @param {MountConfig} config MountConfig + + * @returns {Promise>} ComponentFixture + */ +function setupFixture ( + component: Type, + config: MountConfig, +): ComponentFixture { + const fixture = getTestBed().createComponent(component) + + setupComponent(config, fixture) + + return fixture +} + +// Best known way to currently detect whether or not a function is a signal is if the signal symbol exists. +// From there, we can take our best guess based on what exists on the object itself. +// @see https://github.com/cypress-io/cypress/issues/29731. +function isSignal (prop: any): boolean { + try { + const symbol = Object.getOwnPropertySymbols(prop).find((symbol) => symbol.toString() === 'Symbol(SIGNAL)') + + return !!symbol + } catch (e) { + // likely a primitive type, object, array, or something else (i.e. not a signal). + // We can return false here. + return false + } +} + +// currently not a great way to detect if a function is an InputSignal. +// @see https://github.com/cypress-io/cypress/issues/29731. +function isInputSignal (prop: any): boolean { + return isSignal(prop) && typeof prop === 'function' && prop['name'] === 'inputValueFn' +} + +// currently not a great way to detect if a function is a Model Signal. +// @see https://github.com/cypress-io/cypress/issues/29731. +function isModelSignal (prop: any): boolean { + return isSignal(prop) && isWritableSignal(prop) && typeof prop.subscribe === 'function' +} + +// currently not a great way to detect if a function is a Writable Signal. +// @see https://github.com/cypress-io/cypress/issues/29731. +function isWritableSignal (prop: any): boolean { + return isSignal(prop) && typeof prop === 'function' && typeof prop.set === 'function' +} + +function registerSignalEventsIfNeeded ( + propKey: string, + propValue: any, + componentValue: any, + injector: Injector, + fixture: ComponentFixture, +) { + const isPropValueASignal = isSignal(propValue) + + if (isPropValueASignal) { + // propValue -> componentValue + const convertedToObservable = toObservable(propValue, { + // @ts-expect-error - monorepo clashing types between Angular 18 and Angular 21 + injector, + }) + + // push the subscription into an array to be cleaned up at the end of the test + // to prevent a memory leak + activeInternalSubscriptions.push( + convertedToObservable.subscribe((value) => { + // keep the component up to date as prop signal changes + fixture.componentRef.setInput(propKey, value) + }), + ) + } + + const isComponentValueAModelSignal = isModelSignal(componentValue) + + if (isPropValueASignal && isComponentValueAModelSignal) { + // propValue <- componentValue + const modelChanged$ = toObservable(componentValue, { + // @ts-expect-error - monorepo clashing types between Angular 18 and Angular 21 + injector, + }) + + activeInternalSubscriptions.push( + modelChanged$.subscribe((value) => { + propValue.set(value) + }), + ) + } +} + +// In the case of signals, if we need to create an output spy, we need to check first whether or not a user has one defined first or has it created through +// autoSpyOutputs. If so, we need to subscribe to the writable signal to push updates into the event emitter. We do NOT observe input signals and output spies will not +// work for input signals. +function detectAndRegisterOutputSpyToSignal (config: MountConfig, component: { [key: string]: any } & Partial, key: string, injector: Injector): void { + if (config.componentProperties) { + const expectedChangeKey = `${key}Change` + let changeKeyIfExists = !!Object.keys(config.componentProperties).find( + (componentKey) => componentKey === expectedChangeKey, + ) + + if (changeKeyIfExists) { + component[expectedChangeKey] = + // @ts-expect-error + config.componentProperties[expectedChangeKey] + } + + if (changeKeyIfExists) { + const componentValue = component[key] + + // if the user passed in a change key or we created one due to config.autoSpyOutputs being set to true for a given signal, + // we will create a subscriber that will emit an event every time the value inside the signal changes. We only do this + // if the signal is writable and not an input signal. + if (isWritableSignal(componentValue) && !isInputSignal(componentValue)) { + activeInternalSubscriptions.push( + toObservable(componentValue, { + // @ts-expect-error - monorepo clashing types between Angular 18 and Angular 21 + injector, + }).subscribe((value) => { + component[expectedChangeKey]?.emit(value) + }), + ) + } + } + } +} + +/** + * Gets the componentInstance and Object.assigns any componentProperties() passed in the MountConfig + * + * @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: MountConfig, + fixture: ComponentFixture, +): void { + let component = fixture.componentInstance as unknown as { [key: string]: any } & Partial + const injector = fixture.componentRef.injector + + if (config?.componentProperties) { + if (component instanceof WrapperComponent) { + component = Object.assign(component, config.componentProperties) + } + + getComponentInputs(fixture.componentRef.componentType).forEach((key) => { + // only assign props if they are passed into the component + if (config?.componentProperties?.hasOwnProperty(key)) { + // @ts-expect-error + const passedInValue = config?.componentProperties[key] + + registerSignalEventsIfNeeded( + key, + passedInValue, + component[key], + injector, + fixture, + ) + + detectAndRegisterOutputSpyToSignal(config, component, key, injector) + + fixture.componentRef.setInput( + key, + isSignal(passedInValue) ? passedInValue() : passedInValue, + ) + } + }) + + getComponentOutputs(fixture.componentRef.componentType).forEach((key) => { + const property = component[key] + + // With the introduction of https://github.com/cypress-io/cypress/pull/31993, we want to make sure that component inputs are reference safe inside cy.mount(). + // However, the exception to this is if the user passes in a Cypress output spy as a property in order to maintain backwards compatibility. + // @ts-expect-error + if (property instanceof EventEmitter || (config?.componentProperties?.hasOwnProperty(key) && config?.componentProperties[key] instanceof EventEmitter)) { + // only assign props if they are passed into the component + if (config?.componentProperties?.hasOwnProperty(key)) { + // @ts-expect-error + const passedInValue = config?.componentProperties[key] + + component[key] = passedInValue + } + } + }) + } + + // Manually call ngOnChanges when mounting components using the class syntax. + // This is necessary because we are assigning input values to the class directly + // on mount and therefore the ngOnChanges() lifecycle is not triggered. + if (component.ngOnChanges && config.componentProperties) { + const { componentProperties } = config + + const simpleChanges: SimpleChanges = Object.entries(componentProperties).reduce((acc, [key, value]) => { + acc[key] = new SimpleChange(null, value, true) + + return acc + }, {} as {[key: string]: SimpleChange}) + + if (Object.keys(componentProperties).length > 0) { + component.ngOnChanges(simpleChanges) + } + } +} + +/** + * Gets the input properties of a component - cannot rely on Object.keys() because inclusion of optional properties depends on useDefineForClassFields=true + * Since Angular 15, useDefineForClassFields=false + * @param componentType + * @returns array of input property names + */ +function getComponentInputs (componentType: Type): string[] { + // Access Angular's metadata to get input properties + const propMetadata = (componentType as any).ɵcmp?.inputs || {} + + return Object.keys(propMetadata) +} + +function getComponentOutputs (componentType: Type): string[] { + // Access Angular's metadata to get output properties + const propMetadata = (componentType as any).ɵcmp?.outputs || {} + + return Object.keys(propMetadata) +} + +/** + * Mounts an Angular component inside Cypress browser + * + * @param component Angular component being mounted or its template + * @param config configuration used to configure the TestBed + * @example + * import { mount } from '@cypress/angular' + * import { StepperComponent } from './stepper.component' + * import { MyService } from 'services/my.service' + * import { SharedModule } from 'shared/shared.module'; + * it('mounts', () => { + * mount(StepperComponent, { + * providers: [MyService], + * imports: [SharedModule] + * }) + * cy.get('[data-cy=increment]').click() + * cy.get('[data-cy=counter]').should('have.text', '1') + * }) + * + * // or + * + * it('mounts with template', () => { + * mount('', { + * declarations: [StepperComponent], + * }) + * }) + * + * @see {@link https://on.cypress.io/mounting-angular} for more details. + * + * @returns A component and component fixture + */ +export function mount ( + component: Type | string, + config: MountConfig = { }, +): Cypress.Chainable> { + // Remove last mounted component if cy.mount is called more than once in a test + if (activeFixture) { + cleanup() + } + + const componentFixture = initTestBed(component, config) + + let mountResponsePromiseResolver: any + let mountResponsePromiseRejector: any + let mountResponsePromise: Promise> = new Promise((resolve, reject) => { + mountResponsePromiseResolver = resolve + mountResponsePromiseRejector = reject + }) + + const fixture = setupFixture(componentFixture, config) + + activeFixture = fixture + fixture.whenStable().then(() => { + const mountResponse: MountResponse = { + fixture, + component: fixture.componentInstance, + } + + const logMessage = typeof component === 'string' ? 'Component' : componentFixture.name + + Cypress.log({ + name: 'mount', + message: logMessage, + consoleProps: () => ({ result: mountResponse }), + }) + + mountResponsePromiseResolver(mountResponse) + }).catch((error) => { + mountResponsePromiseRejector(error) + }) + + return cy.wrap(mountResponsePromise, { 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 + * @example + * import { StepperComponent } from './stepper.component' + * import { mount, createOutputSpy } from '@cypress/angular' + * + * it('Has spy', () => { + * mount(StepperComponent, { componentProperties: { change: createOutputSpy('changeSpy') } }) + * cy.get('[data-cy=increment]').click() + * cy.get('@changeSpy').should('have.been.called') + * }) + * + * // Or for use with Angular Signals following the output nomenclature. + * // see https://v17.angular.io/guide/model-inputs#differences-between-model-and-input/ + * + * it('Has spy', () => { + * mount(StepperComponent, { componentProperties: { count: signal(0), countChange: createOutputSpy('countChange') } }) + * cy.get('[data-cy=increment]').click() + * cy.get('@countChange').should('have.been.called') + * }) + */ +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(cleanup) diff --git a/npm/angular-zoneless/tsconfig.json b/npm/angular-zoneless/tsconfig.json new file mode 100644 index 00000000000..b983f87dd86 --- /dev/null +++ b/npm/angular-zoneless/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "experimentalDecorators": true, + "target": "ES2022", + "module": "ES2022", + "skipLibCheck": true, + "lib": [ + "ESNext", + "DOM" + ], + "allowJs": true, + "declaration": true, + "outDir": "dist", + "strict": true, + "baseUrl": "./", + "types": [ + "cypress" + ], + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "moduleResolution": "node", + "noPropertyAccessFromIndexSignature": true, + }, + "include": ["src/**/*.*"], + "exclude": ["src/**/*-spec.*"] +} diff --git a/npm/angular/rollup.config.mjs b/npm/angular/rollup.config.mjs index 4ef324cfd1a..bf13d0250ee 100644 --- a/npm/angular/rollup.config.mjs +++ b/npm/angular/rollup.config.mjs @@ -8,6 +8,7 @@ const config = { '@angular/platform-browser-dynamic/testing', 'zone.js', 'zone.js/testing', + '@angular/core/rxjs-interop', ], } diff --git a/npm/angular/src/mount.ts b/npm/angular/src/mount.ts index df7f7f9a8cf..f7e0a8f8af1 100644 --- a/npm/angular/src/mount.ts +++ b/npm/angular/src/mount.ts @@ -369,11 +369,13 @@ function detectAndRegisterOutputSpyToSignal (config: MountConfig, componen // we will create a subscriber that will emit an event every time the value inside the signal changes. We only do this // if the signal is writable and not an input signal. if (isWritableSignal(componentValue) && !isInputSignal(componentValue)) { - toObservable(componentValue, { - injector, - }).subscribe((value) => { - component[expectedChangeKey]?.emit(value) - }) + activeInternalSubscriptions.push( + toObservable(componentValue, { + injector, + }).subscribe((value) => { + component[expectedChangeKey]?.emit(value) + }), + ) } } } diff --git a/npm/cypress-schematic/src/ct.spec.ts b/npm/cypress-schematic/src/ct.spec.ts index 79ee6c4a224..658eedcc812 100644 --- a/npm/cypress-schematic/src/ct.spec.ts +++ b/npm/cypress-schematic/src/ct.spec.ts @@ -24,7 +24,7 @@ const copyAngularMount = async (projectPath: string) => { const cypressSchematicPackagePath = path.join(__dirname, '..') -const ANGULAR_PROJECTS: ProjectFixtureDir[] = ['angular-20', 'angular-21'] +const ANGULAR_PROJECTS: ProjectFixtureDir[] = ['angular-19', 'angular-20'] const timeout = 1000 * 60 * 5 diff --git a/npm/cypress-schematic/src/e2e.spec.ts b/npm/cypress-schematic/src/e2e.spec.ts index 43f5a8368d4..c3c747fcfac 100644 --- a/npm/cypress-schematic/src/e2e.spec.ts +++ b/npm/cypress-schematic/src/e2e.spec.ts @@ -25,7 +25,7 @@ const runCommandInProject = (command: string, projectPath: string) => { const cypressSchematicPackagePath = path.join(__dirname, '..') -const ANGULAR_PROJECTS: ProjectFixtureDir[] = ['angular-20', 'angular-21'] +const ANGULAR_PROJECTS: ProjectFixtureDir[] = ['angular-19', 'angular-20'] describe('ng add @cypress/schematic / only e2e', { timeout: 1000 * 60 * 5 }, function () { for (const project of ANGULAR_PROJECTS) { diff --git a/npm/webpack-dev-server/test/handlers/angularHandler.spec.ts b/npm/webpack-dev-server/test/handlers/angularHandler.spec.ts index 4300ff785ab..3564fefaefb 100644 --- a/npm/webpack-dev-server/test/handlers/angularHandler.spec.ts +++ b/npm/webpack-dev-server/test/handlers/angularHandler.spec.ts @@ -84,7 +84,7 @@ describe('angularHandler', { timeout: 60000 }, function () { browser: 'src/main.ts', tsConfig: 'tsconfig.app.json', assets: ['src/favicon.ico', 'src/assets'], - styles: ['src/styles.scss'], + styles: ['src/styles.css'], optimization: false, extractLicenses: false, sourceMap: true, diff --git a/packages/scaffold-config/src/frameworks.ts b/packages/scaffold-config/src/frameworks.ts index 3412b1707c3..554d4f4356e 100644 --- a/packages/scaffold-config/src/frameworks.ts +++ b/packages/scaffold-config/src/frameworks.ts @@ -101,6 +101,16 @@ export function getBundler (bundler: WizardBundler['type']): WizardBundler { const mountModule = (mountModule: T) => (projectPath: string) => Promise.resolve(mountModule) +const angularMountModule = async (projectPath: string) => { + const angularCorePkg = await isDependencyInstalled(dependencies.WIZARD_DEPENDENCY_ANGULAR_CORE, projectPath) + + if (!angularCorePkg.detectedVersion || !semver.valid(angularCorePkg.detectedVersion)) { + return 'cypress/angular' + } + + return semver.major(angularCorePkg.detectedVersion) >= 21 ? 'cypress/angular-zoneless' : 'cypress/angular' +} + export const SUPPORT_STATUSES: Readonly = ['alpha', 'beta', 'full', 'community'] as const export const CT_FRAMEWORKS: Cypress.ComponentFrameworkDefinition[] = [ @@ -188,7 +198,7 @@ export const CT_FRAMEWORKS: Cypress.ComponentFrameworkDefinition[] = [ }, codeGenFramework: 'angular', glob: '*.component.ts', - mountModule: mountModule('cypress/angular'), + mountModule: angularMountModule, supportStatus: 'full', componentIndexHtml: componentIndexHtmlGenerator(), specPattern: '**/*.cy.ts', diff --git a/packages/scaffold-config/test/supportFile.spec.ts b/packages/scaffold-config/test/supportFile.spec.ts index 6ce9d0e12c9..7b63d4c052d 100644 --- a/packages/scaffold-config/test/supportFile.spec.ts +++ b/packages/scaffold-config/test/supportFile.spec.ts @@ -160,7 +160,7 @@ describe('supportFileComponent', () => { }) describe('angular', () => { - for (const mountModule of ['cypress/angular'] as const) { + for (const mountModule of ['cypress/angular', 'cypress/angular-zoneless'] as const) { it(`handles ${mountModule} and TS`, () => { const actual = supportFileComponent('ts', mountModule) 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 77523979fb1..21312971c83 100644 --- a/system-tests/project-fixtures/angular/src/app/mount.cy.ts +++ b/system-tests/project-fixtures/angular/src/app/mount.cy.ts @@ -431,7 +431,7 @@ describe('angular mount', () => { }, } - cy.mount(ProductComponent, { providers: [{ provider: Cart, useValue: cartFake }] }) + cy.mount(ProductComponent, { providers: [{ provide: Cart, useValue: cartFake }] }) cy.get('[data-testid=btn-buy]').click().then(() => { const cart = TestBed.inject(Cart) diff --git a/system-tests/projects/angular-18/cypress.config.ts b/system-tests/projects/angular-18/cypress.config.ts index 6d9c63eb56a..14d6d08c72b 100644 --- a/system-tests/projects/angular-18/cypress.config.ts +++ b/system-tests/projects/angular-18/cypress.config.ts @@ -15,6 +15,7 @@ export default defineConfig({ '@angular/core/testing': require.resolve('@angular/core/testing'), '@angular/core/primitives/event-dispatch': require.resolve('@angular/core/primitives/event-dispatch'), '@angular/core/primitives/signals': require.resolve('@angular/core/primitives/signals'), + '@angular/core/rxjs-interop': require.resolve('@angular/core/rxjs-interop'), '@angular/core': require.resolve('@angular/core'), '@angular/platform-browser/testing': require.resolve('@angular/platform-browser/testing'), '@angular/platform-browser': require.resolve('@angular/platform-browser'), diff --git a/system-tests/projects/angular-19/cypress.config.ts b/system-tests/projects/angular-19/cypress.config.ts index 6c9ecece70c..bb183164cbf 100644 --- a/system-tests/projects/angular-19/cypress.config.ts +++ b/system-tests/projects/angular-19/cypress.config.ts @@ -16,6 +16,7 @@ export default defineConfig({ '@angular/core/primitives/di': require.resolve('@angular/core/primitives/di'), '@angular/core/primitives/event-dispatch': require.resolve('@angular/core/primitives/event-dispatch'), '@angular/core/primitives/signals': require.resolve('@angular/core/primitives/signals'), + '@angular/core/rxjs-interop': require.resolve('@angular/core/rxjs-interop'), '@angular/core': require.resolve('@angular/core'), '@angular/platform-browser/testing': require.resolve('@angular/platform-browser/testing'), '@angular/platform-browser': require.resolve('@angular/platform-browser'), diff --git a/system-tests/projects/angular-20/cypress.config.ts b/system-tests/projects/angular-20/cypress.config.ts index 6c9ecece70c..bb183164cbf 100644 --- a/system-tests/projects/angular-20/cypress.config.ts +++ b/system-tests/projects/angular-20/cypress.config.ts @@ -16,6 +16,7 @@ export default defineConfig({ '@angular/core/primitives/di': require.resolve('@angular/core/primitives/di'), '@angular/core/primitives/event-dispatch': require.resolve('@angular/core/primitives/event-dispatch'), '@angular/core/primitives/signals': require.resolve('@angular/core/primitives/signals'), + '@angular/core/rxjs-interop': require.resolve('@angular/core/rxjs-interop'), '@angular/core': require.resolve('@angular/core'), '@angular/platform-browser/testing': require.resolve('@angular/platform-browser/testing'), '@angular/platform-browser': require.resolve('@angular/platform-browser'), diff --git a/system-tests/projects/angular-21/.gitignore b/system-tests/projects/angular-21/.gitignore new file mode 100644 index 00000000000..f558a1cc19a --- /dev/null +++ b/system-tests/projects/angular-21/.gitignore @@ -0,0 +1 @@ +.angular \ No newline at end of file diff --git a/system-tests/projects/angular-21/angular.json b/system-tests/projects/angular-21/angular.json index de3acc75e0e..cc2bb01f139 100644 --- a/system-tests/projects/angular-21/angular.json +++ b/system-tests/projects/angular-21/angular.json @@ -20,7 +20,7 @@ "src/assets" ], "styles": [ - "src/styles.scss" + "src/styles.css" ] }, "configurations": { diff --git a/system-tests/projects/angular-21/cypress.config.ts b/system-tests/projects/angular-21/cypress.config.ts index 6c9ecece70c..bbfc9ed9fb9 100644 --- a/system-tests/projects/angular-21/cypress.config.ts +++ b/system-tests/projects/angular-21/cypress.config.ts @@ -16,13 +16,12 @@ export default defineConfig({ '@angular/core/primitives/di': require.resolve('@angular/core/primitives/di'), '@angular/core/primitives/event-dispatch': require.resolve('@angular/core/primitives/event-dispatch'), '@angular/core/primitives/signals': require.resolve('@angular/core/primitives/signals'), + '@angular/core/rxjs-interop': require.resolve('@angular/core/rxjs-interop'), '@angular/core': require.resolve('@angular/core'), '@angular/platform-browser/testing': require.resolve('@angular/platform-browser/testing'), '@angular/platform-browser': require.resolve('@angular/platform-browser'), '@angular/platform-browser-dynamic/testing': require.resolve('@angular/platform-browser-dynamic/testing'), '@angular/platform-browser-dynamic': require.resolve('@angular/platform-browser-dynamic'), - 'zone.js/testing': require.resolve('zone.js/testing'), - 'zone.js': require.resolve('zone.js'), }, }, }, diff --git a/system-tests/projects/angular-21/cypress/support/component-index.html b/system-tests/projects/angular-21/cypress/support/component-index.html new file mode 100644 index 00000000000..ac6e79fd83d --- /dev/null +++ b/system-tests/projects/angular-21/cypress/support/component-index.html @@ -0,0 +1,12 @@ + + + + + + + Components App + + +
+ + \ No newline at end of file diff --git a/system-tests/projects/angular-21/cypress/support/component.ts b/system-tests/projects/angular-21/cypress/support/component.ts new file mode 100644 index 00000000000..98af3c0752d --- /dev/null +++ b/system-tests/projects/angular-21/cypress/support/component.ts @@ -0,0 +1,11 @@ +import { mount } from 'cypress/angular-zoneless' + +declare global { + namespace Cypress { + interface Chainable { + mount: typeof mount + } + } +} + +Cypress.Commands.add('mount', mount) diff --git a/system-tests/projects/angular-21/package.json b/system-tests/projects/angular-21/package.json index 786517a051f..91acdb61e12 100644 --- a/system-tests/projects/angular-21/package.json +++ b/system-tests/projects/angular-21/package.json @@ -27,8 +27,6 @@ "@angular/platform-browser-dynamic": "^21.0.0", "jsdom": "^27.1.0", "typescript": "~5.9.2", - "vitest": "^4.0.8", - "zone.js": "~0.15.0" - }, - "projectFixtureDirectory": "angular" + "vitest": "^4.0.8" + } } diff --git a/system-tests/projects/angular-21/src/app/app.component.css b/system-tests/projects/angular-21/src/app/app.component.css new file mode 100644 index 00000000000..089468ae755 --- /dev/null +++ b/system-tests/projects/angular-21/src/app/app.component.css @@ -0,0 +1,3 @@ +h1 { + color: red +} diff --git a/system-tests/projects/angular-21/src/app/app.component.cy.ts b/system-tests/projects/angular-21/src/app/app.component.cy.ts new file mode 100644 index 00000000000..aa1f8e4481f --- /dev/null +++ b/system-tests/projects/angular-21/src/app/app.component.cy.ts @@ -0,0 +1,7 @@ +// Keep this test very simple as "@cypress/schematic" relies on it to run smoke tests +import { AppComponent } from './app.component' + +it('should', () => { + cy.mount(AppComponent) + cy.get('h1').contains('Hello World', { timeout: 250 }) +}) diff --git a/system-tests/projects/angular-21/src/app/app.component.html b/system-tests/projects/angular-21/src/app/app.component.html new file mode 100644 index 00000000000..1aaaa44955e --- /dev/null +++ b/system-tests/projects/angular-21/src/app/app.component.html @@ -0,0 +1 @@ +

Hello World your app is running!

diff --git a/system-tests/projects/angular-21/src/app/app.component.ts b/system-tests/projects/angular-21/src/app/app.component.ts new file mode 100644 index 00000000000..f39efda2f49 --- /dev/null +++ b/system-tests/projects/angular-21/src/app/app.component.ts @@ -0,0 +1,10 @@ +import { Component, signal } from '@angular/core' + +@Component({ + selector: 'app-root', + templateUrl: './app.component.html', + styleUrl: './app.component.css', +}) +export class AppComponent { + protected readonly title = signal('angular') +} diff --git a/system-tests/projects/angular-21/src/app/app.config.ts b/system-tests/projects/angular-21/src/app/app.config.ts new file mode 100644 index 00000000000..76fdc73c4a2 --- /dev/null +++ b/system-tests/projects/angular-21/src/app/app.config.ts @@ -0,0 +1,7 @@ +import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core' + +export const appConfig: ApplicationConfig = { + providers: [ + provideBrowserGlobalErrorListeners(), + ], +} diff --git a/system-tests/projects/angular-21/src/app/components/cart.component.ts b/system-tests/projects/angular-21/src/app/components/cart.component.ts new file mode 100644 index 00000000000..f139cf974ba --- /dev/null +++ b/system-tests/projects/angular-21/src/app/components/cart.component.ts @@ -0,0 +1,26 @@ +import { Component, Injectable } from '@angular/core' + +@Injectable({ providedIn: 'root' }) +export class Cart { + #items: string[] = [] + + add (product: string) { + this.#items.push(product) + } + + getItems () { + return this.#items + } +} + +@Component({ + template: `

Great Product

`, +}) +export class ProductComponent { + constructor (private cart: Cart) { + } + + buy () { + this.cart.add('Great Product') + } +} diff --git a/system-tests/projects/angular-21/src/app/components/counter.component.ts b/system-tests/projects/angular-21/src/app/components/counter.component.ts new file mode 100644 index 00000000000..e02515e4156 --- /dev/null +++ b/system-tests/projects/angular-21/src/app/components/counter.component.ts @@ -0,0 +1,16 @@ +import { Component, model } from '@angular/core' + +@Component({ + selector: 'counter-component', + standalone: false, + template: ``, +}) +export class CounterComponent { + count = model(0) + + increment () { + this.count.set(this.count() + 1) + } +} diff --git a/system-tests/projects/angular-21/src/app/components/errors.ts b/system-tests/projects/angular-21/src/app/components/errors.ts new file mode 100644 index 00000000000..b25f10ebb9b --- /dev/null +++ b/system-tests/projects/angular-21/src/app/components/errors.ts @@ -0,0 +1,30 @@ +import { Component, effect, input } from '@angular/core' + +@Component({ + selector: 'errors-component', + template: `
+ + +
`, +}) +export class ErrorsComponent { + throwError = input(false) + + constructor () { + effect(() => { + if (this.throwError()) { + throw new Error('mount error') + } + }) + } + + syncError () { + throw new Error('sync error') + } + + asyncError () { + setTimeout(() => { + throw new Error('async error') + }) + } +} diff --git a/system-tests/projects/angular-21/src/app/components/legacy/button-output.component.ts b/system-tests/projects/angular-21/src/app/components/legacy/button-output.component.ts new file mode 100644 index 00000000000..a86b7a9702d --- /dev/null +++ b/system-tests/projects/angular-21/src/app/components/legacy/button-output.component.ts @@ -0,0 +1,11 @@ +import { Component, EventEmitter, Output } from '@angular/core' + +@Component({ + selector: 'app-button-output', + standalone: false, + template: ``, +}) +export class ButtonOutputComponent { + // Used to test legacy @Output() decorators + @Output() clicked: EventEmitter = new EventEmitter() +} diff --git a/system-tests/projects/angular-21/src/app/components/legacy/modules/child.component.ts b/system-tests/projects/angular-21/src/app/components/legacy/modules/child.component.ts new file mode 100644 index 00000000000..951a23085f5 --- /dev/null +++ b/system-tests/projects/angular-21/src/app/components/legacy/modules/child.component.ts @@ -0,0 +1,11 @@ +import { Component, Input } from '@angular/core' + +@Component({ + selector: 'child-component', + standalone: false, + template: '

{{msg}}

', +}) +export class ChildComponent { + // Used to test legacy @Input() decorators + @Input() msg!: string +} diff --git a/system-tests/projects/angular-21/src/app/components/legacy/modules/parent-child.module.ts b/system-tests/projects/angular-21/src/app/components/legacy/modules/parent-child.module.ts new file mode 100644 index 00000000000..84123e3ccc0 --- /dev/null +++ b/system-tests/projects/angular-21/src/app/components/legacy/modules/parent-child.module.ts @@ -0,0 +1,8 @@ +import { NgModule } from '@angular/core' +import { ParentComponent } from './parent.component' +import { ChildComponent } from './child.component' + +// legacy modules, which are not default convention since Angular 19 +@NgModule({ + declarations: [ParentComponent, ChildComponent], +}) export class ParentChildModule {} diff --git a/system-tests/projects/angular-21/src/app/components/legacy/modules/parent.component.ts b/system-tests/projects/angular-21/src/app/components/legacy/modules/parent.component.ts new file mode 100644 index 00000000000..1b4d2da521b --- /dev/null +++ b/system-tests/projects/angular-21/src/app/components/legacy/modules/parent.component.ts @@ -0,0 +1,10 @@ +import { Component } from '@angular/core' + +@Component({ + selector: 'parent-component', + standalone: false, + template: '', +}) +export class ParentComponent { + msg = 'Hello World from ParentComponent' +} diff --git a/system-tests/projects/angular-21/src/app/components/legacy/projection.component.ts b/system-tests/projects/angular-21/src/app/components/legacy/projection.component.ts new file mode 100644 index 00000000000..2f0f1ea4bc9 --- /dev/null +++ b/system-tests/projects/angular-21/src/app/components/legacy/projection.component.ts @@ -0,0 +1,8 @@ +import { Component } from '@angular/core' + +@Component({ + selector: 'app-projection', + template: `

`, + standalone: false, +}) +export class ProjectionComponent {} diff --git a/system-tests/projects/angular-21/src/app/components/legacy/providers/another-child-providers.component.ts b/system-tests/projects/angular-21/src/app/components/legacy/providers/another-child-providers.component.ts new file mode 100644 index 00000000000..b01f02e2590 --- /dev/null +++ b/system-tests/projects/angular-21/src/app/components/legacy/providers/another-child-providers.component.ts @@ -0,0 +1,20 @@ +import { Component, signal } from '@angular/core' +import { ChildProvidersService } from './child-providers.service' + +@Component({ + standalone: false, + selector: 'app-another-child', + template: ``, + providers: [ChildProvidersService], +}) +export class AnotherChildProvidersComponent { + message = signal('default another child message') + + constructor (private readonly service: ChildProvidersService) {} + + async handleClick (): Promise { + const message = await this.service.getMessage() + + this.message.set(message) + } +} diff --git a/system-tests/projects/angular-21/src/app/components/legacy/providers/child-providers.component.ts b/system-tests/projects/angular-21/src/app/components/legacy/providers/child-providers.component.ts new file mode 100644 index 00000000000..f99560b93a1 --- /dev/null +++ b/system-tests/projects/angular-21/src/app/components/legacy/providers/child-providers.component.ts @@ -0,0 +1,19 @@ +import { Component, signal } from '@angular/core' +import { ChildProvidersService } from './child-providers.service' + +@Component({ + selector: 'app-child-providers', + standalone: false, + template: ``, +}) +export class ChildProvidersComponent { + message = signal('default message') + + constructor (private readonly service: ChildProvidersService) {} + + async handleClick (): Promise { + const message = await this.service.getMessage() + + this.message.set(message) + } +} diff --git a/system-tests/projects/angular-21/src/app/components/legacy/providers/child-providers.service.ts b/system-tests/projects/angular-21/src/app/components/legacy/providers/child-providers.service.ts new file mode 100644 index 00000000000..651a49ded99 --- /dev/null +++ b/system-tests/projects/angular-21/src/app/components/legacy/providers/child-providers.service.ts @@ -0,0 +1,13 @@ +import { Injectable } from '@angular/core' + +@Injectable({ + providedIn: 'root', +}) +export class ChildProvidersService { + async getMessage (): Promise { + const response = await fetch('https://myfakeapiurl.com/api/message') + const data = await response.json() + + return data.message + } +} diff --git a/system-tests/projects/angular-21/src/app/components/legacy/providers/component-provider.component.ts b/system-tests/projects/angular-21/src/app/components/legacy/providers/component-provider.component.ts new file mode 100644 index 00000000000..3701e455666 --- /dev/null +++ b/system-tests/projects/angular-21/src/app/components/legacy/providers/component-provider.component.ts @@ -0,0 +1,20 @@ +import { Component, Injectable } from '@angular/core' + +@Injectable({ providedIn: 'root' }) +export class MessageService { + get message () { + return 'globally provided service' + } +} + +@Component({ + template: `

{{messageService.message}}

`, + providers: [{ + provide: MessageService, + useValue: { message: 'component provided service' }, + }], +}) +export class ComponentProviderComponent { + constructor (public messageService: MessageService) { + } +} diff --git a/system-tests/projects/angular-21/src/app/components/legacy/providers/parent-providers.component.ts b/system-tests/projects/angular-21/src/app/components/legacy/providers/parent-providers.component.ts new file mode 100644 index 00000000000..17e7e6aca51 --- /dev/null +++ b/system-tests/projects/angular-21/src/app/components/legacy/providers/parent-providers.component.ts @@ -0,0 +1,10 @@ +import { Component } from '@angular/core' + +@Component({ + standalone: false, + // declare components in the template + template: ` + + `, +}) +export class ParentProvidersComponent {} diff --git a/system-tests/projects/angular-21/src/app/components/legacy/with-directives.component.ts b/system-tests/projects/angular-21/src/app/components/legacy/with-directives.component.ts new file mode 100644 index 00000000000..675effb953a --- /dev/null +++ b/system-tests/projects/angular-21/src/app/components/legacy/with-directives.component.ts @@ -0,0 +1,15 @@ +import { Component } from '@angular/core' + +@Component({ + selector: 'with-directives-component', + standalone: false, + template: ` +
    +
  • {{ item }}
  • +
`, +}) +export class WithDirectivesComponent { + show = true + + items = ['breakfast', 'lunch', 'dinner'] +} diff --git a/system-tests/projects/angular-21/src/app/components/lifecycle.component.ts b/system-tests/projects/angular-21/src/app/components/lifecycle.component.ts new file mode 100644 index 00000000000..36728065c25 --- /dev/null +++ b/system-tests/projects/angular-21/src/app/components/lifecycle.component.ts @@ -0,0 +1,24 @@ +import { Component, input, OnInit, OnChanges, SimpleChanges } from '@angular/core' + +@Component({ + selector: 'app-lifecycle', + standalone: false, + template: `

Hi {{ name() }}. ngOnInit fired: {{ ngOnInitFired }} and ngOnChanges fired: {{ ngOnChangesFired }} and conditionalName: {{ conditionalName }}

`, +}) +export class LifecycleComponent implements OnInit, OnChanges { + name = input('') + ngOnInitFired = false + ngOnChangesFired = false + conditionalName = false + + ngOnInit (): void { + this.ngOnInitFired = true + } + + ngOnChanges (changes: SimpleChanges): void { + this.ngOnChangesFired = true + if (changes['name'].currentValue === 'CONDITIONAL NAME') { + this.conditionalName = true + } + } +} diff --git a/system-tests/projects/angular-21/src/app/components/logo.component.ts b/system-tests/projects/angular-21/src/app/components/logo.component.ts new file mode 100644 index 00000000000..8e8552378db --- /dev/null +++ b/system-tests/projects/angular-21/src/app/components/logo.component.ts @@ -0,0 +1,7 @@ +import { Component } from '@angular/core' + +@Component({ + selector: 'app-logo', + template: ``, +}) +export class LogoComponent {} diff --git a/system-tests/projects/angular-21/src/app/components/signals.component.cy.ts b/system-tests/projects/angular-21/src/app/components/signals.component.cy.ts new file mode 100644 index 00000000000..63687a82244 --- /dev/null +++ b/system-tests/projects/angular-21/src/app/components/signals.component.cy.ts @@ -0,0 +1,14 @@ +import { SignalsComponent } from './signals.component' + +describe('SignalsComponent', () => { + it('can mount a signals component', () => { + cy.mount(SignalsComponent) + }) + + it('can increment the count using a signal', () => { + cy.mount(SignalsComponent) + cy.get('span').contains(0) + cy.get('button').click() + cy.get('span').contains(1) + }) +}) diff --git a/system-tests/projects/angular-21/src/app/components/signals.component.ts b/system-tests/projects/angular-21/src/app/components/signals.component.ts new file mode 100644 index 00000000000..2495023cfaa --- /dev/null +++ b/system-tests/projects/angular-21/src/app/components/signals.component.ts @@ -0,0 +1,14 @@ +import { Component, signal } from '@angular/core' + +@Component({ + standalone: true, + selector: 'app-signals', + template: `{{ count() }} `, +}) +export class SignalsComponent { + count = signal(0) + + increment (): void { + this.count.update((_count: number) => _count + 1) + } +} diff --git a/system-tests/projects/angular-21/src/app/components/standalone.component.cy.ts b/system-tests/projects/angular-21/src/app/components/standalone.component.cy.ts new file mode 100644 index 00000000000..63148da8d5d --- /dev/null +++ b/system-tests/projects/angular-21/src/app/components/standalone.component.cy.ts @@ -0,0 +1,22 @@ +import type { InputSignal } from '@angular/core' +import { StandaloneComponent } from './standalone.component' + +describe('StandaloneComponent', () => { + it('can mount a standalone component', () => { + cy.mount(StandaloneComponent, { + componentProperties: { + name: 'Angular' as unknown as InputSignal, + }, + }) + + 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-21/src/app/components/standalone.component.ts b/system-tests/projects/angular-21/src/app/components/standalone.component.ts new file mode 100644 index 00000000000..76d14a41c8d --- /dev/null +++ b/system-tests/projects/angular-21/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 { + name = input('') +} diff --git a/system-tests/projects/angular-21/src/app/components/transient-services.component.ts b/system-tests/projects/angular-21/src/app/components/transient-services.component.ts new file mode 100644 index 00000000000..efe26cec798 --- /dev/null +++ b/system-tests/projects/angular-21/src/app/components/transient-services.component.ts @@ -0,0 +1,25 @@ +import { Component, Injectable } from '@angular/core' + +@Injectable({ providedIn: 'root' }) +export class TransientService { + get message () { + return 'Original Transient Service' + } +} + +@Injectable({ providedIn: 'root' }) +class DirectService { + constructor (private transientService: TransientService) { + } + + get message () { + return this.transientService.message + } +} + +@Component({ + template: `

{{directService.message}}

`, +}) +export class TransientServicesComponent { + constructor (public directService: DirectService) {} +} diff --git a/system-tests/projects/angular-21/src/app/components/url-image.component.css b/system-tests/projects/angular-21/src/app/components/url-image.component.css new file mode 100644 index 00000000000..b1f6eb9f4cc --- /dev/null +++ b/system-tests/projects/angular-21/src/app/components/url-image.component.css @@ -0,0 +1,6 @@ +.test-img { + background-image: url("../../assets/test.png"); + background-size: contain; + height: 100px; + width: 100px; +} diff --git a/system-tests/projects/angular-21/src/app/components/url-image.component.ts b/system-tests/projects/angular-21/src/app/components/url-image.component.ts new file mode 100644 index 00000000000..d2e58afd3ac --- /dev/null +++ b/system-tests/projects/angular-21/src/app/components/url-image.component.ts @@ -0,0 +1,8 @@ +import { Component } from '@angular/core' + +@Component({ + selector: 'app-url-image', + template: `
`, + styleUrl: './url-image.component.css', +}) +export class UrlImageComponent {} diff --git a/system-tests/projects/angular-21/src/app/errors.cy.ts b/system-tests/projects/angular-21/src/app/errors.cy.ts new file mode 100644 index 00000000000..ba353c23643 --- /dev/null +++ b/system-tests/projects/angular-21/src/app/errors.cy.ts @@ -0,0 +1,23 @@ +import type { InputSignal } from '@angular/core' +import { ErrorsComponent } from './components/errors' + +describe('Errors', () => { + it('error on mount', () => { + cy.mount(ErrorsComponent, { componentProperties: { throwError: true as unknown as InputSignal } }) + }) + + it('sync error', () => { + cy.mount(ErrorsComponent) + cy.get('#sync-error').click() + }) + + it('async error', () => { + cy.mount(ErrorsComponent) + cy.get('#async-error').click() + }) + + it('command failure', { defaultCommandTimeout: 50 }, () => { + cy.mount(ErrorsComponent) + cy.get('element-that-does-not-exist') + }) +}) diff --git a/system-tests/projects/angular-21/src/app/mount.cy.ts b/system-tests/projects/angular-21/src/app/mount.cy.ts new file mode 100644 index 00000000000..8b1eb54c915 --- /dev/null +++ b/system-tests/projects/angular-21/src/app/mount.cy.ts @@ -0,0 +1,487 @@ +import { createOutputSpy } from 'cypress/angular-zoneless' +import { EventEmitter, Component } from '@angular/core' +import type { InputSignal } from '@angular/core' +import { TestBed } from '@angular/core/testing' + +import { ParentChildModule } from './components/legacy/modules/parent-child.module' +import { ParentComponent } from './components/legacy/modules/parent.component' +import { ChildComponent } from './components/legacy/modules/child.component' + +import { WithDirectivesComponent } from './components/legacy/with-directives.component' +import { ButtonOutputComponent } from './components/legacy/button-output.component' +import { CounterComponent } from './components/counter.component' +import { ProjectionComponent } from './components/legacy/projection.component' + +import { ChildProvidersComponent } from './components/legacy/providers/child-providers.component' +import { ParentProvidersComponent } from './components/legacy/providers/parent-providers.component' +import { ChildProvidersService } from './components/legacy/providers/child-providers.service' +import { AnotherChildProvidersComponent } from './components/legacy/providers/another-child-providers.component' + +import { LifecycleComponent } from './components/lifecycle.component' +import { LogoComponent } from './components/logo.component' +import { TransientService, TransientServicesComponent } from './components/transient-services.component' +import { ComponentProviderComponent, MessageService } from './components/legacy/providers/component-provider.component' +import { Cart, ProductComponent } from './components/cart.component' +import { UrlImageComponent } from './components/url-image.component' + +@Component({ + standalone: false, + template: `Hello World`, +}) +class WrapperComponent {} + +// Starting with Angular v19, standalone = true is the new default behavior. +// This means that the ng module configurations, including test module configurations, +// do not work by default with components. Cypress for non standalone components +// injects the CommonModule by default and allows users to add module declarations. +// unless standalone - false is configured for the component, this no longer works in Angular v19. +describe('angular mount', () => { + describe('legacy', () => { + it('pushes CommonModule into component', () => { + cy.mount(WithDirectivesComponent) + cy.get('ul').should('exist') + cy.get('li').should('have.length', 3) + + cy.get('button').click() + + cy.get('ul').should('not.exist') + }) + + it('accepts imports', () => { + cy.mount(ParentComponent, { imports: [ParentChildModule] }) + cy.contains('h1', 'Hello World from ParentComponent') + }) + + it('accepts declarations', () => { + cy.mount(ParentComponent, { + declarations: [ChildComponent], + }) + + cy.contains('h1', 'Hello World from ParentComponent') + }) + + it('accepts providers', () => { + cy.mount(CounterComponent) + cy.contains('button', 'Increment: 0').click().contains('Increment: 1') + }) + + describe('@Input() decorators', () => { + it('detects changes via setInput method', () => { + cy.mount(ChildComponent, { componentProperties: { msg: 'Hello World from Spec' } }) + .then(({ fixture }) => { + return cy.contains('h1', 'Hello World from Spec').wrap(fixture) + }) + .then((fixture) => { + // NOTE: the correct way to set an input is to use the componentRef.setInput method so angular change detection runs properly. + // @see https://github.com/cypress-io/cypress/issues/32391 thread for more details. + fixture.componentRef.setInput('msg', 'I just changed!') + cy.contains('h1', 'I just changed!') + }) + }) + }) + + describe('@Output() decorators', () => { + 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') + cy.get('button').click() + 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') + }) + }) + + describe('content projection', () => { + 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('cy.intercept()', () => { + it('can use cy.intercept', () => { + cy.intercept('GET', '**/api/message', { + statusCode: 200, + body: { message: 'test' }, + }) + + cy.mount(ChildProvidersComponent, { + providers: [ChildProvidersService], + }) + + cy.get('button').contains('default message') + cy.get('button').click() + cy.get('button').contains('test') + }) + + it('can use cy.intercept on child component', () => { + cy.intercept('GET', '**/api/message', { + statusCode: 200, + body: { message: 'test' }, + }) + + cy.mount(ParentProvidersComponent, { + declarations: [ChildProvidersComponent, AnotherChildProvidersComponent], + providers: [ChildProvidersService], + }) + + cy.get('button').contains('default message').click() + cy.get('button').contains('test') + }) + + it('can use intercept with component with a provider override', () => { + cy.intercept('GET', '**/api/message', { + statusCode: 200, + body: { + message: 'test', + }, + }) + + cy.mount(AnotherChildProvidersComponent, { + providers: [ChildProvidersService], + }) + + cy.get('button').contains('default another child message').click() + cy.get('button').contains('test') + }) + + it('can use intercept with child component with a provider override', () => { + cy.intercept('GET', '**/api/message', { + statusCode: 200, + body: { + message: 'test', + }, + }) + + cy.mount(ParentProvidersComponent, { + declarations: [ChildProvidersComponent, AnotherChildProvidersComponent], + providers: [ChildProvidersService], + }) + + cy.get('button').contains('default another child message').click() + cy.get('button').contains('test') + }) +}) + +it('can make test doubles for child components', () => { + cy.mount(ParentProvidersComponent, { + declarations: [ChildProvidersComponent, AnotherChildProvidersComponent], + providers: [ + { + provide: ChildProvidersService, + useValue: { + async getMessage (): Promise { + return 'test' + }, + } as ChildProvidersService, + }, + ], + }) + + cy.get('button').contains('default message').click() + cy.get('button').contains('test') +}) + +it('can use a test double for a component with a provider override', () => { + cy.mount(AnotherChildProvidersComponent) + TestBed.overrideComponent(AnotherChildProvidersComponent, { add: { providers: [{ + provide: ChildProvidersService, + useValue: { + async getMessage (): Promise { + return 'test' + }, + }, + }] } }) + + cy.get('button').contains('default another child message').click() + cy.get('button').contains('test') +}) + +it('can use a test double for a child component with a provider override', () => { + TestBed.overrideProvider(ChildProvidersService, { + useValue: { + async getMessage (): Promise { + return 'test' + }, + } as ChildProvidersService, + }) + + cy.mount(ParentProvidersComponent, { + declarations: [ChildProvidersComponent, AnotherChildProvidersComponent], + }) + + cy.get('button').contains('default another child message').click() + cy.get('button').contains('test') +}) + +it('handles ngOnChanges on mount', () => { + cy.mount(LifecycleComponent, { + componentProperties: { + name: 'Angular' as unknown as InputSignal, + }, + }) + + cy.get('p').should('have.text', 'Hi Angular. ngOnInit fired: true and ngOnChanges fired: true and conditionalName: false') +}) + +it('handles ngOnChanges on mount with templates', () => { + cy.mount('', { + declarations: [LifecycleComponent], + componentProperties: { + name: 'Angular', + }, + }) + + cy.get('p').should('have.text', 'Hi Angular. ngOnInit fired: true and ngOnChanges fired: true and conditionalName: false') +}) + +it('creates simpleChanges from componentProperties and calls ngOnChanges on Mount', () => { + cy.mount(LifecycleComponent, { + componentProperties: { + name: 'CONDITIONAL NAME' as unknown as InputSignal, + }, + }) + + cy.get('p').should('have.text', 'Hi CONDITIONAL NAME. ngOnInit fired: true and ngOnChanges fired: true and conditionalName: true') +}) + +it('creates simpleChanges from componentProperties and calls ngOnChanges on Mount with template', () => { + cy.mount('', { + declarations: [LifecycleComponent], + componentProperties: { + name: 'CONDITIONAL NAME', + }, + }) + + cy.get('p').should('have.text', 'Hi CONDITIONAL NAME. ngOnInit fired: true and ngOnChanges fired: true and conditionalName: true') +}) + +it('ngOnChanges is not fired when no componentProperties given', () => { + cy.mount(LifecycleComponent) + cy.get('p').should('have.text', 'Hi . ngOnInit fired: true and ngOnChanges fired: false and conditionalName: false') +}) + +it('ngOnChanges is not fired when no componentProperties given with template', () => { + cy.mount('', { + declarations: [LifecycleComponent], + }) + + cy.get('p').should('have.text', 'Hi . ngOnInit fired: true and ngOnChanges fired: false and conditionalName: false') +}) + +context('assets', () => { + it('can load static assets from ', () => { + cy.mount(LogoComponent) + cy.get('img').should('be.visible').and('have.prop', 'naturalWidth').should('be.greaterThan', 0) + }) + + it('can load root relative css "url()" assets', () => { + cy.mount(UrlImageComponent) + cy.get('.test-img') + .invoke('css', 'background-image') + .then((img) => { + expect(img).to.contain('__cypress/src/test.png') + }) + }) +}) + +context('dependency injection', () => { + it('should not override transient service', () => { + cy.mount(TransientServicesComponent) + cy.get('p').should('have.text', 'Original Transient Service') + }) + + it('should override transient service', () => { + cy.mount(TransientServicesComponent, { providers: [{ provide: TransientService, useValue: { message: 'Overridden Transient Service' } }] }) + cy.get('p').should('have.text', 'Overridden Transient Service') + }) + + it('should have a component provider', () => { + cy.mount(ComponentProviderComponent) + cy.get('p').should('have.text', 'component provided service') + }) + + it('should not override component-providers via providers', () => { + cy.mount(ComponentProviderComponent, { providers: [{ provide: MessageService, useValue: { message: 'overridden service' } }] }) + cy.get('p').should('have.text', 'component provided service') + }) + + it('should override component-providers via TestBed.overrideComponent', () => { + TestBed.overrideComponent(ComponentProviderComponent, { set: { providers: [{ provide: MessageService, useValue: { message: 'overridden service' } }] } }) + cy.mount(ComponentProviderComponent) + cy.get('p').should('have.text', 'overridden service') + }) + + it('should remove component-providers', () => { + TestBed.overrideComponent(ComponentProviderComponent, { set: { providers: [] } }) + cy.mount(ComponentProviderComponent) + cy.get('p').should('have.text', 'globally provided service') + }) + + it('should use TestBed.inject', () => { + cy.mount(ProductComponent) + cy.get('[data-testid=btn-buy]').click().then(() => { + const cart = TestBed.inject(Cart) + + expect(cart.getItems()).to.have.length(1) + }) + }) + + it('should verify a faked service', () => { + const cartFake = { + items: [] as string[], + add (product: string) { + this.items.push(product) + }, + getItems () { + return this.items + }, + } + + cy.mount(ProductComponent, { providers: [{ provide: Cart, useValue: cartFake }] }) + + cy.get('[data-testid=btn-buy]').click().then(() => { + const cart = TestBed.inject(Cart) + + expect(cart.getItems()).to.have.length(1) + }) + }) +}) + +describe('teardown', () => { + const cyRootSelector = '[data-cy-root]' + + beforeEach(() => { + cy.get(cyRootSelector).should('be.empty') + }) + + it('should mount', () => { + cy.mount(ButtonOutputComponent) + cy.get(cyRootSelector).should('not.be.empty') + }) + + it('should remove previous mounted component', () => { + cy.mount(ChildComponent, { componentProperties: { msg: 'Render 1' } }) + cy.contains('Render 1') + cy.mount(ChildComponent, { componentProperties: { msg: 'Render 2' } }) + cy.contains('Render 2') + + cy.contains('Render 1').should('not.exist') + cy.get(cyRootSelector).children().should('have.length', 1) + }) +}) + +it('should error when passing in undecorated component', () => { + Cypress.on('fail', (err) => { + expect(err.message).contain('Please add a @Pipe/@Directive/@Component') + + return false + }) + + class MyClass {} + + cy.mount(MyClass) +}) + +context('component-index.html', () => { + before(() => { + const cyRootSelector = '[data-cy-root]' + const cyRoot = document.querySelector(cyRootSelector)! + + expect(cyRoot.parentElement === document.body) + document.body.innerHTML = ` +
+
+
+ ` + }) + + it('preserves html hierarchy', () => { + const cyRootSelector = '[data-cy-root]' + + cy.mount(ChildComponent, { componentProperties: { msg: 'Render 1' } }) + cy.contains('Render 1') + cy.get(cyRootSelector).should('exist').parent().should('have.id', 'container') + cy.get('#container').should('exist').parent().should('have.prop', 'tagName').should('eq', 'BODY') + + // structure persists after teardown + cy.mount(ChildComponent, { componentProperties: { msg: 'Render 2' } }) + cy.contains('Render 2') + cy.get(cyRootSelector).should('exist').parent().should('have.id', 'container') + cy.get('#container').should('exist').parent().should('have.prop', 'tagName').should('eq', 'BODY') + }) +}) diff --git a/system-tests/projects/angular-21/src/assets/.gitkeep b/system-tests/projects/angular-21/src/assets/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/system-tests/projects/angular-21/src/assets/cypress-logo-light.png b/system-tests/projects/angular-21/src/assets/cypress-logo-light.png new file mode 100644 index 00000000000..d8ae5306850 Binary files /dev/null and b/system-tests/projects/angular-21/src/assets/cypress-logo-light.png differ diff --git a/system-tests/projects/angular-21/src/assets/test.png b/system-tests/projects/angular-21/src/assets/test.png new file mode 100644 index 00000000000..eddef1726e3 Binary files /dev/null and b/system-tests/projects/angular-21/src/assets/test.png differ diff --git a/system-tests/projects/angular-21/src/index.html b/system-tests/projects/angular-21/src/index.html new file mode 100644 index 00000000000..aa063061391 --- /dev/null +++ b/system-tests/projects/angular-21/src/index.html @@ -0,0 +1,13 @@ + + + + + Angular + + + + + + + + diff --git a/system-tests/projects/angular-21/src/main.ts b/system-tests/projects/angular-21/src/main.ts new file mode 100644 index 00000000000..6b980df2fdc --- /dev/null +++ b/system-tests/projects/angular-21/src/main.ts @@ -0,0 +1,6 @@ +import { bootstrapApplication } from '@angular/platform-browser' +import { appConfig } from './app/app.config' +import { AppComponent } from './app/app.component' + +bootstrapApplication(AppComponent, appConfig) +.catch((err) => console.error(err)) diff --git a/system-tests/projects/angular-21/src/styles.css b/system-tests/projects/angular-21/src/styles.css new file mode 100644 index 00000000000..3e49a9a0100 --- /dev/null +++ b/system-tests/projects/angular-21/src/styles.css @@ -0,0 +1,4 @@ +/* You can add global styles to this file, and also import other style files */ +* { + font-family: 'Courier New', Courier, monospace; +} diff --git a/system-tests/projects/angular-21/tsconfig.app.json b/system-tests/projects/angular-21/tsconfig.app.json new file mode 100644 index 00000000000..38c2811d03b --- /dev/null +++ b/system-tests/projects/angular-21/tsconfig.app.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/app", + "types": [] + }, + "include": [ + "src/**/*.ts" + ] +} diff --git a/system-tests/projects/angular-21/yarn.lock b/system-tests/projects/angular-21/yarn.lock index 78f7662a1e6..60292ee40b6 100644 --- a/system-tests/projects/angular-21/yarn.lock +++ b/system-tests/projects/angular-21/yarn.lock @@ -7028,8 +7028,3 @@ zod@3.25.76, zod@^3.23.8: version "3.25.76" resolved "https://registry.yarnpkg.com/zod/-/zod-3.25.76.tgz#26841c3f6fd22a6a2760e7ccb719179768471e34" integrity sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ== - -zone.js@~0.15.0: - version "0.15.1" - resolved "https://registry.yarnpkg.com/zone.js/-/zone.js-0.15.1.tgz#1e109adb75f80e9e004ee8e0d4a0a52e0a336481" - integrity sha512-XE96n56IQpJM7NAoXswY3XRLcWFW83xe0BiAOeMD7K5k5xecOeul3Qcpx6GqEeeHNkW5DWL5zOyTbEfB4eti8w== diff --git a/system-tests/projects/angular-custom-config/cypress.config.ts b/system-tests/projects/angular-custom-config/cypress.config.ts index 36925974e25..753a97efa6e 100644 --- a/system-tests/projects/angular-custom-config/cypress.config.ts +++ b/system-tests/projects/angular-custom-config/cypress.config.ts @@ -27,6 +27,7 @@ export default defineConfig({ '@angular/core/primitives/di': require.resolve('@angular/core/primitives/di'), '@angular/core/primitives/event-dispatch': require.resolve('@angular/core/primitives/event-dispatch'), '@angular/core/primitives/signals': require.resolve('@angular/core/primitives/signals'), + '@angular/core/rxjs-interop': require.resolve('@angular/core/rxjs-interop'), '@angular/core': require.resolve('@angular/core'), '@angular/platform-browser/testing': require.resolve('@angular/platform-browser/testing'), '@angular/platform-browser': require.resolve('@angular/platform-browser'), diff --git a/system-tests/projects/angular-custom-root/cypress.config.ts b/system-tests/projects/angular-custom-root/cypress.config.ts index 109d2c82936..cd9cbccb918 100644 --- a/system-tests/projects/angular-custom-root/cypress.config.ts +++ b/system-tests/projects/angular-custom-root/cypress.config.ts @@ -16,6 +16,7 @@ export default defineConfig({ '@angular/core/primitives/di': require.resolve('@angular/core/primitives/di'), '@angular/core/primitives/event-dispatch': require.resolve('@angular/core/primitives/event-dispatch'), '@angular/core/primitives/signals': require.resolve('@angular/core/primitives/signals'), + '@angular/core/rxjs-interop': require.resolve('@angular/core/rxjs-interop'), '@angular/core': require.resolve('@angular/core'), '@angular/platform-browser/testing': require.resolve('@angular/platform-browser/testing'), '@angular/platform-browser': require.resolve('@angular/platform-browser'), diff --git a/system-tests/test/component_testing_spec.ts b/system-tests/test/component_testing_spec.ts index 35830c884ad..36ad4a88591 100644 --- a/system-tests/test/component_testing_spec.ts +++ b/system-tests/test/component_testing_spec.ts @@ -74,7 +74,7 @@ describe(`React major versions with Webpack`, function () { } }) -const ANGULAR_VERSIONS = ['18', '19', '20', '21'] as const +const ANGULAR_VERSIONS = ['18', '19', '20'] as const describe(`Angular CLI versions`, () => { systemTests.setup() @@ -89,6 +89,16 @@ describe(`Angular CLI versions`, () => { }) } + // NOTE: Angular 21 has to be tested separate because it uses the zoneless mount function, + // which doesn't support zone.js any longer OR support autoDetectChanges or autoSpyOutputs + systemTests.it(`v21 with mount tests`, { + project: `angular-21`, + spec: 'src/**/*.cy.ts,!src/app/errors.cy.ts', + testingType: 'component', + browser: 'chrome', + expectedExitCode: 0, + }) + systemTests.it('angular 19 custom config', { project: 'angular-custom-config', spec: 'src/app/my-component.cy.ts', diff --git a/yarn.lock b/yarn.lock index 8c5031770dd..f8cc3615020 100644 --- a/yarn.lock +++ b/yarn.lock @@ -215,6 +215,13 @@ dependencies: tslib "^2.3.0" +"@angular/common@^21.0.0": + version "21.0.1" + resolved "https://registry.yarnpkg.com/@angular/common/-/common-21.0.1.tgz#de7098aa800b6f5af3f1684369dfc26c0d50f7c8" + integrity sha512-EqdTGpFp7PVdTVztO7TB6+QxdzUbYXKKT2jwG2Gg+PIQZ2A8XrLPRmGXyH/DLlc5IhnoJlLbngmBRCLCO4xWog== + dependencies: + tslib "^2.3.0" + "@angular/core@^18.0.0": version "18.2.13" resolved "https://registry.npmjs.org/@angular/core/-/core-18.2.13.tgz#50a4269386201b31105dffd6e14c84ca29859cd9" @@ -222,6 +229,13 @@ dependencies: tslib "^2.3.0" +"@angular/core@^21.0.0": + version "21.0.1" + resolved "https://registry.yarnpkg.com/@angular/core/-/core-21.0.1.tgz#78130bcdab520257573709d27eab83ee653ec286" + integrity sha512-z0G9Bwzgqr0fQVbtMgqwl+SbbiqtJD7I2xT6U5p45LetKHojcfigH29dxi/vqALPwEdgb2nSIx7RqVhoyynraQ== + dependencies: + tslib "^2.3.0" + "@angular/platform-browser-dynamic@^18.0.0": version "18.2.13" resolved "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-18.2.13.tgz#6b649f750d2a3d5b0cfaa5fa289f85841f972102" @@ -229,6 +243,13 @@ dependencies: tslib "^2.3.0" +"@angular/platform-browser-dynamic@^21.0.0": + version "21.0.1" + resolved "https://registry.yarnpkg.com/@angular/platform-browser-dynamic/-/platform-browser-dynamic-21.0.1.tgz#6b044e60ac2d5926525f0688e9e53bf45f3f3b43" + integrity sha512-TzCKf3p1NBK1NYoPJXLScSjVeiQ52DaXf9gweNUGtCmX3EkVKf1sx4Ny1x4DxaTwB5XZn+O+L3nVLstPBj7UGA== + dependencies: + tslib "^2.3.0" + "@antfu/install-pkg@^0.3.3": version "0.3.3" resolved "https://registry.yarnpkg.com/@antfu/install-pkg/-/install-pkg-0.3.3.tgz#34c3837132157e6ca23fe9587d1e174b0f33dc1a"