ravensight-playtest 0.4.0 → 0.5.1

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,148 @@
1
+ // Local recording only. This module never uploads, reads credentials, or captures
2
+ // a display/audio device. Final files use the existing opt-in upload allowlist.
3
+ import { execFile } from 'node:child_process';
4
+ import fs from 'node:fs/promises';
5
+ import path from 'node:path';
6
+ import { promisify } from 'node:util';
7
+ import { detectFfmpeg } from '../../detect.js';
8
+ import { MAX_VIDEO_BYTES } from '../../upload/allowlist.js';
9
+
10
+ const execute = promisify(execFile);
11
+ export const RECORDING_LIMITS = Object.freeze({
12
+ durationMs: 15 * 60 * 1000,
13
+ videoBytes: MAX_VIDEO_BYTES,
14
+ frameBytes: 256 * 1024 * 1024,
15
+ singleFrameBytes: 4 * 1024 * 1024,
16
+ width: 1280,
17
+ height: 720,
18
+ fps: 10,
19
+ finalizeMs: 60000,
20
+ });
21
+
22
+ // Test seams may lower limits, never raise production ceilings.
23
+ export function recordingLimits(overrides = {}) {
24
+ const out = { ...RECORDING_LIMITS };
25
+ for (const [key, value] of Object.entries(overrides)) {
26
+ if (!(key in out) || !Number.isSafeInteger(value) || value < 1 || value > out[key]) {
27
+ throw new Error(`invalid recording limit: ${key}`);
28
+ }
29
+ out[key] = value;
30
+ }
31
+ return out;
32
+ }
33
+
34
+ export function recordingViewport(viewport, limits = RECORDING_LIMITS) {
35
+ const width = Number(viewport?.width ?? viewport?.w ?? limits.width);
36
+ const height = Number(viewport?.height ?? viewport?.h ?? limits.height);
37
+ if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width < 1 || height < 1) {
38
+ throw new Error('recording viewport must have positive integer dimensions');
39
+ }
40
+ const scale = Math.min(1, limits.width / width, limits.height / height);
41
+ return { width: Math.max(1, Math.floor(width * scale)), height: Math.max(1, Math.floor(height * scale)) };
42
+ }
43
+
44
+ export async function recordingDirectory(runDir) {
45
+ await fs.mkdir(runDir, { recursive: true });
46
+ // Reusing an old playable recording after a failed capture is worse than
47
+ // reporting no recording. Do not silently replace it either.
48
+ for (const name of ['session.webm', 'session.mp4', 'recording.json']) {
49
+ if (await fs.lstat(path.join(runDir, name)).then(() => true, e => { if (e.code === 'ENOENT') return false; throw e; })) {
50
+ throw new Error(`recording output already exists: ${name}`);
51
+ }
52
+ }
53
+ const directory = await fs.mkdtemp(path.join(runDir, '.recording-'));
54
+ await fs.chmod(directory, 0o700);
55
+ return directory;
56
+ }
57
+
58
+ export function recordingMetadata({ driver, startedAt, endedAt, reason = 'stopped', ...details }) {
59
+ return {
60
+ schemaVersion: 1, driver, status: 'failed', audio: false,
61
+ startedAt: new Date(startedAt).toISOString(), endedAt: new Date(endedAt).toISOString(),
62
+ durationMs: Math.max(0, endedAt - startedAt), durationSource: 'capture-clock',
63
+ relativePath: null, mimeType: null, bytes: 0, reason, stopReason: reason, ...details,
64
+ };
65
+ }
66
+
67
+ export async function saveRecording(runDir, metadata) {
68
+ const pending = path.join(runDir, '.recording.json.tmp');
69
+ await fs.writeFile(pending, `${JSON.stringify(metadata, null, 2)}\n`, { mode: 0o600, flag: 'wx' });
70
+ await fs.rename(pending, path.join(runDir, 'recording.json'));
71
+ return metadata;
72
+ }
73
+
74
+ export async function videoInfo(file, { limits = RECORDING_LIMITS } = {}) {
75
+ const stat = await fs.lstat(file);
76
+ if (!stat.isFile() || stat.size < 16 || stat.size > limits.videoBytes) throw new Error('invalid_video_size');
77
+ const handle = await fs.open(file, 'r');
78
+ const head = Buffer.alloc(16);
79
+ try { await handle.read(head, 0, head.length, 0); } finally { await handle.close(); }
80
+ const webm = head.subarray(0, 4).equals(Buffer.from([0x1a, 0x45, 0xdf, 0xa3]));
81
+ const mp4 = head.toString('ascii', 4, 8) === 'ftyp';
82
+ if (!webm && !mp4) throw new Error('invalid_video_container');
83
+ return { bytes: stat.size, mimeType: webm ? 'video/webm' : 'video/mp4' };
84
+ }
85
+
86
+ // ffprobe is optional for Playwright, whose successful close finalizes WebM.
87
+ // When installed, verify the actual media timeline and absence of audio. A
88
+ // present tool rejecting the media is a failure, never a fallback success.
89
+ export async function probeVideo(file, { limits = RECORDING_LIMITS, run = execute } = {}) {
90
+ const info = await videoInfo(file, { limits });
91
+ const binary = process.env.RAVENSIGHT_FFPROBE || (process.env.RAVENSIGHT_FFMPEG
92
+ ? path.join(path.dirname(process.env.RAVENSIGHT_FFMPEG), process.platform === 'win32' ? 'ffprobe.exe' : 'ffprobe') : 'ffprobe');
93
+ let stdout;
94
+ try {
95
+ ({ stdout } = await run(binary, ['-v', 'error', '-show_entries', 'stream=codec_type,width,height:format=duration', '-of', 'json', file],
96
+ { timeout: 10000, killSignal: 'SIGKILL', maxBuffer: 64 * 1024, windowsHide: true }));
97
+ } catch (error) {
98
+ if (error.code === 'ENOENT') return info;
99
+ throw new Error('invalid_video_media');
100
+ }
101
+ try {
102
+ const media = JSON.parse(stdout);
103
+ const video = media.streams.filter(stream => stream.codec_type === 'video');
104
+ const durationMs = Math.round(Number(media.format.duration) * 1000);
105
+ if (video.length !== 1 || media.streams.some(stream => stream.codec_type === 'audio')
106
+ || !Number.isSafeInteger(durationMs) || durationMs <= 0 || durationMs > limits.durationMs
107
+ || video[0].width > limits.width || video[0].height > limits.height) throw new Error();
108
+ return { ...info, durationMs, durationSource: 'media', width: video[0].width, height: video[0].height };
109
+ } catch { throw new Error('invalid_video_media'); }
110
+ }
111
+
112
+ /** Encode only numbered PNGs we wrote, with their actual capture timestamps. */
113
+ export async function encodeFrames({ directory, frames, endedAt, limits = RECORDING_LIMITS, encoder, run = execute, inspect = probeVideo }) {
114
+ if (!frames.length) throw new Error('no_frames');
115
+ const detected = encoder ?? await detectFfmpeg();
116
+ if (!detected.path) throw new Error('encoder_unavailable');
117
+ const first = frames[0].at;
118
+ const durationMs = Math.min(limits.durationMs, Math.max(1000 / limits.fps, endedAt - first));
119
+ const lines = ['ffconcat version 1.0'];
120
+ for (let i = 0; i < frames.length; i++) {
121
+ const frame = frames[i];
122
+ if (!/^\d{6}\.png$/.test(frame.name) || !Number.isFinite(frame.at) || (i && frame.at < frames[i - 1].at)) {
123
+ throw new Error('invalid_frame_timeline');
124
+ }
125
+ const end = Math.min(first + durationMs, frames[i + 1]?.at ?? first + durationMs);
126
+ lines.push(`file '${frame.name}'`, `duration ${Math.max(0.001, (end - frame.at) / 1000).toFixed(6)}`);
127
+ }
128
+ lines.push(`file '${frames.at(-1).name}'`);
129
+ const list = path.join(directory, 'frames.ffconcat');
130
+ await fs.writeFile(list, `${lines.join('\n')}\n`, { mode: 0o600, flag: 'wx' });
131
+ const output = path.join(directory, 'encoded.mp4');
132
+ try {
133
+ await run(detected.path, [
134
+ '-nostdin', '-hide_banner', '-loglevel', 'error', '-n',
135
+ '-protocol_whitelist', 'file', '-f', 'concat', '-safe', '1', '-i', list,
136
+ '-an', '-vf', `scale=${limits.width}:${limits.height}:force_original_aspect_ratio=decrease,pad=ceil(iw/2)*2:ceil(ih/2)*2,setsar=1`,
137
+ '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '23', '-pix_fmt', 'yuv420p',
138
+ '-r', '30', '-t', (durationMs / 1000).toFixed(3), '-fs', String(limits.videoBytes - 4096),
139
+ '-movflags', '+faststart', output,
140
+ ], { cwd: directory, timeout: limits.finalizeMs, killSignal: 'SIGKILL', maxBuffer: 64 * 1024, windowsHide: true });
141
+ const info = await inspect(output, { limits });
142
+ return { file: output, durationMs, ...info, truncated: info.durationMs != null && info.durationMs + 150 < durationMs };
143
+ } catch (error) {
144
+ await fs.rm(output, { force: true });
145
+ // Never include process output, a game path, or inherited environment in a report.
146
+ throw new Error(error.killed ? 'encoding_timeout' : 'encoding_failed');
147
+ }
148
+ }
@@ -18,7 +18,7 @@ export async function readBridgeConfig(file) {
18
18
  }
