src/screenplay/questions/targets.ts

/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { Answerable, AnswersQuestions, Expectation, List, LogicError, MetaQuestion, Question, UsesAbilities } from '@serenity-js/core';
import { formatted } from '@serenity-js/core/lib/io';
import type { Element, ElementArray } from 'webdriverio';

import { ElementArrayListAdapter } from './lists';
import { Locator } from './locators';
import { NestedTargetBuilder } from './NestedTargetBuilder';
import { TargetBuilder } from './TargetBuilder';

/**
 * @desc
 *  A type alias representing a {@link @serenity-js/core/lib/screenplay/questions~List} of WebdriverIO Web elements.
 *
 * @public
 *
 * @see {@link @serenity-js/core/lib/screenplay/questions~List}
 *
 * @typedef {List<ElementArrayListAdapter, Promise<Element<'async'>>, Promise<ElementArray>>} TargetList
 */
export type TargetList = List<ElementArrayListAdapter, Promise<Element<'async'>>, Promise<ElementArray>>;

/**
 * @desc
 *  Provides a convenient way to retrieve a single web element or multiple web elements,
 *  so that they can be used with Serenity/JS {@link @serenity-js/core/lib/screenplay~Interaction}s.
 *
 *  Check out the examples below, as well as the unit tests demonstrating the usage.
 *
 *  @example <caption>Imaginary website under test</caption>
 *   <body>
 *       <ul id="basket">
 *           <li><a href="#">Apple</a></li>
 *           <li><a href="#">Banana</a></li>
 *           <li><a href="#">Coconut</a></li>
 *           <li><a href="#" class="has-discount">Date</a></li>
 *       </ul>
 *       <div id="summary">
 *           <strong class="out-of-stock">Coconut</strong> is not available
 *       </div>
 *       <button type="submit">Proceed to Checkout</button>
 *   </body>
 *
 *  @example <caption>Locating a single element</caption>
 *   import { by, Target, TargetElement } from '@serenity-js/webdriverio';
 *
 *   const proceedToCheckoutButton: TargetElement =
 *       Target.the('Proceed to Checkout button').located(by.css(`button[type='submit']`));
 *
 *  @example <caption>Locating multiple elements</caption>
 *   import { by, Target, TargetElements } from '@serenity-js/webdriverio';
 *
 *   const basketItems: TargetElements =
 *       Target.all('items in the basket').located(by.css('ul#basket li'));
 *
 *  @example <caption>Locating element relative to another element</caption>
 *   import { by, Target, TargetElement } from '@serenity-js/webdriverio';
 *
 *   const summary: TargetElement =
 *       Target.the('summary').located(by.id('message'));
 *
 *   const outOfStockItem: TargetElement =
 *       Target.the('out of stock item').of(summary).located(by.css('.out-of-stock'))
 *
 *  @example <caption>Filtering elements matched by a locator</caption>
 *   import { by, Target, Text } from '@serenity-js/webdriverio';
 *   import { endsWith } from '@serenity-js/assertions';
 *
 *   const basketItems =
 *       Target.all('items in the basket').located(by.css('ul#basket li'))
 *          .where(Text, endsWith('e'));    // Apple, Date
 *
 *  @example <caption>Counting items matched by a locator</caption>
 *   import { endsWith } from '@serenity-js/assertions';
 *   import { Question } from '@serenity-js/core';
 *   import { by, Target, Text } from '@serenity-js/webdriverio';
 *
 *   const basketItemsCount: Question<Promise<number>> =
 *       Target.all('items in the basket').located(by.css('ul#basket li'))
 *          .count()    // 4
 *
 *  @example <caption>Getting first item matched by a locator</caption>
 *   import { Question } from '@serenity-js/core';
 *   import { by, Target } from '@serenity-js/webdriverio';
 *   import { Element } from 'webdriverio';
 *
 *   const apple: Question<Promise<Element<'async'>>>  =
 *       Target.all('items in the basket').located(by.css('ul#basket li'))
 *          .first()
 *
 *  @example <caption>Getting last item matched by a locator</caption>
 *   import { Question } from '@serenity-js/core';
 *   import { by, Target } from '@serenity-js/webdriverio';
 *   import { endsWith } from '@serenity-js/assertions';
 *   import { Element } from 'webdriverio';
 *
 *   const date: Question<Promise<Element<'async'>>>  =
 *       Target.all('items in the basket').located(by.css('ul#basket li'))
 *          .last()
 *
 *  @example <caption>Getting nth item matched by a locator</caption>
 *   import { Question } from '@serenity-js/core';
 *   import { by, Target } from '@serenity-js/webdriverio';
 *   import { Element } from 'webdriverio';
 *
 *   const banana: Question<Promise<Element<'async'>>>  =
 *       Target.all('items in the basket').located(by.css('ul#basket li'))
 *          .get(1)
 *
 *  @example <caption>Using multiple filters and nested targets</caption>
 *   import { Question } from '@serenity-js/core';
 *   import { contain, endsWith } from '@serenity-js/assertions';
 *   import { by, CSSClasses, Target, Text } from '@serenity-js/webdriverio';
 *   import { Element } from 'webdriverio';
 *
 *   class Basket {
 *       static component = Target.the('basket').located(by.id('basket'));
 *
 *       static items     = Target.all('items').located(by.css('li'))
 *          .of(Basket.component);
 *
 *       static link      = Target.the('link').located(by.css('a'));
 *   }
 *
 *   const date: Question<Promise<Element<'async'>>>  =
 *       Basket.items
 *          .where(Text, endsWith('e'))
 *          .where(CSSClasses.of(Basket.link), contain('has-discount'))
 *          .first()
 *
 *  @example <caption>Clicking on an element</caption>
 *   import { actorCalled } from '@serenity-js/core';
 *   import { BrowseTheWeb, Click } from '@serenity-js/webdriverio';
 *
 *   actorCalled('Jane')
 *       .whoCan(BrowseTheWeb.using(browser))
 *       .attemptsTo(
 *           Click.on(proceedToCheckoutButton),
 *       );
 *
 *  @example <caption>Retrieving text of multiple elements and performing an assertion</caption>
 *   import { Ensure, contain } from '@serenity-js/assertions';
 *   import { actorCalled } from '@serenity-js/core';
 *   import { BrowseTheWeb, Text } from '@serenity-js/webdriverio';
 *
 *   const basketItemNames = Text.ofAll(basketItems);
 *
 *   actorCalled('Jane')
 *       .whoCan(BrowseTheWeb.using(browser))
 *       .attemptsTo(
 *           Ensure.that(basketItemNames, contain('Apple'))
 *       );
 *
 *  @example <caption>Waiting on an element</caption>
 *   import { actorCalled } from '@serenity-js/core';
 *   import { BrowseTheWeb, Wait, isClickable } from '@serenity-js/webdriverio';
 *
 *   actorCalled('Jane')
 *       .whoCan(BrowseTheWeb.using(browser))
 *       .attemptsTo(
 *           Wait.until(proceedToCheckoutButton, isClickable()),
 *       );
 */
