@mindscraft/branch-video-agent-cli 0.4.1 → 0.4.3

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
@@ -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
 
@@ -47,6 +47,57 @@ AI Gateway 生产媒体验收使用 `ai-gateway media-understand --raw`。P0 模
47
47
  '@ | npm exec --yes --package=@mindscraft/branch-video-agent-cli branch-video-agent -- project list
48
48
  ```
49
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` 自身始终只读,不承担发布。
98
+
99
+ 从 `0.4.3` 起,规划器删除被更长场景完整包含的有序前缀场景,保留条件、状态效果与全部目标边;场景数由实际图决定,不固定为 96。重复搜索状态也会合并以减少规划开销。覆盖或路径预算失败仍返回失败,不通过删边取得通过。复测时记录实际可执行文件路径;升级全局 npm 包不会更新任务内复制的旧 CLI。
100
+
50
101
  AIHub 导入公共库示例:
51
102
 
52
103
  ```powershell
@@ -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,
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 | media-understand | 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
+ }
@@ -0,0 +1,10 @@
1
+ export class PlaytestError extends Error {
2
+ code;
3
+ details;
4
+ constructor(code, message, details) {
5
+ super(message);
6
+ this.name = 'PlaytestError';
7
+ this.code = code;
8
+ this.details = details;
9
+ }
10
+ }
@@ -0,0 +1,2 @@
1
+ export declare function normalizeScriptForFingerprint(script: unknown): unknown;
2
+ export declare function createScriptFingerprint(script: unknown): string;
@@ -0,0 +1,46 @@
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
+ }
@@ -0,0 +1,7 @@
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
+ };
@@ -0,0 +1,115 @@
1
+ function actionTarget(action, from) {
2
+ const candidate = action;
3
+ if (candidate?.type === 'goto' && typeof candidate.target === 'string')
4
+ return candidate.target;
5
+ if (candidate?.type === 'end')
6
+ return '@ended';
7
+ if (candidate?.type === 'restart' || candidate?.type === 'loop' || candidate?.type === 'seek' || candidate?.type === 'segment')
8
+ return from;
9
+ return null;
10
+ }
11
+ function addEdge(edges, from, owner, ownerId, trigger, action, isDefault = false, effects = {}) {
12
+ const actionRecord = action;
13
+ const to = actionTarget(action, from);
14
+ if (!to)
15
+ return;
16
+ const valuePart = trigger.value === undefined ? '' : `:${JSON.stringify(trigger.value)}`;
17
+ const defaultPart = isDefault ? ':default' : '';
18
+ edges.push({
19
+ id: `${from}:${owner}:${ownerId}:${trigger.type}${valuePart}${defaultPart}->${to}`,
20
+ from,
21
+ to,
22
+ owner,
23
+ ownerId,
24
+ trigger,
25
+ condition: effects.condition,
26
+ variableActions: effects.variableActions,
27
+ inventoryActions: effects.inventoryActions,
28
+ actionType: String(actionRecord?.type || ''),
29
+ action: { ...(actionRecord || {}) },
30
+ default: isDefault || undefined,
31
+ });
32
+ }
33
+ function addBranchConfigEdges(edges, from, owner, ownerId, config) {
34
+ for (const rule of config?.rules || []) {
35
+ const rawTrigger = rule.trigger || { type: 'unknown' };
36
+ const trigger = {
37
+ type: rawTrigger.type,
38
+ value: rawTrigger.value ?? rawTrigger.optionId ?? rawTrigger.optionIds,
39
+ };
40
+ addEdge(edges, from, owner, ownerId, trigger, rule.action, false, {
41
+ condition: rule.condition,
42
+ variableActions: rule.variableActions,
43
+ inventoryActions: rule.inventoryActions,
44
+ });
45
+ }
46
+ addEdge(edges, from, owner, ownerId, { type: 'default' }, config?.defaultAction, true);
47
+ }
48
+ export function extractPlaytestEdges(script) {
49
+ const graph = script?.graph;
50
+ const nodes = graph?.nodes || {};
51
+ const edges = [];
52
+ for (const [nodeId, rawNode] of Object.entries(nodes)) {
53
+ const node = rawNode || {};
54
+ if (node.type === 'start')
55
+ addEdge(edges, nodeId, 'node', nodeId, { type: 'enter' }, node.config?.next);
56
+ if (node.type === 'video' || node.type === 'image') {
57
+ addEdge(edges, nodeId, 'node', nodeId, { type: 'complete' }, node.config?.onComplete);
58
+ }
59
+ addBranchConfigEdges(edges, nodeId, 'node', nodeId, node.branchConfig);
60
+ for (const handler of node.config?.messageHandlers || []) {
61
+ addEdge(edges, nodeId, 'node', nodeId, handler.when || { type: 'message' }, handler.then, false, {
62
+ variableActions: handler.variableActions,
63
+ inventoryActions: handler.inventoryActions,
64
+ });
65
+ }
66
+ for (const legacy of node.branches || []) {
67
+ addEdge(edges, nodeId, 'node', nodeId, { type: 'legacy', value: legacy.when }, legacy.then, false, {
68
+ condition: typeof legacy.when === 'object' ? legacy.when : undefined,
69
+ variableActions: legacy.variableActions,
70
+ inventoryActions: legacy.inventoryActions,
71
+ });
72
+ }
73
+ for (const interaction of node.interactions || []) {
74
+ addBranchConfigEdges(edges, nodeId, 'interaction', interaction.id, interaction.branchConfig);
75
+ for (const legacy of interaction.branches || []) {
76
+ addEdge(edges, nodeId, 'interaction', interaction.id, { type: 'legacy', value: legacy.when }, legacy.then, false, {
77
+ condition: typeof legacy.when === 'object' ? legacy.when : undefined,
78
+ variableActions: legacy.variableActions,
79
+ inventoryActions: legacy.inventoryActions,
80
+ });
81
+ }
82
+ addEdge(edges, nodeId, 'interaction', interaction.id, { type: 'timeout' }, interaction.fallback?.onTimeout);
83
+ addEdge(edges, nodeId, 'interaction', interaction.id, { type: 'default' }, interaction.fallback?.onDefault, true);
84
+ }
85
+ }
86
+ for (const edge of edges) {
87
+ if (edge.actionType !== 'restart')
88
+ continue;
89
+ const oldTarget = edge.to;
90
+ edge.to = String(graph?.entryNodeId || edge.from);
91
+ edge.id = edge.id.replace(`->${oldTarget}`, `->${edge.to}`);
92
+ }
93
+ return [...new Map(edges.map((edge) => [edge.id, edge])).values()];
94
+ }
95
+ export function getReachablePlaytestGraph(script) {
96
+ const graph = script?.graph;
97
+ const nodes = graph?.nodes || {};
98
+ const entryNodeId = String(graph?.entryNodeId || '');
99
+ const allEdges = extractPlaytestEdges(script);
100
+ const reachableNodeIds = new Set();
101
+ const reachableEdges = [];
102
+ const queue = entryNodeId ? [entryNodeId] : [];
103
+ while (queue.length) {
104
+ const nodeId = queue.shift();
105
+ if (reachableNodeIds.has(nodeId))
106
+ continue;
107
+ reachableNodeIds.add(nodeId);
108
+ for (const edge of allEdges.filter((candidate) => candidate.from === nodeId)) {
109
+ reachableEdges.push(edge);
110
+ if (nodes[edge.to] && !reachableNodeIds.has(edge.to))
111
+ queue.push(edge.to);
112
+ }
113
+ }
114
+ return { entryNodeId, reachableNodeIds: [...reachableNodeIds], reachableEdges };
115
+ }
@@ -0,0 +1,6 @@
1
+ import type { PlaytestReport } from './types.js';
2
+ export declare function createJUnitXml(report: PlaytestReport): string;
3
+ export declare function writePlaytestReport(reportDir: string, report: PlaytestReport): Promise<{
4
+ jsonPath: string;
5
+ junitPath: string;
6
+ }>;