@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.
- package/LICENSE +21 -0
- package/dist/__tests__/devices.test.d.ts +2 -0
- package/dist/__tests__/devices.test.d.ts.map +1 -0
- package/dist/__tests__/devices.test.js +117 -0
- package/dist/__tests__/devices.test.js.map +1 -0
- package/dist/__tests__/dsl.test.d.ts +2 -0
- package/dist/__tests__/dsl.test.d.ts.map +1 -0
- package/dist/__tests__/dsl.test.js +385 -0
- package/dist/__tests__/dsl.test.js.map +1 -0
- package/dist/__tests__/markdown-scene.test.d.ts +2 -0
- package/dist/__tests__/markdown-scene.test.d.ts.map +1 -0
- package/dist/__tests__/markdown-scene.test.js +508 -0
- package/dist/__tests__/markdown-scene.test.js.map +1 -0
- package/dist/__tests__/reactive.test.d.ts +2 -0
- package/dist/__tests__/reactive.test.d.ts.map +1 -0
- package/dist/__tests__/reactive.test.js +383 -0
- package/dist/__tests__/reactive.test.js.map +1 -0
- package/dist/__tests__/swarm.test.d.ts +2 -0
- package/dist/__tests__/swarm.test.d.ts.map +1 -0
- package/dist/__tests__/swarm.test.js +214 -0
- package/dist/__tests__/swarm.test.js.map +1 -0
- package/dist/actor.d.ts +104 -0
- package/dist/actor.d.ts.map +1 -0
- package/dist/actor.js +527 -0
- package/dist/actor.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +273 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +21 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +120 -0
- package/dist/config.js.map +1 -0
- package/dist/devices.d.ts +55 -0
- package/dist/devices.d.ts.map +1 -0
- package/dist/devices.js +167 -0
- package/dist/devices.js.map +1 -0
- package/dist/dsl.d.ts +99 -0
- package/dist/dsl.d.ts.map +1 -0
- package/dist/dsl.js +247 -0
- package/dist/dsl.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -0
- package/dist/init.d.ts +9 -0
- package/dist/init.d.ts.map +1 -0
- package/dist/init.js +27 -0
- package/dist/init.js.map +1 -0
- package/dist/loader.d.ts +2 -0
- package/dist/loader.d.ts.map +1 -0
- package/dist/loader.js +10 -0
- package/dist/loader.js.map +1 -0
- package/dist/markdown-scene.d.ts +120 -0
- package/dist/markdown-scene.d.ts.map +1 -0
- package/dist/markdown-scene.js +452 -0
- package/dist/markdown-scene.js.map +1 -0
- package/dist/message-bus.d.ts +31 -0
- package/dist/message-bus.d.ts.map +1 -0
- package/dist/message-bus.js +74 -0
- package/dist/message-bus.js.map +1 -0
- package/dist/reactive.d.ts +267 -0
- package/dist/reactive.d.ts.map +1 -0
- package/dist/reactive.js +779 -0
- package/dist/reactive.js.map +1 -0
- package/dist/runner.d.ts +51 -0
- package/dist/runner.d.ts.map +1 -0
- package/dist/runner.js +306 -0
- package/dist/runner.js.map +1 -0
- package/dist/scene.d.ts +40 -0
- package/dist/scene.d.ts.map +1 -0
- package/dist/scene.js +110 -0
- package/dist/scene.js.map +1 -0
- package/dist/selectors.d.ts +57 -0
- package/dist/selectors.d.ts.map +1 -0
- package/dist/selectors.js +193 -0
- package/dist/selectors.js.map +1 -0
- package/dist/swarm.d.ts +64 -0
- package/dist/swarm.d.ts.map +1 -0
- package/dist/swarm.js +306 -0
- package/dist/swarm.js.map +1 -0
- package/dist/team-manager.d.ts +120 -0
- package/dist/team-manager.d.ts.map +1 -0
- package/dist/team-manager.js +267 -0
- package/dist/team-manager.js.map +1 -0
- package/dist/types.d.ts +653 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +61 -0
package/dist/reactive.js
ADDED
|
@@ -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
|