@scenetest/scenes 0.1.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 (90) hide show
  1. package/LICENSE +21 -0
  2. package/dist/__tests__/devices.test.d.ts +2 -0
  3. package/dist/__tests__/devices.test.d.ts.map +1 -0
  4. package/dist/__tests__/devices.test.js +117 -0
  5. package/dist/__tests__/devices.test.js.map +1 -0
  6. package/dist/__tests__/dsl.test.d.ts +2 -0
  7. package/dist/__tests__/dsl.test.d.ts.map +1 -0
  8. package/dist/__tests__/dsl.test.js +385 -0
  9. package/dist/__tests__/dsl.test.js.map +1 -0
  10. package/dist/__tests__/markdown-scene.test.d.ts +2 -0
  11. package/dist/__tests__/markdown-scene.test.d.ts.map +1 -0
  12. package/dist/__tests__/markdown-scene.test.js +508 -0
  13. package/dist/__tests__/markdown-scene.test.js.map +1 -0
  14. package/dist/__tests__/reactive.test.d.ts +2 -0
  15. package/dist/__tests__/reactive.test.d.ts.map +1 -0
  16. package/dist/__tests__/reactive.test.js +383 -0
  17. package/dist/__tests__/reactive.test.js.map +1 -0
  18. package/dist/__tests__/swarm.test.d.ts +2 -0
  19. package/dist/__tests__/swarm.test.d.ts.map +1 -0
  20. package/dist/__tests__/swarm.test.js +214 -0
  21. package/dist/__tests__/swarm.test.js.map +1 -0
  22. package/dist/actor.d.ts +104 -0
  23. package/dist/actor.d.ts.map +1 -0
  24. package/dist/actor.js +527 -0
  25. package/dist/actor.js.map +1 -0
  26. package/dist/cli.d.ts +3 -0
  27. package/dist/cli.d.ts.map +1 -0
  28. package/dist/cli.js +273 -0
  29. package/dist/cli.js.map +1 -0
  30. package/dist/config.d.ts +21 -0
  31. package/dist/config.d.ts.map +1 -0
  32. package/dist/config.js +120 -0
  33. package/dist/config.js.map +1 -0
  34. package/dist/devices.d.ts +55 -0
  35. package/dist/devices.d.ts.map +1 -0
  36. package/dist/devices.js +167 -0
  37. package/dist/devices.js.map +1 -0
  38. package/dist/dsl.d.ts +99 -0
  39. package/dist/dsl.d.ts.map +1 -0
  40. package/dist/dsl.js +247 -0
  41. package/dist/dsl.js.map +1 -0
  42. package/dist/index.d.ts +13 -0
  43. package/dist/index.d.ts.map +1 -0
  44. package/dist/index.js +16 -0
  45. package/dist/index.js.map +1 -0
  46. package/dist/init.d.ts +9 -0
  47. package/dist/init.d.ts.map +1 -0
  48. package/dist/init.js +27 -0
  49. package/dist/init.js.map +1 -0
  50. package/dist/loader.d.ts +2 -0
  51. package/dist/loader.d.ts.map +1 -0
  52. package/dist/loader.js +10 -0
  53. package/dist/loader.js.map +1 -0
  54. package/dist/markdown-scene.d.ts +120 -0
  55. package/dist/markdown-scene.d.ts.map +1 -0
  56. package/dist/markdown-scene.js +452 -0
  57. package/dist/markdown-scene.js.map +1 -0
  58. package/dist/message-bus.d.ts +31 -0
  59. package/dist/message-bus.d.ts.map +1 -0
  60. package/dist/message-bus.js +74 -0
  61. package/dist/message-bus.js.map +1 -0
  62. package/dist/reactive.d.ts +267 -0
  63. package/dist/reactive.d.ts.map +1 -0
  64. package/dist/reactive.js +779 -0
  65. package/dist/reactive.js.map +1 -0
  66. package/dist/runner.d.ts +51 -0
  67. package/dist/runner.d.ts.map +1 -0
  68. package/dist/runner.js +306 -0
  69. package/dist/runner.js.map +1 -0
  70. package/dist/scene.d.ts +40 -0
  71. package/dist/scene.d.ts.map +1 -0
  72. package/dist/scene.js +110 -0
  73. package/dist/scene.js.map +1 -0
  74. package/dist/selectors.d.ts +57 -0
  75. package/dist/selectors.d.ts.map +1 -0
  76. package/dist/selectors.js +193 -0
  77. package/dist/selectors.js.map +1 -0
  78. package/dist/swarm.d.ts +64 -0
  79. package/dist/swarm.d.ts.map +1 -0
  80. package/dist/swarm.js +306 -0
  81. package/dist/swarm.js.map +1 -0
  82. package/dist/team-manager.d.ts +120 -0
  83. package/dist/team-manager.d.ts.map +1 -0
  84. package/dist/team-manager.js +267 -0
  85. package/dist/team-manager.js.map +1 -0
  86. package/dist/types.d.ts +653 -0
  87. package/dist/types.d.ts.map +1 -0
  88. package/dist/types.js +2 -0
  89. package/dist/types.js.map +1 -0
  90. package/package.json +61 -0
