Skip to content

fix(cdk/testing): Add support to send keys to conenteditable elements in testbed environment. #19107

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/cdk/testing/protractor/protractor-element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,8 @@ export class ProtractorElement implements TestElement {
}

async clear(): Promise<void> {
return this.element.clear();
await this.element.sendKeys(Key.BACK_SPACE);
await this.element.clear();
}

async click(...args: [] | ['center'] | [number, number]): Promise<void> {
Expand Down
48 changes: 41 additions & 7 deletions src/cdk/testing/testbed/fake-events/type-in-element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,31 @@ import {triggerFocus} from './element-focus';
* Checks whether the given Element is a text input element.
* @docs-private
*/
export function isTextInput(element: Element): element is HTMLInputElement | HTMLTextAreaElement {
function isTextInput(element: Element): element is HTMLInputElement | HTMLTextAreaElement {
const nodeName = element.nodeName.toLowerCase();
return nodeName === 'input' || nodeName === 'textarea' ;
}

/**
* Checks whether the given Element's content is editable.
* An element is content editable if
* - the element has "contenteditable" attribute set to "true"
* - any of its ancestors has "contenteditable" attribute set to "true"
* - the owner document has designMode attribute set to "on".
* @docs-private
*/
function isContentEditable(element: Element): element is HTMLElement {
return element instanceof HTMLElement && element.isContentEditable;
}

/**
* Checks whether the given Element changes with input from keyboard.
* @docs-private
*/
function isInputAware(element: Element): element is HTMLElement {
return isTextInput(element) || isContentEditable(element);
}

/**
* Focuses an input, sets its value and dispatches
* the `input` event, simulating the user typing.
Expand All @@ -41,6 +61,9 @@ export function typeInElement(element: HTMLElement, modifiers: ModifierKeys,
...keys: (string | {keyCode?: number, key?: string})[]): void;

export function typeInElement(element: HTMLElement, ...modifiersAndKeys: any) {
if (!isInputAware(element)) {
throw new Error('Attempting to send keys to an invalid element');
}
const first = modifiersAndKeys[0];
let modifiers: ModifierKeys;
let rest: (string | {keyCode?: number, key?: string})[];
Expand All @@ -60,20 +83,31 @@ 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;
if (isInputAware(element) && key.key?.length === 1) {
if (isTextInput(element)) {
element.value += key.key;
} else {
element.appendChild(new Text(key.key));
}
dispatchFakeEvent(element, 'input');
}
dispatchKeyboardEvent(element, 'keyup', key.keyCode, key.key, modifiers);
}
}

/**
* Clears the text in an input or textarea element.
* Clears the content or text of an input aware element.
* @docs-private
*/
export function clearElement(element: HTMLInputElement | HTMLTextAreaElement) {
triggerFocus(element as HTMLElement);
element.value = '';
export function clearElement(element: Element) {
if (!isInputAware(element)) {
throw new Error('Attempting to clear an invalid element');
}
triggerFocus(element);
if (isTextInput(element)) {
element.value = '';
} else {
element.textContent = '';
}
dispatchFakeEvent(element, 'input');
}
4 changes: 0 additions & 4 deletions src/cdk/testing/testbed/unit-test-element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ import {
dispatchFakeEvent,
dispatchMouseEvent,
dispatchPointerEvent,
isTextInput,
triggerBlur,
triggerFocus,
typeInElement,
Expand Down Expand Up @@ -73,9 +72,6 @@ export class UnitTestElement implements TestElement {
}

async clear(): Promise<void> {
if (!isTextInput(this.element)) {
throw Error('Attempting to clear an invalid element');
}
clearElement(this.element);
await this._stabilize();
}
Expand Down
81 changes: 80 additions & 1 deletion src/cdk/testing/tests/cross-environment.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,25 +15,33 @@ import {
import {MainComponentHarness} from './harnesses/main-component-harness';
import {SubComponentHarness, SubComponentSpecialHarness} from './harnesses/sub-component-harness';

/** Environments for the tests. */
export const enum TestEnvironment {
TEST_BED,
PROTRACTOR,
}

/**
* Tests that should behave equal in testbed and protractor environment.
*
* To reduce code duplication and ensure tests will act equal
* with TestbedHarnessEnvironment and ProtractorHarnessEnvironment, this set of tests is
* executed in unit tests and tests.
*
* @param environment in which environment the tests are running
* @param getHarnessLoaderFromEnvironment env specific closure to get HarnessLoader
* @param getMainComponentHarnessFromEnvironment env specific closure to get MainComponentHarness
* @param getActiveElementId env specific closure to get active element
*
* @docs-private
*/
export function crossEnvironmentSpecs(
environment: TestEnvironment,
getHarnessLoaderFromEnvironment: () => HarnessLoader,
getMainComponentHarnessFromEnvironment: () => Promise<MainComponentHarness>,
// Maybe we should introduce HarnessLoader.getActiveElement(): TestElement
// then this 3rd parameter could get removed.
getActiveElementId: () => Promise<string | null>,
getActiveElementId: () => Promise<string | null>,
) {
describe('HarnessLoader', () => {
let loader: HarnessLoader;
Expand Down Expand Up @@ -305,11 +313,14 @@ export function crossEnvironmentSpecs(

it('should be able to clear', async () => {
const input = await harness.input();
const inputEvent = await harness.inputEvent();
await input.sendKeys('Yi');
expect(await input.getProperty('value')).toBe('Yi');
expect(await inputEvent.text()).toBe('Count: 2');

await input.clear();
expect(await input.getProperty('value')).toBe('');
expect(await inputEvent.text()).toBe('Count: 3');
});

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

it('should be able to send key to a contenteditable', async () => {
const editable = await harness.editable();
const editableInputEvent = await harness.editableInputEvent();

await editable.sendKeys('Yi');

expect(await editable.text()).toBe('Yi');
expect(await editableInputEvent.text()).toBe('Count: 2');

await editable.clear();

expect(await editable.text()).toBe('');
expect(await editableInputEvent.text()).toBe('Count: 3');
});

it('should be able to send key to a child of a contenteditable', async () => {
const editable = await harness.editableP();

await editable.sendKeys('Yi');

expect(await editable.text()).toBe('Yi');
});

it('should not update not contenteditable div on send key', async () => {
const notEditable = await harness.notEditable();
const notEditableInputEvent = await harness.notEditableInputEvent();

await expectAsyncError(notEditable.sendKeys('Yi'));

expect(await notEditable.text()).toBe('');
expect(await notEditableInputEvent.text()).toBe('Count: 0');

await expectAsyncError(notEditable.clear());
});

it('should send key based on designMode', async () => {
const notEditable = await harness.notEditable();
const inheritEditable = await harness.inheritEditable();

await expectAsyncError(notEditable.sendKeys('Yi'));
expect(await notEditable.text()).toBe('');

await expectAsyncError(inheritEditable.sendKeys('Yi'));
expect(await inheritEditable.text()).toBe('');

await (await harness.designModeOnButton()).click();

await expectAsyncError(notEditable.sendKeys('Yi'));
expect(await notEditable.text()).toBe('');

await inheritEditable.sendKeys('Yi');
// The following expectation is failing on Protractor environment
// In protractor environment, the text is empty.
if (environment == TestEnvironment.TEST_BED) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to figure out why this is failing, not just disable the test

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I understand that. Just wondering if you have any idea why this may be happening.
And even with this happening, if we can't find a solution, can we submit this CL?
(The CL is an improvement over current testbed implementation, this bug already exists in Protractor)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd like to at least understand what's happening first. If we know why its happening I might be ok with submitting it as is (with a comment explaining).

Does the example work if you run it and manually interact? I'm not sure why its happening, but here are some guesses:

  • Is the click handler on the design mode on button actually firing? Clicking is more finicky in Protractor, it can fail if other elements are overlapping or something like that
  • Maybe it needs some delay before trying to edit? I'm not sure if the browser takes some time to activate design mode

Copy link
Contributor Author

@ienzam ienzam Nov 23, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry I am new here. How can I run the e2e test (as opposed to test) to interact with it to see what's happening?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should be able to run the end to end tests with yarn e2e and the unit tests with yarn test all

expect(await inheritEditable.text()).toBe('Yi');
} else {
expect(await inheritEditable.text()).toBe('');
}

await (await harness.designModeOffButton()).click();
});

it('should be able to retrieve dimensions', async () => {
const dimensions = await (await harness.title()).getDimensions();
expect(dimensions).toEqual(jasmine.objectContaining({height: 100, width: 200}));
Expand Down Expand Up @@ -576,3 +649,9 @@ export async function checkIsHarness<T extends ComponentHarness>(
await finalCheck(result as T);
}
}

async function expectAsyncError(promise: Promise<unknown>) {
let error = false;
await promise.catch(() => { error = true; });
expect(error).toBe(true);
}
10 changes: 10 additions & 0 deletions src/cdk/testing/tests/harnesses/main-component-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export class MainComponentHarness extends ComponentHarness {
readonly counter = this.locatorFor('#counter');
readonly input = this.locatorFor('#input');
readonly value = this.locatorFor('#value');
readonly inputEvent = this.locatorFor('#input-event-count');
readonly allLabels = this.locatorForAll('label');
readonly allLists = this.locatorForAll(SubComponentHarness);
readonly memo = this.locatorFor('textarea');
Expand Down Expand Up @@ -95,6 +96,15 @@ export class MainComponentHarness extends ComponentHarness {
readonly customEventBasic = this.locatorFor('#custom-event-basic');
readonly customEventObject = this.locatorFor('#custom-event-object');

readonly editable = this.locatorFor('#editable');
readonly editableP = this.locatorFor('#editable p');
readonly editableInputEvent = this.locatorFor('#editable-input-event');
readonly notEditable = this.locatorFor('#not-editable');
readonly notEditableInputEvent = this.locatorFor('#not-editable-input-event');
readonly inheritEditable = this.locatorFor('#inherit-editable');
readonly designModeOnButton = this.locatorFor('#design-mode-on');
readonly designModeOffButton = this.locatorFor('#design-mode-off');

private _testTools = this.locatorFor(SubComponentHarness);

async increaseCounter(times: number) {
Expand Down
3 changes: 2 additions & 1 deletion src/cdk/testing/tests/protractor.e2e.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {HarnessLoader} from '@angular/cdk/testing';
import {ProtractorHarnessEnvironment} from '@angular/cdk/testing/protractor';
import {browser, by, element as protractorElement, ElementFinder} from 'protractor';
import {crossEnvironmentSpecs} from './cross-environment.spec';
import {crossEnvironmentSpecs, TestEnvironment} from './cross-environment.spec';
import {MainComponentHarness} from './harnesses/main-component-harness';

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

describe('environment independent', () => crossEnvironmentSpecs(
TestEnvironment.PROTRACTOR,
() => ProtractorHarnessEnvironment.loader(),
() => ProtractorHarnessEnvironment.loader().getHarness(MainComponentHarness),
async () => (await activeElement()).getAttribute('id'),
Expand Down
21 changes: 20 additions & 1 deletion src/cdk/testing/tests/test-main-component.html
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,29 @@ <h1 style="height: 100px; width: 200px;">Main Component</h1>
<div id="asyncCounter">{{asyncCounter}}</div>
</div>
<div class="inputs">
<input [(ngModel)]="input" id="input" aria-label="input" (keydown)="onKeyDown($event)">
<input #inputEl [(ngModel)]="input" id="input" aria-label="input" (keydown)="onKeyDown($event)">
<span class="special-key">{{specialKey}}</span>
<div id="value">Input: {{input}}</div>
<div id="input-event-count">Count: {{inputElementInputEventCount$ | async}}</div>
<textarea id="memo" aria-label="memo">{{memo}}</textarea>
<div class="contenteditables">
<div #editable id="editable" contenteditable="true" style="width: 100px; height: 100px">
<p style="width: 50px; height: 50px">
</p>
</div>
<div id="editable-input-event">
Count: {{editableElementInputEventCount$ | async}}
</div>
<div #notEditable id="not-editable" contenteditable="false" style="width: 100px; height: 100px">
</div>
<div id="not-editable-input-event">
Count: {{notEditableElementInputEventCount$ | async}}
</div>
<div id="inherit-editable" style="width: 100px; height: 100px">
</div>
<button id="design-mode-on" (click)="turnOnDesignMode()">Turn designMode on</button>
<button id="design-mode-off" (click)="turnOffDesignMode()">Turn designMode off</button>
</div>
<select
id="single-select"
aria-label="single select"
Expand Down
27 changes: 27 additions & 0 deletions src/cdk/testing/tests/test-main-component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import {
ViewChild,
ViewEncapsulation
} from '@angular/core';
import {fromEvent, Observable, defer} from 'rxjs';
import {map, startWith} from 'rxjs/operators';

@Component({
selector: 'test-main',
Expand Down Expand Up @@ -48,6 +50,17 @@ export class TestMainComponent implements OnDestroy {
@ViewChild('clickTestElement') clickTestElement: ElementRef<HTMLElement>;
@ViewChild('taskStateResult') taskStateResultElement: ElementRef<HTMLElement>;

@ViewChild('inputEl', {static: true}) inputElement: ElementRef<HTMLElement>;
@ViewChild('editable', {static: true}) editableElement: ElementRef<HTMLElement>;
@ViewChild('notEditable', {static: true}) notEditableElement: ElementRef<HTMLElement>;

readonly inputElementInputEventCount$ =
defer(() => countInputEvent(this.inputElement));
readonly editableElementInputEventCount$ =
defer(() => countInputEvent(this.editableElement));
readonly notEditableElementInputEventCount$ =
defer(() => countInputEvent(this.notEditableElement));

private _fakeOverlayElement: HTMLElement;

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

turnOnDesignMode() {
document.designMode = 'on';
}

turnOffDesignMode() {
document.designMode = 'off';
}

runTaskOutsideZone() {
this._zone.runOutsideAngular(() => setTimeout(() => {
this.taskStateResultElement.nativeElement.textContent = 'result';
Expand All @@ -114,3 +135,9 @@ export class TestMainComponent implements OnDestroy {
obj.y = Math.round(event.clientY - top);
}
}

function countInputEvent(elementRef: ElementRef<HTMLElement>): Observable<number> {
return fromEvent(elementRef.nativeElement, 'input').pipe(
map((event, index) => index + 1),
startWith(0));
}
3 changes: 2 additions & 1 deletion src/cdk/testing/tests/testbed.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
import {TestbedHarnessEnvironment} from '@angular/cdk/testing/testbed';
import {waitForAsync, ComponentFixture, fakeAsync, TestBed} from '@angular/core/testing';
import {querySelectorAll as piercingQuerySelectorAll} from 'kagekiri';
import {crossEnvironmentSpecs} from './cross-environment.spec';
import {crossEnvironmentSpecs, TestEnvironment} from './cross-environment.spec';
import {FakeOverlayHarness} from './harnesses/fake-overlay-harness';
import {MainComponentHarness} from './harnesses/main-component-harness';
import {TestComponentsModule} from './test-components-module';
Expand Down Expand Up @@ -167,6 +167,7 @@ describe('TestbedHarnessEnvironment', () => {
});

describe('environment independent', () => crossEnvironmentSpecs(
TestEnvironment.TEST_BED,
() => TestbedHarnessEnvironment.loader(fixture),
() => TestbedHarnessEnvironment.harnessForFixture(fixture, MainComponentHarness),
() => Promise.resolve(document.activeElement!.id),
Expand Down