From 8c1b1256c61001f6d1fe99315ecb52c6699b2e9d Mon Sep 17 00:00:00 2001 From: Emanuel Hein Date: Sat, 11 Jul 2020 12:51:22 +0200 Subject: [PATCH 1/4] feature(cdk/testing) emulate selection in testbed environment - emulate cursor move on send arrow keys - emulate selection on send shift + arrow key - respect cursor position and selection on send characters Prerequisite #19709 (will need select on tab into input with text) --- .../emulate-arrow-in-text-input.ts | 190 +++++++++ .../fake-events/emulate-char-in-text-input.ts | 37 ++ .../emulate-text-input-behavior.spec.ts | 401 ++++++++++++++++++ .../emulate-text-input-behavior.ts | 45 ++ .../testbed/fake-events/text-input-element.ts | 72 ++++ .../testbed/fake-events/type-in-element.ts | 25 +- src/cdk/testing/testbed/unit-test-element.ts | 4 - .../testing/tests/cross-environment.spec.ts | 97 ++++- .../tests/harnesses/main-component-harness.ts | 5 + .../testing/tests/test-main-component.html | 1 + 10 files changed, 854 insertions(+), 23 deletions(-) create mode 100644 src/cdk/testing/testbed/fake-events/emulate-arrow-in-text-input.ts create mode 100644 src/cdk/testing/testbed/fake-events/emulate-char-in-text-input.ts create mode 100644 src/cdk/testing/testbed/fake-events/emulate-text-input-behavior.spec.ts create mode 100644 src/cdk/testing/testbed/fake-events/emulate-text-input-behavior.ts create mode 100644 src/cdk/testing/testbed/fake-events/text-input-element.ts diff --git a/src/cdk/testing/testbed/fake-events/emulate-arrow-in-text-input.ts b/src/cdk/testing/testbed/fake-events/emulate-arrow-in-text-input.ts new file mode 100644 index 000000000000..c619d2c95bc4 --- /dev/null +++ b/src/cdk/testing/testbed/fake-events/emulate-arrow-in-text-input.ts @@ -0,0 +1,190 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { + TextInputElement, getSelectionStart, getValueLength, getSelectionEnd, +} from './text-input-element'; + +type ArrowKey = 'ArrowUp' | 'ArrowDown' | 'ArrowLeft' | 'ArrowRight'; +export function isArrowKey(key: string): key is ArrowKey { + return key.startsWith('Arrow'); +} + +/** + * will move cursor and/or change selection of text value + */ +export function emulateArrowInTextInput( + modifiers: { shift?: boolean }, key: ArrowKey, element: TextInputElement, +) { + if (modifiers.shift) { + switch (key) { + case 'ArrowUp': return emulateShiftArrowUp(element); + case 'ArrowRight': return emulateShiftArrowRight(element); + case 'ArrowDown': return emulateShiftArrowDown(element); + case 'ArrowLeft': return emulateShiftArrowLeft(element); + } + } else { + switch (key) { + case 'ArrowUp': return emulateArrowUp(element); + case 'ArrowRight': return emulateArrowRight(element); + case 'ArrowDown': return emulateArrowDown(element); + case 'ArrowLeft': return emulateArrowLeft(element); + } + } +} + +function emulateArrowUp(target: TextInputElement) { + const lineLength = getLineLength(target); + const selectionStart = getSelectionStart(target); + + setCursor(target, Math.max(selectionStart - lineLength, 0)); +} + +function setCursor(target: TextInputElement, position: number) { + target.setSelectionRange(position, position, 'none'); +} + +function getLineLength(target: TextInputElement, valueLength = getValueLength(target)) { + return target instanceof HTMLTextAreaElement ? target.cols : valueLength; +} + +function emulateArrowRight(target: TextInputElement) { + if (target.selectionStart !== target.selectionEnd) { + setCursor(target, getSelectionEnd(target)); + return; + } + + const valueLength = getValueLength(target); + const selectionEnd = target.selectionEnd; + if (selectionEnd && selectionEnd < valueLength) { + setCursor(target, selectionEnd + 1); + } else { + setCursor(target, valueLength); + } +} + +function emulateArrowDown(target: TextInputElement) { + const valueLength = getValueLength(target); + const lineLength = getLineLength(target, valueLength); + const selectionEnd = getSelectionEnd(target); + setCursor(target, Math.min(selectionEnd + lineLength, valueLength)); +} + +function emulateArrowLeft(target: TextInputElement) { + if (target.selectionStart !== target.selectionEnd) { + setCursor(target, getSelectionStart(target)); + target.selectionEnd = target.selectionStart; + return; + } + + const selectionStart = getSelectionStart(target); + if (selectionStart && selectionStart > 0) { + setCursor(target, selectionStart - 1); + return; + } + + const valueLength = getValueLength(target); + if (valueLength > 0) { + setCursor(target, valueLength - 1); + return; + } + + setCursor(target, 0); +} + +function emulateShiftArrowUp(target: TextInputElement) { + const lineLength = getLineLength(target); + + if (isSelectionDirectionOf(target, 'forward')) { + reduceSelectionAtSelectionEnd(target, lineLength); + } else { + extendSelectionAtSelectionStart(target, lineLength); + } +} + +function isSelectionDirectionOf(target: TextInputElement, direction: 'forward' | 'backward') { + return target.selectionDirection === direction && (target.selectionStart !== target.selectionEnd); +} + +function extendSelectionAtSelectionStart(target: TextInputElement, lengthToExtend: number) { + const selectionStart = getSelectionStart(target); + const newSelectionStart = Math.max(selectionStart - lengthToExtend, 0); + target.selectionStart = newSelectionStart; + if (target.selectionDirection !== 'backward') { + target.selectionDirection = 'backward'; + } +} + +function reduceSelectionAtSelectionEnd(target: TextInputElement, lengthToReduce: number) { + const selectionStart = getSelectionStart(target); + const selectionEnd = getSelectionEnd(target); + const newSelectionEnd = selectionEnd - lengthToReduce; + + if (selectionStart < newSelectionEnd) { + target.selectionEnd = newSelectionEnd; + } else { + target.selectionEnd = selectionStart; + target.selectionDirection = 'none'; + // there's a different behavior in firefox, which would move selection end: + // target.selectionEnd = selectionStart; + // target.selectionStart = newSelectionEnd; + // target.selectionDirection = 'backward'; + } +} + +function emulateShiftArrowRight(target: TextInputElement) { + if (isSelectionDirectionOf(target, 'backward')) { + reduceSelectionAtSelectionStart(target, 1); + } else { + extendSelectionAtSelectionEnd(target, 1); + } +} + +function extendSelectionAtSelectionEnd(target: TextInputElement, lengthToExtend: number) { + const valueLength = getValueLength(target); + const selectionEnd = getSelectionEnd(target); + target.selectionEnd = Math.min(selectionEnd + lengthToExtend, valueLength); + if (target.selectionDirection !== 'forward') { + target.selectionDirection = 'forward'; + } +} + +function reduceSelectionAtSelectionStart(target: TextInputElement, lengthToReduce: number) { + const selectionStart = getSelectionStart(target); + const selectionEnd = getSelectionEnd(target); + const newSelectionStart = selectionStart + lengthToReduce; + + if (newSelectionStart < selectionEnd) { + target.selectionStart = newSelectionStart; + } else { + target.selectionStart = selectionEnd; + target.selectionDirection = 'none'; + // there's a different behavior in firefox, which would move selection end: + // target.selectionStart = selectionEnd; + // target.selectionEnd = newSelectionStart; + // target.selectionDirection = 'forward'; + } +} + +function emulateShiftArrowDown(target: TextInputElement) { + const lineLength = getLineLength(target); + + if (isSelectionDirectionOf(target, 'backward')) { + reduceSelectionAtSelectionStart(target, lineLength); + } else { + extendSelectionAtSelectionEnd(target, lineLength); + } +} + +function emulateShiftArrowLeft(target: TextInputElement) { + if (isSelectionDirectionOf(target, 'forward')) { + reduceSelectionAtSelectionEnd(target, 1); + } else { + extendSelectionAtSelectionStart(target, 1); + } +} diff --git a/src/cdk/testing/testbed/fake-events/emulate-char-in-text-input.ts b/src/cdk/testing/testbed/fake-events/emulate-char-in-text-input.ts new file mode 100644 index 000000000000..66d3a03b9f2b --- /dev/null +++ b/src/cdk/testing/testbed/fake-events/emulate-char-in-text-input.ts @@ -0,0 +1,37 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {dispatchFakeEvent} from './dispatch-events'; +import {getSelectionEnd, getSelectionStart, TextInputElement} from './text-input-element'; + +/** + * emulate writing characters into text input fields. + * @docs-private + */ +export function writeCharacter(element: TextInputElement, key: string) { + if (!hasToRespectSelection(element)) { + element.value += key; + } else { + const value = element.value; + const selectionStart = getSelectionStart(element); + const valueBeforeSelection = value.substr(0, selectionStart); + const selectionEnd = getSelectionEnd(element); + const valueAfterSelection = value.substr(selectionEnd); + element.value = valueBeforeSelection + key + valueAfterSelection; + + const cursor = selectionStart + key.length; + element.setSelectionRange(cursor, cursor, 'none'); + } + dispatchFakeEvent(element, 'input'); +} + +function hasToRespectSelection(element: TextInputElement): boolean { + return typeof element.value === 'string' && element.value.length > 0 + && typeof element.selectionStart === 'number' && element.selectionStart < element.value.length + ; +} diff --git a/src/cdk/testing/testbed/fake-events/emulate-text-input-behavior.spec.ts b/src/cdk/testing/testbed/fake-events/emulate-text-input-behavior.spec.ts new file mode 100644 index 000000000000..d1f81dcb27b4 --- /dev/null +++ b/src/cdk/testing/testbed/fake-events/emulate-text-input-behavior.spec.ts @@ -0,0 +1,401 @@ +import {emulateKeyInTextInput} from './emulate-text-input-behavior'; +import {isTextInput, TextInputElement} from './text-input-element'; + +describe('type in text input', () => { + describe('in general', () => { + let input: TextInputElement; + + beforeEach(() => input = document.createElement('input')); + + it('should be a TextInput', () => { + expect(isTextInput(input)).toBe(true); + }); + + it('should change value on character input', () => { + emulateKeyInTextInput({}, 'a', input); + emulateKeyInTextInput({}, 'b', input); + emulateKeyInTextInput({}, 'c', input); + + expect(input.value).toBe('abc'); + }); + + describe('simulate selection', () => { + it('should select last character on Shift + ArrowLeft', () => { + input.value = 'abc'; + + emulateKeyInTextInput({ shift: true }, 'ArrowLeft', input); + + expect(input.selectionStart).toBe(2, 'start'); + expect(input.selectionEnd).toBe(3, 'end'); + expect(input.selectionDirection).toBe('backward', 'direction'); + }); + + it('should extend selection backward', () => { + input.value = 'abc'; + + emulateKeyInTextInput({ shift: true }, 'ArrowLeft', input); + emulateKeyInTextInput({ shift: true }, 'ArrowLeft', input); + + expect(input.selectionStart).toBe(1, 'start'); + expect(input.selectionEnd).toBe(3, 'end'); + expect(input.selectionDirection).toBe('backward', 'direction'); + }); + + it('should stop extending selection backward at the beginning of the input', () => { + input.value = 'abc'; + + emulateKeyInTextInput({ shift: true }, 'ArrowLeft', input); + emulateKeyInTextInput({ shift: true }, 'ArrowLeft', input); + emulateKeyInTextInput({ shift: true }, 'ArrowLeft', input); + emulateKeyInTextInput({ shift: true }, 'ArrowLeft', input); + + expect(input.selectionStart).toBe(0, 'start'); + expect(input.selectionEnd).toBe(3, 'end'); + expect(input.selectionDirection).toBe('backward', 'direction'); + }); + + it('should reduce selection backward', () => { + input.value = 'abc'; + + emulateKeyInTextInput({ shift: true }, 'ArrowLeft', input); + emulateKeyInTextInput({ shift: true }, 'ArrowLeft', input); + emulateKeyInTextInput({ shift: true }, 'ArrowRight', input); + + expect(input.selectionStart).toBe(2, 'start'); + expect(input.selectionEnd).toBe(3, 'end'); + expect(input.selectionDirection).toBe('backward', 'direction'); + }); + + it('should move cursor without shift', () => { + input.value = 'abc'; + + emulateKeyInTextInput({}, 'ArrowLeft', input); + + expect(input.selectionStart).toBe(2, 'start'); + expect(input.selectionEnd).toBe(2, 'end'); + expect(input.selectionDirection).toEqual( + jasmine.stringMatching(/none|forward/), 'direction', + ); + }); + + it('should start selection from cursor position', () => { + input.value = 'abc'; + + emulateKeyInTextInput({}, 'ArrowLeft', input); + emulateKeyInTextInput({ shift: true }, 'ArrowLeft', input); + + expect(input.selectionStart).toBe(1, 'start'); + expect(input.selectionEnd).toBe(2, 'end'); + expect(input.selectionDirection).toBe('backward', 'direction'); + }); + + it('should type at cursor position', () => { + emulateKeyInTextInput({}, 'a', input); + emulateKeyInTextInput({}, 'b', input); + emulateKeyInTextInput({}, 'ArrowLeft', input); + emulateKeyInTextInput({}, 'c', input); + + expect(input.value).toBe('acb'); + expect(input.selectionStart).toBe(2, 'start'); + expect(input.selectionEnd).toBe(2, 'end'); + expect(input.selectionDirection).toEqual( + jasmine.stringMatching(/none|forward/), 'direction', + ); + }); + + it('should change selection direction from backwards to none', () => { + input.value = 'abcd'; + emulateKeyInTextInput({}, 'ArrowLeft', input); + emulateKeyInTextInput({}, 'ArrowLeft', input); + emulateKeyInTextInput({ shift: true }, 'ArrowLeft', input); + emulateKeyInTextInput({ shift: true }, 'ArrowRight', input); + + expect(input.selectionStart).toBe(2, 'start'); + expect(input.selectionEnd).toBe(2, 'end'); + expect(input.selectionDirection).toEqual( + jasmine.stringMatching(/none|forward/), 'direction', + ); + }); + + it('should change selection direction from backwards to forwards', () => { + input.value = 'abcd'; + emulateKeyInTextInput({}, 'ArrowLeft', input); + emulateKeyInTextInput({}, 'ArrowLeft', input); + emulateKeyInTextInput({ shift: true }, 'ArrowLeft', input); + emulateKeyInTextInput({ shift: true }, 'ArrowRight', input); + emulateKeyInTextInput({ shift: true }, 'ArrowRight', input); + + expect(input.selectionStart).toBe(2, 'start'); + expect(input.selectionEnd).toBe(3, 'end'); + expect(input.selectionDirection).toBe('forward', 'direction'); + }); + + it('should extend selection forward', () => { + input.value = 'abc'; + input.selectionStart = input.selectionEnd = 0; + + emulateKeyInTextInput({ shift: true }, 'ArrowRight', input); + emulateKeyInTextInput({ shift: true }, 'ArrowRight', input); + + expect(input.selectionStart).toBe(0, 'start'); + expect(input.selectionEnd).toBe(2, 'end'); + expect(input.selectionDirection).toBe('forward', 'direction'); + }); + + it('should stop extending selection forward at the end of the input', () => { + input.value = 'abc'; + input.selectionStart = input.selectionEnd = 0; + + emulateKeyInTextInput({ shift: true }, 'ArrowRight', input); + emulateKeyInTextInput({ shift: true }, 'ArrowRight', input); + emulateKeyInTextInput({ shift: true }, 'ArrowRight', input); + emulateKeyInTextInput({ shift: true }, 'ArrowRight', input); + + expect(input.selectionStart).toBe(0, 'start'); + expect(input.selectionEnd).toBe(3, 'end'); + expect(input.selectionDirection).toBe('forward', 'direction'); + }); + + it('should reduce selection forward', () => { + input.value = 'abc'; + input.selectionStart = input.selectionEnd = 0; + + emulateKeyInTextInput({ shift: true }, 'ArrowRight', input); + emulateKeyInTextInput({ shift: true }, 'ArrowRight', input); + emulateKeyInTextInput({ shift: true }, 'ArrowLeft', input); + + expect(input.selectionStart).toBe(0, 'start'); + expect(input.selectionEnd).toBe(1, 'end'); + expect(input.selectionDirection).toBe('forward', 'direction'); + }); + + it('should change selection direction from forward to none', () => { + input.value = 'abcd'; + input.selectionStart = input.selectionEnd = 2; + + emulateKeyInTextInput({ shift: true }, 'ArrowRight', input); + emulateKeyInTextInput({ shift: true }, 'ArrowLeft', input); + + expect(input.selectionStart).toBe(2, 'start'); + expect(input.selectionEnd).toBe(2, 'end'); + expect(input.selectionDirection).toEqual( + jasmine.stringMatching(/none|forward/), 'direction', + ); + }); + + it('should change selection direction from forward to backward', () => { + input.value = 'abcd'; + input.selectionStart = input.selectionEnd = 2; + + emulateKeyInTextInput({ shift: true }, 'ArrowRight', input); + emulateKeyInTextInput({ shift: true }, 'ArrowLeft', input); + emulateKeyInTextInput({ shift: true }, 'ArrowLeft', input); + + expect(input.selectionStart).toBe(1, 'start'); + expect(input.selectionEnd).toBe(2, 'end'); + expect(input.selectionDirection).toBe('backward', 'direction'); + }); + }); + + // describe('simulate backspace', () => { + // pending('backspace not implemented yet'); + // it('should delete last character', () => { + // input.value = 'abcd'; + + // emulateKeyInTextInput({}, 'Backspace', input); + + // expect(input.value).toBe('abc', 'value'); + // expect(input.selectionStart).toBe(3, 'start'); + // expect(input.selectionEnd).toBe(3, 'end'); + // expect(input.selectionDirection).toEqual( + // jasmine.stringMatching(/none|forward/), 'direction', + // ); + // }); + + // it('should delete all characters', () => { + // input.value = 'abcd'; + + // emulateKeyInTextInput({}, 'Backspace', input); + // emulateKeyInTextInput({}, 'Backspace', input); + // emulateKeyInTextInput({}, 'Backspace', input); + // emulateKeyInTextInput({}, 'Backspace', input); + + // expect(input.value).toBe('', 'value'); + // expect(input.selectionStart).toBe(0, 'start'); + // expect(input.selectionEnd).toBe(0, 'end'); + // expect(input.selectionDirection).toEqual( + // jasmine.stringMatching(/none|forward/), 'direction', + // ); + // }); + + // it('should not trigger events after deleting all characters', () => { + // input.value = 'abcd'; + // const eventSpy = spyOn(input, 'dispatchEvent'); + + // emulateKeyInTextInput({}, 'Backspace', input); + // emulateKeyInTextInput({}, 'Backspace', input); + // emulateKeyInTextInput({}, 'Backspace', input); + // emulateKeyInTextInput({}, 'Backspace', input); + // emulateKeyInTextInput({}, 'Backspace', input); + // emulateKeyInTextInput({}, 'Backspace', input); + + // expect(eventSpy).toHaveBeenCalledTimes(4); + // expect(input.value).toBe('', 'value'); + // expect(input.selectionStart).toBe(0, 'start'); + // expect(input.selectionEnd).toBe(0, 'end'); + // expect(input.selectionDirection).toEqual( + // jasmine.stringMatching(/none|forward/), 'direction', + // ); + // }); + + // it('should delete at cursor position', () => { + // input.value = 'abc'; + // emulateKeyInTextInput({}, 'ArrowLeft', input); + // emulateKeyInTextInput({}, 'Backspace', input); + // emulateKeyInTextInput({}, 'd', input); + + // expect(input.value).toBe('adc'); + // expect(input.selectionStart).toBe(2, 'start'); + // expect(input.selectionEnd).toBe(2, 'end'); + // expect(input.selectionDirection).toEqual( + // jasmine.stringMatching(/none|forward/), 'direction', + // ); + // }); + + // it('should delete all selected characters', () => { + // input.value = 'abcd'; + // emulateKeyInTextInput({}, 'ArrowLeft', input); + // emulateKeyInTextInput({ shift: true }, 'ArrowLeft', input); + // emulateKeyInTextInput({ shift: true }, 'ArrowLeft', input); + // emulateKeyInTextInput({}, 'Backspace', input); + + // expect(input.value).toBe('ad', 'value'); + // expect(input.selectionStart).toBe(1, 'start'); + // expect(input.selectionEnd).toBe(1, 'end'); + // expect(input.selectionDirection).toEqual( + // jasmine.stringMatching(/none|forward/), 'direction', + // ); + // }); + // }); + }); + + describe('HTMLInputElement', () => { + let input: HTMLInputElement; + + beforeEach(() => input = document.createElement('input')); + + it('should be a TextInput', () => { + expect(isTextInput(input)).toBe(true); + }); + + // it('should not append new line on "Enter"', () => { + // pending('enter not implemented yet'); + // emulateKeyInTextInput({}, 'a', input); + // emulateKeyInTextInput({}, 'Enter', input); + // emulateKeyInTextInput({}, 'c', input); + + // expect(input.value).toBe('ac'); + // }); + + it('on arrow up key should select value until it\'s start', () => { + input.value = 'abcd'; + + emulateKeyInTextInput({}, 'ArrowLeft', input); + emulateKeyInTextInput({ shift: true }, 'ArrowUp', input); + + expect(input.selectionStart).toBe(0, 'start'); + expect(input.selectionEnd).toBe(3, 'end'); + expect(input.selectionDirection).toBe('backward', 'direction'); + }); + + it('on arrow down key should select value until it\'s start', () => { + input.value = 'abcd'; + input.selectionStart = input.selectionEnd = 1; + + emulateKeyInTextInput({ shift: true }, 'ArrowDown', input); + + expect(input.selectionStart).toBe(1, 'start'); + expect(input.selectionEnd).toBe(4, 'end'); + expect(input.selectionDirection).toBe('forward', 'direction'); + }); + + it('on arrow up then down key should remove selection', () => { + input.value = 'abcd'; + input.selectionStart = input.selectionEnd = 2; + + emulateKeyInTextInput({ shift: true }, 'ArrowUp', input); + emulateKeyInTextInput({ shift: true }, 'ArrowDown', input); + + expect(input.selectionStart).toBe(2, 'start'); + expect(input.selectionEnd).toBe(2, 'end'); + expect(input.selectionDirection).toEqual( + jasmine.stringMatching(/none|forward/), 'direction', + ); + }); + + it('on arrow up then twice down key should select from cursor to end', () => { + input.value = 'abcd'; + input.selectionStart = input.selectionEnd = 2; + + emulateKeyInTextInput({ shift: true }, 'ArrowUp', input); + emulateKeyInTextInput({ shift: true }, 'ArrowDown', input); + emulateKeyInTextInput({ shift: true }, 'ArrowDown', input); + + expect(input.selectionStart).toBe(2, 'start'); + expect(input.selectionEnd).toBe(4, 'end'); + expect(input.selectionDirection).toBe('forward', 'direction'); + }); + + it('on arrow down then up key should remove selection', () => { + input.value = 'abcd'; + input.selectionStart = input.selectionEnd = 2; + + emulateKeyInTextInput({ shift: true }, 'ArrowDown', input); + emulateKeyInTextInput({ shift: true }, 'ArrowUp', input); + + expect(input.selectionStart).toBe(2, 'start'); + expect(input.selectionEnd).toBe(2, 'end'); + expect(input.selectionDirection).toEqual( + jasmine.stringMatching(/none|forward/), 'direction', + ); + }); + + it('on arrow down then twice up key should select from start to cursor', () => { + input.value = 'abcd'; + input.selectionStart = input.selectionEnd = 2; + + emulateKeyInTextInput({ shift: true }, 'ArrowDown', input); + emulateKeyInTextInput({ shift: true }, 'ArrowUp', input); + emulateKeyInTextInput({ shift: true }, 'ArrowUp', input); + + expect(input.selectionStart).toBe(0, 'start'); + expect(input.selectionEnd).toBe(2, 'end'); + expect(input.selectionDirection).toBe('backward', 'direction'); + }); + }); + + describe('HTMLTextAreaElement', () => { + let textarea: HTMLTextAreaElement; + + beforeEach(() => textarea = document.createElement('textarea')); + + it('should be a TextInput', () => { + expect(isTextInput(textarea)).toBe(true); + }); + + // it('should append new line on "Enter"', () => { + // pending('enter not implemented yet'); + // emulateKeyInTextInput({}, 'a', textarea); + // emulateKeyInTextInput({}, 'Enter', textarea); + // emulateKeyInTextInput({}, 'c', textarea); + + // expect(textarea.value).toBe('a\nc'); + // }); + }); + + describe('HTMLButtonElement', () => { + it('should not be a TextInput', () => { + expect(isTextInput(document.createElement('button'))).toBe(false); + }); + }); +}); diff --git a/src/cdk/testing/testbed/fake-events/emulate-text-input-behavior.ts b/src/cdk/testing/testbed/fake-events/emulate-text-input-behavior.ts new file mode 100644 index 000000000000..bd65c087de61 --- /dev/null +++ b/src/cdk/testing/testbed/fake-events/emulate-text-input-behavior.ts @@ -0,0 +1,45 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {ModifierKeys} from '@angular/cdk/testing'; +import {dispatchFakeEvent} from './dispatch-events'; +import {TextInputElement} from './text-input-element'; +import {emulateArrowInTextInput, isArrowKey} from './emulate-arrow-in-text-input'; +import {writeCharacter} from './emulate-char-in-text-input'; + +/** + * Emulate browser behavior of keys in text inputs. + * + * will not send key events themself but: + * - Change the value on character input at cursor position + * + * @param modifiers ModifierKeys that may change behavior + * @param key to emulate + * @param element TextInputElement + * @see to send key events use {@linkcode file://./type-in-element.ts#typeInElement} + * + * @docs-private + */ +export function emulateKeyInTextInput( + modifiers: ModifierKeys, key: string, element: TextInputElement, +) { + if (key.length === 1) { + writeCharacter(element, key); + } else if (isArrowKey(key)) { + emulateArrowInTextInput(modifiers, key, element); + } +} + +/** + * Clears the text of a TextInputElement. + * @docs-private + */ +export function clearTextElement(element: TextInputElement) { + element.value = ''; + dispatchFakeEvent(element, 'input'); +} diff --git a/src/cdk/testing/testbed/fake-events/text-input-element.ts b/src/cdk/testing/testbed/fake-events/text-input-element.ts new file mode 100644 index 000000000000..ab4787be6d18 --- /dev/null +++ b/src/cdk/testing/testbed/fake-events/text-input-element.ts @@ -0,0 +1,72 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +/** + * Checks whether the given Element is a TextInputElement. + * @docs-private + */ +export function isTextInput(element: Element): element is TextInputElement { + return isTextArea(element) || isInputWithText(element); +} + +function isTextArea(element: Element): element is HTMLTextAreaElement { + return element.nodeName.toLowerCase() === 'textarea'; +} + +const inputsTypesWithoutText = ['checkbox', 'radio']; +function isInputWithText(element: Element): element is HTMLInputElement { + return element.nodeName.toLowerCase() === 'input' + && (inputsTypesWithoutText.indexOf((element as HTMLInputElement).type) < 0) + ; +} + +/** + * Inputs that contains editable text values. + * @docs-private + */ +export type TextInputElement = HTMLInputElement | HTMLTextAreaElement; + +/** + * get selection start with fallbacks + * @docs-private + */ +export function getSelectionStart(element: TextInputElement): number { + if (typeof element.selectionStart === 'number') { + return element.selectionStart; + } else if (typeof element.selectionEnd === 'number') { + return element.selectionEnd; + } else { + return getValueLength(element); + } +} + +/** + * get selection end with fallbacks + * @docs-private + */ +export function getSelectionEnd(element: TextInputElement): number { + if (typeof element.selectionEnd === 'number') { + return element.selectionEnd; + } else if (typeof element.selectionStart === 'number') { + return element.selectionStart; + } else { + return getValueLength(element); + } +} + +/** + * get value length with fallbacks + * @docs-private + */ +export function getValueLength(element: TextInputElement): number { + if (typeof element.value === 'string') { + return element.value.length; + } else { + return 0; + } +} 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 416692af5b51..bf850f10e723 100644 --- a/src/cdk/testing/testbed/fake-events/type-in-element.ts +++ b/src/cdk/testing/testbed/fake-events/type-in-element.ts @@ -7,16 +7,19 @@ */ import {ModifierKeys} from '@angular/cdk/testing'; -import {dispatchFakeEvent, dispatchKeyboardEvent} from './dispatch-events'; +import {dispatchKeyboardEvent} from './dispatch-events'; import {triggerFocus} from './element-focus'; +import {emulateKeyInTextInput, clearTextElement} from './emulate-text-input-behavior'; +import {TextInputElement, isTextInput as newIsTextInput} from './text-input-element'; /** * Checks whether the given Element is a text input element. + * @deprecated use isTextInput from './emulate-key-in-text-input' + * @breaking-change 11.0.0 * @docs-private */ -export function isTextInput(element: Element): element is HTMLInputElement | HTMLTextAreaElement { - const nodeName = element.nodeName.toLowerCase(); - return nodeName === 'input' || nodeName === 'textarea' ; +export function isTextInput(element: Element): element is TextInputElement { + return newIsTextInput(element); } /** @@ -60,9 +63,8 @@ export function typeInElement(element: HTMLElement, ...modifiersAndKeys: any) { for (const key of keys) { dispatchKeyboardEvent(element, 'keydown', key.keyCode, key.key, modifiers); dispatchKeyboardEvent(element, 'keypress', key.keyCode, key.key, modifiers); - if (isTextInput(element) && key.key && key.key.length === 1) { - element.value += key.key; - dispatchFakeEvent(element, 'input'); + if (isTextInput(element) && key.key) { + emulateKeyInTextInput(modifiers, key.key, element); } dispatchKeyboardEvent(element, 'keyup', key.keyCode, key.key, modifiers); } @@ -72,8 +74,11 @@ export function typeInElement(element: HTMLElement, ...modifiersAndKeys: any) { * Clears the text in an input or textarea element. * @docs-private */ -export function clearElement(element: HTMLInputElement | HTMLTextAreaElement) { +export function clearElement(element: Element) { + if (!isTextInput(element)) { + throw Error('Attempting to clear an invalid element (not inputs or textareas)'); + } + triggerFocus(element as HTMLElement); - element.value = ''; - dispatchFakeEvent(element, 'input'); + clearTextElement(element); } diff --git a/src/cdk/testing/testbed/unit-test-element.ts b/src/cdk/testing/testbed/unit-test-element.ts index 1c5cac69c250..3d297ebd49e5 100644 --- a/src/cdk/testing/testbed/unit-test-element.ts +++ b/src/cdk/testing/testbed/unit-test-element.ts @@ -12,7 +12,6 @@ import { clearElement, dispatchMouseEvent, dispatchPointerEvent, - isTextInput, triggerBlur, triggerFocus, typeInElement, @@ -64,9 +63,6 @@ export class UnitTestElement implements TestElement { async clear(): Promise { await this._stabilize(); - if (!isTextInput(this.element)) { - throw Error('Attempting to clear an invalid element'); - } clearElement(this.element); await this._stabilize(); } diff --git a/src/cdk/testing/tests/cross-environment.spec.ts b/src/cdk/testing/tests/cross-environment.spec.ts index 6713d6b6970a..f417589687cf 100644 --- a/src/cdk/testing/tests/cross-environment.spec.ts +++ b/src/cdk/testing/tests/cross-environment.spec.ts @@ -14,6 +14,7 @@ import { } from '@angular/cdk/testing'; import {MainComponentHarness} from './harnesses/main-component-harness'; import {SubComponentHarness, SubComponentSpecialHarness} from './harnesses/sub-component-harness'; +import {TestKey} from '../test-element'; /** * Tests that should behave equal in testbed and protractor environment. @@ -303,15 +304,6 @@ export function crossEnvironmentSpecs( harness = await getMainComponentHarnessFromEnvironment(); }); - it('should be able to clear', async () => { - const input = await harness.input(); - await input.sendKeys('Yi'); - expect(await input.getProperty('value')).toBe('Yi'); - - await input.clear(); - expect(await input.getProperty('value')).toBe(''); - }); - it('should be able to click', async () => { const counter = await harness.counter(); expect(await counter.text()).toBe('0'); @@ -333,6 +325,7 @@ export function crossEnvironmentSpecs( expect(await input.getProperty('value')).toBe('Yi'); expect(await value.text()).toBe('Input: Yi'); + expect(await harness.getInputValue()).toBe('Yi'); }); it('focuses the element before sending key', async () => { @@ -494,6 +487,92 @@ export function crossEnvironmentSpecs( expect(await button.isFocused()).toBe(false); }); }); + + describe('equal behavior (emulated in testbed) on', () => { + let harness: MainComponentHarness; + const initialValue = 'abc'; + let input: TestElement; + let checkbox: TestElement; + + beforeEach(async () => { + harness = await getMainComponentHarnessFromEnvironment(); + input = await harness.input(); + checkbox = await harness.checkbox(); + + // @breaking-change 11.0.0 Remove non-null assertion once `setInputValue` is required. + await input.setInputValue!(initialValue); + }); + + describe('initially', () => { + it('input should have the value "abc"', async () => { + expect(await harness.getInputValue()).toBe('abc'); + }); + it('checkbox should be unchecked', async () => { + expect(await checkbox.getProperty('checked')).toBe(false, 'initial'); + }); + }); + + describe('clear', () => { + it('should delete text of text inputs', async () => { + await input.clear(); + expect(await harness.getInputValue()).toBe(''); + }); + + it('will throw for checkboxes', async () => { + try { + await checkbox.clear(); + fail('should have thrown'); + } catch (e) { + expect(e.toString()).toContain('invalid element'); + } + }); + + it('will throw for other html elements', async () => { + try { + await (await harness.button()).clear(); + fail('should have thrown'); + } catch (e) { + expect(e.toString()).toContain('invalid element'); + } + }); + }); + + describe('arrow keys in inputs', () => { + it('arrow left should move cursor', async () => { + await input.sendKeys(TestKey.LEFT_ARROW, 'd'); + expect(await harness.getInputValue()).toBe('abdc'); + }); + + it('shift + arrows left should start selection backwards', async () => { + await input.sendKeys({ shift: true }, TestKey.LEFT_ARROW, TestKey.LEFT_ARROW); + await input.sendKeys('d'); + expect(await harness.getInputValue()).toBe('ad'); + }); + + it('shift + arrow up should selection all up to the start of input', async () => { + await input.sendKeys(TestKey.LEFT_ARROW); + await input.sendKeys({ shift: true }, TestKey.UP_ARROW); + await input.sendKeys('d'); + expect(await harness.getInputValue()).toBe('dc'); + }); + + it('shift + 2x arrows downs should select all from selection end to input end', async () => { + await input.sendKeys(TestKey.LEFT_ARROW); + await input.sendKeys({ shift: true }, TestKey.UP_ARROW); + await input.sendKeys({ shift: true }, TestKey.DOWN_ARROW, TestKey.DOWN_ARROW); + await input.sendKeys('d'); + expect(await harness.getInputValue()).toBe('abd'); + }); + + it('shift + 2x arrows up should select all from selection start to input start', async () => { + await input.sendKeys(TestKey.LEFT_ARROW); + await input.sendKeys({ shift: true }, TestKey.RIGHT_ARROW); + await input.sendKeys({ shift: true }, TestKey.UP_ARROW, TestKey.UP_ARROW); + await input.sendKeys('d'); + expect(await harness.getInputValue()).toBe('dc'); + }); + }); + }); } export async function checkIsElement(result: ComponentHarness | TestElement, selector?: string) { diff --git a/src/cdk/testing/tests/harnesses/main-component-harness.ts b/src/cdk/testing/tests/harnesses/main-component-harness.ts index 4694f8297b66..eb5e19219338 100644 --- a/src/cdk/testing/tests/harnesses/main-component-harness.ts +++ b/src/cdk/testing/tests/harnesses/main-component-harness.ts @@ -26,6 +26,7 @@ export class MainComponentHarness extends ComponentHarness { readonly allLabels = this.locatorForAll('label'); readonly allLists = this.locatorForAll(SubComponentHarness); readonly memo = this.locatorFor('textarea'); + readonly checkbox = this.locatorFor('input[type=checkbox]'); readonly clickTest = this.locatorFor('.click-test'); readonly clickTestResult = this.locatorFor('.click-test-result'); // Allow null for element @@ -112,6 +113,10 @@ export class MainComponentHarness extends ComponentHarness { return (await this.input()).sendKeys({alt: true}, 'j'); } + async getInputValue(): Promise { + return (await this.input()).getProperty('value'); + } + async getTaskStateResult(): Promise { await (await this.taskStateTestTrigger()).click(); // Wait for async tasks to complete since the click caused a diff --git a/src/cdk/testing/tests/test-main-component.html b/src/cdk/testing/tests/test-main-component.html index 6c7f4339283f..fa812a6f3b23 100644 --- a/src/cdk/testing/tests/test-main-component.html +++ b/src/cdk/testing/tests/test-main-component.html @@ -17,6 +17,7 @@

