@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
JavaScript
"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/).
*
* 
*
* 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