@serenity-js/core 3.42.2 → 3.43.1

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 (104) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/esm/Serenity.d.ts +3 -1
  3. package/esm/Serenity.d.ts.map +1 -1
  4. package/esm/Serenity.js +4 -3
  5. package/esm/Serenity.js.map +1 -1
  6. package/esm/config/SerenityConfig.d.ts +1 -1
  7. package/esm/config/SerenityConfig.js +1 -1
  8. package/esm/events/SceneFinishes.d.ts +1 -1
  9. package/esm/events/SceneFinishes.js +1 -1
  10. package/esm/events/actor/ActorStageExitCompleted.d.ts +1 -1
  11. package/esm/events/actor/ActorStageExitCompleted.js +1 -1
  12. package/esm/events/actor/ActorStageExitFailed.d.ts +1 -1
  13. package/esm/events/actor/ActorStageExitFailed.js +1 -1
  14. package/esm/events/actor/ActorStageExitStarts.d.ts +1 -1
  15. package/esm/events/actor/ActorStageExitStarts.js +1 -1
  16. package/esm/screenplay/Actor.d.ts +3 -3
  17. package/esm/screenplay/Actor.d.ts.map +1 -1
  18. package/esm/screenplay/Actor.js +7 -8
  19. package/esm/screenplay/Actor.js.map +1 -1
  20. package/esm/screenplay/abilities/Ability.d.ts +6 -6
  21. package/esm/screenplay/abilities/Ability.js +6 -6
  22. package/esm/screenplay/abilities/Discardable.d.ts +5 -4
  23. package/esm/screenplay/abilities/Discardable.d.ts.map +1 -1
  24. package/esm/screenplay/abilities/Discardable.js +25 -1
  25. package/esm/screenplay/abilities/Discardable.js.map +1 -1
  26. package/esm/screenplay/abilities/Initialisable.d.ts +9 -8
  27. package/esm/screenplay/abilities/Initialisable.d.ts.map +1 -1
  28. package/esm/screenplay/abilities/Initialisable.js +22 -1
  29. package/esm/screenplay/abilities/Initialisable.js.map +1 -1
  30. package/esm/stage/ActorLifecycleManager.d.ts +191 -0
  31. package/esm/stage/ActorLifecycleManager.d.ts.map +1 -0
  32. package/esm/stage/ActorLifecycleManager.js +255 -0
  33. package/esm/stage/ActorLifecycleManager.js.map +1 -0
  34. package/esm/stage/Stage.d.ts +22 -38
  35. package/esm/stage/Stage.d.ts.map +1 -1
  36. package/esm/stage/Stage.js +61 -117
  37. package/esm/stage/Stage.js.map +1 -1
  38. package/esm/stage/StageManager.d.ts +2 -4
  39. package/esm/stage/StageManager.d.ts.map +1 -1
  40. package/esm/stage/StageManager.js +0 -5
  41. package/esm/stage/StageManager.js.map +1 -1
  42. package/esm/stage/index.d.ts +1 -0
  43. package/esm/stage/index.d.ts.map +1 -1
  44. package/esm/stage/index.js +1 -0
  45. package/esm/stage/index.js.map +1 -1
  46. package/lib/Serenity.d.ts +3 -1
  47. package/lib/Serenity.d.ts.map +1 -1
  48. package/lib/Serenity.js +4 -3
  49. package/lib/Serenity.js.map +1 -1
  50. package/lib/config/SerenityConfig.d.ts +1 -1
  51. package/lib/config/SerenityConfig.js +1 -1
  52. package/lib/events/SceneFinishes.d.ts +1 -1
  53. package/lib/events/SceneFinishes.js +1 -1
  54. package/lib/events/actor/ActorStageExitCompleted.d.ts +1 -1
  55. package/lib/events/actor/ActorStageExitCompleted.js +1 -1
  56. package/lib/events/actor/ActorStageExitFailed.d.ts +1 -1
  57. package/lib/events/actor/ActorStageExitFailed.js +1 -1
  58. package/lib/events/actor/ActorStageExitStarts.d.ts +1 -1
  59. package/lib/events/actor/ActorStageExitStarts.js +1 -1
  60. package/lib/screenplay/Actor.d.ts +3 -3
  61. package/lib/screenplay/Actor.d.ts.map +1 -1
  62. package/lib/screenplay/Actor.js +6 -7
  63. package/lib/screenplay/Actor.js.map +1 -1
  64. package/lib/screenplay/abilities/Ability.d.ts +6 -6
  65. package/lib/screenplay/abilities/Ability.js +6 -6
  66. package/lib/screenplay/abilities/Discardable.d.ts +5 -4
  67. package/lib/screenplay/abilities/Discardable.d.ts.map +1 -1
  68. package/lib/screenplay/abilities/Discardable.js +27 -0
  69. package/lib/screenplay/abilities/Discardable.js.map +1 -1
  70. package/lib/screenplay/abilities/Initialisable.d.ts +9 -8
  71. package/lib/screenplay/abilities/Initialisable.d.ts.map +1 -1
  72. package/lib/screenplay/abilities/Initialisable.js +24 -0
  73. package/lib/screenplay/abilities/Initialisable.js.map +1 -1
  74. package/lib/stage/ActorLifecycleManager.d.ts +191 -0
  75. package/lib/stage/ActorLifecycleManager.d.ts.map +1 -0
  76. package/lib/stage/ActorLifecycleManager.js +259 -0
  77. package/lib/stage/ActorLifecycleManager.js.map +1 -0
  78. package/lib/stage/Stage.d.ts +22 -38
  79. package/lib/stage/Stage.d.ts.map +1 -1
  80. package/lib/stage/Stage.js +57 -113
  81. package/lib/stage/Stage.js.map +1 -1
  82. package/lib/stage/StageManager.d.ts +2 -4
  83. package/lib/stage/StageManager.d.ts.map +1 -1
  84. package/lib/stage/StageManager.js +0 -5
  85. package/lib/stage/StageManager.js.map +1 -1
  86. package/lib/stage/index.d.ts +1 -0
  87. package/lib/stage/index.d.ts.map +1 -1
  88. package/lib/stage/index.js +1 -0
  89. package/lib/stage/index.js.map +1 -1
  90. package/package.json +3 -3
  91. package/src/Serenity.ts +5 -2
  92. package/src/config/SerenityConfig.ts +1 -1
  93. package/src/events/SceneFinishes.ts +1 -1
  94. package/src/events/actor/ActorStageExitCompleted.ts +1 -1
  95. package/src/events/actor/ActorStageExitFailed.ts +1 -1
  96. package/src/events/actor/ActorStageExitStarts.ts +1 -1
  97. package/src/screenplay/Actor.ts +8 -11
  98. package/src/screenplay/abilities/Ability.ts +6 -6
  99. package/src/screenplay/abilities/Discardable.ts +9 -4
  100. package/src/screenplay/abilities/Initialisable.ts +15 -8
  101. package/src/stage/ActorLifecycleManager.ts +314 -0
  102. package/src/stage/Stage.ts +87 -165
  103. package/src/stage/StageManager.ts +3 -7
  104. package/src/stage/index.ts +1 -0