Main Component

{{specialKey}}
Input: {{input}}
+
From fa1d571dab3105927c003286946d30df36298455 Mon Sep 17 00:00:00 2001 From: Emanuel Hein Date: Sun, 19 Jul 2020 16:35:10 +0200 Subject: [PATCH 2/4] bugfix(cdk/testing): should not ignore prevent default Currently sending a key to a test input changes it's value even if default event is prevented. Now default event won't run if default is prevented --- .../fake-events/type-in-element.spec.ts | 108 ++++++++++++++++++ .../testbed/fake-events/type-in-element.ts | 6 +- 2 files changed, 112 insertions(+), 2 deletions(-) create mode 100644 src/cdk/testing/testbed/fake-events/type-in-element.spec.ts diff --git a/src/cdk/testing/testbed/fake-events/type-in-element.spec.ts b/src/cdk/testing/testbed/fake-events/type-in-element.spec.ts new file mode 100644 index 000000000000..2e8bc36ce66a --- /dev/null +++ b/src/cdk/testing/testbed/fake-events/type-in-element.spec.ts @@ -0,0 +1,108 @@ +import {typeInElement} from './type-in-element'; + +describe('type in elements', () => { + describe('type in input', () => { + it('should send keydown event', () => { + // given + const input = document.createElement('input'); + + const keydownSpy = jasmine.createSpy(); + input.addEventListener('keydown', keydownSpy); + + // when + typeInElement(input, 'a'); + + // then + expect(keydownSpy).toHaveBeenCalledTimes(1); + expect(keydownSpy).toHaveBeenCalledWith(jasmine.objectContaining({ key: 'a' })); + }); + + it('should send keypress event', () => { + // given + const input = document.createElement('input'); + + const keypressSpy = jasmine.createSpy(); + input.addEventListener('keypress', keypressSpy); + + // when + typeInElement(input, 'a'); + + // then + expect(keypressSpy).toHaveBeenCalledTimes(1); + expect(keypressSpy).toHaveBeenCalledWith(jasmine.objectContaining({ key: 'a' })); + }); + + it('should send keyup event', () => { + // given + const input = document.createElement('input'); + + const keyupSpy = jasmine.createSpy(); + input.addEventListener('keyup', keyupSpy); + + // when + typeInElement(input, 'a'); + + // then + expect(keyupSpy).toHaveBeenCalledTimes(1); + expect(keyupSpy).toHaveBeenCalledWith(jasmine.objectContaining({ key: 'a' })); + }); + + it('should send input event', () => { + // given + const input = document.createElement('input'); + + const inputEventSpy = jasmine.createSpy(); + input.addEventListener('input', inputEventSpy); + + // when + typeInElement(input, 'a'); + + // then + expect(inputEventSpy).toHaveBeenCalledTimes(1); + }); + + it('should send events in order', () => { + // given + const input = document.createElement('input'); + + const eventSpy = jasmine.createSpy(); + input.addEventListener('input', eventSpy); + input.addEventListener('keydown', eventSpy); + input.addEventListener('keyup', eventSpy); + input.addEventListener('keypress', eventSpy); + + // when + typeInElement(input, 'a'); + + // then + expect(eventSpy.calls.all().map((call: any) => call.args[0].type)).toEqual([ + 'keydown', 'keypress', 'input', 'keyup', + ]); + }); + + it('should change value of inputs', () => { + // given + const input = document.createElement('input'); + + // when + typeInElement(input, 'a'); + + // then + expect(input.value).toEqual('a'); + }); + + it('should not execute default action if prevented', () => { + // given + const input = document.createElement('input'); + input.addEventListener('keypress', (event) => { + event.preventDefault(); + }); + + // when + typeInElement(input, 'a'); + + // then + expect(input.value).toEqual(''); + }); + }); +}); 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 bf850f10e723..caf1997732de 100644 --- a/src/cdk/testing/testbed/fake-events/type-in-element.ts +++ b/src/cdk/testing/testbed/fake-events/type-in-element.ts @@ -62,8 +62,10 @@ export function typeInElement(element: HTMLElement, ...modifiersAndKeys: any) { triggerFocus(element); for (const key of keys) { dispatchKeyboardEvent(element, 'keydown', key.keyCode, key.key, modifiers); - dispatchKeyboardEvent(element, 'keypress', key.keyCode, key.key, modifiers); - if (isTextInput(element) && key.key) { + const keypresss = dispatchKeyboardEvent( + element, 'keypress', key.keyCode, key.key, modifiers, + ); + if (!keypresss.defaultPrevented && isTextInput(element) && key.key) { emulateKeyInTextInput(modifiers, key.key, element); } dispatchKeyboardEvent(element, 'keyup', key.keyCode, key.key, modifiers); From 2c683cec0e51383aaf4071405efc9acb9a314882 Mon Sep 17 00:00:00 2001 From: Emanuel Hein Date: Sat, 25 Jul 2020 21:42:02 +0200 Subject: [PATCH 3/4] fix safari throws on input[type=color].selectionStart.get() --- .../fake-events/emulate-char-in-text-input.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/cdk/testing/testbed/fake-events/emulate-char-in-text-input.ts b/src/cdk/testing/testbed/fake-events/emulate-char-in-text-input.ts index 66d3a03b9f2b..77b0187397a3 100644 --- a/src/cdk/testing/testbed/fake-events/emulate-char-in-text-input.ts +++ b/src/cdk/testing/testbed/fake-events/emulate-char-in-text-input.ts @@ -30,8 +30,18 @@ export function writeCharacter(element: TextInputElement, key: string) { dispatchFakeEvent(element, 'input'); } +const knownInputTypesWithSelectionIssues = ['color']; function hasToRespectSelection(element: TextInputElement): boolean { - return typeof element.value === 'string' && element.value.length > 0 - && typeof element.selectionStart === 'number' && element.selectionStart < element.value.length - ; + try { + return typeof element.value === 'string' && element.value.length > 0 + && typeof element.selectionStart === 'number' && element.selectionStart < element.value.length + ; + } catch (e) { + // Safari will throw 'type error' on input[type=color].selectionStart.get(). + if (knownInputTypesWithSelectionIssues.indexOf(element.type) < 0) { + console.warn('could not detect selection of ', element); + } + // In general we can't respect selection if we can't detect selection so this will return false. + return false; + } } From 84c9e49f5639c013acf9f3352a861b2a38ea086e Mon Sep 17 00:00:00 2001 From: Emanuel Hein Date: Sun, 26 Jul 2020 12:05:37 +0200 Subject: [PATCH 4/4] docs(cdk/testing): describe differences between testbed and protractor --- guides/using-component-harnesses.md | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/guides/using-component-harnesses.md b/guides/using-component-harnesses.md index e0fb3ab6b3ec..4e23cad8d2fb 100644 --- a/guides/using-component-harnesses.md +++ b/guides/using-component-harnesses.md @@ -8,7 +8,7 @@ idea for component harnesses comes from the [PageObject](https://martinfowler.com/bliki/PageObject.html) pattern commonly used for integration testing. -Angular Material offers test harnesses for many of its components. The Angular team strongly +Angular Material offers test harnesses for most of its components. The Angular team strongly encourages developers to use these harnesses for testing to avoid creating brittle tests that rely on a component's internals. @@ -262,3 +262,23 @@ When tests depend on the implementation details, they become a common source of library changes. Angular CDK's test harnesses makes component library updates easier for both application authors and the Angular team, as the Angular team only has to update the harness once for everyone. + +## Differences between Testbed and real browser testing (Protractor) + +Writing test for both environments is very similar now. But even thought we do our best to get the +behavior as close as possible, it's a low priority task and there are still some slight differences +in the result of sending keys you should be arware of: + +* **All keys** will send keydown, keypress and keyup events. +* **Normal characters** will change the value of text inputs at the cursor position or replace + selection. +* **Arrow keys** + * **in text inputs** will emulate cursor movement or, if send with modifier ```{ shift: true }```, + seletion. But in textareas arrow up and down will just use col count and won't hit exactly one + line beacuse font measures and width and line wrapping. + * **in select inputs** might change the selected option. +* **Page-Up/-Down** won't do anything but sending the key events. +* **Backspace/Delete** won't delete anything. +* **Enter** in inputs won't submit forms or insert a new line in multiline inputs. But of course it + may sometimes select an option, if the component listens to the keydown event. +* **Tab** won't change focus to the next or previous input, nor select it's text value.