19
19
 
20
20
  export function createStdioDriver({ spawn: spawnImpl = spawn, timeoutMs = 30000 } = {}) {
21
- let child, buffer = '', nextId = 0, runDirectory, step = 0, shots = 0, lastHash;
21
+ let child, childState, buffer = '', nextId = 0, runDirectory, step = 0, shots = 0, lastHash;
22
22
  const pending = new Map();
23
23
  const fail = message => {
24
24
  for (const entry of pending.values()) { clearTimeout(entry.timer); entry.reject(new DriverError(message, { fatal: true })); }
@@ -52,6 +52,9 @@ export function createStdioDriver({ spawn: spawnImpl = spawn, timeoutMs = 30000
52
52
  const config = await readBridgeConfig(target);
53
53
  runDirectory = runDir;
54
54
  child = spawnImpl(config.command, config.args || [], { cwd: config.cwd || path.dirname(path.resolve(target)), shell: false, stdio: ['pipe', 'pipe', 'pipe'] });
55
+ const state = { process: child, closed: false, stopping: null };
56
+ state.close = new Promise(resolve => child.once('close', () => { state.closed = true; resolve(); }));
57
+ childState = state;
55
58
  child.stdout.setEncoding('utf8');
56
59
  child.stdout.on('data', chunk => {
57
60
  buffer += chunk;
@@ -92,20 +95,33 @@ export function createStdioDriver({ spawn: spawnImpl = spawn, timeoutMs = 30000
92
95
  async screenshot(reason) {
93
96
  const image = await request('screenshot');
94
97
  if (typeof image?.pngBase64 !== 'string' || !image.pngBase64.startsWith('iVBORw0KGgo')) throw new DriverError('This bridge did not return a PNG screenshot.');
95
- const relativePath = path.join('screenshots', screenshotName(++shots, reason));
98
+ const relativePath = path.posix.join('screenshots', screenshotName(++shots, reason));
96
99
  const file = path.join(runDirectory, relativePath);
97
100
  await fs.writeFile(file, Buffer.from(image.pngBase64, 'base64'));
98
101
  return { path: file, relativePath };
99
102
  },
100
103
  async consoleErrors() { return []; },
101
104
  async stop() {
102
- if (child && child.exitCode === null && !child.killed) {
103
- child.kill('SIGTERM');
104
- const process = child;
105
- const timer = setTimeout(() => { if (process.exitCode === null) process.kill('SIGKILL'); }, 1000);
106
- timer.unref();
107
- }
108
105
  fail('Game bridge stopped.');
106
+ const state = childState;
107
+ if (state && !state.stopping) {
108
+ state.stopping = (async () => {
109
+ if (state.closed) return;
110
+ const process = state.process;
111
+ let deadline;
112
+ const force = setTimeout(() => { if (!state.closed) process.kill('SIGKILL'); }, 1000);
113
+ try {
114
+ const timedOut = new Promise((resolve, reject) => {
115
+ deadline = setTimeout(() => reject(new DriverError('Game bridge did not close after stopping.', { fatal: true })), 5000);
116
+ });
117
+ if (process.exitCode === null && process.signalCode === null && !process.killed) process.kill('SIGTERM');
118
+ // Windows can retain the bridge's cwd until its pipes close, even
119
+ // after exit. Concurrent/repeated stops share the same close wait.
120
+ await Promise.race([state.close, timedOut]);
121
+ } finally { clearTimeout(force); clearTimeout(deadline); }
122
+ })();
123
+ }
124
+ if (state) await state.stopping;
109
125
  return { videoPath: null };
110
126
  }
111
127
  };
@@ -1,4 +1,5 @@
1
- import { mkdir, rename, stat, readdir } from 'node:fs/promises';
1
+ import { mkdir, rename, stat, readdir, rm, chmod } from 'node:fs/promises';
2
+ import { recordingLimits, recordingViewport, recordingDirectory, recordingMetadata, saveRecording, probeVideo } from './recording.js';
2
3
  import path from 'node:path';
3
4
  import {
4
5
  DriverError,
@@ -1121,7 +1122,7 @@ export const CHECK_PLAYTEST_IDENTITY = Object.freeze({ token: 'check', runId: nu
1121
1122
  * the one the platform implies
1122
1123
  * @returns {Object} a driver
1123
1124
  */
1124
- export function createWebDriver({ importer, headless = true, playtest = null, platform = process.platform, modifier = null } = {}) {
1125
+ export function createWebDriver({ importer, headless = true, playtest = null, platform = process.platform, modifier = null, recording = {}, now = () => Date.now(), inspectVideo = probeVideo } = {}) {
1125
1126
  const editModifier = modifier === 'Meta' || modifier === 'Control' ? modifier : platformModifier(platform);
1126
1127
  let browser = null;
1127
1128
  let context = null;
@@ -1129,6 +1130,16 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
1129
1130
  let target = null;
1130
1131
  let runDirectory = null;
1131
1132
  let videoRequested = false;
1133
+ const limits = recordingLimits(recording);
1134
+ let captureDirectory = null;
1135
+ let captureStartedAt = null;
1136
+ let stopPromise = null;
1137
+ let captureTimer = null;
1138
+ let captureDeadline = null;
1139
+ let runSignal = null;
1140
+ let onAbort = null;
1141
+ let inspecting = false;
1142
+ let terminalReason = null;
1132
1143
  let step = 0;
1133
1144
  let shots = 0;
1134
1145
  let lastHash = null;
@@ -1344,6 +1355,77 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
1344
1355
  return observationHash(after);
1345
1356
  }
1346
1357
 
1358
+ function stop({ reason = 'stopped' } = {}) {
1359
+ if (stopPromise) return stopPromise;
1360
+ terminalReason ??= reason;
1361
+ stopPromise = Promise.resolve().then(finalize);
1362
+ return stopPromise;
1363
+ }
1364
+
1365
+ async function finalize() {
1366
+ clearInterval(captureTimer);
1367
+ clearTimeout(captureDeadline);
1368
+ runSignal?.removeEventListener('abort', onAbort);
1369
+ const endedAt = now();
1370
+ let videoPath = null;
1371
+ let promotedPath = null;
1372
+ let finalized = true;
1373
+ // Only the explicitly selected page's handle may become session.webm.
1374
+ const video = videoRequested && page && typeof page.video === 'function' ? page.video() : null;
1375
+ try { if (context) await withTimeout(context.close(), 15000); } catch { finalized = false; }
1376
+ let metadata = null;
1377
+ if (videoRequested && captureStartedAt != null) {
1378
+ metadata = recordingMetadata({ driver: 'playwright_web', startedAt: captureStartedAt, endedAt,
1379
+ reason: terminalReason, timeOrigin: 'page-creation-request' });
1380
+ if (video) {
1381
+ try {
1382
+ let source;
1383
+ if (typeof video.saveAs === 'function') {
1384
+ source = path.join(captureDirectory, 'selected-final.webm');
1385
+ await withTimeout(video.saveAs(source), 15000);
1386
+ } else source = await withTimeout(video.path(), 15000);
1387
+ // No arbitrary run-directory scan or old-file fallback on a crash.
1388
+ if (path.dirname(path.resolve(source)) !== path.resolve(captureDirectory)) throw new Error('foreign_video_path');
1389
+ const info = await inspectVideo(source, { limits });
1390
+ if (info.mimeType !== 'video/webm') throw new Error('invalid_video_container');
1391
+ const destination = path.join(runDirectory, 'session.webm');
1392
+ await rename(source, destination);
1393
+ promotedPath = destination;
1394
+ await chmod(destination, 0o600);
1395
+ videoPath = destination;
1396
+ metadata = { ...metadata, ...info, relativePath: 'session.webm',
1397
+ status: finalized && ['stopped', 'completed'].includes(terminalReason) ? 'complete' : 'partial' };
1398
+ } catch (error) {
1399
+ if (promotedPath) await rm(promotedPath, { force: true });
1400
+ videoPath = null;
1401
+ metadata.reason = 'video_finalization_failed';
1402
+ metadata.failure = ['invalid_video_size', 'invalid_video_container', 'invalid_video_media', 'foreign_video_path'].includes(error.message) ? error.message : 'recorder_unavailable';
1403
+ }
1404
+ } else metadata.reason = 'video_unavailable';
1405
+ }
1406
+ try { if (browser) await withTimeout(browser.close(), 15000); } catch { /* already closed or unavailable */ }
1407
+ if (captureDirectory) await rm(captureDirectory, { recursive: true, force: true });
1408
+ if (metadata) await saveRecording(runDirectory, metadata);
1409
+ page = null;
1410
+ context = null;
1411
+ browser = null;
1412
+ return { videoPath, ...(metadata ? { recording: metadata } : {}) };
1413
+ }
1414
+
1415
+ async function inspectCapture() {
1416
+ if (inspecting || stopPromise || !captureDirectory) return;
1417
+ inspecting = true;
1418
+ try {
1419
+ let bytes = 0;
1420
+ for (const name of await readdir(captureDirectory)) {
1421
+ bytes += (await stat(path.join(captureDirectory, name))).size;
1422
+ }
1423
+ // Leave room for WebM's final index; oversize output is never published.
1424
+ if (bytes >= Math.max(1, limits.videoBytes - 8 * 1024 * 1024)) await stop({ reason: 'size_limit' });
1425
+ } catch { await stop({ reason: 'capture_failed' }); }
1426
+ finally { inspecting = false; }
1427
+ }
1428
+
1347
1429
  return {
1348
1430
  name: 'playwright_web',
1349
1431
  supportsRefs: true,
@@ -1357,20 +1439,26 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
1357
1439
  * @param {boolean} [opts.recordVideo]
1358
1440
  * @param {{width: number, height: number}} [opts.viewport]
1359
1441
  */
1360
- async launch({ target: url, runDir, recordVideo = false, viewport = DEFAULT_VIEWPORT }) {
1442
+ async launch({ target: url, runDir, recordVideo = false, viewport = DEFAULT_VIEWPORT, signal }) {
1361
1443
  if (!url) throw new DriverError('playwright_web needs a target URL', { fatal: true });
1444
+ if (signal?.aborted) throw new DriverError('run canceled before browser launch');
1362
1445
  target = url;
1363
1446
  viewportSize = viewport || DEFAULT_VIEWPORT;
1364
1447
  runDirectory = runDir;
1365
1448
  videoRequested = Boolean(recordVideo);
1449
+ if (videoRequested) {
1450
+ viewport = recordingViewport(viewport, limits);
1451
+ viewportSize = viewport;
1452
+ captureDirectory = await recordingDirectory(runDir);
1453
+ }
1366
1454
 
1455
+ try {
1367
1456
  const playwright = await loadPlaywright(importer);
1368
1457
  await mkdir(runDir, { recursive: true });
1369
-
1370
- browser = await playwright.chromium.launch({ headless });
1458
+ browser = await playwright.chromium.launch({ headless, ...(videoRequested ? { args: ['--mute-audio'] } : {}) });
1371
1459
  context = await browser.newContext({
1372
1460
  viewport,
1373
- ...(videoRequested ? { recordVideo: { dir: runDir, size: viewport } } : {})
1461
+ ...(videoRequested ? { recordVideo: { dir: captureDirectory, size: viewport }, permissions: [] } : {})
1374
1462
  });
1375
1463
  // The playtest identity goes in as an init script, not as
1376
1464
  // `extraHTTPHeaders`: a context header rides on every request the page
@@ -1387,7 +1475,34 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
1387
1475
  // One token per document: set again by every new document, kept by
1388
1476
  // pushState and hash route changes, which do not run init scripts.
1389
1477
  if (typeof context.addInitScript === 'function') await context.addInitScript(markDocument);
1478
+ if (videoRequested && typeof context.route === 'function') {
1479
+ const origin = new URL(url).origin;
1480
+ await context.route('**/*', route => {
1481
+ const request = route.request();
1482
+ if (request.isNavigationRequest() && !request.frame().parentFrame() && new URL(request.url()).origin !== origin) {
1483
+ return route.abort('blockedbyclient');
1484
+ }
1485
+ return route.continue();
1486
+ });
1487
+ }
1488
+ captureStartedAt = videoRequested ? now() : null;
1390
1489
  page = await context.newPage();
1490
+ if (videoRequested) {
1491
+ // Context video may briefly allocate a popup file; close it immediately
1492
+ // and discard all non-selected files during finalization.
1493
+ page.on('popup', popup => { void popup.close().catch(() => {}); });
1494
+ page.on('crash', () => { void stop({ reason: 'game_crashed' }).catch(() => {}); });
1495
+ page.on('close', () => { if (!stopPromise) void stop({ reason: 'page_closed' }).catch(() => {}); });
1496
+ captureTimer = setInterval(() => { void inspectCapture(); }, 250);
1497
+ captureTimer.unref?.();
1498
+ captureDeadline = setTimeout(() => { void stop({ reason: 'duration_limit' }).catch(() => {}); }, limits.durationMs);
1499
+ captureDeadline.unref?.();
1500
+ }
1501
+ runSignal = signal;
1502
+ onAbort = () => { void stop({ reason: 'canceled' }).catch(() => {}); };
1503
+ signal?.addEventListener('abort', onAbort, { once: true });
1504
+ if (signal?.aborted) { await stop({ reason: 'canceled' }); throw new DriverError('run canceled during browser launch'); }
1505
+
1391
1506
  page.on('console', message => {
1392
1507
  if (message.type() !== 'error') return;
1393
1508
  let location = null;
@@ -1426,6 +1541,11 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
1426
1541
  if (response && response.status() >= 400) {
1427
1542
  throw new DriverError(`${url} answered ${response.status()}`, { fatal: true });
1428
1543
  }
1544
+ } catch (error) {
1545
+ await stop({ reason: 'launch_failed' }).catch(() => {});
1546
+ if (captureDirectory) await rm(captureDirectory, { recursive: true, force: true }).catch(() => {});
1547
+ throw error;
1548
+ }
1429
1549
  },
1430
1550
 
1431
1551
  /** @returns {Promise<import('./driver.js').Observation>} */
@@ -1586,7 +1706,9 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
1586
1706
  async screenshot(name) {
1587
1707
  const current = requirePage();
1588
1708
  shots += 1;
1589
- const relativePath = path.join('screenshots', screenshotName(shots, name));
1709
+ // Forward slashes on every platform: this is the evidence ref the server
1710
+ // matches against upload paths.
1711
+ const relativePath = path.posix.join('screenshots', screenshotName(shots, name));
1590
1712
  const absolute = path.join(runDirectory, relativePath);
1591
1713
  await mkdir(path.dirname(absolute), { recursive: true });
1592
1714
  await current.screenshot({ path: absolute, type: 'png', timeout: ACTION_TIMEOUT_MS });
@@ -1598,33 +1720,8 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
1598
1720
  return attachNetworkFailures(consoleBuffer.splice(0, consoleBuffer.length), networkFailures);
1599
1721
  },
1600
1722
 
1601
- /** @returns {Promise<{videoPath: string|null}>} */
1602
- async stop() {
1603
- let videoPath = null;
1604
- const video = videoRequested && page && typeof page.video === 'function' ? page.video() : null;
1605
- try {
1606
- if (context) await context.close();
1607
- } catch {
1608
- // A context that is already gone is not an error worth failing the
1609
- // run over; the report is already written by the time we get here.
1610
- }
1611
- if (video) {
1612
- try {
1613
- videoPath = await renameVideo(await video.path(), runDirectory);
1614
- } catch {
1615
- videoPath = await findVideo(runDirectory);
1616
- }
1617
- }
1618
- try {
1619
- if (browser) await browser.close();
1620
- } catch {
1621
- // Same: a closed browser is the desired end state either way.
1622
- }
1623
- page = null;
1624
- context = null;
1625
- browser = null;
1626
- return { videoPath };
1627
- }
1723
+ /** Idempotent: cancellation, crashes and finally share one finalization. */
1724
+ stop
1628
1725
  };
1629
1726
  }
1630
1727
 
@@ -1694,37 +1791,4 @@ export function attachNetworkFailures(entries, failures) {
1694
1791
  });
1695
1792
  }
1696
1793
 
1697
- /**
1698
- * Playwright names its recording with a random id. The upload allowlist and
1699
- * the server's 90 day lifecycle rule are written against `session.webm`, so
1700
- * the file is renamed rather than uploaded under whatever name it got.
1701
- *
1702
- * @param {string} source
1703
- * @param {string} runDir
1704
- * @returns {Promise<string>}
1705
- */
1706
- async function renameVideo(source, runDir) {
1707
- const target = path.join(runDir, 'session.webm');
1708
- if (!source || path.resolve(source) === path.resolve(target)) return target;
1709
- await rename(source, target);
1710
- return target;
1711
- }
1712
-
1713
- /**
1714
- * @param {string} runDir
1715
- * @returns {Promise<string|null>}
1716
- */
1717
- async function findVideo(runDir) {
1718
- try {
1719
- const entries = await readdir(runDir);
1720
- const webm = entries.find(entry => entry.endsWith('.webm'));
1721
- if (!webm) return null;
1722
- const source = path.join(runDir, webm);
1723
- await stat(source);
1724
- return await renameVideo(source, runDir);
1725
- } catch {
1726
- return null;
1727
- }
1728
- }
1729
-
1730
1794
  export default createWebDriver;
@@ -61,7 +61,7 @@ export function createWebDriverAdapter({name='desktop_driver',fetch:fetchImpl=gl
61
61
  }catch(error){return {ok:false,changed:false,error:error instanceof DriverError?error.message:'Device input failed.'};}
62
62
  finally {if(session)await request('DELETE',route('/actions')).catch(()=>{});}
63
63
  },
64
- async screenshot(reason){const relativePath=path.join('screenshots',screenshotName(++shots,reason));const file=path.join(runDirectory,relativePath);await fs.writeFile(file,Buffer.from(await frame(),'base64'));return {path:file,relativePath,reason};},
64
+ async screenshot(reason){const relativePath=path.posix.join('screenshots',screenshotName(++shots,reason));const file=path.join(runDirectory,relativePath);await fs.writeFile(file,Buffer.from(await frame(),'base64'));return {path:file,relativePath,reason};},
65
65
  async consoleErrors(){return [];},
66
66
  async stop(){if(session){const current=route('');session=null;await request('DELETE',current);}return {videoPath:null};}
67
67
  };return driver;