export class Target {

    /**
     * @desc
     *  Locates a single Web element
     *
     * @param {string} description
     *  A human-readable name of the element, which will be used in the report
     *
     * @returns {TargetBuilder<TargetElement> & NestedTargetBuilder<TargetNestedElement>}
     */
    static the(description: string): TargetBuilder<TargetElement> & NestedTargetBuilder<TargetNestedElement> {
        return {
            located(locator: Locator): TargetElement {
                return new TargetElement(`the ${ description }`, locator);
            },

            of(parent: Answerable<Element<'async'>>) {
                return {
                    located(locator: Locator): TargetNestedElement {
                        return new TargetNestedElement(parent, new TargetElement(description, locator));
                    }
                }
            }
        }
    }

    /**
     * @desc
     *  Locates a group of Web elements
     *
     * @param {string} description
     *  A human-readable name of the group of elements, which will be used in the report
     *
     * @returns {TargetBuilder<TargetElements> & NestedTargetBuilder<TargetNestedElements>}
     */
    static all(description: string): TargetBuilder<TargetElements> & NestedTargetBuilder<TargetNestedElements> {
        return {
            located(locator: Locator): TargetElements {
                return new TargetElements(description, locator);
            },

            of(parent: Answerable<Element<'async'>>) {
                return {
                    located(locator: Locator): TargetNestedElements {
                        return new TargetNestedElements(parent, new TargetElements(description, locator));
                    }
                }
            }
        }
    }
}

/**
 * @desc
 *  You probably don't want to use this class directly. See {@link Target} instead.
 *
 * @extends {@serenity-js/core/lib/screenplay~Question}
 * @implements {@serenity-js/core/lib/screenplay/questions~MetaQuestion}
 *
 * @see {@link Target}
 */
