@mindscraft/branch-video-agent-cli 0.4.6 → 0.4.7

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/README.md CHANGED
@@ -67,9 +67,9 @@ AI Gateway 生产媒体验收使用 `ai-gateway media-understand --raw`。P0 模
67
67
 
68
68
  场景规划保留较长路线,删除其完整前缀场景;沿途动作、条件、状态效果和检查仍随长路线执行。`maxScenarios` 限制去重后的场景数;无法在步骤、访问次数或搜索预算内到达的边仍会使规划失败。
69
69
 
70
- 搜索展开上限的数值不变;可安全去重的相同状态不重复计入,其他状态沿原搜索计数。
71
-
72
- 实测范围不包含 Web 节点的 `branchConfig.defaultAction` 兜底分支,也不要求为仅落入该兜底的消息提供操作配方。业务脚本和兜底行为保持不变;显式消息分支(包括名为 `default` 的消息)、答对、答错及适用的超时仍须真实操作。仅通过豁免分支到达的节点不在本轮覆盖目标内;仍有正常路径可达的节点继续测试。JSON 报告以 `WEB_DEFAULT_NOT_TESTED` 逐边记录,JUnit 标为 `skipped`,不计作覆盖或通过;报告 `passed` 仅表示本轮要求实测的范围通过。该豁免不声称兜底不可达,也不影响其他类型的 default 校验。
70
+ 搜索展开上限的数值不变;可安全去重的相同状态不重复计入,其他状态沿原搜索计数。
71
+
72
+ 实测范围不包含 Web 节点的 `branchConfig.defaultAction` 兜底分支,也不要求为仅落入该兜底的消息提供操作配方。业务脚本和兜底行为保持不变;显式消息分支(包括名为 `default` 的消息)、答对、答错及适用的超时仍须真实操作。仅通过豁免分支到达的节点不在本轮覆盖目标内;仍有正常路径可达的节点继续测试。JSON 报告以 `WEB_DEFAULT_NOT_TESTED` 逐边记录,JUnit 标为 `skipped`,不计作覆盖或通过;报告 `passed` 仅表示本轮要求实测的范围通过。该豁免不声称兜底不可达,也不影响其他类型的 default 校验。
73
73
 