@@ -0,0 +1,314 @@
1
+ import { ensure, isDefined, property } from 'tiny-types';
2
+
3
+ import { ConfigurationError, LogicError, RaiseErrors } from '../errors/index.js';
4
+ import { ActorEntersStage, ActorSpotlighted, } from '../events/index.js';
5
+ import { CorrelationId } from '../model/index.js';
6
+ import type { Clock, Duration } from '../screenplay/index.js';
7
+ import { Actor, ScheduleWork } from '../screenplay/index.js';
8
+ import type { Cast } from './Cast.js';
9
+ import type { Stage } from './Stage.js';
10
+
11
+ /**
12
+ * Represents the focus area where actors are tracked.
13
+ *
14
+ * - `'foreground'` - Scene-scoped actors that are dismissed when a scene finishes
15
+ * - `'background'` - Test run-scoped actors that persist across scenes and are dismissed when the test run finishes
16
+ *
17
+ * @group Stage
18
+ */
19
+ export type StageFocus = 'foreground' | 'background';
20
+
21
+ /**
22
+ * Manages the lifecycle of [actors](https://serenity-js.org/api/core/class/Actor/) on the [stage](https://serenity-js.org/api/core/class/Stage/),
23
+ * including their creation, retrieval, and tracking of which actors are in the foreground (scene-scoped)
24
+ * versus background (test run-scoped).
25
+ *
26
+ * The `ActorLifecycleManager` is responsible for:
27
+ * - Instantiating and caching actors via the configured [cast](https://serenity-js.org/api/core/class/Cast/)
28
+ * - Tracking which actor is currently in the spotlight (active)
29
+ * - Managing the focus area (foreground vs background) where new actors are created
30
+ * - Providing access to actors for dismissal when scenes or test runs finish
31
+ *
32
+ * ## Default behaviour
33
+ *
34
+ * By default, actors created before the actual test scenario starts, e.g. in beforeAll hooks, are placed in the `'background'` focus area.
35
+ * When a [`SceneStarts`](https://serenity-js.org/api/core-events/class/SceneStarts/) event is announced,
36
+ * the focus switches to `'foreground'`. When a [`SceneFinishes`](https://serenity-js.org/api/core-events/class/SceneFinishes/)
37
+ * event is announced, foreground actors are dismissed, their abilities [discarded](https://serenity-js.org/api/core/class/Discardable/)
38
+ * and focus returns to `'background'`.
39
+ *
40
+ * ## Custom lifecycle management
41
+ *
42
+ * Test runner adapters like [`@serenity-js/playwright-test`](https://serenity-js.org/api/playwright-test/),
43
+ * where test execution and reporting happen in separate processes, can inject a custom `ActorLifecycleManager` instance
44
+ * to control actor lifecycle programmatically.
45
+ *
46
+ * ```typescript
47
+ * const actorLifecycleManager = new ActorLifecycleManager(cast, clock, interactionTimeout);
48
+ * const serenity = new Serenity(clock, cueTimeout, actorLifecycleManager);
49
+ *
50
+ * // At the start of each test:
51
+ * actorLifecycleManager.switchFocus('foreground');
52
+ * ```
53
+ *
54
+ * ## Learn more
55
+ * - [`Stage`](https://serenity-js.org/api/core/class/Stage/)
56
+ * - [`Cast`](https://serenity-js.org/api/core/class/Cast/)
57
+ * - [`Actor`](https://serenity-js.org/api/core/class/Actor/)
58
+ *
59
+ * @group Stage
60
+ */
61
+ export class ActorLifecycleManager {
62
+
63
+ /**
64
+ * The most recent actor referenced via the {@apilink actor} method
65
+ */
66
+ private currentActor?: Actor;
67
+
68
+ /**
69
+ * The scene in which the spotlight was last set.
70
+ * Used to detect when the spotlight shifts to a different scene context.
71
+ */
72
+ private currentActorScene: CorrelationId = new CorrelationId('unknown');
73
+
74
+ private currentFocusValue: StageFocus = 'background';
75
+
76
+ private actors: Record<StageFocus, Map<string, Actor>> = {
77
+ 'foreground': new Map<string, Actor>(),
78
+ 'background': new Map<string, Actor>(),
79
+ }
80
+
81
+ protected stage: Stage;
82
+
83
+ constructor(
84
+ protected cast: Cast,
85
+ protected readonly clock: Clock,
86
+ protected interactionTimeout: Duration,
87
+ ) {
88
+ }
89
+
90
+ /**
91
+ * Configures the manager with new settings.
92
+ *
93
+ * @param options - Configuration options
94
+ * @param options.interactionTimeout - The maximum time to wait for an interaction to complete
95
+ */
96
+ configure({ interactionTimeout }: { interactionTimeout: Duration }): void {
97
+ this.interactionTimeout = interactionTimeout;
98
+ }
99
+
100
+ /**
101
+ * Associates this manager with a [`Stage`](https://serenity-js.org/api/core/class/Stage/) instance.
102
+ *
103
+ * This method is called automatically by the `Stage` during construction.
104
+ * It establishes the bidirectional relationship between the manager and the stage,
105
+ * allowing the manager to emit [domain events](https://serenity-js.org/api/core-events/class/DomainEvent/)
106
+ * when actors enter the stage or are spotlighted.
107
+ *
108
+ * @param stage - The Stage instance to associate with this manager
109
+ */
110
+ assignTo(stage: Stage): void {
111
+ this.stage = stage;
112
+ }
113
+
114
+ /**
115
+ * Configures the manager to use the provided [cast](https://serenity-js.org/api/core/class/Cast/) for preparing actors.
116
+ *
117
+ * @param actors - The cast to use for preparing new actors
118
+ *
119
+ * @throws [`ConfigurationError`](https://serenity-js.org/api/core/class/ConfigurationError/)
120
+ * If the provided cast is not defined or doesn't have a `prepare` method
121
+ */
122
+ engage(actors: Cast): void {
123
+ this.cast = ensure('actors', actors, isDefined(), property('prepare', isDefined()));
124
+ }
125
+
126
+ /**
127
+ * Returns the current [cast](https://serenity-js.org/api/core/class/Cast/) used for preparing actors.
128
+ *
129
+ * @returns The currently configured cast
130
+ */
131
+ currentCast(): Cast {
132
+ return this.cast;
133
+ }
134
+
135
+ /**
136
+ * Instantiates a new [`Actor`](https://serenity-js.org/api/core/class/Actor/) or fetches an existing one
137
+ * identified by their name if they've already been instantiated.
138
+ *
139
+ * When a new actor is instantiated, an [`ActorEntersStage`](https://serenity-js.org/api/core-events/class/ActorEntersStage/)
140
+ * event is announced. When the spotlight shifts to a different actor (or the same actor in a different scene),
141
+ * an [`ActorSpotlighted`](https://serenity-js.org/api/core-events/class/ActorSpotlighted/) event is announced.
142
+ *
143
+ * Actors are first looked up in the `'background'` focus area, then in `'foreground'`.
144
+ * New actors are always created in the current focus area.
145
+ *
146
+ * @param name - Case-sensitive name of the Actor, e.g. `Alice`
147
+ * @returns The actor with the given name
148
+ */
149
+ public actor(name: string): Actor {
150
+ if (! this.existingActorCalled(name)) {
151
+ const actor = this.prepareActor(new Actor(name, this.stage, [
152
+ new RaiseErrors(this.stage),
153
+ new ScheduleWork(this.clock, this.interactionTimeout)
154
+ ]));
155
+
156
+ this.actors[this.currentFocusValue].set(actor.name, actor);
157
+
158
+ this.stage.announce(
159
+ new ActorEntersStage(
160
+ this.stage.currentSceneId(),
161
+ actor.toJSON(),
162
+ this.stage.currentTime(),
163
+ )
164
+ );
165
+ }
166
+
167
+ const previousActorInSpotlight = this.currentActor;
168
+ const previousSceneOfSpotlightedActor = this.currentActorScene;
169
+
170
+ this.currentActor = this.existingActorCalled(name);
171
+ this.currentActorScene = this.stage.currentSceneId();
172
+
173
+ const spotlightShifted = this.currentActor !== previousActorInSpotlight
174
+ || ! this.stage.currentSceneId().equals(previousSceneOfSpotlightedActor);
175
+
176
+ if (spotlightShifted) {
177
+ this.stage.announce(
178
+ new ActorSpotlighted(
179
+ this.stage.currentSceneId(),
180
+ this.currentActor.toJSON(),
181
+ this.stage.currentTime(),
182
+ )
183
+ );
184
+ }
185
+
186
+ return this.currentActor;
187
+ }
188
+
189
+ private prepareActor(actor: Actor): Actor {
190
+
191
+ let preparedActor: Actor;
192
+
193
+ try {
194
+ preparedActor = this.cast.prepare(actor);
195
+ }
196
+ catch (error) {
197
+ throw new ConfigurationError(`${ this.typeOf(this.cast) } encountered a problem when preparing actor "${ actor.name }" for stage`, error);
198
+ }
199
+
200
+ if (! (preparedActor instanceof Actor)) {
201
+ throw new ConfigurationError(`Instead of a new instance of actor "${ actor.name }", ${ this.typeOf(this.cast) } returned ${ preparedActor }`);
202
+ }
203
+
204
+ return preparedActor;
205
+ }
206
+
207
+ private typeOf(cast: Cast): string {
208
+ return cast.constructor === Object
209
+ ? 'Cast'
210
+ : cast.constructor.name;
211
+ }
212
+
213
+ private existingActorCalled(name: string): Actor {
214
+ return this.actors['background'].has(name)
215
+ ? this.actors['background'].get(name)
216
+ : this.actors['foreground'].get(name);
217
+ }
218
+
219
+ /**
220
+ * Returns `true` if there is an [`Actor`](https://serenity-js.org/api/core/class/Actor/) in the spotlight, `false` otherwise.
221
+ *
222
+ * @returns `true` if an actor is currently spotlighted
223
+ */
224
+ public hasActorInTheSpotlight(): boolean {
225
+ return Boolean(this.currentActor);
226
+ }
227
+
228
+ /**
229
+ * Returns the last [`Actor`](https://serenity-js.org/api/core/class/Actor/) instantiated
230
+ * via [`actor`](https://serenity-js.org/api/core/class/ActorLifecycleManager/#actor).
231
+ *
232
+ * @returns The currently spotlighted actor
233
+ *
234
+ * @throws [`LogicError`](https://serenity-js.org/api/core/class/LogicError/)
235
+ * If no [`Actor`](https://serenity-js.org/api/core/class/Actor/) has been activated yet
236
+ */
237
+ public actorInTheSpotlight(): Actor {
238
+ if (! this.currentActor) {
239
+ throw new LogicError(`There is no actor in the spotlight yet. Make sure you instantiate one with stage.actor(actorName) before calling this method.`);
240
+ }
241
+
242
+ return this.currentActor;
243
+ }
244
+
245
+ /**
246
+ * Switches the focus to the specified stage area.
247
+ *
248
+ * Actors created after this call will be added to the specified area.
249
+ * This method is typically called automatically by the [`Stage`](https://serenity-js.org/api/core/class/Stage/)
250
+ * in response to [`SceneStarts`](https://serenity-js.org/api/core-events/class/SceneStarts/) and
251
+ * [`SceneFinishes`](https://serenity-js.org/api/core-events/class/SceneFinishes/) events.
252
+ *
253
+ * Test runner adapters can also call this method directly to control actor lifecycle
254
+ * when scene events are not available (e.g., in Playwright Test where the reporter
255
+ * runs in a separate process).
256
+ *
257
+ * @param focus - The focus area to switch to: `'foreground'` for scene-scoped actors,
258
+ * `'background'` for test run-scoped actors
259
+ */
260
+ switchFocus(focus: StageFocus): void {
261
+ this.currentFocusValue = focus;
262
+ }
263
+
264
+ /**
265
+ * Returns the current focus area.
266
+ *
267
+ * @returns The current focus: `'foreground'` or `'background'`
268
+ */
269
+ currentFocus(): StageFocus {
270
+ return this.currentFocusValue;
271
+ }
272
+
273
+ /**
274
+ * Returns all actors in the specified focus area.
275
+ *
276
+ * This method is used by the [`Stage`](https://serenity-js.org/api/core/class/Stage/) to retrieve
277
+ * actors for dismissal when scenes or test runs finish.
278
+ *
279
+ * @param focus - The focus area to retrieve actors from
280
+ * @returns An array of actors in the specified focus area
281
+ */
282
+ actorsIn(focus: StageFocus): Actor[] {
283
+ return Array.from(this.actors[focus].values());
284
+ }
285
+
286
+ /**
287
+ * Clears the spotlight if the current actor is in the specified focus area.
288
+ *
289
+ * This ensures that after actors in a focus area are dismissed, the spotlight
290
+ * doesn't reference a dismissed actor.
291
+ *
292
+ * @param focus - The focus area to check
293
+ */
294
+ clearSpotlightIfIn(focus: StageFocus): void {
295
+
296
+ const actors = this.actorsIn(focus);
297
+
298
+ if (actors.includes(this.currentActor)) {
299
+ this.currentActor = undefined;
300
+ }
301
+ }
302
+
303
+ /**
304
+ * Clears all actors from the specified focus area.
305
+ *
306
+ * This method is called by the [`Stage`](https://serenity-js.org/api/core/class/Stage/) after
307
+ * actors have been dismissed to remove them from the internal tracking maps.
308
+ *
309
+ * @param focus - The focus area to clear
310
+ */
311
+ clearActorsIn(focus: StageFocus): void {
312
+ this.actors[focus].clear();
313
+ }
314
+ }
@@ -1,17 +1,13 @@
1
- import { ensure, isDefined, property } from 'tiny-types';
1
+ import { ensure, isDefined } from 'tiny-types';
2
2
 
