@mindscraft/branch-video-agent-cli 0.3.5 → 0.4.0

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 CHANGED
@@ -10,12 +10,6 @@
10
10
  npm exec --yes --package=@mindscraft/branch-video-agent-cli branch-video-agent -- --help
11
11
  ```
12
12
 
13
- 中国大陆用户如果希望更稳定、更快地安装,可以走 npm 的公开镜像:
14
-
15
- ```powershell
16
- npm exec --yes --registry=https://registry.npmmirror.com --package=@mindscraft/branch-video-agent-cli branch-video-agent -- --help
17
- ```
18
-
19
13
  如果你更喜欢简写,从 `0.1.2` 开始也支持:
20
14
 
21
15
  ```powershell
@@ -25,10 +19,13 @@ npx -y @mindscraft/branch-video-agent-cli --help
25
19
  也可以全局安装:
26
20
 
27
21
  ```powershell
28
- npm install -g @mindscraft/branch-video-agent-cli
22
+ npm install -g @mindscraft/branch-video-agent-cli@latest --registry=https://registry.npmjs.org/
29
23
  branch-video-agent --help
24
+ branch-video-agent --version
30
25
  ```
31
26
 
27
+ 从 `0.3.6` 开始,发布版 CLI 在每次执行命令前查询 npm 官方 registry 的 `latest`,发现新版本时先全局安装该精确版本,再用原参数重新执行命令。registry 查询或安装失败时命令会 fail closed,不会继续提交业务请求。仅在明确接受旧版风险的紧急诊断中设置 `BRANCH_VIDEO_AGENT_DISABLE_AUTO_UPDATE=1` 跳过检查。
28
+
32
29
  环境变量:
33
30
 
34
31
  - `AIHUB_AGENT_TOKEN`
@@ -48,6 +45,51 @@ AI Gateway 调用同样只传 `AIHUB_AGENT_TOKEN`;CLI 不读取、不传递 `A
48
45
  '@ | npm exec --yes --package=@mindscraft/branch-video-agent-cli branch-video-agent -- project list
49
46
  ```
50
47
 
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
+
51
93
  AIHub 导入公共库示例:
52
94
 
53
95
  ```powershell
@@ -116,7 +158,7 @@ AIHub 导入公共库示例:
116
158
 
117
159
  `character search` 只返回已经人工添加 `accepted_for_reuse` 标签的角色资产;新入库资产默认未验收,需要在角色资产库页面验收后才会被 Agent 二次复用。
118
160
 
119
- AI Gateway 图片生成示例:
161
+ AI Gateway 图片生成示例:
120
162
 
121
163
  ```powershell
122
164
  @'
@@ -124,24 +166,24 @@ AI Gateway 图片生成示例:
124
166
  "model": "doubao-seedream-5-0-260128",
125
167
  "prompt": "3D 卡通风格,穿白大褂的喵星人科学老师在实验室讲解电路"
126
168
  }
127
- '@ | npm exec --yes --package=@mindscraft/branch-video-agent-cli branch-video-agent -- ai-gateway image-generate -- --raw
128
- ```
129
-
130
- AI Gateway Banana Pro 同步生成示例(实际模型值为 `gemini-3-pro-image-preview`):
131
-
132
- ```powershell
133
- @'
134
- {
135
- "model": "gemini-3-pro-image-preview",
136
- "prompt": "一幅写实风格的山水画,远处有云雾缭绕的山峰",
137
- "size": "16:9",
138
- "image_size_ratios": "2K",
139
- "output_format": "image/png"
140
- }
141
- '@ | npm exec --yes --package=@mindscraft/branch-video-agent-cli branch-video-agent -- ai-gateway image-generate -- --raw
142
- ```
143
-
144
- Banana Pro 图片编辑继续使用 `ai-gateway image-edit`,请求体为 JSON,`image` 是 HTTP(S) URL 字符串数组;GPT Image 2 编辑仍使用 multipart 文件描述。Banana Pro 当前只支持同步调用,不使用 `--wait`。响应通常包含 `data[].b64_json`,不要把完整 base64 写进 Agent 回复或持久化日志。
169
+ '@ | npm exec --yes --package=@mindscraft/branch-video-agent-cli branch-video-agent -- ai-gateway image-generate -- --raw
170
+ ```
171
+
172
+ AI Gateway Banana Pro 同步生成示例(实际模型值为 `gemini-3-pro-image-preview`):
173
+
174
+ ```powershell
175
+ @'
176
+ {
177
+ "model": "gemini-3-pro-image-preview",
178
+ "prompt": "一幅写实风格的山水画,远处有云雾缭绕的山峰",
179
+ "size": "16:9",
180
+ "image_size_ratios": "2K",
181
+ "output_format": "image/png"
182
+ }
183
+ '@ | npm exec --yes --package=@mindscraft/branch-video-agent-cli branch-video-agent -- ai-gateway image-generate -- --raw
184
+ ```
185
+
186
+ Banana Pro 图片编辑继续使用 `ai-gateway image-edit`,请求体为 JSON,`image` 是 HTTP(S) URL 字符串数组;GPT Image 2 编辑仍使用 multipart 文件描述。Banana Pro 当前只支持同步调用,不使用 `--wait`。响应通常包含 `data[].b64_json`,不要把完整 base64 写进 Agent 回复或持久化日志。
145
187
 
