diff --git a/packages/playwright-ct-angular/registerSource.mjs b/packages/playwright-ct-angular/registerSource.mjs index e23c99c7f85a2..997be15e4dd2d 100644 --- a/packages/playwright-ct-angular/registerSource.mjs +++ b/packages/playwright-ct-angular/registerSource.mjs @@ -18,9 +18,15 @@ // This file is injected into the registry as text, no dependencies are allowed. import 'zone.js'; +import { + Component as defineComponent, + reflectComponentType +} from '@angular/core'; import { getTestBed, TestBed } from '@angular/core/testing'; -import { BrowserDynamicTestingModule, platformBrowserDynamicTesting } from '@angular/platform-browser-dynamic/testing'; -import { EventEmitter, reflectComponentType, Component as defineComponent } from '@angular/core'; +import { + BrowserDynamicTestingModule, + platformBrowserDynamicTesting, +} from '@angular/platform-browser-dynamic/testing'; import { Router } from '@angular/router'; /** @typedef {import('@playwright/experimental-ct-core/types/component').Component} Component */ @@ -34,6 +40,8 @@ const __pwLoaderRegistry = new Map(); const __pwRegistry = new Map(); /** @type {Map} */ const __pwFixtureRegistry = new Map(); +/** @type {WeakMap>} */ +const __pwOutputSubscriptionRegistry = new WeakMap(); getTestBed().initTestEnvironment( BrowserDynamicTestingModule, @@ -96,12 +104,22 @@ function __pwUpdateProps(fixture, props = {}) { * @param {import('@angular/core/testing').ComponentFixture} fixture */ function __pwUpdateEvents(fixture, events = {}) { - for (const [name, value] of Object.entries(events)) { - fixture.debugElement.children[0].componentInstance[name] = { - ...new EventEmitter(), - emit: event => value(event) - }; + const outputSubscriptionRecord = + __pwOutputSubscriptionRegistry.get(fixture) ?? {}; + for (const [name, listener] of Object.entries(events)) { + /* Unsubscribe previous listener. */ + outputSubscriptionRecord[name]?.unsubscribe(); + + const subscription = fixture.debugElement.children[0].componentInstance[ + name + ].subscribe((event) => listener(event)); + + /* Store new subscription. */ + outputSubscriptionRecord[name] = subscription; } + + /* Update output subscription registry. */ + __pwOutputSubscriptionRegistry.set(fixture, outputSubscriptionRecord); } function __pwUpdateSlots(Component, slots = {}, tagName) { @@ -211,8 +229,12 @@ window.playwrightMount = async (component, rootElement, hooksConfig) => { window.playwrightUnmount = async rootElement => { const fixture = __pwFixtureRegistry.get(rootElement.id); - if (!fixture) - throw new Error('Component was not mounted'); + if (!fixture) throw new Error('Component was not mounted'); + + /* Unsubscribe from all outputs. */ + for (const subscription of Object.values(__pwOutputSubscriptionRegistry.get(fixture) ?? {})) + subscription?.unsubscribe(); + __pwOutputSubscriptionRegistry.delete(fixture); fixture.destroy(); fixture.nativeElement.replaceChildren(); diff --git a/tests/components/ct-angular/src/components/output.component.ts b/tests/components/ct-angular/src/components/output.component.ts new file mode 100644 index 0000000000000..5aaacb95a43a7 --- /dev/null +++ b/tests/components/ct-angular/src/components/output.component.ts @@ -0,0 +1,17 @@ +import { DOCUMENT } from "@angular/common"; +import { Component, Output, inject } from "@angular/core"; +import { Subject, finalize } from "rxjs"; + +@Component({ + standalone: true, + template: `OutputComponent`, +}) +export class OutputComponent { + @Output() answerChange = new Subject().pipe( + /* Detect when observable is unsubscribed from, + * and set a global variable `hasUnsubscribed` to true. */ + finalize(() => ((this._window as any).hasUnsubscribed = true)) + ); + + private _window = inject(DOCUMENT).defaultView; +} diff --git a/tests/components/ct-angular/tests/events.spec.ts b/tests/components/ct-angular/tests/events.spec.ts index a4f67d72026e3..b11194ab9084e 100644 --- a/tests/components/ct-angular/tests/events.spec.ts +++ b/tests/components/ct-angular/tests/events.spec.ts @@ -1,5 +1,6 @@ import { test, expect } from '@playwright/experimental-ct-angular'; import { ButtonComponent } from '@/components/button.component'; +import { OutputComponent } from '@/components/output.component'; test('emit an submit event when the button is clicked', async ({ mount }) => { const messages: string[] = []; @@ -14,3 +15,49 @@ test('emit an submit event when the button is clicked', async ({ mount }) => { await component.click(); expect(messages).toEqual(['hello']); }); + +test('replace existing listener when new listener is set', async ({ + mount, +}) => { + let count = 0; + + const component = await mount(ButtonComponent, { + props: { + title: 'Submit', + }, + on: { + submit() { + count++; + }, + }, + }); + + component.update({ + on: { + submit() { + count++; + }, + }, + }); + + await component.click(); + expect(count).toBe(1); +}); + +test('unsubscribe from events when the component is unmounted', async ({ + mount, + page, +}) => { + const component = await mount(OutputComponent, { + on: { + answerChange() {}, + }, + }); + + await component.unmount(); + + /* Check that the output observable had been unsubscribed from + * as it sets a global variable `hasUnusbscribed` to true + * when it detects unsubscription. Cf. OutputComponent. */ + expect(await page.evaluate(() => (window as any).hasUnsubscribed)).toBe(true); +});