From ca57ddbc718c0c27cf04e2bef1ac1c5e41ed650d Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Thu, 20 Jul 2023 11:53:16 +0300 Subject: [PATCH] fix(cdk/testing): account for preventDefault in keyboard events Currently we try to mimic the user typing in the `typeInElement` utility by incrementally setting the value and dispatching the same sequence of events. The problem is that we weren't accounting for `preventDefault` which can block some keys from being assigned and some events from firing. This leads to inconsistencies between tests and the sequence of events triggered by a user. It is especially noticeable in components like the chip input where some keys act as separators. These changes update the logic to take `preventDefault` into account and try to mimic the native event sequence as closely as possible. Fixes #27475. --- .../testbed/fake-events/type-in-element.ts | 40 +++++++---- .../testing/tests/cross-environment.spec.ts | 69 ++++++++++++++++++- .../tests/harnesses/main-component-harness.ts | 6 +- .../testing/tests/test-main-component.html | 15 ++++ src/cdk/testing/tests/test-main-component.ts | 16 ++++- 5 files changed, 128 insertions(+), 18 deletions(-) diff --git a/src/cdk/testing/testbed/fake-events/type-in-element.ts b/src/cdk/testing/testbed/fake-events/type-in-element.ts index 12547d86f00c..031a4fd6d274 100644 --- a/src/cdk/testing/testbed/fake-events/type-in-element.ts +++ b/src/cdk/testing/testbed/fake-events/type-in-element.ts @@ -112,26 +112,40 @@ export function typeInElement(element: HTMLElement, ...modifiersAndKeys: any[]) triggerFocus(element); - // When we aren't entering the value incrementally, assign it all at once ahead - // of time so that any listeners to the key events below will have access to it. - if (!enterValueIncrementally) { - (element as HTMLInputElement).value = keys.reduce((value, key) => value + (key.key || ''), ''); - } + let nonIncrementalValue = ''; for (const key of keys) { - dispatchKeyboardEvent(element, 'keydown', key.keyCode, key.key, modifiers); - dispatchKeyboardEvent(element, 'keypress', key.keyCode, key.key, modifiers); - if (isInput && key.key && key.key.length === 1) { - if (enterValueIncrementally) { - (element as HTMLInputElement | HTMLTextAreaElement).value += key.key; - dispatchFakeEvent(element, 'input'); + const downEvent = dispatchKeyboardEvent(element, 'keydown', key.keyCode, key.key, modifiers); + + // If the handler called `preventDefault` during `keydown`, the browser doesn't insert the + // value or dispatch `keypress` and `input` events. `keyup` is still dispatched. + if (!downEvent.defaultPrevented) { + const pressEvent = dispatchKeyboardEvent( + element, + 'keypress', + key.keyCode, + key.key, + modifiers, + ); + + // If the handler called `preventDefault` during `keypress`, the browser doesn't insert the + // value or dispatch the `input` event. `keyup` is still dispatched. + if (!pressEvent.defaultPrevented && isInput && key.key && key.key.length === 1) { + if (enterValueIncrementally) { + element.value += key.key; + dispatchFakeEvent(element, 'input'); + } else { + nonIncrementalValue += key.key; + } } } + dispatchKeyboardEvent(element, 'keyup', key.keyCode, key.key, modifiers); } - // Since we weren't dispatching `input` events while sending the keys, we have to do it now. - if (!enterValueIncrementally) { + // Since we weren't adding the value or dispatching `input` events, we do it all at once now. + if (!enterValueIncrementally && nonIncrementalValue.length > 0 && isInput) { + element.value = nonIncrementalValue; dispatchFakeEvent(element, 'input'); } } diff --git a/src/cdk/testing/tests/cross-environment.spec.ts b/src/cdk/testing/tests/cross-environment.spec.ts index 0afd6bae6b5e..5b255f9eeab7 100644 --- a/src/cdk/testing/tests/cross-environment.spec.ts +++ b/src/cdk/testing/tests/cross-environment.spec.ts @@ -204,19 +204,19 @@ export function crossEnvironmentSpecs( }); it('should send enter key', async () => { - const specialKey = await harness.specaialKey(); + const specialKey = await harness.specialKey(); await harness.sendEnter(); expect(await specialKey.text()).toBe('enter'); }); it('should send comma key', async () => { - const specialKey = await harness.specaialKey(); + const specialKey = await harness.specialKey(); await harness.sendComma(); expect(await specialKey.text()).toBe(','); }); it('should send alt+j key', async () => { - const specialKey = await harness.specaialKey(); + const specialKey = await harness.specialKey(); await harness.sendAltJ(); expect(await specialKey.text()).toBe('alt-j'); }); @@ -289,6 +289,69 @@ export function crossEnvironmentSpecs( expect(await element.getText()).toBe('Has comma inside attribute'); }); + it( + 'should prevent the value from changing and dispatch the correct event sequence ' + + 'when preventDefault is called on an input during `keydown`', + async () => { + const button = await harness.preventDefaultKeydownButton(); + const input = await harness.preventDefaultInput(); + const value = await harness.preventDefaultInputValues(); + + await button.click(); + await input.sendKeys('321'); + + expect((await value.text()).split('|')).toEqual([ + // Event sequence for 3 + 'keydown - 3 - ', + 'keypress - 3 - ', + 'input - - 3', + 'keyup - 3 - 3', + + // Event sequence for 2 which calls preventDefault + 'keydown - 2 - 3', + 'keyup - 2 - 3', + + // Event sequence for 1 + 'keydown - 1 - 3', + 'keypress - 1 - 3', + 'input - - 31', + 'keyup - 1 - 31', + ]); + }, + ); + + it( + 'should prevent the value from changing and dispatch the correct event sequence ' + + 'when preventDefault is called on an input during `keypress`', + async () => { + const button = await harness.preventDefaultKeypressButton(); + const input = await harness.preventDefaultInput(); + const value = await harness.preventDefaultInputValues(); + + await button.click(); + await input.sendKeys('321'); + + expect((await value.text()).split('|')).toEqual([ + // Event sequence for 3 + 'keydown - 3 - ', + 'keypress - 3 - ', + 'input - - 3', + 'keyup - 3 - 3', + + // Event sequence for 2 which calls preventDefault + 'keydown - 2 - 3', + 'keypress - 2 - 3', + 'keyup - 2 - 3', + + // Event sequence for 1 + 'keydown - 1 - 3', + 'keypress - 1 - 3', + 'input - - 31', + 'keyup - 1 - 31', + ]); + }, + ); + if (!skipAsyncTests) { it('should wait for async operation to complete', async () => { const asyncCounter = await harness.asyncCounter(); diff --git a/src/cdk/testing/tests/harnesses/main-component-harness.ts b/src/cdk/testing/tests/harnesses/main-component-harness.ts index ee3bcd32d0df..630c33ad82c6 100644 --- a/src/cdk/testing/tests/harnesses/main-component-harness.ts +++ b/src/cdk/testing/tests/harnesses/main-component-harness.ts @@ -39,6 +39,10 @@ export class MainComponentHarness extends ComponentHarness { readonly multiSelectChangeEventCounter = this.locatorFor('#multi-select-change-counter'); readonly numberInput = this.locatorFor('#number-input'); readonly numberInputValue = this.locatorFor('#number-input-value'); + readonly preventDefaultInput = this.locatorFor('#prevent-default-input'); + readonly preventDefaultInputValues = this.locatorFor('#prevent-default-input-values'); + readonly preventDefaultKeydownButton = this.locatorFor('#prevent-default-keydown'); + readonly preventDefaultKeypressButton = this.locatorFor('#prevent-default-keypress'); readonly contextmenuTestResult = this.locatorFor('.contextmenu-test-result'); readonly contenteditable = this.locatorFor('#contenteditable'); // Allow null for element @@ -68,7 +72,7 @@ export class MainComponentHarness extends ComponentHarness { SubComponentHarness.with({title: 'List of test tools', itemCount: 4}), ); readonly lastList = this.locatorFor(SubComponentHarness.with({selector: ':last-child'})); - readonly specaialKey = this.locatorFor('.special-key'); + readonly specialKey = this.locatorFor('.special-key'); readonly requiredAncestorRestrictedSubcomponent = this.locatorFor( SubComponentHarness.with({ancestor: '.other'}), diff --git a/src/cdk/testing/tests/test-main-component.html b/src/cdk/testing/tests/test-main-component.html index 8ec6080aa042..7b248ed4b224 100644 --- a/src/cdk/testing/tests/test-main-component.html +++ b/src/cdk/testing/tests/test-main-component.html @@ -50,6 +50,21 @@

