UNPKG

@serenity-js/core

Version:

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

707 lines 27.8 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); Object.defineProperty(exports, "__esModule", { value: true }); exports.Question = void 0; const objects_1 = require("tiny-types/lib/objects"); const util = __importStar(require("util")); // eslint-disable-line unicorn/import-style const errors_1 = require("../errors"); const io_1 = require("../io"); const Interaction_1 = require("./Interaction"); const Describable_1 = require("./questions/Describable"); const tag_functions_1 = require("./questions/tag-functions"); const Unanswered_1 = require("./questions/Unanswered"); /** * **Questions** describe how [actors](https://serenity-js.org/api/core/class/Actor/) should query the system under test or the test environment to retrieve some information. * * Questions are the core building block of the [Screenplay Pattern](https://serenity-js.org/handbook/design/screenplay-pattern), * along with [actors](https://serenity-js.org/api/core/class/Actor/), [abilities](https://serenity-js.org/api/core/class/Ability/), * [interactions](https://serenity-js.org/api/core/class/Interaction/), * and [tasks](https://serenity-js.org/api/core/class/Task/). * * ![Screenplay Pattern](https://serenity-js.org/images/design/serenity-js-screenplay-pattern.png) * * Learn more about: * - [`Actor`](https://serenity-js.org/api/core/class/Actor/) * - [`Ability`](https://serenity-js.org/api/core/class/Ability/) * - [`Interaction`](https://serenity-js.org/api/core/class/Interaction/) * - [`QuestionAdapter`](https://serenity-js.org/api/core/#QuestionAdapter) * * ## Implementing a basic custom Question * * ```ts * import { actorCalled, AnswersQuestions, UsesAbilities, Question } from '@serenity-js/core' * import { Ensure, equals } from '@serenity-js/assertions' * * const LastItemOf = <T>(list: T[]): Question<T> => * Question.about('last item from the list', (actor: AnswersQuestions & UsesAbilities) => { * return list[list.length - 1] * }); * * await actorCalled('Quentin').attemptsTo( * Ensure.that(LastItemFrom([1,2,3]), equals(3)), * ) * ``` * * ## Implementing a Question that uses an Ability * * Just like the [interactions](https://serenity-js.org/api/core/class/Interaction/), a [`Question`](https://serenity-js.org/api/core/class/Question/) * also can use [actor's](https://serenity-js.org/api/core/class/Actor/) [abilities](https://serenity-js.org/api/core/class/Ability/). * * Here, we use the ability to [`CallAnApi`](https://serenity-js.org/api/rest/class/CallAnApi/) to retrieve a property of * an HTTP response. * * ```ts * import { AnswersQuestions, UsesAbilities, Question } from '@serenity-js/core' * import { CallAnApi } from '@serenity-js/rest' * * const TextOfLastResponseStatus = () => * Question.about<number>(`the text of the last response status`, actor => { * return CallAnApi.as(actor).mapLastResponse(response => response.statusText) * }) * ``` * * #### Learn more * - [`CallAnApi`](https://serenity-js.org/api/rest/class/CallAnApi/) * - [`LastResponse`](https://serenity-js.org/api/rest/class/LastResponse/) * * ## Mapping answers to other questions * * Apart from retrieving information, [questions](https://serenity-js.org/api/core/class/Question/) can be used to transform information retrieved by other questions. * * Here, we use the factory method [`Question.about`](https://serenity-js.org/api/core/class/Question/#about) to produce a question that makes the received [actor](https://serenity-js.org/api/core/class/Actor/) * answer [`LastResponse.status`](https://serenity-js.org/api/rest/class/LastResponse/#status) and then compare it against some expected value. * * ```ts * import { actorCalled, AnswersQuestions, UsesAbilities, Question } from '@serenity-js/core' * import { CallAnApi, LastResponse } from '@serenity-js/rest' * import { Ensure, equals } from '@serenity-js/assertions' * * const RequestWasSuccessful = () => * Question.about<number>(`the text of the last response status`, async actor => { * const status = await actor.answer(LastResponse.status()); * * return status === 200; * }) * * await actorCalled('Quentin') * .whoCan(CallAnApi.at('https://api.example.org/')); * .attemptsTo( * Send.a(GetRequest.to('/books/0-688-00230-7')), * Ensure.that(RequestWasSuccessful(), isTrue()), * ) * ``` * * Note that the above example is for demonstration purposes only, Serenity/JS provides an easier way to * verify the response status of the [`LastResponse`](https://serenity-js.org/api/rest/class/LastResponse/): * * ```ts * import { actorCalled } from '@serenity-js/core' * import { CallAnApi, LastResponse } from '@serenity-js/rest' * import { Ensure, equals } from '@serenity-js/assertions' * * await actorCalled('Quentin') * .whoCan(CallAnApi.at('https://api.example.org/')); * .attemptsTo( * Send.a(GetRequest.to('/books/0-688-00230-7')), * Ensure.that(LastResponse.status(), equals(200)), * ) * ``` * * @group Screenplay Pattern */ class Question extends Describable_1.Describable { static about(description, body, metaQuestionBody) { const statement = typeof metaQuestionBody === 'function' ? new MetaQuestionStatement(description, body, metaQuestionBody) : new QuestionStatement(description, body); return Question.createAdapter(statement); } /** * Generates a [`QuestionAdapter`](https://serenity-js.org/api/core/#QuestionAdapter) that recursively resolves * any [`Answerable`](https://serenity-js.org/api/core/#Answerable) fields of the provided object, * including [`Answerable`](https://serenity-js.org/api/core/#Answerable) fields * of [nested objects](https://serenity-js.org/api/core/#WithAnswerableProperties). * * Optionally, the method accepts `overrides` to be shallow-merged with the fields of the original `source`, * producing a new merged object. * * Overrides are applied from left to right, with subsequent objects overwriting property assignments of the previous ones. * * #### Resolving an object recursively using `Question.fromObject` * * ```ts * import { actorCalled, Question } from '@serenity-js/core' * import { Send, PostRequest } from '@serenity-js/rest' * import { By, Text, PageElement } from '@serenity-js/web' * * await actorCalled('Daisy') * .whoCan(CallAnApi.at('https://api.example.org')) * .attemptsTo( * Send.a( * PostRequest.to('/products/2') * .with( * Question.fromObject({ * name: Text.of(PageElement.located(By.css('.name'))), * }) * ) * ) * ); * ``` * * #### Merging objects using `Question.fromObject` * * ```ts * import { actorCalled, Question } from '@serenity-js/core' * import { Send, PostRequest } from '@serenity-js/rest' * import { By, Text, PageElement } from '@serenity-js/web' * * await actorCalled('Daisy') * .whoCan(CallAnApi.at('https://api.example.org')) * .attemptsTo( * Send.a( * PostRequest.to('/products/2') * .with( * Question.fromObject({ * name: Text.of(PageElement.located(By.css('.name'))), * quantity: undefined, * }, { * quantity: 2, * }) * ) * ) * ); * ``` * * #### Learn more * - [`WithAnswerableProperties`](https://serenity-js.org/api/core/#WithAnswerableProperties) * - [`RecursivelyAnswered`](https://serenity-js.org/api/core/#RecursivelyAnswered) * - [`Answerable`](https://serenity-js.org/api/core/#Answerable) * * @param source * @param overrides */ static fromObject(source, ...overrides) { return Question.about('value', async (actor) => { if (source === null || source === undefined) { return source; } const sources = []; for (const [i, currentSource] of [source, ...overrides].entries()) { sources.push(await recursivelyAnswer(actor, currentSource, `argument ${i}`)); } return Object.assign({}, ...sources); }); } /** * Generates a [`QuestionAdapter`](https://serenity-js.org/api/core/#QuestionAdapter) that resolves * any [`Answerable`](https://serenity-js.org/api/core/#Answerable) elements of the provided array. */ static fromArray(source, options) { const formatter = new ValueFormatter(ValueFormatter.defaultOptions); const description = source.length === 0 ? '[ ]' : Question.about(formatter.format(source), async (actor) => { const descriptions = await (0, io_1.asyncMap)(source, item => item instanceof Describable_1.Describable ? item.describedBy(actor) : Question.formattedValue(options).of(item).answeredBy(actor)); return `[ ${descriptions.join(', ')} ]`; }); return Question.about(description, async (actor) => { return await (0, io_1.asyncMap)(source, item => actor.answer(item)); }); } /** * Checks if the value is a [`Question`](https://serenity-js.org/api/core/class/Question/). * * @param maybeQuestion * The value to check */ static isAQuestion(maybeQuestion) { return !!maybeQuestion && typeof maybeQuestion.answeredBy === 'function'; } /** * Checks if the value is a [`MetaQuestion`](https://serenity-js.org/api/core/interface/MetaQuestion/). * * @param maybeMetaQuestion * The value to check */ static isAMetaQuestion(maybeMetaQuestion) { return !!maybeMetaQuestion && typeof maybeMetaQuestion['of'] === 'function' && maybeMetaQuestion['of'].length === 1; // arity of 1 } /** * Creates a [`MetaQuestion`](https://serenity-js.org/api/core/interface/MetaQuestion/) that can be composed with any [`Answerable`](https://serenity-js.org/api/core/#Answerable) * to produce a single-line description of its value. * * ```ts * import { actorCalled, Question } from '@serenity-js/core' * import { Ensure, equals } from '@serenity-js/assertions' * * const accountDetails = () => * Question.about('account details', actor => ({ name: 'Alice', age: 28 })) * * await actorCalled('Alice').attemptsTo( * Ensure.that( * Question.formattedValue().of(accountDetails()), * equals('{ name: "Alice", age: 28 }'), * ), * ) * ``` * * @param options */ static formattedValue(options) { return MetaQuestionAboutFormattedValue.using(options); } /** * Creates a [`MetaQuestion`](https://serenity-js.org/api/core/interface/MetaQuestion/) that can be composed with any [`Answerable`](https://serenity-js.org/api/core/#Answerable) * to return its value when the answerable is a [`Question`](https://serenity-js.org/api/core/class/Question/), * or the answerable itself otherwise. * * The description of the resulting question is produced by calling [`Question.describedBy`](https://serenity-js.org/api/core/class/Question/#describedBy) on the * provided answerable. * * ```ts * import { actorCalled, Question } from '@serenity-js/core' * import { Ensure, equals } from '@serenity-js/assertions' * * const accountDetails = () => * Question.about('account details', actor => ({ name: 'Alice', age: 28 })) * * await actorCalled('Alice').attemptsTo( * Ensure.that( * Question.description().of(accountDetails()), * equals('account details'), * ), * Ensure.that( * Question.value().of(accountDetails()), * equals({ name: 'Alice', age: 28 }), * ), * ) * ``` */ static value() { return new MetaQuestionAboutValue(); } static createAdapter(statement) { function getStatement() { return statement; } if (typeof statement[util.inspect.custom] === 'function') { Object.defineProperty( // statement must be a function because Proxy apply trap works only with functions getStatement, util.inspect.custom, { value: statement[util.inspect.custom].bind(statement), writable: false, }); } return new Proxy(getStatement, { get(currentStatement, key, receiver) { const target = currentStatement(); if (key === util.inspect.custom) { return target[util.inspect.custom].bind(target); } if (key === Symbol.toPrimitive) { return (_hint) => { return target.toString(); }; } if (key in target) { const field = Reflect.get(target, key); const isFunction = typeof field == 'function'; const mustAllowProxyChaining = isFunction && target instanceof QuestionStatement && key === 'describedAs'; // `describedAs` returns `this`, which must be bound to proxy itself to allow proxy chaining if (mustAllowProxyChaining) { // see https://javascript.info/proxy#proxy-limitations return field.bind(receiver); } return isFunction ? field.bind(target) : field; } if (key === 'then') { return; } return Question.about(Question.staticFieldDescription(target, key), async (actor) => { const answer = await actor.answer(target); if (!isDefined(answer)) { return undefined; // eslint-disable-line unicorn/no-useless-undefined } const field = answer[key]; return typeof field === 'function' ? field.bind(answer) : field; }).describedAs(Question.formattedValue()); }, set(currentStatement, key, value, receiver) { const target = currentStatement(); return Reflect.set(target, key, value); }, apply(currentStatement, _thisArgument, parameters) { const target = currentStatement(); return Question.about(Question.methodDescription(target, parameters), async (actor) => { const params = []; for (const parameter of parameters) { const answered = await actor.answer(parameter); params.push(answered); } const field = await actor.answer(target); return typeof field === 'function' ? field(...params) : field; }); }, getPrototypeOf(currentStatement) { return Reflect.getPrototypeOf(currentStatement()); }, }); } static staticFieldDescription(target, key) { // "of" is characteristic of Serenity/JS MetaQuestion if (key === 'of') { return `${target} ${key}`; } const originalSubject = (0, io_1.f) `${target}`; const fieldDescription = (typeof key === 'number' || /^\d+$/.test(String(key))) ? `[${String(key)}]` // array index : `.${String(key)}`; // field/method name return `${originalSubject}${fieldDescription}`; } static methodDescription(target, parameters) { const targetDescription = target.toString(); // this is a Serenity/JS MetaQuestion, of(singleParameter) if (targetDescription.endsWith(' of') && parameters.length === 1) { return `${targetDescription} ${parameters[0]}`; } const parameterDescriptions = [ '(', parameters.map(p => (0, io_1.f) `${p}`).join(', '), ')', ].join(''); return `${targetDescription}${parameterDescriptions}`; } /** * Changes the description of this object, as returned by [`Describable.describedBy`](https://serenity-js.org/api/core/class/Describable/#describedBy) * and [`Describable.toString`](https://serenity-js.org/api/core/class/Describable/#toString). * * @param description * Replaces the current description according to the following rules: * - If `description` is an [`Answerable`](https://serenity-js.org/api/core/#Answerable), it replaces the current description * - If `description` is a [`MetaQuestion`](https://serenity-js.org/api/core/interface/MetaQuestion/), the current description is passed as `context` to `description.of(context)`, and the result replaces the current description */ describedAs(description) { super.setDescription(Question.isAMetaQuestion(description) ? description.of(this) : description); return this; } /** * Maps this question to one of a different type. * * ```ts * Question.about('number returned as string', actor => '42') // returns: QuestionAdapter<string> * .as(Number) // returns: QuestionAdapter<number> * ``` * * @param mapping */ as(mapping) { return Question.about((0, io_1.f) `${this}.as(${mapping})`, async (actor) => { const answer = (await actor.answer(this)); return mapping(answer); }); } } exports.Question = Question; /** * @package */ class QuestionStatement extends Interaction_1.Interaction { body; answer = new Unanswered_1.Unanswered(); constructor(subject, body, location = QuestionStatement.callerLocation(4)) { super(subject, location); this.body = body; } /** * Returns a Question that resolves to `true` if resolving the `QuestionStatement` * returns a value other than `null` or `undefined`, and doesn't throw errors. */ isPresent() { return new IsPresent(this); } async answeredBy(actor) { this.answer = await this.body(actor); return this.answer; } async performAs(actor) { await this.body(actor); } [util.inspect.custom](depth, options, inspect) { return (0, io_1.inspectedObject)(this.answer)(depth, options, inspect); } describedAs(description) { super.setDescription(Question.isAMetaQuestion(description) ? description.of(this) : description); return this; } as(mapping) { return Question.about((0, io_1.f) `${this}.as(${mapping})`, async (actor) => { const answer = await actor.answer(this); if (!isDefined(answer)) { return undefined; // eslint-disable-line unicorn/no-useless-undefined } return mapping(answer); }); } } /** * @package */ class MetaQuestionStatement extends QuestionStatement { metaQuestionBody; constructor(subject, body, metaQuestionBody) { super(subject, body); this.metaQuestionBody = metaQuestionBody; } of(answerable) { return Question.about((0, tag_functions_1.the) `${this} of ${answerable}`, actor => actor.answer(this.metaQuestionBody(answerable))); } } /** * @package */ class IsPresent extends Question { question; constructor(question) { super((0, io_1.f) `${question}.isPresent()`); this.question = question; } async answeredBy(actor) { try { const answer = await actor.answer(this.question); if (answer === undefined || answer === null) { return false; } if (this.isOptional(answer)) { return await actor.answer(answer.isPresent()); } return true; } catch { return false; } } isOptional(maybeOptional) { return typeof maybeOptional === 'object' && Reflect.has(maybeOptional, 'isPresent'); } } /** * @package */ function isDefined(value) { return value !== undefined && value !== null; } /** * @package */ const maxRecursiveCallsLimit = 100; /** * @package */ async function recursivelyAnswer(actor, answerable, description, currentRecursion = 0) { if (currentRecursion >= maxRecursiveCallsLimit) { throw new errors_1.LogicError(`Question.fromObject() has reached the limit of ${maxRecursiveCallsLimit} recursive calls while trying to resolve ${description}. Could it contain cyclic references?`); } const answer = await actor.answer(answerable); if ((0, objects_1.isRecord)(answer)) { const entries = Object.entries(answer); const answeredEntries = []; for (const [key, value] of entries) { answeredEntries.push([key, await recursivelyAnswer(actor, value, description, currentRecursion + 1)]); } return Object.fromEntries(answeredEntries); } if (Array.isArray(answer)) { const answeredEntries = []; for (const item of answer) { answeredEntries.push(await recursivelyAnswer(actor, item, description, currentRecursion + 1)); } return answeredEntries; } return answer; } class MetaQuestionAboutValue { of(answerable) { return new QuestionAboutValue(answerable); } toString() { return 'value'; } } class QuestionAboutValue extends Question { context; constructor(context) { super(QuestionAboutFormattedValue.of(context).toString()); this.context = context; } async answeredBy(actor) { return await actor.answer(this.context); } } class MetaQuestionAboutFormattedValue { formatter; static using(options) { return new MetaQuestionAboutFormattedValue(new ValueFormatter({ ...ValueFormatter.defaultOptions, ...options, })); } constructor(formatter) { this.formatter = formatter; } of(context) { return new QuestionAboutFormattedValue(this.formatter, context); } toString() { return 'formatted value'; } } class QuestionAboutFormattedValue extends Question { formatter; context; static of(context) { return new QuestionAboutFormattedValue(new ValueFormatter(ValueFormatter.defaultOptions), context); } constructor(formatter, context) { const description = context === undefined ? 'formatted value' : formatter.format(context); super(description); this.formatter = formatter; this.context = context; } async answeredBy(actor) { const answer = await actor.answer(this.context); return this.formatter.format(answer); } async describedBy(actor) { const unanswered = !this.context || !this.context['answer'] || Unanswered_1.Unanswered.isUnanswered(this.context.answer); const answer = unanswered ? await actor.answer(this.context) : this.context.answer; return this.formatter.format(answer); } of(context) { return new QuestionAboutFormattedValue(this.formatter, Question.isAMetaQuestion(this.context) ? this.context.of(context) : context); } } class ValueFormatter { options; static defaultOptions = { maxLength: Number.POSITIVE_INFINITY }; constructor(options) { this.options = options; } format(value) { if (value === null) { return 'null'; } if (value === undefined) { return 'undefined'; } if (typeof value === 'string') { return `"${this.trim(value)}"`; } if (typeof value === 'symbol') { return `Symbol(${this.trim(value.description)})`; } if (typeof value === 'bigint') { return `${this.trim(value.toString())}`; } if (io_1.ValueInspector.isPromise(value)) { return 'Promise'; } if (Array.isArray(value)) { return value.length === 0 ? '[ ]' : `[ ${this.trim(value.map(item => this.format(item)).join(', '))} ]`; } if (value instanceof Map) { return `Map(${this.format(Object.fromEntries(value.entries()))})`; } if (value instanceof Set) { return `Set(${this.format(Array.from(value.values()))})`; } if (io_1.ValueInspector.isDate(value)) { return `Date(${value.toISOString()})`; } if (value instanceof RegExp) { return `${value}`; } if (io_1.ValueInspector.hasItsOwnToString(value)) { return `${this.trim(value.toString())}`; } if (io_1.ValueInspector.isPlainObject(value)) { const stringifiedEntries = Object .entries(value) .reduce((acc, [key, value]) => acc.concat(`${key}: ${this.format(value)}`), []) .join(', '); return `{ ${this.trim(stringifiedEntries)} }`; } if (typeof value === 'object') { const entries = (0, objects_1.significantFieldsOf)(value) .map(field => [field, value[field]]); return `${value.constructor.name}(${this.format(Object.fromEntries(entries))})`; } return String(value); } trim(value) { const ellipsis = '...'; const oneLiner = value.replaceAll(/\n+/g, ' '); const maxLength = Math.max(ellipsis.length + 1, this.options.maxLength); return oneLiner.length > maxLength ? `${oneLiner.slice(0, Math.max(0, maxLength) - ellipsis.length)}${ellipsis}` : oneLiner; } } //# sourceMappingURL=Question.js.map