Skip to content

Commit 2d2d4a3

Browse files
committed
fix(cdk/testing): Add support to send keys to contenteditable elements in testbed environment.
Fixes #19102
1 parent b07c539 commit 2d2d4a3

9 files changed

+184
-16
lines changed

src/cdk/testing/protractor/protractor-element.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,8 @@ export class ProtractorElement implements TestElement {
7878
}
7979

8080
async clear(): Promise<void> {
81-
return this.element.clear();
81+
await this.element.sendKeys(Key.BACK_SPACE);
82+
await this.element.clear();
8283
}
8384

8485
async click(...args: [] | ['center'] | [number, number]): Promise<void> {

src/cdk/testing/testbed/fake-events/type-in-element.ts

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,31 @@ import {triggerFocus} from './element-focus';
1414
* Checks whether the given Element is a text input element.
1515
* @docs-private
1616
*/
17-
export function isTextInput(element: Element): element is HTMLInputElement | HTMLTextAreaElement {
17+
function isTextInput(element: Element): element is HTMLInputElement | HTMLTextAreaElement {
1818
const nodeName = element.nodeName.toLowerCase();
1919
return nodeName === 'input' || nodeName === 'textarea' ;
2020
}
2121

22+
/**
23+
* Checks whether the given Element's content is editable.
24+
* An element is content editable if
25+
* - the element has "contenteditable" attribute set to "true"
26+
* - any of its ancestors has "contenteditable" attribute set to "true"
27+
* - the owner document has designMode attribute set to "on".
28+
* @docs-private
29+
*/
30+
function isContentEditable(element: Element): element is HTMLElement {
31+
return element instanceof HTMLElement && element.isContentEditable;
32+
}
33+
34+
/**
35+
* Checks whether the given Element changes with input from keyboard.
36+
* @docs-private
37+
*/
38+
function isInputAware(element: Element): element is HTMLElement {
39+
return isTextInput(element) || isContentEditable(element);
40+
}
41+
2242
/**
2343
* Focuses an input, sets its value and dispatches
2444
* the `input` event, simulating the user typing.
@@ -41,6 +61,9 @@ export function typeInElement(element: HTMLElement, modifiers: ModifierKeys,
4161
...keys: (string | {keyCode?: number, key?: string})[]): void;
4262

4363
export function typeInElement(element: HTMLElement, ...modifiersAndKeys: any) {
64+
if (!isInputAware(element)) {
65+
throw new Error('Attempting to send keys to an invalid element');
66+
}
4467
const first = modifiersAndKeys[0];
4568
let modifiers: ModifierKeys;
4669
let rest: (string | {keyCode?: number, key?: string})[];
@@ -60,20 +83,31 @@ export function typeInElement(element: HTMLElement, ...modifiersAndKeys: any) {
6083
for (const key of keys) {
6184
dispatchKeyboardEvent(element, 'keydown', key.keyCode, key.key, modifiers);
6285
dispatchKeyboardEvent(element, 'keypress', key.keyCode, key.key, modifiers);
63-
if (isTextInput(element) && key.key && key.key.length === 1) {
64-
element.value += key.key;
86+
if (isInputAware(element) && key.key?.length === 1) {
87+
if (isTextInput(element)) {
88+
element.value += key.key;
89+
} else {
90+
element.appendChild(new Text(key.key));
91+
}
6592
dispatchFakeEvent(element, 'input');
6693
}
6794
dispatchKeyboardEvent(element, 'keyup', key.keyCode, key.key, modifiers);
6895
}
6996
}
7097

7198
/**
72-
* Clears the text in an input or textarea element.
99+
* Clears the content or text of an input aware element.
73100
* @docs-private
74101
*/
75-
export function clearElement(element: HTMLInputElement | HTMLTextAreaElement) {
76-
triggerFocus(element as HTMLElement);
77-
element.value = '';
102+
export function clearElement(element: Element) {
103+
if (!isInputAware(element)) {
104+
throw new Error('Attempting to clear an invalid element');
105+
}
106+
triggerFocus(element);
107+
if (isTextInput(element)) {
108+
element.value = '';
109+
} else {
110+
element.textContent = '';
111+
}
78112
dispatchFakeEvent(element, 'input');
79113
}

src/cdk/testing/testbed/unit-test-element.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ import {
2222
dispatchFakeEvent,
2323
dispatchMouseEvent,
2424
dispatchPointerEvent,
25-
isTextInput,
2625
triggerBlur,
2726
triggerFocus,
2827
typeInElement,
@@ -73,9 +72,6 @@ export class UnitTestElement implements TestElement {
7372
}
7473

7574
async clear(): Promise<void> {
76-
if (!isTextInput(this.element)) {
77-
throw Error('Attempting to clear an invalid element');
78-
}
7975
clearElement(this.element);
8076
await this._stabilize();
8177
}

src/cdk/testing/tests/cross-environment.spec.ts

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,25 +15,33 @@ import {
1515
import {MainComponentHarness} from './harnesses/main-component-harness';
1616
import {SubComponentHarness, SubComponentSpecialHarness} from './harnesses/sub-component-harness';
1717

18+
/** Environments for the tests. */
19+
export const enum TestEnvironment {
20+
TEST_BED,
21+
PROTRACTOR,
22+
}
23+
1824
/**
1925
* Tests that should behave equal in testbed and protractor environment.
2026
*
2127
* To reduce code duplication and ensure tests will act equal
2228
* with TestbedHarnessEnvironment and ProtractorHarnessEnvironment, this set of tests is
2329
* executed in unit tests and tests.
2430
*
31+
* @param environment in which environment the tests are running
2532
* @param getHarnessLoaderFromEnvironment env specific closure to get HarnessLoader
2633
* @param getMainComponentHarnessFromEnvironment env specific closure to get MainComponentHarness
2734
* @param getActiveElementId env specific closure to get active element
2835
*
2936
* @docs-private
3037
*/
3138
export function crossEnvironmentSpecs(
39+
environment: TestEnvironment,
3240
getHarnessLoaderFromEnvironment: () => HarnessLoader,
3341
getMainComponentHarnessFromEnvironment: () => Promise<MainComponentHarness>,
3442
// Maybe we should introduce HarnessLoader.getActiveElement(): TestElement
3543
// then this 3rd parameter could get removed.
36-
getActiveElementId: () => Promise<string | null>,
44+
getActiveElementId: () => Promise<string | null>,
3745
) {
3846
describe('HarnessLoader', () => {
3947
let loader: HarnessLoader;
@@ -305,11 +313,14 @@ export function crossEnvironmentSpecs(
305313

306314
it('should be able to clear', async () => {
307315
const input = await harness.input();
316+
const inputEvent = await harness.inputEvent();
308317
await input.sendKeys('Yi');
309318
expect(await input.getProperty('value')).toBe('Yi');
319+
expect(await inputEvent.text()).toBe('Count: 2');
310320

311321
await input.clear();
312322
expect(await input.getProperty('value')).toBe('');
323+
expect(await inputEvent.text()).toBe('Count: 3');
313324
});
314325

315326
it('should be able to click', async () => {
@@ -355,6 +366,68 @@ export function crossEnvironmentSpecs(
355366
expect(await getActiveElementId()).toBe(await input.getAttribute('id'));
356367
});
357368

369+
it('should be able to send key to a contenteditable', async () => {
370+
const editable = await harness.editable();
371+
const editableInputEvent = await harness.editableInputEvent();
372+
373+
await editable.sendKeys('Yi');
374+
375+
expect(await editable.text()).toBe('Yi');
376+
expect(await editableInputEvent.text()).toBe('Count: 2');
377+
378+
await editable.clear();
379+
380+
expect(await editable.text()).toBe('');
381+
expect(await editableInputEvent.text()).toBe('Count: 3');
382+
});
383+
384+
it('should be able to send key to a child of a contenteditable', async () => {
385+
const editable = await harness.editableP();
386+
387+
await editable.sendKeys('Yi');
388+
389+
expect(await editable.text()).toBe('Yi');
390+
});
391+
392+
it('should not update not contenteditable div on send key', async () => {
393+
const notEditable = await harness.notEditable();
394+
const notEditableInputEvent = await harness.notEditableInputEvent();
395+
396+
await expectAsyncError(notEditable.sendKeys('Yi'));
397+
398+
expect(await notEditable.text()).toBe('');
399+
expect(await notEditableInputEvent.text()).toBe('Count: 0');
400+
401+
await expectAsyncError(notEditable.clear());
402+
});
403+
404+
it('should send key based on designMode', async () => {
405+
const notEditable = await harness.notEditable();
406+
const inheritEditable = await harness.inheritEditable();
407+
408+
await expectAsyncError(notEditable.sendKeys('Yi'));
409+
expect(await notEditable.text()).toBe('');
410+
411+
await expectAsyncError(inheritEditable.sendKeys('Yi'));
412+
expect(await inheritEditable.text()).toBe('');
413+
414+
await (await harness.designModeOnButton()).click();
415+
416+
await expectAsyncError(notEditable.sendKeys('Yi'));
417+
expect(await notEditable.text()).toBe('');
418+
419+
await inheritEditable.sendKeys('Yi');
420+
// The following expectation is failing on Protractor environment
421+
// In protractor environment, the text is empty.
422+
if (environment == TestEnvironment.TEST_BED) {
423+
expect(await inheritEditable.text()).toBe('Yi');
424+
} else {
425+
expect(await inheritEditable.text()).toBe('');
426+
}
427+
428+
await (await harness.designModeOffButton()).click();
429+
});
430+
358431
it('should be able to retrieve dimensions', async () => {
359432
const dimensions = await (await harness.title()).getDimensions();
360433
expect(dimensions).toEqual(jasmine.objectContaining({height: 100, width: 200}));
@@ -576,3 +649,9 @@ export async function checkIsHarness<T extends ComponentHarness>(
576649
await finalCheck(result as T);
577650
}
578651
}
652+
653+
async function expectAsyncError(promise: Promise<unknown>) {
654+
let error = false;
655+
await promise.catch(() => { error = true; });
656+
expect(error).toBe(true);
657+
}

src/cdk/testing/tests/harnesses/main-component-harness.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export class MainComponentHarness extends ComponentHarness {
2323
readonly counter = this.locatorFor('#counter');
2424
readonly input = this.locatorFor('#input');
2525
readonly value = this.locatorFor('#value');
26+
readonly inputEvent = this.locatorFor('#input-event-count');
2627
readonly allLabels = this.locatorForAll('label');
2728
readonly allLists = this.locatorForAll(SubComponentHarness);
2829
readonly memo = this.locatorFor('textarea');
@@ -95,6 +96,15 @@ export class MainComponentHarness extends ComponentHarness {
9596
readonly customEventBasic = this.locatorFor('#custom-event-basic');
9697
readonly customEventObject = this.locatorFor('#custom-event-object');
9798

99+
readonly editable = this.locatorFor('#editable');
100+
readonly editableP = this.locatorFor('#editable p');
101+
readonly editableInputEvent = this.locatorFor('#editable-input-event');
102+
readonly notEditable = this.locatorFor('#not-editable');
103+
readonly notEditableInputEvent = this.locatorFor('#not-editable-input-event');
104+
readonly inheritEditable = this.locatorFor('#inherit-editable');
105+
readonly designModeOnButton = this.locatorFor('#design-mode-on');
106+
readonly designModeOffButton = this.locatorFor('#design-mode-off');
107+
98108
private _testTools = this.locatorFor(SubComponentHarness);
99109

100110
async increaseCounter(times: number) {

src/cdk/testing/tests/protractor.e2e.spec.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import {HarnessLoader} from '@angular/cdk/testing';
22
import {ProtractorHarnessEnvironment} from '@angular/cdk/testing/protractor';
33
import {browser, by, element as protractorElement, ElementFinder} from 'protractor';
4-
import {crossEnvironmentSpecs} from './cross-environment.spec';
4+
import {crossEnvironmentSpecs, TestEnvironment} from './cross-environment.spec';
55
import {MainComponentHarness} from './harnesses/main-component-harness';
66

77
// Kagekiri is available globally in the browser. We declare it here so we can use it in the
@@ -82,6 +82,7 @@ describe('ProtractorHarnessEnvironment', () => {
8282
});
8383

8484
describe('environment independent', () => crossEnvironmentSpecs(
85+
TestEnvironment.PROTRACTOR,
8586
() => ProtractorHarnessEnvironment.loader(),
8687
() => ProtractorHarnessEnvironment.loader().getHarness(MainComponentHarness),
8788
async () => (await activeElement()).getAttribute('id'),

src/cdk/testing/tests/test-main-component.html

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,29 @@ <h1 style="height: 100px; width: 200px;">Main Component</h1>
1414
<div id="asyncCounter">{{asyncCounter}}</div>
1515
</div>
1616
<div class="inputs">
17-
<input [(ngModel)]="input" id="input" aria-label="input" (keydown)="onKeyDown($event)">
17+
<input #inputEl [(ngModel)]="input" id="input" aria-label="input" (keydown)="onKeyDown($event)">
1818
<span class="special-key">{{specialKey}}</span>
1919
<div id="value">Input: {{input}}</div>
20+
<div id="input-event-count">Count: {{inputElementInputEventCount$ | async}}</div>
2021
<textarea id="memo" aria-label="memo">{{memo}}</textarea>
22+
<div class="contenteditables">
23+
<div #editable id="editable" contenteditable="true" style="width: 100px; height: 100px">
24+
<p style="width: 50px; height: 50px">
25+
</p>
26+
</div>
27+
<div id="editable-input-event">
28+
Count: {{editableElementInputEventCount$ | async}}
29+
</div>
30+
<div #notEditable id="not-editable" contenteditable="false" style="width: 100px; height: 100px">
31+
</div>
32+
<div id="not-editable-input-event">
33+
Count: {{notEditableElementInputEventCount$ | async}}
34+
</div>
35+
<div id="inherit-editable" style="width: 100px; height: 100px">
36+
</div>
37+
<button id="design-mode-on" (click)="turnOnDesignMode()">Turn designMode on</button>
38+
<button id="design-mode-off" (click)="turnOffDesignMode()">Turn designMode off</button>
39+
</div>
2140
<select
2241
id="single-select"
2342
aria-label="single select"

src/cdk/testing/tests/test-main-component.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import {
1818
ViewChild,
1919
ViewEncapsulation
2020
} from '@angular/core';
21+
import {fromEvent, Observable, defer} from 'rxjs';
22+
import {map, startWith} from 'rxjs/operators';
2123

2224
@Component({
2325
selector: 'test-main',
@@ -48,6 +50,17 @@ export class TestMainComponent implements OnDestroy {
4850
@ViewChild('clickTestElement') clickTestElement: ElementRef<HTMLElement>;
4951
@ViewChild('taskStateResult') taskStateResultElement: ElementRef<HTMLElement>;
5052

53+
@ViewChild('inputEl', {static: true}) inputElement: ElementRef<HTMLElement>;
54+
@ViewChild('editable', {static: true}) editableElement: ElementRef<HTMLElement>;
55+
@ViewChild('notEditable', {static: true}) notEditableElement: ElementRef<HTMLElement>;
56+
57+
readonly inputElementInputEventCount$ =
58+
defer(() => countInputEvent(this.inputElement));
59+
readonly editableElementInputEventCount$ =
60+
defer(() => countInputEvent(this.editableElement));
61+
readonly notEditableElementInputEventCount$ =
62+
defer(() => countInputEvent(this.notEditableElement));
63+
5164
private _fakeOverlayElement: HTMLElement;
5265

5366
constructor(private _cdr: ChangeDetectorRef, private _zone: NgZone) {
@@ -102,6 +115,14 @@ export class TestMainComponent implements OnDestroy {
102115
this.customEventData = JSON.stringify({message: event.message, value: event.value});
103116
}
104117

118+
turnOnDesignMode() {
119+
document.designMode = 'on';
120+
}
121+
122+
turnOffDesignMode() {
123+
document.designMode = 'off';
124+
}
125+
105126
runTaskOutsideZone() {
106127
this._zone.runOutsideAngular(() => setTimeout(() => {
107128
this.taskStateResultElement.nativeElement.textContent = 'result';
@@ -114,3 +135,9 @@ export class TestMainComponent implements OnDestroy {
114135
obj.y = Math.round(event.clientY - top);
115136
}
116137
}
138+
139+
function countInputEvent(elementRef: ElementRef<HTMLElement>): Observable<number> {
140+
return fromEvent(elementRef.nativeElement, 'input').pipe(
141+
map((event, index) => index + 1),
142+
startWith(0));
143+
}

src/cdk/testing/tests/testbed.spec.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
import {TestbedHarnessEnvironment} from '@angular/cdk/testing/testbed';
66
import {waitForAsync, ComponentFixture, fakeAsync, TestBed} from '@angular/core/testing';
77
import {querySelectorAll as piercingQuerySelectorAll} from 'kagekiri';
8-
import {crossEnvironmentSpecs} from './cross-environment.spec';
8+
import {crossEnvironmentSpecs, TestEnvironment} from './cross-environment.spec';
99
import {FakeOverlayHarness} from './harnesses/fake-overlay-harness';
1010
import {MainComponentHarness} from './harnesses/main-component-harness';
1111
import {TestComponentsModule} from './test-components-module';
@@ -167,6 +167,7 @@ describe('TestbedHarnessEnvironment', () => {
167167
});
168168

169169
describe('environment independent', () => crossEnvironmentSpecs(
170+
TestEnvironment.TEST_BED,
170171
() => TestbedHarnessEnvironment.loader(fixture),
171172
() => TestbedHarnessEnvironment.harnessForFixture(fixture, MainComponentHarness),
172173
() => Promise.resolve(document.activeElement!.id),

0 commit comments

Comments
 (0)