@serenity-js/web
Version:
Serenity/JS Screenplay Pattern library offering a flexible, web driver-agnostic approach for interacting with web-based user interfaces and components, suitable for various testing contexts
185 lines (166 loc) • 6.49 kB
text/typescript
import type { Activity, Answerable, AnswersQuestions, UsesAbilities } from '@serenity-js/core';
import { Interaction, Question, the } from '@serenity-js/core';
import { asyncMap } from '@serenity-js/core/lib/io';
import { BrowseTheWeb } from '../abilities';
import type { PageElement } from '../models';
import { Key } from '../models';
import { PageElementInteraction } from './PageElementInteraction';
/**
* Instructs an [actor](https://serenity-js.org/api/core/class/Actor/) who has the [ability](https://serenity-js.org/api/core/class/Ability/) to [`BrowseTheWeb`](https://serenity-js.org/api/web/class/BrowseTheWeb/)
* to send a key press or a sequence of keys to a Web element.
*
* **Note:** On macOS, some keyboard shortcuts might not work
* with the [`devtools` protocol](https://webdriver.io/docs/automationProtocols/#devtools-protocol).
*
* For example:
* - to *copy*, instead of [`Key.Meta`](https://serenity-js.org/api/web/class/Key/#Meta)+`C`, use [`Key.Control`](https://serenity-js.org/api/web/class/Key/#Control)+[`Key.Insert`](https://serenity-js.org/api/web/class/Key/#Insert)
* - to *cut*, instead of [`Key.Meta`](https://serenity-js.org/api/web/class/Key/#Meta)+`X`, use [`Key.Control`](https://serenity-js.org/api/web/class/Key/#Control)+[`Key.Delete`](https://serenity-js.org/api/web/class/Key/#Delete)
* - to *paste*, instead of [`Key.Meta`](https://serenity-js.org/api/web/class/Key/#Meta)+`V`, use [`Key.Shift`](https://serenity-js.org/api/web/class/Key/#Shift)+[`Key.Insert`](https://serenity-js.org/api/web/class/Key/#Insert)
*
* ## Example widget
*
* ```html
* <form>
* <input type="text" name="example" id="example" />
* </form>
* ```
*
* ## Lean Page Object describing the widget
*
* ```ts
* import { By, PageElement } from '@serenity-js/web'
*
* class Form {
* static exampleInput = () =>
* PageElement.located(By.id('example'))
* .describedAs('example input')
* }
* ```
*
* ## Pressing keys
*
* ```ts
* import { actorCalled } from '@serenity-js/core'
* import { Key, Press, Value } from '@serenity-js/web'
* import { Ensure, equals } from '@serenity-js/assertions'
*
* await actorCalled('Priyanka')
* .attemptsTo(
* Press.the('H', 'i', '!', Key.ENTER).in(Form.exampleInput()),
* Ensure.that(Value.of(Form.exampleInput), equals('Hi!')),
* )
* ```
*
* ## Learn more
*
* - [`Key`](https://serenity-js.org/api/web/class/Key/)
* - [`BrowseTheWeb`](https://serenity-js.org/api/web/class/BrowseTheWeb/)
* - [`PageElement`](https://serenity-js.org/api/web/class/PageElement/)
*
* @group Activities
*/
export class Press extends PageElementInteraction {
/**
* Instantiates an [interaction](https://serenity-js.org/api/core/class/Interaction/)
* that instructs the [actor](https://serenity-js.org/api/core/class/Actor/)
* to press a sequence of [keys](https://serenity-js.org/api/web/class/Key/),
*
* When no `field` is specified, the key sequence will be sent to the currently focused element,
* and if no element is focused - to the `document.body` to handle.
*
* @param keys
* A sequence of one or more keys to press
*/
static the(...keys: Array<Answerable<Key | string | Key[] | string[]>>): Activity & { in: (field: Answerable<PageElement>) => Interaction } {
return new Press(KeySequence.of(keys));
}
/**
* Send the key sequence to a specific element.
*
* @param field
*/
in(field: Answerable<PageElement>): Interaction {
return new PressKeyInField(this.keys, field)
}
/**
* @param keys
* A sequence of one or more keys to press
*/
protected constructor(
private readonly keys: Answerable<Array<Key | string>>
) {
super(the `#actor presses ${ keys }`);
}
/**
* @inheritDoc
*/
async performAs(actor: UsesAbilities & AnswersQuestions): Promise<void> {
const keys = await actor.answer(this.keys);
const page = await BrowseTheWeb.as(actor).currentPage();
return page.sendKeys(keys);
}
}
class PressKeyInField extends PageElementInteraction {
/**
* @param {Answerable<Array<Key | string>>} keys
* A sequence of one or more keys to press
*
* @param {Answerable<PageElement>} field
* Web element to send the keys to
*/
constructor(
private readonly keys: Answerable<Array<Key | string>>,
private readonly field: Answerable<PageElement> /* todo | Question<AlertPromise> | AlertPromise */,
) {
super(the`#actor presses ${ keys } in ${ field }`, Interaction.callerLocation(3));
}
/**
* @inheritDoc
*/
async performAs(actor: UsesAbilities & AnswersQuestions): Promise<void> {
const field = await this.resolve(actor, this.field);
const keys = await actor.answer(this.keys);
const page = await BrowseTheWeb.as(actor).currentPage();
// fix for protractor
await page.executeScript(
/* c8 ignore next */
function focus(element: any) {
element.focus();
},
await field,
);
return page.sendKeys(keys);
}
}
/**
* @package
*/
class KeySequence extends Question<Promise<Array<Key | string>>> {
static of(keys: Array<Answerable<Key | string | Key[] | string[]>>) {
return new KeySequence(keys);
}
constructor(private readonly keys: Array<Answerable<Key | string | Key[] | string[]>>) {
super(KeySequence.describe(keys));
}
async answeredBy(actor: AnswersQuestions & UsesAbilities): Promise<Array<string | Key>> {
const keys = await asyncMap(this.keys, key => actor.answer(key));
return keys
.flat()
.filter(key => !! key);
}
private static describe(keys: Array<Answerable<Key | string | Key[] | string[]>>): string {
const prefix = keys.length === 1 ? 'key' : 'keys';
const description = keys.reduce((acc, key, index) => {
const separator = Key.isKey(key) && key.isModifier
? '-'
: acc.separator;
return {
description: index === 0
? `${ key }`
: `${ acc.description }${acc.separator}${ key }`,
separator,
}
}, { description: '', separator: ', ' }).description;
return `${ prefix } ${ description }`;
}
}