@mindscraft/branch-video-agent-cli 0.4.1 → 0.4.2
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 +49 -4
- 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 +588 -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 +7 -4
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { PlaytestError } from './errors.js';
|
|
2
|
+
import { getReachablePlaytestGraph } from './graph.js';
|
|
3
|
+
function compare(actual, op, expected) {
|
|
4
|
+
if (op === 'eq')
|
|
5
|
+
return actual === expected;
|
|
6
|
+
if (op === 'ne')
|
|
7
|
+
return actual !== expected;
|
|
8
|
+
if (op === 'gt')
|
|
9
|
+
return Number(actual) > Number(expected);
|
|
10
|
+
if (op === 'gte')
|
|
11
|
+
return Number(actual) >= Number(expected);
|
|
12
|
+
if (op === 'lt')
|
|
13
|
+
return Number(actual) < Number(expected);
|
|
14
|
+
if (op === 'lte')
|
|
15
|
+
return Number(actual) <= Number(expected);
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
function conditionMatches(condition, variables, inventory) {
|
|
19
|
+
if (!condition)
|
|
20
|
+
return true;
|
|
21
|
+
if (condition.type === 'variable' || condition.variable)
|
|
22
|
+
return compare(variables[condition.variable], condition.op, condition.value);
|
|
23
|
+
if (condition.type === 'inventory') {
|
|
24
|
+
const source = inventory.items[condition.item] ?? inventory.cards[condition.item] ?? 0;
|
|
25
|
+
return compare(source, condition.op, condition.value);
|
|
26
|
+
}
|
|
27
|
+
if (condition.type === 'not')
|
|
28
|
+
return !conditionMatches(condition.condition, variables, inventory);
|
|
29
|
+
if (condition.type === 'and')
|
|
30
|
+
return (condition.conditions || []).every((item) => conditionMatches(item, variables, inventory));
|
|
31
|
+
if (condition.type === 'or')
|
|
32
|
+
return (condition.conditions || []).some((item) => conditionMatches(item, variables, inventory));
|
|
33
|
+
if (Array.isArray(condition.and))
|
|
34
|
+
return condition.and.every((item) => conditionMatches(item, variables, inventory));
|
|
35
|
+
if (Array.isArray(condition.or))
|
|
36
|
+
return condition.or.some((item) => conditionMatches(item, variables, inventory));
|
|
37
|
+
if (condition.not)
|
|
38
|
+
return !conditionMatches(condition.not, variables, inventory);
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
function applyEffects(state, edge, targetNode) {
|
|
42
|
+
const variables = { ...state.variables };
|
|
43
|
+
const inventory = { items: { ...state.inventory.items }, cards: { ...state.inventory.cards } };
|
|
44
|
+
const variableActions = [...(edge.variableActions || []), ...(targetNode?.variableActions || [])];
|
|
45
|
+
for (const action of variableActions) {
|
|
46
|
+
const current = variables[action.variable];
|
|
47
|
+
if (action.operator === 'set')
|
|
48
|
+
variables[action.variable] = action.value;
|
|
49
|
+
else if (action.operator === 'add')
|
|
50
|
+
variables[action.variable] = Number(current || 0) + Number(action.value || 0);
|
|
51
|
+
else if (action.operator === 'subtract')
|
|
52
|
+
variables[action.variable] = Number(current || 0) - Number(action.value || 0);
|
|
53
|
+
}
|
|
54
|
+
const inventoryActions = [...(edge.inventoryActions || []), ...(targetNode?.inventoryActions || [])];
|
|
55
|
+
for (const action of inventoryActions) {
|
|
56
|
+
const bucket = action.kind === 'card' ? inventory.cards : inventory.items;
|
|
57
|
+
const count = Number(action.count ?? 1);
|
|
58
|
+
if (action.op === 'set')
|
|
59
|
+
bucket[action.id] = count;
|
|
60
|
+
else if (action.op === 'add')
|
|
61
|
+
bucket[action.id] = (bucket[action.id] || 0) + count;
|
|
62
|
+
else if (action.op === 'remove')
|
|
63
|
+
bucket[action.id] = Math.max(0, (bucket[action.id] || 0) - count);
|
|
64
|
+
}
|
|
65
|
+
return { variables, inventory };
|
|
66
|
+
}
|
|
67
|
+
function findPathToEdge(entryNodeId, edges, targetEdgeId, limits, nodeVisitLimits, script) {
|
|
68
|
+
const definitions = script?.resources?.variables || {};
|
|
69
|
+
const variables = Object.fromEntries(Object.entries(definitions).map(([name, definition]) => [name, definition?.default ?? 0]));
|
|
70
|
+
const initial = {
|
|
71
|
+
nodeId: entryNodeId,
|
|
72
|
+
edges: [],
|
|
73
|
+
visits: { [entryNodeId]: 1 },
|
|
74
|
+
variables,
|
|
75
|
+
inventory: { items: {}, cards: {} },
|
|
76
|
+
};
|
|
77
|
+
const entryEffects = applyEffects(initial, { variableActions: [], inventoryActions: [] }, script?.graph?.nodes?.[entryNodeId]);
|
|
78
|
+
const queue = [{ ...initial, ...entryEffects }];
|
|
79
|
+
let examined = 0;
|
|
80
|
+
while (queue.length) {
|
|
81
|
+
const state = queue.shift();
|
|
82
|
+
if (state.edges.length >= limits.maxStepsPerScenario)
|
|
83
|
+
continue;
|
|
84
|
+
examined += 1;
|
|
85
|
+
if (examined > Math.max(10000, limits.maxScenarios * limits.maxStepsPerScenario * 20))
|
|
86
|
+
break;
|
|
87
|
+
for (const edge of edges.filter((candidate) => candidate.from === state.nodeId)) {
|
|
88
|
+
if (!conditionMatches(edge.condition, state.variables, state.inventory))
|
|
89
|
+
continue;
|
|
90
|
+
const nextVisits = { ...state.visits, [edge.to]: (state.visits[edge.to] || 0) + 1 };
|
|
91
|
+
const visitLimit = nodeVisitLimits[edge.to] ?? limits.maxNodeVisits;
|
|
92
|
+
if (nextVisits[edge.to] > visitLimit)
|
|
93
|
+
continue;
|
|
94
|
+
const nextEdges = [...state.edges, edge];
|
|
95
|
+
if (edge.id === targetEdgeId)
|
|
96
|
+
return nextEdges;
|
|
97
|
+
const effects = applyEffects(state, edge, script?.graph?.nodes?.[edge.to]);
|
|
98
|
+
queue.push({ nodeId: edge.to, edges: nextEdges, visits: nextVisits, ...effects });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
export function buildPlaytestScenarios(script, limits, nodeVisitLimits = {}) {
|
|
104
|
+
const { entryNodeId, reachableEdges } = getReachablePlaytestGraph(script);
|
|
105
|
+
if (!entryNodeId)
|
|
106
|
+
throw new PlaytestError('PLAYTEST_GRAPH_INVALID', 'script graph has no entryNodeId');
|
|
107
|
+
if (reachableEdges.length + 1 > limits.maxScenarios) {
|
|
108
|
+
throw new PlaytestError('PATH_BUDGET_EXCEEDED', `Required scenario count ${reachableEdges.length + 1} exceeds maxScenarios ${limits.maxScenarios}`, {
|
|
109
|
+
reachableEdges: reachableEdges.length,
|
|
110
|
+
maxScenarios: limits.maxScenarios,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
const edgeScenarios = reachableEdges.map((targetEdge, index) => {
|
|
114
|
+
const edges = findPathToEdge(entryNodeId, reachableEdges, targetEdge.id, limits, nodeVisitLimits, script);
|
|
115
|
+
if (!edges) {
|
|
116
|
+
throw new PlaytestError('PATH_BUDGET_EXCEEDED', `No path to edge ${targetEdge.id} fits the configured step/node-visit budget`, {
|
|
117
|
+
edgeId: targetEdge.id,
|
|
118
|
+
maxStepsPerScenario: limits.maxStepsPerScenario,
|
|
119
|
+
maxNodeVisits: limits.maxNodeVisits,
|
|
120
|
+
nodeVisitLimits,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
id: `scenario-${String(index + 2).padStart(3, '0')}`,
|
|
125
|
+
targetEdgeId: targetEdge.id,
|
|
126
|
+
edges,
|
|
127
|
+
};
|
|
128
|
+
});
|
|
129
|
+
return [{ id: 'scenario-001', targetEdgeId: `@entry:${entryNodeId}`, edges: [] }, ...edgeScenarios];
|
|
130
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function assertPlaytestStaticPreflight(script: unknown): void;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { PlaytestError } from './errors.js';
|
|
2
|
+
import { extractPlaytestEdges } from './graph.js';
|
|
3
|
+
const NODE_TYPES = new Set(['start', 'video', 'image', 'web', 'end', 'avg']);
|
|
4
|
+
export function assertPlaytestStaticPreflight(script) {
|
|
5
|
+
const candidate = script;
|
|
6
|
+
if (candidate?.version !== '3.0.0') {
|
|
7
|
+
throw new PlaytestError('STATIC_PREFLIGHT_FAILED', 'script version must be 3.0.0');
|
|
8
|
+
}
|
|
9
|
+
const entryNodeId = candidate.graph?.entryNodeId;
|
|
10
|
+
const nodes = candidate.graph?.nodes;
|
|
11
|
+
if (typeof entryNodeId !== 'string' || !nodes || typeof nodes !== 'object' || !nodes[entryNodeId]) {
|
|
12
|
+
throw new PlaytestError('STATIC_PREFLIGHT_FAILED', 'script graph entryNodeId/nodes are invalid');
|
|
13
|
+
}
|
|
14
|
+
for (const [nodeId, node] of Object.entries(nodes)) {
|
|
15
|
+
if (!NODE_TYPES.has(node?.type)) {
|
|
16
|
+
throw new PlaytestError('STATIC_PREFLIGHT_FAILED', `Unsupported V3 node type ${node?.type || 'missing'} at ${nodeId}`);
|
|
17
|
+
}
|
|
18
|
+
if (node?.id !== nodeId) {
|
|
19
|
+
throw new PlaytestError('STATIC_PREFLIGHT_FAILED', `Node key/id mismatch at ${nodeId}`);
|
|
20
|
+
}
|
|
21
|
+
if (node?.type === 'web' && (node.config?.params !== undefined || node.config?.events !== undefined)) {
|
|
22
|
+
throw new PlaytestError('STATIC_PREFLIGHT_FAILED', `Web node ${nodeId} contains legacy params/events fields`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
for (const edge of extractPlaytestEdges(script)) {
|
|
26
|
+
if (!nodes[edge.to]) {
|
|
27
|
+
throw new PlaytestError('STATIC_PREFLIGHT_FAILED', `Edge ${edge.id} targets missing node ${edge.to}`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
export type PlaytestLocator = {
|
|
2
|
+
by: 'role';
|
|
3
|
+
role: string;
|
|
4
|
+
name?: string;
|
|
5
|
+
exact?: boolean;
|
|
6
|
+
} | {
|
|
7
|
+
by: 'label';
|
|
8
|
+
label: string;
|
|
9
|
+
exact?: boolean;
|
|
10
|
+
} | {
|
|
11
|
+
by: 'testId';
|
|
12
|
+
testId: string;
|
|
13
|
+
} | {
|
|
14
|
+
by: 'css';
|
|
15
|
+
selector: string;
|
|
16
|
+
};
|
|
17
|
+
export type PlaytestStep = {
|
|
18
|
+
action: 'click';
|
|
19
|
+
locator: PlaytestLocator;
|
|
20
|
+
} | {
|
|
21
|
+
action: 'fill';
|
|
22
|
+
locator: PlaytestLocator;
|
|
23
|
+
value: string;
|
|
24
|
+
} | {
|
|
25
|
+
action: 'selectOption';
|
|
26
|
+
locator: PlaytestLocator;
|
|
27
|
+
value: string | string[];
|
|
28
|
+
} | {
|
|
29
|
+
action: 'check';
|
|
30
|
+
locator: PlaytestLocator;
|
|
31
|
+
checked?: boolean;
|
|
32
|
+
} | {
|
|
33
|
+
action: 'press';
|
|
34
|
+
locator: PlaytestLocator;
|
|
35
|
+
key: string;
|
|
36
|
+
} | {
|
|
37
|
+
action: 'dragTo';
|
|
38
|
+
locator: PlaytestLocator;
|
|
39
|
+
target: PlaytestLocator;
|
|
40
|
+
} | {
|
|
41
|
+
action: 'setInputFiles';
|
|
42
|
+
locator: PlaytestLocator;
|
|
43
|
+
files: string[];
|
|
44
|
+
} | {
|
|
45
|
+
action: 'waitFor';
|
|
46
|
+
locator: PlaytestLocator;
|
|
47
|
+
state?: 'attached' | 'detached' | 'visible' | 'hidden';
|
|
48
|
+
timeoutMs?: number;
|
|
49
|
+
};
|
|
50
|
+
export interface PlaytestWebResult {
|
|
51
|
+
nodeId: string;
|
|
52
|
+
routeValue: string;
|
|
53
|
+
rawMessage: {
|
|
54
|
+
eventName: string;
|
|
55
|
+
value?: unknown;
|
|
56
|
+
data?: unknown;
|
|
57
|
+
};
|
|
58
|
+
steps: PlaytestStep[];
|
|
59
|
+
}
|
|
60
|
+
export interface PlaytestContract {
|
|
61
|
+
schemaVersion: 'branch-video-playtest/1';
|
|
62
|
+
scriptFingerprint: string;
|
|
63
|
+
webResults: PlaytestWebResult[];
|
|
64
|
+
storageStatePath?: string;
|
|
65
|
+
microphoneWavPath?: string;
|
|
66
|
+
nodeVisitLimits?: Record<string, number>;
|
|
67
|
+
}
|
|
68
|
+
export interface PlaytestEdge {
|
|
69
|
+
id: string;
|
|
70
|
+
from: string;
|
|
71
|
+
to: string;
|
|
72
|
+
owner: 'node' | 'interaction';
|
|
73
|
+
ownerId: string;
|
|
74
|
+
trigger: {
|
|
75
|
+
type: string;
|
|
76
|
+
value?: unknown;
|
|
77
|
+
};
|
|
78
|
+
condition?: unknown;
|
|
79
|
+
variableActions?: unknown[];
|
|
80
|
+
inventoryActions?: unknown[];
|
|
81
|
+
actionType: string;
|
|
82
|
+
action: Record<string, unknown>;
|
|
83
|
+
default?: boolean;
|
|
84
|
+
}
|
|
85
|
+
export interface PlaytestScenario {
|
|
86
|
+
id: string;
|
|
87
|
+
targetEdgeId: string;
|
|
88
|
+
edges: PlaytestEdge[];
|
|
89
|
+
}
|
|
90
|
+
export interface PlaytestLimits {
|
|
91
|
+
scenarioTimeoutMs: number;
|
|
92
|
+
maxStepsPerScenario: number;
|
|
93
|
+
maxScenarios: number;
|
|
94
|
+
maxNodeVisits: number;
|
|
95
|
+
}
|
|
96
|
+
export interface PlaytestSourceDraft {
|
|
97
|
+
kind: 'draft';
|
|
98
|
+
projectId: string;
|
|
99
|
+
}
|
|
100
|
+
export interface PlaytestSourcePublished {
|
|
101
|
+
kind: 'published';
|
|
102
|
+
projectId: string;
|
|
103
|
+
version: number;
|
|
104
|
+
playUrl: string;
|
|
105
|
+
}
|
|
106
|
+
export type PlaytestSource = PlaytestSourceDraft | PlaytestSourcePublished;
|
|
107
|
+
export interface PlaytestRunInput {
|
|
108
|
+
source: PlaytestSource;
|
|
109
|
+
contractPath: string;
|
|
110
|
+
reportDir: string;
|
|
111
|
+
concurrency?: number;
|
|
112
|
+
viewport?: {
|
|
113
|
+
width: number;
|
|
114
|
+
height: number;
|
|
115
|
+
};
|
|
116
|
+
limits?: Partial<PlaytestLimits>;
|
|
117
|
+
}
|
|
118
|
+
export interface PlaytestFinding {
|
|
119
|
+
code: string;
|
|
120
|
+
message: string;
|
|
121
|
+
phase?: 'input' | 'source-read' | 'static-preflight' | 'contract' | 'scenario-generation' | 'browser' | 'coverage';
|
|
122
|
+
scenarioId?: string;
|
|
123
|
+
nodeId?: string;
|
|
124
|
+
edgeId?: string;
|
|
125
|
+
details?: unknown;
|
|
126
|
+
}
|
|
127
|
+
export interface PlaytestScenarioResult {
|
|
128
|
+
id: string;
|
|
129
|
+
targetEdgeId: string;
|
|
130
|
+
status: 'passed' | 'failed';
|
|
131
|
+
durationMs: number;
|
|
132
|
+
visitedNodes: string[];
|
|
133
|
+
coveredEdges: string[];
|
|
134
|
+
findings: PlaytestFinding[];
|
|
135
|
+
warnings: PlaytestFinding[];
|
|
136
|
+
artifacts: Record<string, string>;
|
|
137
|
+
}
|
|
138
|
+
export interface PlaytestReport {
|
|
139
|
+
schemaVersion: 'branch-video-playtest-report/1';
|
|
140
|
+
source: PlaytestSource | null;
|
|
141
|
+
scriptFingerprint: string | null;
|
|
142
|
+
startedAt: string;
|
|
143
|
+
finishedAt: string;
|
|
144
|
+
status: 'passed' | 'failed';
|
|
145
|
+
reachableNodeIds: string[];
|
|
146
|
+
reachableEdgeIds: string[];
|
|
147
|
+
coveredNodeIds: string[];
|
|
148
|
+
coveredEdgeIds: string[];
|
|
149
|
+
scenarios: PlaytestScenarioResult[];
|
|
150
|
+
findings: PlaytestFinding[];
|
|
151
|
+
warnings: PlaytestFinding[];
|
|
152
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
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.2",
|
|
4
4
|
"description": "Published CLI for branch-video and AIHub agent APIs.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -15,9 +15,12 @@
|
|
|
15
15
|
"dist",
|
|
16
16
|
"README.md"
|
|
17
17
|
],
|
|
18
|
-
"engines": {
|
|
19
|
-
"node": ">=18"
|
|
20
|
-
},
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=18"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"playwright": "^1.57.0"
|
|
23
|
+
},
|
|
21
24
|
"publishConfig": {
|
|
22
25
|
"access": "public",
|
|
23
26
|
"registry": "https://registry.npmjs.org/"
|