@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.
@@ -0,0 +1,34 @@
1
+ import { mkdir, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ function escapeXml(value) {
4
+ return String(value)
5
+ .replaceAll('&', '&')
6
+ .replaceAll('<', '&lt;')
7
+ .replaceAll('>', '&gt;')
8
+ .replaceAll('"', '&quot;')
9
+ .replaceAll("'", '&apos;');
10
+ }
11
+ export function createJUnitXml(report) {
12
+ const scenarioFindingKeys = new Set(report.scenarios.flatMap((scenario) => scenario.findings.map((finding) => `${finding.code}\u0000${finding.message}`)));
13
+ const gateFindings = report.findings.filter((finding) => !scenarioFindingKeys.has(`${finding.code}\u0000${finding.message}`));
14
+ const failures = report.scenarios.filter((scenario) => scenario.status === 'failed').length + gateFindings.length;
15
+ const duration = report.scenarios.reduce((sum, scenario) => sum + scenario.durationMs, 0) / 1000;
16
+ const scenarioCases = report.scenarios.map((scenario) => {
17
+ const finding = scenario.findings[0];
18
+ const failure = finding
19
+ ? `<failure type="${escapeXml(finding.code)}" message="${escapeXml(finding.message)}">${escapeXml(JSON.stringify(finding.details ?? null))}</failure>`
20
+ : '';
21
+ return `<testcase classname="branch-video-playtest" name="${escapeXml(scenario.id)}" time="${(scenario.durationMs / 1000).toFixed(3)}">${failure}</testcase>`;
22
+ }).join('');
23
+ const gateCases = gateFindings.map((finding, index) => (`<testcase classname="branch-video-playtest.gate" name="gate-${index + 1}-${escapeXml(finding.code)}" time="0.000"><failure type="${escapeXml(finding.code)}" message="${escapeXml(finding.message)}">${escapeXml(JSON.stringify(finding.details ?? null))}</failure></testcase>`)).join('');
24
+ const tests = report.scenarios.length + gateFindings.length;
25
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<testsuite name="branch-video-playtest" tests="${tests}" failures="${failures}" time="${duration.toFixed(3)}">${scenarioCases}${gateCases}</testsuite>\n`;
26
+ }
27
+ export async function writePlaytestReport(reportDir, report) {
28
+ await mkdir(reportDir, { recursive: true });
29
+ const jsonPath = path.join(reportDir, 'branch-video-playtest-report.json');
30
+ const junitPath = path.join(reportDir, 'junit.xml');
31
+ await writeFile(jsonPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
32
+ await writeFile(junitPath, createJUnitXml(report), 'utf8');
33
+ return { jsonPath, junitPath };
34
+ }
@@ -0,0 +1,23 @@
1
+ import type { PlaytestContract, PlaytestLimits, PlaytestScenario, PlaytestScenarioResult } from './types.js';
2
+ type ScriptRecord = Record<string, any>;
3
+ interface RunnerOptions {
4
+ script: ScriptRecord;
5
+ contract: PlaytestContract;
6
+ scenarios: PlaytestScenario[];
7
+ playerUrl: string;
8
+ draftScriptUrl?: string;
9
+ reportDir: string;
10
+ concurrency: number;
11
+ limits: PlaytestLimits;
12
+ viewport: {
13
+ width: number;
14
+ height: number;
15
+ };
16
+ }
17
+ export declare function runPlaytestScenarios(options: RunnerOptions): Promise<PlaytestScenarioResult[]>;
18
+ export declare function createDraftPlayerUrl(baseUrl: string): {
19
+ playerUrl: string;
20
+ scriptUrl: string;
21
+ };
22
+ export declare function createPublishedPlayerUrl(playUrl: string): string;
23
+ export {};
@@ -0,0 +1,571 @@
1
+ import { mkdir, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { chromium } from 'playwright';
4
+ import { PlaytestError } from './errors.js';
5
+ function locatorFor(frame, spec) {
6
+ if (spec.by === 'role')
7
+ return frame.getByRole(spec.role, { name: spec.name, exact: spec.exact });
8
+ if (spec.by === 'label')
9
+ return frame.getByLabel(spec.label, { exact: spec.exact });
10
+ if (spec.by === 'testId')
11
+ return frame.getByTestId(spec.testId);
12
+ return frame.locator(spec.selector);
13
+ }
14
+ async function runWebStep(frame, step) {
15
+ const locator = locatorFor(frame, step.locator);
16
+ if (step.action === 'click')
17
+ await locator.click();
18
+ else if (step.action === 'fill')
19
+ await locator.fill(step.value);
20
+ else if (step.action === 'selectOption')
21
+ await locator.selectOption(step.value);
22
+ else if (step.action === 'check')
23
+ await locator.setChecked(step.checked ?? true);
24
+ else if (step.action === 'press')
25
+ await locator.press(step.key);
26
+ else if (step.action === 'dragTo')
27
+ await locator.dragTo(locatorFor(frame, step.target));
28
+ else if (step.action === 'setInputFiles')
29
+ await locator.setInputFiles(step.files);
30
+ else
31
+ await locator.waitFor({ state: step.state ?? 'visible', timeout: step.timeoutMs });
32
+ }
33
+ function appendInstrumentationQuery(rawUrl) {
34
+ const url = new URL(rawUrl);
35
+ url.searchParams.set('embedPlatform', 'web_player_sdk');
36
+ url.searchParams.set('playtest', 'true');
37
+ return url.toString();
38
+ }
39
+ function rawMessageValue(message) {
40
+ if (message?.value !== undefined)
41
+ return message.value;
42
+ if (message?.data?.value !== undefined)
43
+ return message.data.value;
44
+ if (message?.data?.result !== undefined)
45
+ return message.data.result;
46
+ if (message?.data?.UEStatus !== undefined)
47
+ return message.data.UEStatus;
48
+ if (message?.data?.data?.UEStatus !== undefined)
49
+ return message.data.data.UEStatus;
50
+ if (message?.data?.data?.data?.UEStatus !== undefined)
51
+ return message.data.data.data.UEStatus;
52
+ return message?.data;
53
+ }
54
+ function comparableMessageValue(value) {
55
+ return typeof value === 'string' || typeof value === 'number' ? String(value) : value;
56
+ }
57
+ function matchesRawMessage(observed, expected) {
58
+ const actual = observed.data;
59
+ const actualEventName = actual?.eventName ?? actual?.type ?? actual?.event;
60
+ if (actualEventName !== expected.eventName)
61
+ return false;
62
+ const expectedValue = expected.value ?? expected.data?.value ?? expected.data;
63
+ return JSON.stringify(comparableMessageValue(rawMessageValue(actual))) === JSON.stringify(comparableMessageValue(expectedValue));
64
+ }
65
+ function getNode(script, nodeId) {
66
+ return script.graph?.nodes?.[nodeId] || {};
67
+ }
68
+ async function waitForPlayerFrame(page, playerUrl, timeout) {
69
+ const expected = new URL(playerUrl);
70
+ const iframe = page.locator('iframe[title="BV playtest player"]');
71
+ const handle = await iframe.elementHandle({ timeout });
72
+ const deadline = Date.now() + timeout;
73
+ while (Date.now() < deadline) {
74
+ const frame = await handle?.contentFrame();
75
+ if (frame) {
76
+ try {
77
+ const actual = new URL(frame.url());
78
+ if (actual.origin === expected.origin && actual.pathname === expected.pathname)
79
+ return frame;
80
+ }
81
+ catch {
82
+ // Navigation has not committed yet.
83
+ }
84
+ }
85
+ await page.waitForTimeout(50);
86
+ }
87
+ throw new PlaytestError('PLAYER_LOAD_FAILED', `Player iframe did not load ${playerUrl}`);
88
+ }
89
+ async function waitForNode(frame, nodeId, timeout) {
90
+ try {
91
+ await frame.waitForFunction((expectedNodeId) => (globalThis.document.querySelector('[data-bv-node-id]')?.getAttribute('data-bv-node-id') === expectedNodeId), nodeId, { timeout });
92
+ }
93
+ catch {
94
+ const actual = await frame.locator('[data-bv-node-id]').first().getAttribute('data-bv-node-id').catch(() => null);
95
+ throw new PlaytestError('ROUTE_TARGET_MISMATCH', `Expected node ${nodeId}, reached ${actual || 'none'}`, { expected: nodeId, actual });
96
+ }
97
+ }
98
+ async function startPlayback(frame, entryNodeId, entryNodeType, timeout) {
99
+ const startButton = frame.getByRole('button', { name: '开始体验' });
100
+ if (await startButton.isVisible().catch(() => false))
101
+ await startButton.click();
102
+ await frame.locator('[data-bv-node-id]').first().waitFor({ state: 'visible', timeout });
103
+ const current = await frame.locator('[data-bv-node-id]').first().getAttribute('data-bv-node-id');
104
+ if (current !== entryNodeId) {
105
+ const observed = await readObservedMessages(frame);
106
+ const sawEntry = observed.some((message) => JSON.stringify(message.data).includes(entryNodeId));
107
+ if (!sawEntry && entryNodeType !== 'start') {
108
+ throw new PlaytestError('NODE_NOT_RENDERED', `Entry node ${entryNodeId} was not observed before ${current || 'none'}`);
109
+ }
110
+ }
111
+ }
112
+ async function assertNodeMedia(frame, node) {
113
+ if (node.type === 'video') {
114
+ const video = frame.locator('video').first();
115
+ await video.waitFor({ state: 'visible' });
116
+ const state = await video.evaluate(async (element) => {
117
+ if (element.readyState < 1) {
118
+ await new Promise((resolve, reject) => {
119
+ const timer = globalThis.setTimeout(() => reject(new Error('loadedmetadata timeout')), 10000);
120
+ element.addEventListener('loadedmetadata', () => { globalThis.clearTimeout(timer); resolve(); }, { once: true });
121
+ element.addEventListener('error', () => { globalThis.clearTimeout(timer); reject(new Error('video error')); }, { once: true });
122
+ });
123
+ }
124
+ const before = element.currentTime;
125
+ await element.play().catch(() => undefined);
126
+ await new Promise((resolve) => globalThis.setTimeout(resolve, 200));
127
+ return { readyState: element.readyState, duration: element.duration, progressed: element.currentTime > before };
128
+ });
129
+ if (state.readyState < 1 || !Number.isFinite(state.duration)) {
130
+ throw new PlaytestError('MEDIA_LOAD_FAILED', `Video media failed on node ${node.id}`, state);
131
+ }
132
+ }
133
+ if (node.type === 'image') {
134
+ const image = frame.locator('img').filter({ visible: true }).first();
135
+ await image.waitFor({ state: 'visible' });
136
+ const loaded = await image.evaluate((element) => element.complete && element.naturalWidth > 0);
137
+ if (!loaded)
138
+ throw new PlaytestError('MEDIA_LOAD_FAILED', `Image media failed on node ${node.id}`);
139
+ }
140
+ if (node.type === 'web') {
141
+ await frame.locator('iframe[title="Web 页面"]').waitFor({ state: 'visible' });
142
+ }
143
+ }
144
+ async function readObservedMessages(frame) {
145
+ return frame.evaluate(() => globalThis.__bvPlaytestObservedMessages || []).catch(() => []);
146
+ }
147
+ async function executeWebEdge(frame, node, edge, contract) {
148
+ const explicitRoutes = new Set((node.branchConfig?.rules || [])
149
+ .filter((rule) => rule.trigger?.type === 'message')
150
+ .map((rule) => String(rule.trigger.value)));
151
+ const recipe = edge.trigger.type === 'message'
152
+ ? contract.webResults.find((candidate) => candidate.nodeId === edge.from && candidate.routeValue === String(edge.trigger.value))
153
+ : contract.webResults.find((candidate) => candidate.nodeId === edge.from && !explicitRoutes.has(candidate.routeValue));
154
+ if (!recipe)
155
+ throw new PlaytestError('WEB_PLAYTEST_RECIPE_MISSING', `No recipe for ${edge.from}:${String(edge.trigger.value)}`);
156
+ const webFrame = frame.childFrames().find((candidate) => candidate.url() !== 'about:blank');
157
+ if (!webFrame)
158
+ throw new PlaytestError('WEB_FRAME_LOAD_FAILED', `Web iframe did not load on node ${edge.from}`);
159
+ const before = (await readObservedMessages(frame)).length;
160
+ for (const step of recipe.steps)
161
+ await runWebStep(webFrame, step);
162
+ try {
163
+ await frame.waitForFunction((payload) => {
164
+ const { start, rawMessage } = payload;
165
+ const messages = (globalThis.__bvPlaytestObservedMessages || []).slice(start);
166
+ const expectedEvent = rawMessage.eventName;
167
+ const expectedValue = rawMessage.value ?? rawMessage.data?.value ?? rawMessage.data;
168
+ return messages.some((entry) => {
169
+ const actual = entry.data;
170
+ const eventName = actual?.eventName ?? actual?.type ?? actual?.event;
171
+ const value = actual?.value
172
+ ?? actual?.data?.value
173
+ ?? actual?.data?.result
174
+ ?? actual?.data?.UEStatus
175
+ ?? actual?.data?.data?.UEStatus
176
+ ?? actual?.data?.data?.data?.UEStatus
177
+ ?? actual?.data;
178
+ const comparableActual = typeof value === 'string' || typeof value === 'number' ? String(value) : value;
179
+ const comparableExpected = typeof expectedValue === 'string' || typeof expectedValue === 'number' ? String(expectedValue) : expectedValue;
180
+ return eventName === expectedEvent && JSON.stringify(comparableActual) === JSON.stringify(comparableExpected);
181
+ });
182
+ }, { start: before, rawMessage: recipe.rawMessage }, { timeout: 10000 });
183
+ }
184
+ catch {
185
+ const observed = (await readObservedMessages(frame)).slice(before);
186
+ throw new PlaytestError('WEB_MESSAGE_NOT_EMITTED', `Web actions did not emit the expected raw message for ${edge.from}:${recipe.routeValue}`, { observed });
187
+ }
188
+ const emitted = (await readObservedMessages(frame)).slice(before);
189
+ if (!emitted.some((message) => matchesRawMessage(message, recipe.rawMessage))) {
190
+ throw new PlaytestError('WEB_MESSAGE_NOT_EMITTED', `Expected Web raw message was not recorded for ${edge.from}:${recipe.routeValue}`);
191
+ }
192
+ }
193
+ function chooseResultOption(interaction, result) {
194
+ const type = String(interaction.interactive?.type || '');
195
+ if (type.toLowerCase().includes('judge')) {
196
+ const correct = interaction.interactive?.correctAnswer !== false;
197
+ return [String(result === 'correct' ? correct : !correct)];
198
+ }
199
+ const options = interaction.interactive?.options || [];
200
+ const correct = options.filter((option) => option.isCorrect).map((option) => String(option.id));
201
+ const wrong = options.filter((option) => !option.isCorrect).map((option) => String(option.id));
202
+ if (result === 'correct')
203
+ return correct.length ? correct : options.slice(0, 1).map((option) => String(option.id));
204
+ if (result === 'partial') {
205
+ if (correct.length > 1)
206
+ return [correct[0]];
207
+ if (correct.length && wrong.length)
208
+ return [correct[0], wrong[0]];
209
+ }
210
+ return wrong.length ? [wrong[0]] : options.slice(-1).map((option) => String(option.id));
211
+ }
212
+ async function clickAction(frame, action) {
213
+ const control = frame.locator(`[data-bv-action="${action}"]`).first();
214
+ const nestedButton = control.locator('button').first();
215
+ if (await nestedButton.count())
216
+ await nestedButton.click();
217
+ else
218
+ await control.click();
219
+ }
220
+ async function executeNativeInteraction(frame, node, edge, contract) {
221
+ const interaction = (node.interactions || []).find((candidate) => candidate.id === edge.ownerId);
222
+ if (!interaction)
223
+ throw new PlaytestError('INTERACTION_NOT_RENDERED', `Interaction ${edge.ownerId} is absent from node ${edge.from}`);
224
+ if (node.type === 'web' && interaction.trigger?.type === 'message') {
225
+ await executeWebEdge(frame, node, {
226
+ ...edge,
227
+ owner: 'node',
228
+ ownerId: node.id,
229
+ trigger: { type: 'message', value: interaction.trigger.value },
230
+ }, contract);
231
+ }
232
+ else if (interaction.trigger?.type === 'time' && node.type === 'video') {
233
+ await frame.locator('video').first().evaluate((element, time) => {
234
+ element.currentTime = time + 0.05;
235
+ element.dispatchEvent(new globalThis.Event('timeupdate', { bubbles: true }));
236
+ }, Number(interaction.trigger.value || 0));
237
+ }
238
+ else if (interaction.trigger?.type === 'time' && Number(interaction.trigger.value || 0) > 0) {
239
+ await frame.page().waitForTimeout(Number(interaction.trigger.value) * 1000 + 50);
240
+ }
241
+ else if (interaction.trigger?.type === 'complete' && node.type === 'video') {
242
+ await frame.locator('video').first().evaluate((element) => {
243
+ element.dispatchEvent(new globalThis.Event('ended', { bubbles: true }));
244
+ });
245
+ }
246
+ await frame.locator(`[data-bv-interaction-id="${edge.ownerId}"]`).waitFor({ state: 'attached' });
247
+ if (edge.trigger.type === 'timeout') {
248
+ await frame.page().waitForTimeout((Number(interaction.timeLimit || 0) + 2.5) * 1000);
249
+ return;
250
+ }
251
+ const interactionType = String(interaction.interactive?.type || '');
252
+ const resultValue = edge.trigger.type === 'result'
253
+ ? String(edge.trigger.value)
254
+ : edge.trigger.type === 'legacy'
255
+ ? String(edge.trigger.value) === 'true'
256
+ ? 'correct'
257
+ : String(edge.trigger.value) === 'partial'
258
+ ? 'partial'
259
+ : 'incorrect'
260
+ : 'incorrect';
261
+ if (interactionType === 'score_settlement') {
262
+ await clickAction(frame, 'confirm-settlement');
263
+ return;
264
+ }
265
+ if (interactionType === 'logic_chain') {
266
+ const ids = (interaction.interactive?.chainOptions || []).map((option) => String(option.id));
267
+ const orderedIds = resultValue === 'correct' ? ids : [...ids].reverse();
268
+ for (const optionId of orderedIds) {
269
+ await frame.locator(`[data-bv-option-id="${optionId}"]`).first().click();
270
+ }
271
+ await clickAction(frame, 'submit');
272
+ return;
273
+ }
274
+ if (interactionType === 'blank_fill') {
275
+ const blanks = interaction.interactive?.blanks || [];
276
+ const options = interaction.interactive?.options || [];
277
+ for (let index = 0; index < blanks.length; index += 1) {
278
+ const blank = blanks[index];
279
+ const correctId = String(blank.correctOptionId || '');
280
+ const wrongId = String(options.find((option) => String(option.id) !== correctId)?.id || correctId);
281
+ const optionId = resultValue === 'incorrect' || (resultValue === 'partial' && index > 0) ? wrongId : correctId;
282
+ await frame.locator(`[data-bv-option-id="${optionId}"]`).first().dragTo(frame.locator(`[data-bv-blank-id="${blank.id}"]`));
283
+ }
284
+ await clickAction(frame, 'submit');
285
+ return;
286
+ }
287
+ if (interactionType === 'input_fill') {
288
+ const blanks = interaction.interactive?.blanks || [];
289
+ for (let index = 0; index < blanks.length; index += 1) {
290
+ const blank = blanks[index];
291
+ const shouldBeWrong = resultValue === 'incorrect' || (resultValue === 'partial' && index > 0);
292
+ const answer = shouldBeWrong ? '__wrong__' : (blank.correctAnswers?.[0] ?? 'playtest');
293
+ const control = frame.locator(`[data-bv-blank-id="${blank.id}"]`);
294
+ if (blank.type === 'number') {
295
+ await control.click();
296
+ for (const key of String(answer))
297
+ await control.press(key);
298
+ }
299
+ else {
300
+ await control.fill(String(answer));
301
+ }
302
+ }
303
+ await clickAction(frame, 'submit');
304
+ return;
305
+ }
306
+ let optionIds = [];
307
+ if (edge.trigger.type === 'option')
308
+ optionIds = [String(edge.trigger.value)];
309
+ else if (edge.trigger.type === 'options')
310
+ optionIds = Array.isArray(edge.trigger.value) ? edge.trigger.value.map(String) : [];
311
+ else if (edge.trigger.type === 'result')
312
+ optionIds = chooseResultOption(interaction, String(edge.trigger.value));
313
+ else if (edge.trigger.type === 'legacy') {
314
+ const legacy = String(edge.trigger.value);
315
+ optionIds = legacy === 'true'
316
+ ? chooseResultOption(interaction, 'correct')
317
+ : legacy === 'partial'
318
+ ? chooseResultOption(interaction, 'partial')
319
+ : chooseResultOption(interaction, 'incorrect');
320
+ }
321
+ if (interaction.interactive?.collectMode?.mode === 'all') {
322
+ optionIds = (interaction.interactive?.options || []).map((option) => String(option.id));
323
+ }
324
+ if (optionIds.length) {
325
+ for (const optionId of optionIds) {
326
+ const option = frame.locator(`[data-bv-option-id="${optionId}"]`);
327
+ if (await option.count())
328
+ await option.first().click();
329
+ else {
330
+ const optionText = interaction.interactive?.options?.find((candidate) => String(candidate.id) === optionId)?.text;
331
+ if (!optionText)
332
+ throw new PlaytestError('INTERACTION_DRIVER_UNSUPPORTED', `No real control for option ${optionId} in interaction ${edge.ownerId}`);
333
+ await frame.getByText(String(optionText), { exact: true }).click();
334
+ }
335
+ }
336
+ const submit = frame.locator('[data-bv-action="submit"]');
337
+ if (await submit.isVisible().catch(() => false))
338
+ await clickAction(frame, 'submit');
339
+ return;
340
+ }
341
+ const inputs = frame.locator(`[data-bv-interaction-id="${edge.ownerId}"] input`);
342
+ if (await inputs.count()) {
343
+ const blanks = interaction.interactive?.blanks || [];
344
+ for (let index = 0; index < await inputs.count(); index += 1) {
345
+ const answer = blanks[index]?.correctAnswers?.[0] ?? blanks[index]?.answer ?? blanks[index]?.correctAnswer ?? blanks[index]?.answers?.[0] ?? 'playtest';
346
+ await inputs.nth(index).fill(String(edge.trigger.value === 'incorrect' ? '__wrong__' : answer));
347
+ }
348
+ await clickAction(frame, 'submit');
349
+ return;
350
+ }
351
+ throw new PlaytestError('INTERACTION_DRIVER_UNSUPPORTED', `No real DOM driver is available for ${edge.ownerId}:${edge.trigger.type}`);
352
+ }
353
+ async function executeEdge(frame, edge, options) {
354
+ const node = getNode(options.script, edge.from);
355
+ await assertNodeMedia(frame, node);
356
+ if (edge.owner === 'interaction') {
357
+ await executeNativeInteraction(frame, node, edge, options.contract);
358
+ }
359
+ else if (node.type === 'web' && (edge.trigger.type === 'message' || edge.default)) {
360
+ await executeWebEdge(frame, node, edge, options.contract);
361
+ }
362
+ else if (node.type === 'video' && edge.trigger.type === 'time') {
363
+ await frame.locator('video').first().evaluate((element, time) => {
364
+ element.currentTime = time + 0.05;
365
+ element.dispatchEvent(new globalThis.Event('timeupdate', { bubbles: true }));
366
+ }, Number(edge.trigger.value));
367
+ }
368
+ else if (node.type === 'video' && edge.trigger.type === 'complete') {
369
+ await frame.locator('video').first().evaluate((element) => {
370
+ if (Number.isFinite(element.duration) && element.duration > 0)
371
+ element.currentTime = Math.max(0, element.duration - 0.01);
372
+ element.dispatchEvent(new globalThis.Event('timeupdate', { bubbles: true }));
373
+ element.dispatchEvent(new globalThis.Event('ended', { bubbles: true }));
374
+ });
375
+ }
376
+ else if (edge.trigger.type === 'enter') {
377
+ // Start/enter transitions are produced by the runtime itself.
378
+ }
379
+ else if (node.type === 'image' && edge.trigger.type === 'complete') {
380
+ const durationMs = (node.config?.slides || []).reduce((sum, slide) => sum + Number(slide.duration || 0) * 1000, 0);
381
+ await frame.page().waitForTimeout(Math.min(durationMs + 250, options.limits.scenarioTimeoutMs / 2));
382
+ }
383
+ else {
384
+ throw new PlaytestError('EDGE_DRIVER_UNSUPPORTED', `No real runtime driver for edge ${edge.id}`);
385
+ }
386
+ if (edge.actionType === 'end') {
387
+ try {
388
+ await frame.waitForFunction(() => (globalThis.document.querySelector('[data-bv-ended]')?.getAttribute('data-bv-ended') === 'true'), undefined, { timeout: Math.min(10000, options.limits.scenarioTimeoutMs) });
389
+ }
390
+ catch {
391
+ throw new PlaytestError('ROUTE_TARGET_MISMATCH', `Edge ${edge.id} did not reach the ended state`);
392
+ }
393
+ }
394
+ else if (edge.actionType === 'seek' && node.type === 'video' && Number.isFinite(Number(edge.action.time))) {
395
+ await frame.waitForFunction((expectedTime) => {
396
+ const video = globalThis.document.querySelector('video');
397
+ return video && Math.abs(Number(video.currentTime) - Number(expectedTime)) < 0.5;
398
+ }, Number(edge.action.time), { timeout: Math.min(10000, options.limits.scenarioTimeoutMs) });
399
+ }
400
+ else {
401
+ await waitForNode(frame, edge.to, Math.min(10000, options.limits.scenarioTimeoutMs));
402
+ }
403
+ }
404
+ async function createContext(browser, options, scenarioDir) {
405
+ const launchOptions = {
406
+ viewport: options.viewport,
407
+ recordVideo: { dir: scenarioDir, size: options.viewport },
408
+ permissions: options.contract.microphoneWavPath ? ['microphone'] : [],
409
+ };
410
+ if (options.contract.storageStatePath)
411
+ launchOptions.storageState = options.contract.storageStatePath;
412
+ return browser.newContext(launchOptions);
413
+ }
414
+ async function runScenario(browser, scenario, options) {
415
+ const startedAt = Date.now();
416
+ const scenarioDir = path.join(options.reportDir, 'artifacts', scenario.id);
417
+ await mkdir(scenarioDir, { recursive: true });
418
+ const context = await createContext(browser, options, scenarioDir);
419
+ const page = await context.newPage();
420
+ const logs = [];
421
+ const findings = [];
422
+ const warnings = [];
423
+ const visitedNodes = [];
424
+ const coveredEdges = [];
425
+ const artifacts = {};
426
+ let playerReady = false;
427
+ let traceStopped = false;
428
+ page.on('console', (message) => {
429
+ const text = message.text();
430
+ logs.push(`[console:${message.type()}] ${text}`);
431
+ const isNetworkOrTelemetryNoise = /failed to load resource|net::|telemetry|analytics|favicon/i.test(text);
432
+ const isComponentException = /uncaught|unhandled|error boundary|component stack|TypeError|ReferenceError|React/i.test(text);
433
+ if (playerReady && message.type() === 'error' && !isNetworkOrTelemetryNoise && isComponentException) {
434
+ findings.push({ code: 'COMPONENT_CONSOLE_ERROR', message: text, scenarioId: scenario.id });
435
+ }
436
+ else if (message.type() === 'error') {
437
+ warnings.push({ code: 'BROWSER_CONSOLE_WARNING', message: text, scenarioId: scenario.id });
438
+ }
439
+ });
440
+ page.on('pageerror', (error) => {
441
+ logs.push(`[pageerror] ${error.stack || error.message}`);
442
+ if (playerReady)
443
+ findings.push({ code: 'COMPONENT_RUNTIME_ERROR', message: error.message, scenarioId: scenario.id });
444
+ });
445
+ page.on('requestfailed', (request) => {
446
+ const message = `${request.method()} ${request.url()} ${request.failure()?.errorText || ''}`;
447
+ logs.push(`[requestfailed] ${message}`);
448
+ warnings.push({ code: 'REQUEST_FAILED_WARNING', message, scenarioId: scenario.id });
449
+ });
450
+ await context.addInitScript(() => {
451
+ const browserWindow = globalThis;
452
+ browserWindow.__bvPlaytestObservedMessages = [];
453
+ browserWindow.addEventListener('message', (event) => {
454
+ browserWindow.__bvPlaytestObservedMessages.push({ data: event.data, origin: event.origin, at: Date.now() });
455
+ });
456
+ });
457
+ await context.tracing.start({ screenshots: true, snapshots: true, sources: true });
458
+ if (options.draftScriptUrl) {
459
+ await page.route(options.draftScriptUrl, (route) => route.fulfill({
460
+ status: 200,
461
+ contentType: 'application/json',
462
+ headers: { 'access-control-allow-origin': '*' },
463
+ body: JSON.stringify(options.script),
464
+ }));
465
+ }
466
+ let timeoutHandle;
467
+ const scenarioTimeout = new Promise((_, reject) => {
468
+ timeoutHandle = globalThis.setTimeout(() => reject(new PlaytestError('SCENARIO_TIMEOUT', `Scenario ${scenario.id} exceeded ${options.limits.scenarioTimeoutMs}ms`)), options.limits.scenarioTimeoutMs);
469
+ });
470
+ try {
471
+ await Promise.race([(async () => {
472
+ await page.setContent('<!doctype html><html><body style="margin:0"><iframe title="BV playtest player" style="border:0;width:1280px;height:720px"></iframe></body></html>');
473
+ await page.locator('iframe[title="BV playtest player"]').evaluate((element, url) => { element.src = url; }, options.playerUrl);
474
+ const playerFrame = await waitForPlayerFrame(page, options.playerUrl, options.limits.scenarioTimeoutMs);
475
+ const entryNodeId = String(options.script.graph?.entryNodeId || '');
476
+ await startPlayback(playerFrame, entryNodeId, String(getNode(options.script, entryNodeId).type || ''), options.limits.scenarioTimeoutMs);
477
+ playerReady = true;
478
+ await assertNodeMedia(playerFrame, getNode(options.script, entryNodeId));
479
+ visitedNodes.push(String(options.script.graph?.entryNodeId || ''));
480
+ for (const edge of scenario.edges) {
481
+ await executeEdge(playerFrame, edge, options);
482
+ coveredEdges.push(edge.id);
483
+ if (!edge.to.startsWith('@'))
484
+ visitedNodes.push(edge.to);
485
+ }
486
+ })(), scenarioTimeout]);
487
+ }
488
+ catch (error) {
489
+ const failure = error instanceof PlaytestError ? error : new PlaytestError('SCENARIO_FAILED', error instanceof Error ? error.message : String(error));
490
+ findings.push({ code: failure.code, message: failure.message, scenarioId: scenario.id, edgeId: scenario.targetEdgeId, details: failure.details });
491
+ const screenshotPath = path.join(scenarioDir, 'failure.png');
492
+ await page.screenshot({ path: screenshotPath, fullPage: true }).catch(() => undefined);
493
+ artifacts.screenshot = screenshotPath;
494
+ }
495
+ finally {
496
+ if (timeoutHandle)
497
+ globalThis.clearTimeout(timeoutHandle);
498
+ const logPath = path.join(scenarioDir, 'browser.log');
499
+ await writeFile(logPath, `${logs.join('\n')}\n`, 'utf8');
500
+ artifacts.browserLog = logPath;
501
+ const tracePath = path.join(scenarioDir, 'trace.zip');
502
+ await context.tracing.stop({ path: tracePath }).then(() => { traceStopped = true; }).catch(() => undefined);
503
+ if (traceStopped)
504
+ artifacts.trace = tracePath;
505
+ const video = page.video();
506
+ await context.close();
507
+ if (video) {
508
+ const videoPath = await video.path().catch(() => null);
509
+ if (videoPath)
510
+ artifacts.video = videoPath;
511
+ }
512
+ }
513
+ return {
514
+ id: scenario.id,
515
+ targetEdgeId: scenario.targetEdgeId,
516
+ status: findings.length ? 'failed' : 'passed',
517
+ durationMs: Date.now() - startedAt,
518
+ visitedNodes: [...new Set(visitedNodes)],
519
+ coveredEdges,
520
+ findings,
521
+ warnings,
522
+ artifacts,
523
+ };
524
+ }
525
+ export async function runPlaytestScenarios(options) {
526
+ let browser;
527
+ try {
528
+ browser = await chromium.launch({
529
+ headless: true,
530
+ args: options.contract.microphoneWavPath
531
+ ? ['--use-fake-ui-for-media-stream', '--use-fake-device-for-media-stream', `--use-file-for-fake-audio-capture=${path.resolve(options.contract.microphoneWavPath)}`]
532
+ : [],
533
+ });
534
+ }
535
+ catch (error) {
536
+ const message = error instanceof Error ? error.message : String(error);
537
+ if (/executable.*doesn.t exist|browser.*not found|playwright install/i.test(message)) {
538
+ throw new PlaytestError('PLAYWRIGHT_BROWSER_MISSING', 'Playwright Chromium is not installed. Run: npx playwright install chromium', { cause: message });
539
+ }
540
+ throw error;
541
+ }
542
+ try {
543
+ const results = new Array(options.scenarios.length);
544
+ let nextIndex = 0;
545
+ const workers = Array.from({ length: Math.min(options.concurrency, options.scenarios.length) }, async () => {
546
+ while (nextIndex < options.scenarios.length) {
547
+ const index = nextIndex;
548
+ nextIndex += 1;
549
+ results[index] = await runScenario(browser, options.scenarios[index], options);
550
+ }
551
+ });
552
+ await Promise.all(workers);
553
+ return results;
554
+ }
555
+ finally {
556
+ await browser.close();
557
+ }
558
+ }
559
+ export function createDraftPlayerUrl(baseUrl) {
560
+ const scriptUrl = 'https://branch-video-playtest.invalid/script.json';
561
+ const playerUrl = new URL('/branch-video', baseUrl);
562
+ playerUrl.searchParams.set('script', scriptUrl);
563
+ return { playerUrl: appendInstrumentationQuery(playerUrl.toString()), scriptUrl };
564
+ }
565
+ export function createPublishedPlayerUrl(playUrl) {
566
+ const url = new URL(playUrl);
567
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') {
568
+ throw new PlaytestError('VALIDATION_ERROR', 'published playUrl must be an HTTP(S) URL');
569
+ }
570
+ return url.toString();
571
+ }
@@ -0,0 +1,2 @@
1
+ import type { PlaytestLimits, PlaytestScenario } from './types.js';
2
+ export declare function buildPlaytestScenarios(script: unknown, limits: PlaytestLimits, nodeVisitLimits?: Record<string, number>): PlaytestScenario[];