74
74
  ```powershell
75
75
  @'
@@ -99,6 +99,40 @@ function closedChoiceDefault(interaction) {
99
99
  && interactive.options.every((option) => typeof option.id === 'string'
100
100
  && (interaction.branches || []).some((branch) => branch.when === option.id && branch.then));
101
101
  }
102
+ // Deliberately narrow: the image timer pauses at the first tick and every
103
+ // settlement result leaves this node. Ordinary resumable interactions do not qualify.
104
+ function settlementBlocksImageCompletion(nodeId, node, nodes) {
105
+ if (node.type !== 'image' || node.branchConfig || node.branches || node.events
106
+ || node.config?.events || node.config?.messageHandlers
107
+ || (node.config?.loop !== undefined && node.config.loop !== false)
108
+ || !Array.isArray(node.config?.slides) || node.config.slides.length !== 1
109
+ || !Array.isArray(node.interactions) || node.interactions.length !== 1)
110
+ return false;
111
+ const slide = node.config.slides[0];
112
+ if (!slide || typeof slide.id !== 'string' || !slide.id.trim()
113
+ || typeof slide.assetRef !== 'string' || !slide.assetRef.trim()
114
+ || (slide.duration !== undefined && (!Number.isFinite(slide.duration) || slide.duration < 0))
115
+ || (slide.duration || 3) <= 0.1)
116
+ return false;
117
+ const interaction = node.interactions[0];
118
+ if (!interaction || interaction.interactive?.type !== 'score_settlement'
119
+ || interaction.branchConfig || interaction.events
120
+ || (interaction.timeLimit !== undefined && (!Number.isFinite(interaction.timeLimit) || interaction.timeLimit > 0))
121
+ || !(interaction.trigger?.type === 'time' && interaction.trigger.value === 0
122
+ || interaction.trigger?.type === 'slide' && interaction.trigger.index === 0 && interaction.trigger.offsetSec === 0)
123
+ || !Array.isArray(interaction.branches))
124
+ return false;
125
+ const outcomes = settlementConditions(interaction.interactive);
126
+ if (!outcomes)
127
+ return false;
128
+ const leavesNode = (action) => action?.type === 'end'
129
+ || action?.type === 'goto' && typeof action.target === 'string'
130
+ && Boolean(nodes[action.target.trim()]) && action.target.trim() !== nodeId;
131
+ if (interaction.branches.some((branch) => !branch || branch.condition || !leavesNode(branch.then))
132
+ || [interaction.fallback?.onDefault, interaction.fallback?.onTimeout].some(action => action && !leavesNode(action)))
133
+ return false;
134
+ return [...outcomes.keys()].every(result => interaction.branches.some((branch) => (typeof branch.when === 'string' && branch.when.trim().toLowerCase() === result)));
135
+ }
102
136
  export function extractPlaytestEdges(script) {
103
137
  const graph = script?.graph;
104
138
  const nodes = graph?.nodes || {};
@@ -108,14 +142,21 @@ export function extractPlaytestEdges(script) {
108
142
  if (node.type === 'start')
109
143
  addEdge(edges, nodeId, 'node', nodeId, { type: 'enter' }, node.config?.next);
110
144
  const avgCompletionInteraction = node.type === 'avg' && (node.interactions || []).some((interaction) => interaction.trigger?.type === 'complete');
111
- if (node.type === 'video' || node.type === 'image' || (node.type === 'avg' && !node.branchConfig && !avgCompletionInteraction)) {
145
+ const mediaCompletion = node.type === 'video' || node.type === 'image' || node.type === 'avg';
146
+ if (mediaCompletion && !node.branchConfig && !avgCompletionInteraction) {
112
147
  addEdge(edges, nodeId, 'node', nodeId, { type: 'complete' }, node.config?.onComplete);
148
+ if (settlementBlocksImageCompletion(nodeId, node, nodes)) {
149
+ const completion = edges.at(-1);
150
+ if (completion?.from === nodeId && completion.owner === 'node' && completion.trigger.type === 'complete') {
151
+ completion.unreachableReason = 'Immediate image settlement pauses completion and every result leaves the node';
152
+ }
153
+ }
113
154
  }
114
155
  // Completion interactions preempt completion routing, not enter/message rules.
115
156
  const nodeBranchConfig = avgCompletionInteraction && node.branchConfig
116
157
  ? { ...node.branchConfig, rules: (node.branchConfig.rules || []).filter((rule) => rule.trigger?.type !== 'complete'), defaultAction: undefined }
117
158
  : node.branchConfig;
118
- addBranchConfigEdges(edges, nodeId, 'node', nodeId, nodeBranchConfig, node.type === 'avg' ? 'complete' : 'default');
159
+ addBranchConfigEdges(edges, nodeId, 'node', nodeId, nodeBranchConfig, mediaCompletion ? 'complete' : 'default');
119
160
  for (const handler of node.config?.messageHandlers || []) {
120
161
  addEdge(edges, nodeId, 'node', nodeId, handler.when || { type: 'message' }, handler.then, false, {
121
162
  variableActions: handler.variableActions,
@@ -1,5 +1,5 @@
1
- import { type Frame } from 'playwright';
2
- import type { PlaytestContract, PlaytestLimits, PlaytestScenario, PlaytestScenarioResult } from './types.js';
1
+ import { type ElementHandle, type Frame } from 'playwright';
2
+ import type { PlaytestContract, PlaytestEdge, PlaytestLimits, PlaytestScenario, PlaytestScenarioResult } from './types.js';
3
3
  type ScriptRecord = Record<string, any>;
4
4
  interface RunnerOptions {
5
5
  script: ScriptRecord;
@@ -15,8 +15,17 @@ interface RunnerOptions {
15
15
  height: number;
16
16
  };
17
17
  }
18
+ interface VerifiedMedia {
19
+ element: ElementHandle;
20
+ source: string;
21
+ nodeId: string;
22
+ }
18
23
  export declare function startPlayback(frame: Frame, entryNodeId: string, entryNodeType: string, timeout: number): Promise<void>;
24
+ export declare function assertNodeMedia(frame: Frame, node: ScriptRecord, deadline: number): Promise<VerifiedMedia | undefined>;
25
+ export declare function driveVerifiedVideo(media: VerifiedMedia | undefined, trigger: 'time' | 'complete' | 'complete-interaction', time?: number): Promise<void>;
26
+ export declare function executeWebEdge(frame: Frame, node: ScriptRecord, edge: PlaytestEdge, contract: PlaytestContract, deadline: number): Promise<void>;
19
27
  export declare function completeAvgPlayback(frame: Frame, nodeId: string, deadline: number): Promise<void>;
28
+ export declare function executeEdge(frame: Frame, edge: PlaytestEdge, options: RunnerOptions, deadline: number): Promise<void>;
20
29
  export declare function runPlaytestScenarios(options: RunnerOptions): Promise<PlaytestScenarioResult[]>;
21
30
  export declare function createDraftPlayerUrl(baseUrl: string): {
22
31
  playerUrl: string;
@@ -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.4.7",
4
4
  "description": "Published CLI for branch-video and AIHub agent APIs.",
5
5
  "type": "module",
6
6
  "bin": {