@mindscraft/branch-video-agent-cli 0.4.4 → 0.4.6
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 +22 -6
- package/dist/commands/aiGateway.js +67 -2
- package/dist/commands/playtest.js +12 -3
- package/dist/commands/project.js +5 -0
- package/dist/lib/flags.d.ts +1 -0
- package/dist/lib/flags.js +6 -2
- package/dist/lib/http.d.ts +1 -0
- package/dist/lib/http.js +1 -0
- package/dist/playtest/contract.js +13 -7
- package/dist/playtest/graph.d.ts +1 -0
- package/dist/playtest/graph.js +99 -9
- package/dist/playtest/report.js +4 -2
- package/dist/playtest/runner.d.ts +1 -0
- package/dist/playtest/runner.js +138 -28
- package/dist/playtest/scenarios.js +4 -0
- package/dist/playtest/types.d.ts +2 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,6 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
`@mindscraft/branch-video-agent-cli` 是一个可脱离当前代码仓库使用的发布版 CLI,用于通过 Bearer agent token 调用 branch-video、AIHub workflow 与 AI Gateway agent API。
|
|
4
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
|
+
|
|
5
19
|
## 安装与执行
|
|
6
20
|
|
|
7
21
|
推荐显式指定包和可执行名:
|
|
@@ -53,7 +67,9 @@ AI Gateway 生产媒体验收使用 `ai-gateway media-understand --raw`。P0 模
|
|
|
53
67
|
|
|
54
68
|
场景规划保留较长路线,删除其完整前缀场景;沿途动作、条件、状态效果和检查仍随长路线执行。`maxScenarios` 限制去重后的场景数;无法在步骤、访问次数或搜索预算内到达的边仍会使规划失败。
|
|
55
69
|
|
|
56
|
-
搜索展开上限的数值不变;可安全去重的相同状态不重复计入,其他状态沿原搜索计数。
|
|
70
|
+
搜索展开上限的数值不变;可安全去重的相同状态不重复计入,其他状态沿原搜索计数。
|
|
71
|
+
|
|
72
|
+
实测范围不包含 Web 节点的 `branchConfig.defaultAction` 兜底分支,也不要求为仅落入该兜底的消息提供操作配方。业务脚本和兜底行为保持不变;显式消息分支(包括名为 `default` 的消息)、答对、答错及适用的超时仍须真实操作。仅通过豁免分支到达的节点不在本轮覆盖目标内;仍有正常路径可达的节点继续测试。JSON 报告以 `WEB_DEFAULT_NOT_TESTED` 逐边记录,JUnit 标为 `skipped`,不计作覆盖或通过;报告 `passed` 仅表示本轮要求实测的范围通过。该豁免不声称兜底不可达,也不影响其他类型的 default 校验。
|
|
57
73
|
|
|
58
74
|
```powershell
|
|
59
75
|
@'
|
|
@@ -94,11 +110,11 @@ sidecar 使用 `branch-video-playtest/1`,Web 结果必须提供 iframe 内的
|
|
|
94
110
|
|
|
95
111
|
支持 `click`、`fill`、`selectOption`、`check`、`press`、`dragTo`、`setInputFiles` 和 `waitFor`。只要输入中有合法 `reportDir`,静态门禁、契约、版本漂移和浏览器启动等前置失败也会输出 `branch-video-playtest-report.json`、`junit.xml`;失败场景另存截图、trace、视频和浏览器日志。若 Chromium 缺失,执行 `npx playwright install chromium`。
|
|
96
112
|
|
|
97
|
-
CI 顺序为:V3 静态 self-test → 草稿 `playtest run` → 外部发布步骤 → `version get` 精确内容读回。发布内容与已测草稿一致时无需再跑发布版全量测试;内容变化后应重新测试。`playtest run` 自身始终只读,不承担发布。
|
|
98
|
-
|
|
99
|
-
从 `0.4.3` 起,规划器删除被更长场景完整包含的有序前缀场景,保留条件、状态效果与全部目标边;场景数由实际图决定,不固定为 96。重复搜索状态也会合并以减少规划开销。覆盖或路径预算失败仍返回失败,不通过删边取得通过。复测时记录实际可执行文件路径;升级全局 npm 包不会更新任务内复制的旧 CLI。
|
|
100
|
-
|
|
101
|
-
从 `0.4.4` 起,runner 在现有超时内等待“进入游戏”或“开始体验”入口;已开始播放时直接继续,后续节点和路由断言保持不变。
|
|
113
|
+
CI 顺序为:V3 静态 self-test → 草稿 `playtest run` → 外部发布步骤 → `version get` 精确内容读回。发布内容与已测草稿一致时无需再跑发布版全量测试;内容变化后应重新测试。`playtest run` 自身始终只读,不承担发布。
|
|
114
|
+
|
|
115
|
+
从 `0.4.3` 起,规划器删除被更长场景完整包含的有序前缀场景,保留条件、状态效果与全部目标边;场景数由实际图决定,不固定为 96。重复搜索状态也会合并以减少规划开销。覆盖或路径预算失败仍返回失败,不通过删边取得通过。复测时记录实际可执行文件路径;升级全局 npm 包不会更新任务内复制的旧 CLI。
|
|
116
|
+
|
|
117
|
+
从 `0.4.4` 起,runner 在现有超时内等待“进入游戏”或“开始体验”入口;已开始播放时直接继续,后续节点和路由断言保持不变。
|
|
102
118
|
|
|
103
119
|
AIHub 导入公共库示例:
|
|
104
120
|
|
|
@@ -127,6 +127,58 @@ function normalizeImageEditPayload(payload) {
|
|
|
127
127
|
files,
|
|
128
128
|
};
|
|
129
129
|
}
|
|
130
|
+
const imageTasksPath = '/api/agent/ai-gateway/images/tasks';
|
|
131
|
+
function validateImageWait(context) {
|
|
132
|
+
if (context.flags.wait && [context.flags.timeoutMs, context.flags.pollIntervalMs].some((value) => !Number.isSafeInteger(value) || value <= 0)) {
|
|
133
|
+
throw new CliCommandError('Image wait timeout and poll interval must be positive integer milliseconds', 'VALIDATION_ERROR');
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
async function finishImageTask(context, initial) {
|
|
137
|
+
let task = initial;
|
|
138
|
+
context.runtime.writeStderr(`${JSON.stringify({ event: 'image-task', taskId: task.taskId, idempotencyKey: task.idempotencyKey, status: task.status })}\n`);
|
|
139
|
+
if (!context.flags.wait)
|
|
140
|
+
return { data: task };
|
|
141
|
+
const deadline = context.runtime.now() + context.flags.timeoutMs;
|
|
142
|
+
while (true) {
|
|
143
|
+
if (task.status === 'succeeded') {
|
|
144
|
+
const result = task.result || {};
|
|
145
|
+
return { data: { ...result, data: (result.data || []).map((item) => ({ ...item, url: context.client.resolveUrl(item.url) })),
|
|
146
|
+
taskId: task.taskId, idempotencyKey: task.idempotencyKey, status: task.status } };
|
|
147
|
+
}
|
|
148
|
+
if (task.status === 'failed' || task.status === 'unknown') {
|
|
149
|
+
throw new CliCommandError(task.error?.message || `Image task ${task.status}`, task.status === 'unknown' ? 'RECONCILIATION_REQUIRED' : 'RUN_FAILED', task);
|
|
150
|
+
}
|
|
151
|
+
const remaining = deadline - context.runtime.now();
|
|
152
|
+
if (remaining <= 0)
|
|
153
|
+
return { data: { ...task, waitStopped: 'timeout', message: 'Local wait stopped; query this task to continue. No new submission was made.' } };
|
|
154
|
+
await context.runtime.sleep(Math.min(context.flags.pollIntervalMs, remaining));
|
|
155
|
+
if (context.runtime.now() >= deadline)
|
|
156
|
+
return { data: { ...task, waitStopped: 'timeout', message: 'Local wait stopped; query this task to continue. No new submission was made.' } };
|
|
157
|
+
try {
|
|
158
|
+
task = (await context.client.request(`${imageTasksPath}/${encodeURIComponent(task.taskId)}`, { method: 'GET', timeoutMs: Math.max(1, Math.min(15000, deadline - context.runtime.now())) })).data;
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
return { data: { ...task, waitStopped: context.runtime.now() >= deadline ? 'timeout' : 'query_error', message: 'Local wait stopped; task status is last observed. Recover using taskId.' } };
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
async function submitImage(context, operation) {
|
|
166
|
+
validateImageWait(context);
|
|
167
|
+
const idempotencyKey = getRequiredString(context.payload, 'idempotencyKey');
|
|
168
|
+
if (!/^[A-Za-z0-9._:-]{1,128}$/.test(idempotencyKey))
|
|
169
|
+
throw new CliCommandError('Invalid idempotencyKey', 'VALIDATION_ERROR');
|
|
170
|
+
const payload = omitKeys(context.payload, ['idempotencyKey']);
|
|
171
|
+
context.runtime.writeStderr(`${JSON.stringify({ event: 'image-submit', idempotencyKey, recovery: 'ai-gateway image-get' })}\n`);
|
|
172
|
+
let result;
|
|
173
|
+
try {
|
|
174
|
+
result = await context.client.request(imageTasksPath, { method: 'POST', timeoutMs: 15000,
|
|
175
|
+
body: { idempotencyKey, operation, body: operation === 'edits' ? normalizeImageEditPayload(payload) : payload } });
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
throw new CliCommandError('Submission response unavailable or rejected; recover by idempotencyKey before any new generation.', 'IMAGE_SUBMIT_ERROR', { idempotencyKey, status: 'submission_unconfirmed', error: error instanceof Error ? error.message : String(error) });
|
|
179
|
+
}
|
|
180
|
+
return finishImageTask(context, result.data);
|
|
181
|
+
}
|
|
130
182
|
function inferMusicQueryOperation(operation) {
|
|
131
183
|
switch (operation) {
|
|
132
184
|
case 'vocal-removal':
|
|
@@ -197,20 +249,33 @@ export const aiGatewayCommands = {
|
|
|
197
249
|
});
|
|
198
250
|
return { data: result.data, requestId: result.requestId };
|
|
199
251
|
},
|
|
200
|
-
'image-generate': async (
|
|
252
|
+
'image-generate': async (context) => {
|
|
253
|
+
if (context.flags.async || context.flags.wait)
|
|
254
|
+
return submitImage(context, 'generations');
|
|
255
|
+
const { client, payload } = context;
|
|
201
256
|
const result = await client.request('/api/agent/ai-gateway/images/generations', {
|
|
202
257
|
method: 'POST',
|
|
203
258
|
body: payload,
|
|
204
259
|
});
|
|
205
260
|
return { data: result.data, requestId: result.requestId };
|
|
206
261
|
},
|
|
207
|
-
'image-edit': async (
|
|
262
|
+
'image-edit': async (context) => {
|
|
263
|
+
if (context.flags.async || context.flags.wait)
|
|
264
|
+
return submitImage(context, 'edits');
|
|
265
|
+
const { client, payload } = context;
|
|
208
266
|
const result = await client.request('/api/agent/ai-gateway/images/edits', {
|
|
209
267
|
method: 'POST',
|
|
210
268
|
body: normalizeImageEditPayload(payload),
|
|
211
269
|
});
|
|
212
270
|
return { data: result.data, requestId: result.requestId };
|
|
213
271
|
},
|
|
272
|
+
'image-get': async (context) => {
|
|
273
|
+
validateImageWait(context);
|
|
274
|
+
const taskId = getOptionalString(context.payload, 'taskId');
|
|
275
|
+
const idempotencyKey = taskId ? '' : getRequiredString(context.payload, 'idempotencyKey');
|
|
276
|
+
const result = await context.client.request(taskId ? `${imageTasksPath}/${encodeURIComponent(taskId)}` : imageTasksPath, { method: 'GET', query: idempotencyKey ? { idempotencyKey } : undefined, timeoutMs: 15000 });
|
|
277
|
+
return finishImageTask(context, result.data);
|
|
278
|
+
},
|
|
214
279
|
'video-create': async (context) => {
|
|
215
280
|
const result = await context.client.request('/api/agent/ai-gateway/predictions', {
|
|
216
281
|
method: 'POST',
|
|
@@ -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.d.ts
CHANGED
package/dist/lib/flags.js
CHANGED
|
@@ -58,6 +58,9 @@ export function parseCliArgs(argv) {
|
|
|
58
58
|
case '--wait':
|
|
59
59
|
flags.wait = true;
|
|
60
60
|
break;
|
|
61
|
+
case '--async':
|
|
62
|
+
flags.async = true;
|
|
63
|
+
break;
|
|
61
64
|
case '--timeout':
|
|
62
65
|
flags.timeoutMs = Number(readValue(argv, index, arg));
|
|
63
66
|
index += 1;
|
|
@@ -95,12 +98,13 @@ export function getCliHelpText() {
|
|
|
95
98
|
'Groups:',
|
|
96
99
|
' preset list | upsert',
|
|
97
100
|
' workflow run | status | outputs',
|
|
98
|
-
' project list | get | create | import-aihub | update | delete',
|
|
101
|
+
' project list | get | draft-get | create | import-aihub | update | delete',
|
|
99
102
|
' version list | get | publish',
|
|
100
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',
|
|
101
104
|
' aihub-production schema | start | status | retry',
|
|
102
105
|
' character search | create | get',
|
|
103
|
-
' ai-gateway capabilities | request | media-understand | image-generate | image-edit | video-create | video-get | seed-audio-create | seed-audio-get | music-submit | music-get | tts-create | tts-get',
|
|
106
|
+
' ai-gateway capabilities | request | media-understand | image-generate | image-edit | image-get | video-create | video-get | seed-audio-create | seed-audio-get | music-submit | music-get | tts-create | tts-get',
|
|
107
|
+
' Images: --async (or --wait) requires stdin idempotencyKey; image-get accepts taskId or idempotencyKey. Recovery receipts go to stderr; stdout remains one JSON document.',
|
|
104
108
|
' playtest run',
|
|
105
109
|
'',
|
|
106
110
|
'Config precedence:',
|
package/dist/lib/http.d.ts
CHANGED
package/dist/lib/http.js
CHANGED
|
@@ -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,74 @@ 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));
|
|
47
101
|
}
|
|
48
102
|
export function extractPlaytestEdges(script) {
|
|
49
103
|
const graph = script?.graph;
|
|
@@ -53,10 +107,15 @@ export function extractPlaytestEdges(script) {
|
|
|
53
107
|
const node = rawNode || {};
|
|
54
108
|
if (node.type === 'start')
|
|
55
109
|
addEdge(edges, nodeId, 'node', nodeId, { type: 'enter' }, node.config?.next);
|
|
56
|
-
|
|
110
|
+
const avgCompletionInteraction = node.type === 'avg' && (node.interactions || []).some((interaction) => interaction.trigger?.type === 'complete');
|
|
111
|
+
if (node.type === 'video' || node.type === 'image' || (node.type === 'avg' && !node.branchConfig && !avgCompletionInteraction)) {
|
|
57
112
|
addEdge(edges, nodeId, 'node', nodeId, { type: 'complete' }, node.config?.onComplete);
|
|
58
113
|
}
|
|
59
|
-
|
|
114
|
+
// Completion interactions preempt completion routing, not enter/message rules.
|
|
115
|
+
const nodeBranchConfig = avgCompletionInteraction && node.branchConfig
|
|
116
|
+
? { ...node.branchConfig, rules: (node.branchConfig.rules || []).filter((rule) => rule.trigger?.type !== 'complete'), defaultAction: undefined }
|
|
117
|
+
: node.branchConfig;
|
|
118
|
+
addBranchConfigEdges(edges, nodeId, 'node', nodeId, nodeBranchConfig, node.type === 'avg' ? 'complete' : 'default');
|
|
60
119
|
for (const handler of node.config?.messageHandlers || []) {
|
|
61
120
|
addEdge(edges, nodeId, 'node', nodeId, handler.when || { type: 'message' }, handler.then, false, {
|
|
62
121
|
variableActions: handler.variableActions,
|
|
@@ -71,19 +130,45 @@ export function extractPlaytestEdges(script) {
|
|
|
71
130
|
});
|
|
72
131
|
}
|
|
73
132
|
for (const interaction of node.interactions || []) {
|
|
133
|
+
const firstEdge = edges.length;
|
|
134
|
+
const settlement = interaction.interactive?.type === 'score_settlement'
|
|
135
|
+
? settlementConditions(interaction.interactive) : null;
|
|
74
136
|
addBranchConfigEdges(edges, nodeId, 'interaction', interaction.id, interaction.branchConfig);
|
|
75
|
-
for (const legacy of interaction.branches || []) {
|
|
137
|
+
for (const legacy of interaction.branchConfig ? [] : interaction.branches || []) {
|
|
76
138
|
addEdge(edges, nodeId, 'interaction', interaction.id, { type: 'legacy', value: legacy.when }, legacy.then, false, {
|
|
77
|
-
condition: typeof legacy.when === 'object' ? legacy.when : undefined,
|
|
139
|
+
condition: settlement?.get(String(legacy.when).trim().toLowerCase()) || (typeof legacy.when === 'object' ? legacy.when : undefined),
|
|
78
140
|
variableActions: legacy.variableActions,
|
|
79
141
|
inventoryActions: legacy.inventoryActions,
|
|
80
142
|
});
|
|
81
143
|
}
|
|
82
144
|
addEdge(edges, nodeId, 'interaction', interaction.id, { type: 'timeout' }, interaction.fallback?.onTimeout);
|
|
83
|
-
|
|
145
|
+
if (!interaction.branchConfig)
|
|
146
|
+
addEdge(edges, nodeId, 'interaction', interaction.id, { type: 'default' }, interaction.fallback?.onDefault, true);
|
|
147
|
+
for (const edge of edges.slice(firstEdge)) {
|
|
148
|
+
if (edge.default && closedChoiceDefault(interaction)) {
|
|
149
|
+
edge.unreachableReason = 'Button story choice has no skip control and every option has an unconditional legacy branch';
|
|
150
|
+
}
|
|
151
|
+
if (edge.default && !interaction.branchConfig && settlement) {
|
|
152
|
+
const unmatched = [...settlement].filter(([result]) => !(interaction.branches || []).some((branch) => String(branch.when).trim().toLowerCase() === result));
|
|
153
|
+
if (!unmatched.length) {
|
|
154
|
+
edge.unreachableReason = 'Every configured settlement level, including extrema and gap fallback, emits an explicitly handled score result';
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
edge.condition = { type: 'or', conditions: unmatched.map(([, condition]) => condition) };
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (edge.trigger.type === 'timeout' && interaction.interactive?.type === 'score_settlement'
|
|
161
|
+
&& !(Number(interaction.timeLimit) > 0)) {
|
|
162
|
+
edge.unreachableReason = 'Settlement has no active interaction timer';
|
|
163
|
+
}
|
|
164
|
+
}
|
|
84
165
|
}
|
|
85
166
|
}
|
|
86
167
|
for (const edge of edges) {
|
|
168
|
+
// Test-scope policy, not a claim that the business fallback is unreachable.
|
|
169
|
+
if (nodes[edge.from]?.type === 'web' && edge.owner === 'node' && edge.default) {
|
|
170
|
+
edge.skippedReason = 'Web default fallback is excluded from real-action testing by approved test scope; not tested';
|
|
171
|
+
}
|
|
87
172
|
if (edge.actionType !== 'restart')
|
|
88
173
|
continue;
|
|
89
174
|
const oldTarget = edge.to;
|
|
@@ -99,6 +184,7 @@ export function getReachablePlaytestGraph(script) {
|
|
|
99
184
|
const allEdges = extractPlaytestEdges(script);
|
|
100
185
|
const reachableNodeIds = new Set();
|
|
101
186
|
const reachableEdges = [];
|
|
187
|
+
const excludedEdges = [];
|
|
102
188
|
const queue = entryNodeId ? [entryNodeId] : [];
|
|
103
189
|
while (queue.length) {
|
|
104
190
|
const nodeId = queue.shift();
|
|
@@ -106,10 +192,14 @@ export function getReachablePlaytestGraph(script) {
|
|
|
106
192
|
continue;
|
|
107
193
|
reachableNodeIds.add(nodeId);
|
|
108
194
|
for (const edge of allEdges.filter((candidate) => candidate.from === nodeId)) {
|
|
195
|
+
if (edge.unreachableReason || edge.skippedReason) {
|
|
196
|
+
excludedEdges.push(edge);
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
109
199
|
reachableEdges.push(edge);
|
|
110
200
|
if (nodes[edge.to] && !reachableNodeIds.has(edge.to))
|
|
111
201
|
queue.push(edge.to);
|
|
112
202
|
}
|
|
113
203
|
}
|
|
114
|
-
return { entryNodeId, reachableNodeIds: [...reachableNodeIds], reachableEdges };
|
|
204
|
+
return { entryNodeId, reachableNodeIds: [...reachableNodeIds], reachableEdges, excludedEdges };
|
|
115
205
|
}
|
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 });
|
|
@@ -16,6 +16,7 @@ interface RunnerOptions {
|
|
|
16
16
|
};
|
|
17
17
|
}
|
|
18
18
|
export declare function startPlayback(frame: Frame, entryNodeId: string, entryNodeType: string, timeout: number): Promise<void>;
|
|
19
|
+
export declare function completeAvgPlayback(frame: Frame, nodeId: string, deadline: number): Promise<void>;
|
|
19
20
|
export declare function runPlaytestScenarios(options: RunnerOptions): Promise<PlaytestScenarioResult[]>;
|
|
20
21
|
export declare function createDraftPlayerUrl(baseUrl: string): {
|
|
21
22
|
playerUrl: string;
|
package/dist/playtest/runner.js
CHANGED
|
@@ -86,20 +86,36 @@ async function waitForPlayerFrame(page, playerUrl, timeout) {
|
|
|
86
86
|
}
|
|
87
87
|
throw new PlaytestError('PLAYER_LOAD_FAILED', `Player iframe did not load ${playerUrl}`);
|
|
88
88
|
}
|
|
89
|
+
async function playerStartupExceptions(frame) {
|
|
90
|
+
return frame.evaluate(() => {
|
|
91
|
+
const exceptions = globalThis.__bvPlaytestStartupExceptions;
|
|
92
|
+
return Array.isArray(exceptions) ? exceptions : [];
|
|
93
|
+
}).catch(() => []);
|
|
94
|
+
}
|
|
95
|
+
async function throwForPlayerStartupException(frame) {
|
|
96
|
+
const exceptions = await playerStartupExceptions(frame);
|
|
97
|
+
if (exceptions.length) {
|
|
98
|
+
throw new PlaytestError('PLAYER_INIT_FAILED', 'Player raised an exception before the playtest handshake', { startupExceptions: exceptions });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
89
101
|
async function assertPlayerProtocol(frame, timeout) {
|
|
90
102
|
const root = frame.locator('[data-bv-playtest-protocol]').first();
|
|
91
103
|
try {
|
|
92
|
-
|
|
104
|
+
if (timeout <= 0)
|
|
105
|
+
throw new Error('Scenario deadline elapsed');
|
|
106
|
+
await root.waitFor({ state: 'attached', timeout });
|
|
93
107
|
}
|
|
94
108
|
catch {
|
|
95
|
-
|
|
109
|
+
await throwForPlayerStartupException(frame);
|
|
110
|
+
throw new PlaytestError('PLAYER_HANDSHAKE_TIMEOUT', 'Player did not expose the BV playtest protocol within the scenario budget');
|
|
96
111
|
}
|
|
97
112
|
const protocol = await root.getAttribute('data-bv-playtest-protocol');
|
|
98
113
|
if (protocol !== '1') {
|
|
99
114
|
throw new PlaytestError('PLAYER_PLAYTEST_PROTOCOL_UNSUPPORTED', `Unsupported BV playtest protocol: ${protocol || 'missing'}`, { protocol });
|
|
100
115
|
}
|
|
101
116
|
if (await root.getAttribute('data-bv-player-state') === 'error') {
|
|
102
|
-
|
|
117
|
+
const exceptions = await playerStartupExceptions(frame);
|
|
118
|
+
throw new PlaytestError('PLAYER_INIT_FAILED', 'Player entered the error state before playback started', exceptions.length ? { startupExceptions: exceptions } : undefined);
|
|
103
119
|
}
|
|
104
120
|
}
|
|
105
121
|
async function waitForNode(frame, nodeId, timeout) {
|
|
@@ -245,11 +261,76 @@ async function clickAction(frame, action) {
|
|
|
245
261
|
else
|
|
246
262
|
await control.click();
|
|
247
263
|
}
|
|
248
|
-
|
|
264
|
+
function normalizeSettlementResult(value) {
|
|
265
|
+
return String(value ?? '').trim().toLowerCase();
|
|
266
|
+
}
|
|
267
|
+
async function executeScoreSettlement(frame, interaction, edge) {
|
|
268
|
+
const target = (edge.trigger.type === 'legacy' || edge.trigger.type === 'result')
|
|
269
|
+
? normalizeSettlementResult(edge.trigger.value) : '';
|
|
270
|
+
if (!edge.default && !['score_s', 'score_a', 'score_b', 'default'].includes(target)) {
|
|
271
|
+
throw new PlaytestError('INTERACTION_DRIVER_UNSUPPORTED', `Unsupported score settlement target: ${edge.trigger.type}:${String(edge.trigger.value || '')}`);
|
|
272
|
+
}
|
|
273
|
+
const actual = normalizeSettlementResult(await frame.locator('[data-bv-settlement-result]').getAttribute('data-bv-settlement-result'));
|
|
274
|
+
if (!actual)
|
|
275
|
+
throw new PlaytestError('SETTLEMENT_RESULT_MISSING', `Score settlement did not expose a result for ${target}`);
|
|
276
|
+
if (!['score_s', 'score_a', 'score_b', 'default'].includes(actual)) {
|
|
277
|
+
throw new PlaytestError('INTERACTION_DRIVER_UNSUPPORTED', `Unsupported score settlement result: ${actual}`);
|
|
278
|
+
}
|
|
279
|
+
if (edge.default) {
|
|
280
|
+
if (interaction.branchConfig) {
|
|
281
|
+
throw new PlaytestError('INTERACTION_DRIVER_UNSUPPORTED', 'Score settlement default does not support explicit branchConfig');
|
|
282
|
+
}
|
|
283
|
+
const matchedLegacy = (interaction.branches || []).some((branch) => (typeof branch?.when === 'string' && normalizeSettlementResult(branch.when) === actual));
|
|
284
|
+
if (matchedLegacy) {
|
|
285
|
+
throw new PlaytestError('SETTLEMENT_DEFAULT_NOT_REACHED', `Score settlement result ${actual} has an explicit branch`);
|
|
286
|
+
}
|
|
287
|
+
await clickAction(frame, 'confirm-settlement');
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
if (actual !== target)
|
|
291
|
+
throw new PlaytestError('SETTLEMENT_RESULT_MISMATCH', `Score settlement result ${actual} does not match ${target}`, { actual, target });
|
|
292
|
+
await clickAction(frame, 'confirm-settlement');
|
|
293
|
+
}
|
|
294
|
+
export async function completeAvgPlayback(frame, nodeId, deadline) {
|
|
295
|
+
if (Date.now() >= deadline)
|
|
296
|
+
throw new PlaytestError('AVG_COMPLETION_TIMEOUT', `No scenario budget remains for AVG ${nodeId}`);
|
|
297
|
+
let clickedStep;
|
|
298
|
+
let observedAvg = false;
|
|
299
|
+
while (Date.now() < deadline) {
|
|
300
|
+
const state = await frame.evaluate((id) => {
|
|
301
|
+
const document = globalThis.document;
|
|
302
|
+
const current = document.querySelector('[data-bv-node-id]')?.getAttribute('data-bv-node-id');
|
|
303
|
+
const avg = Array.from(document.querySelectorAll('[data-bv-avg-node-id]')).find((element) => element.getAttribute('data-bv-avg-node-id') === id);
|
|
304
|
+
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' };
|
|
305
|
+
}, nodeId);
|
|
306
|
+
if (state.ended || (state.nodeId && state.nodeId !== nodeId))
|
|
307
|
+
return;
|
|
308
|
+
if (state.step != null)
|
|
309
|
+
observedAvg = true;
|
|
310
|
+
if (state.completed)
|
|
311
|
+
return;
|
|
312
|
+
const next = frame.getByRole('button', { name: '下一句', exact: true });
|
|
313
|
+
if (state.step != null && state.step !== clickedStep && await next.isVisible()) {
|
|
314
|
+
const remaining = deadline - Date.now();
|
|
315
|
+
if (remaining <= 0)
|
|
316
|
+
break;
|
|
317
|
+
await next.click({ timeout: remaining });
|
|
318
|
+
clickedStep = state.step;
|
|
319
|
+
}
|
|
320
|
+
const remaining = deadline - Date.now();
|
|
321
|
+
if (remaining > 0)
|
|
322
|
+
await frame.page().waitForTimeout(Math.min(100, remaining));
|
|
323
|
+
}
|
|
324
|
+
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
|
+
}
|
|
326
|
+
async function executeNativeInteraction(frame, node, edge, contract, deadline) {
|
|
249
327
|
const interaction = (node.interactions || []).find((candidate) => candidate.id === edge.ownerId);
|
|
250
328
|
if (!interaction)
|
|
251
329
|
throw new PlaytestError('INTERACTION_NOT_RENDERED', `Interaction ${edge.ownerId} is absent from node ${edge.from}`);
|
|
252
|
-
if (node.type === '
|
|
330
|
+
if (node.type === 'avg' && interaction.trigger?.type === 'complete') {
|
|
331
|
+
await completeAvgPlayback(frame, edge.from, deadline);
|
|
332
|
+
}
|
|
333
|
+
else if (node.type === 'web' && interaction.trigger?.type === 'message') {
|
|
253
334
|
await executeWebEdge(frame, node, {
|
|
254
335
|
...edge,
|
|
255
336
|
owner: 'node',
|
|
@@ -277,19 +358,23 @@ async function executeNativeInteraction(frame, node, edge, contract) {
|
|
|
277
358
|
return;
|
|
278
359
|
}
|
|
279
360
|
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
361
|
if (interactionType === 'score_settlement') {
|
|
290
|
-
await
|
|
362
|
+
await executeScoreSettlement(frame, interaction, edge);
|
|
291
363
|
return;
|
|
292
364
|
}
|
|
365
|
+
const legacy = edge.trigger.type === 'legacy' ? String(edge.trigger.value) : undefined;
|
|
366
|
+
const exactOption = legacy === undefined ? undefined : interaction.interactive?.options?.find((option) => String(option.id) === legacy);
|
|
367
|
+
const legacyResult = new Map([['true', 'correct'], ['correct', 'correct'], ['partial', 'partial'], ['false', 'incorrect'], ['incorrect', 'incorrect'], ['wrong', 'incorrect']]).get(legacy || '');
|
|
368
|
+
if (legacy !== undefined && !exactOption && !legacyResult
|
|
369
|
+
&& !(legacy === 'all_clicked' && interaction.interactive?.collectMode?.mode === 'all')) {
|
|
370
|
+
throw new PlaytestError('INTERACTION_DRIVER_UNSUPPORTED', `Unknown legacy interaction trigger: ${legacy}`);
|
|
371
|
+
}
|
|
372
|
+
if (exactOption && ['logic_chain', 'blank_fill', 'input_fill'].includes(interactionType)) {
|
|
373
|
+
throw new PlaytestError('INTERACTION_DRIVER_UNSUPPORTED', `Exact option trigger is not a result for ${interactionType}`);
|
|
374
|
+
}
|
|
375
|
+
const resultValue = edge.trigger.type === 'result'
|
|
376
|
+
? String(edge.trigger.value)
|
|
377
|
+
: legacyResult || 'incorrect';
|
|
293
378
|
if (interactionType === 'logic_chain') {
|
|
294
379
|
const ids = (interaction.interactive?.chainOptions || []).map((option) => String(option.id));
|
|
295
380
|
const orderedIds = resultValue === 'correct' ? ids : [...ids].reverse();
|
|
@@ -339,12 +424,10 @@ async function executeNativeInteraction(frame, node, edge, contract) {
|
|
|
339
424
|
else if (edge.trigger.type === 'result')
|
|
340
425
|
optionIds = chooseResultOption(interaction, String(edge.trigger.value));
|
|
341
426
|
else if (edge.trigger.type === 'legacy') {
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
? chooseResultOption(interaction, 'partial')
|
|
347
|
-
: chooseResultOption(interaction, 'incorrect');
|
|
427
|
+
if (exactOption)
|
|
428
|
+
optionIds = [String(exactOption.id)];
|
|
429
|
+
else if (legacyResult)
|
|
430
|
+
optionIds = chooseResultOption(interaction, legacyResult);
|
|
348
431
|
}
|
|
349
432
|
if (interaction.interactive?.collectMode?.mode === 'all') {
|
|
350
433
|
optionIds = (interaction.interactive?.options || []).map((option) => String(option.id));
|
|
@@ -378,11 +461,14 @@ async function executeNativeInteraction(frame, node, edge, contract) {
|
|
|
378
461
|
}
|
|
379
462
|
throw new PlaytestError('INTERACTION_DRIVER_UNSUPPORTED', `No real DOM driver is available for ${edge.ownerId}:${edge.trigger.type}`);
|
|
380
463
|
}
|
|
381
|
-
async function executeEdge(frame, edge, options) {
|
|
464
|
+
async function executeEdge(frame, edge, options, deadline) {
|
|
382
465
|
const node = getNode(options.script, edge.from);
|
|
383
466
|
await assertNodeMedia(frame, node);
|
|
384
467
|
if (edge.owner === 'interaction') {
|
|
385
|
-
await executeNativeInteraction(frame, node, edge, options.contract);
|
|
468
|
+
await executeNativeInteraction(frame, node, edge, options.contract, deadline);
|
|
469
|
+
}
|
|
470
|
+
else if (node.type === 'avg' && (edge.trigger.type === 'complete' || edge.default)) {
|
|
471
|
+
await completeAvgPlayback(frame, edge.from, deadline);
|
|
386
472
|
}
|
|
387
473
|
else if (node.type === 'web' && (edge.trigger.type === 'message' || edge.default)) {
|
|
388
474
|
await executeWebEdge(frame, node, edge, options.contract);
|
|
@@ -478,6 +564,18 @@ async function runScenario(browser, scenario, options) {
|
|
|
478
564
|
await context.addInitScript(() => {
|
|
479
565
|
const browserWindow = globalThis;
|
|
480
566
|
browserWindow.__bvPlaytestObservedMessages = [];
|
|
567
|
+
browserWindow.__bvPlaytestStartupExceptions = [];
|
|
568
|
+
const recordStartupException = (reason) => {
|
|
569
|
+
const error = reason instanceof Error ? reason : undefined;
|
|
570
|
+
const message = error?.message || reason?.message || String(reason);
|
|
571
|
+
const stack = error?.stack || reason?.stack;
|
|
572
|
+
browserWindow.__bvPlaytestStartupExceptions.push(stack ? { message, stack } : { message });
|
|
573
|
+
};
|
|
574
|
+
browserWindow.addEventListener('error', (event) => {
|
|
575
|
+
if (event?.error instanceof Error || event?.message)
|
|
576
|
+
recordStartupException(event.error || event.message);
|
|
577
|
+
}, true);
|
|
578
|
+
browserWindow.addEventListener('unhandledrejection', (event) => recordStartupException(event.reason));
|
|
481
579
|
browserWindow.addEventListener('message', (event) => {
|
|
482
580
|
browserWindow.__bvPlaytestObservedMessages.push({ data: event.data, origin: event.origin, at: Date.now() });
|
|
483
581
|
});
|
|
@@ -493,15 +591,19 @@ async function runScenario(browser, scenario, options) {
|
|
|
493
591
|
}
|
|
494
592
|
let timeoutHandle;
|
|
495
593
|
const scenarioDeadline = Date.now() + options.limits.scenarioTimeoutMs;
|
|
594
|
+
let playerFrame;
|
|
595
|
+
let playerHandshakeComplete = false;
|
|
596
|
+
let activeEdge;
|
|
496
597
|
const scenarioTimeout = new Promise((_, reject) => {
|
|
497
|
-
timeoutHandle = globalThis.setTimeout(() => reject(new PlaytestError('SCENARIO_TIMEOUT', `Scenario ${scenario.id} exceeded ${options.limits.scenarioTimeoutMs}ms
|
|
598
|
+
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
599
|
});
|
|
499
600
|
try {
|
|
500
601
|
await Promise.race([(async () => {
|
|
501
602
|
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
603
|
await page.locator('iframe[title="BV playtest player"]').evaluate((element, url) => { element.src = url; }, options.playerUrl);
|
|
503
|
-
|
|
504
|
-
await assertPlayerProtocol(playerFrame,
|
|
604
|
+
playerFrame = await waitForPlayerFrame(page, options.playerUrl, options.limits.scenarioTimeoutMs);
|
|
605
|
+
await assertPlayerProtocol(playerFrame, scenarioDeadline - Date.now());
|
|
606
|
+
playerHandshakeComplete = true;
|
|
505
607
|
const entryNodeId = String(options.script.graph?.entryNodeId || '');
|
|
506
608
|
const entryTimeout = scenarioDeadline - Date.now();
|
|
507
609
|
if (entryTimeout <= 0)
|
|
@@ -511,7 +613,8 @@ async function runScenario(browser, scenario, options) {
|
|
|
511
613
|
await assertNodeMedia(playerFrame, getNode(options.script, entryNodeId));
|
|
512
614
|
visitedNodes.push(String(options.script.graph?.entryNodeId || ''));
|
|
513
615
|
for (const edge of scenario.edges) {
|
|
514
|
-
|
|
616
|
+
activeEdge = edge;
|
|
617
|
+
await executeEdge(playerFrame, edge, options, scenarioDeadline);
|
|
515
618
|
coveredEdges.push(edge.id);
|
|
516
619
|
if (!edge.to.startsWith('@'))
|
|
517
620
|
visitedNodes.push(edge.to);
|
|
@@ -519,7 +622,14 @@ async function runScenario(browser, scenario, options) {
|
|
|
519
622
|
})(), scenarioTimeout]);
|
|
520
623
|
}
|
|
521
624
|
catch (error) {
|
|
522
|
-
|
|
625
|
+
let failure = error instanceof PlaytestError ? error : new PlaytestError('SCENARIO_FAILED', error instanceof Error ? error.message : String(error));
|
|
626
|
+
if (failure.code === 'SCENARIO_TIMEOUT' && playerFrame && !playerHandshakeComplete) {
|
|
627
|
+
const exceptions = await playerStartupExceptions(playerFrame);
|
|
628
|
+
if (exceptions.length)
|
|
629
|
+
failure = new PlaytestError('PLAYER_INIT_FAILED', 'Player raised an exception before the playtest handshake', { startupExceptions: exceptions });
|
|
630
|
+
else
|
|
631
|
+
failure = new PlaytestError('PLAYER_HANDSHAKE_TIMEOUT', 'Player did not expose the BV playtest protocol within the scenario budget');
|
|
632
|
+
}
|
|
523
633
|
findings.push({ code: failure.code, message: failure.message, scenarioId: scenario.id, edgeId: scenario.targetEdgeId, details: failure.details });
|
|
524
634
|
const screenshotPath = path.join(scenarioDir, 'failure.png');
|
|
525
635
|
await page.screenshot({ path: screenshotPath, fullPage: true }).catch(() => undefined);
|
|
@@ -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') {
|
package/dist/playtest/types.d.ts
CHANGED