@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 +3 -3
- package/dist/playtest/graph.js +43 -2
- package/dist/playtest/runner.d.ts +11 -2
- package/dist/playtest/runner.js +188 -102
- package/dist/playtest/scenarios.js +3 -0
- package/package.json +1 -1
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
|
@'
|
package/dist/playtest/graph.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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;
|
package/dist/playtest/runner.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
|
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 (
|
|
205
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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 (
|
|
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((
|
|
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('
|
|
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 === '
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
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,
|
|
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({
|
|
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,
|
|
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];
|