refactor(core): Add four tests and fix code to make tests pass. (#55747)

The first test asserts that bubbling does not work right now.

The second asserts that stopPropagation works, which should pass when test #1 passes too.

The third test asserts properties about the events passed to the event handler.

THe fourth test asserts that mouse events do not translate to jsaction nor help emit the jsaction binary. This required a change in code to make this pass.

PR Close #55747
This commit is contained in:
Thomas Nguyen
2024-05-09 17:59:56 -07:00
committed by Andrew Scott
parent ef029b8d00
commit 629b255ddb
3 changed files with 325 additions and 224 deletions
@@ -142,6 +142,14 @@ export function collectDomEventsInfo(
continue;
}
const name: string = firstParam;
if (
name === 'mouseenter' ||
name === 'mouseleave' ||
name === 'pointerenter' ||
name === 'pointerleave'
) {
continue;
}
eventTypesToReplay.add(name);
const listenerElement = unwrapRNode(lView[secondParam]) as any as Element;
i++; // move the cursor to the next position (location of the listener idx)
-8
View File
@@ -145,14 +145,6 @@ function insertEventRecordScript(
const captureEventTypes = [];
const eventTypes = [];
for (const eventType of events) {
if (
eventType === 'mouseenter' ||
eventType === 'mouseleave' ||
eventType === 'pointerenter' ||
eventType === 'pointerleave'
) {
continue;
}
if (
eventType === 'focus' ||
eventType === 'blur' ||
+317 -216
View File
@@ -25,6 +25,7 @@ import {getAppContents, hydrate, render as renderHtml, resetTViewsFor} from './d
* event dispatch (JSAction) logic.
*/
const EVENT_DISPATCH_SCRIPT = `<script type="text/javascript" id="${EVENT_DISPATCH_SCRIPT_ID}"></script>`;
const DEFAULT_DOCUMENT = `<html><head></head><body>${EVENT_DISPATCH_SCRIPT}<app></app></body></html>`;
/** Checks whether event dispatch script is present in the generated HTML */
function hasEventDispatchScript(content: string) {
@@ -37,88 +38,78 @@ function hasJSActionAttrs(content: string) {
}
describe('event replay', () => {
let doc: Document;
const originalDocument = globalThis.document;
const originalWindow = globalThis.window;
beforeAll(async () => {
globalThis.window = globalThis as unknown as Window & typeof globalThis;
await import('@angular/core/primitives/event-dispatch/contract_bundle_min.js' as string);
});
beforeEach(() => {
if (getPlatform()) destroyPlatform();
doc = TestBed.inject(DOCUMENT);
});
afterAll(() => {
globalThis.window = originalWindow;
globalThis.document = originalDocument;
destroyPlatform();
});
describe('event replay', () => {
/**
* This renders the application with server side rendering logic.
*
* @param component the test component to be rendered
* @param doc the document
* @param envProviders the environment providers
* @returns a promise containing the server rendered app as a string
*/
async function ssr(
component: Type<unknown>,
options: {doc?: string; enableEventReplay?: boolean; hydrationDisabled?: boolean} = {},
): Promise<string> {
const {
enableEventReplay = true,
hydrationDisabled,
doc = `<html><head></head><body>${EVENT_DISPATCH_SCRIPT}<app></app></body></html>`,
} = options;
afterEach(() => {
doc.body.textContent = '';
});
const hydrationProviders = hydrationDisabled
? []
: enableEventReplay
? provideClientHydration(withEventReplay())
: provideClientHydration();
/**
* This renders the application with server side rendering logic.
*
* @param component the test component to be rendered
* @param doc the document
* @param envProviders the environment providers
* @returns a promise containing the server rendered app as a string
*/
async function ssr(
component: Type<unknown>,
options: {doc?: string; enableEventReplay?: boolean; hydrationDisabled?: boolean} = {},
): Promise<string> {
const {enableEventReplay = true, hydrationDisabled, doc = DEFAULT_DOCUMENT} = options;
const bootstrap = () =>
bootstrapApplication(component, {
providers: [provideServerRendering(), hydrationProviders],
});
const hydrationProviders = hydrationDisabled
? []
: enableEventReplay
? provideClientHydration(withEventReplay())
: provideClientHydration();
return renderApplication(bootstrap, {
document: doc,
const bootstrap = () =>
bootstrapApplication(component, {
providers: [provideServerRendering(), hydrationProviders],
});
}
describe('server rendering', () => {
let doc: Document;
const originalDocument = globalThis.document;
const originalWindow = globalThis.window;
return renderApplication(bootstrap, {
document: doc,
});
}
function render(doc: Document, html: string) {
renderHtml(doc, html);
globalThis.document = doc;
const scripts = doc.getElementsByTagName('script');
for (const script of Array.from(scripts)) {
if (script?.textContent?.startsWith('window.__jsaction_bootstrap')) {
eval(script.textContent);
}
}
function render(doc: Document, html: string) {
renderHtml(doc, html);
globalThis.document = doc;
const scripts = doc.getElementsByTagName('script');
for (const script of Array.from(scripts)) {
if (script?.textContent?.startsWith('window.__jsaction_bootstrap')) {
eval(script.textContent);
}
}
}
beforeAll(async () => {
globalThis.window = globalThis as unknown as Window & typeof globalThis;
await import('@angular/core/primitives/event-dispatch/contract_bundle_min.js' as string);
});
beforeEach(() => {
doc = TestBed.inject(DOCUMENT);
});
afterEach(() => {
doc.body.textContent = '';
});
afterAll(() => {
globalThis.window = originalWindow;
globalThis.document = originalDocument;
});
it('should serialize event types to be listened to and jsaction attribute', async () => {
const clickSpy = jasmine.createSpy('onClick');
const focusSpy = jasmine.createSpy('onFocus');
@Component({
standalone: true,
selector: 'app',
template: `
it('should serialize event types to be listened to and jsaction attribute', async () => {
const clickSpy = jasmine.createSpy('onClick');
const focusSpy = jasmine.createSpy('onFocus');
@Component({
standalone: true,
selector: 'app',
template: `
<div (click)="onClick()" id="click-element">
<div id="focus-container">
<div id="focus-action-element" (focus)="onFocus()">
@@ -127,172 +118,282 @@ describe('event replay', () => {
</div>
</div>
`,
})
class SimpleComponent {
onClick = clickSpy;
onFocus = focusSpy;
}
})
class SimpleComponent {
onClick = clickSpy;
onFocus = focusSpy;
}
const html = await ssr(SimpleComponent);
const ssrContents = getAppContents(html);
const docContents = `<html><head></head><body>${EVENT_DISPATCH_SCRIPT}<app></app></body></html>`;
const html = await ssr(SimpleComponent, {doc: docContents});
const ssrContents = getAppContents(html);
expect(ssrContents).toContain(
`<script>window.__jsaction_bootstrap('ngContracts', document.body, "ng", ["click"],["focus"]);</script>`,
);
render(doc, ssrContents);
const el = doc.getElementById('click-element')!;
const button = doc.getElementById('focus-target-element')!;
const clickEvent = new CustomEvent('click', {bubbles: true});
el.dispatchEvent(clickEvent);
const focusEvent = new CustomEvent('focus');
button.dispatchEvent(focusEvent);
expect(clickSpy).not.toHaveBeenCalled();
expect(focusSpy).not.toHaveBeenCalled();
resetTViewsFor(SimpleComponent);
const appRef = await hydrate(doc, SimpleComponent, {
hydrationFeatures: [withEventReplay()],
});
appRef.tick();
expect(clickSpy).toHaveBeenCalled();
expect(focusSpy).toHaveBeenCalled();
});
render(doc, ssrContents);
const el = doc.getElementById('click-element')!;
const button = doc.getElementById('focus-target-element')!;
const clickEvent = new CustomEvent('click', {bubbles: true});
el.dispatchEvent(clickEvent);
const focusEvent = new CustomEvent('focus');
button.dispatchEvent(focusEvent);
expect(clickSpy).not.toHaveBeenCalled();
expect(focusSpy).not.toHaveBeenCalled();
resetTViewsFor(SimpleComponent);
const appRef = await hydrate(doc, SimpleComponent, {
hydrationFeatures: [withEventReplay()],
});
appRef.tick();
expect(clickSpy).toHaveBeenCalled();
expect(focusSpy).toHaveBeenCalled();
});
it('should remove jsaction attributes, but continue listening to events.', async () => {
@Component({
standalone: true,
selector: 'app',
template: `
it('should remove jsaction attributes, but continue listening to events.', async () => {
@Component({
standalone: true,
selector: 'app',
template: `
<div (click)="onClick()" id="1">
<div (click)="onClick()" id="2"></div>
</div>
`,
})
class SimpleComponent {
onClick() {}
}
})
class SimpleComponent {
onClick() {}
}
const docContents = `<html><head></head><body>${EVENT_DISPATCH_SCRIPT}<app></app></body></html>`;
const html = await ssr(SimpleComponent, {doc: docContents});
const ssrContents = getAppContents(html);
render(doc, ssrContents);
const el = doc.getElementById('1')!;
expect(el.hasAttribute('jsaction')).toBeTrue();
expect((el.firstChild as Element).hasAttribute('jsaction')).toBeTrue();
resetTViewsFor(SimpleComponent);
const appRef = await hydrate(doc, SimpleComponent, {
hydrationFeatures: [withEventReplay()],
});
appRef.tick();
expect(el.hasAttribute('jsaction')).toBeFalse();
expect((el.firstChild as Element).hasAttribute('jsaction')).toBeFalse();
});
const docContents = `<html><head></head><body>${EVENT_DISPATCH_SCRIPT}<app></app></body></html>`;
const html = await ssr(SimpleComponent, {doc: docContents});
const ssrContents = getAppContents(html);
render(doc, ssrContents);
const el = doc.getElementById('1')!;
expect(el.hasAttribute('jsaction')).toBeTrue();
expect((el.firstChild as Element).hasAttribute('jsaction')).toBeTrue();
resetTViewsFor(SimpleComponent);
const appRef = await hydrate(doc, SimpleComponent, {
hydrationFeatures: [withEventReplay()],
});
appRef.tick();
expect(el.hasAttribute('jsaction')).toBeFalse();
expect((el.firstChild as Element).hasAttribute('jsaction')).toBeFalse();
});
it(`should add 'nonce' attribute to event record script when 'ngCspNonce' is provided`, async () => {
@Component({
standalone: true,
selector: 'app',
template: `
it(`should add 'nonce' attribute to event record script when 'ngCspNonce' is provided`, async () => {
@Component({
standalone: true,
selector: 'app',
template: `
<div (click)="onClick()">
<div (blur)="onClick()"></div>
</div>
`,
})
class SimpleComponent {
onClick() {}
})
class SimpleComponent {
onClick() {}
}
const doc =
`<html><head></head><body>${EVENT_DISPATCH_SCRIPT}` +
`<app ngCspNonce="{{nonce}}"></app></body></html>`;
const html = await ssr(SimpleComponent, {doc});
expect(getAppContents(html)).toContain('<script nonce="{{nonce}}">window.__jsaction_bootstrap');
});
describe('bubbling behavior', () => {
it('should propagate events', async () => {
const onClickSpy = jasmine.createSpy();
@Component({
standalone: true,
selector: 'app',
template: `
<div id="top" (click)="onClick()">
<div id="bottom" (click)="onClick()"></div>
</div>
`,
})
class SimpleComponent {
onClick = onClickSpy;
}
const docContents = `<html><head></head><body>${EVENT_DISPATCH_SCRIPT}<app></app></body></html>`;
const html = await ssr(SimpleComponent, {doc: docContents});
const ssrContents = getAppContents(html);
render(doc, ssrContents);
resetTViewsFor(SimpleComponent);
const bottomEl = doc.getElementById('bottom')!;
bottomEl.click();
const appRef = await hydrate(doc, SimpleComponent, {
hydrationFeatures: [withEventReplay()],
});
appRef.tick();
// This is a bug
expect(onClickSpy).toHaveBeenCalledTimes(1);
onClickSpy.calls.reset();
bottomEl.click();
expect(onClickSpy).toHaveBeenCalledTimes(2);
});
it('should not propagate events if stopPropagation is called', async () => {
@Component({
standalone: true,
selector: 'app',
template: `
<div id="top" (click)="onClick($event)">
<div id="bottom" (click)="onClick($event)"></div>
</div>
`,
})
class SimpleComponent {
onClick(e: Event) {
e.stopPropagation();
}
const doc =
`<html><head></head><body>${EVENT_DISPATCH_SCRIPT}` +
`<app ngCspNonce="{{nonce}}"></app></body></html>`;
const html = await ssr(SimpleComponent, {doc});
expect(getAppContents(html)).toContain(
'<script nonce="{{nonce}}">window.__jsaction_bootstrap',
);
}
const onClickSpy = spyOn(SimpleComponent.prototype, 'onClick').and.callThrough();
const docContents = `<html><head></head><body>${EVENT_DISPATCH_SCRIPT}<app></app></body></html>`;
const html = await ssr(SimpleComponent, {doc: docContents});
const ssrContents = getAppContents(html);
render(doc, ssrContents);
resetTViewsFor(SimpleComponent);
const bottomEl = doc.getElementById('bottom')!;
bottomEl.click();
const appRef = await hydrate(doc, SimpleComponent, {
hydrationFeatures: [withEventReplay()],
});
appRef.tick();
expect(onClickSpy).toHaveBeenCalledTimes(1);
onClickSpy.calls.reset();
bottomEl.click();
expect(onClickSpy).toHaveBeenCalledTimes(1);
});
describe('event dispatch script', () => {
it('should not be present on a page when hydration is disabled', async () => {
@Component({
standalone: true,
selector: 'app',
template: '<input (click)="onClick()" />',
})
class SimpleComponent {
onClick() {}
}
const doc = `<html><head></head><body>${EVENT_DISPATCH_SCRIPT}<app></app></body></html>`;
const html = await ssr(SimpleComponent, {doc, hydrationDisabled: true});
const ssrContents = getAppContents(html);
expect(hasJSActionAttrs(ssrContents)).toBeFalse();
expect(hasEventDispatchScript(ssrContents)).toBeFalse();
});
it('should not be present on a page if there are no events to replay', async () => {
@Component({
standalone: true,
selector: 'app',
template: 'Some text',
})
class SimpleComponent {}
const doc = `<html><head></head><body>${EVENT_DISPATCH_SCRIPT}<app></app></body></html>`;
const html = await ssr(SimpleComponent, {doc});
const ssrContents = getAppContents(html);
expect(hasJSActionAttrs(ssrContents)).toBeFalse();
expect(hasEventDispatchScript(ssrContents)).toBeFalse();
});
it('should not be present on a page where event replay is not enabled', async () => {
@Component({
standalone: true,
selector: 'app',
template: '<input (click)="onClick()" />',
})
class SimpleComponent {
onClick() {}
}
const doc = `<html><head></head><body>${EVENT_DISPATCH_SCRIPT}<app></app></body></html>`;
const html = await ssr(SimpleComponent, {doc, enableEventReplay: false});
const ssrContents = getAppContents(html);
// Expect that there are no JSAction artifacts in the HTML
// (even though there are events in a template), since event
// replay is disabled in the config.
expect(hasJSActionAttrs(ssrContents)).toBeFalse();
expect(hasEventDispatchScript(ssrContents)).toBeFalse();
});
it('should be retained if there are events to replay', async () => {
@Component({
standalone: true,
selector: 'app',
template: '<input (click)="onClick()" />',
})
class SimpleComponent {
onClick() {}
}
const doc = `<html><head></head><body>${EVENT_DISPATCH_SCRIPT}<app></app></body></html>`;
const html = await ssr(SimpleComponent, {doc});
const ssrContents = getAppContents(html);
expect(hasJSActionAttrs(ssrContents)).toBeTrue();
expect(hasEventDispatchScript(ssrContents)).toBeTrue();
// Verify that inlined event delegation script goes first and
// event contract setup goes second (since it uses some code from
// the inlined script).
expect(ssrContents).toContain(
`<script type="text/javascript" id="ng-event-dispatch-contract"></script>` +
`<script>window.__jsaction_bootstrap('ngContracts', document.body, "ng", ["click"]);</script>`,
);
});
it('should not have differences in event fields', async () => {
let currentEvent!: Event;
@Component({
standalone: true,
selector: 'app',
template: `
<div id="top" (click)="onClick($event)">
<div id="bottom" (click)="onClick($event)"></div>
</div>
`,
})
class SimpleComponent {
onClick(event: Event) {
currentEvent = event;
}
}
const docContents = `<html><head></head><body>${EVENT_DISPATCH_SCRIPT}<app></app></body></html>`;
const html = await ssr(SimpleComponent, {doc: docContents});
const ssrContents = getAppContents(html);
render(doc, ssrContents);
resetTViewsFor(SimpleComponent);
const bottomEl = doc.getElementById('bottom')!;
bottomEl.click();
const appRef = await hydrate(doc, SimpleComponent, {
hydrationFeatures: [withEventReplay()],
});
appRef.tick();
const replayedEvent = currentEvent;
bottomEl.click();
appRef.tick();
const normalEvent = currentEvent;
expect(replayedEvent).not.toBe(normalEvent);
expect(replayedEvent.target).toBe(normalEvent.target);
expect(replayedEvent.currentTarget).toBe(normalEvent.currentTarget);
expect(replayedEvent.composedPath).toBe(normalEvent.composedPath);
expect(replayedEvent.eventPhase).toBe(normalEvent.eventPhase);
});
});
describe('event dispatch script', () => {
it('should not be present on a page when hydration is disabled', async () => {
@Component({
standalone: true,
selector: 'app',
template: '<input (click)="onClick()" />',
})
class SimpleComponent {
onClick() {}
}
const doc = `<html><head></head><body>${EVENT_DISPATCH_SCRIPT}<app></app></body></html>`;
const html = await ssr(SimpleComponent, {doc, hydrationDisabled: true});
const ssrContents = getAppContents(html);
expect(hasJSActionAttrs(ssrContents)).toBeFalse();
expect(hasEventDispatchScript(ssrContents)).toBeFalse();
});
it('should not be present on a page if there are no events to replay', async () => {
@Component({
standalone: true,
selector: 'app',
template: 'Some text',
})
class SimpleComponent {}
const html = await ssr(SimpleComponent);
const ssrContents = getAppContents(html);
expect(hasJSActionAttrs(ssrContents)).toBeFalse();
expect(hasEventDispatchScript(ssrContents)).toBeFalse();
});
it('should not replay mouse events', async () => {
@Component({
standalone: true,
selector: 'app',
template: '<div (mouseenter)="doThing()"><div>',
})
class SimpleComponent {
doThing() {}
}
const html = await ssr(SimpleComponent);
const ssrContents = getAppContents(html);
expect(hasJSActionAttrs(ssrContents)).toBeFalse();
expect(hasEventDispatchScript(ssrContents)).toBeFalse();
});
it('should not be present on a page where event replay is not enabled', async () => {
@Component({
standalone: true,
selector: 'app',
template: '<input (click)="onClick()" />',
})
class SimpleComponent {
onClick() {}
}
const html = await ssr(SimpleComponent, {enableEventReplay: false});
const ssrContents = getAppContents(html);
// Expect that there are no JSAction artifacts in the HTML
// (even though there are events in a template), since event
// replay is disabled in the config.
expect(hasJSActionAttrs(ssrContents)).toBeFalse();
expect(hasEventDispatchScript(ssrContents)).toBeFalse();
});
it('should be retained if there are events to replay', async () => {
@Component({
standalone: true,
selector: 'app',
template: '<input (click)="onClick()" />',
})
class SimpleComponent {
onClick() {}
}
const html = await ssr(SimpleComponent);
const ssrContents = getAppContents(html);
expect(hasJSActionAttrs(ssrContents)).toBeTrue();
expect(hasEventDispatchScript(ssrContents)).toBeTrue();
// Verify that inlined event delegation script goes first and
// event contract setup goes second (since it uses some code from
// the inlined script).
expect(ssrContents).toContain(
`<script type="text/javascript" id="ng-event-dispatch-contract"></script>` +
`<script>window.__jsaction_bootstrap('ngContracts', document.body, "ng", ["click"]);</script>`,
);
});
});
});