diff --git a/packages/core/spec/screenplay/questions/description.spec.ts b/packages/core/spec/screenplay/questions/description.spec.ts new file mode 100644 index 0000000000..87c8e08850 --- /dev/null +++ b/packages/core/spec/screenplay/questions/description.spec.ts @@ -0,0 +1,182 @@ +/* eslint-disable unicorn/no-null,unicorn/no-useless-undefined */ +import { beforeEach, describe, it } from 'mocha'; +import { given } from 'mocha-testdata'; + +import { type Actor, Cast, description, Masked, Question, type QuestionAdapter, Serenity, Task } from '../../../src'; +import { expect } from '../../expect'; + +function p(value: T): Promise { + return Promise.resolve(value); +} + +function q(value: T, description: string = 'some value'): QuestionAdapter> { + return Question.about('some value', actor => value); +} + +describe('description', () => { + + let serenity: Serenity, + actor: Actor; + + beforeEach(() => { + serenity = new Serenity(); + + serenity.configure({ + crew: [ ], + actors: Cast.where(actor => actor) + }); + + actor = serenity.theActorCalled('Tess'); + }); + + describe('with no parameters', () => { + + it('resolves to the original string value', async () => { + const question = description`#actor performs an interaction`; + + const answer = await actor.answer(question); + + expect(answer).to.equal('#actor performs an interaction'); + }); + + it('is described by the original string value', async () => { + const question = description`#actor performs an interaction`; + + const taskDescription = question.toString(); + + expect(taskDescription).to.equal('#actor performs an interaction'); + }); + }); + + describe('with primitive value parameters', () => { + + const examples = [ + // Primitive values + { description: 'string', value: 'string', expectedAnswer: `#actor enters "string"`, expectedDescription: `#actor enters "string"`, }, + { description: 'number', value: 123, expectedAnswer: `#actor enters 123`, expectedDescription: `#actor enters 123`, }, + { description: 'NaN', value: Number.NaN, expectedAnswer: `#actor enters NaN`, expectedDescription: `#actor enters NaN`, }, + { description: 'Infinity', value: Number.POSITIVE_INFINITY, expectedAnswer: `#actor enters Infinity`, expectedDescription: `#actor enters Infinity`, }, + { description: 'bigint', value: BigInt(123), expectedAnswer: `#actor enters 123`, expectedDescription: `#actor enters 123`, }, + { description: 'boolean', value: false, expectedAnswer: `#actor enters false`, expectedDescription: `#actor enters false`, }, + { description: 'undefined', value: undefined, expectedAnswer: `#actor enters undefined`, expectedDescription: `#actor enters undefined`, }, + { description: 'symbol', value: Symbol('abc'), expectedAnswer: `#actor enters Symbol(abc)`, expectedDescription: `#actor enters Symbol(abc)`, }, + { description: 'null', value: null, expectedAnswer: `#actor enters null`, expectedDescription: `#actor enters null`, }, + + // Promised primitive values + { description: 'Promise', value: p('string'), expectedAnswer: `#actor enters "string"`, expectedDescription: `#actor enters Promise` }, + { description: 'Promise', value: p(123), expectedAnswer: `#actor enters 123`, expectedDescription: `#actor enters Promise` }, + { description: 'Promise', value: p(BigInt(123)), expectedAnswer: `#actor enters 123`, expectedDescription: `#actor enters Promise` }, + { description: 'Promise', value: p(false), expectedAnswer: `#actor enters false`, expectedDescription: `#actor enters Promise` }, + { description: 'Promise', value: p(undefined), expectedAnswer: `#actor enters undefined`, expectedDescription: `#actor enters Promise` }, + { description: 'Promise', value: p(Symbol('abc')), expectedAnswer: `#actor enters Symbol(abc)`, expectedDescription: `#actor enters Promise` }, + { description: 'Promise', value: p(null), expectedAnswer: `#actor enters null`, expectedDescription: `#actor enters Promise` }, + + // Questions resolving to primitive values + { description: 'Promise', value: q('string'), expectedAnswer: `#actor enters "string"`, expectedDescription: `#actor enters some value` }, + { description: 'Promise', value: q(123), expectedAnswer: `#actor enters 123`, expectedDescription: `#actor enters some value` }, + { description: 'Promise', value: q(BigInt(123)), expectedAnswer: `#actor enters 123`, expectedDescription: `#actor enters some value` }, + { description: 'Promise', value: q(false), expectedAnswer: `#actor enters false`, expectedDescription: `#actor enters some value` }, + { description: 'Promise', value: q(undefined), expectedAnswer: `#actor enters undefined`, expectedDescription: `#actor enters some value` }, + { description: 'Promise', value: q(Symbol('abc')), expectedAnswer: `#actor enters Symbol(abc)`, expectedDescription: `#actor enters some value` }, + { description: 'Promise', value: q(null), expectedAnswer: `#actor enters null`, expectedDescription: `#actor enters some value` }, + + // Questions resolving to promised primitive values + { description: 'Promise', value: q(p('string')), expectedAnswer: `#actor enters "string"`, expectedDescription: `#actor enters some value` }, + { description: 'Promise', value: q(p(123)), expectedAnswer: `#actor enters 123`, expectedDescription: `#actor enters some value` }, + { description: 'Promise', value: q(p(BigInt(123))), expectedAnswer: `#actor enters 123`, expectedDescription: `#actor enters some value` }, + { description: 'Promise', value: q(p(false)), expectedAnswer: `#actor enters false`, expectedDescription: `#actor enters some value` }, + { description: 'Promise', value: q(p(undefined)), expectedAnswer: `#actor enters undefined`, expectedDescription: `#actor enters some value` }, + { description: 'Promise', value: q(p(Symbol('abc'))), expectedAnswer: `#actor enters Symbol(abc)`, expectedDescription: `#actor enters some value` }, + { description: 'Promise', value: q(p(null)), expectedAnswer: `#actor enters null`, expectedDescription: `#actor enters some value` }, + ]; + + given(examples). + it('resolves to a value with interpolated parameters', async ({ value, expectedAnswer }) => { + const question = description`#actor enters ${ value }`; + + const answer = await actor.answer(question); + + expect(answer).to.equal(expectedAnswer); + }); + + given(examples). + it('is described using interpolated parameters', async ({ value, expectedDescription }) => { + const question = description`#actor enters ${ value }`; + + expect(question.toString()).to.equal(expectedDescription); + }); + }); + + describe('with objects', () => { + class Person { + constructor( + public readonly firstName: string, + public readonly lastName: string, + ) { + } + } + + const examples = [ + { description: 'plain object', value: { name: { first: 'Jan', last: 'Molak' } }, expectedAnswer: `#actor enters { name: { first: "Jan", last: "Molak" } }`, expectedDescription: `#actor enters { name: { first: "Jan", last: "Molak" } }`, }, + { description: 'Date', value: new Date(1995, 11, 17, 3, 24, 0), expectedAnswer: `#actor enters Date(1995-12-17T03:24:00.000Z)`, expectedDescription: `#actor enters Date(1995-12-17T03:24:00.000Z)`, }, + { description: 'Array', value: [ 'hello', 123 ], expectedAnswer: `#actor enters [ "hello", 123 ]`, expectedDescription: `#actor enters [ "hello", 123 ]`, }, + { description: 'Map', value: new Map([['key', 'value']]), expectedAnswer: `#actor enters Map({ key: "value" })`, expectedDescription: `#actor enters Map({ key: "value" })`, }, + { description: 'Set', value: new Set([ 1, 2, 3 ]), expectedAnswer: `#actor enters Set([ 1, 2, 3 ])`, expectedDescription: `#actor enters Set([ 1, 2, 3 ])`, }, + { description: 'Error', value: new Error('example'), expectedAnswer: `#actor enters Error({ message: "example", stack: "Error: example at ... })`, expectedDescription: `#actor enters Error({ message: "example", stack: "Error: example at ... })`, }, + { description: 'RegExp', value: /[Hh]ello/g, expectedAnswer: `#actor enters /[Hh]ello/g`, expectedDescription: `#actor enters /[Hh]ello/g`, }, + { description: 'custom toString', value: { toString: () => 'example' } as any, expectedAnswer: `#actor enters example`, expectedDescription: `#actor enters example`, }, + { description: 'instance no toString', value: new Person('Jan', 'Molak'), expectedAnswer: `#actor enters Person({ firstName: "Jan", lastName: "Molak" })`, expectedDescription: `#actor enters Person({ firstName: "Jan", lastName: "Molak" })` }, + ]; + + given(examples). + it('resolves to a value with interpolated parameters', async ({ value, expectedAnswer }) => { + const question = description`#actor enters ${ value }`; + + const answer = await actor.answer(question); + + expect(answer).to.equal(expectedAnswer); + }); + + given(examples). + it('is described using interpolated parameters', async ({ value, expectedDescription }) => { + const question = description`#actor enters ${ value }`; + + expect(question.toString()).to.equal(expectedDescription); + }); + }); + + describe('with masked values', () => { + + it('masks the value in the description', async () => { + const taskDescription = description`#actor enters ${ Masked.valueOf(`password`) }`; + const task = Task.where(taskDescription); + + expect(task.toString()).to.equal(`#actor enters [MASKED]`); + + const answer = await task.describedBy(actor); + + expect(answer).to.equal(`#actor enters "password"`); + }); + + it(`masks the value in the description when it's nested in an object`, async () => { + const taskDescription = description`#actor enters ${ { password: Masked.valueOf(`password`) } }`; + const task = Task.where(taskDescription); + + expect(task.toString()).to.equal(`#actor enters { password: [MASKED] }`); + }); + + it(`masks the value in the description when it's nested in an array`, async () => { + const taskDescription = description`#actor enters ${ [ Masked.valueOf(`password`) ] }`; + const task = Task.where(taskDescription); + + expect(task.toString()).to.equal(`#actor enters [ [MASKED] ]`); + }); + }); + + it('can have the default description overridden', async () => { + const question = description`/products/${ 1 }/attributes/${ Promise.resolve(2) }` + .describedAs('/products/:productId/attributes/:attributeId'); + + expect(question.toString()).to.equal('/products/:productId/attributes/:attributeId') + }); +}); diff --git a/packages/core/spec/screenplay/questions/descriptionText.spec.ts b/packages/core/spec/screenplay/questions/descriptionText.spec.ts new file mode 100644 index 0000000000..68950d05fe --- /dev/null +++ b/packages/core/spec/screenplay/questions/descriptionText.spec.ts @@ -0,0 +1,155 @@ +/* eslint-disable unicorn/no-null,unicorn/no-useless-undefined */ +import { describe, it } from 'mocha'; +import { given } from 'mocha-testdata'; + +import type { QuestionAdapter} from '../../../src'; +import { Masked, Question, descriptionText } from '../../../src'; +import { expect } from '../../expect'; + +function p(value: T): Promise { + return Promise.resolve(value); +} + +function q(value: T, description: string = 'some value'): QuestionAdapter> { + return Question.about('some value', actor => value); +} + +describe('descriptionText', () => { + + describe('using custom options', () => { + it(`trims the parameter to the desired maximum length`, async () => { + const result = descriptionText({ maxLength: 10 }) `#actor enters a very ${ 'looooooong-should-be-trimmed' } string`; + + expect(result).to.equal('#actor enters a very "looooooong..." string'); + }); + + it(`trims the nested object's properties to the desired maximum length`, async () => { + const value = { + name: { + first: 'Extremely long first name', + last: 'Even longer last name', + }, + }; + + const result = descriptionText({ maxLength: 20 }) `#actor enters ${ value }`; + + expect(result).to.equal('#actor enters { name: { first: "Extr... }'); + }); + + it(`complains if the custom maximum length is less than 10`, async () => { + expect(() => descriptionText({ maxLength: 9 }) `#actor enters a very ${ 'looooooong-should-be-trimmed' } string`).to.throw(Error, 'options.maxLength should either be equal to 10 or be greater than 10'); + }); + }); + + describe('using default options', () => { + + describe('with no parameters', () => { + + it('is returns the original string value', async () => { + const result = descriptionText`#actor performs an interaction`; + + expect(result).to.equal('#actor performs an interaction'); + }); + }); + + describe('with primitive value parameters', () => { + + const examples = [ + // Primitive values + { description: 'string', value: 'string', expectedResult: `#actor enters "string"`, }, + { description: 'number', value: 123, expectedResult: `#actor enters 123`, }, + { description: 'NaN', value: Number.NaN, expectedResult: `#actor enters NaN`, }, + { description: 'Infinity', value: Number.POSITIVE_INFINITY, expectedResult: `#actor enters Infinity`, }, + { description: 'bigint', value: BigInt(123), expectedResult: `#actor enters 123`, }, + { description: 'boolean', value: false, expectedResult: `#actor enters false`, }, + { description: 'undefined', value: undefined, expectedResult: `#actor enters undefined`, }, + { description: 'symbol', value: Symbol('abc'), expectedResult: `#actor enters Symbol(abc)`, }, + { description: 'null', value: null, expectedResult: `#actor enters null`, }, + + // Promised primitive values + { description: 'Promise', value: p('string'), expectedResult: `#actor enters Promise` }, + { description: 'Promise', value: p(123), expectedResult: `#actor enters Promise` }, + { description: 'Promise', value: p(BigInt(123)), expectedResult: `#actor enters Promise` }, + { description: 'Promise', value: p(false), expectedResult: `#actor enters Promise` }, + { description: 'Promise', value: p(undefined), expectedResult: `#actor enters Promise` }, + { description: 'Promise', value: p(Symbol('abc')), expectedResult: `#actor enters Promise` }, + { description: 'Promise', value: p(null), expectedResult: `#actor enters Promise` }, + + // Questions resolving to primitive values + { description: 'Question', value: q('string'), expectedResult: `#actor enters some value` }, + { description: 'Question', value: q(123), expectedResult: `#actor enters some value` }, + { description: 'Question', value: q(BigInt(123)), expectedResult: `#actor enters some value` }, + { description: 'Question', value: q(false), expectedResult: `#actor enters some value` }, + { description: 'Question', value: q(undefined), expectedResult: `#actor enters some value` }, + { description: 'Question', value: q(Symbol('abc')), expectedResult: `#actor enters some value` }, + { description: 'Question', value: q(null), expectedResult: `#actor enters some value` }, + + // Questions resolving to promised primitive values + { description: 'Question>', value: q(p('string')), expectedResult: `#actor enters some value` }, + { description: 'Question>', value: q(p(123)), expectedResult: `#actor enters some value` }, + { description: 'Question>', value: q(p(BigInt(123))), expectedResult: `#actor enters some value` }, + { description: 'Question>', value: q(p(false)), expectedResult: `#actor enters some value` }, + { description: 'Question>', value: q(p(undefined)), expectedResult: `#actor enters some value` }, + { description: 'Question>', value: q(p(Symbol('abc'))), expectedResult: `#actor enters some value` }, + { description: 'Question>', value: q(p(null)), expectedResult: `#actor enters some value` }, + ]; + + given(examples). + it('is described using interpolated parameters', async ({ value, expectedResult }) => { + const result = descriptionText`#actor enters ${ value }`; + + expect(result).to.equal(expectedResult); + }); + }); + + describe('with objects', () => { + class Person { + constructor( + public readonly firstName: string, + public readonly lastName: string, + ) { + } + } + + const examples = [ + { description: 'plain object', value: { name: { first: 'Jan', last: 'Molak' } }, expectedValue: `#actor enters { name: { first: "Jan", last: "Molak" } }`, }, + { description: 'Array', value: [ 'hello', 123 ], expectedValue: `#actor enters [ "hello", 123 ]`, }, + { description: 'Date', value: new Date(1995, 11, 17, 3, 24, 0), expectedValue: `#actor enters Date(1995-12-17T03:24:00.000Z)`, }, + { description: 'Map', value: new Map([['key', 'value']]), expectedValue: `#actor enters Map({ key: "value" })`, }, + { description: 'Set', value: new Set([ 1, 2, 3 ]), expectedValue: `#actor enters Set([ 1, 2, 3 ])`, }, + { description: 'Error', value: new Error('example'), expectedValue: `#actor enters Error({ message: "example", stack: "Error: example at ... })`, }, + { description: 'RegExp', value: /[Hh]ello/g, expectedValue: `#actor enters /[Hh]ello/g`, }, + { description: 'custom toString', value: { toString: () => 'example' } as any, expectedValue: `#actor enters example`, }, + { description: 'instance no toString', value: new Person('Jan', 'Molak'), expectedValue: `#actor enters Person({ firstName: "Jan", lastName: "Molak" })`, }, + ]; + + given(examples). + it('is described using interpolated parameters', async ({ value, expectedValue }) => { + const result = descriptionText`#actor enters ${ value }`; + + expect(result).to.equal(expectedValue); + }); + }); + + describe('with masked values', () => { + + it('retains the masked primitive value', async () => { + const result = descriptionText`#actor enters ${ Masked.valueOf(`password`) }`; + + expect(result).to.equal(`#actor enters [MASKED]`); + }); + + it('retains the masked primitive value nested in an object', async () => { + const result = descriptionText`#actor enters ${ { password: Masked.valueOf(`password`) } }`; + + expect(result).to.equal(`#actor enters { password: [MASKED] }`); + }); + + it('retains the masked primitive value nested in an array', async () => { + const result = descriptionText`#actor enters ${ [ Masked.valueOf(`password`) ] }`; + + expect(result).to.equal(`#actor enters [ [MASKED] ]`); + }); + }); + }); +}); diff --git a/packages/core/src/screenplay/questions/description.ts b/packages/core/src/screenplay/questions/description.ts new file mode 100644 index 0000000000..11298e1149 --- /dev/null +++ b/packages/core/src/screenplay/questions/description.ts @@ -0,0 +1,202 @@ +import { asyncMap, isPlainObject } from '../../io'; +import type { Answerable } from '../Answerable'; +import type { QuestionAdapter } from '../Question'; +import { Question } from '../Question'; +import { DescriptionOptions, descriptionText } from './descriptionText'; + +/** + * Creates a single-line description of an {@apilink Activity} by transforming a [template literal](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals#Tagged_templates), + * parameterised with [primitive data types](https://developer.mozilla.org/en-US/docs/Glossary/Primitive), [complex data structures](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#objects), + * or any {@apilink Answerable|Answerables} resolving to those, into a {@link QuestionAdapter|`QuestionAdapter`} that can be used with {@apilink Task.where} and {@apilink Interaction.where}. + * + * For brevity, you can use the alias [`the`](/api/core/function/the/) instead of `description`. + * + * ## Parameterising `description` with primitive data types + * + * When `description` is parameterised with primitive data types, it behaves similarly to a regular template literal and produces a {@apilink Question} that resolves to a string with the interpolated values. + * + * ```ts + * import { actorCalled, description, Task } from '@serenity-js/core' + * + * const providePhoneNumber = (phoneNumber: Answerable) => + * Task.where(description `#actor provides phone number: ${ phoneNumber }`, /* *\/) + * + * await actorCalled('Alice').attemptsTo( + * providePhoneNumber('(555) 123-4567'), + * // produces a Task described as: 'Alice provides phone number: (555) 123-4567' + * ) + * ``` + * + * ## Using `the` for brevity and improved readability + * + * If you find `description` to be too verbose, you can replace it with [`the`](/api/core/function/the/), which is an alias for `description`. + * + * ```ts + * import { actorCalled, Task, the } from '@serenity-js/core' + * + * const providePhoneNumber = (phoneNumber: Answerable) => + * Task.where(the`#actor provides phone number: ${ phoneNumber }`, /* *\/) + * + * await actorCalled('Alice').attemptsTo( + * providePhoneNumber('(555) 123-4567'), + * // produces a Task described as: 'Alice provides phone number: (555) 123-4567' + * ) + * ``` + * + * ## Configuring the output + * + * You can configure the output of `description` by using {@apilink DescriptionOptions}: + * + * ```ts + * import { actorCalled, Task, the } from '@serenity-js/core' + * + * const providePhoneNumber = (phoneNumber: Answerable) => + * Task.where(description({ maxLength: 10 }) `#actor provides phone number: ${ phoneNumber }`, /* *\/) + * + * await actorCalled('Alice').attemptsTo( + * providePhoneNumber('(555) 123-4567'), + * // produces a Task described as: 'Alice provides phone number: (555) 123-...' + * ) + * ``` + * + * ## Parameterising `description` with Questions + * + * When `description` is parameterised with {@apilink Question|Questions} or any other {@link Answerable|Answerables}, their values are resolves by an {@apilink Actor} performing the given {@apilink Activity}, + * are interpolated into the resulting string. + * + * ```ts + * import { actorCalled, description, Task } from '@serenity-js/core' + * + * interface MyNotes { + * phoneNumber: string; + * } + * + * const providePhoneNumber = (phoneNumber: Answerable) => + * Task.where(description `#actor provides phone number: ${ phoneNumber }`, /* *\/) + * + * await actorCalled('Alice').attemptsTo( + * notes().set('phoneNumber', '(555) 123-4567'), + * providePhoneNumber(notes().get('phoneNumber')), + * // produces a Task described as: 'Alice provides phone number: (555) 123-4567' + * ) + * ``` + * + * ## Parameterising `description` using objects with a custom `toString` method + * + * When `description` is parameterised with objects that have a custom [`toString()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/toString) + * method, or {@link Answerable|Answerables} resolving to such objects, the `toString()` method is called to produce the resulting string. + * + * ```ts + * import { actorCalled, description, Task } from '@serenity-js/core' + * + * class PhoneNumber { + * constructor( + * private readonly areaCode: string, + * private readonly centralOfficeCode: string, + * private readonly lineNumber: string, + * ) {} + * + * toString() { + * return `(${ this.areaCode }) ${ this.centralOfficeCode }-${ this.lineNumber }` + * } + * } + * + * const providePhoneNumber = (phoneNumber: Answerable) => + * Task.where(description `#actor provides phone number: ${ phoneNumber }`, /* *\/) + * + * await actorCalled('Alice').attemptsTo( + * providePhoneNumber(new PhoneNumber('555', '123', '4567')), + * // produces a Task described as: 'Alice provides phone number: (555) 123-4567' + * ) + * ``` + * + * ## Using with objects without a custom toString method + * + * When `description` is parameterised with complex objects that don't have a custom `toString()` method, or {@link Answerable}s resolving to such objects, + * the resulting string will contain a JSON-like string representation of the object. + * + * ```ts + * import { actorCalled, description, Task } from '@serenity-js/core' + * + * interface PhoneNumber { + * areaCode: string; + * centralOfficeCode: string; + * lineNumber: string; + * } + * + * const providePhoneNumber = (phoneNumber: Answerable) => + * Task.where(description `#actor provides phone number: ${ phoneNumber }`, /* *\/) + * + * await actorCalled('Alice').attemptsTo( + * providePhoneNumber({ areaCode: '555', centralOfficeCode: '123', lineNumber: '4567' }), + * // produces a Task described as: 'Alice provides phone number: { areaCode: "555", centralOfficeCode: "123", lineNumber: "4567" }' + * ) + * ``` + * + * ## Using with masked values + * + * When `description` is parameterised with {@apilink Masked} values, + * the resulting string will contain a masked representation of the values. + * + * ```ts + * import { actorCalled, description, Task } from '@serenity-js/core' + * + * const providePhoneNumber = (phoneNumber: Answerable) => + * Task.where(description `#actor provides phone number: ${ phoneNumber }`, /* *\/) + * + * await actorCalled('Alice').attemptsTo( + * providePhoneNumber(Masked.valueOf('(555) 123-4567')), + * // produces a Task described as: 'Alice provides phone number: [MASKED]' + * ) + * ``` + * + * ## Learn more + * + * - {@apilink Answerable} + * - {@apilink Question} + * - {@apilink Question.describedAs} + * - {@apilink QuestionAdapter} + * + * @group Questions + */ +export function description(options: DescriptionOptions): (templates: TemplateStringsArray, ...placeholders: Array>) => QuestionAdapter +export function description(templates: TemplateStringsArray, ...parameters: Array>): QuestionAdapter +export function description(...args: any[]): any { + return isPlainObject(args[0]) + ? (templates: TemplateStringsArray, ...parameters: Array>) => + templateToQuestion(templates, parameters, args[0]) + : templateToQuestion(args[0], args.slice(1), { maxLength: 50 }); +} + +/** + * Convenience alias for [`description`](/api/core/function/description/) + * + * ## Using `the` to describe an Activity + * + * Just like [`description`](/api/core/function/description/), `the` can be used to create a single-line description of an {@apilink Activity} by transforming a [template literal](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals#Tagged_templates) + * parameterised with [primitive data types](https://developer.mozilla.org/en-US/docs/Glossary/Primitive), [complex data structures](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#objects), + * or any {@apilink Answerable|Answerables} resolving to those. + * + * ```ts + * import { actorCalled, Task, the } from '@serenity-js/core' + * + * const providePhoneNumber = (phoneNumber: Answerable) => + * Task.where(the`#actor provides phone number: ${ phoneNumber }`, /* *\/) + * + * await actorCalled('Alice').attemptsTo( + * providePhoneNumber('(555) 123-4567'), + * // produces a Task described as: 'Alice provides phone number: (555) 123-4567' + * ) + * ``` + * + * @group Questions + */ +export const the = description; + +function templateToQuestion(templates: TemplateStringsArray, parameters: Array>, options: DescriptionOptions): QuestionAdapter { + const description = descriptionText(options)(templates, ...parameters); + return Question.about(description, async actor => { + const answers = await asyncMap(parameters, parameter => actor.answer(parameter)); + return descriptionText(options)(templates, ...answers); + }); +} diff --git a/packages/core/src/screenplay/questions/descriptionText.ts b/packages/core/src/screenplay/questions/descriptionText.ts new file mode 100644 index 0000000000..7e65a179f3 --- /dev/null +++ b/packages/core/src/screenplay/questions/descriptionText.ts @@ -0,0 +1,192 @@ +import { ensure, isGreaterThanOrEqualTo } from 'tiny-types'; +import { isPlainObject } from '../../io'; +import { Answerable } from '../Answerable'; +import { Masked } from './Masked'; +import { significantFieldsOf } from 'tiny-types/lib/objects'; + +/** + * Configuration options for the [`description`](/api/core/function/description/), [`the`](/api/core/function/the/), and [`descriptionText`](/api/core/function/descriptionText/) functions. + * + * @group Questions + */ +export interface DescriptionOptions { + /** + * The maximum length of the string representation of the value. + * String representations longer than this value will be truncated and appended with an ellipsis. + */ + maxLength: number; +} + +/** + * Interpolating a [template literal](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals#Tagged_templates), + * parameterised with [primitive data types](https://developer.mozilla.org/en-US/docs/Glossary/Primitive), [complex data structures](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#objects), + * or any {@apilink Answerable|Answerables} and produces a single-line, human-readable description. This function is used internally by [`description`](/api/core/function/description/) and [`the`](/api/core/function/the/) functions. + * + * @param options + * + * @group Questions + */ +export function descriptionText(options: DescriptionOptions): (templates: TemplateStringsArray, ...placeholders: Array>) => string +export function descriptionText(templates: TemplateStringsArray, ...parameters: Array>): string +export function descriptionText(...args: any[]): any { + return isPlainObject(args[0]) + ? (templates: TemplateStringsArray, ...parameters: Array>) => + templateToString(templates, parameters, parameterDescriptionText(args[0])) + : templateToString(args[0], args.slice(1), parameterDescriptionText({ maxLength: 50 })); +} + +function templateToString(templates: TemplateStringsArray, parameters: Array>, describer: (parameter: any) => string): string { + return templates.flatMap((template, i) => + i < parameters.length + ? [ template, describer(parameters[i]) ] + : [ template ], + ).join(''); +} + +/** + * Produces a human-readable description of the parameter. + * This function is used internally by [`descriptionText`](/api/core/function/descriptionText/). + * + * @param options + * + * @group Questions + */ +export function parameterDescriptionText(options: DescriptionOptions): (parameter: any) => string { + const maxLength = ensure('options.maxLength', options.maxLength, isGreaterThanOrEqualTo(10)); + const trim = trimTo(maxLength); + + return function describeParameter(parameter: any): string { + if (parameter === null) { + return 'null'; + } + if (parameter === undefined) { + return 'undefined'; + } + if (typeof parameter === 'string') { + return `"${ trim(parameter) }"`; + } + if (typeof parameter === 'symbol') { + return `Symbol(${ trim(parameter.description) })`; + } + if (typeof parameter === 'bigint') { + return `${ trim(parameter.toString()) }`; + } + if (isAPromise(parameter)) { + return 'Promise'; + } + if (Array.isArray(parameter)) { + return `[ ${ trim(parameter.map(item => describeParameter(item)).join(', ')) } ]`; + } + if (parameter instanceof Map) { + return `Map(${ describeParameter(Object.fromEntries(parameter.entries())) })`; + } + if (parameter instanceof Set) { + return `Set(${ describeParameter(Array.from(parameter.values())) })`; + } + if (isADate(parameter)) { + return `Date(${ parameter.toISOString() })`; + } + if (parameter instanceof RegExp) { + return `${ parameter }`; + } + if (parameter instanceof Masked) { + return `${ parameter }`; + } + if (hasItsOwnToString(parameter)) { + return `${ trim(parameter.toString()) }`; + } + if (isPlainObject(parameter)) { + const stringifiedEntries = Object + .entries(parameter) + .reduce((acc, [ key, value ]) => acc.concat(`${ key }: ${ describeParameter(value) }`), []) + .join(', '); + + return `{ ${ trim(stringifiedEntries) } }`; + } + if (typeof parameter === 'object') { + const entries = significantFieldsOf(parameter) + .map(field => [ field, (parameter as any)[field] ]); + return `${ parameter.constructor.name }(${ describeParameter(Object.fromEntries(entries)) })`; + } + return `${ parameter }`; + }; +} + +function trimTo(maxLength: number): (value: string) => string { + const ellipsis = '...'; + return (value: string) => { + const oneLiner = value.replaceAll(/\n+/g, ' '); + return oneLiner.length > maxLength + ? `${ oneLiner.slice(0, Math.max(0, maxLength)) }${ ellipsis }` + : value; + }; +} + +/** + * Checks if the value is a {@apilink Promise} + * + * @param v + */ +function isAPromise(v: Answerable): v is Promise { + return typeof v === 'object' + && 'then' in v; +} + +/** + * Checks if the value is a {@apilink Date} + * + * @param v + */ +function isADate(v: Answerable): v is Date { + return v instanceof Date; +} + +/** + * Checks if the value defines its own `toString` method + * + * @param v + */ +function hasItsOwnToString(v: Answerable): v is { toString: () => string } { + return typeof v === 'object' + && !!(v as any).toString + && typeof (v as any).toString === 'function' + && !isNative((v as any).toString); +} + +/** + * Inspired by https://davidwalsh.name/detect-native-function + * + * @param v + */ +function isNative(v: any): v is Function { // eslint-disable-line @typescript-eslint/ban-types + + const + toString = Object.prototype.toString, // Used to resolve the internal `{@apilink Class}` of values + fnToString = Function.prototype.toString, // Used to resolve the decompiled source of functions + hostConstructor = /^\[object .+?Constructor]$/; // Used to detect host constructors (Safari > 4; really typed array specific) + + // Compile a regexp using a common native method as a template. + // We chose `Object#toString` because there's a good chance it is not being mucked with. + const nativeFunctionTemplate = new RegExp( + '^' + + // Coerce `Object#toString` to a string + String(toString) + // Escape any special regexp characters + .replaceAll(/[$()*+./?[\\\]^{|}]/g, '\\$&') + // Replace mentions of `toString` with `.*?` to keep the template generic. + // Replace thing like `for ...` to support environments like Rhino which add extra info + // such as method arity. + .replaceAll(/toString|(function).*?(?=\\\()| for .+?(?=\\])/g, '$1.*?') + + '$', + ); + + const type = typeof v; + return type === 'function' + // Use `Function#toString` to bypass the value's own `toString` method + // and avoid being faked out. + ? nativeFunctionTemplate.test(fnToString.call(v)) + // Fallback to a host object check because some environments will represent + // things like typed arrays as DOM methods which may not conform to the + // normal native pattern. + : (v && type === 'object' && hostConstructor.test(toString.call(v))) || false; +} diff --git a/packages/core/src/screenplay/questions/index.ts b/packages/core/src/screenplay/questions/index.ts index 5c89647fe9..471e01a0b5 100644 --- a/packages/core/src/screenplay/questions/index.ts +++ b/packages/core/src/screenplay/questions/index.ts @@ -1,6 +1,8 @@ export * from './AnswersQuestions'; export * from './ChainableMetaQuestion'; export * from './Check'; +export * from './description'; +export * from './descriptionText'; export * from './Expectation'; export * from './expectations'; export * from './List';