3
3
  import type { SerenityConfig } from '../config/index.js';
4
4
  import {
5
- ConfigurationError,
6
5
  ErrorFactory,
7
6
  type ErrorOptions,
8
7
  LogicError,
9
- RaiseErrors,
10
8
  type RuntimeError
11
9
  } from '../errors/index.js';
12
10
  import {
13
- ActorEntersStage,
14
- ActorSpotlighted,
15
11
  ActorStageExitAttempted,
16
12
  ActorStageExitCompleted,
17
13
  ActorStageExitFailed,
@@ -23,9 +19,11 @@ import {
23
19
  TestRunFinishes
24
20
  } from '../events/index.js';
25
21
  import { type ActivityDetails, CorrelationId, type CorrelationIdFactory, Name } from '../model/index.js';
26
- import { Actor, type Clock, type Duration, ScheduleWork, type Timestamp } from '../screenplay/index.js';
27
- import type { ListensToDomainEvents } from '../stage/index.js';
22
+ import type { Actor} from '../screenplay/index.js';
23
+ import type { Clock, Duration, Timestamp } from '../screenplay/index.js';
24
+ import { ActorLifecycleManager, type StageFocus } from './ActorLifecycleManager.js';
28
25
  import type { Cast } from './Cast.js';
26
+ import type { ListensToDomainEvents } from './ListensToDomainEvents.js';
29
27
  import type { StageManager } from './StageManager.js';
30
28
 
31
29
  /**
@@ -48,50 +46,33 @@ export class Stage implements EmitsDomainEvents {
48
46
 
49
47
  public static readonly unknownSceneId = new CorrelationId('unknown')
50
48
 
51
- /**
52
- * Actors instantiated after the scene has started,
53
- * who will be dismissed when the scene finishes.
54
- */
55
- private actorsOnFrontStage: Map<string, Actor> = new Map<string, Actor>();
56
-
57
- /**
58
- * Actors instantiated before the scene has started,
59
- * who will be dismissed when the test run finishes.
60
- */
61
- private actorsOnBackstage: Map<string, Actor> = new Map<string, Actor>();
62
-
63
- private actorsOnStage: Map<string, Actor> = this.actorsOnBackstage;
64
-
65
- /**
66
- * The most recent actor referenced via the [`Actor`](https://serenity-js.org/api/core/class/Actor/) method
67
- */
68
- private actorInTheSpotlight: Actor = undefined;
69
-
70
- /**
71
- * The scene in which the spotlight was last set.
72
- * Used to detect when the spotlight shifts to a different scene context.
73
- */
74
- private sceneOfSpotlightedActor: CorrelationId = undefined;
75
-
76
49
  private currentActivity: { id: CorrelationId, details: ActivityDetails } = undefined;
77
50
 
78
51
  private currentScene: CorrelationId = Stage.unknownSceneId;
79
52
 
53
+ private readonly actorLifecycleManager: ActorLifecycleManager
54
+
80
55
  /**
81
- * @param cast
82
- * @param manager
83
- * @param errors
84
- * @param clock
85
- * @param interactionTimeout
86
- * @param sceneIdFactory
56
+ * Creates a new Stage instance.
57
+ *
58
+ * @param cast - The default cast to use for preparing actors
59
+ * @param manager - The stage manager responsible for notifying listeners of domain events
60
+ * @param errors - Factory for creating runtime errors with proper context
61
+ * @param clock - Clock used for timestamping domain events
62
+ * @param interactionTimeout - Default timeout for actor interactions
63
+ * @param sceneIdFactory - Factory for creating scene correlation IDs
64
+ * @param actorLifecycleManager - Optional custom ActorLifecycleManager instance.
65
+ * When provided, allows test runner adapters to control actor lifecycle programmatically.
66
+ * If not provided, a default manager is created.
87
67
  */
88
68
  constructor(
89
- private cast: Cast,
90
- private manager: StageManager,
69
+ cast: Cast,
70
+ private readonly manager: StageManager,
91
71
  private errors: ErrorFactory,
92
72
  private readonly clock: Clock,
93
- private interactionTimeout: Duration,
73
+ interactionTimeout: Duration,
94
74
  private readonly sceneIdFactory: CorrelationIdFactory = CorrelationId,
75
+ actorLifecycleManager?: ActorLifecycleManager,
95
76
  ) {
96
77
  ensure('Cast', cast, isDefined());
97
78
  ensure('StageManager', manager, isDefined());
@@ -99,13 +80,18 @@ export class Stage implements EmitsDomainEvents {
99
80
  ensure('Clock', clock, isDefined());
100
81
  ensure('interactionTimeout', interactionTimeout, isDefined());
101
82
  ensure('sceneIdFactory', sceneIdFactory, isDefined());
83
+
84
+ this.actorLifecycleManager = actorLifecycleManager ?? new ActorLifecycleManager(cast, this.clock, interactionTimeout);
85
+ this.actorLifecycleManager.assignTo(this);
102
86
  }
103
87
 
104
88
  configure(options: Pick<SerenityConfig, 'actors' | 'cueTimeout' | 'interactionTimeout' | 'diffFormatter'>): void {
105
- this.interactionTimeout = options.interactionTimeout || this.interactionTimeout;
89
+ if (options.interactionTimeout) {
90
+ this.actorLifecycleManager.configure({ interactionTimeout: options.interactionTimeout });
91
+ }
106
92
 
107
93
  if (options.actors) {
108
- this.engage(options.actors);
94
+ this.actorLifecycleManager.engage(options.actors);
109
95
  }
110
96
 
111
97
  if (options.cueTimeout) {
@@ -134,52 +120,7 @@ export class Stage implements EmitsDomainEvents {
134
120
  * Case-sensitive name of the Actor, e.g. `Alice`
135
121
  */
136
122
  actor(name: string): Actor {
137
- if (! this.instantiatedActorCalled(name)) {
138
- let actor;
139
- try {
140
- const newActor = new Actor(name, this, [
141
- new RaiseErrors(this),
142
- new ScheduleWork(this.clock, this.interactionTimeout)
143
- ]);
144
-
145
- actor = this.cast.prepare(newActor);
146
- }
147
- catch (error) {
148
- throw new ConfigurationError(`${ this.typeOf(this.cast) } encountered a problem when preparing actor "${ name }" for stage`, error);
149
- }
150
-
151
- if (! (actor instanceof Actor)) {
152
- throw new ConfigurationError(`Instead of a new instance of actor "${ name }", ${ this.typeOf(this.cast) } returned ${ actor }`);
153
- }
154
-
155
- this.actorsOnStage.set(name, actor);
156
-
157
- this.announce(
158
- new ActorEntersStage(
159
- this.currentScene,
160
- actor.toJSON(),
161
- )
162
- )
163
- }
164
-
165
- const previousActorInSpotlight = this.actorInTheSpotlight;
166
- const previousSceneOfSpotlightedActor = this.sceneOfSpotlightedActor;
167
- this.actorInTheSpotlight = this.instantiatedActorCalled(name);
168
- this.sceneOfSpotlightedActor = this.currentScene;
169
-
170
- const spotlightShifted = this.actorInTheSpotlight !== previousActorInSpotlight
171
- || ! this.currentScene.equals(previousSceneOfSpotlightedActor);
172
-
173
- if (spotlightShifted) {
174
- this.announce(
175
- new ActorSpotlighted(
176
- this.currentScene,
177
- this.actorInTheSpotlight.toJSON(),
178
- )
179
- );
180
- }
181
-
182
- return this.actorInTheSpotlight;
123
+ return this.actorLifecycleManager.actor(name);
183
124
  }
184
125
 
185
126
  /**
@@ -190,18 +131,14 @@ export class Stage implements EmitsDomainEvents {
190
131
  * If no [`Actor`](https://serenity-js.org/api/core/class/Actor/) has been activated yet
191
132
  */
192
133
  theActorInTheSpotlight(): Actor {
193
- if (! this.actorInTheSpotlight) {
194
- throw new LogicError(`There is no actor in the spotlight yet. Make sure you instantiate one with stage.actor(actorName) before calling this method.`);
195
- }
196
-
197
- return this.actorInTheSpotlight;
134
+ return this.actorLifecycleManager.actorInTheSpotlight();
198
135
  }
199
136
 
200
137
  /**
201
138
  * Returns `true` if there is an [`Actor`](https://serenity-js.org/api/core/class/Actor/) in the spotlight, `false` otherwise.
202
139
  */
203
140
  theShowHasStarted(): boolean {
204
- return !! this.actorInTheSpotlight;
141
+ return this.actorLifecycleManager.hasActorInTheSpotlight();
205
142
  }
206
143
 
207
144
  /**
@@ -211,12 +148,12 @@ export class Stage implements EmitsDomainEvents {
211
148
  * @param actors
212
149
  */
213
150
  engage(actors: Cast): void {
214
- this.cast = ensure('actors', actors, isDefined(), property('prepare', isDefined()));
151
+ this.actorLifecycleManager.engage(actors);
215
152
  }
216
153
 
217
154
  /**
218
155
  * Assigns listeners to be notified of [Serenity/JS domain events](https://serenity-js.org/api/core-events/class/DomainEvent/)
219
- * emitted via [`Stage.announce`](https://serenity-js.org/api/core/class/Stage/#announce).s
156
+ * emitted via [`Stage.announce`](https://serenity-js.org/api/core/class/Stage/#announce).
220
157
  *
221
158
  * @param listeners
222
159
  */
@@ -238,24 +175,70 @@ export class Stage implements EmitsDomainEvents {
238
175
 
239
176
  private announceSingle(event: DomainEvent): void {
240
177
  if (event instanceof SceneStarts) {
241
- this.actorsOnStage = this.actorsOnFrontStage;
178
+ this.actorLifecycleManager.switchFocus('foreground');
242
179
  }
243
180
 
244
181
  if (event instanceof SceneFinishes || event instanceof TestRunFinishes) {
245
- this.notifyOfStageExit(this.currentSceneId());
182
+ this.notifyOfStageExit(this.actorLifecycleManager.currentFocus());
246
183
  }
247
184
 
248
185
  this.manager.notifyOf(event);
249
186
 
250
187
  if (event instanceof SceneFinishes) {
251
- this.dismiss(this.actorsOnStage);
252
-
253
- this.actorsOnStage = this.actorsOnBackstage;
188
+ this.dismissActorsIn('foreground');
189
+ this.actorLifecycleManager.switchFocus('background');
254
190
  }
255
191
 
256
192
  if (event instanceof TestRunFinishes) {
257
- this.dismiss(this.actorsOnStage);
193
+ this.dismissActorsIn('background');
194
+ }
195
+ }
196
+
197
+ private notifyOfStageExit(focus: StageFocus): void {
198
+ for (const actor of this.actorLifecycleManager.actorsIn(focus)) {
199
+ this.announce(new ActorStageExitStarts(
200
+ this.currentSceneId(),
201
+ actor.toJSON(),
202
+ this.currentTime(),
203
+ ));
204
+ }
205
+ }
206
+
207
+ private async dismissActorsIn(focus: StageFocus): Promise<void> {
208
+ const actors = this.actorLifecycleManager.actorsIn(focus);
209
+
210
+ this.actorLifecycleManager.clearSpotlightIfIn(focus);
211
+
212
+ // Wait for the Photographer to finish taking any screenshots
213
+ await this.manager.waitForAsyncOperationsToComplete();
214
+
215
+ const actorsToDismiss = new Map<Actor, CorrelationId>(actors.map(actor => [ actor, CorrelationId.create() ]));
216
+
217
+ for (const [ actor, correlationId ] of actorsToDismiss) {
218
+ this.announce(new ActorStageExitAttempted(
219
+ correlationId,
220
+ new Name(actor.name),
221
+ this.currentTime(),
222
+ ));
223
+ }
224
+
225
+ // Try to dismiss each actor
226
+ for (const [ actor, correlationId ] of actorsToDismiss) {
227
+ try {
228
+ await actor.dismiss();
229
+
230
+ this.announce(new ActorStageExitCompleted(correlationId, new Name(actor.name), this.currentTime()));
231
+ }
232
+ catch (error) {
233
+ this.announce(new ActorStageExitFailed(
234
+ error,
235
+ correlationId,
236
+ this.currentTime()
237
+ ));
238
+ }
258
239
  }
240
+
241
+ this.actorLifecycleManager.clearActorsIn(focus);
259
242
  }
260
243
 
261
244
  /**
@@ -263,7 +246,7 @@ export class Stage implements EmitsDomainEvents {
263
246
  * [`DomainEvent`](https://serenity-js.org/api/core-events/class/DomainEvent/) objects are instantiated by you programmatically.
264
247
  */
265
248
  currentTime(): Timestamp {
266
- return this.manager.currentTime();
249
+ return this.clock.now();
267
250
  }
268
251
 
269
252
  /**
@@ -343,65 +326,4 @@ export class Stage implements EmitsDomainEvents {
343
326
  ...options,
344
327
  });
345
328
  }
346
-
347
- private instantiatedActorCalled(name: string): Actor | undefined {
348
- return this.actorsOnBackstage.has(name)
349
- ? this.actorsOnBackstage.get(name)
350
- : this.actorsOnFrontStage.get(name)
351
- }
352
-
353
- private notifyOfStageExit(sceneId: CorrelationId): void {
354
- for (const actor of this.actorsOnStage.values()) {
355
- this.announce(new ActorStageExitStarts(
356
- sceneId,
357
- actor.toJSON(),
358
- this.currentTime(),
359
- ));
360
- }
361
- }
362
-
363
- private async dismiss(activeActors: Map<string, Actor>): Promise<void> {
364
- const actors = Array.from(activeActors.values());
365
-
366
- if (actors.includes(this.actorInTheSpotlight)) {
367
- this.actorInTheSpotlight = undefined;
368
- }
369
-
370
- // Wait for the Photographer to finish taking any screenshots
371
- await this.manager.waitForAsyncOperationsToComplete();
372
-
373
- const actorsToDismiss = new Map<Actor, CorrelationId>(actors.map(actor => [actor, CorrelationId.create()]));
374
-
375
- for (const [ actor, correlationId ] of actorsToDismiss) {
376
- this.announce(new ActorStageExitAttempted(
377
- correlationId,
378
- new Name(actor.name),
379
- this.currentTime(),
380
- ));
381
- }
382
-
383
- // Try to dismiss each actor
384
- for (const [ actor, correlationId ] of actorsToDismiss) {
385
- try {
386
- await actor.dismiss();
387
-
388
- this.announce(new ActorStageExitCompleted(correlationId, new Name(actor.name), this.currentTime()));
389
- }
390
- catch (error) {
391
- this.announce(new ActorStageExitFailed(
392
- error,
393
- correlationId,
394
- this.currentTime()
395
- ));
396
- }
397
- }
398
-
399
- activeActors.clear();
400
- }
401
-
402
- private typeOf(cast: Cast): string {
403
- return cast.constructor === Object
404
- ? 'Cast'
405
- : cast.constructor.name;
406
- }
407
329
  }