UNPKG

@serenity-js/core

Version:

The core Serenity/JS framework, providing the Screenplay Pattern interfaces, as well as the test reporting and integration infrastructure

403 lines (340 loc) 15 kB
import { ListItemNotFoundError, LogicError } from '../../errors'; import { d } from '../../io'; import type { UsesAbilities } from '../abilities'; import type { Actor } from '../Actor'; import type { Answerable } from '../Answerable'; import type { MetaQuestionAdapter, QuestionAdapter } from '../Question'; import { Question } from '../Question'; import type { AnswersQuestions, ChainableMetaQuestion, MetaQuestion } from '../questions'; import { Task } from '../Task'; import type { Expectation } from './Expectation'; import { ExpectationMet } from './expectations'; /** * Serenity/JS Screenplay Pattern-style wrapper around [`Array`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array) * and array-like structures - see [`PageElement`](https://serenity-js.org/api/web/class/PageElements/). * * @group Questions */ export abstract class List<Item_Type> extends Question<Promise<Array<Item_Type>>> { protected subject?: string; static of<IT, CT, RQT extends (Question<Promise<Array<IT>>> | Question<Array<IT>>)>(collection: Answerable<Array<IT>> & ChainableMetaQuestion<CT, RQT>): MetaList<CT, IT>; static of<IT>(collection: Answerable<Array<IT>>): List<IT>; static of<IT>(collection: unknown): unknown { if (Question.isAMetaQuestion<unknown, Question<Array<IT>>>(collection)) { return new MetaList(collection as Answerable<Array<IT>> & ChainableMetaQuestion<unknown, Question<Array<IT>>>); } return new ArrayList<IT>(collection as Answerable<Array<IT>>); } constructor(protected readonly collection: Answerable<Array<Item_Type>>) { super(d`${ collection }`); } forEach(callback: (current: CurrentItem<Item_Type>, index: number, items: Array<Item_Type>) => Promise<void> | void): Task { return new ForEachLoop(this.collection, this.toString(), callback); } abstract eachMappedTo<Mapped_Item_Type>( question: MetaQuestion<Item_Type, Question<Promise<Mapped_Item_Type> | Mapped_Item_Type>> ): List<Mapped_Item_Type>; abstract where<Answer_Type>( question: MetaQuestion<Item_Type, Question<Promise<Answer_Type> | Answer_Type>>, expectation: Expectation<Answer_Type> ): List<Item_Type>; abstract count(): QuestionAdapter<number>; abstract first(): QuestionAdapter<Item_Type>; abstract last(): QuestionAdapter<Item_Type>; abstract nth(index: number): QuestionAdapter<Item_Type>; async answeredBy(actor: AnswersQuestions & UsesAbilities): Promise<Array<Item_Type>> { const collection = await actor.answer(this.collection); if (! Array.isArray(collection)) { throw new LogicError(d`A List has to wrap an Array-compatible object. ${ collection } given.`); } return collection; } /** * @param {number} index */ protected ordinal(index: number): string { const lastDigit = Math.abs(index) % 10, lastTwoDigits = Math.abs(index) % 100; switch (true) { case (lastDigit === 1 && lastTwoDigits !== 11): return index + 'st'; case (lastDigit === 2 && lastTwoDigits !== 12): return index + 'nd'; case (lastDigit === 3 && lastTwoDigits !== 13): return index + 'rd'; default: return index + 'th'; } } } /** * @package */ class ArrayList<Item_Type> extends List<Item_Type> { override eachMappedTo<Mapped_Item_Type>( question: MetaQuestion<Item_Type, Question<Promise<Mapped_Item_Type> | Mapped_Item_Type>>, ): List<Mapped_Item_Type> { return new ArrayList( new EachMappedTo(this.collection, question, this.toString()) ); } override where<Answer_Type>( question: MetaQuestion<Item_Type, Question<Promise<Answer_Type> | Answer_Type>>, expectation: Expectation<Answer_Type> ): List<Item_Type> { return new ArrayList<Item_Type>( new Where(this.collection, question, expectation, this.toString()) ) as this; } override count(): QuestionAdapter<number> { return Question.about(`the number of ${ this.toString() }`, async actor => { const items = await this.answeredBy(actor); return items.length; }); } override first(): QuestionAdapter<Item_Type> { return Question.about(`the first of ${ this.toString() }`, async actor => { const items = await this.answeredBy(actor); if (items.length === 0) { throw new ListItemNotFoundError(d`Can't retrieve the first item from a list with 0 items: ${ items }`) } return items[0]; }); } override last(): QuestionAdapter<Item_Type> { return Question.about(`the last of ${ this.toString() }`, async actor => { const items = await this.answeredBy(actor); if (items.length === 0) { throw new ListItemNotFoundError(d`Can't retrieve the last item from a list with 0 items: ${ items }`) } return items.at(-1); }); } override nth(index: number): QuestionAdapter<Item_Type> { return Question.about(`the ${ this.ordinal(index + 1) } of ${ this.toString() }`, async actor => { const items = await this.answeredBy(actor); if (index < 0 || items.length <= index) { throw new ListItemNotFoundError(`Can't retrieve the ${ this.ordinal(index) } item from a list with ${ items.length } items: ` + d`${ items }`) } return items[index]; }); } } /** * Serenity/JS Screenplay Pattern-style wrapper around * a [`ChainableMetaQuestion`](https://serenity-js.org/api/core/interface/ChainableMetaQuestion/) representing a collection * that can be resolved in `Supported_Context_Type` of another [`Question`](https://serenity-js.org/api/core/class/Question/). * * For example, [`PageElements.located`](https://serenity-js.org/api/web/class/PageElements/#located) returns `MetaList<PageElement>`, * which allows for the collection of page elements to be resolved in the context * of dynamically-provided root element. * * ```typescript * import { By, PageElements, PageElement } from '@serenity-js/web' * * const firstLabel = () => * PageElements.located(By.css('label')) * .first() * .describedAs('first label') * * const exampleForm = () => * PageElement.located(By.css('form#example1')) * .describedAs('example form') * * const anotherExampleForm = () => * PageElement.located(By.css('form#example2')) * .describedAs('another example form') * * // Next, you can compose the above questions dynamically with various "contexts": * // firstLabel().of(exampleForm()) * // firstLabel().of(anotherExampleForm()) * ``` * * @group Questions */ export class MetaList<Supported_Context_Type, Item_Type> extends List<Item_Type> implements ChainableMetaQuestion<Supported_Context_Type, MetaList<Supported_Context_Type, Item_Type>> { constructor( protected override readonly collection: Answerable<Array<Item_Type>> & ChainableMetaQuestion<Supported_Context_Type, Question<Promise<Array<Item_Type>>> | Question<Array<Item_Type>>> ) { super(collection); } of(context: Answerable<Supported_Context_Type>): MetaList<Supported_Context_Type, Item_Type> { return new MetaList<Supported_Context_Type, Item_Type>( this.collection.of(context) ).describedAs(this.toString() + d` of ${ context }`) } override eachMappedTo<Mapped_Item_Type>( question: MetaQuestion<Item_Type, Question<Promise<Mapped_Item_Type> | Mapped_Item_Type>>, ): MetaList<Supported_Context_Type, Mapped_Item_Type> { return new MetaList( new MetaEachMappedTo(this.collection, question, this.toString()), ); } override where<Answer_Type>( question: MetaQuestion<Item_Type, Question<Promise<Answer_Type> | Answer_Type>>, expectation: Expectation<Answer_Type> ): MetaList<Supported_Context_Type, Item_Type> { return new MetaList<Supported_Context_Type, Item_Type>( new MetaWhere(this.collection, question, expectation, this.toString()) ) as this; } override count(): MetaQuestionAdapter<Supported_Context_Type, number> { return Question.about(`the number of ${ this.toString() }`, async actor => { const items = await this.answeredBy(actor); return items.length; }, (parent: Answerable<Supported_Context_Type>) => this.of(parent).count() ); } override first(): MetaQuestionAdapter<Supported_Context_Type, Item_Type> { return Question.about(`the first of ${ this.toString() }`, async actor => { const items = await this.answeredBy(actor); if (items.length === 0) { throw new ListItemNotFoundError(d`Can't retrieve the first item from a list with 0 items: ${ items }`) } return items[0]; }, (parent: Answerable<Supported_Context_Type>) => this.of(parent).first() ); } override last(): MetaQuestionAdapter<Supported_Context_Type, Item_Type> { return Question.about(`the last of ${ this.toString() }`, async actor => { const items = await this.answeredBy(actor); if (items.length === 0) { throw new ListItemNotFoundError(d`Can't retrieve the last item from a list with 0 items: ${ items }`) } return items.at(-1); }, (parent: Answerable<Supported_Context_Type>) => this.of(parent).last() ); } override nth(index: number): MetaQuestionAdapter<Supported_Context_Type, Item_Type> { return Question.about(`the ${ this.ordinal(index + 1) } of ${ this.toString() }`, async actor => { const items = await this.answeredBy(actor); if (index < 0 || items.length <= index) { throw new ListItemNotFoundError(`Can't retrieve the ${ this.ordinal(index) } item from a list with ${ items.length } items: ` + d`${ items }`) } return items[index]; }, (parent: Answerable<Supported_Context_Type>) => this.of(parent).nth(index) ); } } /** * @package */ class Where<Item_Type, Answer_Type> extends Question<Promise<Array<Item_Type>>> { constructor( protected readonly collection: Answerable<Array<Item_Type>>, protected readonly question: MetaQuestion<Item_Type, Question<Promise<Answer_Type> | Answer_Type>>, protected readonly expectation: Expectation<Answer_Type>, originalSubject: string, ) { const prefix = collection instanceof Where ? ' and' : ' where'; super(originalSubject + prefix + d` ${ question } does ${ expectation }`); } async answeredBy(actor: AnswersQuestions & UsesAbilities): Promise<Array<Item_Type>> { try { const collection = await actor.answer(this.collection); const results: Item_Type[] = []; for (const item of collection) { const actual = this.question.of(item) as Answerable<Answer_Type>; const expectationOutcome = await actor.answer(this.expectation.isMetFor(actual)); if (expectationOutcome instanceof ExpectationMet) { results.push(item); } } return results; } catch (error) { throw new LogicError(d`Couldn't check if ${ this.question } of an item of ${ this.collection } does ${ this.expectation }: ` + error.message, error); } } } /** * @package */ class MetaWhere<Supported_Context_Type, Item_Type, Answer_Type> extends Where<Item_Type, Answer_Type> implements ChainableMetaQuestion<Supported_Context_Type, Question<Promise<Array<Item_Type>>> | Question<Array<Item_Type>>> { of(context: Answerable<Supported_Context_Type>): MetaWhere<Supported_Context_Type, Item_Type, Answer_Type> { return new MetaWhere<Supported_Context_Type, Item_Type, Answer_Type>( (this.collection as Answerable<Array<Item_Type>> & ChainableMetaQuestion<Supported_Context_Type, Question<Promise<Array<Item_Type>>> | Question<Array<Item_Type>>>).of(context), this.question, this.expectation, this.toString() ); } } /** * @package */ class EachMappedTo<Item_Type, Mapped_Item_Type> extends Question<Promise<Array<Mapped_Item_Type>>> { constructor( protected readonly collection: Answerable<Array<Item_Type>>, protected readonly mapping: MetaQuestion<Item_Type, Question<Promise<Mapped_Item_Type> | Mapped_Item_Type>>, originalSubject: string, ) { super(originalSubject + d` mapped to ${ mapping }`); } async answeredBy(actor: AnswersQuestions & UsesAbilities): Promise<Array<Mapped_Item_Type>> { const collection: Array<Item_Type> = await actor.answer(this.collection); const mapped: Mapped_Item_Type[] = []; for (const item of collection) { mapped.push(await actor.answer(this.mapping.of(item))) } return mapped; } } /** * @package */ class MetaEachMappedTo<Supported_Context_Type, Item_Type, Mapped_Item_Type> extends EachMappedTo<Item_Type, Mapped_Item_Type> { of(context: Answerable<Supported_Context_Type>): MetaEachMappedTo<Supported_Context_Type, Item_Type, Mapped_Item_Type> { return new MetaEachMappedTo<Supported_Context_Type, Item_Type, Mapped_Item_Type>( (this.collection as Answerable<Array<Item_Type>> & ChainableMetaQuestion<Supported_Context_Type, Question<Promise<Array<Item_Type>>> | Question<Array<Item_Type>>>).of(context), this.mapping, this.toString() ); } } /** * @package */ class ForEachLoop<Item_Type> extends Task { constructor( private readonly collection: Answerable<Array<Item_Type>>, private readonly subject: string, private readonly fn: (current: CurrentItem<Item_Type>, index: number, items: Array<Item_Type>) => Promise<void> | void, ) { super(`#actor iterates over ${ subject }`); } async performAs(actor: Actor): Promise<void> { const collection: Array<Item_Type> = await actor.answer(this.collection); for (const [index, item] of collection.entries()) { await this.fn({ actor, item }, index, collection); } } } /** * @group Questions */ export interface CurrentItem<Item_Type> { item: Item_Type; actor: Actor; }