@mindscraft/branch-video-agent-cli 0.4.6 → 0.5.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.
@@ -11,24 +11,31 @@ function locatorFor(frame, spec) {
11
11
  return frame.getByTestId(spec.testId);
12
12
  return frame.locator(spec.selector);
13
13
  }
14
- async function runWebStep(frame, step) {
14
+ function remainingBudget(deadline) {
15
+ const remaining = deadline - Date.now();
16
+ if (remaining <= 0)
17
+ throw new PlaytestError('SCENARIO_TIMEOUT', 'Scenario deadline elapsed');
18
+ return remaining;
19
+ }
20
+ async function runWebStep(frame, step, deadline) {
15
21
  const locator = locatorFor(frame, step.locator);
22
+ const timeout = remainingBudget(deadline);
16
23
  if (step.action === 'click')
17
- await locator.click();
24
+ await locator.click({ timeout });
18
25
  else if (step.action === 'fill')
19
- await locator.fill(step.value);
26
+ await locator.fill(step.value, { timeout });
20
27
  else if (step.action === 'selectOption')
21
- await locator.selectOption(step.value);
28
+ await locator.selectOption(step.value, { timeout });
22
29
  else if (step.action === 'check')
23
- await locator.setChecked(step.checked ?? true);
30
+ await locator.setChecked(step.checked ?? true, { timeout });
24
31
  else if (step.action === 'press')
25
- await locator.press(step.key);
32
+ await locator.press(step.key, { timeout });
26
33
  else if (step.action === 'dragTo')
27
- await locator.dragTo(locatorFor(frame, step.target));
34
+ await locator.dragTo(locatorFor(frame, step.target), { timeout });
28
35
  else if (step.action === 'setInputFiles')
29
- await locator.setInputFiles(step.files);
36
+ await locator.setInputFiles(step.files, { timeout });
30
37
  else
31
- await locator.waitFor({ state: step.state ?? 'visible', timeout: step.timeoutMs });
38
+ await locator.waitFor({ state: step.state ?? 'visible', timeout: step.timeoutMs && step.timeoutMs > 0 ? Math.min(step.timeoutMs, timeout) : timeout });
32
39
  }
33
40
  function appendInstrumentationQuery(rawUrl) {
34
41
  const url = new URL(rawUrl);
@@ -153,42 +160,74 @@ export async function startPlayback(frame, entryNodeId, entryNodeType, timeout)
153
160
  }
154
161
  }
155
162
  }