@@ -0,0 +1,779 @@
1
+ /**
2
+ * Reactive flow execution model.
3
+ *
4
+ * In the standard `scene()` model, `await` is the trigger — actions queue
5
+ * on a chain and execute only when awaited. The test writer carries a
6
+ * mental timeline: "has this happened yet? do I need to wait?"
7
+ *
8
+ * In the reactive `flow()` model:
9
+ *
10
+ * 1. Actor DSL calls are **declarations** — they push to a persistent
11
+ * per-actor queue and return immediately.
12
+ * 2. After the flow function returns, all actors **drain their queues
13
+ * concurrently** — each actor advances through its own queue as fast
14
+ * as the DOM allows.
15
+ * 3. `see()`, `seeText()`, and friends already poll/wait for DOM state,
16
+ * so cross-actor synchronization happens *through the application
17
+ * under test* rather than through `await` ordering in the script.
18
+ *
19
+ * This eliminates the "test-writer conceptualisation race condition" —
20
+ * you can declare `bob.seeText('Hello')` at any point relative to
21
+ * `alice.click('send')` and it will work, because bob's queue reaches
22
+ * that instruction whenever it reaches it. If the text is already
23
+ * there, it resolves instantly. If not, it polls. No `waitUntil`
24
+ * API is needed.
25
+ *
26
+ * @example
27
+ * ```ts
28
+ * import { flow } from '@scenetest/scenes'
29
+ *
30
+ * flow('two users chat', ({ actor }) => {
31
+ * const alice = actor('alice')
32
+ * const bob = actor('bob')
33
+ *
34
+ * // Declaration phase — nothing executes yet
35
+ * alice.openTo('/chat')
36
+ * alice.see('message-input').typeInto('message-input', 'Hello!').click('send')
37
+ *
38
+ * bob.openTo('/chat')
39
+ * bob.seeText('Hello!')
40
+ * // ↑ no race: bob will poll for "Hello!" whenever he gets to that
41
+ * // instruction in his queue. alice may or may not have sent it yet.
42
+ *
43
+ * // When this function returns, browsers launch in parallel,
44
+ * // then both actors drain concurrently.
45
+ * })
46
+ * ```
47
+ */
48
+ import { resolveSelector } from './selectors.js';
49
+ import { parseDslLines, parseAction, applyDslAction } from './dsl.js';
50
+ import { scene, getCurrentSession } from './scene.js';
51
+ // ---------------------------------------------------------------------------
52
+ // Interpolation helpers
53
+ // ---------------------------------------------------------------------------
54
+ /**
55
+ * Escape a value for safe use in selectors.
56
+ *
57
+ * Prevents injection attacks by escaping characters that could break
58
+ * out of selector context (brackets, quotes, backslashes).
59
+ */
60
+ function escapeForSelector(value) {
61
+ // Escape backslashes first, then brackets and quotes
62
+ return value
63
+ .replace(/\\/g, '\\\\')
64
+ .replace(/\[/g, '\\[')
65
+ .replace(/\]/g, '\\]')
66
+ .replace(/"/g, '\\"')
67
+ .replace(/'/g, "\\'");
68
+ }
69
+ // ---------------------------------------------------------------------------
70
+ // ConcurrentActorHandleImpl
71
+ // ---------------------------------------------------------------------------
72
+ /**
73
+ * Concurrent actor handle implementation (declarative / flow model).
74
+ *
75
+ * Unlike `SequentialActorHandleImpl`, every DSL method pushes to a single
76
+ * persistent queue on the actor itself and returns `this`. Scope lives on
77
+ * the actor so it flows naturally through the sequential drain.
78
+ */
79
+ export class ConcurrentActorHandleImpl {
80
+ bus;
81
+ timeline;
82
+ warnings;
83
+ actionTimeout;
84
+ warnAfter;
85
+ role;
86
+ id;
87
+ username;
88
+ email;
89
+ password;
90
+ _page;
91
+ queue = [];
92
+ currentScope;
93
+ scopeStack = [];
94
+ warningTriggers = [];
95
+ conditionalMonitors = [];
96
+ _draining = false;
97
+ _aborted = false;
98
+ _abortReason;
99
+ /** Registry of all actors in this scene, for [actor.field] interpolation */
100
+ _actorRegistry = null;
101
+ /** Team metadata for [team.field] interpolation */
102
+ _teamMetadata = null;
103
+ constructor(role, config, page, bus, timeline, warnings, actionTimeout, warnAfter) {
104
+ this.bus = bus;
105
+ this.timeline = timeline;
106
+ this.warnings = warnings;
107
+ this.actionTimeout = actionTimeout;
108
+ this.warnAfter = warnAfter;
109
+ this.role = role;
110
+ this._page = page;
111
+ this.currentScope = page;
112
+ this.id = config.id;
113
+ // Forward all config properties
114
+ for (const [key, value] of Object.entries(config)) {
115
+ if (!(key in this)) {
116
+ ;
117
+ this[key] = value;
118
+ }
119
+ else if (key !== 'id') {
120
+ ;
121
+ this[key] = value;
122
+ }
123
+ }
124
+ }
125
+ /**
126
+ * Playwright page for this actor.
127
+ * Available after page initialization (before drain).
128
+ */
129
+ get page() {
130
+ if (!this._page) {
131
+ throw new Error(`Actor "${this.role}" page not initialized. Pages are created before drain.`);
132
+ }
133
+ return this._page;
134
+ }
135
+ /**
136
+ * Set the Playwright page for this actor.
137
+ * Called by the flow runner after declaration, before drain.
138
+ */
139
+ _setPage(page) {
140
+ this._page = page;
141
+ this.currentScope = page;
142
+ }
143
+ /**
144
+ * Set the actor registry for [actor.field] interpolation.
145
+ * Called by the flow runner after all actors are created.
146
+ */
147
+ _setActorRegistry(registry) {
148
+ this._actorRegistry = registry;
149
+ }
150
+ /**
151
+ * Set team metadata for [team.field] interpolation.
152
+ */
153
+ _setTeamMetadata(metadata) {
154
+ this._teamMetadata = metadata;
155
+ }
156
+ // -----------------------------------------------------------------------
157
+ // Queue management
158
+ // -----------------------------------------------------------------------
159
+ push(name, target, execute) {
160
+ this.queue.push({ name, target, execute });
161
+ return this;
162
+ }
163
+ /** Number of queued actions */
164
+ get pending() {
165
+ return this.queue.length;
166
+ }
167
+ /** Whether this actor has been aborted by a peer failure */
168
+ get aborted() {
169
+ return this._aborted;
170
+ }
171
+ // -----------------------------------------------------------------------
172
+ // Scope helper — scope is always set during drain (page is initialized)
173
+ // -----------------------------------------------------------------------
174
+ get scope() {
175
+ return this.currentScope ?? this.page;
176
+ }
177
+ // -----------------------------------------------------------------------
178
+ // Navigation
179
+ // -----------------------------------------------------------------------
180
+ openTo(url) {
181
+ return this.push('openTo', url, async () => {
182
+ await this.page.goto(url, { timeout: this.actionTimeout });
183
+ this.currentScope = this.page;
184
+ this.scopeStack = [];
185
+ });
186
+ }
187
+ scrollToBottom() {
188
+ return this.push('scrollToBottom', undefined, async () => {
189
+ const scope = this.scope;
190
+ if (scope === this.page) {
191
+ await this.page.evaluate(() => {
192
+ window.scrollTo(0, document.body.scrollHeight);
193
+ });
194
+ }
195
+ else {
196
+ await scope.evaluate((el) => {
197
+ let current = el;
198
+ while (current) {
199
+ const style = window.getComputedStyle(current);
200
+ if ((style.overflowY === 'auto' || style.overflowY === 'scroll') &&
201
+ current.scrollHeight > current.clientHeight) {
202
+ current.scrollTop = current.scrollHeight;
203
+ return;
204
+ }
205
+ current = current.parentElement;
206
+ }
207
+ window.scrollTo(0, document.body.scrollHeight);
208
+ });
209
+ }
210
+ });
211
+ }
212
+ // -----------------------------------------------------------------------
213
+ // Observation
214
+ // -----------------------------------------------------------------------
215
+ see(selector) {
216
+ return this.push('see', selector, async () => {
217
+ const locator = resolveSelector(this.scope, selector);
218
+ await locator.waitFor({ state: 'visible', timeout: this.actionTimeout });
219
+ this.scopeStack.push(this.scope);
220
+ this.currentScope = locator;
221
+ });
222
+ }
223
+ seeInView(selector) {
224
+ return this.push('seeInView', selector, async () => {
225
+ const locator = resolveSelector(this.scope, selector);
226
+ await locator.waitFor({ state: 'visible', timeout: this.actionTimeout });
227
+ // Verify element is within the viewport without scrolling
228
+ const inViewport = await locator.evaluate((el) => {
229
+ const rect = el.getBoundingClientRect();
230
+ const vh = window.innerHeight || document.documentElement.clientHeight;
231
+ const vw = window.innerWidth || document.documentElement.clientWidth;
232
+ return rect.top >= 0 && rect.left >= 0 && rect.bottom <= vh && rect.right <= vw;
233
+ });
234
+ if (!inViewport) {
235
+ throw new Error(`Element "${selector}" is visible but not in the viewport (requires scrolling)`);
236
+ }
237
+ this.scopeStack.push(this.scope);
238
+ this.currentScope = locator;
239
+ });
240
+ }
241
+ notSee(selector) {
242
+ return this.push('notSee', selector, async () => {
243
+ await resolveSelector(this.scope, selector).waitFor({
244
+ state: 'hidden',
245
+ timeout: this.actionTimeout,
246
+ });
247
+ });
248
+ }
249
+ seeText(text) {
250
+ return this.push('seeText', text, async () => {
251
+ const locator = this.page.getByText(text).first();
252
+ await locator.waitFor({ state: 'visible', timeout: this.actionTimeout });
253
+ this.scopeStack.push(this.scope);
254
+ this.currentScope = locator;
255
+ });
256
+ }
257
+ seeToast(selector) {
258
+ return this.push('seeToast', selector, async () => {
259
+ const locator = resolveSelector(this.scope, selector);
260
+ await locator.waitFor({ state: 'visible', timeout: this.actionTimeout });
261
+ await locator.waitFor({ state: 'hidden', timeout: this.actionTimeout });
262
+ });
263
+ }
264
+ // -----------------------------------------------------------------------
265
+ // Interaction
266
+ // -----------------------------------------------------------------------
267
+ click(selector) {
268
+ if (!selector) {
269
+ return this.push('click', '(scope)', async () => {
270
+ const scope = this.scope;
271
+ if (scope === this.page) {
272
+ throw new Error('click with no selector requires a scope (use see() first)');
273
+ }
274
+ await scope.click({ timeout: this.actionTimeout });
275
+ });
276
+ }
277
+ return this.push('click', selector, async () => {
278
+ await resolveSelector(this.scope, selector).click({
279
+ timeout: this.actionTimeout,
280
+ });
281
+ });
282
+ }
283
+ typeInto(selector, value) {
284
+ return this.push('typeInto', `${selector}=${value}`, async () => {
285
+ await resolveSelector(this.scope, selector).fill(value, {
286
+ timeout: this.actionTimeout,
287
+ });
288
+ });
289
+ }
290
+ check(selector) {
291
+ return this.push('check', selector, async () => {
292
+ await resolveSelector(this.scope, selector).check({
293
+ timeout: this.actionTimeout,
294
+ });
295
+ });
296
+ }
297
+ select(selector, value) {
298
+ return this.push('select', `${selector}=${value}`, async () => {
299
+ await resolveSelector(this.scope, selector).selectOption(value, {
300
+ timeout: this.actionTimeout,
301
+ });
302
+ });
303
+ }
304
+ // -----------------------------------------------------------------------
305
+ // Scope navigation
306
+ // -----------------------------------------------------------------------
307
+ up(selector) {
308
+ if (!selector) {
309
+ return this.push('up', '(root)', async () => {
310
+ this.currentScope = this.page;
311
+ this.scopeStack = [];
312
+ });
313
+ }
314
+ return this.push('up', selector, async () => {
315
+ const ancestorLocator = resolveSelector(this.page, selector);
316
+ await ancestorLocator.waitFor({
317
+ state: 'visible',
318
+ timeout: this.actionTimeout,
319
+ });
320
+ this.scopeStack.push(this.scope);
321
+ this.currentScope = ancestorLocator;
322
+ });
323
+ }
324
+ prev() {
325
+ return this.push('prev', undefined, async () => {
326
+ if (this.scopeStack.length === 0) {
327
+ this.currentScope = this.page;
328
+ }
329
+ else {
330
+ this.currentScope = this.scopeStack.pop();
331
+ }
332
+ });
333
+ }
334
+ // -----------------------------------------------------------------------
335
+ // Timing & coordination
336
+ // -----------------------------------------------------------------------
337
+ wait(ms) {
338
+ return this.push('wait', `${ms}ms`, async () => {
339
+ await new Promise((resolve) => setTimeout(resolve, ms));
340
+ });
341
+ }
342
+ emit(message) {
343
+ return this.push('emit', message, async () => {
344
+ this.bus.emit(message);
345
+ });
346
+ }
347
+ /**
348
+ * Block this actor's queue until a message arrives on the bus.
349
+ *
350
+ * Because the bus is sticky, if the message was already emitted this
351
+ * resolves immediately — no race.
352
+ */
353
+ waitFor(message) {
354
+ return this.push('waitFor', message, async () => {
355
+ await this.bus.waitFor(message);
356
+ });
357
+ }
358
+ // -----------------------------------------------------------------------
359
+ // Escape hatch
360
+ // -----------------------------------------------------------------------
361
+ do(fn) {
362
+ return this.push('do', 'custom', async () => {
363
+ await fn(this.page);
364
+ });
365
+ }
366
+ // -----------------------------------------------------------------------
367
+ // Text DSL
368
+ // -----------------------------------------------------------------------
369
+ /**
370
+ * Queue actions from a text DSL string.
371
+ *
372
+ * Parses the multiline string into individual action lines and pushes
373
+ * each onto the actor's queue. Returns `this` for chaining.
374
+ *
375
+ * @example
376
+ * ```ts
377
+ * user.dsl(`
378
+ * openTo /login
379
+ * see login-form
380
+ * typeInto email alice@test.com
381
+ * click submit
382
+ * `)
383
+ * user.see('dashboard')
384
+ * ```
385
+ */
386
+ dsl(text) {
387
+ const lines = parseDslLines(text);
388
+ for (const line of lines) {
389
+ const interpolated = this._interpolate(line);
390
+ const parsed = parseAction(interpolated);
391
+ applyDslAction(this, parsed);
392
+ }
393
+ return this;
394
+ }
395
+ /**
396
+ * Interpolate [namespace.field] references in a string.
397
+ *
398
+ * Supported namespaces:
399
+ * - [self.field] — this actor's own fields
400
+ * - [role.field] — another actor's fields (requires actor registry)
401
+ * - [team.field] — team metadata
402
+ */
403
+ _interpolate(line) {
404
+ return line.replace(/\[([\w][\w-]*)\.([\w]+)\]/g, (match, namespace, field) => {
405
+ // [self.field] — current actor's fields
406
+ if (namespace === 'self') {
407
+ const value = this[field];
408
+ if (value === undefined) {
409
+ throw new Error(`[self.${field}] — actor "${this.role}" has no field "${field}"`);
410
+ }
411
+ return escapeForSelector(String(value));
412
+ }
413
+ // [team.field] — team metadata
414
+ if (namespace === 'team') {
415
+ if (!this._teamMetadata) {
416
+ throw new Error(`[team.${field}] — no team metadata available`);
417
+ }
418
+ const value = this._teamMetadata[field];
419
+ if (value === undefined) {
420
+ throw new Error(`[team.${field}] — team has no field "${field}"`);
421
+ }
422
+ return escapeForSelector(String(value));
423
+ }
424
+ // [role.field] — another actor's fields
425
+ if (!this._actorRegistry) {
426
+ throw new Error(`[${namespace}.${field}] — cannot reference other actors outside a scene context`);
427
+ }
428
+ const actor = this._actorRegistry.get(namespace);
429
+ if (!actor) {
430
+ const available = [...this._actorRegistry.keys()].join(', ');
431
+ throw new Error(`[${namespace}.${field}] — unknown actor "${namespace}" (available: ${available})`);
432
+ }
433
+ const value = actor[field];
434
+ if (value === undefined) {
435
+ throw new Error(`[${namespace}.${field}] — actor "${namespace}" has no field "${field}"`);
436
+ }
437
+ return escapeForSelector(String(value));
438
+ });
439
+ }
440
+ // -----------------------------------------------------------------------
441
+ // Monitoring
442
+ // -----------------------------------------------------------------------
443
+ /**
444
+ * Register a persistent warning trigger.
445
+ * If the selector becomes visible during any action, a warning is recorded.
446
+ * Unlike the `scene()` model, there are no watchers that clear after
447
+ * each await — warnings are the right primitive for reactive flows.
448
+ */
449
+ warnIf(selector, message) {
450
+ this.warningTriggers.push({ selector, message, triggered: false });
451
+ }
452
+ /**
453
+ * Conditional monitor — "if this appears at any point, do these things."
454
+ *
455
+ * Registers a persistent monitor that polls during every subsequent
456
+ * action in the actor's queue. When `selector` becomes visible, the
457
+ * sub-actions declared inside `callback` execute inline (the current
458
+ * action is paused, the sub-actions run, then the action resumes).
459
+ * One-shot: the monitor stops polling after it fires once.
460
+ *
461
+ * The callback receives `this` (the actor), but while it runs the
462
+ * actor's internal queue is temporarily swapped so that DSL calls
463
+ * push to the monitor's sub-action list instead of the main queue.
464
+ *
465
+ * @example
466
+ * ```ts
467
+ * alice.openTo('/app')
468
+ * alice.if('welcome-modal', a => a.click('dismiss'))
469
+ * alice.see('dashboard')
470
+ * // If the welcome modal pops up during any action after the if(),
471
+ * // click('dismiss') fires inline, then the action resumes.
472
+ * ```
473
+ */
474
+ if(selector, callback) {
475
+ const monitor = {
476
+ selector,
477
+ actions: [],
478
+ triggered: false,
479
+ };
480
+ // Queue-swap: redirect DSL calls to the monitor's sub-action list
481
+ const mainQueue = this.queue;
482
+ this.queue = monitor.actions;
483
+ try {
484
+ callback(this);
485
+ }
486
+ finally {
487
+ this.queue = mainQueue;
488
+ }
489
+ this.conditionalMonitors.push(monitor);
490
+ }
491
+ // -----------------------------------------------------------------------
492
+ // Abort
493
+ // -----------------------------------------------------------------------
494
+ /**
495
+ * Abort this actor's queue. Called by `drainAll` when a peer actor fails.
496
+ * The actor will throw at its next action boundary.
497
+ */
498
+ abort(reason) {
499
+ this._aborted = true;
500
+ this._abortReason = reason;
501
+ }
502
+ // -----------------------------------------------------------------------
503
+ // Drain — the execution engine
504
+ // -----------------------------------------------------------------------
505
+ /**
506
+ * Drain the action queue.
507
+ *
508
+ * Executes all queued actions sequentially. Called automatically by the
509
+ * flow runner after the declaration phase completes.
510
+ *
511
+ * Each action is executed with concurrent warning-trigger polling (same
512
+ * approach as `ActionChainImpl.executeWithWatchers`).
513
+ */
514
+ async drain() {
515
+ if (!this._page) {
516
+ throw new Error(`Actor "${this.role}" cannot drain — page not initialized. Call _setPage() first.`);
517
+ }
518
+ if (this._draining) {
519
+ throw new Error(`Actor "${this.role}" is already draining`);
520
+ }
521
+ this._draining = true;
522
+ try {
523
+ for (const action of this.queue) {
524
+ // Check abort before each action
525
+ if (this._aborted) {
526
+ throw new Error(`Actor "${this.role}" aborted: ${this._abortReason}`);
527
+ }
528
+ const start = Date.now();
529
+ const entry = {
530
+ action: action.name,
531
+ target: action.target,
532
+ actor: this.role,
533
+ timestamp: start,
534
+ };
535
+ // Slow-action warning timer
536
+ let warned = false;
537
+ const warnTimer = setInterval(() => {
538
+ const elapsed = Date.now() - start;
539
+ console.warn(`⏱ ${elapsed}ms - ${this.role}.${action.name}(${action.target ?? ''}) - still waiting...`);
540
+ warned = true;
541
+ }, this.warnAfter);
542
+ try {
543
+ await this.executeWithMonitors(action);
544
+ entry.duration = Date.now() - start;
545
+ if (warned) {
546
+ console.warn(`✓ ${entry.duration}ms - ${this.role}.${action.name}(${action.target ?? ''}) - completed`);
547
+ }
548
+ }
549
+ catch (err) {
550
+ entry.duration = Date.now() - start;
551
+ entry.error = err instanceof Error ? err.message : String(err);
552
+ this.timeline.push(entry);
553
+ throw err;
554
+ }
555
+ finally {
556
+ clearInterval(warnTimer);
557
+ }
558
+ this.timeline.push(entry);
559
+ }
560
+ }
561
+ finally {
562
+ this.queue = [];
563
+ this._draining = false;
564
+ }
565
+ }
566
+ /**
567
+ * Execute a single action while polling monitors concurrently.
568
+ *
569
+ * Handles both warning triggers (record a warning) and conditional
570
+ * monitors (execute sub-actions inline).
571
+ */
572
+ async executeWithMonitors(action) {
573
+ const hasWarnings = this.warningTriggers.some((t) => !t.triggered);
574
+ const hasMonitors = this.conditionalMonitors.some((m) => !m.triggered);
575
+ if (!hasWarnings && !hasMonitors) {
576
+ await action.execute();
577
+ return;
578
+ }
579
+ let actionComplete = false;
580
+ let actionError = null;
581
+ const actionPromise = action
582
+ .execute()
583
+ .then(() => {
584
+ actionComplete = true;
585
+ })
586
+ .catch((err) => {
587
+ actionError = err;
588
+ actionComplete = true;
589
+ });
590
+ const poll = async () => {
591
+ const pollInterval = 50;
592
+ while (!actionComplete) {
593
+ // Poll warning triggers
594
+ for (const trigger of this.warningTriggers) {
595
+ if (trigger.triggered)
596
+ continue;
597
+ try {
598
+ const locator = resolveSelector(this.page, trigger.selector);
599
+ const isVisible = await locator.isVisible();
600
+ if (isVisible) {
601
+ trigger.triggered = true;
602
+ this.warnings.push({
603
+ selector: trigger.selector,
604
+ message: trigger.message,
605
+ timestamp: Date.now(),
606
+ actor: this.role,
607
+ duringAction: `${action.name}(${action.target ?? ''})`,
608
+ });
609
+ }
610
+ }
611
+ catch {
612
+ // Ignore errors from isVisible check
613
+ }
614
+ }
615
+ // Poll conditional monitors
616
+ for (const monitor of this.conditionalMonitors) {
617
+ if (monitor.triggered)
618
+ continue;
619
+ try {
620
+ const locator = resolveSelector(this.page, monitor.selector);
621
+ const isVisible = await locator.isVisible();
622
+ if (isVisible) {
623
+ monitor.triggered = true;
624
+ // Execute sub-actions inline — shares the actor's scope
625
+ for (const subAction of monitor.actions) {
626
+ await subAction.execute();
627
+ }
628
+ }
629
+ }
630
+ catch {
631
+ // Ignore errors from isVisible check
632
+ }
633
+ }
634
+ if (!actionComplete) {
635
+ await new Promise((r) => setTimeout(r, pollInterval));
636
+ }
637
+ }
638
+ };
639
+ await Promise.all([actionPromise, poll()]);
640
+ if (actionError) {
641
+ throw actionError;
642
+ }
643
+ }
644
+ }
645
+ // ---------------------------------------------------------------------------
646
+ // drainAll — concurrent executor
647
+ // ---------------------------------------------------------------------------
648
+ /**
649
+ * Drain all actors concurrently.
650
+ *
651
+ * When any actor fails, all others are aborted so they don't hang waiting
652
+ * for DOM state that will never arrive. We use `Promise.allSettled` to
653
+ * collect all results and throw the first *original* (non-abort) error.
654
+ */
655
+ export async function drainAll(actors) {
656
+ if (actors.length === 0)
657
+ return;
658
+ if (actors.length === 1) {
659
+ await actors[0].drain();
660
+ return;
661
+ }
662
+ const drainPromises = actors.map((actor) => actor.drain().catch((err) => {
663
+ // On failure, signal all peers to stop
664
+ const msg = err instanceof Error ? err.message : String(err);
665
+ for (const other of actors) {
666
+ if (other !== actor && !other.aborted) {
667
+ other.abort(`${actor.role} failed: ${msg}`);
668
+ }
669
+ }
670
+ throw err;
671
+ }));
672
+ const results = await Promise.allSettled(drainPromises);
673
+ const failures = results.filter((r) => r.status === 'rejected');
674
+ if (failures.length > 0) {
675
+ // Prefer the first non-abort error
676
+ const original = failures.find((f) => !(f.reason instanceof Error && f.reason.message.includes(' aborted: ')));
677
+ throw (original ?? failures[0]).reason;
678
+ }
679
+ }
680
+ // ---------------------------------------------------------------------------
681
+ // flow() — reactive scene registration
682
+ // ---------------------------------------------------------------------------
683
+ /**
684
+ * Define a reactive flow.
685
+ *
686
+ * Inside the flow function, actor DSL calls just queue actions — nothing
687
+ * executes. After the function returns, all actors drain their queues
688
+ * concurrently through the application.
689
+ *
690
+ * @example
691
+ * ```ts
692
+ * import { flow } from '@scenetest/scenes'
693
+ *
694
+ * flow('user updates profile', ({ actor }) => {
695
+ * const user = actor('user')
696
+ *
697
+ * user.openTo('/login')
698
+ * user
699
+ * .see('login-form')
700
+ * .typeInto('email', user.email!)
701
+ * .typeInto('password', user.password!)
702
+ * .click('submit')
703
+ *
704
+ * user.see('dashboard')
705
+ * user.openTo('/profile')
706
+ * user
707
+ * .see('profile-form')
708
+ * .typeInto('name-input', 'New Name')
709
+ * .click('save-button')
710
+ *
711
+ * user.seeText('New Name')
712
+ * })
713
+ * ```
714
+ *
715
+ * @example Multi-actor
716
+ * ```ts
717
+ * flow('two users chat', ({ actor }) => {
718
+ * const alice = actor('alice')
719
+ * const bob = actor('bob')
720
+ *
721
+ * alice.openTo('/chat')
722
+ * alice.see('message-input').typeInto('message-input', 'Hello!').click('send')
723
+ *
724
+ * bob.openTo('/chat')
725
+ * bob.seeText('Hello!')
726
+ * })
727
+ * ```
728
+ */
729
+ export function flow(name, fn) {
730
+ // Register as a normal scene — the runner doesn't need to know it's
731
+ // reactive. The wrapping scene fn handles the three-phase execution.
732
+ scene(name, async (context) => {
733
+ const session = getCurrentSession();
734
+ if (!session) {
735
+ throw new Error('flow() must be run inside the scene runner');
736
+ }
737
+ const reactiveActors = [];
738
+ const actorRoles = [];
739
+ // Actor registry for [role.field] interpolation across actors
740
+ const actorRegistry = new Map();
741
+ const flowContext = {
742
+ actor: (role) => {
743
+ // Check if already created (re-referencing an actor)
744
+ const existing = actorRegistry.get(role);
745
+ if (existing) {
746
+ return existing;
747
+ }
748
+ // Resolve config synchronously — no browser needed yet
749
+ const config = session.getActorConfig(role);
750
+ // Create reactive handle without a page (deferred init)
751
+ const reactive = new ConcurrentActorHandleImpl(role, config, null, // page created in phase 2
752
+ session.getMessageBus(), session.timeline, session.warnings, session.actionTimeout, session.warnAfter);
753
+ reactiveActors.push(reactive);
754
+ actorRoles.push(role);
755
+ actorRegistry.set(role, reactive);
756
+ return reactive;
757
+ },
758
+ teamIndex: context.teamIndex,
759
+ };
760
+ // Phase 1: Declaration — user code queues actions, nothing executes.
761
+ // actor() is synchronous so the flow body can be sync too.
762
+ const result = fn(flowContext);
763
+ if (result && typeof result.then === 'function') {
764
+ await result;
765
+ }
766
+ // Set actor registry on all actors for [role.field] interpolation
767
+ for (const actor of reactiveActors) {
768
+ actor._setActorRegistry(actorRegistry);
769
+ }
770
+ // Phase 2: Initialize — create browser contexts in parallel
771
+ await Promise.all(reactiveActors.map(async (actor, i) => {
772
+ const page = await session.createPage(actorRoles[i]);
773
+ actor._setPage(page);
774
+ }));
775
+ // Phase 3: Execution — all actors drain concurrently
776
+ await drainAll(reactiveActors);
777
+ });
778
+ }
779
+ //# sourceMappingURL=reactive.js.map