@mindscraft/branch-video-agent-cli 0.4.2 → 0.4.4
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 +58 -50
- package/dist/playtest/runner.d.ts +2 -0
- package/dist/playtest/runner.js +23 -7
- package/dist/playtest/scenarios.d.ts +1 -0
- package/dist/playtest/scenarios.js +59 -10
- package/package.json +7 -7
package/README.md
CHANGED
|
@@ -31,9 +31,9 @@ branch-video-agent --version
|
|
|
31
31
|
- `AIHUB_AGENT_TOKEN`
|
|
32
32
|
- `BRANCH_VIDEO_AGENT_BASE_URL`
|
|
33
33
|
|
|
34
|
-
AI Gateway 调用同样只传 `AIHUB_AGENT_TOKEN`;CLI 不读取、不传递 `AiGatewayKey` 或 AIHub `projectKey`,上游鉴权由服务端处理。
|
|
35
|
-
|
|
36
|
-
AI Gateway 生产媒体验收使用 `ai-gateway media-understand --raw`。P0 模型固定为 `doubao-seed-2-0-mini-260215`,输入为带 SHA-256 的公共 HTTPS 图片/视频 URL、一个 candidate、可选 reference、prompt 与 criteria;输出为 `media_acceptance_review.v1`。该能力只判断可见画面,不声称分析视频音轨。`kimi-k3` 和 `MiniMax-M3` 在真实 payload Canary 通过前不可调用。
|
|
34
|
+
AI Gateway 调用同样只传 `AIHUB_AGENT_TOKEN`;CLI 不读取、不传递 `AiGatewayKey` 或 AIHub `projectKey`,上游鉴权由服务端处理。
|
|
35
|
+
|
|
36
|
+
AI Gateway 生产媒体验收使用 `ai-gateway media-understand --raw`。P0 模型固定为 `doubao-seed-2-0-mini-260215`,输入为带 SHA-256 的公共 HTTPS 图片/视频 URL、一个 candidate、可选 reference、prompt 与 criteria;输出为 `media_acceptance_review.v1`。该能力只判断可见画面,不声称分析视频音轨。`kimi-k3` 和 `MiniMax-M3` 在真实 payload Canary 通过前不可调用。
|
|
37
37
|
|
|
38
38
|
最小调用示例:
|
|
39
39
|
|
|
@@ -44,55 +44,63 @@ AI Gateway 生产媒体验收使用 `ai-gateway media-understand --raw`。P0 模
|
|
|
44
44
|
"page": 1,
|
|
45
45
|
"limit": 20
|
|
46
46
|
}
|
|
47
|
-
'@ | npm exec --yes --package=@mindscraft/branch-video-agent-cli branch-video-agent -- project list
|
|
48
|
-
```
|
|
49
|
-
|
|
50
|
-
## BV V3 全分支可播放性测试
|
|
51
|
-
|
|
52
|
-
`playtest run` 从 `0.4.0` 起提供,使用 Playwright Chromium 驱动真实播放器。命令只读草稿或已发布版本,不会保存测试副本或自动发布;运行前仍应先通过 V3 静态 self-test。
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
"
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
47
|
+
'@ | npm exec --yes --package=@mindscraft/branch-video-agent-cli branch-video-agent -- project list
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## BV V3 全分支可播放性测试
|
|
51
|
+
|
|
52
|
+
`playtest run` 从 `0.4.0` 起提供,使用 Playwright Chromium 驱动真实播放器。命令只读草稿或已发布版本,不会保存测试副本或自动发布;运行前仍应先通过 V3 静态 self-test。
|
|
53
|
+
|
|
54
|
+
场景规划保留较长路线,删除其完整前缀场景;沿途动作、条件、状态效果和检查仍随长路线执行。`maxScenarios` 限制去重后的场景数;无法在步骤、访问次数或搜索预算内到达的边仍会使规划失败。
|
|
55
|
+
|
|
56
|
+
搜索展开上限的数值不变;可安全去重的相同状态不重复计入,其他状态沿原搜索计数。
|
|
57
|
+
|
|
58
|
+
```powershell
|
|
59
|
+
@'
|
|
60
|
+
{
|
|
61
|
+
"source": { "kind": "draft", "projectId": "project-id" },
|
|
62
|
+
"contractPath": "project.playtest.json",
|
|
63
|
+
"reportDir": "playtest-results",
|
|
64
|
+
"concurrency": 2,
|
|
65
|
+
"limits": {
|
|
66
|
+
"scenarioTimeoutMs": 60000,
|
|
67
|
+
"maxStepsPerScenario": 100,
|
|
68
|
+
"maxScenarios": 500,
|
|
69
|
+
"maxNodeVisits": 3
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
'@ | branch-video-agent playtest run
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
发布版把 `source` 改为 `{ "kind": "published", "projectId": "...", "version": 3, "playUrl": "<发布接口返回值>" }`。CLI 会先读回精确版本并比较规范化内容摘要,再直接使用 `playUrl`;漂移返回 `PUBLISHED_SCRIPT_MISMATCH`。该 SHA-256 摘要只由 sidecar 绑定、发布读回比较和报告追踪消费,不是身份认证、数字签名或 HMAC。
|
|
76
|
+
|
|
77
|
+
sidecar 使用 `branch-video-playtest/1`,Web 结果必须提供 iframe 内的真实操作,不接受测试侧消息注入:
|
|
78
|
+
|
|
79
|
+
```json
|
|
80
|
+
{
|
|
81
|
+
"schemaVersion": "branch-video-playtest/1",
|
|
82
|
+
"scriptFingerprint": "sha256:...",
|
|
83
|
+
"webResults": [{
|
|
84
|
+
"nodeId": "web_quiz",
|
|
85
|
+
"routeValue": "success",
|
|
86
|
+
"rawMessage": { "eventName": "ActivityData", "value": "1" },
|
|
87
|
+
"steps": [{
|
|
88
|
+
"action": "click",
|
|
89
|
+
"locator": { "by": "role", "role": "button", "name": "正确答案" }
|
|
90
|
+
}]
|
|
91
|
+
}]
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
支持 `click`、`fill`、`selectOption`、`check`、`press`、`dragTo`、`setInputFiles` 和 `waitFor`。只要输入中有合法 `reportDir`,静态门禁、契约、版本漂移和浏览器启动等前置失败也会输出 `branch-video-playtest-report.json`、`junit.xml`;失败场景另存截图、trace、视频和浏览器日志。若 Chromium 缺失,执行 `npx playwright install chromium`。
|
|
96
|
+
|
|
97
|
+
CI 顺序为:V3 静态 self-test → 草稿 `playtest run` → 外部发布步骤 → `version get` 精确内容读回。发布内容与已测草稿一致时无需再跑发布版全量测试;内容变化后应重新测试。`playtest run` 自身始终只读,不承担发布。
|
|
92
98
|
|
|
93
|
-
|
|
99
|
+
从 `0.4.3` 起,规划器删除被更长场景完整包含的有序前缀场景,保留条件、状态效果与全部目标边;场景数由实际图决定,不固定为 96。重复搜索状态也会合并以减少规划开销。覆盖或路径预算失败仍返回失败,不通过删边取得通过。复测时记录实际可执行文件路径;升级全局 npm 包不会更新任务内复制的旧 CLI。
|
|
94
100
|
|
|
95
|
-
|
|
101
|
+
从 `0.4.4` 起,runner 在现有超时内等待“进入游戏”或“开始体验”入口;已开始播放时直接继续,后续节点和路由断言保持不变。
|
|
102
|
+
|
|
103
|
+
AIHub 导入公共库示例:
|
|
96
104
|
|
|
97
105
|
```powershell
|
|
98
106
|
@'
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type Frame } from 'playwright';
|
|
1
2
|
import type { PlaytestContract, PlaytestLimits, PlaytestScenario, PlaytestScenarioResult } from './types.js';
|
|
2
3
|
type ScriptRecord = Record<string, any>;
|
|
3
4
|
interface RunnerOptions {
|
|
@@ -14,6 +15,7 @@ interface RunnerOptions {
|
|
|
14
15
|
height: number;
|
|
15
16
|
};
|
|
16
17
|
}
|
|
18
|
+
export declare function startPlayback(frame: Frame, entryNodeId: string, entryNodeType: string, timeout: number): Promise<void>;
|
|
17
19
|
export declare function runPlaytestScenarios(options: RunnerOptions): Promise<PlaytestScenarioResult[]>;
|
|
18
20
|
export declare function createDraftPlayerUrl(baseUrl: string): {
|
|
19
21
|
playerUrl: string;
|
package/dist/playtest/runner.js
CHANGED
|
@@ -111,12 +111,24 @@ async function waitForNode(frame, nodeId, timeout) {
|
|
|
111
111
|
throw new PlaytestError('ROUTE_TARGET_MISMATCH', `Expected node ${nodeId}, reached ${actual || 'none'}`, { expected: nodeId, actual });
|
|
112
112
|
}
|
|
113
113
|
}
|
|
114
|
-
async function startPlayback(frame, entryNodeId, entryNodeType, timeout) {
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
114
|
+
export async function startPlayback(frame, entryNodeId, entryNodeType, timeout) {
|
|
115
|
+
const deadline = Date.now() + timeout;
|
|
116
|
+
const remaining = () => {
|
|
117
|
+
const value = deadline - Date.now();
|
|
118
|
+
if (value <= 0)
|
|
119
|
+
throw new PlaytestError('SCENARIO_TIMEOUT', 'Scenario timeout elapsed before entry playback completed');
|
|
120
|
+
return value;
|
|
121
|
+
};
|
|
122
|
+
const entryNode = frame.locator('[data-bv-node-id]').first();
|
|
123
|
+
if (!(await entryNode.isVisible().catch(() => false))) {
|
|
124
|
+
const startButton = frame.getByRole('button', { name: /^(开始体验|进入游戏)$/ }).first();
|
|
125
|
+
await startButton.or(entryNode).filter({ visible: true }).first().waitFor({ state: 'visible', timeout: remaining() });
|
|
126
|
+
if (!(await entryNode.isVisible().catch(() => false)) && await startButton.isVisible().catch(() => false)) {
|
|
127
|
+
await startButton.click({ timeout: remaining() });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
await entryNode.waitFor({ state: 'visible', timeout: remaining() });
|
|
131
|
+
const current = await entryNode.getAttribute('data-bv-node-id');
|
|
120
132
|
if (current !== entryNodeId) {
|
|
121
133
|
const observed = await readObservedMessages(frame);
|
|
122
134
|
const sawEntry = observed.some((message) => JSON.stringify(message.data).includes(entryNodeId));
|
|
@@ -480,6 +492,7 @@ async function runScenario(browser, scenario, options) {
|
|
|
480
492
|
}));
|
|
481
493
|
}
|
|
482
494
|
let timeoutHandle;
|
|
495
|
+
const scenarioDeadline = Date.now() + options.limits.scenarioTimeoutMs;
|
|
483
496
|
const scenarioTimeout = new Promise((_, reject) => {
|
|
484
497
|
timeoutHandle = globalThis.setTimeout(() => reject(new PlaytestError('SCENARIO_TIMEOUT', `Scenario ${scenario.id} exceeded ${options.limits.scenarioTimeoutMs}ms`)), options.limits.scenarioTimeoutMs);
|
|
485
498
|
});
|
|
@@ -490,7 +503,10 @@ async function runScenario(browser, scenario, options) {
|
|
|
490
503
|
const playerFrame = await waitForPlayerFrame(page, options.playerUrl, options.limits.scenarioTimeoutMs);
|
|
491
504
|
await assertPlayerProtocol(playerFrame, options.limits.scenarioTimeoutMs);
|
|
492
505
|
const entryNodeId = String(options.script.graph?.entryNodeId || '');
|
|
493
|
-
|
|
506
|
+
const entryTimeout = scenarioDeadline - Date.now();
|
|
507
|
+
if (entryTimeout <= 0)
|
|
508
|
+
throw new PlaytestError('SCENARIO_TIMEOUT', `Scenario ${scenario.id} exceeded ${options.limits.scenarioTimeoutMs}ms`);
|
|
509
|
+
await startPlayback(playerFrame, entryNodeId, String(getNode(options.script, entryNodeId).type || ''), entryTimeout);
|
|
494
510
|
playerReady = true;
|
|
495
511
|
await assertNodeMedia(playerFrame, getNode(options.script, entryNodeId));
|
|
496
512
|
visitedNodes.push(String(options.script.graph?.entryNodeId || ''));
|
|
@@ -1,2 +1,3 @@
|
|
|
1
1
|
import type { PlaytestLimits, PlaytestScenario } from './types.js';
|
|
2
|
+
export declare function removePrefixScenarios(scenarios: PlaytestScenario[]): PlaytestScenario[];
|
|
2
3
|
export declare function buildPlaytestScenarios(script: unknown, limits: PlaytestLimits, nodeVisitLimits?: Record<string, number>): PlaytestScenario[];
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
1
2
|
import { PlaytestError } from './errors.js';
|
|
2
3
|
import { getReachablePlaytestGraph } from './graph.js';
|
|
3
4
|
function compare(actual, op, expected) {
|
|
@@ -64,7 +65,7 @@ function applyEffects(state, edge, targetNode) {
|
|
|
64
65
|
}
|
|
65
66
|
return { variables, inventory };
|
|
66
67
|
}
|
|
67
|
-
function findPathToEdge(entryNodeId, edges, targetEdgeId, limits, nodeVisitLimits, script) {
|
|
68
|
+
function findPathToEdge(entryNodeId, edges, targetEdgeId, limits, nodeVisitLimits, script, future) {
|
|
68
69
|
const definitions = script?.resources?.variables || {};
|
|
69
70
|
const variables = Object.fromEntries(Object.entries(definitions).map(([name, definition]) => [name, definition?.default ?? 0]));
|
|
70
71
|
const initial = {
|
|
@@ -76,11 +77,31 @@ function findPathToEdge(entryNodeId, edges, targetEdgeId, limits, nodeVisitLimit
|
|
|
76
77
|
};
|
|
77
78
|
const entryEffects = applyEffects(initial, { variableActions: [], inventoryActions: [] }, script?.graph?.nodes?.[entryNodeId]);
|
|
78
79
|
const queue = [{ ...initial, ...entryEffects }];
|
|
80
|
+
const seen = new Set();
|
|
81
|
+
let head = 0;
|
|
79
82
|
let examined = 0;
|
|
80
|
-
while (queue.length) {
|
|
81
|
-
const state = queue
|
|
83
|
+
while (head < queue.length) {
|
|
84
|
+
const state = queue[head];
|
|
85
|
+
queue[head++] = undefined;
|
|
82
86
|
if (state.edges.length >= limits.maxStepsPerScenario)
|
|
83
87
|
continue;
|
|
88
|
+
const records = [state.visits, state.variables, state.inventory.items, state.inventory.cards];
|
|
89
|
+
// Non-finite or non-scalar values keep the original search; JSON keys would conflate them.
|
|
90
|
+
const canDeduplicate = records.every((record) => Object.values(record).every((value) => (typeof value === 'string' || typeof value === 'boolean'
|
|
91
|
+
|| (typeof value === 'number' && Number.isFinite(value) && !Object.is(value, -0)))));
|
|
92
|
+
if (canDeduplicate) {
|
|
93
|
+
const relevant = future.get(state.nodeId);
|
|
94
|
+
// Past-only visits and unread variables cannot change any continuation.
|
|
95
|
+
const projected = [
|
|
96
|
+
Object.fromEntries(Object.entries(state.visits).filter(([name]) => relevant.nodes.has(name))),
|
|
97
|
+
Object.fromEntries(Object.entries(state.variables).filter(([name]) => relevant.variables.has(name))),
|
|
98
|
+
state.inventory.items, state.inventory.cards,
|
|
99
|
+
];
|
|
100
|
+
const key = JSON.stringify([state.nodeId, state.edges.length, ...projected.map((record) => (Object.keys(record).sort().map((name) => [name, record[name]])))]);
|
|
101
|
+
if (seen.has(key))
|
|
102
|
+
continue;
|
|
103
|
+
seen.add(key);
|
|
104
|
+
}
|
|
84
105
|
examined += 1;
|
|
85
106
|
if (examined > Math.max(10000, limits.maxScenarios * limits.maxStepsPerScenario * 20))
|
|
86
107
|
break;
|
|
@@ -100,18 +121,38 @@ function findPathToEdge(entryNodeId, edges, targetEdgeId, limits, nodeVisitLimit
|
|
|
100
121
|
}
|
|
101
122
|
return null;
|
|
102
123
|
}
|
|
124
|
+
export function removePrefixScenarios(scenarios) {
|
|
125
|
+
// ponytail: pairwise scan; index prefixes if scenario planning becomes a bottleneck.
|
|
126
|
+
return scenarios.filter((scenario) => !scenarios.some((longer) => (longer.edges.length > scenario.edges.length
|
|
127
|
+
&& scenario.edges.every((edge, index) => isDeepStrictEqual(edge, longer.edges[index])))));
|
|
128
|
+
}
|
|
103
129
|
export function buildPlaytestScenarios(script, limits, nodeVisitLimits = {}) {
|
|
104
130
|
const { entryNodeId, reachableEdges } = getReachablePlaytestGraph(script);
|
|
105
131
|
if (!entryNodeId)
|
|
106
132
|
throw new PlaytestError('PLAYTEST_GRAPH_INVALID', 'script graph has no entryNodeId');
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
133
|
+
const future = new Map();
|
|
134
|
+
const collectVariables = (condition, names) => {
|
|
135
|
+
if (!condition || typeof condition !== 'object')
|
|
136
|
+
return;
|
|
137
|
+
if (condition.type === 'variable' || condition.variable)
|
|
138
|
+
names.add(String(condition.variable));
|
|
139
|
+
for (const child of Object.values(condition))
|
|
140
|
+
collectVariables(child, names);
|
|
141
|
+
};
|
|
142
|
+
for (const nodeId of new Set([entryNodeId, ...reachableEdges.map((edge) => edge.to)])) {
|
|
143
|
+
const nodes = new Set([nodeId]);
|
|
144
|
+
const variables = new Set();
|
|
145
|
+
// Include all structural continuations, even currently false conditions and loops.
|
|
146
|
+
for (const from of nodes) {
|
|
147
|
+
for (const edge of reachableEdges.filter((candidate) => candidate.from === from)) {
|
|
148
|
+
nodes.add(edge.to);
|
|
149
|
+
collectVariables(edge.condition, variables);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
future.set(nodeId, { nodes, variables });
|
|
112
153
|
}
|
|
113
154
|
const edgeScenarios = reachableEdges.map((targetEdge, index) => {
|
|
114
|
-
const edges = findPathToEdge(entryNodeId, reachableEdges, targetEdge.id, limits, nodeVisitLimits, script);
|
|
155
|
+
const edges = findPathToEdge(entryNodeId, reachableEdges, targetEdge.id, limits, nodeVisitLimits, script, future);
|
|
115
156
|
if (!edges) {
|
|
116
157
|
throw new PlaytestError('PATH_BUDGET_EXCEEDED', `No path to edge ${targetEdge.id} fits the configured step/node-visit budget`, {
|
|
117
158
|
edgeId: targetEdge.id,
|
|
@@ -126,5 +167,13 @@ export function buildPlaytestScenarios(script, limits, nodeVisitLimits = {}) {
|
|
|
126
167
|
edges,
|
|
127
168
|
};
|
|
128
169
|
});
|
|
129
|
-
|
|
170
|
+
const scenarios = removePrefixScenarios([{ id: 'scenario-001', targetEdgeId: `@entry:${entryNodeId}`, edges: [] }, ...edgeScenarios]);
|
|
171
|
+
if (scenarios.length > limits.maxScenarios) {
|
|
172
|
+
throw new PlaytestError('PATH_BUDGET_EXCEEDED', `Required scenario count ${scenarios.length} exceeds maxScenarios ${limits.maxScenarios}`, {
|
|
173
|
+
reachableEdges: reachableEdges.length,
|
|
174
|
+
requiredScenarios: scenarios.length,
|
|
175
|
+
maxScenarios: limits.maxScenarios,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
return scenarios;
|
|
130
179
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mindscraft/branch-video-agent-cli",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.4",
|
|
4
4
|
"description": "Published CLI for branch-video and AIHub agent APIs.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -15,12 +15,12 @@
|
|
|
15
15
|
"dist",
|
|
16
16
|
"README.md"
|
|
17
17
|
],
|
|
18
|
-
"engines": {
|
|
19
|
-
"node": ">=18"
|
|
20
|
-
},
|
|
21
|
-
"dependencies": {
|
|
22
|
-
"playwright": "^1.57.0"
|
|
23
|
-
},
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=18"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"playwright": "^1.57.0"
|
|
23
|
+
},
|
|
24
24
|
"publishConfig": {
|
|
25
25
|
"access": "public",
|
|
26
26
|
"registry": "https://registry.npmjs.org/"
|