From 0b43713bce33d36942086b55e41fc1fe85b9b0ce Mon Sep 17 00:00:00 2001 From: AtofStryker Date: Mon, 4 Nov 2024 16:03:34 -0500 Subject: [PATCH] breaking: remove the cypress/angular-signals test harness. The changes incorporated in @cypress/angular-signals are now merged upstream into @cypress/angular and the package is no longer needed [run ci] --- .circleci/cache-version.txt | 2 +- .circleci/workflows.yml | 17 - CHANGELOG.md | 1 - CONTRIBUTING.md | 1 - cli/.eslintignore | 1 - cli/.gitignore | 1 - cli/CHANGELOG.md | 1 + cli/package.json | 9 +- cli/scripts/post-build.js | 1 - npm/angular-signals/.eslintignore | 5 - npm/angular-signals/.eslintrc | 8 - npm/angular-signals/.npmignore | 3 - npm/angular-signals/.releaserc.js | 3 - npm/angular-signals/CHANGELOG.md | 6 - npm/angular-signals/README.md | 11 - npm/angular-signals/package.json | 74 --- npm/angular-signals/rollup.config.mjs | 14 - npm/angular-signals/src/index.ts | 1 - npm/angular-signals/src/mount.ts | 554 ------------------ npm/angular-signals/tsconfig.json | 26 - npm/angular/package.json | 13 +- npm/angular/src/mount.ts | 164 +++++- .../cypress/support/component.ts | 2 +- .../signals-complex.component.cy.ts | 2 +- .../signals-optional.component.cy.ts | 2 +- .../signals-required.component.cy.ts | 2 +- yarn.lock | 9 +- 27 files changed, 180 insertions(+), 753 deletions(-) delete mode 100644 npm/angular-signals/.eslintignore delete mode 100644 npm/angular-signals/.eslintrc delete mode 100644 npm/angular-signals/.npmignore delete mode 100644 npm/angular-signals/.releaserc.js delete mode 100644 npm/angular-signals/CHANGELOG.md delete mode 100644 npm/angular-signals/README.md delete mode 100644 npm/angular-signals/package.json delete mode 100644 npm/angular-signals/rollup.config.mjs delete mode 100644 npm/angular-signals/src/index.ts delete mode 100644 npm/angular-signals/src/mount.ts delete mode 100644 npm/angular-signals/tsconfig.json diff --git a/.circleci/cache-version.txt b/.circleci/cache-version.txt index 566df802ad66..630705afad51 100644 --- a/.circleci/cache-version.txt +++ b/.circleci/cache-version.txt @@ -1,3 +1,3 @@ # Bump this version to force CI to re-create the cache from scratch. -10-30-24-wds-3-removal +11-04-24-angular-signals-removal diff --git a/.circleci/workflows.yml b/.circleci/workflows.yml index f9bb5136932a..d46dbabb6c60 100644 --- a/.circleci/workflows.yml +++ b/.circleci/workflows.yml @@ -2163,15 +2163,6 @@ jobs: command: yarn lerna run build --scope=@cypress/angular - store-npm-logs - npm-angular-signals: - <<: *defaults - steps: - - restore_cached_workspace - - run: - name: Build - command: yarn lerna run build --scope=@cypress/angular-signals - - store-npm-logs - npm-puppeteer-unit-tests: <<: *defaults steps: @@ -2943,9 +2934,6 @@ linux-x64-workflow: &linux-x64-workflow - npm-angular: requires: - build - - npm-angular-signals: - requires: - - build - npm-mount-utils: requires: - build @@ -2964,7 +2952,6 @@ linux-x64-workflow: &linux-x64-workflow requires: - check-ts - npm-angular - - npm-angular-signals - npm-eslint-plugin-dev - npm-puppeteer-unit-tests - npm-puppeteer-cypress-tests @@ -3302,9 +3289,6 @@ linux-x64-contributor-workflow: &linux-x64-contributor-workflow - npm-angular: requires: - build - - npm-angular-signals: - requires: - - build - npm-mount-utils: requires: - build @@ -3322,7 +3306,6 @@ linux-x64-contributor-workflow: &linux-x64-contributor-workflow requires: - check-ts - npm-angular - - npm-angular-signals - npm-eslint-plugin-dev - npm-puppeteer-unit-tests - npm-puppeteer-cypress-tests diff --git a/CHANGELOG.md b/CHANGELOG.md index c387bf62a53c..be435cb6b72d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,6 @@ - [Cypress App](https://on.cypress.io/changelog) - [`@cypress/angular`](https://github.com/cypress-io/cypress/blob/develop/npm/angular/CHANGELOG.md) -- [`@cypress/angular-signals`](https://github.com/cypress-io/cypress/blob/develop/npm/angular-signals/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 597e3979202c..a75e59f32367 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -182,7 +182,6 @@ 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 signals](./npm/angular-signals) | `@cypress/angular-signals` | Cypress component testing for Angular 17/18 including support for signals. | | [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/.eslintignore b/cli/.eslintignore index a0fd3df705de..8bd0ed4f295f 100644 --- a/cli/.eslintignore +++ b/cli/.eslintignore @@ -10,7 +10,6 @@ package.json # these are all copied from dist'd builds from the individual libs /angular -/angular-signals /react /react18 /vue diff --git a/cli/.gitignore b/cli/.gitignore index 81806971abcd..d13afcd7f1bc 100644 --- a/cli/.gitignore +++ b/cli/.gitignore @@ -21,4 +21,3 @@ react* mount-utils angular svelte -angular-signals diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index 9636a7dbcb9c..26becf951810 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -19,6 +19,7 @@ _Released 12/3/2024 (PENDING)_ - `@cypress/vite-dev-server` no longer supports `vite` versions 2 and 3. Addresses [#29377](https://github.com/cypress-io/cypress/issues/29377) and [#29378](https://github.com/cypress-io/cypress/issues/29378). - Cypress Component Testing no longer supports `Nuxt.js` version 2. Addresses [#30468](https://github.com/cypress-io/cypress/issues/30468). - Cypress Component Testing no longer supports `Angular` versions 13, 14, 15, and 16. The minimum supported version is now `17.2.0` in order to support all Angular [signals](https://angular.dev/guide/signals). Addresses [#29582](https://github.com/cypress-io/cypress/issues/29582). Addressed in [#30539](https://github.com/cypress-io/cypress/pull/30539). +- The `cypress/angular-signals` test harness is no longer included in the Cypress binary. Instead, signals support is now shipped with `cypress/angular`! This requires `rxjs` to be installed as a `peerDependency`. Addresses [#29606](https://github.com/cypress-io/cypress/issues/29606). **Bugfixes:** diff --git a/cli/package.json b/cli/package.json index be03c816bcc3..08ee8eff3753 100644 --- a/cli/package.json +++ b/cli/package.json @@ -68,7 +68,6 @@ "@babel/cli": "7.24.8", "@babel/preset-env": "7.25.3", "@cypress/angular": "0.0.0-development", - "@cypress/angular-signals": "0.0.0-development", "@cypress/grep": "0.0.0-development", "@cypress/mount-utils": "0.0.0-development", "@cypress/react": "0.0.0-development", @@ -119,8 +118,7 @@ "vue2", "react18", "angular", - "svelte", - "angular-signals" + "svelte" ], "bin": { "cypress": "bin/cypress" @@ -172,11 +170,6 @@ "types": "./svelte/dist/index.d.ts", "import": "./svelte/dist/cypress-svelte.esm-bundler.js", "require": "./svelte/dist/cypress-svelte.cjs.js" - }, - "./angular-signals": { - "types": "./angular-signals/dist/index.d.ts", - "import": "./angular-signals/dist/index.js", - "require": "./angular-signals/dist/index.js" } }, "workspaces": { diff --git a/cli/scripts/post-build.js b/cli/scripts/post-build.js index 752578adf983..ab853799a748 100644 --- a/cli/scripts/post-build.js +++ b/cli/scripts/post-build.js @@ -13,7 +13,6 @@ const npmModulesToCopy = [ 'vue', 'vue2', 'angular', - 'angular-signals', 'svelte', ] diff --git a/npm/angular-signals/.eslintignore b/npm/angular-signals/.eslintignore deleted file mode 100644 index 79afe972da7d..000000000000 --- a/npm/angular-signals/.eslintignore +++ /dev/null @@ -1,5 +0,0 @@ -**/dist -**/*.d.ts -**/package-lock.json -**/tsconfig.json -**/cypress/fixtures \ No newline at end of file diff --git a/npm/angular-signals/.eslintrc b/npm/angular-signals/.eslintrc deleted file mode 100644 index f044f320923e..000000000000 --- a/npm/angular-signals/.eslintrc +++ /dev/null @@ -1,8 +0,0 @@ -{ - "plugins": [ - "cypress" - ], - "extends": [ - "plugin:@cypress/dev/tests" - ] -} diff --git a/npm/angular-signals/.npmignore b/npm/angular-signals/.npmignore deleted file mode 100644 index d4372c984ed9..000000000000 --- a/npm/angular-signals/.npmignore +++ /dev/null @@ -1,3 +0,0 @@ -examples -src -cypress \ No newline at end of file diff --git a/npm/angular-signals/.releaserc.js b/npm/angular-signals/.releaserc.js deleted file mode 100644 index 17d3bb871472..000000000000 --- a/npm/angular-signals/.releaserc.js +++ /dev/null @@ -1,3 +0,0 @@ -module.exports = { - ...require('../../.releaserc'), -} diff --git a/npm/angular-signals/CHANGELOG.md b/npm/angular-signals/CHANGELOG.md deleted file mode 100644 index e7244aa851c6..000000000000 --- a/npm/angular-signals/CHANGELOG.md +++ /dev/null @@ -1,6 +0,0 @@ -# @cypress/angular-signals-v1.0.0 (2024-07-02) - - -### Features - -* add Angular Signals CT Harness for Angular 17.2 and up for users to be able to use Angular Signals within their component tests ([#29621](https://github.com/cypress-io/cypress/issues/29621)) ([f2554f1](https://github.com/cypress-io/cypress/commit/f2554f12d6d1f438db898fbbc10a100ebff733ce)) diff --git a/npm/angular-signals/README.md b/npm/angular-signals/README.md deleted file mode 100644 index bbe66b417b27..000000000000 --- a/npm/angular-signals/README.md +++ /dev/null @@ -1,11 +0,0 @@ -# @cypress/angular-signals - -Mount Angular components in the open source [Cypress.io](https://www.cypress.io/) test runner. This package is an extension of `@cypress/angular`, but with [signals](https://angular.dev/guide/signals) support. - -> **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-signals` should only be done for advanced use-cases. - -## Development - -Run `yarn build` to compile and sync packages to the `cypress` cli package. - -## [Changelog](./CHANGELOG.md) diff --git a/npm/angular-signals/package.json b/npm/angular-signals/package.json deleted file mode 100644 index 104c1f5b21be..000000000000 --- a/npm/angular-signals/package.json +++ /dev/null @@ -1,74 +0,0 @@ -{ - "name": "@cypress/angular-signals", - "version": "0.0.0-development", - "description": "Test Angular Components using Signals with Cypress", - "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": "^17.2.0", - "@angular/core": "^17.2.0", - "@angular/platform-browser-dynamic": "^17.2.0", - "@cypress/mount-utils": "0.0.0-development", - "typescript": "~5.4.5", - "zone.js": "~0.14.6" - }, - "peerDependencies": { - "@angular/common": ">=17.2", - "@angular/core": ">=17.2", - "@angular/platform-browser-dynamic": ">=17.2", - "rxjs": ">=7.5.0", - "zone.js": ">=0.13.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-signals/#readme", - "bugs": "https://github.com/cypress-io/cypress/issues/new?assignees=&labels=npm%3A%20%40cypress%2Fangular&template=1-bug-report.md&title=", - "keywords": [ - "angular", - "cypress", - "cypress-io", - "test", - "testing" - ], - "contributors": [ - { - "name": "Bill Glesias", - "social": "@atofstryker" - } - ], - "module": "dist/index.js", - "publishConfig": { - "access": "public" - }, - "nx": { - "targets": { - "build": { - "outputs": [ - "{workspaceRoot}/cli/angular-signals" - ] - } - } - }, - "standard": { - "globals": [ - "Cypress", - "cy", - "expect" - ] - } -} diff --git a/npm/angular-signals/rollup.config.mjs b/npm/angular-signals/rollup.config.mjs deleted file mode 100644 index 4ef324cfd1a3..000000000000 --- a/npm/angular-signals/rollup.config.mjs +++ /dev/null @@ -1,14 +0,0 @@ -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', - 'zone.js', - 'zone.js/testing', - ], -} - -export default createEntries({ formats: ['es'], input: 'src/index.ts', config }) diff --git a/npm/angular-signals/src/index.ts b/npm/angular-signals/src/index.ts deleted file mode 100644 index 1af962e1d530..000000000000 --- a/npm/angular-signals/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './mount' diff --git a/npm/angular-signals/src/mount.ts b/npm/angular-signals/src/mount.ts deleted file mode 100644 index 1c039f2970be..000000000000 --- a/npm/angular-signals/src/mount.ts +++ /dev/null @@ -1,554 +0,0 @@ -import 'zone.js' - -/** - * @hack fixes "Mocha has already been patched with Zone" error. - */ -// @ts-ignore -window.Mocha['__zone_patch__'] = false -import 'zone.js/testing' - -import { CommonModule } from '@angular/common' -import { Component, ErrorHandler, EventEmitter, Injectable, SimpleChange, SimpleChanges, Type, OnChanges, Injector, InputSignal, WritableSignal, signal } from '@angular/core' -import { toObservable } from '@angular/core/rxjs-interop' -import { - ComponentFixture, - getTestBed, - TestModuleMetadata, - TestBed, - TestComponentRenderer, -} from '@angular/core/testing' -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 - * @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 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/component-framework-configuration' - 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 -}; - -// 'zone.js/testing' is not properly aliasing `it.skip` but it does provide `xit`/`xspecify` -// Written up under https://github.com/angular/angular/issues/46297 but is not seeing movement -// so we'll patch here pending a fix in that library -// @ts-ignore Ignore so that way we can bypass semantic error TS7017: Element implicitly has an 'any' type because type 'typeof globalThis' has no index signature. -globalThis.it.skip = globalThis.xit - -@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, - }) - - // 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 -} - -@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') { - // 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 {ComponentFixture} ComponentFixture - */ -function setupFixture ( - component: Type, - config: MountConfig, -): ComponentFixture { - const fixture = getTestBed().createComponent(component) - - setupComponent(config, fixture) - - fixture.whenStable().then(() => { - fixture.autoDetectChanges(config.autoDetectChanges ?? true) - }) - - 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 convertPropertyToSignalIfApplicable (propValue: any, componentValue: any, injector: Injector) { - const isComponentValueAnInputSignal = isInputSignal(componentValue) - const isComponentValueAModelSignal = isModelSignal(componentValue) - let convertedValueIfApplicable = propValue - - // If the component has the property defined as an InputSignal, we need to detect whether a non signal value or not was passed into the component as a prop - // and attempt to merge the value in correctly. - // We don't want to expose the primitive created signal as it should really be one-way binding from within the component. - // However, to make CT testing easier, a user can technically pass in a signal to an input component and assert on the signal itself and pass in updates - // down to the component as 1 way binding is supported by the test harness - if (isComponentValueAnInputSignal) { - const isPassedInValueNotASignal = !isSignal(propValue) - - if (isPassedInValueNotASignal) { - // Input signals require an injection context to set initial values. - // Because of this, we cannot create them outside the scope of the component. - // Options for input signals also don't allow the passing of an injection contexts, so in order to work around this, - // we convert the non signal input passed into the input to a writable signal - convertedValueIfApplicable = signal(propValue) - } - - // If the component has the property defined as a ModelSignal, we need to detect whether a signal value or not was passed into the component as a prop. - // If a non signal property is passed into the component model (primitive, object, array, etc), we need to set the model to that value and propagate changes of that model through the output spy. - // Since the non signal type likely lives outside the context of Angular, the non signal type will NOT be updated outside of this context. Instead, the output spy will allow you - // to see this change. - // If the value passed into the property is in fact a signal, we need to set up two-way binding between the signals to make sure changes from one propagate to the other. - } else if (isComponentValueAModelSignal) { - const isPassedInValueLikelyARegularSignal = isWritableSignal(propValue) - - // if the value passed into the component is a signal, set up two-way binding - if (isPassedInValueLikelyARegularSignal) { - // update the passed in value with the models updates - componentValue.subscribe((value: any) => { - propValue.set(value) - }) - - // update the model signal with the properties updates - const convertedToObservable = toObservable(propValue, { - 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) => { - componentValue.set(value) - }), - ) - } else { - // it's a non signal type, set it as we only need to handle updating the model signal and emit changes on this through the output spy. - componentValue.set(propValue) - - convertedValueIfApplicable = componentValue - } - } - - return convertedValueIfApplicable -} - -// 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) - - // since spies do NOT make change handlers by default, similar to the Output() decorator, we need to create the spy and subscribe to the signal - if (!changeKeyIfExists && config.autoSpyOutputs) { - component[expectedChangeKey] = createOutputSpy(`${expectedChangeKey}Spy`) - changeKeyIfExists = true - } - - 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)) { - toObservable(componentValue, { - 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) { - // convert primitives to signals if passed in type is a primitive but expected type is signal - // a bit of magic. need to move to another function - Object.keys(component).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] - - const componentValue = component[key] - - // @ts-expect-error - config.componentProperties[key] = convertPropertyToSignalIfApplicable(passedInValue, componentValue, injector) - detectAndRegisterOutputSpyToSignal(config, component, key, injector) - } - }) - - component = Object.assign(component, config.componentProperties) - } - - if (config.autoSpyOutputs) { - Object.keys(component).forEach((key) => { - const property = component[key] - - if (property instanceof EventEmitter) { - component[key] = createOutputSpy(`${key}Spy`) - } - }) - } - - // 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) - } - } -} - -/** - * 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-signals' - * 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) - - activeFixture = setupFixture(componentFixture, config) - - const mountResponse: MountResponse = { - fixture: activeFixture, - component: activeFixture.componentInstance, - } - - const logMessage = typeof component === 'string' ? 'Component' : componentFixture.name - - Cypress.log({ - name: 'mount', - 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 - * @example - * import { StepperComponent } from './stepper.component' - * import { mount, createOutputSpy } from '@cypress/angular-signals' - * - * 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-signals/tsconfig.json b/npm/angular-signals/tsconfig.json deleted file mode 100644 index a73e01dcecc9..000000000000 --- a/npm/angular-signals/tsconfig.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "compilerOptions": { - "experimentalDecorators": true, - "target": "es2020", - "module": "es2020", - "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/package.json b/npm/angular/package.json index c7b1541fb207..a2b02d0cecf4 100644 --- a/npm/angular/package.json +++ b/npm/angular/package.json @@ -1,13 +1,14 @@ { "name": "@cypress/angular", "version": "0.0.0-development", - "description": "Test Angular Components using Cypress", + "description": "Test Angular Components with Cypress", "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": {}, @@ -16,14 +17,16 @@ "@angular/core": "^17.2.0", "@angular/platform-browser-dynamic": "^17.2.0", "@cypress/mount-utils": "0.0.0-development", + "rollup": "^4.24.4", "typescript": "~5.4.5", - "zone.js": "~0.11.4" + "zone.js": "~0.14.6" }, "peerDependencies": { "@angular/common": ">=17.2", "@angular/core": ">=17.2", "@angular/platform-browser-dynamic": ">=17.2", - "zone.js": ">=0.11.0" + "rxjs": ">=7.5.0", + "zone.js": ">=0.13.0" }, "files": [ "dist" @@ -44,6 +47,10 @@ "testing" ], "contributors": [ + { + "name": "Bill Glesias", + "social": "@atofstryker" + }, { "name": "Jordan Powell", "social": "@jordanpowell88" diff --git a/npm/angular/src/mount.ts b/npm/angular/src/mount.ts index 0c923456a1df..140b2215f248 100644 --- a/npm/angular/src/mount.ts +++ b/npm/angular/src/mount.ts @@ -8,7 +8,8 @@ window.Mocha['__zone_patch__'] = false import 'zone.js/testing' import { CommonModule } from '@angular/common' -import { Component, ErrorHandler, EventEmitter, Injectable, SimpleChange, SimpleChanges, Type, OnChanges } from '@angular/core' +import { Component, ErrorHandler, EventEmitter, Injectable, SimpleChange, SimpleChanges, Type, OnChanges, Injector, InputSignal, WritableSignal, signal } from '@angular/core' +import { toObservable } from '@angular/core/rxjs-interop' import { ComponentFixture, getTestBed, @@ -24,6 +25,7 @@ import { setupHooks, getContainerEl, } from '@cypress/mount-utils' +import type { Subscription } from 'rxjs' /** * Additional module configurations needed while mounting the component, like @@ -70,10 +72,12 @@ export interface MountConfig extends TestModuleMetadata { * cy.get('@mySpy').should('have.been.called') * }) */ - componentProperties?: Partial<{ [P in keyof T]: T[P] }> + // 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 @@ -86,8 +90,15 @@ function cleanup () { 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 = [] } /** @@ -254,6 +265,129 @@ function setupFixture ( 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 convertPropertyToSignalIfApplicable (propValue: any, componentValue: any, injector: Injector) { + const isComponentValueAnInputSignal = isInputSignal(componentValue) + const isComponentValueAModelSignal = isModelSignal(componentValue) + let convertedValueIfApplicable = propValue + + // If the component has the property defined as an InputSignal, we need to detect whether a non signal value or not was passed into the component as a prop + // and attempt to merge the value in correctly. + // We don't want to expose the primitive created signal as it should really be one-way binding from within the component. + // However, to make CT testing easier, a user can technically pass in a signal to an input component and assert on the signal itself and pass in updates + // down to the component as 1 way binding is supported by the test harness + if (isComponentValueAnInputSignal) { + const isPassedInValueNotASignal = !isSignal(propValue) + + if (isPassedInValueNotASignal) { + // Input signals require an injection context to set initial values. + // Because of this, we cannot create them outside the scope of the component. + // Options for input signals also don't allow the passing of an injection contexts, so in order to work around this, + // we convert the non signal input passed into the input to a writable signal + convertedValueIfApplicable = signal(propValue) + } + + // If the component has the property defined as a ModelSignal, we need to detect whether a signal value or not was passed into the component as a prop. + // If a non signal property is passed into the component model (primitive, object, array, etc), we need to set the model to that value and propagate changes of that model through the output spy. + // Since the non signal type likely lives outside the context of Angular, the non signal type will NOT be updated outside of this context. Instead, the output spy will allow you + // to see this change. + // If the value passed into the property is in fact a signal, we need to set up two-way binding between the signals to make sure changes from one propagate to the other. + } else if (isComponentValueAModelSignal) { + const isPassedInValueLikelyARegularSignal = isWritableSignal(propValue) + + // if the value passed into the component is a signal, set up two-way binding + if (isPassedInValueLikelyARegularSignal) { + // update the passed in value with the models updates + componentValue.subscribe((value: any) => { + propValue.set(value) + }) + + // update the model signal with the properties updates + const convertedToObservable = toObservable(propValue, { + 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) => { + componentValue.set(value) + }), + ) + } else { + // it's a non signal type, set it as we only need to handle updating the model signal and emit changes on this through the output spy. + componentValue.set(propValue) + + convertedValueIfApplicable = componentValue + } + } + + return convertedValueIfApplicable +} + +// 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) + + // since spies do NOT make change handlers by default, similar to the Output() decorator, we need to create the spy and subscribe to the signal + if (!changeKeyIfExists && config.autoSpyOutputs) { + component[expectedChangeKey] = createOutputSpy(`${expectedChangeKey}Spy`) + changeKeyIfExists = true + } + + 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)) { + toObservable(componentValue, { + injector, + }).subscribe((value) => { + component[expectedChangeKey]?.emit(value) + }) + } + } + } +} + /** * Gets the componentInstance and Object.assigns any componentProperties() passed in the MountConfig * @@ -266,8 +400,25 @@ function setupComponent ( fixture: ComponentFixture, ): void { let component = fixture.componentInstance as unknown as { [key: string]: any } & Partial + const injector = fixture.componentRef.injector if (config?.componentProperties) { + // convert primitives to signals if passed in type is a primitive but expected type is signal + // a bit of magic. need to move to another function + Object.keys(component).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] + + const componentValue = component[key] + + // @ts-expect-error + config.componentProperties[key] = convertPropertyToSignalIfApplicable(passedInValue, componentValue, injector) + detectAndRegisterOutputSpyToSignal(config, component, key, injector) + } + }) + component = Object.assign(component, config.componentProperties) } @@ -373,6 +524,15 @@ export function mount ( * 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() diff --git a/system-tests/projects/angular-signals/cypress/support/component.ts b/system-tests/projects/angular-signals/cypress/support/component.ts index 958e21d484f5..381375eef6a2 100644 --- a/system-tests/projects/angular-signals/cypress/support/component.ts +++ b/system-tests/projects/angular-signals/cypress/support/component.ts @@ -1,4 +1,4 @@ -import { mount } from 'cypress/angular-signals' +import { mount } from 'cypress/angular' // Augment the Cypress namespace to include type definitions for // your custom command. diff --git a/system-tests/projects/angular-signals/src/signals-complex/signals-complex.component.cy.ts b/system-tests/projects/angular-signals/src/signals-complex/signals-complex.component.cy.ts index 998098cd8a51..10cfb04d02d0 100644 --- a/system-tests/projects/angular-signals/src/signals-complex/signals-complex.component.cy.ts +++ b/system-tests/projects/angular-signals/src/signals-complex/signals-complex.component.cy.ts @@ -1,6 +1,6 @@ import { signal } from '@angular/core' import { SignalsComplexComponent } from './signals-complex.component' -import { createOutputSpy } from 'cypress/angular-signals' +import { createOutputSpy } from 'cypress/angular' import cloneDeep from 'lodash/cloneDeep' const user = { diff --git a/system-tests/projects/angular-signals/src/signals-optional/signals-optional.component.cy.ts b/system-tests/projects/angular-signals/src/signals-optional/signals-optional.component.cy.ts index 544b864db358..855040a5cda5 100644 --- a/system-tests/projects/angular-signals/src/signals-optional/signals-optional.component.cy.ts +++ b/system-tests/projects/angular-signals/src/signals-optional/signals-optional.component.cy.ts @@ -1,6 +1,6 @@ import { signal } from '@angular/core' import { SignalsOptionalComponent } from './signals-optional.component' -import { createOutputSpy } from 'cypress/angular-signals' +import { createOutputSpy } from 'cypress/angular' it('can handle default props', () => { cy.mount(SignalsOptionalComponent) diff --git a/system-tests/projects/angular-signals/src/signals-required/signals-required.component.cy.ts b/system-tests/projects/angular-signals/src/signals-required/signals-required.component.cy.ts index a1a47bd8d805..37949b419c06 100644 --- a/system-tests/projects/angular-signals/src/signals-required/signals-required.component.cy.ts +++ b/system-tests/projects/angular-signals/src/signals-required/signals-required.component.cy.ts @@ -1,6 +1,6 @@ import { signal, computed } from '@angular/core' import { SignalsRequiredComponent } from './signals-required.component' -import { createOutputSpy } from 'cypress/angular-signals' +import { createOutputSpy } from 'cypress/angular' // NOTE: if this is the only test in your test suite, this error will continually throw until the fixture is closed. it('errors on required props missing', (done) => { diff --git a/yarn.lock b/yarn.lock index 208e4f545d51..8cf19ce43de7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -27495,7 +27495,7 @@ rollup@^3.27.1: optionalDependencies: fsevents "~2.3.2" -rollup@^4.13.0, rollup@^4.20.0: +rollup@^4.13.0, rollup@^4.20.0, rollup@^4.24.4: version "4.24.4" resolved "https://registry.npmjs.org/rollup/-/rollup-4.24.4.tgz#fdc76918de02213c95447c9ffff5e35dddb1d058" integrity sha512-vGorVWIsWfX3xbcyAS+I047kFKapHYivmkaT63Smj77XwvLSJos6M1xGqZnBPFQFBRZDOcG1QnYEIxAvTr/HjA== @@ -33100,13 +33100,6 @@ zone.js@0.9.0: resolved "https://registry.yarnpkg.com/zone.js/-/zone.js-0.9.0.tgz#f42319d657f7616724ed40c5907d4614b4c683fa" integrity sha512-EfygvVnLxPSCMSgJ4h7SoY+XNr7ybdwvvwEQ70lvMFl9coNnciXSyWi8Kg6znK1ubyUSffkCKvleSQpLuUKw0Q== -zone.js@~0.11.4: - version "0.11.7" - resolved "https://registry.yarnpkg.com/zone.js/-/zone.js-0.11.7.tgz#262194267c7b964e8da77ce16b9fba9bea23cfdc" - integrity sha512-e39K2EdK5JfA3FDuUTVRvPlYV4aBfnOOcGuILhQAT7nzeV12uSrLBzImUM9CDVoncDSX4brR/gwqu0heQ3BQ0g== - dependencies: - tslib "^2.3.0" - zone.js@~0.14.6: version "0.14.6" resolved "https://registry.yarnpkg.com/zone.js/-/zone.js-0.14.6.tgz#9a74284f4812b807c97d942e78166cb2da8b5614"