Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: 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

Merged
merged 12 commits into from
Jun 28, 2024

Conversation

AtofStryker
Copy link
Contributor

@AtofStryker AtofStryker commented Jun 5, 2024

Additional details

Since signals introduced new methods/types to the API, we need to introduce a new test harness to Cypress in order to support signals. Right now we are calling it cypress/angular-signals. This will be readily available once this PR is merged in and cypress is released. In the future, we should be able to eventually merge this upstream into cypress/angular with the next major version of cypress when we remove/deprecate support for angular 13-16. In other words, the new harness need should not be permanent.

Documentation / Behavior

Typings issue

Mentioned in the issue is the following:

When attempting to set up a component test by mounting the Component directly, it fails to set the signal/model inputs through the componentProperties and gives incorrect type assertions on componentProperties.

This is because in our standard cypress/angular mount function, we expect a direct 1:1 mapping as the prop types

componentProperties?: Partial<{ [P in keyof T]: T[P] }>

In our new cypress/angular-signals harness, we need to make sure that a given generic, type T (or in the example given in the issue, a string) , can be inferred by an input(), signal(), or model() signal.

componentProperties?: Partial<{ [P in keyof T]: T[P] extends InputSignal<infer V> ? InputSignal<V> | WritableSignal<V> | V : T[P]}>

This way, specifying a string for an InputSignal<string> is a completely valid type.

Input Signal Behavior

Getting input() signals to work OOTB was a bit difficult, since we cannot create them outside of an angular context and there is no way to provide an injection context, even when setting an initial value. Because of this, Cypress handles input() signals when being injected into a component from the cypress context the following way:

  1. If a prop is an input() signal, but the value provided is a match of the generic type, we wrap the generic value in a writable signal() and merge that into the prop. In other words:
  cy.mount(TestComponentComponent, {
    componentProperties: {
      title: 'Test Component',
    },
  });

is really

  cy.mount(TestComponentComponent, {
    componentProperties: {
      title: signal('Test Component'),
    },
  });

This allows us to make a signal and avoid the ctx.title is not a function error mentioned in #29264 while allowing the passing of primitives into the component to work.

Change spy for inputs

Since the prop in question is an input(), we do not propagate changes to titleChange or related output spies. In other words, this will not work:

  cy.mount(TestComponentComponent, {
    componentProperties: {
      title: 'Test Component',
      // @ts-expect-error
      titleChange: createOutputSpy('titleChange')
    },
  });

  // some action that changes the title...

  // this assertion will NEVER be true, even if you make `title` a signal. we will NEVER emit change events for props that are type `input()`
  cy.get('@titleChange').should('have.been.called');

Which brings up the question, how can I change or assert on input updates/changes?

  1. If a prop is an input() signal, but the value provided is a match of the generic type wrapped in a signal, we merge the value as is to allow for one-way data binding (more on this below).

Since input() in our case can also take a WritableSignal, we can just pass a signal() as a variable reference into the component and mutate the signal directly. This gives us the one-way binding we need to test input() signals outside the context of Angular.

  const myTitleSignal = signal('Test Component')
  cy.mount(TestComponentComponent, {
    componentProperties: {
      title: myTitleSignal
    },
  });

cy.get('the-title-element').should('have.text', 'Test Component')
cy.then(() => {
   // now set the input() through a signal to update the one-way binding
   myTitleSignal.set('FooBar')
})
// works
cy.get('the-title-element').should('have.text', 'FooBar')

Model Signal Behavior

Since model() signals are writable signals, they have the ability to support two-way data binding. This means Cypress handles model() signals the following way:

  1. If a prop is an modal() signal, but the value provided is a match of the generic type, we set the generic value in a model() and merge that into the prop. In other words:
  cy.mount(TestComponentComponent, {
    componentProperties: {
      count: 1
    },
  });

cy.get('the-count-element').should('have.text', '1')
Change spy for models

