@mindscraft/branch-video-agent-cli 0.4.0 → 0.4.1
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 +21 -51
- package/dist/commands/aiGateway.js +23 -0
- package/dist/index.js +1 -3
- package/dist/lib/flags.js +1 -2
- package/dist/lib/http.d.ts +0 -1
- package/dist/lib/http.js +0 -3
- package/package.json +1 -4
- package/dist/commands/playtest.d.ts +0 -2
- package/dist/commands/playtest.js +0 -200
- package/dist/playtest/contract.d.ts +0 -2
- package/dist/playtest/contract.js +0 -119
- package/dist/playtest/errors.d.ts +0 -5
- package/dist/playtest/errors.js +0 -10
- package/dist/playtest/fingerprint.d.ts +0 -2
- package/dist/playtest/fingerprint.js +0 -46
- package/dist/playtest/graph.d.ts +0 -7
- package/dist/playtest/graph.js +0 -115
- package/dist/playtest/report.d.ts +0 -6
- package/dist/playtest/report.js +0 -34
- package/dist/playtest/runner.d.ts +0 -23
- package/dist/playtest/runner.js +0 -571
- package/dist/playtest/scenarios.d.ts +0 -2
- package/dist/playtest/scenarios.js +0 -130
- package/dist/playtest/staticPreflight.d.ts +0 -1
- package/dist/playtest/staticPreflight.js +0 -30
- package/dist/playtest/types.d.ts +0 -152
- package/dist/playtest/types.js +0 -1
package/README.md
CHANGED
|
@@ -31,7 +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`,上游鉴权由服务端处理。
|
|
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 通过前不可调用。
|
|
35
37
|
|
|
36
38
|
最小调用示例:
|
|
37
39
|
|
|
@@ -45,51 +47,6 @@ AI Gateway 调用同样只传 `AIHUB_AGENT_TOKEN`;CLI 不读取、不传递 `A
|
|
|
45
47
|
'@ | npm exec --yes --package=@mindscraft/branch-video-agent-cli branch-video-agent -- project list
|
|
46
48
|
```
|
|
47
49
|
|
|
48
|
-
## BV V3 全分支可播放性测试
|
|
49
|
-
|
|
50
|
-
`playtest run` 从 `0.4.0` 起提供,使用 Playwright Chromium 驱动真实播放器。命令只读草稿或已发布版本,不会保存测试副本或自动发布;运行前仍应先通过 V3 静态 self-test。
|
|
51
|
-
|
|
52
|
-
```powershell
|
|
53
|
-
@'
|
|
54
|
-
{
|
|
55
|
-
"source": { "kind": "draft", "projectId": "project-id" },
|
|
56
|
-
"contractPath": "project.playtest.json",
|
|
57
|
-
"reportDir": "playtest-results",
|
|
58
|
-
"concurrency": 2,
|
|
59
|
-
"limits": {
|
|
60
|
-
"scenarioTimeoutMs": 60000,
|
|
61
|
-
"maxStepsPerScenario": 100,
|
|
62
|
-
"maxScenarios": 500,
|
|
63
|
-
"maxNodeVisits": 3
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
'@ | branch-video-agent playtest run
|
|
67
|
-
```
|
|
68
|
-
|
|
69
|
-
发布版把 `source` 改为 `{ "kind": "published", "projectId": "...", "version": 3, "playUrl": "<发布接口返回值>" }`。CLI 会先读回精确版本并比较规范化内容摘要,再直接使用 `playUrl`;漂移返回 `PUBLISHED_SCRIPT_MISMATCH`。该 SHA-256 摘要只由 sidecar 绑定、发布读回比较和报告追踪消费,不是身份认证、数字签名或 HMAC。
|
|
70
|
-
|
|
71
|
-
sidecar 使用 `branch-video-playtest/1`,Web 结果必须提供 iframe 内的真实操作,不接受测试侧消息注入:
|
|
72
|
-
|
|
73
|
-
```json
|
|
74
|
-
{
|
|
75
|
-
"schemaVersion": "branch-video-playtest/1",
|
|
76
|
-
"scriptFingerprint": "sha256:...",
|
|
77
|
-
"webResults": [{
|
|
78
|
-
"nodeId": "web_quiz",
|
|
79
|
-
"routeValue": "success",
|
|
80
|
-
"rawMessage": { "eventName": "ActivityData", "value": "1" },
|
|
81
|
-
"steps": [{
|
|
82
|
-
"action": "click",
|
|
83
|
-
"locator": { "by": "role", "role": "button", "name": "正确答案" }
|
|
84
|
-
}]
|
|
85
|
-
}]
|
|
86
|
-
}
|
|
87
|
-
```
|
|
88
|
-
|
|
89
|
-
支持 `click`、`fill`、`selectOption`、`check`、`press`、`dragTo`、`setInputFiles` 和 `waitFor`。只要输入中有合法 `reportDir`,静态门禁、契约、版本漂移和浏览器启动等前置失败也会输出 `branch-video-playtest-report.json`、`junit.xml`;失败场景另存截图、trace、视频和浏览器日志。若 Chromium 缺失,执行 `npx playwright install chromium`。
|
|
90
|
-
|
|
91
|
-
CI 顺序应固定为:V3 静态 self-test → 草稿 `playtest run` → 外部发布步骤 → `version get` 读回 → 发布版 `playtest run`。`playtest run` 自身始终只读,不承担发布。
|
|
92
|
-
|
|
93
50
|
AIHub 导入公共库示例:
|
|
94
51
|
|
|
95
52
|
```powershell
|
|
@@ -114,7 +71,7 @@ AIHub 导入公共库示例:
|
|
|
114
71
|
"name": "判断题-增强版",
|
|
115
72
|
"defaultThemeId": "cat",
|
|
116
73
|
"urlsByTheme": {
|
|
117
|
-
"cat": "https://aic-view.sdp.
|
|
74
|
+
"cat": "https://aic-view.sdp.cn.ndhy.com/view/judge-base-v2"
|
|
118
75
|
},
|
|
119
76
|
"configTemplate": {
|
|
120
77
|
"waitForPageReady": true
|
|
@@ -187,17 +144,30 @@ Banana Pro 图片编辑继续使用 `ai-gateway image-edit`,请求体为 JSON
|
|
|
187
144
|
|
|
188
145
|
AI Gateway 视频生成并等待示例:
|
|
189
146
|
|
|
147
|
+
Seedance 2.5 必须使用完整模型名 `doubao-seedance-2-5-260628`。使用 `--wait --raw` 时,CLI 会始终轮询创建响应中的 task ID,并原样保留最终 `video_url` 与 `last_frame_url`。
|
|
148
|
+
|
|
190
149
|
```powershell
|
|
191
150
|
@'
|
|
192
151
|
{
|
|
193
|
-
"model": "doubao-seedance-2-
|
|
194
|
-
"
|
|
195
|
-
|
|
196
|
-
|
|
152
|
+
"model": "doubao-seedance-2-5-260628",
|
|
153
|
+
"content": [
|
|
154
|
+
{
|
|
155
|
+
"type": "text",
|
|
156
|
+
"text": "镜头推进到实验台,卡通猫老师点亮一个小灯泡"
|
|
157
|
+
}
|
|
158
|
+
],
|
|
159
|
+
"omni_reference_task_type": "auto",
|
|
160
|
+
"duration": 4,
|
|
161
|
+
"resolution": "720p",
|
|
162
|
+
"output_format": "mp4",
|
|
163
|
+
"return_last_frame": true,
|
|
164
|
+
"generate_audio": false
|
|
197
165
|
}
|
|
198
166
|
'@ | npm exec --yes --package=@mindscraft/branch-video-agent-cli branch-video-agent -- ai-gateway video-create -- --wait --raw
|
|
199
167
|
```
|
|
200
168
|
|
|
169
|
+
Seedance 2.5 支持 480p、720p、1080p 和 4–30 秒生成;在播放器兼容性完成验证前,推荐 480p/720p + MP4。完整的多图片/视频/音频参考数量限制,以及 `reference`、`edit`、`extend` 的差异,见仓库 Skill 的 `references/ai-gateway-parameters.md`。
|
|
170
|
+
|
|
201
171
|
AI Gateway Seed Audio 音频生成并等待示例:
|
|
202
172
|
|
|
203
173
|
```powershell
|
|
@@ -8,6 +8,21 @@ function ensureObject(value, fieldName) {
|
|
|
8
8
|
}
|
|
9
9
|
return value;
|
|
10
10
|
}
|
|
11
|
+
function validateMediaUnderstandingPayload(payload) {
|
|
12
|
+
for (const field of ['production_task_id', 'model', 'profile', 'prompt']) {
|
|
13
|
+
getRequiredString(payload, field);
|
|
14
|
+
}
|
|
15
|
+
if (!Array.isArray(payload.media) || payload.media.length === 0) {
|
|
16
|
+
throw new CliCommandError('media must be a non-empty JSON array', 'VALIDATION_ERROR');
|
|
17
|
+
}
|
|
18
|
+
if (!Array.isArray(payload.criteria) || payload.criteria.length === 0) {
|
|
19
|
+
throw new CliCommandError('criteria must be a non-empty JSON array', 'VALIDATION_ERROR');
|
|
20
|
+
}
|
|
21
|
+
const candidates = payload.media.filter((item) => isJsonRecord(item) && item.role === 'candidate');
|
|
22
|
+
if (candidates.length !== 1) {
|
|
23
|
+
throw new CliCommandError('media must contain exactly one candidate', 'VALIDATION_ERROR');
|
|
24
|
+
}
|
|
25
|
+
}
|
|
11
26
|
function normalizeOperation(value) {
|
|
12
27
|
return value.trim().replace(/^\/+/, '');
|
|
13
28
|
}
|
|
@@ -174,6 +189,14 @@ export const aiGatewayCommands = {
|
|
|
174
189
|
});
|
|
175
190
|
return { data: result.data, requestId: result.requestId };
|
|
176
191
|
},
|
|
192
|
+
'media-understand': async ({ client, payload }) => {
|
|
193
|
+
validateMediaUnderstandingPayload(payload);
|
|
194
|
+
const result = await client.request('/api/agent/ai-gateway/media/understand', {
|
|
195
|
+
method: 'POST',
|
|
196
|
+
body: payload,
|
|
197
|
+
});
|
|
198
|
+
return { data: result.data, requestId: result.requestId };
|
|
199
|
+
},
|
|
177
200
|
'image-generate': async ({ client, payload }) => {
|
|
178
201
|
const result = await client.request('/api/agent/ai-gateway/images/generations', {
|
|
179
202
|
method: 'POST',
|
package/dist/index.js
CHANGED
|
@@ -6,7 +6,6 @@ import { versionCommands } from './commands/version.js';
|
|
|
6
6
|
import { workflowCommands } from './commands/workflow.js';
|
|
7
7
|
import { characterCommands } from './commands/character.js';
|
|
8
8
|
import { aiGatewayCommands } from './commands/aiGateway.js';
|
|
9
|
-
import { playtestCommands } from './commands/playtest.js';
|
|
10
9
|
import { CliCommandError } from './lib/errors.js';
|
|
11
10
|
import { getCliHelpText, parseCliArgs, resolveCliConfig } from './lib/flags.js';
|
|
12
11
|
import { CliHttpClient } from './lib/http.js';
|
|
@@ -21,7 +20,6 @@ const commandRegistry = {
|
|
|
21
20
|
'aihub-production': productionCommands,
|
|
22
21
|
character: characterCommands,
|
|
23
22
|
'ai-gateway': aiGatewayCommands,
|
|
24
|
-
playtest: playtestCommands,
|
|
25
23
|
};
|
|
26
24
|
function normalizeError(error) {
|
|
27
25
|
if (error instanceof CliCommandError) {
|
|
@@ -46,7 +44,7 @@ function writeError(runtime, command, raw, error) {
|
|
|
46
44
|
}
|
|
47
45
|
writeJson(runtime, {
|
|
48
46
|
ok: false,
|
|
49
|
-
data:
|
|
47
|
+
data: null,
|
|
50
48
|
error: error.message,
|
|
51
49
|
code: error.code,
|
|
52
50
|
requestId: error.requestId || null,
|
package/dist/lib/flags.js
CHANGED
|
@@ -100,8 +100,7 @@ export function getCliHelpText() {
|
|
|
100
100
|
' 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
101
|
' aihub-production schema | start | status | retry',
|
|
102
102
|
' character search | create | get',
|
|
103
|
-
' ai-gateway capabilities | request | image-generate | image-edit | video-create | video-get | seed-audio-create | seed-audio-get | music-submit | music-get | tts-create | tts-get',
|
|
104
|
-
' playtest run',
|
|
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',
|
|
105
104
|
'',
|
|
106
105
|
'Config precedence:',
|
|
107
106
|
' AIHUB_AGENT_TOKEN / BRANCH_VIDEO_AGENT_BASE_URL > stdin JSON or --token-file > command flags',
|
package/dist/lib/http.d.ts
CHANGED
|
@@ -13,7 +13,6 @@ export declare class CliHttpClient {
|
|
|
13
13
|
private readonly token;
|
|
14
14
|
private readonly fetchImpl;
|
|
15
15
|
constructor(baseUrl: string, token: string, fetchImpl: typeof fetch);
|
|
16
|
-
resolveUrl(path: string): string;
|
|
17
16
|
request<T>(path: string, options: RequestOptions): Promise<RequestResult<T>>;
|
|
18
17
|
}
|
|
19
18
|
export {};
|
package/dist/lib/http.js
CHANGED
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.1",
|
|
4
4
|
"description": "Published CLI for branch-video and AIHub agent APIs.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -18,9 +18,6 @@
|
|
|
18
18
|
"engines": {
|
|
19
19
|
"node": ">=18"
|
|
20
20
|
},
|
|
21
|
-
"dependencies": {
|
|
22
|
-
"playwright": "^1.57.0"
|
|
23
|
-
},
|
|
24
21
|
"publishConfig": {
|
|
25
22
|
"access": "public",
|
|
26
23
|
"registry": "https://registry.npmjs.org/"
|
|
@@ -1,200 +0,0 @@
|
|
|
1
|
-
import { CliCommandError } from '../lib/errors.js';
|
|
2
|
-
import { validatePlaytestContract } from '../playtest/contract.js';
|
|
3
|
-
import { PlaytestError } from '../playtest/errors.js';
|
|
4
|
-
import { createScriptFingerprint } from '../playtest/fingerprint.js';
|
|
5
|
-
import { getReachablePlaytestGraph } from '../playtest/graph.js';
|
|
6
|
-
import { writePlaytestReport } from '../playtest/report.js';
|
|
7
|
-
import { createDraftPlayerUrl, createPublishedPlayerUrl, runPlaytestScenarios } from '../playtest/runner.js';
|
|
8
|
-
import { buildPlaytestScenarios } from '../playtest/scenarios.js';
|
|
9
|
-
import { assertPlaytestStaticPreflight } from '../playtest/staticPreflight.js';
|
|
10
|
-
const DEFAULT_LIMITS = {
|
|
11
|
-
scenarioTimeoutMs: 60000,
|
|
12
|
-
maxStepsPerScenario: 100,
|
|
13
|
-
maxScenarios: 500,
|
|
14
|
-
maxNodeVisits: 3,
|
|
15
|
-
};
|
|
16
|
-
function isObject(value) {
|
|
17
|
-
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
18
|
-
}
|
|
19
|
-
function parseInput(payload) {
|
|
20
|
-
const source = payload.source;
|
|
21
|
-
if (!isObject(source) || (source.kind !== 'draft' && source.kind !== 'published') || typeof source.projectId !== 'string') {
|
|
22
|
-
throw new PlaytestError('VALIDATION_ERROR', 'source.kind and source.projectId are required');
|
|
23
|
-
}
|
|
24
|
-
if (source.kind === 'published' && (!Number.isInteger(source.version) || source.version <= 0 || typeof source.playUrl !== 'string')) {
|
|
25
|
-
throw new PlaytestError('VALIDATION_ERROR', 'published source requires positive version and server-returned playUrl');
|
|
26
|
-
}
|
|
27
|
-
if (typeof payload.contractPath !== 'string' || typeof payload.reportDir !== 'string') {
|
|
28
|
-
throw new PlaytestError('VALIDATION_ERROR', 'contractPath and reportDir are required');
|
|
29
|
-
}
|
|
30
|
-
return payload;
|
|
31
|
-
}
|
|
32
|
-
function extractScript(response) {
|
|
33
|
-
const candidate = response;
|
|
34
|
-
const script = candidate?.script ?? candidate?.data?.script ?? candidate?.draft?.script ?? candidate?.project?.script;
|
|
35
|
-
if (!isObject(script))
|
|
36
|
-
throw new PlaytestError('SCRIPT_READ_FAILED', 'server response does not contain a V3 script');
|
|
37
|
-
return script;
|
|
38
|
-
}
|
|
39
|
-
async function readSourceScript(source, client) {
|
|
40
|
-
const projectId = encodeURIComponent(source.projectId);
|
|
41
|
-
if (source.kind === 'draft') {
|
|
42
|
-
return client.request(`/api/branch-video/${projectId}`, { method: 'GET' });
|
|
43
|
-
}
|
|
44
|
-
return client.request(`/api/branch-video/${projectId}/versions/${source.version}`, { method: 'GET' });
|
|
45
|
-
}
|
|
46
|
-
function normalizePositiveInt(value, fallback, name) {
|
|
47
|
-
if (value === undefined)
|
|
48
|
-
return fallback;
|
|
49
|
-
if (!Number.isInteger(value) || Number(value) <= 0)
|
|
50
|
-
throw new PlaytestError('VALIDATION_ERROR', `${name} must be a positive integer`);
|
|
51
|
-
return Number(value);
|
|
52
|
-
}
|
|
53
|
-
async function runPlaytest({ client, payload, runtime }) {
|
|
54
|
-
const startedAt = new Date().toISOString();
|
|
55
|
-
let input = null;
|
|
56
|
-
let source = null;
|
|
57
|
-
let fingerprint = null;
|
|
58
|
-
let requestId = null;
|
|
59
|
-
let reachableNodeIds = [];
|
|
60
|
-
let reachableEdgeIds = [];
|
|
61
|
-
let scenarioResults = [];
|
|
62
|
-
let phase = 'input';
|
|
63
|
-
try {
|
|
64
|
-
input = parseInput(payload);
|
|
65
|
-
source = input.source;
|
|
66
|
-
phase = 'source-read';
|
|
67
|
-
const sourceResult = await readSourceScript(input.source, client);
|
|
68
|
-
requestId = sourceResult.requestId;
|
|
69
|
-
const script = extractScript(sourceResult.data);
|
|
70
|
-
fingerprint = createScriptFingerprint(script);
|
|
71
|
-
phase = 'static-preflight';
|
|
72
|
-
assertPlaytestStaticPreflight(script);
|
|
73
|
-
phase = 'contract';
|
|
74
|
-
let rawContract;
|
|
75
|
-
try {
|
|
76
|
-
rawContract = JSON.parse(await runtime.readTextFile(input.contractPath));
|
|
77
|
-
}
|
|
78
|
-
catch (error) {
|
|
79
|
-
throw new PlaytestError('PLAYTEST_CONTRACT_INVALID', `Cannot read playtest contract: ${error instanceof Error ? error.message : String(error)}`);
|
|
80
|
-
}
|
|
81
|
-
if (input.source.kind === 'published' && (!isObject(rawContract) || rawContract.scriptFingerprint !== fingerprint)) {
|
|
82
|
-
throw new PlaytestError('PUBLISHED_SCRIPT_MISMATCH', 'published version readback does not match the playtest contract fingerprint', {
|
|
83
|
-
expected: isObject(rawContract) ? rawContract.scriptFingerprint : null,
|
|
84
|
-
actual: fingerprint,
|
|
85
|
-
projectId: input.source.projectId,
|
|
86
|
-
version: input.source.version,
|
|
87
|
-
});
|
|
88
|
-
}
|
|
89
|
-
const contract = validatePlaytestContract(script, rawContract);
|
|
90
|
-
const limits = {
|
|
91
|
-
scenarioTimeoutMs: normalizePositiveInt(input.limits?.scenarioTimeoutMs, DEFAULT_LIMITS.scenarioTimeoutMs, 'limits.scenarioTimeoutMs'),
|
|
92
|
-
maxStepsPerScenario: normalizePositiveInt(input.limits?.maxStepsPerScenario, DEFAULT_LIMITS.maxStepsPerScenario, 'limits.maxStepsPerScenario'),
|
|
93
|
-
maxScenarios: normalizePositiveInt(input.limits?.maxScenarios, DEFAULT_LIMITS.maxScenarios, 'limits.maxScenarios'),
|
|
94
|
-
maxNodeVisits: normalizePositiveInt(input.limits?.maxNodeVisits, DEFAULT_LIMITS.maxNodeVisits, 'limits.maxNodeVisits'),
|
|
95
|
-
};
|
|
96
|
-
const concurrency = Math.min(8, normalizePositiveInt(input.concurrency, 2, 'concurrency'));
|
|
97
|
-
const viewport = input.viewport || { width: 1280, height: 720 };
|
|
98
|
-
phase = 'scenario-generation';
|
|
99
|
-
const scenarios = buildPlaytestScenarios(script, limits, contract.nodeVisitLimits);
|
|
100
|
-
const graph = getReachablePlaytestGraph(script);
|
|
101
|
-
reachableNodeIds = graph.reachableNodeIds;
|
|
102
|
-
reachableEdgeIds = graph.reachableEdges.map((edge) => edge.id);
|
|
103
|
-
const draftPlayer = input.source.kind === 'draft' ? createDraftPlayerUrl(client.resolveUrl('/')) : null;
|
|
104
|
-
const playerUrl = input.source.kind === 'draft'
|
|
105
|
-
? draftPlayer.playerUrl
|
|
106
|
-
: createPublishedPlayerUrl(input.source.playUrl);
|
|
107
|
-
phase = 'browser';
|
|
108
|
-
scenarioResults = await runPlaytestScenarios({
|
|
109
|
-
script,
|
|
110
|
-
contract,
|
|
111
|
-
scenarios,
|
|
112
|
-
playerUrl,
|
|
113
|
-
draftScriptUrl: draftPlayer?.scriptUrl,
|
|
114
|
-
reportDir: input.reportDir,
|
|
115
|
-
concurrency,
|
|
116
|
-
limits,
|
|
117
|
-
viewport,
|
|
118
|
-
});
|
|
119
|
-
const coveredNodeIds = [...new Set(scenarioResults.flatMap((result) => result.visitedNodes))];
|
|
120
|
-
const coveredEdgeIds = [...new Set(scenarioResults.flatMap((result) => result.coveredEdges))];
|
|
121
|
-
const findings = scenarioResults.flatMap((result) => result.findings);
|
|
122
|
-
const warnings = [...new Map(scenarioResults
|
|
123
|
-
.flatMap((result) => result.warnings)
|
|
124
|
-
.map((warning) => [`${warning.code}:${warning.message}`, warning])).values()];
|
|
125
|
-
phase = 'coverage';
|
|
126
|
-
for (const nodeId of graph.reachableNodeIds) {
|
|
127
|
-
if (!coveredNodeIds.includes(nodeId))
|
|
128
|
-
findings.push({ code: 'NODE_UNCOVERED', message: `Reachable node ${nodeId} was not rendered`, nodeId, phase });
|
|
129
|
-
}
|
|
130
|
-
for (const edge of graph.reachableEdges) {
|
|
131
|
-
if (!coveredEdgeIds.includes(edge.id))
|
|
132
|
-
findings.push({ code: 'EDGE_UNCOVERED', message: `Reachable edge ${edge.id} was not covered`, edgeId: edge.id, phase });
|
|
133
|
-
}
|
|
134
|
-
const report = {
|
|
135
|
-
schemaVersion: 'branch-video-playtest-report/1',
|
|
136
|
-
source: input.source,
|
|
137
|
-
scriptFingerprint: fingerprint,
|
|
138
|
-
startedAt,
|
|
139
|
-
finishedAt: new Date().toISOString(),
|
|
140
|
-
status: findings.length ? 'failed' : 'passed',
|
|
141
|
-
reachableNodeIds,
|
|
142
|
-
reachableEdgeIds,
|
|
143
|
-
coveredNodeIds,
|
|
144
|
-
coveredEdgeIds,
|
|
145
|
-
scenarios: scenarioResults,
|
|
146
|
-
findings,
|
|
147
|
-
warnings,
|
|
148
|
-
};
|
|
149
|
-
const reportPaths = await writePlaytestReport(input.reportDir, report);
|
|
150
|
-
if (report.status === 'failed') {
|
|
151
|
-
const first = report.findings[0];
|
|
152
|
-
throw new PlaytestError(first?.code || 'PLAYTEST_FAILED', first?.message || 'BV V3 playtest failed', { reportPaths, report });
|
|
153
|
-
}
|
|
154
|
-
return { data: { report, reportPaths }, requestId };
|
|
155
|
-
}
|
|
156
|
-
catch (error) {
|
|
157
|
-
if (error instanceof PlaytestError && isObject(error.details) && error.details.reportPaths)
|
|
158
|
-
throw error;
|
|
159
|
-
const failure = error instanceof PlaytestError
|
|
160
|
-
? error
|
|
161
|
-
: error instanceof CliCommandError
|
|
162
|
-
? new PlaytestError(error.code, error.message, { data: error.data, requestId: error.requestId, status: error.status })
|
|
163
|
-
: new PlaytestError('PLAYTEST_FAILED', error instanceof Error ? error.message : String(error));
|
|
164
|
-
const reportDir = input?.reportDir ?? (typeof payload.reportDir === 'string' ? payload.reportDir : null);
|
|
165
|
-
if (!reportDir)
|
|
166
|
-
throw failure;
|
|
167
|
-
const finding = { code: failure.code, message: failure.message, phase, details: failure.details };
|
|
168
|
-
const report = {
|
|
169
|
-
schemaVersion: 'branch-video-playtest-report/1',
|
|
170
|
-
source,
|
|
171
|
-
scriptFingerprint: fingerprint,
|
|
172
|
-
startedAt,
|
|
173
|
-
finishedAt: new Date().toISOString(),
|
|
174
|
-
status: 'failed',
|
|
175
|
-
reachableNodeIds,
|
|
176
|
-
reachableEdgeIds,
|
|
177
|
-
coveredNodeIds: [...new Set(scenarioResults.flatMap((result) => result.visitedNodes))],
|
|
178
|
-
coveredEdgeIds: [...new Set(scenarioResults.flatMap((result) => result.coveredEdges))],
|
|
179
|
-
scenarios: scenarioResults,
|
|
180
|
-
findings: [finding],
|
|
181
|
-
warnings: [],
|
|
182
|
-
};
|
|
183
|
-
const reportPaths = await writePlaytestReport(reportDir, report);
|
|
184
|
-
throw new PlaytestError(failure.code, failure.message, { reportPaths, report });
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
export const playtestCommands = {
|
|
188
|
-
run: async (context) => {
|
|
189
|
-
try {
|
|
190
|
-
return await runPlaytest(context);
|
|
191
|
-
}
|
|
192
|
-
catch (error) {
|
|
193
|
-
if (error instanceof CliCommandError)
|
|
194
|
-
throw error;
|
|
195
|
-
if (error instanceof PlaytestError)
|
|
196
|
-
throw new CliCommandError(error.message, error.code, error.details);
|
|
197
|
-
throw error;
|
|
198
|
-
}
|
|
199
|
-
},
|
|
200
|
-
};
|
|
@@ -1,119 +0,0 @@
|
|
|
1
|
-
import { createScriptFingerprint } from './fingerprint.js';
|
|
2
|
-
import { getReachablePlaytestGraph } from './graph.js';
|
|
3
|
-
import { PlaytestError } from './errors.js';
|
|
4
|
-
const ACTIONS = new Set(['click', 'fill', 'selectOption', 'check', 'press', 'dragTo', 'setInputFiles', 'waitFor']);
|
|
5
|
-
const LOCATORS = new Set(['role', 'label', 'testId', 'css']);
|
|
6
|
-
function isObject(value) {
|
|
7
|
-
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
8
|
-
}
|
|
9
|
-
function assertLocator(locator, path) {
|
|
10
|
-
if (!isObject(locator) || !LOCATORS.has(locator.by)) {
|
|
11
|
-
throw new PlaytestError('PLAYTEST_CONTRACT_INVALID', `${path} must use role, label, testId, or css locator`);
|
|
12
|
-
}
|
|
13
|
-
if (locator.by === 'role' && typeof locator.role !== 'string') {
|
|
14
|
-
throw new PlaytestError('PLAYTEST_CONTRACT_INVALID', `${path}.role is required`);
|
|
15
|
-
}
|
|
16
|
-
if (locator.by === 'label' && typeof locator.label !== 'string') {
|
|
17
|
-
throw new PlaytestError('PLAYTEST_CONTRACT_INVALID', `${path}.label is required`);
|
|
18
|
-
}
|
|
19
|
-
if (locator.by === 'testId' && typeof locator.testId !== 'string') {
|
|
20
|
-
throw new PlaytestError('PLAYTEST_CONTRACT_INVALID', `${path}.testId is required`);
|
|
21
|
-
}
|
|
22
|
-
if (locator.by === 'css' && typeof locator.selector !== 'string') {
|
|
23
|
-
throw new PlaytestError('PLAYTEST_CONTRACT_INVALID', `${path}.selector is required`);
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
function assertStep(step, path) {
|
|
27
|
-
if (!isObject(step) || !ACTIONS.has(step.action)) {
|
|
28
|
-
throw new PlaytestError('PLAYTEST_CONTRACT_INVALID', `${path}.action is unsupported`);
|
|
29
|
-
}
|
|
30
|
-
assertLocator(step.locator, `${path}.locator`);
|
|
31
|
-
if (step.action === 'dragTo')
|
|
32
|
-
assertLocator(step.target, `${path}.target`);
|
|
33
|
-
}
|
|
34
|
-
function rawMappingValue(rawMessage) {
|
|
35
|
-
if (rawMessage.eventName === 'PageEnd')
|
|
36
|
-
return '*';
|
|
37
|
-
const value = rawMessage.value ?? (isObject(rawMessage.data) ? rawMessage.data.value ?? rawMessage.data.result : rawMessage.data);
|
|
38
|
-
if (typeof value === 'string')
|
|
39
|
-
return value;
|
|
40
|
-
return value === undefined ? '' : JSON.stringify(value);
|
|
41
|
-
}
|
|
42
|
-
export function validatePlaytestContract(script, rawContract) {
|
|
43
|
-
if (!isObject(rawContract) || rawContract.schemaVersion !== 'branch-video-playtest/1') {
|
|
44
|
-
throw new PlaytestError('PLAYTEST_CONTRACT_INVALID', 'contract schemaVersion must be branch-video-playtest/1');
|
|
45
|
-
}
|
|
46
|
-
const scriptFingerprint = createScriptFingerprint(script);
|
|
47
|
-
if (rawContract.scriptFingerprint !== scriptFingerprint) {
|
|
48
|
-
throw new PlaytestError('SCRIPT_FINGERPRINT_MISMATCH', 'playtest contract does not match the normalized script fingerprint', {
|
|
49
|
-
expected: rawContract.scriptFingerprint,
|
|
50
|
-
actual: scriptFingerprint,
|
|
51
|
-
});
|
|
52
|
-
}
|
|
53
|
-
if (!Array.isArray(rawContract.webResults)) {
|
|
54
|
-
throw new PlaytestError('PLAYTEST_CONTRACT_INVALID', 'contract.webResults must be an array');
|
|
55
|
-
}
|
|
56
|
-
for (const [resultIndex, result] of rawContract.webResults.entries()) {
|
|
57
|
-
if (!isObject(result) || typeof result.nodeId !== 'string' || typeof result.routeValue !== 'string') {
|
|
58
|
-
throw new PlaytestError('PLAYTEST_CONTRACT_INVALID', `webResults[${resultIndex}] must declare nodeId and routeValue`);
|
|
59
|
-
}
|
|
60
|
-
if (!isObject(result.rawMessage) || typeof result.rawMessage.eventName !== 'string') {
|
|
61
|
-
throw new PlaytestError('PLAYTEST_CONTRACT_INVALID', `webResults[${resultIndex}].rawMessage.eventName is required`);
|
|
62
|
-
}
|
|
63
|
-
if (!Array.isArray(result.steps) || result.steps.length === 0) {
|
|
64
|
-
throw new PlaytestError('WEB_PLAYTEST_RECIPE_MISSING', `Web result ${result.nodeId}:${result.routeValue} has no real-action steps`);
|
|
65
|
-
}
|
|
66
|
-
result.steps.forEach((step, stepIndex) => assertStep(step, `webResults[${resultIndex}].steps[${stepIndex}]`));
|
|
67
|
-
}
|
|
68
|
-
const nodes = script?.graph?.nodes || {};
|
|
69
|
-
const { reachableEdges, reachableNodeIds } = getReachablePlaytestGraph(script);
|
|
70
|
-
for (const nodeId of reachableNodeIds) {
|
|
71
|
-
const node = nodes[nodeId];
|
|
72
|
-
if (node?.type !== 'web')
|
|
73
|
-
continue;
|
|
74
|
-
const mapping = node.config?.messageMapping || {};
|
|
75
|
-
for (const routeValue of new Set(Object.values(mapping).filter((value) => typeof value === 'string'))) {
|
|
76
|
-
const recipe = rawContract.webResults.find((candidate) => candidate.nodeId === nodeId && candidate.routeValue === routeValue);
|
|
77
|
-
if (!recipe) {
|
|
78
|
-
throw new PlaytestError('WEB_PLAYTEST_RECIPE_MISSING', `Reachable Web result ${nodeId}:${routeValue} has no real-action recipe`, {
|
|
79
|
-
nodeId,
|
|
80
|
-
routeValue,
|
|
81
|
-
});
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
for (const recipe of rawContract.webResults) {
|
|
86
|
-
const node = nodes[recipe.nodeId];
|
|
87
|
-
if (node?.type !== 'web') {
|
|
88
|
-
throw new PlaytestError('PLAYTEST_CONTRACT_INVALID', `Web recipe ${recipe.nodeId}:${recipe.routeValue} does not reference a Web node`);
|
|
89
|
-
}
|
|
90
|
-
const mapping = node.config?.messageMapping || {};
|
|
91
|
-
const rawValue = rawMappingValue(recipe.rawMessage);
|
|
92
|
-
const mappedValue = typeof mapping[rawValue] === 'string' && mapping[rawValue] ? mapping[rawValue] : rawValue;
|
|
93
|
-
if (mappedValue !== recipe.routeValue) {
|
|
94
|
-
throw new PlaytestError('WEB_MESSAGE_MAPPING_MISMATCH', `Web recipe ${recipe.nodeId}:${recipe.routeValue} does not match messageMapping`, {
|
|
95
|
-
rawValue,
|
|
96
|
-
mappedValue,
|
|
97
|
-
});
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
for (const edge of reachableEdges) {
|
|
101
|
-
if (nodes[edge.from]?.type !== 'web' || edge.owner !== 'node')
|
|
102
|
-
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
|
-
const recipe = edge.trigger.type === 'message'
|
|
107
|
-
? rawContract.webResults.find((candidate) => candidate.nodeId === edge.from && candidate.routeValue === String(edge.trigger.value))
|
|
108
|
-
: edge.default
|
|
109
|
-
? rawContract.webResults.find((candidate) => candidate.nodeId === edge.from && !explicitRoutes.has(candidate.routeValue))
|
|
110
|
-
: null;
|
|
111
|
-
if (!recipe) {
|
|
112
|
-
throw new PlaytestError('WEB_PLAYTEST_RECIPE_MISSING', `Reachable Web edge ${edge.id} has no real-action recipe`, {
|
|
113
|
-
nodeId: edge.from,
|
|
114
|
-
edgeId: edge.id,
|
|
115
|
-
});
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
return rawContract;
|
|
119
|
-
}
|
package/dist/playtest/errors.js
DELETED
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
import { createHash } from 'node:crypto';
|
|
2
|
-
const EMPTY_ASSETS = {
|
|
3
|
-
videos: {},
|
|
4
|
-
images: {},
|
|
5
|
-
audios: {},
|
|
6
|
-
speeches: {},
|
|
7
|
-
};
|
|
8
|
-
function isRecord(value) {
|
|
9
|
-
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
10
|
-
}
|
|
11
|
-
function canonicalize(value) {
|
|
12
|
-
if (Array.isArray(value)) {
|
|
13
|
-
return value.map((item) => canonicalize(item));
|
|
14
|
-
}
|
|
15
|
-
if (!isRecord(value))
|
|
16
|
-
return value;
|
|
17
|
-
return Object.fromEntries(Object.entries(value)
|
|
18
|
-
.filter(([, item]) => item !== undefined)
|
|
19
|
-
.sort(([left], [right]) => left.localeCompare(right))
|
|
20
|
-
.map(([key, item]) => [key, canonicalize(item)]));
|
|
21
|
-
}
|
|
22
|
-
export function normalizeScriptForFingerprint(script) {
|
|
23
|
-
if (!isRecord(script))
|
|
24
|
-
return canonicalize(script);
|
|
25
|
-
const normalized = { ...script };
|
|
26
|
-
delete normalized.editorData;
|
|
27
|
-
normalized.assets = {
|
|
28
|
-
...EMPTY_ASSETS,
|
|
29
|
-
...(isRecord(script.assets) ? script.assets : {}),
|
|
30
|
-
};
|
|
31
|
-
normalized.resources = {
|
|
32
|
-
variables: [],
|
|
33
|
-
...(isRecord(script.resources) ? script.resources : {}),
|
|
34
|
-
};
|
|
35
|
-
if (isRecord(script.metadata)) {
|
|
36
|
-
const metadata = { ...script.metadata };
|
|
37
|
-
delete metadata.createdAt;
|
|
38
|
-
delete metadata.updatedAt;
|
|
39
|
-
normalized.metadata = metadata;
|
|
40
|
-
}
|
|
41
|
-
return canonicalize(normalized);
|
|
42
|
-
}
|
|
43
|
-
export function createScriptFingerprint(script) {
|
|
44
|
-
const canonicalJson = JSON.stringify(normalizeScriptForFingerprint(script));
|
|
45
|
-
return `sha256:${createHash('sha256').update(canonicalJson).digest('hex')}`;
|
|
46
|
-
}
|
package/dist/playtest/graph.d.ts
DELETED
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
import type { PlaytestEdge } from './types.js';
|
|
2
|
-
export declare function extractPlaytestEdges(script: unknown): PlaytestEdge[];
|
|
3
|
-
export declare function getReachablePlaytestGraph(script: unknown): {
|
|
4
|
-
entryNodeId: string;
|
|
5
|
-
reachableNodeIds: string[];
|
|
6
|
-
reachableEdges: PlaytestEdge[];
|
|
7
|
-
};
|