146
188
  AI Gateway 视频生成并等待示例:
147
189
 
package/dist/bin.js CHANGED
@@ -1,3 +1,23 @@
1
1
  #!/usr/bin/env node
2
+ import { readFileSync } from 'node:fs';
2
3
  import { main } from './index.js';
3
- process.exitCode = await main(process.argv.slice(2));
4
+ import { autoUpdateAndReexec } from './lib/autoUpdate.js';
5
+ const packageJson = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
6
+ const argv = process.argv.slice(2);
7
+ const update = await autoUpdateAndReexec({
8
+ currentVersion: packageJson.version,
9
+ argv,
10
+ executablePath: process.argv[1],
11
+ env: process.env,
12
+ platform: process.platform,
13
+ writeStderr: (text) => process.stderr.write(text),
14
+ });
15
+ if (update.handled) {
16
+ process.exitCode = update.exitCode;
17
+ }
18
+ else if (argv.length === 1 && ['--version', '-v'].includes(argv[0])) {
19
+ process.stdout.write(`${packageJson.version}\n`);
20
+ }
21
+ else {
22
+ process.exitCode = await main(argv);
23
+ }
@@ -0,0 +1,2 @@
1
+ import type { CommandHandler } from '../lib/types.js';
2
+ export declare const playtestCommands: Record<string, CommandHandler>;
@@ -0,0 +1,200 @@
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
+ };
package/dist/index.js CHANGED
@@ -6,6 +6,7 @@ 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';
9
10
  import { CliCommandError } from './lib/errors.js';
10
11
  import { getCliHelpText, parseCliArgs, resolveCliConfig } from './lib/flags.js';
11
12
  import { CliHttpClient } from './lib/http.js';
@@ -20,6 +21,7 @@ const commandRegistry = {
20
21
  'aihub-production': productionCommands,
21
22
  character: characterCommands,
22
23
  'ai-gateway': aiGatewayCommands,
24
+ playtest: playtestCommands,
23
25
  };
