@mindscraft/branch-video-agent-cli 0.4.5 → 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 +17 -15
- package/dist/commands/playtest.js +12 -3
- package/dist/commands/project.js +5 -0
- package/dist/lib/flags.js +1 -1
- package/dist/playtest/contract.js +13 -7
- package/dist/playtest/graph.d.ts +1 -0
- package/dist/playtest/graph.js +140 -9
- package/dist/playtest/report.js +4 -2
- package/dist/playtest/runner.d.ts +12 -2
- package/dist/playtest/runner.js +318 -122
- package/dist/playtest/scenarios.js +7 -0
- package/dist/playtest/types.d.ts +2 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
# Branch Video Agent CLI
|
|
2
2
|
|
|
3
|
-
`@mindscraft/branch-video-agent-cli` 是一个可脱离当前代码仓库使用的发布版 CLI,用于通过 Bearer agent token 调用 branch-video、AIHub workflow 与 AI Gateway agent API。
|
|
4
|
-
|
|
5
|
-
## 图片持久任务(待发布)
|
|
6
|
-
|
|
7
|
-
`ai-gateway image-generate` / `image-edit` 支持 `--async` 提交、`--wait` 等待,stdin JSON 必须含稳定 `idempotencyKey`。无这两个标记时保持同步兼容。
|
|
8
|
-
|
|
9
|
-
```powershell
|
|
10
|
-
'{"idempotencyKey":"asset-shot09-v1","model":"gpt-image-2","prompt":"原提示词"}' | branch-video-agent ai-gateway image-generate --async --raw 2>receipt.jsonl
|
|
11
|
-
'{"idempotencyKey":"asset-shot09-v1"}' | branch-video-agent ai-gateway image-get --wait --raw
|
|
12
|
-
'{"taskId":"img_..."}' | branch-video-agent ai-gateway image-get --raw
|
|
13
|
-
```
|
|
14
|
-
|
|
15
|
-
stderr 输出提交前幂等身份、提交后 taskId 回执;stdout 为单个 JSON。提交响应丢失后按同 key 查询或原样重提,不换 key。等待超时返回任务最后状态及 `waitStopped`,Ctrl+C 不取消服务器任务;unknown 需要人工核对,不能自动重新生成。成功结果保留 `data[].url`,URL 需同项目 Bearer 下载再进入现有资产上传流程,不是公开 CDN URL。
|
|
16
|
-
|
|
17
|
-
完整 API、幂等与发布边界见仓库 `docs/ai-gateway-image-tasks.md`。本地构建验收需设 `BRANCH_VIDEO_AGENT_DISABLE_AUTO_UPDATE=1`,确保运行候选 dist 而非自动切换到 registry 版本;本任务不执行 npm 发布。
|
|
3
|
+
`@mindscraft/branch-video-agent-cli` 是一个可脱离当前代码仓库使用的发布版 CLI,用于通过 Bearer agent token 调用 branch-video、AIHub workflow 与 AI Gateway agent API。
|
|
4
|
+
|
|
5
|
+
## 图片持久任务(待发布)
|
|
6
|
+
|
|
7
|
+
`ai-gateway image-generate` / `image-edit` 支持 `--async` 提交、`--wait` 等待,stdin JSON 必须含稳定 `idempotencyKey`。无这两个标记时保持同步兼容。
|
|
8
|
+
|
|
9
|
+
```powershell
|
|
10
|
+
'{"idempotencyKey":"asset-shot09-v1","model":"gpt-image-2","prompt":"原提示词"}' | branch-video-agent ai-gateway image-generate --async --raw 2>receipt.jsonl
|
|
11
|
+
'{"idempotencyKey":"asset-shot09-v1"}' | branch-video-agent ai-gateway image-get --wait --raw
|
|
12
|
+
'{"taskId":"img_..."}' | branch-video-agent ai-gateway image-get --raw
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
stderr 输出提交前幂等身份、提交后 taskId 回执;stdout 为单个 JSON。提交响应丢失后按同 key 查询或原样重提,不换 key。等待超时返回任务最后状态及 `waitStopped`,Ctrl+C 不取消服务器任务;unknown 需要人工核对,不能自动重新生成。成功结果保留 `data[].url`,URL 需同项目 Bearer 下载再进入现有资产上传流程,不是公开 CDN URL。
|
|
16
|
+
|
|
17
|
+
完整 API、幂等与发布边界见仓库 `docs/ai-gateway-image-tasks.md`。本地构建验收需设 `BRANCH_VIDEO_AGENT_DISABLE_AUTO_UPDATE=1`,确保运行候选 dist 而非自动切换到 registry 版本;本任务不执行 npm 发布。
|
|
18
18
|
|
|
19
19
|
## 安装与执行
|
|
20
20
|
|
|
@@ -69,6 +69,8 @@ AI Gateway 生产媒体验收使用 `ai-gateway media-understand --raw`。P0 模
|
|
|
69
69
|
|
|
70
70
|
搜索展开上限的数值不变;可安全去重的相同状态不重复计入,其他状态沿原搜索计数。
|
|
71
71
|
|
|
72
|
+
实测范围不包含 Web 节点的 `branchConfig.defaultAction` 兜底分支,也不要求为仅落入该兜底的消息提供操作配方。业务脚本和兜底行为保持不变;显式消息分支(包括名为 `default` 的消息)、答对、答错及适用的超时仍须真实操作。仅通过豁免分支到达的节点不在本轮覆盖目标内;仍有正常路径可达的节点继续测试。JSON 报告以 `WEB_DEFAULT_NOT_TESTED` 逐边记录,JUnit 标为 `skipped`,不计作覆盖或通过;报告 `passed` 仅表示本轮要求实测的范围通过。该豁免不声称兜底不可达,也不影响其他类型的 default 校验。
|
|
73
|
+
|
|
72
74
|
```powershell
|
|
73
75
|
@'
|
|
74
76
|
{
|
|
@@ -59,6 +59,7 @@ async function runPlaytest({ client, payload, runtime }) {
|
|
|
59
59
|
let reachableNodeIds = [];
|
|
60
60
|
let reachableEdgeIds = [];
|
|
61
61
|
let scenarioResults = [];
|
|
62
|
+
let reachabilityWarnings = [];
|
|
62
63
|
let phase = 'input';
|
|
63
64
|
try {
|
|
64
65
|
input = parseInput(payload);
|
|
@@ -70,6 +71,13 @@ async function runPlaytest({ client, payload, runtime }) {
|
|
|
70
71
|
fingerprint = createScriptFingerprint(script);
|
|
71
72
|
phase = 'static-preflight';
|
|
72
73
|
assertPlaytestStaticPreflight(script);
|
|
74
|
+
const graph = getReachablePlaytestGraph(script);
|
|
75
|
+
reachabilityWarnings = graph.excludedEdges.map(edge => ({
|
|
76
|
+
code: edge.skippedReason ? 'WEB_DEFAULT_NOT_TESTED' : 'EDGE_NOT_USER_TRIGGERABLE',
|
|
77
|
+
message: (edge.skippedReason || edge.unreachableReason),
|
|
78
|
+
phase: 'scenario-generation', nodeId: edge.from, edgeId: edge.id,
|
|
79
|
+
details: { edge, interaction: script.graph.nodes[edge.from]?.interactions?.find((item) => item.id === edge.ownerId) },
|
|
80
|
+
}));
|
|
73
81
|
phase = 'contract';
|
|
74
82
|
let rawContract;
|
|
75
83
|
try {
|
|
@@ -94,10 +102,11 @@ async function runPlaytest({ client, payload, runtime }) {
|
|
|
94
102
|
maxNodeVisits: normalizePositiveInt(input.limits?.maxNodeVisits, DEFAULT_LIMITS.maxNodeVisits, 'limits.maxNodeVisits'),
|
|
95
103
|
};
|
|
96
104
|
const concurrency = Math.min(8, normalizePositiveInt(input.concurrency, 2, 'concurrency'));
|
|
105
|
+
if (limits.scenarioTimeoutMs > 2147483647)
|
|
106
|
+
throw new PlaytestError('VALIDATION_ERROR', 'limits.scenarioTimeoutMs must not exceed 2147483647');
|
|
97
107
|
const viewport = input.viewport || { width: 1280, height: 720 };
|
|
98
108
|
phase = 'scenario-generation';
|
|
99
109
|
const scenarios = buildPlaytestScenarios(script, limits, contract.nodeVisitLimits);
|
|
100
|
-
const graph = getReachablePlaytestGraph(script);
|
|
101
110
|
reachableNodeIds = graph.reachableNodeIds;
|
|
102
111
|
reachableEdgeIds = graph.reachableEdges.map((edge) => edge.id);
|
|
103
112
|
const draftPlayer = input.source.kind === 'draft' ? createDraftPlayerUrl(client.resolveUrl('/')) : null;
|
|
@@ -144,7 +153,7 @@ async function runPlaytest({ client, payload, runtime }) {
|
|
|
144
153
|
coveredEdgeIds,
|
|
145
154
|
scenarios: scenarioResults,
|
|
146
155
|
findings,
|
|
147
|
-
warnings,
|
|
156
|
+
warnings: [...reachabilityWarnings, ...warnings],
|
|
148
157
|
};
|
|
149
158
|
const reportPaths = await writePlaytestReport(input.reportDir, report);
|
|
150
159
|
if (report.status === 'failed') {
|
|
@@ -178,7 +187,7 @@ async function runPlaytest({ client, payload, runtime }) {
|
|
|
178
187
|
coveredEdgeIds: [...new Set(scenarioResults.flatMap((result) => result.coveredEdges))],
|
|
179
188
|
scenarios: scenarioResults,
|
|
180
189
|
findings: [finding],
|
|
181
|
-
warnings:
|
|
190
|
+
warnings: reachabilityWarnings,
|
|
182
191
|
};
|
|
183
192
|
const reportPaths = await writePlaytestReport(reportDir, report);
|
|
184
193
|
throw new PlaytestError(failure.code, failure.message, { reportPaths, report });
|
package/dist/commands/project.js
CHANGED
|
@@ -14,6 +14,11 @@ export const projectCommands = {
|
|
|
14
14
|
});
|
|
15
15
|
return { data: result.data, requestId: result.requestId };
|
|
16
16
|
},
|
|
17
|
+
'draft-get': async ({ client, payload }) => {
|
|
18
|
+
const projectId = getRequiredString(payload, 'projectId');
|
|
19
|
+
const result = await client.request(`/api/branch-video/${encodeURIComponent(projectId)}`, { method: 'GET' });
|
|
20
|
+
return { data: result.data, requestId: result.requestId };
|
|
21
|
+
},
|
|
17
22
|
create: async ({ client, payload }) => {
|
|
18
23
|
const result = await client.request('/api/branch-video', {
|
|
19
24
|
method: 'POST',
|
package/dist/lib/flags.js
CHANGED
|
@@ -98,7 +98,7 @@ export function getCliHelpText() {
|
|
|
98
98
|
'Groups:',
|
|
99
99
|
' preset list | upsert',
|
|
100
100
|
' workflow run | status | outputs',
|
|
101
|
-
' project list | get | create | import-aihub | update | delete',
|
|
101
|
+
' project list | get | draft-get | create | import-aihub | update | delete',
|
|
102
102
|
' version list | get | publish',
|
|
103
103
|
' agent-production create | status | cancel | finalize | autopilot-binding | events | metrics | multica-comment-payload | node claim|inputs|complete|submit|block|fail|retry|expire | blocker resolve | artifact submit|list | asset-job poll|retry|revise|attach-artifact|fail | gate evaluate|results',
|
|
104
104
|
' aihub-production schema | start | status | retry',
|
|
@@ -66,13 +66,24 @@ export function validatePlaytestContract(script, rawContract) {
|
|
|
66
66
|
result.steps.forEach((step, stepIndex) => assertStep(step, `webResults[${resultIndex}].steps[${stepIndex}]`));
|
|
67
67
|
}
|
|
68
68
|
const nodes = script?.graph?.nodes || {};
|
|
69
|
-
const { reachableEdges, reachableNodeIds } = getReachablePlaytestGraph(script);
|
|
69
|
+
const { reachableEdges, reachableNodeIds, excludedEdges } = getReachablePlaytestGraph(script);
|
|
70
70
|
for (const nodeId of reachableNodeIds) {
|
|
71
71
|
const node = nodes[nodeId];
|
|
72
72
|
if (node?.type !== 'web')
|
|
73
73
|
continue;
|
|
74
74
|
const mapping = node.config?.messageMapping || {};
|
|
75
|
+
const skipsDefault = excludedEdges.some(edge => edge.from === nodeId && edge.skippedReason);
|
|
76
|
+
const explicitRoutes = new Set(reachableEdges
|
|
77
|
+
.filter(edge => edge.from === nodeId && edge.owner === 'node' && edge.trigger.type === 'message')
|
|
78
|
+
.map(edge => String(edge.trigger.value)));
|
|
79
|
+
// A message may open a native interaction rather than route the Web node.
|
|
80
|
+
for (const interaction of node.interactions || []) {
|
|
81
|
+
if (interaction.trigger?.type === 'message')
|
|
82
|
+
explicitRoutes.add(String(interaction.trigger.value));
|
|
83
|
+
}
|
|
75
84
|
for (const routeValue of new Set(Object.values(mapping).filter((value) => typeof value === 'string'))) {
|
|
85
|
+
if (skipsDefault && !explicitRoutes.has(routeValue))
|
|
86
|
+
continue;
|
|
76
87
|
const recipe = rawContract.webResults.find((candidate) => candidate.nodeId === nodeId && candidate.routeValue === routeValue);
|
|
77
88
|
if (!recipe) {
|
|
78
89
|
throw new PlaytestError('WEB_PLAYTEST_RECIPE_MISSING', `Reachable Web result ${nodeId}:${routeValue} has no real-action recipe`, {
|
|
@@ -100,14 +111,9 @@ export function validatePlaytestContract(script, rawContract) {
|
|
|
100
111
|
for (const edge of reachableEdges) {
|
|
101
112
|
if (nodes[edge.from]?.type !== 'web' || edge.owner !== 'node')
|
|
102
113
|
continue;
|
|
103
|
-
const explicitRoutes = new Set(reachableEdges
|
|
104
|
-
.filter((candidate) => candidate.from === edge.from && candidate.trigger.type === 'message')
|
|
105
|
-
.map((candidate) => String(candidate.trigger.value)));
|
|
106
114
|
const recipe = edge.trigger.type === 'message'
|
|
107
115
|
? rawContract.webResults.find((candidate) => candidate.nodeId === edge.from && candidate.routeValue === String(edge.trigger.value))
|
|
108
|
-
:
|
|
109
|
-
? rawContract.webResults.find((candidate) => candidate.nodeId === edge.from && !explicitRoutes.has(candidate.routeValue))
|
|
110
|
-
: null;
|
|
116
|
+
: null;
|
|
111
117
|
if (!recipe) {
|
|
112
118
|
throw new PlaytestError('WEB_PLAYTEST_RECIPE_MISSING', `Reachable Web edge ${edge.id} has no real-action recipe`, {
|
|
113
119
|
nodeId: edge.from,
|
package/dist/playtest/graph.d.ts
CHANGED
package/dist/playtest/graph.js
CHANGED
|
@@ -30,20 +30,108 @@ function addEdge(edges, from, owner, ownerId, trigger, action, isDefault = false
|
|
|
30
30
|
default: isDefault || undefined,
|
|
31
31
|
});
|
|
32
32
|
}
|
|
33
|
-
function addBranchConfigEdges(edges, from, owner, ownerId, config) {
|
|
33
|
+
function addBranchConfigEdges(edges, from, owner, ownerId, config, defaultTrigger = 'default') {
|
|
34
|
+
const earlierConditions = new Map();
|
|
34
35
|
for (const rule of config?.rules || []) {
|
|
35
36
|
const rawTrigger = rule.trigger || { type: 'unknown' };
|
|
36
37
|
const trigger = {
|
|
37
38
|
type: rawTrigger.type,
|
|
38
39
|
value: rawTrigger.value ?? rawTrigger.optionId ?? rawTrigger.optionIds,
|
|
39
40
|
};
|
|
41
|
+
const matchValue = trigger.type === 'option'
|
|
42
|
+
? String(trigger.value ?? '').trim().toLowerCase()
|
|
43
|
+
: trigger.type === 'message' ? String(trigger.value ?? '').trim() : trigger.value;
|
|
44
|
+
const key = JSON.stringify([trigger.type, matchValue]);
|
|
45
|
+
const earlier = earlierConditions.get(key) || [];
|
|
46
|
+
const conditions = [
|
|
47
|
+
...(rule.condition ? [rule.condition] : []),
|
|
48
|
+
...earlier.map((condition) => ({ type: 'not', condition })),
|
|
49
|
+
];
|
|
40
50
|
addEdge(edges, from, owner, ownerId, trigger, rule.action, false, {
|
|
41
|
-
condition:
|
|
51
|
+
condition: conditions.length > 1 ? { type: 'and', conditions } : conditions[0],
|
|
42
52
|
variableActions: rule.variableActions,
|
|
43
53
|
inventoryActions: rule.inventoryActions,
|
|
44
54
|
});
|
|
55
|
+
earlierConditions.set(key, [...earlier, rule.condition || { type: 'and', conditions: [] }]);
|
|
45
56
|
}
|
|
46
|
-
|
|
57
|
+
const completeConditions = defaultTrigger === 'complete' ? earlierConditions.get(JSON.stringify(['complete', undefined])) || [] : [];
|
|
58
|
+
addEdge(edges, from, owner, ownerId, { type: defaultTrigger }, config?.defaultAction, true, {
|
|
59
|
+
condition: completeConditions.length ? { type: 'and', conditions: completeConditions.map((condition) => ({ type: 'not', condition })) } : undefined,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
// Mirror ScoreSettlement's sorted ranges, extrema and lowest-level gap fallback.
|
|
63
|
+
function settlementConditions(interactive) {
|
|
64
|
+
const config = interactive.settlementConfig || interactive;
|
|
65
|
+
if (!Array.isArray(config.levels) || !config.levels.length
|
|
66
|
+
|| !config.levels.every((level) => level && typeof level.id === 'string' && Number.isFinite(level.minScore) && Number.isFinite(level.maxScore) && level.minScore <= level.maxScore)
|
|
67
|
+
|| !Array.isArray(config.values))
|
|
68
|
+
return null;
|
|
69
|
+
const levels = [...config.levels].sort((a, b) => b.minScore - a.minScore);
|
|
70
|
+
const primary = config.values?.find((value) => value.isPrimary) || config.values?.[0];
|
|
71
|
+
const and = (...conditions) => ({ type: 'and', conditions });
|
|
72
|
+
const not = (condition) => ({ type: 'not', condition });
|
|
73
|
+
const compare = (op, value) => ({ type: 'settlement_score', variable: primary?.variableName, op, value });
|
|
74
|
+
const high = compare('gte', Math.max(...levels.map(level => level.maxScore)));
|
|
75
|
+
const low = compare('lte', Math.min(...levels.map(level => level.minScore)));
|
|
76
|
+
const ranges = levels.map(level => and(compare('gte', level.minScore), compare('lte', level.maxScore)));
|
|
77
|
+
const result = new Map();
|
|
78
|
+
for (const [index, level] of levels.entries()) {
|
|
79
|
+
const id = level.id.toLowerCase();
|
|
80
|
+
const grade = id.includes('level_s') || id.includes('score_s') ? 'score_s'
|
|
81
|
+
: id.includes('level_a') || id.includes('score_a') ? 'score_a'
|
|
82
|
+
: id.includes('level_b') || id.includes('score_b') ? 'score_b' : 'default';
|
|
83
|
+
const conditions = primary ? [
|
|
84
|
+
and(not(high), not(low), ranges[index], ...ranges.slice(0, index).map(not)),
|
|
85
|
+
...(index === 0 ? [high] : []),
|
|
86
|
+
...(index === levels.length - 1 ? [and(not(high), low), and(not(high), not(low), ...ranges.map(not))] : []),
|
|
87
|
+
] : [level === config.levels[config.levels.length - 1] ? and() : not(and())];
|
|
88
|
+
const prior = result.get(grade);
|
|
89
|
+
result.set(grade, { type: 'or', conditions: [...(prior?.conditions || []), ...conditions] });
|
|
90
|
+
}
|
|
91
|
+
return result;
|
|
92
|
+
}
|
|
93
|
+
function closedChoiceDefault(interaction) {
|
|
94
|
+
const interactive = interaction.interactive || {};
|
|
95
|
+
return !interaction.branchConfig && interactive.type === 'story_choice'
|
|
96
|
+
&& interactive.inputMode === 'button' && !interactive.collectMode
|
|
97
|
+
&& !(interaction.branches || []).some((branch) => branch.when === 'all_clicked')
|
|
98
|
+
&& Array.isArray(interactive.options) && interactive.options.length > 0
|
|
99
|
+
&& interactive.options.every((option) => typeof option.id === 'string'
|
|
100
|
+
&& (interaction.branches || []).some((branch) => branch.when === option.id && branch.then));
|
|
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)));
|
|
47
135
|
}
|
|
48
136
|
export function extractPlaytestEdges(script) {
|
|
49
137
|
const graph = script?.graph;
|
|
@@ -53,10 +141,22 @@ export function extractPlaytestEdges(script) {
|
|
|
53
141
|
const node = rawNode || {};
|
|
54
142
|
if (node.type === 'start')
|
|
55
143
|
addEdge(edges, nodeId, 'node', nodeId, { type: 'enter' }, node.config?.next);
|
|
56
|
-
|
|
144
|
+
const avgCompletionInteraction = node.type === 'avg' && (node.interactions || []).some((interaction) => interaction.trigger?.type === 'complete');
|
|
145
|
+
const mediaCompletion = node.type === 'video' || node.type === 'image' || node.type === 'avg';
|
|
146
|
+
if (mediaCompletion && !node.branchConfig && !avgCompletionInteraction) {
|
|
57
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
|
+
}
|
|
58
154
|
}
|
|
59
|
-
|
|
155
|
+
// Completion interactions preempt completion routing, not enter/message rules.
|
|
156
|
+
const nodeBranchConfig = avgCompletionInteraction && node.branchConfig
|
|
157
|
+
? { ...node.branchConfig, rules: (node.branchConfig.rules || []).filter((rule) => rule.trigger?.type !== 'complete'), defaultAction: undefined }
|
|
158
|
+
: node.branchConfig;
|
|
159
|
+
addBranchConfigEdges(edges, nodeId, 'node', nodeId, nodeBranchConfig, mediaCompletion ? 'complete' : 'default');
|
|
60
160
|
for (const handler of node.config?.messageHandlers || []) {
|
|
61
161
|
addEdge(edges, nodeId, 'node', nodeId, handler.when || { type: 'message' }, handler.then, false, {
|
|
62
162
|
variableActions: handler.variableActions,
|
|
@@ -71,19 +171,45 @@ export function extractPlaytestEdges(script) {
|
|
|
71
171
|
});
|
|
72
172
|
}
|
|
73
173
|
for (const interaction of node.interactions || []) {
|
|
174
|
+
const firstEdge = edges.length;
|
|
175
|
+
const settlement = interaction.interactive?.type === 'score_settlement'
|
|
176
|
+
? settlementConditions(interaction.interactive) : null;
|
|
74
177
|
addBranchConfigEdges(edges, nodeId, 'interaction', interaction.id, interaction.branchConfig);
|
|
75
|
-
for (const legacy of interaction.branches || []) {
|
|
178
|
+
for (const legacy of interaction.branchConfig ? [] : interaction.branches || []) {
|
|
76
179
|
addEdge(edges, nodeId, 'interaction', interaction.id, { type: 'legacy', value: legacy.when }, legacy.then, false, {
|
|
77
|
-
condition: typeof legacy.when === 'object' ? legacy.when : undefined,
|
|
180
|
+
condition: settlement?.get(String(legacy.when).trim().toLowerCase()) || (typeof legacy.when === 'object' ? legacy.when : undefined),
|
|
78
181
|
variableActions: legacy.variableActions,
|
|
79
182
|
inventoryActions: legacy.inventoryActions,
|
|
80
183
|
});
|
|
81
184
|
}
|
|
82
185
|
addEdge(edges, nodeId, 'interaction', interaction.id, { type: 'timeout' }, interaction.fallback?.onTimeout);
|
|
83
|
-
|
|
186
|
+
if (!interaction.branchConfig)
|
|
187
|
+
addEdge(edges, nodeId, 'interaction', interaction.id, { type: 'default' }, interaction.fallback?.onDefault, true);
|
|
188
|
+
for (const edge of edges.slice(firstEdge)) {
|
|
189
|
+
if (edge.default && closedChoiceDefault(interaction)) {
|
|
190
|
+
edge.unreachableReason = 'Button story choice has no skip control and every option has an unconditional legacy branch';
|
|
191
|
+
}
|
|
192
|
+
if (edge.default && !interaction.branchConfig && settlement) {
|
|
193
|
+
const unmatched = [...settlement].filter(([result]) => !(interaction.branches || []).some((branch) => String(branch.when).trim().toLowerCase() === result));
|
|
194
|
+
if (!unmatched.length) {
|
|
195
|
+
edge.unreachableReason = 'Every configured settlement level, including extrema and gap fallback, emits an explicitly handled score result';
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
edge.condition = { type: 'or', conditions: unmatched.map(([, condition]) => condition) };
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (edge.trigger.type === 'timeout' && interaction.interactive?.type === 'score_settlement'
|
|
202
|
+
&& !(Number(interaction.timeLimit) > 0)) {
|
|
203
|
+
edge.unreachableReason = 'Settlement has no active interaction timer';
|
|
204
|
+
}
|
|
205
|
+
}
|
|
84
206
|
}
|
|
85
207
|
}
|
|
86
208
|
for (const edge of edges) {
|
|
209
|
+
// Test-scope policy, not a claim that the business fallback is unreachable.
|
|
210
|
+
if (nodes[edge.from]?.type === 'web' && edge.owner === 'node' && edge.default) {
|
|
211
|
+
edge.skippedReason = 'Web default fallback is excluded from real-action testing by approved test scope; not tested';
|
|
212
|
+
}
|
|
87
213
|
if (edge.actionType !== 'restart')
|
|
88
214
|
continue;
|
|
89
215
|
const oldTarget = edge.to;
|
|
@@ -99,6 +225,7 @@ export function getReachablePlaytestGraph(script) {
|
|
|
99
225
|
const allEdges = extractPlaytestEdges(script);
|
|
100
226
|
const reachableNodeIds = new Set();
|
|
101
227
|
const reachableEdges = [];
|
|
228
|
+
const excludedEdges = [];
|
|
102
229
|
const queue = entryNodeId ? [entryNodeId] : [];
|
|
103
230
|
while (queue.length) {
|
|
104
231
|
const nodeId = queue.shift();
|
|
@@ -106,10 +233,14 @@ export function getReachablePlaytestGraph(script) {
|
|
|
106
233
|
continue;
|
|
107
234
|
reachableNodeIds.add(nodeId);
|
|
108
235
|
for (const edge of allEdges.filter((candidate) => candidate.from === nodeId)) {
|
|
236
|
+
if (edge.unreachableReason || edge.skippedReason) {
|
|
237
|
+
excludedEdges.push(edge);
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
109
240
|
reachableEdges.push(edge);
|
|
110
241
|
if (nodes[edge.to] && !reachableNodeIds.has(edge.to))
|
|
111
242
|
queue.push(edge.to);
|
|
112
243
|
}
|
|
113
244
|
}
|
|
114
|
-
return { entryNodeId, reachableNodeIds: [...reachableNodeIds], reachableEdges };
|
|
245
|
+
return { entryNodeId, reachableNodeIds: [...reachableNodeIds], reachableEdges, excludedEdges };
|
|
115
246
|
}
|
package/dist/playtest/report.js
CHANGED
|
@@ -21,8 +21,10 @@ export function createJUnitXml(report) {
|
|
|
21
21
|
return `<testcase classname="branch-video-playtest" name="${escapeXml(scenario.id)}" time="${(scenario.durationMs / 1000).toFixed(3)}">${failure}</testcase>`;
|
|
22
22
|
}).join('');
|
|
23
23
|
const gateCases = gateFindings.map((finding, index) => (`<testcase classname="branch-video-playtest.gate" name="gate-${index + 1}-${escapeXml(finding.code)}" time="0.000"><failure type="${escapeXml(finding.code)}" message="${escapeXml(finding.message)}">${escapeXml(JSON.stringify(finding.details ?? null))}</failure></testcase>`)).join('');
|
|
24
|
-
const
|
|
25
|
-
|
|
24
|
+
const skipped = report.warnings.filter(warning => warning.code === 'WEB_DEFAULT_NOT_TESTED');
|
|
25
|
+
const skippedCases = skipped.map(warning => (`<testcase classname="branch-video-playtest.excluded" name="${escapeXml(warning.edgeId)}" time="0.000"><skipped message="${escapeXml(warning.message)}"/></testcase>`)).join('');
|
|
26
|
+
const tests = report.scenarios.length + gateFindings.length + skipped.length;
|
|
27
|
+
return `<?xml version="1.0" encoding="UTF-8"?>\n<testsuite name="branch-video-playtest" tests="${tests}" failures="${failures}" skipped="${skipped.length}" time="${duration.toFixed(3)}">${scenarioCases}${gateCases}${skippedCases}</testsuite>\n`;
|
|
26
28
|
}
|
|
27
29
|
export async function writePlaytestReport(reportDir, report) {
|
|
28
30
|
await mkdir(reportDir, { recursive: true });
|
|
@@ -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,7 +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>;
|
|
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>;
|
|
19
29
|
export declare function runPlaytestScenarios(options: RunnerOptions): Promise<PlaytestScenarioResult[]>;
|
|
20
30
|
export declare function createDraftPlayerUrl(baseUrl: string): {
|
|
21
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);
|
|
@@ -86,20 +93,36 @@ async function waitForPlayerFrame(page, playerUrl, timeout) {
|
|
|
86
93
|
}
|
|
87
94
|
throw new PlaytestError('PLAYER_LOAD_FAILED', `Player iframe did not load ${playerUrl}`);
|
|
88
95
|
}
|
|
96
|
+
async function playerStartupExceptions(frame) {
|
|
97
|
+
return frame.evaluate(() => {
|
|
98
|
+
const exceptions = globalThis.__bvPlaytestStartupExceptions;
|
|
99
|
+
return Array.isArray(exceptions) ? exceptions : [];
|
|
100
|
+
}).catch(() => []);
|
|
101
|
+
}
|
|
102
|
+
async function throwForPlayerStartupException(frame) {
|
|
103
|
+
const exceptions = await playerStartupExceptions(frame);
|
|
104
|
+
if (exceptions.length) {
|
|
105
|
+
throw new PlaytestError('PLAYER_INIT_FAILED', 'Player raised an exception before the playtest handshake', { startupExceptions: exceptions });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
89
108
|
async function assertPlayerProtocol(frame, timeout) {
|
|
90
109
|
const root = frame.locator('[data-bv-playtest-protocol]').first();
|
|
91
110
|
try {
|
|
92
|
-
|
|
111
|
+
if (timeout <= 0)
|
|
112
|
+
throw new Error('Scenario deadline elapsed');
|
|
113
|
+
await root.waitFor({ state: 'attached', timeout });
|
|
93
114
|
}
|
|
94
115
|
catch {
|
|
95
|
-
|
|
116
|
+
await throwForPlayerStartupException(frame);
|
|
117
|
+
throw new PlaytestError('PLAYER_HANDSHAKE_TIMEOUT', 'Player did not expose the BV playtest protocol within the scenario budget');
|
|
96
118
|
}
|
|
97
119
|
const protocol = await root.getAttribute('data-bv-playtest-protocol');
|
|
98
120
|
if (protocol !== '1') {
|
|
99
121
|
throw new PlaytestError('PLAYER_PLAYTEST_PROTOCOL_UNSUPPORTED', `Unsupported BV playtest protocol: ${protocol || 'missing'}`, { protocol });
|
|
100
122
|
}
|
|
101
123
|
if (await root.getAttribute('data-bv-player-state') === 'error') {
|
|
102
|
-
|
|
124
|
+
const exceptions = await playerStartupExceptions(frame);
|
|
125
|
+
throw new PlaytestError('PLAYER_INIT_FAILED', 'Player entered the error state before playback started', exceptions.length ? { startupExceptions: exceptions } : undefined);
|
|
103
126
|
}
|
|
104
127
|
}
|
|
105
128
|
async function waitForNode(frame, nodeId, timeout) {
|
|
@@ -137,42 +160,74 @@ export async function startPlayback(frame, entryNodeId, entryNodeType, timeout)
|
|
|
137
160
|
}
|
|
138
161
|
}
|
|
139
162
|
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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 };
|
|
167
200
|
}
|
|
168
201
|
if (node.type === 'web') {
|
|
169
|
-
await frame.locator('iframe[title="Web 页面"]').waitFor({ state: 'visible' });
|
|
202
|
+
await frame.locator('iframe[title="Web 页面"]').waitFor({ state: 'visible', timeout: remainingBudget(deadline) });
|
|
170
203
|
}
|
|
171
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
|
+
}
|
|
172
227
|
async function readObservedMessages(frame) {
|
|
173
228
|
return frame.evaluate(() => globalThis.__bvPlaytestObservedMessages || []).catch(() => []);
|
|
174
229
|
}
|
|
175
|
-
async function executeWebEdge(frame, node, edge, contract) {
|
|
230
|
+
export async function executeWebEdge(frame, node, edge, contract, deadline) {
|
|
176
231
|
const explicitRoutes = new Set((node.branchConfig?.rules || [])
|
|
177
232
|
.filter((rule) => rule.trigger?.type === 'message')
|
|
178
233
|
.map((rule) => String(rule.trigger.value)));
|
|
@@ -181,19 +236,44 @@ async function executeWebEdge(frame, node, edge, contract) {
|
|
|
181
236
|
: contract.webResults.find((candidate) => candidate.nodeId === edge.from && !explicitRoutes.has(candidate.routeValue));
|
|
182
237
|
if (!recipe)
|
|
183
238
|
throw new PlaytestError('WEB_PLAYTEST_RECIPE_MISSING', `No recipe for ${edge.from}:${String(edge.trigger.value)}`);
|
|
184
|
-
const
|
|
239
|
+
const handle = await frame.locator('iframe[title="Web 页面"]').elementHandle({ timeout: remainingBudget(deadline) });
|
|
240
|
+
const webFrame = await handle?.contentFrame();
|
|
185
241
|
if (!webFrame)
|
|
186
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
|
+
});
|
|
187
251
|
const before = (await readObservedMessages(frame)).length;
|
|
188
|
-
for (
|
|
189
|
-
|
|
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
|
+
}
|
|
190
268
|
try {
|
|
191
269
|
await frame.waitForFunction((payload) => {
|
|
192
|
-
const { start, rawMessage } = payload;
|
|
270
|
+
const { start, rawMessage, sourceId } = payload;
|
|
193
271
|
const messages = (globalThis.__bvPlaytestObservedMessages || []).slice(start);
|
|
194
272
|
const expectedEvent = rawMessage.eventName;
|
|
195
273
|
const expectedValue = rawMessage.value ?? rawMessage.data?.value ?? rawMessage.data;
|
|
196
274
|
return messages.some((entry) => {
|
|
275
|
+
if (entry.sourceId !== sourceId)
|
|
276
|
+
return false;
|
|
197
277
|
const actual = entry.data;
|
|
198
278
|
const eventName = actual?.eventName ?? actual?.type ?? actual?.event;
|
|
199
279
|
const value = actual?.value
|
|
@@ -207,14 +287,14 @@ async function executeWebEdge(frame, node, edge, contract) {
|
|
|
207
287
|
const comparableExpected = typeof expectedValue === 'string' || typeof expectedValue === 'number' ? String(expectedValue) : expectedValue;
|
|
208
288
|
return eventName === expectedEvent && JSON.stringify(comparableActual) === JSON.stringify(comparableExpected);
|
|
209
289
|
});
|
|
210
|
-
}, { start: before, rawMessage: recipe.rawMessage }, { timeout:
|
|
290
|
+
}, { start: before, rawMessage: recipe.rawMessage, sourceId }, { timeout: remainingBudget(deadline) });
|
|
211
291
|
}
|
|
212
292
|
catch {
|
|
213
293
|
const observed = (await readObservedMessages(frame)).slice(before);
|
|
214
294
|
throw new PlaytestError('WEB_MESSAGE_NOT_EMITTED', `Web actions did not emit the expected raw message for ${edge.from}:${recipe.routeValue}`, { observed });
|
|
215
295
|
}
|
|
216
296
|
const emitted = (await readObservedMessages(frame)).slice(before);
|
|
217
|
-
if (!emitted.some((message) => matchesRawMessage(message, recipe.rawMessage))) {
|
|
297
|
+
if (!emitted.some((message) => message.sourceId === sourceId && matchesRawMessage(message, recipe.rawMessage))) {
|
|
218
298
|
throw new PlaytestError('WEB_MESSAGE_NOT_EMITTED', `Expected Web raw message was not recorded for ${edge.from}:${recipe.routeValue}`);
|
|
219
299
|
}
|
|
220
300
|
}
|
|
@@ -237,66 +317,137 @@ function chooseResultOption(interaction, result) {
|
|
|
237
317
|
}
|
|
238
318
|
return wrong.length ? [wrong[0]] : options.slice(-1).map((option) => String(option.id));
|
|
239
319
|
}
|
|
240
|
-
async function clickAction(frame, action) {
|
|
320
|
+
async function clickAction(frame, action, deadline) {
|
|
241
321
|
const control = frame.locator(`[data-bv-action="${action}"]`).first();
|
|
242
322
|
const nestedButton = control.locator('button').first();
|
|
243
323
|
if (await nestedButton.count())
|
|
244
|
-
await nestedButton.click();
|
|
324
|
+
await nestedButton.click({ timeout: remainingBudget(deadline) });
|
|
245
325
|
else
|
|
246
|
-
await control.click();
|
|
326
|
+
await control.click({ timeout: remainingBudget(deadline) });
|
|
327
|
+
}
|
|
328
|
+
function normalizeSettlementResult(value) {
|
|
329
|
+
return String(value ?? '').trim().toLowerCase();
|
|
330
|
+
}
|
|
331
|
+
async function executeScoreSettlement(frame, interaction, edge, deadline) {
|
|
332
|
+
const target = (edge.trigger.type === 'legacy' || edge.trigger.type === 'result')
|
|
333
|
+
? normalizeSettlementResult(edge.trigger.value) : '';
|
|
334
|
+
if (!edge.default && !['score_s', 'score_a', 'score_b', 'default'].includes(target)) {
|
|
335
|
+
throw new PlaytestError('INTERACTION_DRIVER_UNSUPPORTED', `Unsupported score settlement target: ${edge.trigger.type}:${String(edge.trigger.value || '')}`);
|
|
336
|
+
}
|
|
337
|
+
const actual = normalizeSettlementResult(await frame.locator('[data-bv-settlement-result]').getAttribute('data-bv-settlement-result'));
|
|
338
|
+
if (!actual)
|
|
339
|
+
throw new PlaytestError('SETTLEMENT_RESULT_MISSING', `Score settlement did not expose a result for ${target}`);
|
|
340
|
+
if (!['score_s', 'score_a', 'score_b', 'default'].includes(actual)) {
|
|
341
|
+
throw new PlaytestError('INTERACTION_DRIVER_UNSUPPORTED', `Unsupported score settlement result: ${actual}`);
|
|
342
|
+
}
|
|
343
|
+
if (edge.default) {
|
|
344
|
+
if (interaction.branchConfig) {
|
|
345
|
+
throw new PlaytestError('INTERACTION_DRIVER_UNSUPPORTED', 'Score settlement default does not support explicit branchConfig');
|
|
346
|
+
}
|
|
347
|
+
const matchedLegacy = (interaction.branches || []).some((branch) => (typeof branch?.when === 'string' && normalizeSettlementResult(branch.when) === actual));
|
|
348
|
+
if (matchedLegacy) {
|
|
349
|
+
throw new PlaytestError('SETTLEMENT_DEFAULT_NOT_REACHED', `Score settlement result ${actual} has an explicit branch`);
|
|
350
|
+
}
|
|
351
|
+
await clickAction(frame, 'confirm-settlement', deadline);
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
if (actual !== target)
|
|
355
|
+
throw new PlaytestError('SETTLEMENT_RESULT_MISMATCH', `Score settlement result ${actual} does not match ${target}`, { actual, target });
|
|
356
|
+
await clickAction(frame, 'confirm-settlement', deadline);
|
|
357
|
+
}
|
|
358
|
+
export async function completeAvgPlayback(frame, nodeId, deadline) {
|
|
359
|
+
if (Date.now() >= deadline)
|
|
360
|
+
throw new PlaytestError('AVG_COMPLETION_TIMEOUT', `No scenario budget remains for AVG ${nodeId}`);
|
|
361
|
+
let clickedStep;
|
|
362
|
+
let observedAvg = false;
|
|
363
|
+
while (Date.now() < deadline) {
|
|
364
|
+
const state = await frame.evaluate((id) => {
|
|
365
|
+
const document = globalThis.document;
|
|
366
|
+
const current = document.querySelector('[data-bv-node-id]')?.getAttribute('data-bv-node-id');
|
|
367
|
+
const avg = Array.from(document.querySelectorAll('[data-bv-avg-node-id]')).find((element) => element.getAttribute('data-bv-avg-node-id') === id);
|
|
368
|
+
return { nodeId: current, step: avg?.getAttribute('data-bv-avg-step'), completed: avg?.getAttribute('data-bv-avg-completed') === 'true', ended: document.querySelector('[data-bv-ended]')?.getAttribute('data-bv-ended') === 'true' };
|
|
369
|
+
}, nodeId);
|
|
370
|
+
if (state.ended || (state.nodeId && state.nodeId !== nodeId))
|
|
371
|
+
return;
|
|
372
|
+
if (state.step != null)
|
|
373
|
+
observedAvg = true;
|
|
374
|
+
if (state.completed)
|
|
375
|
+
return;
|
|
376
|
+
const next = frame.getByRole('button', { name: '下一句', exact: true });
|
|
377
|
+
if (state.step != null && state.step !== clickedStep && await next.isVisible()) {
|
|
378
|
+
const remaining = deadline - Date.now();
|
|
379
|
+
if (remaining <= 0)
|
|
380
|
+
break;
|
|
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
|
+
}
|
|
389
|
+
clickedStep = state.step;
|
|
390
|
+
}
|
|
391
|
+
const remaining = deadline - Date.now();
|
|
392
|
+
if (remaining > 0)
|
|
393
|
+
await frame.page().waitForTimeout(Math.min(100, remaining));
|
|
394
|
+
}
|
|
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 });
|
|
247
396
|
}
|
|
248
|
-
async function executeNativeInteraction(frame, node, edge, contract) {
|
|
397
|
+
async function executeNativeInteraction(frame, node, edge, contract, deadline, media) {
|
|
249
398
|
const interaction = (node.interactions || []).find((candidate) => candidate.id === edge.ownerId);
|
|
250
399
|
if (!interaction)
|
|
251
400
|
throw new PlaytestError('INTERACTION_NOT_RENDERED', `Interaction ${edge.ownerId} is absent from node ${edge.from}`);
|
|
252
|
-
if (node.type === '
|
|
401
|
+
if (node.type === 'avg' && interaction.trigger?.type === 'complete') {
|
|
402
|
+
await completeAvgPlayback(frame, edge.from, deadline);
|
|
403
|
+
}
|
|
404
|
+
else if (node.type === 'web' && interaction.trigger?.type === 'message') {
|
|
253
405
|
await executeWebEdge(frame, node, {
|
|
254
406
|
...edge,
|
|
255
407
|
owner: 'node',
|
|
256
408
|
ownerId: node.id,
|
|
257
409
|
trigger: { type: 'message', value: interaction.trigger.value },
|
|
258
|
-
}, contract);
|
|
410
|
+
}, contract, deadline);
|
|
259
411
|
}
|
|
260
412
|
else if (interaction.trigger?.type === 'time' && node.type === 'video') {
|
|
261
|
-
await
|
|
262
|
-
element.currentTime = time + 0.05;
|
|
263
|
-
element.dispatchEvent(new globalThis.Event('timeupdate', { bubbles: true }));
|
|
264
|
-
}, Number(interaction.trigger.value || 0));
|
|
413
|
+
await driveVerifiedVideo(media, 'time', Number(interaction.trigger.value || 0));
|
|
265
414
|
}
|
|
266
415
|
else if (interaction.trigger?.type === 'time' && Number(interaction.trigger.value || 0) > 0) {
|
|
267
416
|
await frame.page().waitForTimeout(Number(interaction.trigger.value) * 1000 + 50);
|
|
268
417
|
}
|
|
269
418
|
else if (interaction.trigger?.type === 'complete' && node.type === 'video') {
|
|
270
|
-
await
|
|
271
|
-
element.dispatchEvent(new globalThis.Event('ended', { bubbles: true }));
|
|
272
|
-
});
|
|
419
|
+
await driveVerifiedVideo(media, 'complete-interaction');
|
|
273
420
|
}
|
|
274
|
-
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) });
|
|
275
422
|
if (edge.trigger.type === 'timeout') {
|
|
276
423
|
await frame.page().waitForTimeout((Number(interaction.timeLimit || 0) + 2.5) * 1000);
|
|
277
424
|
return;
|
|
278
425
|
}
|
|
279
426
|
const interactionType = String(interaction.interactive?.type || '');
|
|
280
|
-
const resultValue = edge.trigger.type === 'result'
|
|
281
|
-
? String(edge.trigger.value)
|
|
282
|
-
: edge.trigger.type === 'legacy'
|
|
283
|
-
? String(edge.trigger.value) === 'true'
|
|
284
|
-
? 'correct'
|
|
285
|
-
: String(edge.trigger.value) === 'partial'
|
|
286
|
-
? 'partial'
|
|
287
|
-
: 'incorrect'
|
|
288
|
-
: 'incorrect';
|
|
289
427
|
if (interactionType === 'score_settlement') {
|
|
290
|
-
await
|
|
428
|
+
await executeScoreSettlement(frame, interaction, edge, deadline);
|
|
291
429
|
return;
|
|
292
430
|
}
|
|
431
|
+
const legacy = edge.trigger.type === 'legacy' ? String(edge.trigger.value) : undefined;
|
|
432
|
+
const exactOption = legacy === undefined ? undefined : interaction.interactive?.options?.find((option) => String(option.id) === legacy);
|
|
433
|
+
const legacyResult = new Map([['true', 'correct'], ['correct', 'correct'], ['partial', 'partial'], ['false', 'incorrect'], ['incorrect', 'incorrect'], ['wrong', 'incorrect']]).get(legacy || '');
|
|
434
|
+
if (legacy !== undefined && !exactOption && !legacyResult
|
|
435
|
+
&& !(legacy === 'all_clicked' && interaction.interactive?.collectMode?.mode === 'all')) {
|
|
436
|
+
throw new PlaytestError('INTERACTION_DRIVER_UNSUPPORTED', `Unknown legacy interaction trigger: ${legacy}`);
|
|
437
|
+
}
|
|
438
|
+
if (exactOption && ['logic_chain', 'blank_fill', 'input_fill'].includes(interactionType)) {
|
|
439
|
+
throw new PlaytestError('INTERACTION_DRIVER_UNSUPPORTED', `Exact option trigger is not a result for ${interactionType}`);
|
|
440
|
+
}
|
|
441
|
+
const resultValue = edge.trigger.type === 'result'
|
|
442
|
+
? String(edge.trigger.value)
|
|
443
|
+
: legacyResult || 'incorrect';
|
|
293
444
|
if (interactionType === 'logic_chain') {
|
|
294
445
|
const ids = (interaction.interactive?.chainOptions || []).map((option) => String(option.id));
|
|
295
446
|
const orderedIds = resultValue === 'correct' ? ids : [...ids].reverse();
|
|
296
447
|
for (const optionId of orderedIds) {
|
|
297
|
-
await frame.locator(`[data-bv-option-id="${optionId}"]`).first().click();
|
|
448
|
+
await frame.locator(`[data-bv-option-id="${optionId}"]`).first().click({ timeout: remainingBudget(deadline) });
|
|
298
449
|
}
|
|
299
|
-
await clickAction(frame, 'submit');
|
|
450
|
+
await clickAction(frame, 'submit', deadline);
|
|
300
451
|
return;
|
|
301
452
|
}
|
|
302
453
|
if (interactionType === 'blank_fill') {
|
|
@@ -307,9 +458,9 @@ async function executeNativeInteraction(frame, node, edge, contract) {
|
|
|
307
458
|
const correctId = String(blank.correctOptionId || '');
|
|
308
459
|
const wrongId = String(options.find((option) => String(option.id) !== correctId)?.id || correctId);
|
|
309
460
|
const optionId = resultValue === 'incorrect' || (resultValue === 'partial' && index > 0) ? wrongId : correctId;
|
|
310
|
-
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) });
|
|
311
462
|
}
|
|
312
|
-
await clickAction(frame, 'submit');
|
|
463
|
+
await clickAction(frame, 'submit', deadline);
|
|
313
464
|
return;
|
|
314
465
|
}
|
|
315
466
|
if (interactionType === 'input_fill') {
|
|
@@ -320,15 +471,15 @@ async function executeNativeInteraction(frame, node, edge, contract) {
|
|
|
320
471
|
const answer = shouldBeWrong ? '__wrong__' : (blank.correctAnswers?.[0] ?? 'playtest');
|
|
321
472
|
const control = frame.locator(`[data-bv-blank-id="${blank.id}"]`);
|
|
322
473
|
if (blank.type === 'number') {
|
|
323
|
-
await control.click();
|
|
474
|
+
await control.click({ timeout: remainingBudget(deadline) });
|
|
324
475
|
for (const key of String(answer))
|
|
325
|
-
await control.press(key);
|
|
476
|
+
await control.press(key, { timeout: remainingBudget(deadline) });
|
|
326
477
|
}
|
|
327
478
|
else {
|
|
328
|
-
await control.fill(String(answer));
|
|
479
|
+
await control.fill(String(answer), { timeout: remainingBudget(deadline) });
|
|
329
480
|
}
|
|
330
481
|
}
|
|
331
|
-
await clickAction(frame, 'submit');
|
|
482
|
+
await clickAction(frame, 'submit', deadline);
|
|
332
483
|
return;
|
|
333
484
|
}
|
|
334
485
|
let optionIds = [];
|
|
@@ -339,12 +490,10 @@ async function executeNativeInteraction(frame, node, edge, contract) {
|
|
|
339
490
|
else if (edge.trigger.type === 'result')
|
|
340
491
|
optionIds = chooseResultOption(interaction, String(edge.trigger.value));
|
|
341
492
|
else if (edge.trigger.type === 'legacy') {
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
? chooseResultOption(interaction, 'partial')
|
|
347
|
-
: chooseResultOption(interaction, 'incorrect');
|
|
493
|
+
if (exactOption)
|
|
494
|
+
optionIds = [String(exactOption.id)];
|
|
495
|
+
else if (legacyResult)
|
|
496
|
+
optionIds = chooseResultOption(interaction, legacyResult);
|
|
348
497
|
}
|
|
349
498
|
if (interaction.interactive?.collectMode?.mode === 'all') {
|
|
350
499
|
optionIds = (interaction.interactive?.options || []).map((option) => String(option.id));
|
|
@@ -353,17 +502,17 @@ async function executeNativeInteraction(frame, node, edge, contract) {
|
|
|
353
502
|
for (const optionId of optionIds) {
|
|
354
503
|
const option = frame.locator(`[data-bv-option-id="${optionId}"]`);
|
|
355
504
|
if (await option.count())
|
|
356
|
-
await option.first().click();
|
|
505
|
+
await option.first().click({ timeout: remainingBudget(deadline) });
|
|
357
506
|
else {
|
|
358
507
|
const optionText = interaction.interactive?.options?.find((candidate) => String(candidate.id) === optionId)?.text;
|
|
359
508
|
if (!optionText)
|
|
360
509
|
throw new PlaytestError('INTERACTION_DRIVER_UNSUPPORTED', `No real control for option ${optionId} in interaction ${edge.ownerId}`);
|
|
361
|
-
await frame.getByText(String(optionText), { exact: true }).click();
|
|
510
|
+
await frame.getByText(String(optionText), { exact: true }).click({ timeout: remainingBudget(deadline) });
|
|
362
511
|
}
|
|
363
512
|
}
|
|
364
513
|
const submit = frame.locator('[data-bv-action="submit"]');
|
|
365
514
|
if (await submit.isVisible().catch(() => false))
|
|
366
|
-
await clickAction(frame, 'submit');
|
|
515
|
+
await clickAction(frame, 'submit', deadline);
|
|
367
516
|
return;
|
|
368
517
|
}
|
|
369
518
|
const inputs = frame.locator(`[data-bv-interaction-id="${edge.ownerId}"] input`);
|
|
@@ -371,62 +520,81 @@ async function executeNativeInteraction(frame, node, edge, contract) {
|
|
|
371
520
|
const blanks = interaction.interactive?.blanks || [];
|
|
372
521
|
for (let index = 0; index < await inputs.count(); index += 1) {
|
|
373
522
|
const answer = blanks[index]?.correctAnswers?.[0] ?? blanks[index]?.answer ?? blanks[index]?.correctAnswer ?? blanks[index]?.answers?.[0] ?? 'playtest';
|
|
374
|
-
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) });
|
|
375
524
|
}
|
|
376
|
-
await clickAction(frame, 'submit');
|
|
525
|
+
await clickAction(frame, 'submit', deadline);
|
|
377
526
|
return;
|
|
378
527
|
}
|
|
379
528
|
throw new PlaytestError('INTERACTION_DRIVER_UNSUPPORTED', `No real DOM driver is available for ${edge.ownerId}:${edge.trigger.type}`);
|
|
380
529
|
}
|
|
381
|
-
async function executeEdge(frame, edge, options) {
|
|
530
|
+
export async function executeEdge(frame, edge, options, deadline) {
|
|
382
531
|
const node = getNode(options.script, edge.from);
|
|
383
|
-
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
|
+
}
|
|
384
542
|
if (edge.owner === 'interaction') {
|
|
385
|
-
await executeNativeInteraction(frame, node, edge, options.contract);
|
|
543
|
+
await executeNativeInteraction(frame, node, edge, options.contract, deadline, media);
|
|
544
|
+
}
|
|
545
|
+
else if (node.type === 'avg' && (edge.trigger.type === 'complete' || edge.default)) {
|
|
546
|
+
await completeAvgPlayback(frame, edge.from, deadline);
|
|
386
547
|
}
|
|
387
548
|
else if (node.type === 'web' && (edge.trigger.type === 'message' || edge.default)) {
|
|
388
|
-
await executeWebEdge(frame, node, edge, options.contract);
|
|
549
|
+
await executeWebEdge(frame, node, edge, options.contract, deadline);
|
|
389
550
|
}
|
|
390
551
|
else if (node.type === 'video' && edge.trigger.type === 'time') {
|
|
391
|
-
await
|
|
392
|
-
element.currentTime = time + 0.05;
|
|
393
|
-
element.dispatchEvent(new globalThis.Event('timeupdate', { bubbles: true }));
|
|
394
|
-
}, Number(edge.trigger.value));
|
|
552
|
+
await driveVerifiedVideo(media, 'time', Number(edge.trigger.value));
|
|
395
553
|
}
|
|
396
554
|
else if (node.type === 'video' && edge.trigger.type === 'complete') {
|
|
397
|
-
await
|
|
398
|
-
if (Number.isFinite(element.duration) && element.duration > 0)
|
|
399
|
-
element.currentTime = Math.max(0, element.duration - 0.01);
|
|
400
|
-
element.dispatchEvent(new globalThis.Event('timeupdate', { bubbles: true }));
|
|
401
|
-
element.dispatchEvent(new globalThis.Event('ended', { bubbles: true }));
|
|
402
|
-
});
|
|
555
|
+
await driveVerifiedVideo(media, 'complete');
|
|
403
556
|
}
|
|
404
557
|
else if (edge.trigger.type === 'enter') {
|
|
405
558
|
// Start/enter transitions are produced by the runtime itself.
|
|
406
559
|
}
|
|
407
560
|
else if (node.type === 'image' && edge.trigger.type === 'complete') {
|
|
408
561
|
const durationMs = (node.config?.slides || []).reduce((sum, slide) => sum + Number(slide.duration || 0) * 1000, 0);
|
|
409
|
-
await frame.page().waitForTimeout(Math.min(durationMs + 250,
|
|
562
|
+
await frame.page().waitForTimeout(Math.min(durationMs + 250, remainingBudget(deadline)));
|
|
410
563
|
}
|
|
411
564
|
else {
|
|
412
565
|
throw new PlaytestError('EDGE_DRIVER_UNSUPPORTED', `No real runtime driver for edge ${edge.id}`);
|
|
413
566
|
}
|
|
414
|
-
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;
|
|
415
572
|
try {
|
|
416
|
-
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) });
|
|
417
583
|
}
|
|
418
584
|
catch {
|
|
419
|
-
throw new PlaytestError('
|
|
585
|
+
throw new PlaytestError('SAME_NODE_EFFECT_NOT_OBSERVED', `No new matching runtime action receipt for ${edge.id}`);
|
|
420
586
|
}
|
|
421
587
|
}
|
|
422
|
-
else if (edge.actionType === '
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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
|
+
}
|
|
427
595
|
}
|
|
428
596
|
else {
|
|
429
|
-
await waitForNode(frame, edge.to,
|
|
597
|
+
await waitForNode(frame, edge.to, remainingBudget(deadline));
|
|
430
598
|
}
|
|
431
599
|
}
|
|
432
600
|
async function createContext(browser, options, scenarioDir) {
|
|
@@ -478,8 +646,23 @@ async function runScenario(browser, scenario, options) {
|
|
|
478
646
|
await context.addInitScript(() => {
|
|
479
647
|
const browserWindow = globalThis;
|
|
480
648
|
browserWindow.__bvPlaytestObservedMessages = [];
|
|
649
|
+
browserWindow.__bvPlaytestStartupExceptions = [];
|
|
650
|
+
const recordStartupException = (reason) => {
|
|
651
|
+
const error = reason instanceof Error ? reason : undefined;
|
|
652
|
+
const message = error?.message || reason?.message || String(reason);
|
|
653
|
+
const stack = error?.stack || reason?.stack;
|
|
654
|
+
browserWindow.__bvPlaytestStartupExceptions.push(stack ? { message, stack } : { message });
|
|
655
|
+
};
|
|
656
|
+
browserWindow.addEventListener('error', (event) => {
|
|
657
|
+
if (event?.error instanceof Error || event?.message)
|
|
658
|
+
recordStartupException(event.error || event.message);
|
|
659
|
+
}, true);
|
|
660
|
+
browserWindow.addEventListener('unhandledrejection', (event) => recordStartupException(event.reason));
|
|
481
661
|
browserWindow.addEventListener('message', (event) => {
|
|
482
|
-
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
|
+
});
|
|
483
666
|
});
|
|
484
667
|
});
|
|
485
668
|
await context.tracing.start({ screenshots: true, snapshots: true, sources: true });
|
|
@@ -493,25 +676,30 @@ async function runScenario(browser, scenario, options) {
|
|
|
493
676
|
}
|
|
494
677
|
let timeoutHandle;
|
|
495
678
|
const scenarioDeadline = Date.now() + options.limits.scenarioTimeoutMs;
|
|
679
|
+
let playerFrame;
|
|
680
|
+
let playerHandshakeComplete = false;
|
|
681
|
+
let activeEdge;
|
|
496
682
|
const scenarioTimeout = new Promise((_, reject) => {
|
|
497
|
-
timeoutHandle = globalThis.setTimeout(() => reject(new PlaytestError('SCENARIO_TIMEOUT', `Scenario ${scenario.id} exceeded ${options.limits.scenarioTimeoutMs}ms
|
|
683
|
+
timeoutHandle = globalThis.setTimeout(() => reject(new PlaytestError('SCENARIO_TIMEOUT', `Scenario ${scenario.id} exceeded ${options.limits.scenarioTimeoutMs}ms`, activeEdge ? { edgeId: activeEdge.id, nodeId: activeEdge.from, nodeType: getNode(options.script, activeEdge.from).type } : undefined)), options.limits.scenarioTimeoutMs);
|
|
498
684
|
});
|
|
499
685
|
try {
|
|
500
686
|
await Promise.race([(async () => {
|
|
501
687
|
await page.setContent('<!doctype html><html><body style="margin:0"><iframe title="BV playtest player" style="border:0;width:1280px;height:720px"></iframe></body></html>');
|
|
502
688
|
await page.locator('iframe[title="BV playtest player"]').evaluate((element, url) => { element.src = url; }, options.playerUrl);
|
|
503
|
-
|
|
504
|
-
await assertPlayerProtocol(playerFrame,
|
|
689
|
+
playerFrame = await waitForPlayerFrame(page, options.playerUrl, options.limits.scenarioTimeoutMs);
|
|
690
|
+
await assertPlayerProtocol(playerFrame, scenarioDeadline - Date.now());
|
|
691
|
+
playerHandshakeComplete = true;
|
|
505
692
|
const entryNodeId = String(options.script.graph?.entryNodeId || '');
|
|
506
693
|
const entryTimeout = scenarioDeadline - Date.now();
|
|
507
694
|
if (entryTimeout <= 0)
|
|
508
695
|
throw new PlaytestError('SCENARIO_TIMEOUT', `Scenario ${scenario.id} exceeded ${options.limits.scenarioTimeoutMs}ms`);
|
|
509
696
|
await startPlayback(playerFrame, entryNodeId, String(getNode(options.script, entryNodeId).type || ''), entryTimeout);
|
|
510
697
|
playerReady = true;
|
|
511
|
-
await assertNodeMedia(playerFrame, getNode(options.script, entryNodeId));
|
|
698
|
+
await assertNodeMedia(playerFrame, getNode(options.script, entryNodeId), scenarioDeadline);
|
|
512
699
|
visitedNodes.push(String(options.script.graph?.entryNodeId || ''));
|
|
513
700
|
for (const edge of scenario.edges) {
|
|
514
|
-
|
|
701
|
+
activeEdge = edge;
|
|
702
|
+
await executeEdge(playerFrame, edge, options, scenarioDeadline);
|
|
515
703
|
coveredEdges.push(edge.id);
|
|
516
704
|
if (!edge.to.startsWith('@'))
|
|
517
705
|
visitedNodes.push(edge.to);
|
|
@@ -519,8 +707,16 @@ async function runScenario(browser, scenario, options) {
|
|
|
519
707
|
})(), scenarioTimeout]);
|
|
520
708
|
}
|
|
521
709
|
catch (error) {
|
|
522
|
-
|
|
523
|
-
|
|
710
|
+
let failure = error instanceof PlaytestError ? error : new PlaytestError('SCENARIO_FAILED', error instanceof Error ? error.message : String(error));
|
|
711
|
+
if (failure.code === 'SCENARIO_TIMEOUT' && playerFrame && !playerHandshakeComplete) {
|
|
712
|
+
const exceptions = await playerStartupExceptions(playerFrame);
|
|
713
|
+
if (exceptions.length)
|
|
714
|
+
failure = new PlaytestError('PLAYER_INIT_FAILED', 'Player raised an exception before the playtest handshake', { startupExceptions: exceptions });
|
|
715
|
+
else
|
|
716
|
+
failure = new PlaytestError('PLAYER_HANDSHAKE_TIMEOUT', 'Player did not expose the BV playtest protocol within the scenario budget');
|
|
717
|
+
}
|
|
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 });
|
|
524
720
|
const screenshotPath = path.join(scenarioDir, 'failure.png');
|
|
525
721
|
await page.screenshot({ path: screenshotPath, fullPage: true }).catch(() => undefined);
|
|
526
722
|
artifacts.screenshot = screenshotPath;
|
|
@@ -19,6 +19,10 @@ function compare(actual, op, expected) {
|
|
|
19
19
|
function conditionMatches(condition, variables, inventory) {
|
|
20
20
|
if (!condition)
|
|
21
21
|
return true;
|
|
22
|
+
if (condition.type === 'settlement_score') {
|
|
23
|
+
const score = Number(variables[condition.variable] ?? 0);
|
|
24
|
+
return compare(Number.isFinite(score) ? score : 0, condition.op, condition.value);
|
|
25
|
+
}
|
|
22
26
|
if (condition.type === 'variable' || condition.variable)
|
|
23
27
|
return compare(variables[condition.variable], condition.op, condition.value);
|
|
24
28
|
if (condition.type === 'inventory') {
|
|
@@ -42,6 +46,9 @@ function conditionMatches(condition, variables, inventory) {
|
|
|
42
46
|
function applyEffects(state, edge, targetNode) {
|
|
43
47
|
const variables = { ...state.variables };
|
|
44
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;
|
|
45
52
|
const variableActions = [...(edge.variableActions || []), ...(targetNode?.variableActions || [])];
|
|
46
53
|
for (const action of variableActions) {
|
|
47
54
|
const current = variables[action.variable];
|
package/dist/playtest/types.d.ts
CHANGED