Since the prop in question is an model() and is a WritableSignal, we WILL propagate changes to countChange if the output spy is created, either like the example below or if autoSpyOutputs: true is configured. However, the typing for countChange is not supported since this is technically not a prop (@Output() actually adds this as a prop which is not true in our case, even though the signal output is completely valid).

  cy.mount(TestComponentComponent, {
    componentProperties: {
      count: 4,
      // @ts-expect-error
      countChange: createOutputSpy('countChange')
    },
  });

  // some action that changes the count...

  // this assertion will be true
  cy.get('@countChange').should('have.been.called');

However, since count is a primitive in this case and outside the angular context, we CANNOT support two-way data-binding to this variable. In other words, these will not work:

  let count = 5
  cy.mount(TestComponentComponent, {
    componentProperties: {
      count,
      // @ts-expect-error
      countChange: createOutputSpy('countChange')
    },
  });

  // some action that changes the count to 7 inside the component

  cy.then(() => {
    // this assertion will never be true. Count will be 5 
    expect(count).to.be(7)
  })
  // However, the change spy WILL be called since the change occurred inside the component
  let count = 5
  cy.mount(TestComponentComponent, {
    componentProperties: {
      count,
      // @ts-expect-error
      countChange: createOutputSpy('countChange')
    },
  });

  cy.then(() => {
   count = 8
  })
   // this assertion will never be true. Count will be 5 
  cy.get('the-count-element`).should('have.text`, '8')
   // the change spy will also NOT be called

Which brings up the question, how can I have two-way data-binding set up?

  1. If a prop is an model() signal, but the value provided is a match of the generic type wrapped in a signal, we set the initial value and set up two-way data binding (more on this below). This means you can achieve two way data-binding in your component tests like such:
  let count = signal(5)
  cy.mount(TestComponentComponent, {
    componentProperties: {
      count
    },
  });

  // some action that changes the count to 7 inside the component

  cy.then(() => {
    // this assertion will be true
    expect(count()).to.be(7)
  })
  // Additionally, if registered, the change spy will also be called
  let count = signal(5)
  cy.mount(TestComponentComponent, {
    componentProperties: {
      count
    },
  });

  cy.then(() => {
   count.set(8)
  })
  // this assertion will be true
  cy.get('the-count-element').should('have.text', '8')
  // the change spy will also be called

  // later in the test , some action that changes the count to 14 inside the component
  // this assertion will be true
  cy.get('the-count-element').should('have.text', '14')

Steps to test

How has the user experience changed?

PR Tasks

Copy link

cypress bot commented Jun 5, 2024

Passing run #55968 ↗︎

0 130 0 0 Flakiness 0

Details:

Merge branch 'develop' into angular-signals-ct-harness
Project: cypress Commit: 8a8543211f
Status: Passed Duration: 17:35 💡
Started: Jun 28, 2024 2:55 PM Ended: Jun 28, 2024 3:12 PM

Review all test suite changes for PR #29621 ↗︎

@AtofStryker AtofStryker force-pushed the angular-signals-ct-harness branch 3 times, most recently from ace8fa2 to 30d95ad Compare June 5, 2024 18:49
Base automatically changed from feat/support_angular_18 to develop June 7, 2024 16:19
add changelog entry and build binary [run ci]

rename angular18 to angular-signals until we are able to merge back into core package [run ci]

fix linting job [run ci]

make sure angular-signals harness is copied to cli after build [run ci]

add project fixture directory to angular 18 and build binaries for newly named branch

run ci

update cache [run ci]

bust nx cache [run ci]

bust cache on linux [run ci]

try busting the cache... again [run ci]

usually helps when you have the correct build output... [run ci]

fix issue where component internal props were getting blown away when user would not set prop in componentProperties [run ci]
@AtofStryker AtofStryker requested a review from mschile June 19, 2024 15:38
Copy link
Contributor

@jordanpowell88 jordanpowell88 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to confirm a few of the items I left comments on before merging

npm/angular-signals/package.json Outdated Show resolved Hide resolved
<p data-cy="firstName"> {{ acquaintance.firstName }} </p>
<p data-cy="lastName"> {{ acquaintance.lastName }} </p>
<p data-cy="age"> {{ acquaintance.age }} </p>
</li>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Angular also introduced something called Control Flow that we should use moving forward. Angular's compiler handles this for us BUT it would be always ideal to validate its working correctly in our newest Angular CT libs.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can add a system test to validate

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added in 3f93d59

effect(() => {
// there is a bug in Angular 17 that doesn't rerender the signal when set outside the component context
// this is resolved in Angular 18. adding an effect() causes the template to be update when the signal is updated
console.log(`The user is: ${JSON.stringify(this.user())}`)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you wanting to keep these console.logs in here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in this case yes, since it references the signal it forces a recalculation of the value and therefor a repaint. We don't need it in angular 18, but in 17 there is a bug where the signal being updated does not cause a repaint

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm assuming it is possible to spy on the output() without using createOutputSpy? We should add a test around this scenario at least once

cy.mount(SignalsOptionalComponent, {
    componentProperties: {
      title: 'Prop Title',
      count: 7,
      // @ts-expect-error
      countChange: cy.spy().as('countChange')
    },

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for signal()s you need to create the output spy since we need to subscribe to the signal itself to propagate the event. The output isn't captured automatically unfortunately

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be great to test both scenarios (to and from) using the RxJs Interop with signals. Though it is in Developer Preview it is a key piece for most Angular devs.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should be OK from this perspective since we are passing a raw signal down. The interop package can't be used outside of the angular injection context, so I don't think this is going to be a popular use case at least within the cypress spec file

@@ -0,0 +1,536 @@
import 'zone.js'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should do some validation around if including this is still necessary (I think it is). But we should also try to validate if mount works in zoneless

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried to import this from core but couldn't find it in @angular/core. Looks like it might have been renamed to ɵprovideZonelessChangeDetection? Either way gave it a test here and seems to work ab8f852

* @memberof MountConfig
* @description flag defaulted to true to automatically detect changes in your components
*/
autoDetectChanges?: boolean
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We probably don't need this anymore with signals

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we still need it for the fixture update from what I can tell

// update the model signal with the properties updates
toObservable(propValue, {
injector,
}).subscribe((value) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should validate their isn't a memory leak here and that we don't need to unsubscribe (or that it is unsubscribed automatically when we teardown before each test)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was a memory leak. The observable by the looks of it hangs around because the subscription is active. To fix this, I pushed the subscription into a list to clean up when we clean up the fixture. Updated and fixed in 12c7e36. Really good catch!

<body>
<div data-cy-root></div>
</body>
</html>
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
</html>
</html>

cy.get('[data-cy="signals-required-component-title-display"]').should('contain.text', 'Signals Component as Primitive')
})

// FIXME: we currently need to allow this as there isn't a great way to set input signals in a component due to how input signals are instantiated
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we have an issue logged for this work?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logged #29732

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added a comment too in 001813e

}
}

// currently not a great way to detect if a function is an InputSignal. When we discover a better way to detect a signal we should incorporate it here.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want to log an issue to track on finding better ways to determine this stuff?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We probably should. I will create one!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logged #29731

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also updated the comments in 001813e

Copy link
Contributor

@jordanpowell88 jordanpowell88 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR looks good to me. Thanks for putting this together @AtofStryker

Copy link
Collaborator

@ryanthemanuel ryanthemanuel left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work @AtofStryker!

@AtofStryker AtofStryker merged commit f2554f1 into develop Jun 28, 2024
121 of 129 checks passed
@AtofStryker AtofStryker deleted the angular-signals-ct-harness branch June 28, 2024 17:37
@cypress-bot
Copy link
Contributor

cypress-bot bot commented Jul 1, 2024

Released in 13.13.0.

This comment thread has been locked. If you are still experiencing this issue after upgrading to
Cypress v13.13.0, please open a new issue.

@cypress-bot cypress-bot bot locked as resolved and limited conversation to collaborators Jul 1, 2024
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Setting input signals with Angular component tests not supported
4 participants