Main Component

Number value: {{numberControl.value}}
+ + + + +
{{preventDefaultValues.join('|')}}
diff --git a/src/cdk/testing/tests/test-main-component.ts b/src/cdk/testing/tests/test-main-component.ts index 86f6d8a64444..2a3c45e501aa 100644 --- a/src/cdk/testing/tests/test-main-component.ts +++ b/src/cdk/testing/tests/test-main-component.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import {COMMA, ENTER} from '@angular/cdk/keycodes'; +import {COMMA, ENTER, TWO} from '@angular/cdk/keycodes'; import {_supportsShadowDom} from '@angular/cdk/platform'; import {FormControl} from '@angular/forms'; import { @@ -48,6 +48,8 @@ export class TestMainComponent implements OnDestroy { clickResult = {x: -1, y: -1}; rightClickResult = {x: -1, y: -1, button: -1}; numberControl = new FormControl(null); + preventDefaultEventType: string | null = null; + preventDefaultValues: string[] = []; @ViewChild('clickTestElement') clickTestElement: ElementRef; @ViewChild('taskStateResult') taskStateResultElement: ElementRef; @@ -117,6 +119,18 @@ export class TestMainComponent implements OnDestroy { this.customEventData = JSON.stringify({message: event.message, value: event.value}); } + preventDefaultListener(event: Event) { + // `input` events don't have a key + const key = event.type === 'input' ? '' : (event as KeyboardEvent).key; + const input = event.target as HTMLInputElement; + + if (event.type === this.preventDefaultEventType && (event as KeyboardEvent).keyCode === TWO) { + event.preventDefault(); + } + + this.preventDefaultValues.push(`${event.type} - ${key} - ${input.value || ''}`); + } + runTaskOutsideZone() { this._zone.runOutsideAngular(() => setTimeout(() => {