@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.
@@ -0,0 +1,179 @@
1
+ import { isDeepStrictEqual } from 'node:util';
2
+ import { PlaytestError } from './errors.js';
3
+ import { getReachablePlaytestGraph } from './graph.js';
4
+ function compare(actual, op, expected) {
5
+ if (op === 'eq')
6
+ return actual === expected;
7
+ if (op === 'ne')
8
+ return actual !== expected;
9
+ if (op === 'gt')
10
+ return Number(actual) > Number(expected);
11
+ if (op === 'gte')
12
+ return Number(actual) >= Number(expected);
13
+ if (op === 'lt')
14
+ return Number(actual) < Number(expected);
15
+ if (op === 'lte')
16
+ return Number(actual) <= Number(expected);
17
+ return false;
18
+ }
19
+ function conditionMatches(condition, variables, inventory) {
20
+ if (!condition)
21
+ return true;
22
+ if (condition.type === 'variable' || condition.variable)
23
+ return compare(variables[condition.variable], condition.op, condition.value);
24
+ if (condition.type === 'inventory') {
25
+ const source = inventory.items[condition.item] ?? inventory.cards[condition.item] ?? 0;
26
+ return compare(source, condition.op, condition.value);
27
+ }
28
+ if (condition.type === 'not')
29
+ return !conditionMatches(condition.condition, variables, inventory);
30
+ if (condition.type === 'and')
31
+ return (condition.conditions || []).every((item) => conditionMatches(item, variables, inventory));
32
+ if (condition.type === 'or')
33
+ return (condition.conditions || []).some((item) => conditionMatches(item, variables, inventory));
34
+ if (Array.isArray(condition.and))
35
+ return condition.and.every((item) => conditionMatches(item, variables, inventory));
36
+ if (Array.isArray(condition.or))
37
+ return condition.or.some((item) => conditionMatches(item, variables, inventory));
38
+ if (condition.not)
39
+ return !conditionMatches(condition.not, variables, inventory);
40
+ return true;
41
+ }
42
+ function applyEffects(state, edge, targetNode) {
43
+ const variables = { ...state.variables };
44
+ const inventory = { items: { ...state.inventory.items }, cards: { ...state.inventory.cards } };
45
+ const variableActions = [...(edge.variableActions || []), ...(targetNode?.variableActions || [])];
46
+ for (const action of variableActions) {
47
+ const current = variables[action.variable];
48
+ if (action.operator === 'set')
49
+ variables[action.variable] = action.value;
50
+ else if (action.operator === 'add')
51
+ variables[action.variable] = Number(current || 0) + Number(action.value || 0);
52
+ else if (action.operator === 'subtract')
53
+ variables[action.variable] = Number(current || 0) - Number(action.value || 0);
54
+ }
55
+ const inventoryActions = [...(edge.inventoryActions || []), ...(targetNode?.inventoryActions || [])];
56
+ for (const action of inventoryActions) {
57
+ const bucket = action.kind === 'card' ? inventory.cards : inventory.items;
58
+ const count = Number(action.count ?? 1);
59
+ if (action.op === 'set')
60
+ bucket[action.id] = count;
61
+ else if (action.op === 'add')
62
+ bucket[action.id] = (bucket[action.id] || 0) + count;
63
+ else if (action.op === 'remove')
64
+ bucket[action.id] = Math.max(0, (bucket[action.id] || 0) - count);
65
+ }
66
+ return { variables, inventory };
67
+ }
68
+ function findPathToEdge(entryNodeId, edges, targetEdgeId, limits, nodeVisitLimits, script, future) {
69
+ const definitions = script?.resources?.variables || {};
70
+ const variables = Object.fromEntries(Object.entries(definitions).map(([name, definition]) => [name, definition?.default ?? 0]));
71
+ const initial = {
72
+ nodeId: entryNodeId,
73
+ edges: [],
74
+ visits: { [entryNodeId]: 1 },
75
+ variables,
76
+ inventory: { items: {}, cards: {} },
77
+ };
78
+ const entryEffects = applyEffects(initial, { variableActions: [], inventoryActions: [] }, script?.graph?.nodes?.[entryNodeId]);
79
+ const queue = [{ ...initial, ...entryEffects }];
80
+ const seen = new Set();
81
+ let head = 0;
82
+ let examined = 0;
83
+ while (head < queue.length) {
84
+ const state = queue[head];
85
+ queue[head++] = undefined;
86
+ if (state.edges.length >= limits.maxStepsPerScenario)
87
+ continue;
88
+ const records = [state.visits, state.variables, state.inventory.items, state.inventory.cards];
89
+ // Non-finite or non-scalar values keep the original search; JSON keys would conflate them.
90
+ const canDeduplicate = records.every((record) => Object.values(record).every((value) => (typeof value === 'string' || typeof value === 'boolean'
91
+ || (typeof value === 'number' && Number.isFinite(value) && !Object.is(value, -0)))));
92
+ if (canDeduplicate) {
93
+ const relevant = future.get(state.nodeId);
94
+ // Past-only visits and unread variables cannot change any continuation.
95
+ const projected = [
96
+ Object.fromEntries(Object.entries(state.visits).filter(([name]) => relevant.nodes.has(name))),
97
+ Object.fromEntries(Object.entries(state.variables).filter(([name]) => relevant.variables.has(name))),
98
+ state.inventory.items, state.inventory.cards,
99
+ ];
100
+ const key = JSON.stringify([state.nodeId, state.edges.length, ...projected.map((record) => (Object.keys(record).sort().map((name) => [name, record[name]])))]);
101
+ if (seen.has(key))
102
+ continue;
103
+ seen.add(key);
104
+ }
105
+ examined += 1;
106
+ if (examined > Math.max(10000, limits.maxScenarios * limits.maxStepsPerScenario * 20))
107
+ break;
108
+ for (const edge of edges.filter((candidate) => candidate.from === state.nodeId)) {
109
+ if (!conditionMatches(edge.condition, state.variables, state.inventory))
110
+ continue;
111
+ const nextVisits = { ...state.visits, [edge.to]: (state.visits[edge.to] || 0) + 1 };
112
+ const visitLimit = nodeVisitLimits[edge.to] ?? limits.maxNodeVisits;
113
+ if (nextVisits[edge.to] > visitLimit)
114
+ continue;
115
+ const nextEdges = [...state.edges, edge];
116
+ if (edge.id === targetEdgeId)
117
+ return nextEdges;
118
+ const effects = applyEffects(state, edge, script?.graph?.nodes?.[edge.to]);
119
+ queue.push({ nodeId: edge.to, edges: nextEdges, visits: nextVisits, ...effects });
120
+ }
121
+ }
122
+ return null;
123
+ }
124
+ export function removePrefixScenarios(scenarios) {
125
+ // ponytail: pairwise scan; index prefixes if scenario planning becomes a bottleneck.
126
+ return scenarios.filter((scenario) => !scenarios.some((longer) => (longer.edges.length > scenario.edges.length
127
+ && scenario.edges.every((edge, index) => isDeepStrictEqual(edge, longer.edges[index])))));
128
+ }
129
+ export function buildPlaytestScenarios(script, limits, nodeVisitLimits = {}) {
130
+ const { entryNodeId, reachableEdges } = getReachablePlaytestGraph(script);
131
+ if (!entryNodeId)
132
+ throw new PlaytestError('PLAYTEST_GRAPH_INVALID', 'script graph has no entryNodeId');
133
+ const future = new Map();
134
+ const collectVariables = (condition, names) => {
135
+ if (!condition || typeof condition !== 'object')
136
+ return;
137
+ if (condition.type === 'variable' || condition.variable)
138
+ names.add(String(condition.variable));
139
+ for (const child of Object.values(condition))
140
+ collectVariables(child, names);
141
+ };
142
+ for (const nodeId of new Set([entryNodeId, ...reachableEdges.map((edge) => edge.to)])) {
143
+ const nodes = new Set([nodeId]);
144
+ const variables = new Set();
145
+ // Include all structural continuations, even currently false conditions and loops.
146
+ for (const from of nodes) {
147
+ for (const edge of reachableEdges.filter((candidate) => candidate.from === from)) {
148
+ nodes.add(edge.to);
149
+ collectVariables(edge.condition, variables);
150
+ }
151
+ }
152
+ future.set(nodeId, { nodes, variables });
153
+ }
154
+ const edgeScenarios = reachableEdges.map((targetEdge, index) => {
155
+ const edges = findPathToEdge(entryNodeId, reachableEdges, targetEdge.id, limits, nodeVisitLimits, script, future);
156
+ if (!edges) {
157
+ throw new PlaytestError('PATH_BUDGET_EXCEEDED', `No path to edge ${targetEdge.id} fits the configured step/node-visit budget`, {
158
+ edgeId: targetEdge.id,
159
+ maxStepsPerScenario: limits.maxStepsPerScenario,
160
+ maxNodeVisits: limits.maxNodeVisits,
161
+ nodeVisitLimits,
162
+ });
163
+ }
164
+ return {
165
+ id: `scenario-${String(index + 2).padStart(3, '0')}`,
166
+ targetEdgeId: targetEdge.id,
167
+ edges,
168
+ };
169
+ });
170
+ const scenarios = removePrefixScenarios([{ id: 'scenario-001', targetEdgeId: `@entry:${entryNodeId}`, edges: [] }, ...edgeScenarios]);
171
+ if (scenarios.length > limits.maxScenarios) {
172
+ throw new PlaytestError('PATH_BUDGET_EXCEEDED', `Required scenario count ${scenarios.length} exceeds maxScenarios ${limits.maxScenarios}`, {
173
+ reachableEdges: reachableEdges.length,
174
+ requiredScenarios: scenarios.length,
175
+ maxScenarios: limits.maxScenarios,
176
+ });
177
+ }
178
+ return scenarios;
179
+ }
@@ -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.1",
3
+ "version": "0.4.3",
4
4
  "description": "Published CLI for branch-video and AIHub agent APIs.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -18,6 +18,9 @@
18
18
  "engines": {
19
19
  "node": ">=18"
20
20
  },
21
+ "dependencies": {
22
+ "playwright": "^1.57.0"
23
+ },
21
24
  "publishConfig": {
22
25
  "access": "public",
23
26
  "registry": "https://registry.npmjs.org/"