@serenity-js/core 3.23.2 → 3.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/lib/errors/ErrorFactory.js +4 -4
  3. package/lib/errors/ErrorFactory.js.map +1 -1
  4. package/lib/errors/RaiseErrors.d.ts +2 -2
  5. package/lib/errors/RaiseErrors.js +2 -2
  6. package/lib/io/index.d.ts +0 -1
  7. package/lib/io/index.d.ts.map +1 -1
  8. package/lib/io/index.js +0 -1
  9. package/lib/io/index.js.map +1 -1
  10. package/lib/io/inspectedObject.js +1 -1
  11. package/lib/io/inspectedObject.js.map +1 -1
  12. package/lib/io/reflection/ValueInspector.d.ts +56 -0
  13. package/lib/io/reflection/ValueInspector.d.ts.map +1 -0
  14. package/lib/io/reflection/ValueInspector.js +149 -0
  15. package/lib/io/reflection/ValueInspector.js.map +1 -0
  16. package/lib/io/reflection/index.d.ts +1 -2
  17. package/lib/io/reflection/index.d.ts.map +1 -1
  18. package/lib/io/reflection/index.js +1 -2
  19. package/lib/io/reflection/index.js.map +1 -1
  20. package/lib/io/stringified.js +7 -90
  21. package/lib/io/stringified.js.map +1 -1
  22. package/lib/screenplay/Activity.d.ts +5 -12
  23. package/lib/screenplay/Activity.d.ts.map +1 -1
  24. package/lib/screenplay/Activity.js +3 -14
  25. package/lib/screenplay/Activity.js.map +1 -1
  26. package/lib/screenplay/Actor.js +1 -1
  27. package/lib/screenplay/Actor.js.map +1 -1
  28. package/lib/screenplay/Interaction.d.ts +4 -3
  29. package/lib/screenplay/Interaction.d.ts.map +1 -1
  30. package/lib/screenplay/Interaction.js +2 -2
  31. package/lib/screenplay/Interaction.js.map +1 -1
  32. package/lib/screenplay/Question.d.ts +73 -21
  33. package/lib/screenplay/Question.d.ts.map +1 -1
  34. package/lib/screenplay/Question.js +237 -30
  35. package/lib/screenplay/Question.js.map +1 -1
  36. package/lib/screenplay/Task.d.ts +16 -15
  37. package/lib/screenplay/Task.d.ts.map +1 -1
  38. package/lib/screenplay/Task.js +14 -14
  39. package/lib/screenplay/Task.js.map +1 -1
  40. package/lib/screenplay/abilities/Ability.d.ts +8 -6
  41. package/lib/screenplay/abilities/Ability.d.ts.map +1 -1
  42. package/lib/screenplay/abilities/Ability.js +8 -6
  43. package/lib/screenplay/abilities/Ability.js.map +1 -1
  44. package/lib/screenplay/abilities/AbilityType.d.ts +3 -3
  45. package/lib/screenplay/abilities/AnswerQuestions.d.ts +0 -1
  46. package/lib/screenplay/abilities/AnswerQuestions.d.ts.map +1 -1
  47. package/lib/screenplay/abilities/AnswerQuestions.js +2 -4
  48. package/lib/screenplay/abilities/AnswerQuestions.js.map +1 -1
  49. package/lib/screenplay/abilities/PerformActivities.d.ts +5 -3
  50. package/lib/screenplay/abilities/PerformActivities.d.ts.map +1 -1
  51. package/lib/screenplay/abilities/PerformActivities.js +12 -10
  52. package/lib/screenplay/abilities/PerformActivities.js.map +1 -1
  53. package/lib/screenplay/artifacts/CollectsArtifacts.d.ts +2 -2
  54. package/lib/screenplay/notes/NotepadAdapter.d.ts.map +1 -1
  55. package/lib/screenplay/notes/NotepadAdapter.js +44 -4
  56. package/lib/screenplay/notes/NotepadAdapter.js.map +1 -1
  57. package/lib/screenplay/questions/Describable.d.ts +27 -0
  58. package/lib/screenplay/questions/Describable.d.ts.map +1 -0
  59. package/lib/screenplay/questions/Describable.js +40 -0
  60. package/lib/screenplay/questions/Describable.js.map +1 -0
  61. package/lib/screenplay/questions/DescriptionFormattingOptions.d.ts +14 -0
  62. package/lib/screenplay/questions/DescriptionFormattingOptions.d.ts.map +1 -0
  63. package/lib/screenplay/questions/DescriptionFormattingOptions.js +3 -0
  64. package/lib/screenplay/questions/DescriptionFormattingOptions.js.map +1 -0
  65. package/lib/screenplay/questions/Expectation.d.ts +6 -10
  66. package/lib/screenplay/questions/Expectation.d.ts.map +1 -1
  67. package/lib/screenplay/questions/Expectation.js +12 -15
  68. package/lib/screenplay/questions/Expectation.js.map +1 -1
  69. package/lib/screenplay/questions/List.d.ts +1 -3
  70. package/lib/screenplay/questions/List.d.ts.map +1 -1
  71. package/lib/screenplay/questions/List.js +6 -31
  72. package/lib/screenplay/questions/List.js.map +1 -1
  73. package/lib/screenplay/questions/Unanswered.d.ts +1 -0
  74. package/lib/screenplay/questions/Unanswered.d.ts.map +1 -1
  75. package/lib/screenplay/questions/Unanswered.js +3 -0
  76. package/lib/screenplay/questions/Unanswered.js.map +1 -1
  77. package/lib/screenplay/questions/expectations/ExpectationDetails.js +1 -1
  78. package/lib/screenplay/questions/expectations/ExpectationDetails.js.map +1 -1
  79. package/lib/screenplay/questions/index.d.ts +3 -1
  80. package/lib/screenplay/questions/index.d.ts.map +1 -1
  81. package/lib/screenplay/questions/index.js +3 -1
  82. package/lib/screenplay/questions/index.js.map +1 -1
  83. package/lib/screenplay/questions/tag-functions.d.ts +228 -0
  84. package/lib/screenplay/questions/tag-functions.d.ts.map +1 -0
  85. package/lib/screenplay/questions/tag-functions.js +115 -0
  86. package/lib/screenplay/questions/tag-functions.js.map +1 -0
  87. package/lib/screenplay/time/activities/Wait.d.ts.map +1 -1
  88. package/lib/screenplay/time/activities/Wait.js +4 -3
  89. package/lib/screenplay/time/activities/Wait.js.map +1 -1
  90. package/package.json +4 -4
  91. package/src/errors/ErrorFactory.ts +5 -5
  92. package/src/errors/RaiseErrors.ts +2 -2
  93. package/src/io/index.ts +0 -1
  94. package/src/io/inspectedObject.ts +2 -2
  95. package/src/io/reflection/ValueInspector.ts +165 -0
  96. package/src/io/reflection/index.ts +1 -2
  97. package/src/io/stringified.ts +7 -103
  98. package/src/screenplay/Activity.ts +6 -17
  99. package/src/screenplay/Actor.ts +2 -2
  100. package/src/screenplay/Interaction.ts +5 -4
  101. package/src/screenplay/Question.ts +299 -49
  102. package/src/screenplay/Task.ts +18 -17
  103. package/src/screenplay/abilities/Ability.ts +8 -6
  104. package/src/screenplay/abilities/AbilityType.ts +3 -3
  105. package/src/screenplay/abilities/AnswerQuestions.ts +2 -5
  106. package/src/screenplay/abilities/PerformActivities.ts +35 -18
  107. package/src/screenplay/artifacts/CollectsArtifacts.ts +2 -2
  108. package/src/screenplay/notes/NotepadAdapter.ts +57 -6
  109. package/src/screenplay/questions/Describable.ts +48 -0
  110. package/src/screenplay/questions/DescriptionFormattingOptions.ts +13 -0
  111. package/src/screenplay/questions/Expectation.ts +19 -19
  112. package/src/screenplay/questions/List.ts +7 -41
  113. package/src/screenplay/questions/Unanswered.ts +4 -0
  114. package/src/screenplay/questions/expectations/ExpectationDetails.ts +2 -2
  115. package/src/screenplay/questions/index.ts +3 -1
  116. package/src/screenplay/questions/tag-functions.ts +313 -0
  117. package/src/screenplay/time/activities/Wait.ts +4 -3
  118. package/lib/io/isPlainObject.d.ts +0 -7
  119. package/lib/io/isPlainObject.d.ts.map +0 -1
  120. package/lib/io/isPlainObject.js +0 -25
  121. package/lib/io/isPlainObject.js.map +0 -1
  122. package/lib/io/reflection/isPrimitive.d.ts +0 -8
  123. package/lib/io/reflection/isPrimitive.d.ts.map +0 -1
  124. package/lib/io/reflection/isPrimitive.js +0 -24
  125. package/lib/io/reflection/isPrimitive.js.map +0 -1
  126. package/lib/io/reflection/typeOf.d.ts +0 -7
  127. package/lib/io/reflection/typeOf.d.ts.map +0 -1
  128. package/lib/io/reflection/typeOf.js +0 -35
  129. package/lib/io/reflection/typeOf.js.map +0 -1
  130. package/lib/screenplay/questions/q.d.ts +0 -66
  131. package/lib/screenplay/questions/q.d.ts.map +0 -1
  132. package/lib/screenplay/questions/q.js +0 -77
  133. package/lib/screenplay/questions/q.js.map +0 -1
  134. package/src/io/isPlainObject.ts +0 -24
  135. package/src/io/reflection/isPrimitive.ts +0 -20
  136. package/src/io/reflection/typeOf.ts +0 -31
  137. package/src/screenplay/questions/q.ts +0 -82