export class TargetElements
    extends Question<Promise<ElementArray>>
    implements MetaQuestion<Answerable<Element<'async'>>, Promise<ElementArray>>
{
    private readonly list: List<ElementArrayListAdapter, Promise<Element<'async'>>, Promise<ElementArray>>;

    constructor(
        description: string,
        private readonly locator: Locator,
    ) {
        super(description);
        this.list = new List(new ElementArrayListAdapter(this));
    }

    of(parent: Answerable<Element<'async'>>): TargetNestedElements {
        return new TargetNestedElements(parent, this);
    }

    count(): Question<Promise<number>> {
        return this.list.count();
    }

    first(): Question<Promise<Element<'async'>>> {
        return this.list.first()
    }

    last(): Question<Promise<Element<'async'>>> {
        return this.list.last()
    }

    get(index: number): Question<Promise<Element<'async'>>> {
        return this.list.get(index);
    }

    where<Answer_Type>(
        question: MetaQuestion<Answerable<Element<'async'>>, Promise<Answer_Type> | Answer_Type>,
        expectation: Expectation<any, Answer_Type>,
    ): TargetList {
        return this.list.where(question, expectation);
    }

    answeredBy(actor: AnswersQuestions & UsesAbilities): Promise<ElementArray> {
        return this.locator.allMatching()
            .describedAs(this.subject)
            .answeredBy(actor);
    }
}

/**
 * @desc
 *  You probably don't want to use this class directly. See {@link Target} instead.
 *
 * @extends {@serenity-js/core/lib/screenplay~Question}
 * @implements {@serenity-js/core/lib/screenplay/questions~MetaQuestion}
 *
 * @see {@link Target}
 */
export class TargetNestedElements
    extends Question<Promise<ElementArray>>
    implements MetaQuestion<Answerable<Element<'async'>>, Promise<ElementArray>>
{
    private readonly list: List<ElementArrayListAdapter, Promise<Element<'async'>>, Promise<ElementArray>>;

    constructor(
        private readonly parent: Answerable<Element<'async'>>,
        private readonly children: Answerable<ElementArray>,
    ) {
        super(`${ children } of ${ parent }`);
        this.list = new List(new ElementArrayListAdapter(this));
    }

    of(parent: Answerable<Element<'async'>>): Question<Promise<ElementArray>> {
        return new TargetNestedElements(parent, this);
    }

    count(): Question<Promise<number>> {
        return this.list.count();
    }

    first(): Question<Promise<Element<'async'>>> {
        return this.list.first()
    }

    last(): Question<Promise<Element<'async'>>> {
        return this.list.last()
    }

    get(index: number): Question<Promise<Element<'async'>>> {
        return this.list.get(index);
    }

    where<Answer_Type>(
        question: MetaQuestion<Answerable<Element<'async'>>, Promise<Answer_Type> | Answer_Type>,
        expectation: Expectation<any, Answer_Type>,
    ): TargetList {
        return this.list.where(question, expectation);
    }

    async answeredBy(actor: AnswersQuestions & UsesAbilities): Promise<ElementArray> {
        const parent   = await actor.answer(this.parent);
        const children = await actor.answer(this.children);

        if (! parent) {
            throw new LogicError(formatted `Couldn't find ${ this.parent }`);
        }

        return parent.$$(children.selector);
    }
}

/**
 * @desc
 *  You probably don't want to use this class directly. See {@link Target} instead.
 *
 * @extends {@serenity-js/core/lib/screenplay~Question}
 * @implements {@serenity-js/core/lib/screenplay/questions~MetaQuestion}
 *
 * @see {@link Target}
 */
export class TargetElement
    extends Question<Promise<Element<'async'>>>
    implements MetaQuestion<Answerable<Element<'async'>>, Promise<Element<'async'>>>
{
    constructor(
        description: string,
        private readonly locator: Locator,
    ) {
        super(description);
    }

    of(parent: Answerable<Element<'async'>>): Question<Promise<Element<'async'>>> {
        return new TargetNestedElement(parent, this);
    }

    answeredBy(actor: AnswersQuestions & UsesAbilities): Promise<Element<'async'>> {
        return this.locator.firstMatching()
            .describedAs(this.subject)
            .answeredBy(actor);
    }
}

/**
 * @desc
 *  You probably don't want to use this class directly. See {@link Target} instead.
 *
 * @extends {@serenity-js/core/lib/screenplay~Question}
 * @implements {@serenity-js/core/lib/screenplay/questions~MetaQuestion}
 *
 * @see {@link Target}
 */
export class TargetNestedElement
    extends Question<Promise<Element<'async'>>>
    implements MetaQuestion<Answerable<Element<'async'>>, Promise<Element<'async'>>>
{
    constructor(
        private readonly parent: Answerable<Element<'async'>>,
        private readonly child: Answerable<Element<'async'>>,
    ) {
        super(`${ child } of ${ parent }`);
    }

    of(parent: Answerable<Element<'async'>>): Question<Promise<Element<'async'>>> {
        return new TargetNestedElement(parent, this);
    }

    async answeredBy(actor: AnswersQuestions & UsesAbilities): Promise<Element<'async'>> {
        const parent = await actor.answer(this.parent);
        const child  = await actor.answer(this.child);

        if (! parent) {
            throw new LogicError(formatted `Couldn't find ${ this.parent }`);
        }

        return parent.$(child.selector);
    }
}