156
- async function assertNodeMedia(frame, node) {
157
- if (node.type === 'video') {
158
- const video = frame.locator('video').first();
159
- await video.waitFor({ state: 'visible' });
160
- const state = await video.evaluate(async (element) => {
161
- if (element.readyState < 1) {
162
- await new Promise((resolve, reject) => {
163
- const timer = globalThis.setTimeout(() => reject(new Error('loadedmetadata timeout')), 10000);
164
- element.addEventListener('loadedmetadata', () => { globalThis.clearTimeout(timer); resolve(); }, { once: true });
165
- element.addEventListener('error', () => { globalThis.clearTimeout(timer); reject(new Error('video error')); }, { once: true });
166
- });
167
- }
168
- const before = element.currentTime;
169
- await element.play().catch(() => undefined);
170
- await new Promise((resolve) => globalThis.setTimeout(resolve, 200));
171
- return { readyState: element.readyState, duration: element.duration, progressed: element.currentTime > before };
172
- });
173
- if (state.readyState < 1 || !Number.isFinite(state.duration)) {
174
- throw new PlaytestError('MEDIA_LOAD_FAILED', `Video media failed on node ${node.id}`, state);
175
- }
176
- }
177
- if (node.type === 'image') {
178
- const image = frame.locator('img').filter({ visible: true }).first();
179
- await image.waitFor({ state: 'visible' });
180
- const loaded = await image.evaluate((element) => element.complete && element.naturalWidth > 0);
181
- if (!loaded)
182
- throw new PlaytestError('MEDIA_LOAD_FAILED', `Image media failed on node ${node.id}`);
163
+ function mainMedia(frame, node) {
164
+ return frame.locator(`[data-bv-media-role="main"][data-bv-media-node-id=${JSON.stringify(node.id)}]`);
165
+ }
166
+ export async function assertNodeMedia(frame, node, deadline) {
167
+ if (node.type === 'video' || node.type === 'image') {
168
+ const media = mainMedia(frame, node);
169
+ await media.waitFor({ state: 'visible', timeout: remainingBudget(deadline) });
170
+ const element = await media.elementHandle({ timeout: remainingBudget(deadline) });
171
+ if (!element)
172
+ throw new PlaytestError('NODE_MEDIA_CHANGED', `Main media disappeared on ${node.id}`);
173
+ const state = await element.evaluate(async (element, input) => {
174
+ const end = Date.now() + input.timeout;
175
+ const source = element.src;
176
+ do {
177
+ const current = globalThis.document.querySelector('[data-bv-node-id]')?.getAttribute('data-bv-node-id');
178
+ if (!element.isConnected || current !== input.nodeId || element.src !== source
179
+ || element.getAttribute('data-bv-media-node-id') !== input.nodeId)
180
+ return { status: 'changed', current };
181
+ if (input.type === 'image') {
182
+ if (element.complete)
183
+ return { status: element.naturalWidth > 0 ? 'loaded' : 'error', source };
184
+ }
185
+ else {
186
+ if (element.error)
187
+ return { status: 'error', error: element.error.code };
188
+ if (element.readyState >= 2 && Number.isFinite(element.duration) && element.currentSrc === source)
189
+ return { status: 'loaded', source };
190
+ }
191
+ await new Promise(resolve => globalThis.setTimeout(resolve, Math.min(50, Math.max(1, end - Date.now()))));
192
+ } while (Date.now() < end);
193
+ return { status: 'timeout', readyState: element.readyState, source };
194
+ }, { nodeId: node.id, type: node.type, timeout: remainingBudget(deadline) });
195
+ if (state.status === 'changed')
196
+ throw new PlaytestError('NODE_MEDIA_CHANGED', `Node or media source changed while loading ${node.id}`, state);
197
+ if (state.status !== 'loaded')
198
+ throw new PlaytestError('MEDIA_LOAD_FAILED', `Main ${node.type} media failed on node ${node.id}`, state);
199
+ return { element, source: state.source, nodeId: node.id };
183
200
  }
184
201
  if (node.type === 'web') {
185
- await frame.locator('iframe[title="Web 页面"]').waitFor({ state: 'visible' });
202
+ await frame.locator('iframe[title="Web 页面"]').waitFor({ state: 'visible', timeout: remainingBudget(deadline) });
186
203
  }
187
204
  }
205
+ export async function driveVerifiedVideo(media, trigger, time) {
206
+ if (!media)
207
+ throw new PlaytestError('NODE_MEDIA_CHANGED', 'Video was not verified before driving it');
208
+ const valid = await media.element.evaluate((element, input) => {
209
+ const current = globalThis.document.querySelector('[data-bv-node-id]')?.getAttribute('data-bv-node-id');
210
+ if (!element.isConnected || current !== input.nodeId || element.getAttribute('data-bv-media-node-id') !== input.nodeId
211
+ || element.src !== input.source || element.currentSrc !== input.source || element.readyState < 2 || element.error
212
+ || !Number.isFinite(element.duration))
213
+ return false;
214
+ if (input.trigger === 'time')
215
+ element.currentTime = Number(input.time) + 0.05;
216
+ else if (input.trigger === 'complete' && element.duration > 0)
217
+ element.currentTime = Math.max(0, element.duration - 0.01);
218
+ if (input.trigger !== 'complete-interaction')
219
+ element.dispatchEvent(new globalThis.Event('timeupdate', { bubbles: true }));
220
+ if (input.trigger !== 'time')
221
+ element.dispatchEvent(new globalThis.Event('ended', { bubbles: true }));
222
+ return true;
223
+ }, { source: media.source, nodeId: media.nodeId, trigger, time });
224
+ if (!valid)
225
+ throw new PlaytestError('NODE_MEDIA_CHANGED', `Verified video changed before driving ${media.nodeId}`);
226
+ }
188
227
  async function readObservedMessages(frame) {
189
228
  return frame.evaluate(() => globalThis.__bvPlaytestObservedMessages || []).catch(() => []);
190
229
  }
191
- async function executeWebEdge(frame, node, edge, contract) {
230
+ export async function executeWebEdge(frame, node, edge, contract, deadline) {
192
231
  const explicitRoutes = new Set((node.branchConfig?.rules || [])
193
232
  .filter((rule) => rule.trigger?.type === 'message')
194
233
  .map((rule) => String(rule.trigger.value)));
@@ -197,19 +236,44 @@ async function executeWebEdge(frame, node, edge, contract) {
197
236
  : contract.webResults.find((candidate) => candidate.nodeId === edge.from && !explicitRoutes.has(candidate.routeValue));
198
237
  if (!recipe)
199
238
  throw new PlaytestError('WEB_PLAYTEST_RECIPE_MISSING', `No recipe for ${edge.from}:${String(edge.trigger.value)}`);
200
- const webFrame = frame.childFrames().find((candidate) => candidate.url() !== 'about:blank');
239
+ const handle = await frame.locator('iframe[title="Web 页面"]').elementHandle({ timeout: remainingBudget(deadline) });
240
+ const webFrame = await handle?.contentFrame();
201
241
  if (!webFrame)
202
242
  throw new PlaytestError('WEB_FRAME_LOAD_FAILED', `Web iframe did not load on node ${edge.from}`);
243
+ const sourceId = await handle.evaluate((element) => {
244
+ const browserWindow = globalThis;
245
+ browserWindow.__bvPlaytestWebSources ||= new WeakMap();
246
+ const id = (browserWindow.__bvPlaytestWebSourceSequence || 0) + 1;
247
+ browserWindow.__bvPlaytestWebSourceSequence = id;
248
+ browserWindow.__bvPlaytestWebSources.set(element.contentWindow, id);
249
+ return id;
250
+ });
203
251
  const before = (await readObservedMessages(frame)).length;
204
- for (const step of recipe.steps)
205
- await runWebStep(webFrame, step);
252
+ for (let index = 0; index < recipe.steps.length; index += 1) {
253
+ const step = recipe.steps[index];
254
+ try {
255
+ await runWebStep(webFrame, step, deadline);
256
+ }
257
+ catch (error) {
258
+ // A final passive wait may lose its iframe when the real result navigates.
259
+ // Missing actions, wrong/stale messages and unrelated detach remain failures.
260
+ const message = error instanceof Error ? error.message : String(error);
261
+ const emitted = (await readObservedMessages(frame)).slice(before);
262
+ if (!(index === recipe.steps.length - 1 && step.action === 'waitFor'
263
+ && /frame.*detach/i.test(message) && webFrame.isDetached()
264
+ && emitted.some(entry => entry.sourceId === sourceId && matchesRawMessage(entry, recipe.rawMessage))))
265
+ throw error;
266
+ }
267
+ }
206
268
  try {
207
269
  await frame.waitForFunction((payload) => {
208
- const { start, rawMessage } = payload;
270
+ const { start, rawMessage, sourceId } = payload;
209
271
  const messages = (globalThis.__bvPlaytestObservedMessages || []).slice(start);
210
272
  const expectedEvent = rawMessage.eventName;
211
273
  const expectedValue = rawMessage.value ?? rawMessage.data?.value ?? rawMessage.data;
212
274
  return messages.some((entry) => {
275
+ if (entry.sourceId !== sourceId)
276
+ return false;
213
277
  const actual = entry.data;
214
278
  const eventName = actual?.eventName ?? actual?.type ?? actual?.event;
215
279
  const value = actual?.value
@@ -223,14 +287,14 @@ async function executeWebEdge(frame, node, edge, contract) {
223
287
  const comparableExpected = typeof expectedValue === 'string' || typeof expectedValue === 'number' ? String(expectedValue) : expectedValue;
224
288
  return eventName === expectedEvent && JSON.stringify(comparableActual) === JSON.stringify(comparableExpected);
225
289
  });
226
- }, { start: before, rawMessage: recipe.rawMessage }, { timeout: 10000 });
290
+ }, { start: before, rawMessage: recipe.rawMessage, sourceId }, { timeout: remainingBudget(deadline) });
227
291
  }
228
292
  catch {
229
293
  const observed = (await readObservedMessages(frame)).slice(before);
230
294
  throw new PlaytestError('WEB_MESSAGE_NOT_EMITTED', `Web actions did not emit the expected raw message for ${edge.from}:${recipe.routeValue}`, { observed });
231
295
  }
232
296
  const emitted = (await readObservedMessages(frame)).slice(before);
233
- if (!emitted.some((message) => matchesRawMessage(message, recipe.rawMessage))) {
297
+ if (!emitted.some((message) => message.sourceId === sourceId && matchesRawMessage(message, recipe.rawMessage))) {
234
298
  throw new PlaytestError('WEB_MESSAGE_NOT_EMITTED', `Expected Web raw message was not recorded for ${edge.from}:${recipe.routeValue}`);
235
299
  }
236
300
  }
@@ -253,18 +317,18 @@ function chooseResultOption(interaction, result) {
253
317
  }
254
318
  return wrong.length ? [wrong[0]] : options.slice(-1).map((option) => String(option.id));
255
319
  }
256
- async function clickAction(frame, action) {
320
+ async function clickAction(frame, action, deadline) {
257
321
  const control = frame.locator(`[data-bv-action="${action}"]`).first();
258
322
  const nestedButton = control.locator('button').first();
259
323
  if (await nestedButton.count())
260
- await nestedButton.click();
324
+ await nestedButton.click({ timeout: remainingBudget(deadline) });
261
325
  else
262
- await control.click();
326
+ await control.click({ timeout: remainingBudget(deadline) });
263
327
  }
264
328
  function normalizeSettlementResult(value) {
265
329
  return String(value ?? '').trim().toLowerCase();
266
330
  }
267
- async function executeScoreSettlement(frame, interaction, edge) {
331
+ async function executeScoreSettlement(frame, interaction, edge, deadline) {
268
332
  const target = (edge.trigger.type === 'legacy' || edge.trigger.type === 'result')
269
333
  ? normalizeSettlementResult(edge.trigger.value) : '';
270
334
  if (!edge.default && !['score_s', 'score_a', 'score_b', 'default'].includes(target)) {
@@ -284,12 +348,12 @@ async function executeScoreSettlement(frame, interaction, edge) {
284
348
  if (matchedLegacy) {
285
349
  throw new PlaytestError('SETTLEMENT_DEFAULT_NOT_REACHED', `Score settlement result ${actual} has an explicit branch`);
286
350
  }
287
- await clickAction(frame, 'confirm-settlement');
351
+ await clickAction(frame, 'confirm-settlement', deadline);
288
352
  return;
289
353
  }
290
354
  if (actual !== target)
291
355
  throw new PlaytestError('SETTLEMENT_RESULT_MISMATCH', `Score settlement result ${actual} does not match ${target}`, { actual, target });
292
- await clickAction(frame, 'confirm-settlement');
356
+ await clickAction(frame, 'confirm-settlement', deadline);
293
357
  }
294
358
  export async function completeAvgPlayback(frame, nodeId, deadline) {
295
359
  if (Date.now() >= deadline)
@@ -314,7 +378,14 @@ export async function completeAvgPlayback(frame, nodeId, deadline) {
314
378
  const remaining = deadline - Date.now();
315
379
  if (remaining <= 0)
316
380
  break;
317
- await next.click({ timeout: remaining });
381
+ try {
382
+ await next.click({ timeout: Math.min(5000, remaining) });
383
+ }
384
+ catch (error) {
385
+ throw new PlaytestError('AVG_NEXT_CONTROL_BLOCKED', `AVG next control is not clickable on ${nodeId}`, {
386
+ nodeId, step: state.step, cause: error instanceof Error ? error.message : String(error),
387
+ });
388
+ }
318
389
  clickedStep = state.step;
319
390
  }
320
391
  const remaining = deadline - Date.now();
@@ -323,7 +394,7 @@ export async function completeAvgPlayback(frame, nodeId, deadline) {
323
394
  }
324
395
  throw new PlaytestError(observedAvg ? 'AVG_COMPLETION_TIMEOUT' : 'AVG_PLAYTEST_PROTOCOL_UNSUPPORTED', `AVG ${nodeId} did not expose completion within the remaining scenario budget`, { nodeId, clickedStep });
325
396
  }
326
- async function executeNativeInteraction(frame, node, edge, contract, deadline) {
397
+ async function executeNativeInteraction(frame, node, edge, contract, deadline, media) {
327
398
  const interaction = (node.interactions || []).find((candidate) => candidate.id === edge.ownerId);
328
399
  if (!interaction)
329
400
  throw new PlaytestError('INTERACTION_NOT_RENDERED', `Interaction ${edge.ownerId} is absent from node ${edge.from}`);
@@ -336,30 +407,25 @@ async function executeNativeInteraction(frame, node, edge, contract, deadline) {
336
407
  owner: 'node',
337
408
  ownerId: node.id,
338
409
  trigger: { type: 'message', value: interaction.trigger.value },
339
- }, contract);
410
+ }, contract, deadline);
340
411
  }
341
412
  else if (interaction.trigger?.type === 'time' && node.type === 'video') {
342
- await frame.locator('video').first().evaluate((element, time) => {
343
- element.currentTime = time + 0.05;
344
- element.dispatchEvent(new globalThis.Event('timeupdate', { bubbles: true }));
345
- }, Number(interaction.trigger.value || 0));
413
+ await driveVerifiedVideo(media, 'time', Number(interaction.trigger.value || 0));
346
414
  }
347
415
  else if (interaction.trigger?.type === 'time' && Number(interaction.trigger.value || 0) > 0) {
348
416
  await frame.page().waitForTimeout(Number(interaction.trigger.value) * 1000 + 50);
349
417
  }
350
418
  else if (interaction.trigger?.type === 'complete' && node.type === 'video') {
351
- await frame.locator('video').first().evaluate((element) => {
352
- element.dispatchEvent(new globalThis.Event('ended', { bubbles: true }));
353
- });
419
+ await driveVerifiedVideo(media, 'complete-interaction');
354
420
  }
355
- await frame.locator(`[data-bv-interaction-id="${edge.ownerId}"]`).waitFor({ state: 'attached' });
421
+ await frame.locator(`[data-bv-interaction-id="${edge.ownerId}"]`).waitFor({ state: 'attached', timeout: remainingBudget(deadline) });
356
422
  if (edge.trigger.type === 'timeout') {
357
423
  await frame.page().waitForTimeout((Number(interaction.timeLimit || 0) + 2.5) * 1000);
358
424
  return;
359
425
  }
360
426
  const interactionType = String(interaction.interactive?.type || '');
361
427
  if (interactionType === 'score_settlement') {
362
- await executeScoreSettlement(frame, interaction, edge);
428
+ await executeScoreSettlement(frame, interaction, edge, deadline);
363
429
  return;
364
430
  }
365
431
  const legacy = edge.trigger.type === 'legacy' ? String(edge.trigger.value) : undefined;
@@ -379,9 +445,9 @@ async function executeNativeInteraction(frame, node, edge, contract, deadline) {
379
445
  const ids = (interaction.interactive?.chainOptions || []).map((option) => String(option.id));
380
446
  const orderedIds = resultValue === 'correct' ? ids : [...ids].reverse();
381
447
  for (const optionId of orderedIds) {
382
- await frame.locator(`[data-bv-option-id="${optionId}"]`).first().click();
448
+ await frame.locator(`[data-bv-option-id="${optionId}"]`).first().click({ timeout: remainingBudget(deadline) });
383
449
  }
384
- await clickAction(frame, 'submit');
450
+ await clickAction(frame, 'submit', deadline);
385
451
  return;
386
452
  }
387
453
  if (interactionType === 'blank_fill') {
@@ -392,9 +458,9 @@ async function executeNativeInteraction(frame, node, edge, contract, deadline) {
392
458
  const correctId = String(blank.correctOptionId || '');
393
459
  const wrongId = String(options.find((option) => String(option.id) !== correctId)?.id || correctId);
394
460
  const optionId = resultValue === 'incorrect' || (resultValue === 'partial' && index > 0) ? wrongId : correctId;
395
- await frame.locator(`[data-bv-option-id="${optionId}"]`).first().dragTo(frame.locator(`[data-bv-blank-id="${blank.id}"]`));
461
+ await frame.locator(`[data-bv-option-id="${optionId}"]`).first().dragTo(frame.locator(`[data-bv-blank-id="${blank.id}"]`), { timeout: remainingBudget(deadline) });
396
462
  }
397
- await clickAction(frame, 'submit');
463
+ await clickAction(frame, 'submit', deadline);
398
464
  return;
399
465
  }
400
466
  if (interactionType === 'input_fill') {
@@ -405,15 +471,15 @@ async function executeNativeInteraction(frame, node, edge, contract, deadline) {
405
471
  const answer = shouldBeWrong ? '__wrong__' : (blank.correctAnswers?.[0] ?? 'playtest');
406
472
  const control = frame.locator(`[data-bv-blank-id="${blank.id}"]`);
407
473
  if (blank.type === 'number') {
408
- await control.click();
474
+ await control.click({ timeout: remainingBudget(deadline) });
409
475
  for (const key of String(answer))
410
- await control.press(key);
476
+ await control.press(key, { timeout: remainingBudget(deadline) });
411
477
  }
412
478
  else {
413
- await control.fill(String(answer));
479
+ await control.fill(String(answer), { timeout: remainingBudget(deadline) });
414
480
  }
415
481
  }
416
- await clickAction(frame, 'submit');
482
+ await clickAction(frame, 'submit', deadline);
417
483
  return;
418
484
  }
419
485
  let optionIds = [];
@@ -436,17 +502,17 @@ async function executeNativeInteraction(frame, node, edge, contract, deadline) {
436
502
  for (const optionId of optionIds) {
437
503
  const option = frame.locator(`[data-bv-option-id="${optionId}"]`);
438
504
  if (await option.count())
439
- await option.first().click();
505
+ await option.first().click({ timeout: remainingBudget(deadline) });
440
506
  else {
441
507
  const optionText = interaction.interactive?.options?.find((candidate) => String(candidate.id) === optionId)?.text;
442
508
  if (!optionText)
443
509
  throw new PlaytestError('INTERACTION_DRIVER_UNSUPPORTED', `No real control for option ${optionId} in interaction ${edge.ownerId}`);
444
- await frame.getByText(String(optionText), { exact: true }).click();
510
+ await frame.getByText(String(optionText), { exact: true }).click({ timeout: remainingBudget(deadline) });
445
511
  }
446
512
  }
447
513
  const submit = frame.locator('[data-bv-action="submit"]');
448
514
  if (await submit.isVisible().catch(() => false))
449
- await clickAction(frame, 'submit');
515
+ await clickAction(frame, 'submit', deadline);
450
516
  return;
451
517
  }
452
518
  const inputs = frame.locator(`[data-bv-interaction-id="${edge.ownerId}"] input`);
@@ -454,65 +520,81 @@ async function executeNativeInteraction(frame, node, edge, contract, deadline) {
454
520
  const blanks = interaction.interactive?.blanks || [];
455
521
  for (let index = 0; index < await inputs.count(); index += 1) {
456
522
  const answer = blanks[index]?.correctAnswers?.[0] ?? blanks[index]?.answer ?? blanks[index]?.correctAnswer ?? blanks[index]?.answers?.[0] ?? 'playtest';
457
- await inputs.nth(index).fill(String(edge.trigger.value === 'incorrect' ? '__wrong__' : answer));
523
+ await inputs.nth(index).fill(String(edge.trigger.value === 'incorrect' ? '__wrong__' : answer), { timeout: remainingBudget(deadline) });
458
524
  }
459
- await clickAction(frame, 'submit');
525
+ await clickAction(frame, 'submit', deadline);
460
526
  return;
461
527
  }
462
528
  throw new PlaytestError('INTERACTION_DRIVER_UNSUPPORTED', `No real DOM driver is available for ${edge.ownerId}:${edge.trigger.type}`);
463
529
  }
464
- async function executeEdge(frame, edge, options, deadline) {
530
+ export async function executeEdge(frame, edge, options, deadline) {
465
531
  const node = getNode(options.script, edge.from);
466
- await assertNodeMedia(frame, node);
532
+ const media = await assertNodeMedia(frame, node, deadline);
533
+ const sameNode = edge.from === edge.to && edge.actionType !== 'end';
534
+ let beforeSequence = 0;
535
+ if (sameNode) {
536
+ if (node.type !== 'video' || !media || !['loop', 'goto'].includes(edge.actionType)
537
+ || await media.element.getAttribute('data-bv-video-action-protocol') !== '1') {
538
+ throw new PlaytestError('EDGE_EFFECT_OBSERVATION_UNSUPPORTED', `No reliable same-node effect observation for ${edge.id}`);
539
+ }
540
+ beforeSequence = Number(await media.element.getAttribute('data-bv-video-action-sequence') || 0);
541
+ }
467
542
  if (edge.owner === 'interaction') {
468
- await executeNativeInteraction(frame, node, edge, options.contract, deadline);
543
+ await executeNativeInteraction(frame, node, edge, options.contract, deadline, media);
469
544
  }
470
545
  else if (node.type === 'avg' && (edge.trigger.type === 'complete' || edge.default)) {
471
546
  await completeAvgPlayback(frame, edge.from, deadline);
472
547
  }
473
548
  else if (node.type === 'web' && (edge.trigger.type === 'message' || edge.default)) {
474
- await executeWebEdge(frame, node, edge, options.contract);
549
+ await executeWebEdge(frame, node, edge, options.contract, deadline);
475
550
  }
476
551
  else if (node.type === 'video' && edge.trigger.type === 'time') {
477
- await frame.locator('video').first().evaluate((element, time) => {
478
- element.currentTime = time + 0.05;
479
- element.dispatchEvent(new globalThis.Event('timeupdate', { bubbles: true }));
480
- }, Number(edge.trigger.value));
552
+ await driveVerifiedVideo(media, 'time', Number(edge.trigger.value));
481
553
  }
482
554
  else if (node.type === 'video' && edge.trigger.type === 'complete') {
483
- await frame.locator('video').first().evaluate((element) => {
484
- if (Number.isFinite(element.duration) && element.duration > 0)
485
- element.currentTime = Math.max(0, element.duration - 0.01);
486
- element.dispatchEvent(new globalThis.Event('timeupdate', { bubbles: true }));
487
- element.dispatchEvent(new globalThis.Event('ended', { bubbles: true }));
488
- });
555
+ await driveVerifiedVideo(media, 'complete');
489
556
  }
490
557
  else if (edge.trigger.type === 'enter') {
491
558
  // Start/enter transitions are produced by the runtime itself.
492
559
  }
493
560
  else if (node.type === 'image' && edge.trigger.type === 'complete') {
494
561
  const durationMs = (node.config?.slides || []).reduce((sum, slide) => sum + Number(slide.duration || 0) * 1000, 0);
495
- await frame.page().waitForTimeout(Math.min(durationMs + 250, options.limits.scenarioTimeoutMs / 2));
562
+ await frame.page().waitForTimeout(Math.min(durationMs + 250, remainingBudget(deadline)));
496
563
  }
497
564
  else {
498
565
  throw new PlaytestError('EDGE_DRIVER_UNSUPPORTED', `No real runtime driver for edge ${edge.id}`);
499
566
  }
500
- if (edge.actionType === 'end') {
567
+ if (sameNode) {
568
+ const segment = edge.action.segment;
569
+ const time = edge.actionType === 'loop' ? segment?.start ?? 0
570
+ : typeof edge.action.time === 'number' && edge.action.time >= 0 ? edge.action.time : 0;
571
+ const end = edge.actionType === 'loop' ? segment?.end ?? await media.element.evaluate((video) => video.duration) : undefined;
501
572
  try {
502
- await frame.waitForFunction(() => (globalThis.document.querySelector('[data-bv-ended]')?.getAttribute('data-bv-ended') === 'true'), undefined, { timeout: Math.min(10000, options.limits.scenarioTimeoutMs) });
573
+ await frame.waitForFunction(({ element, sequence, nodeId, type, time, end, source }) => {
574
+ if (!element?.isConnected || element.src !== source || element.currentSrc !== source || element.error
575
+ || element.getAttribute('data-bv-media-node-id') !== nodeId
576
+ || globalThis.document.querySelector('[data-bv-node-id]')?.getAttribute('data-bv-node-id') !== nodeId
577
+ || Number(element.getAttribute('data-bv-video-action-sequence') || 0) <= sequence)
578
+ return false;
579
+ const action = JSON.parse(element.getAttribute('data-bv-video-action') || 'null');
580
+ return action?.nodeId === nodeId && action.type === type && action.time === time && action.end === end
581
+ && Number.isFinite(action.position) && Math.abs(action.position - time) <= 0.5;
582
+ }, { element: media.element, sequence: beforeSequence, nodeId: edge.from, type: edge.actionType, time, end, source: media.source }, { timeout: remainingBudget(deadline) });
503
583
  }
504
584
  catch {
505
- throw new PlaytestError('ROUTE_TARGET_MISMATCH', `Edge ${edge.id} did not reach the ended state`);
585
+ throw new PlaytestError('SAME_NODE_EFFECT_NOT_OBSERVED', `No new matching runtime action receipt for ${edge.id}`);
506
586
  }
507
587
  }
508
- else if (edge.actionType === 'seek' && node.type === 'video' && Number.isFinite(Number(edge.action.time))) {
509
- await frame.waitForFunction((expectedTime) => {
510
- const video = globalThis.document.querySelector('video');
511
- return video && Math.abs(Number(video.currentTime) - Number(expectedTime)) < 0.5;
512
- }, Number(edge.action.time), { timeout: Math.min(10000, options.limits.scenarioTimeoutMs) });
588
+ else if (edge.actionType === 'end') {
589
+ try {
590
+ await frame.waitForFunction(() => (globalThis.document.querySelector('[data-bv-ended]')?.getAttribute('data-bv-ended') === 'true'), undefined, { timeout: remainingBudget(deadline) });
591
+ }
592
+ catch {
593
+ throw new PlaytestError('ROUTE_TARGET_MISMATCH', `Edge ${edge.id} did not reach the ended state`);
594
+ }
513
595
  }
514
596
  else {
515
- await waitForNode(frame, edge.to, Math.min(10000, options.limits.scenarioTimeoutMs));
597
+ await waitForNode(frame, edge.to, remainingBudget(deadline));
516
598
  }
517
599
  }
518
600
  async function createContext(browser, options, scenarioDir) {
@@ -577,7 +659,10 @@ async function runScenario(browser, scenario, options) {
577
659
  }, true);
578
660
  browserWindow.addEventListener('unhandledrejection', (event) => recordStartupException(event.reason));
579
661
  browserWindow.addEventListener('message', (event) => {
580
- browserWindow.__bvPlaytestObservedMessages.push({ data: event.data, origin: event.origin, at: Date.now() });
662
+ browserWindow.__bvPlaytestObservedMessages.push({
663
+ data: event.data, origin: event.origin, at: Date.now(),
664
+ sourceId: event.source ? browserWindow.__bvPlaytestWebSources?.get(event.source) : undefined,
665
+ });
581
666
  });
582
667
  });
583
668
  await context.tracing.start({ screenshots: true, snapshots: true, sources: true });
@@ -610,7 +695,7 @@ async function runScenario(browser, scenario, options) {
610
695
  throw new PlaytestError('SCENARIO_TIMEOUT', `Scenario ${scenario.id} exceeded ${options.limits.scenarioTimeoutMs}ms`);
611
696
  await startPlayback(playerFrame, entryNodeId, String(getNode(options.script, entryNodeId).type || ''), entryTimeout);
612
697
  playerReady = true;
613
- await assertNodeMedia(playerFrame, getNode(options.script, entryNodeId));
698
+ await assertNodeMedia(playerFrame, getNode(options.script, entryNodeId), scenarioDeadline);
614
699
  visitedNodes.push(String(options.script.graph?.entryNodeId || ''));
615
700
  for (const edge of scenario.edges) {
616
701
  activeEdge = edge;
@@ -630,7 +715,8 @@ async function runScenario(browser, scenario, options) {
630
715
  else
631
716
  failure = new PlaytestError('PLAYER_HANDSHAKE_TIMEOUT', 'Player did not expose the BV playtest protocol within the scenario budget');
632
717
  }
633
- findings.push({ code: failure.code, message: failure.message, scenarioId: scenario.id, edgeId: scenario.targetEdgeId, details: failure.details });
718
+ findings.push({ code: failure.code, message: failure.message, scenarioId: scenario.id, edgeId: activeEdge?.id || scenario.targetEdgeId,
719
+ details: activeEdge ? { ...(failure.details || {}), targetEdgeId: scenario.targetEdgeId } : failure.details });
634
720
  const screenshotPath = path.join(scenarioDir, 'failure.png');
635
721
  await page.screenshot({ path: screenshotPath, fullPage: true }).catch(() => undefined);
636
722
  artifacts.screenshot = screenshotPath;
@@ -46,6 +46,9 @@ function conditionMatches(condition, variables, inventory) {
46
46
  function applyEffects(state, edge, targetNode) {
47
47
  const variables = { ...state.variables };
48
48
  const inventory = { items: { ...state.inventory.items }, cards: { ...state.inventory.cards } };
49
+ // Same-node replay/media actions do not rerun entry effects; rule effects still run.
50
+ if (edge.from === edge.to && ['loop', 'seek', 'segment', 'goto'].includes(edge.actionType))
51
+ targetNode = undefined;
49
52
  const variableActions = [...(edge.variableActions || []), ...(targetNode?.variableActions || [])];
50
53
  for (const action of variableActions) {
51
54
  const current = variables[action.variable];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mindscraft/branch-video-agent-cli",
3
- "version": "0.4.6",
3
+ "version": "0.5.0",
4
4
  "description": "Published CLI for branch-video and AIHub agent APIs.",
5
5
  "type": "module",
6
6
  "bin": {