@@ -4,7 +4,9 @@ import { ErrorStackParser } from '../errors';
4
4
  import { FileSystemLocation, Path } from '../io';
5
5
  import type { UsesAbilities } from './abilities';
6
6
  import type { PerformsActivities } from './activities';
7
- import type { AnswersQuestions } from './questions';
7
+ import type { Answerable } from './Answerable';
8
+ import type { AnswersQuestions } from './questions/AnswersQuestions';
9
+ import { Describable } from './questions/Describable';
8
10
 
9
11
  /**
10
12
  * **Activities** represents {@apilink Task|tasks} and {@apilink Interaction|interactions} to be performed by an {@apilink Actor|actor}.
@@ -17,17 +19,16 @@ import type { AnswersQuestions } from './questions';
17
19
  *
18
20
  * @group Screenplay Pattern
19
21
  */
20
- export abstract class Activity {
22
+ export abstract class Activity extends Describable {
21
23
 
22
24
  private static errorStackParser = new ErrorStackParser();
23
- readonly #description: string;
24
25
  readonly #location: FileSystemLocation;
25
26
 
26
27
  constructor(
27
- description: string,
28
+ description: Answerable<string>,
28
29
  location: FileSystemLocation = Activity.callerLocation(5)
29
30
  ) {
30
- this.#description = description;
31
+ super(description);
31
32
  this.#location = location;
32
33
  }
33
34
 
@@ -51,18 +52,6 @@ export abstract class Activity {
51
52
  */
52
53
  abstract performAs(actor: PerformsActivities | UsesAbilities | AnswersQuestions): Promise<any>;
53
54
 
54
- /**
55
- * Generates a human-friendly description to be used when reporting this Activity.
56
- *
57
- * **Note**: When this activity is reported, token `#actor` in the description
58
- * will be replaced with the name of the actor performing this Activity.
59
- *
60
- * For example, `#actor clicks on a button` becomes `Wendy clicks on a button`.
61
- */
62
- toString(): string {
63
- return this.#description;
64
- }
65
-
66
55
  protected static callerLocation(frameOffset: number): FileSystemLocation {
67
56
 
68
57
  const originalStackTraceLimit = Error.stackTraceLimit;
@@ -1,6 +1,6 @@
1
1
  import { ConfigurationError, TestCompromisedError } from '../errors';
2
2
  import { ActivityRelatedArtifactGenerated } from '../events';
3
- import { typeOf } from '../io';
3
+ import { ValueInspector } from '../io';
4
4
  import type { Artifact} from '../model';
5
5
  import { Name, } from '../model';
6
6
  import type { Stage } from '../stage';
@@ -250,7 +250,7 @@ export class Actor implements PerformsActivities,
250
250
 
251
251
  private acquireAbility(ability: Ability): void {
252
252
  if (!(ability instanceof Ability)) {
253
- throw new ConfigurationError(`Custom abilities must extend Ability from '@serenity-js/core'. Received ${ typeOf(ability) }`);
253
+ throw new ConfigurationError(`Custom abilities must extend Ability from '@serenity-js/core'. Received ${ ValueInspector.typeOf(ability) }`);
254
254
  }
255
255
 
256
256
  const abilityType = this.mostGenericTypeOf(ability.constructor as AbilityType<Ability>);
@@ -1,5 +1,6 @@
1
1
  import type { UsesAbilities } from './abilities';
2
2
  import { Activity } from './Activity';
3
+ import type { Answerable } from './Answerable';
3
4
  import type { CollectsArtifacts } from './artifacts';
4
5
  import type { AnswersQuestions } from './questions';
5
6
 
@@ -34,11 +35,11 @@ import type { AnswersQuestions } from './questions';
34
35
  * you can easily create your own implementations using the {@apilink Interaction.where} factory method.
35
36
  *
36
37
  * ```ts
37
- * import { Actor, Interaction } from '@serenity-js/core'
38
+ * import { Actor, Interaction, the } from '@serenity-js/core'
38
39
  * import { BrowseTheWeb, Page } from '@serenity-js/web'
39
40
  *
40
41
  * export const ClearLocalStorage = () =>
41
- * Interaction.where(`#actor clears local storage`, async (actor: Actor) => {
42
+ * Interaction.where(the`#actor clears local storage`, async (actor: Actor) => {
42
43
  * // Interaction to ClearLocalStorage directly uses Actor's ability to BrowseTheWeb
43
44
  * const page: Page = await BrowseTheWeb.as(actor).currentPage()
44
45
  * await page.executeScript(() => window.localStorage.clear())
@@ -76,7 +77,7 @@ export abstract class Interaction extends Activity {
76
77
  * @param interaction
77
78
  */
78
79
  static where(
79
- description: string,
80
+ description: Answerable<string>,
80
81
  interaction: (actor: UsesAbilities & AnswersQuestions & CollectsArtifacts) => Promise<void> | void,
81
82
  ): Interaction {
82
83
  return new DynamicallyGeneratedInteraction(description, interaction);
@@ -101,7 +102,7 @@ export abstract class Interaction extends Activity {
101
102
  */
102
103
  class DynamicallyGeneratedInteraction extends Interaction {
103
104
  constructor(
104
- description: string,
105
+ description: Answerable<string>,
105
106
  private readonly interaction: (actor: UsesAbilities & AnswersQuestions & CollectsArtifacts) => Promise<void> | void,
106
107
  ) {
107
108
  super(description, Interaction.callerLocation(4));
@@ -1,15 +1,18 @@
1
- import { isRecord } from 'tiny-types/lib/objects';
1
+ import { isRecord, significantFieldsOf } from 'tiny-types/lib/objects';
2
2
  import * as util from 'util'; // eslint-disable-line unicorn/import-style
3
3
 
4
4
  import { LogicError } from '../errors';
5
5
  import type { FileSystemLocation } from '../io';
6
- import { asyncMap, d, f, inspectedObject } from '../io';
6
+ import { asyncMap, f, inspectedObject, ValueInspector } from '../io';
7
7
  import type { UsesAbilities } from './abilities';
8
8
  import type { Answerable } from './Answerable';
9
9
  import { Interaction } from './Interaction';
10
10
  import type { Optional } from './Optional';
11
11
  import type { AnswersQuestions } from './questions/AnswersQuestions';
12
+ import { Describable } from './questions/Describable';
13
+ import type { DescriptionFormattingOptions } from './questions/DescriptionFormattingOptions';
12
14
  import type { MetaQuestion } from './questions/MetaQuestion';
15
+ import { the } from './questions/tag-functions';
13
16
  import { Unanswered } from './questions/Unanswered';
14
17
  import type { RecursivelyAnswered } from './RecursivelyAnswered';
15
18
  import type { WithAnswerableProperties } from './WithAnswerableProperties';
@@ -79,16 +82,18 @@ import type { WithAnswerableProperties } from './WithAnswerableProperties';
79
82
  * import { Ensure, equals } from '@serenity-js/assertions'
80
83
  *
81
84
  * const RequestWasSuccessful = () =>
82
- * Question.about<number>(`the text of the last response status`, actor => {
83
- * return LastResponse.status().answeredBy(actor) === 200;
84
- * });
85
+ * Question.about<number>(`the text of the last response status`, async actor => {
86
+ * const status = await actor.answer(LastResponse.status());
87
+ *
88
+ * return status === 200;
89
+ * })
85
90
  *
86
91
  * await actorCalled('Quentin')
87
92
  * .whoCan(CallAnApi.at('https://api.example.org/'));
88
93
  * .attemptsTo(
89
94
  * Send.a(GetRequest.to('/books/0-688-00230-7')),
90
95
  * Ensure.that(RequestWasSuccessful(), isTrue()),
91
- * );
96
+ * )
92
97
  * ```
93
98
  *
94
99
  * Note that the above example is for demonstration purposes only, Serenity/JS provides an easier way to
@@ -104,12 +109,12 @@ import type { WithAnswerableProperties } from './WithAnswerableProperties';
104
109
  * .attemptsTo(
105
110
  * Send.a(GetRequest.to('/books/0-688-00230-7')),
106
111
  * Ensure.that(LastResponse.status(), equals(200)),
107
- * );
112
+ * )
108
113
  * ```
109
114
  *
110
115
  * @group Screenplay Pattern
111
116
  */
112
- export abstract class Question<T> {
117
+ export abstract class Question<T> extends Describable {
113
118
 
114
119
  /**
115
120
  * Factory method that simplifies the process of defining custom questions.
@@ -128,18 +133,18 @@ export abstract class Question<T> {
128
133
  * @param [metaQuestionBody]
129
134
  */
130
135
  static about<Answer_Type, Supported_Context_Type>(
131
- description: string,
136
+ description: Answerable<string>,
132
137
  body: (actor: AnswersQuestions & UsesAbilities) => Promise<Answer_Type> | Answer_Type,
133
138
  metaQuestionBody: (answerable: Answerable<Supported_Context_Type>) => Question<Promise<Answer_Type>> | Question<Answer_Type>,
134
139
  ): MetaQuestionAdapter<Supported_Context_Type, Awaited<Answer_Type>>
135
140
 
136
141
  static about<Answer_Type>(
137
- description: string,
142
+ description: Answerable<string>,
138
143
  body: (actor: AnswersQuestions & UsesAbilities) => Promise<Answer_Type> | Answer_Type
139
144
  ): QuestionAdapter<Awaited<Answer_Type>>
140
145
 
141
146
  static about<Answer_Type, Supported_Context_Type extends Answerable<any>>(
142
- description: string,
147
+ description: Answerable<string>,
143
148
  body: (actor: AnswersQuestions & UsesAbilities) => Promise<Answer_Type> | Answer_Type,
144
149
  metaQuestionBody?: (answerable: Supported_Context_Type) => QuestionAdapter<Answer_Type>,
145
150
  ): any
@@ -239,9 +244,23 @@ export abstract class Question<T> {
239
244
  * Generates a {@apilink QuestionAdapter} that resolves
240
245
  * any {@apilink Answerable} elements of the provided array.
241
246
  */
242
- static fromArray<Source_Type>(source: Array<Answerable<Source_Type>>): QuestionAdapter<Source_Type[]> {
243
- return Question.about<Source_Type[]>('value', async actor => {
244
- return await asyncMap<Answerable<Source_Type>, Source_Type>(source, async item => actor.answer(item));
247
+ static fromArray<Source_Type>(source: Array<Answerable<Source_Type>>, options?: DescriptionFormattingOptions): QuestionAdapter<Source_Type[]> {
248
+ const formatter = new ValueFormatter(ValueFormatter.defaultOptions);
249
+
250
+ const description = source.length === 0
251
+ ? '[ ]'
252
+ : Question.about(formatter.format(source), async (actor: AnswersQuestions & UsesAbilities & { name: string }) => {
253
+ const descriptions = await asyncMap(source, item =>
254
+ item instanceof Describable
255
+ ? item.describedBy(actor)
256
+ : Question.formattedValue(options).of(item).answeredBy(actor)
257
+ );
258
+
259
+ return `[ ${ descriptions.join(', ') } ]`;
260
+ });
261
+
262
+ return Question.about<Source_Type[]>(description, async actor => {
263
+ return await asyncMap<Answerable<Source_Type>, Source_Type>(source, item => actor.answer(item));
245
264
  });
246
265
  }
247
266
 
@@ -268,6 +287,62 @@ export abstract class Question<T> {
268
287
  && maybeMetaQuestion['of'].length === 1; // arity of 1
269
288
  }
270
289
 
290
+ /**
291
+ * Creates a {@apilink MetaQuestion} that can be composed with any {@apilink Answerable}
292
+ * to produce a single-line description of its value.
293
+ *
294
+ * ```ts
295
+ * import { actorCalled, Question } from '@serenity-js/core'
296
+ * import { Ensure, equals } from '@serenity-js/assertions'
297
+ *
298
+ * const accountDetails = () =>
299
+ * Question.about('account details', actor => ({ name: 'Alice', age: 28 }))
300
+ *
301
+ * await actorCalled('Alice').attemptsTo(
302
+ * Ensure.that(
303
+ * Question.formattedValue().of(accountDetails()),
304
+ * equals('{ name: "Alice", age: 28 }'),
305
+ * ),
306
+ * )
307
+ * ```
308
+ *
309
+ * @param options
310
+ */
311
+ static formattedValue(options?: DescriptionFormattingOptions): MetaQuestion<any, Question<Promise<string>>> {
312
+ return MetaQuestionAboutFormattedValue.using(options);
313
+ }
314
+
315
+ /**
316
+ * Creates a {@apilink MetaQuestion} that can be composed with any {@apilink Answerable}
317
+ * to return its value when the answerable is a {@apilink Question},
318
+ * or the answerable itself otherwise.
319
+ *
320
+ * The description of the resulting question is produced by calling {@apilink Question.description} on the
321
+ * provided answerable.
322
+ *
323
+ * ```ts
324
+ * import { actorCalled, Question } from '@serenity-js/core'
325
+ * import { Ensure, equals } from '@serenity-js/assertions'
326
+ *
327
+ * const accountDetails = () =>
328
+ * Question.about('account details', actor => ({ name: 'Alice', age: 28 }))
329
+ *
330
+ * await actorCalled('Alice').attemptsTo(
331
+ * Ensure.that(
332
+ * Question.description().of(accountDetails()),
333
+ * equals('account details'),
334
+ * ),
335
+ * Ensure.that(
336
+ * Question.value().of(accountDetails()),
337
+ * equals({ name: 'Alice', age: 28 }),
338
+ * ),
339
+ * )
340
+ * ```
341
+ */
342
+ static value<Answer_Type>(): MetaQuestion<Answer_Type, Question<Promise<Answer_Type>>> {
343
+ return new MetaQuestionAboutValue<Answer_Type>();
344
+ }
345
+
271
346
  protected static createAdapter<AT>(statement: Question<AT>): QuestionAdapter<Awaited<AT>> {
272
347
  function getStatement() {
273
348
  return statement;
@@ -321,7 +396,7 @@ export abstract class Question<T> {
321
396
  return;
322
397
  }
323
398
 
324
- return Question.about(Question.fieldDescription(target, key), async (actor: AnswersQuestions & UsesAbilities) => {
399
+ return Question.about(Question.staticFieldDescription(target, key), async (actor: AnswersQuestions & UsesAbilities) => {
325
400
  const answer = await actor.answer(target as Answerable<AT>);
326
401
 
327
402
  if (!isDefined(answer)) {
@@ -333,7 +408,7 @@ export abstract class Question<T> {
333
408
  return typeof field === 'function'
334
409
  ? field.bind(answer)
335
410
  : field;
336
- });
411
+ }).describedAs(Question.formattedValue());
337
412
  },
338
413
 
339
414
  set(currentStatement: () => Question<AT>, key: string | symbol, value: any, receiver: any): boolean {
@@ -366,7 +441,7 @@ export abstract class Question<T> {
366
441
  }) as any;
367
442
  }
368
443
 
369
- private static fieldDescription<AT>(target: Question<AT>, key: string | symbol): string {
444
+ private static staticFieldDescription<AT>(target: Question<AT>, key: string | symbol): string {
370
445
 
371
446
  // "of" is characteristic of Serenity/JS MetaQuestion
372
447
  if (key === 'of') {
@@ -399,22 +474,29 @@ export abstract class Question<T> {
399
474
  }
400
475
 
401
476
  /**
402
- * Returns the description of the subject of this {@apilink Question}.
477
+ * Instructs the provided {@apilink Actor} to use their {@apilink Ability|abilities}
478
+ * to answer this question.
403
479
  */
404
- abstract toString(): string;
480
+ abstract answeredBy(actor: AnswersQuestions & UsesAbilities): T;
405
481
 
406
482
  /**
407
- * Changes the description of this question's subject.
483
+ * Changes the description of this object, as returned by {@apilink Describable.describedBy}
484
+ * and {@apilink Describable.toString}.
408
485
  *
409
- * @param subject
486
+ * @param description
487
+ * Replaces the current description according to the following rules:
488
+ * - If `description` is an {@apilink Answerable}, it replaces the current description
489
+ * - If `description` is a {@apilink MetaQuestion}, the current description is passed as `context` to `description.of(context)`, and the result replaces the current description
410
490
  */
411
- abstract describedAs(subject: string): this;
491
+ describedAs(description: Answerable<string> | MetaQuestion<Awaited<T>, Question<Promise<string>>>): this {
492
+ super.setDescription(
493
+ Question.isAMetaQuestion(description)
494
+ ? description.of(this as Answerable<Awaited<T>>)
495
+ : description
496
+ );
412
497
 
413
- /**
414
- * Instructs the provided {@apilink Actor} to use their {@apilink Ability|abilities}
415
- * to answer this question.
416
- */
417
- abstract answeredBy(actor: AnswersQuestions & UsesAbilities): T;
498
+ return this;
499
+ }
418
500
 
419
501
  /**
420
502
  * Maps this question to one of a different type.
@@ -497,7 +579,7 @@ class QuestionStatement<Answer_Type> extends Interaction implements Question<Pro
497
579
  private answer: Answer_Type | Unanswered = new Unanswered();
498
580
 
499
581
  constructor(
500
- private subject: string,
582
+ subject: Answerable<string>,
501
583
  private readonly body: (actor: AnswersQuestions & UsesAbilities, ...Parameters) => Promise<Answer_Type> | Answer_Type,
502
584
  location: FileSystemLocation = QuestionStatement.callerLocation(4),
503
585
  ) {
@@ -525,13 +607,14 @@ class QuestionStatement<Answer_Type> extends Interaction implements Question<Pro
525
607
  return inspectedObject(this.answer)(depth, options, inspect);
526
608
  }
527
609
 
528
- describedAs(subject: string): this {
529
- this.subject = subject;
530
- return this;
531
- }
610
+ describedAs(description: Answerable<string> | MetaQuestion<Answer_Type, Question<Promise<string>>>): this {
611
+ super.setDescription(
612
+ Question.isAMetaQuestion(description)
613
+ ? description.of(this)
614
+ : description
615
+ );
532
616
 
533
- override toString(): string {
534
- return this.subject;
617
+ return this;
535
618
  }
536
619
 
537
620
  as<O>(mapping: (answer: Awaited<Answer_Type>) => (Promise<O> | O)): QuestionAdapter<O> {
@@ -555,7 +638,7 @@ class MetaQuestionStatement<Answer_Type, Supported_Context_Type extends Answerab
555
638
  implements MetaQuestion<Supported_Context_Type, QuestionAdapter<Answer_Type>>
556
639
  {
557
640
  constructor(
558
- subject: string,
641
+ subject: Answerable<string>,
559
642
  body: (actor: AnswersQuestions & UsesAbilities, ...Parameters) => Promise<Answer_Type> | Answer_Type,
560
643
  private readonly metaQuestionBody: (answerable: Answerable<Supported_Context_Type>) => QuestionAdapter<Answer_Type>,
561
644
  ) {
@@ -564,7 +647,7 @@ class MetaQuestionStatement<Answer_Type, Supported_Context_Type extends Answerab
564
647
 
565
648
  of(answerable: Answerable<Supported_Context_Type>): QuestionAdapter<Answer_Type> {
566
649
  return Question.about(
567
- this.toString() + d` of ${ answerable }`,
650
+ the`${ this } of ${ answerable }`,
568
651
  actor => actor.answer(this.metaQuestionBody(answerable))
569
652
  );
570
653
  }
@@ -575,11 +658,8 @@ class MetaQuestionStatement<Answer_Type, Supported_Context_Type extends Answerab
575
658
  */
576
659
  class IsPresent<T> extends Question<Promise<boolean>> {
577
660
 
578
- private subject: string;
579
-
580
661
  constructor(private readonly question: Question<T>) {
581
- super();
582
- this.subject = f`${question}.isPresent()`;
662
+ super(f`${question}.isPresent()`);
583
663
  }
584
664
 
585
665
  async answeredBy(actor: AnswersQuestions & UsesAbilities): Promise<boolean> {
@@ -604,15 +684,6 @@ class IsPresent<T> extends Question<Promise<boolean>> {
604
684
  return typeof maybeOptional === 'object'
605
685
  && Reflect.has(maybeOptional, 'isPresent');
606
686
  }
607
-
608
- describedAs(subject: string): this {
609
- this.subject = subject;
610
- return this;
611
- }
612
-
613
- toString(): string {
614
- return this.subject;
615
- }
616
687
  }
617
688
 
618
689
  /**
@@ -666,3 +737,182 @@ async function recursivelyAnswer<K extends number | string | symbol, V> (
666
737
 
667
738
  return answer as Record<K, V>;
668
739
  }
740
+
741
+ class MetaQuestionAboutValue<Answer_Type> implements MetaQuestion<Answer_Type, Question<Promise<Answer_Type>>> {
742
+ of(answerable: Answerable<Answer_Type>): Question<Promise<Answer_Type>> {
743
+ return new QuestionAboutValue<Answer_Type>(answerable);
744
+ }
745
+
746
+ toString(): string {
747
+ return 'value';
748
+ }
749
+ }
750
+
751
+ class QuestionAboutValue<Answer_Type>
752
+ extends Question<Promise<Answer_Type>>
753
+ {
754
+ constructor(private readonly context: Answerable<Answer_Type>) {
755
+ super(QuestionAboutFormattedValue.of(context).toString());
756
+ }
757
+
758
+ async answeredBy(actor: AnswersQuestions & UsesAbilities): Promise<Answer_Type> {
759
+ return await actor.answer(this.context);
760
+ }
761
+ }
762
+
763
+ class MetaQuestionAboutFormattedValue<Supported_Context_Type> implements MetaQuestion<Supported_Context_Type, Question<Promise<string>>> {
764
+ static using<SCT>(options?: DescriptionFormattingOptions): MetaQuestion<SCT, Question<Promise<string>>> {
765
+ return new MetaQuestionAboutFormattedValue(new ValueFormatter({
766
+ ...ValueFormatter.defaultOptions,
767
+ ...options,
768
+ }));
769
+ }
770
+
771
+ constructor(private readonly formatter: ValueFormatter) {
772
+ }
773
+
774
+ of(context: Answerable<Supported_Context_Type>): Question<Promise<string>> & MetaQuestion<any, Question<Promise<string>>> {
775
+ return new QuestionAboutFormattedValue(
776
+ this.formatter,
777
+ context,
778
+ );
779
+ }
780
+
781
+ toString(): string {
782
+ return 'formatted value';
783
+ }
784
+ }
785
+
786
+ class QuestionAboutFormattedValue<Supported_Context_Type>
787
+ extends Question<Promise<string>>
788
+ implements MetaQuestion<any, Question<Promise<string>>>
789
+ {
790
+ static of(context: Answerable<unknown>): Question<Promise<string>> & MetaQuestion<any, Question<Promise<string>>> {
791
+ return new QuestionAboutFormattedValue(new ValueFormatter(ValueFormatter.defaultOptions), context);
792
+ }
793
+
794
+ constructor(
795
+ private readonly formatter: ValueFormatter,
796
+ private context?: Answerable<Supported_Context_Type>
797
+ ) {
798
+ const description = context === undefined
799
+ ? 'formatted value'
800
+ : formatter.format(context);
801
+
802
+ super(description);
803
+ }
804
+
805
+ async answeredBy(actor: AnswersQuestions & UsesAbilities & { name: string }): Promise<string> {
806
+ const answer = await actor.answer(this.context);
807
+
808
+ return this.formatter.format(answer);
809
+ }
810
+
811
+ override async describedBy(actor: AnswersQuestions & UsesAbilities & { name: string }): Promise<string> {
812
+ const unanswered = ! this.context
813
+ || ! this.context['answer']
814
+ || Unanswered.isUnanswered((this.context as any).answer);
815
+
816
+ const answer = unanswered
817
+ ? await actor.answer(this.context)
818
+ : (this.context as any).answer;
819
+
820
+ return this.formatter.format(answer);
821
+ }
822
+
823
+ of(context: Answerable<unknown>): Question<Promise<string>> & MetaQuestion<any, Question<Promise<string>>> {
824
+ return new QuestionAboutFormattedValue(
825
+ this.formatter,
826
+ Question.isAMetaQuestion(this.context)
827
+ ? this.context.of(context)
828
+ : context,
829
+ );
830
+ }
831
+ }
832
+
833
+ class ValueFormatter {
834
+ public static readonly defaultOptions = { maxLength: Number.POSITIVE_INFINITY };
835
+
836
+ constructor(private readonly options: DescriptionFormattingOptions) {
837
+ }
838
+
839
+ format(value: unknown): string {
840
+ if (value === null) {
841
+ return 'null';
842
+ }
843
+
844
+ if (value === undefined) {
845
+ return 'undefined';
846
+ }
847
+
848
+ if (typeof value === 'string') {
849
+ return `"${ this.trim(value) }"`;
850
+ }
851
+
852
+ if (typeof value === 'symbol') {
853
+ return `Symbol(${ this.trim(value.description) })`;
854
+ }
855
+
856
+ if (typeof value === 'bigint') {
857
+ return `${ this.trim(value.toString()) }`;
858
+ }
859
+
860
+ if (ValueInspector.isPromise(value)) {
861
+ return 'Promise';
862
+ }
863
+
864
+ if (Array.isArray(value)) {
865
+ return value.length === 0
866
+ ? '[ ]'
867
+ : `[ ${ this.trim(value.map(item => this.format(item)).join(', ')) } ]`;
868
+ }
869
+
870
+ if (value instanceof Map) {
871
+ return `Map(${ this.format(Object.fromEntries(value.entries())) })`;
872
+ }
873
+
874
+ if (value instanceof Set) {
875
+ return `Set(${ this.format(Array.from(value.values())) })`;
876
+ }
877
+
878
+ if (ValueInspector.isDate(value)) {
879
+ return `Date(${ value.toISOString() })`;
880
+ }
881
+
882
+ if (value instanceof RegExp) {
883
+ return `${ value }`;
884
+ }
885
+
886
+ if (ValueInspector.hasItsOwnToString(value)) {
887
+ return `${ this.trim(value.toString()) }`;
888
+ }
889
+
890
+ if (ValueInspector.isPlainObject(value)) {
891
+ const stringifiedEntries = Object
892
+ .entries(value)
893
+ .reduce((acc, [ key, value ]) => acc.concat(`${ key }: ${ this.format(value) }`), [])
894
+ .join(', ');
895
+
896
+ return `{ ${ this.trim(stringifiedEntries) } }`;
897
+ }
898
+
899
+ if (typeof value === 'object') {
900
+ const entries = significantFieldsOf(value)
901
+ .map(field => [ field, (value as any)[field] ]);
902
+ return `${ value.constructor.name }(${ this.format(Object.fromEntries(entries)) })`;
903
+ }
904
+
905
+ return String(value);
906
+ }
907
+
908
+ private trim(value: string): string {
909
+ const ellipsis = '...';
910
+ const oneLiner = value.replaceAll(/\n+/g, ' ');
911
+
912
+ const maxLength = Math.max(ellipsis.length + 1, this.options.maxLength);
913
+
914
+ return oneLiner.length > maxLength
915
+ ? `${ oneLiner.slice(0, Math.max(0, maxLength) - ellipsis.length) }${ ellipsis }`
916
+ : oneLiner;
917
+ }
918
+ }