@mindscraft/branch-video-agent-cli 0.3.6 → 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 +45 -0
- package/dist/commands/playtest.d.ts +2 -0
- package/dist/commands/playtest.js +200 -0
- package/dist/index.js +3 -1
- package/dist/lib/flags.js +1 -0
- package/dist/lib/http.d.ts +1 -0
- package/dist/lib/http.js +3 -0
- package/dist/playtest/contract.d.ts +2 -0
- package/dist/playtest/contract.js +119 -0
- package/dist/playtest/errors.d.ts +5 -0
- package/dist/playtest/errors.js +10 -0
- package/dist/playtest/fingerprint.d.ts +2 -0
- package/dist/playtest/fingerprint.js +46 -0
- package/dist/playtest/graph.d.ts +7 -0
- package/dist/playtest/graph.js +115 -0
- package/dist/playtest/report.d.ts +6 -0
- package/dist/playtest/report.js +34 -0
- package/dist/playtest/runner.d.ts +23 -0
- package/dist/playtest/runner.js +571 -0
- package/dist/playtest/scenarios.d.ts +2 -0
- package/dist/playtest/scenarios.js +130 -0
- package/dist/playtest/staticPreflight.d.ts +1 -0
- package/dist/playtest/staticPreflight.js +30 -0
- package/dist/playtest/types.d.ts +152 -0
- package/dist/playtest/types.js +1 -0
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -45,6 +45,51 @@ AI Gateway 调用同样只传 `AIHUB_AGENT_TOKEN`;CLI 不读取、不传递 `A
|
|
|
45
45
|
'@ | npm exec --yes --package=@mindscraft/branch-video-agent-cli branch-video-agent -- project list
|
|
46
46
|
```
|
|
47
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
|
+
|
|
48
93
|
AIHub 导入公共库示例:
|
|
49
94
|
|
|
50
95
|
```powershell
|
|
@@ -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 | 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',
|
package/dist/lib/http.d.ts
CHANGED
|
@@ -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
|
@@ -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,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
|
+
}>;
|