24
26
  function normalizeError(error) {
25
27
  if (error instanceof CliCommandError) {
@@ -44,7 +46,7 @@ function writeError(runtime, command, raw, error) {
44
46
  }
45
47
  writeJson(runtime, {
46
48
  ok: false,
47
- data: null,
49
+ data: error.data ?? null,
48
50
  error: error.message,
49
51
  code: error.code,
50
52
  requestId: error.requestId || null,
@@ -0,0 +1,26 @@
1
+ export interface AutoUpdateRunResult {
2
+ status: number | null;
3
+ stdout: string;
4
+ stderr: string;
5
+ }
6
+ export interface AutoUpdateRunOptions {
7
+ env?: Record<string, string | undefined>;
8
+ inheritStdio?: boolean;
9
+ timeoutMs?: number;
10
+ }
11
+ export type AutoUpdateRunner = (command: string, args: string[], options?: AutoUpdateRunOptions) => Promise<AutoUpdateRunResult>;
12
+ export interface AutoUpdateOptions {
13
+ currentVersion: string;
14
+ argv: string[];
15
+ executablePath: string;
16
+ env: Record<string, string | undefined>;
17
+ platform: NodeJS.Platform;
18
+ run?: AutoUpdateRunner;
19
+ writeStderr: (text: string) => void;
20
+ }
21
+ export interface AutoUpdateResult {
22
+ handled: boolean;
23
+ exitCode: number;
24
+ latestVersion: string;
25
+ }
26
+ export declare function autoUpdateAndReexec(options: AutoUpdateOptions): Promise<AutoUpdateResult>;
@@ -0,0 +1,90 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ const PACKAGE_NAME = '@mindscraft/branch-video-agent-cli';
3
+ const OFFICIAL_REGISTRY = 'https://registry.npmjs.org/';
4
+ const REEXEC_ENV = 'BRANCH_VIDEO_AGENT_AUTO_UPDATE_REEXEC';
5
+ const DISABLE_ENV = 'BRANCH_VIDEO_AGENT_DISABLE_AUTO_UPDATE';
6
+ function runProcess(command, args, options) {
7
+ const result = spawnSync(command, args, {
8
+ encoding: 'utf8',
9
+ env: options?.env,
10
+ stdio: options?.inheritStdio ? 'inherit' : 'pipe',
11
+ timeout: options?.timeoutMs,
12
+ });
13
+ return Promise.resolve({
14
+ status: result.status,
15
+ stdout: result.stdout || '',
16
+ stderr: result.stderr || '',
17
+ });
18
+ }
19
+ function failedResult(options, message, latestVersion) {
20
+ options.writeStderr(`[branch-video-agent] ${message}\n`);
21
+ return { handled: true, exitCode: 1, latestVersion };
22
+ }
23
+ function compareVersions(left, right) {
24
+ const [leftCore, leftPrerelease] = left.split('-', 2);
25
+ const [rightCore, rightPrerelease] = right.split('-', 2);
26
+ const leftParts = leftCore.split('.').map(Number);
27
+ const rightParts = rightCore.split('.').map(Number);
28
+ for (let index = 0; index < 3; index += 1) {
29
+ const difference = leftParts[index] - rightParts[index];
30
+ if (difference !== 0)
31
+ return difference;
32
+ }
33
+ if (leftPrerelease === rightPrerelease)
34
+ return 0;
35
+ if (!leftPrerelease)
36
+ return 1;
37
+ if (!rightPrerelease)
38
+ return -1;
39
+ return leftPrerelease.localeCompare(rightPrerelease);
40
+ }
41
+ export async function autoUpdateAndReexec(options) {
42
+ if (options.env[DISABLE_ENV] === '1' || options.env[REEXEC_ENV] === '1') {
43
+ return { handled: false, exitCode: 0, latestVersion: options.currentVersion };
44
+ }
45
+ const run = options.run || runProcess;
46
+ const npmCommand = options.platform === 'win32'
47
+ ? options.env.ComSpec || options.env.COMSPEC || 'cmd.exe'
48
+ : 'npm';
49
+ const npmArgsPrefix = options.platform === 'win32' ? ['/d', '/s', '/c', 'npm'] : [];
50
+ const viewResult = await run(npmCommand, [
51
+ ...npmArgsPrefix,
52
+ 'view',
53
+ `${PACKAGE_NAME}@latest`,
54
+ 'version',
55
+ '--registry',
56
+ OFFICIAL_REGISTRY,
57
+ ], { timeoutMs: 15_000 });
58
+ const latestVersion = viewResult.stdout.trim();
59
+ if (viewResult.status !== 0 || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(latestVersion)) {
60
+ return failedResult(options, `cannot verify ${PACKAGE_NAME}@latest from ${OFFICIAL_REGISTRY}; refusing to run an unverified CLI`, latestVersion || options.currentVersion);
61
+ }
62
+ if (compareVersions(latestVersion, options.currentVersion) <= 0) {
63
+ return { handled: false, exitCode: 0, latestVersion };
64
+ }
65
+ options.writeStderr(`[branch-video-agent] upgrading ${PACKAGE_NAME} ${options.currentVersion} -> ${latestVersion}\n`);
66
+ const installResult = await run(npmCommand, [
67
+ ...npmArgsPrefix,
68
+ 'install',
69
+ '-g',
70
+ `${PACKAGE_NAME}@${latestVersion}`,
71
+ '--registry',
72
+ OFFICIAL_REGISTRY,
73
+ ], { inheritStdio: true, env: options.env, timeoutMs: 120_000 });
74
+ if (installResult.status !== 0) {
75
+ return failedResult(options, `automatic upgrade to ${latestVersion} failed; command was not executed`, latestVersion);
76
+ }
77
+ const childEnv = {
78
+ ...options.env,
79
+ [REEXEC_ENV]: '1',
80
+ };
81
+ const reexecResult = await run(process.execPath, [options.executablePath, ...options.argv], {
82
+ inheritStdio: true,
83
+ env: childEnv,
84
+ });
85
+ return {
86
+ handled: true,
87
+ exitCode: reexecResult.status ?? 1,
88
+ latestVersion,
89
+ };
90
+ }
package/dist/lib/flags.js CHANGED
@@ -101,6 +101,7 @@ export function getCliHelpText() {
101
101
  ' aihub-production schema | start | status | retry',
102
102
  ' character search | create | get',
103
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',
104
105
  '',
105
106
  'Config precedence:',
106
107
  ' AIHUB_AGENT_TOKEN / BRANCH_VIDEO_AGENT_BASE_URL > stdin JSON or --token-file > command flags',
@@ -13,6 +13,7 @@ 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;
16
17
  request<T>(path: string, options: RequestOptions): Promise<RequestResult<T>>;
17
18
  }
18
19
  export {};
package/dist/lib/http.js CHANGED
@@ -26,6 +26,9 @@ export class CliHttpClient {
26
26
  this.token = token;
27
27
  this.fetchImpl = fetchImpl;
28
28
  }
29
+ resolveUrl(path) {
30
+ return buildUrl(this.baseUrl, path);
31
+ }
29
32
  async request(path, options) {
30
33
  const headers = {
31
34
  Authorization: `Bearer ${this.token}`,
@@ -0,0 +1,2 @@
1
+ import type { PlaytestContract } from './types.js';
2
+ export declare function validatePlaytestContract(script: unknown, rawContract: unknown): PlaytestContract;
@@ -0,0 +1,119 @@
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
+ }
@@ -0,0 +1,5 @@
1
+ export declare class PlaytestError extends Error {
2
+ code: string;
3
+ details?: unknown;
4
+ constructor(code: string, message: string, details?: unknown);
5
+ }