ravensight-playtest 0.3.2 → 0.5.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.
@@ -9,8 +9,8 @@
9
9
  //
10
10
  // Both of the differences ./driver.js names fall on this driver's side: a
11
11
  // string `click` target is a scene tree node path, not an accessibility ref,
12
- // and `stop()` answers `videoPath: null` with a `frames/` sequence beside it,
13
- // because stitching is ffmpeg's job and ffmpeg stays optional.
12
+ // and recording uses the selected viewport, with optional local ffmpeg encoding.
13
+ // Without an encoder or a rendered viewport it reports unsupported, never a video.
14
14
  //
15
15
  // What happens at launch, in order:
16
16
  // 1. find the developer's own Godot binary (spec 17 detection order),
@@ -34,6 +34,8 @@ import {
34
34
  prepareProjectCopy,
35
35
  } from './godot-project.js';
36
36
  import { DriverRpcError, GodotRpcClient } from './godot-rpc.js';
37
+ import { detectFfmpeg } from '../../detect.js';
38
+ import { recordingLimits, recordingViewport, recordingDirectory, recordingMetadata, saveRecording, encodeFrames } from './recording.js';
37
39
 
38
40
  export const DRIVER_NAME = 'godot_driver';
39
41
  export const BOOT_TIMEOUT_MS = 60000;
@@ -124,6 +126,9 @@ export class GodotDriver {
124
126
  prepare = prepareProjectCopy,
125
127
  choosePort = findFreePort,
126
128
  now = () => Date.now(),
129
+ recording = {},
130
+ detectEncoder = detectFfmpeg,
131
+ encode = encodeFrames,
127
132
  } = {}) {
128
133
  this.name = DRIVER_NAME;
129
134
  this.projectDir = projectDir;
@@ -148,6 +153,11 @@ export class GodotDriver {
148
153
  this._prepare = prepare;
149
154
  this._choosePort = choosePort;
150
155
  this._now = now;
156
+ this._recordingLimits = recordingLimits(recording);
157
+ this._detectEncoder = detectEncoder;
158
+ this._encode = encode;
159
+ this._frames = [];
160
+ this._frameBytes = 0;
151
161
 
152
162
  this.rpc = null;
153
163
  this.child = null;
@@ -178,8 +188,13 @@ export class GodotDriver {
178
188
  return !this._stopped && this.exitCode !== null && this.exitCode !== 0;
179
189
  }
180
190
 
181
- async launch({ recordVideo = false, viewport } = {}) {
191
+ async launch({ recordVideo = false, viewport, signal } = {}) {
192
+ if (signal?.aborted) throw new Error('run canceled before game launch');
182
193
  if (viewport) this.viewport = viewport;
194
+ if (recordVideo) {
195
+ const size = recordingViewport(this.viewport, this._recordingLimits);
196
+ this.viewport = { w: size.width, h: size.height };
197
+ }
183
198
  if (!this.projectDir) throw new Error('godot driver needs a projectDir');
184
199
  this.godot = this.godotPath
185
200
  ? await this._detect({ explicitPath: this.godotPath, env: this.env, projectDir: this.projectDir })
@@ -198,6 +213,7 @@ export class GodotDriver {
198
213
  this.logPath = this.runDir ? path.join(this.runDir, 'godot.log') : null;
199
214
 
200
215
  const args = ['--path', this.projectCopy];
216
+ if (recordVideo) args.push('--audio-driver', 'Dummy');
201
217
  if (this.headless) args.push('--headless');
202
218
  else args.push('--resolution', `${this.viewport.w}x${this.viewport.h}`);
203
219
  args.push('--', '--ravensight-driver', `--ravensight-driver-port=${this.port}`);
@@ -226,6 +242,10 @@ export class GodotDriver {
226
242
  }
227
243
 
228
244
  if (recordVideo) await this._startFrameCapture();
245
+ this._signal = signal;
246
+ this._onAbort = () => { void this.stop({ reason: 'canceled' }).catch(() => {}); };
247
+ signal?.addEventListener('abort', this._onAbort, { once: true });
248
+ if (signal?.aborted) { await this.stop({ reason: 'canceled' }); throw new Error('run canceled during game launch'); }
229
249
  return {};
230
250
  } catch (error) {
231
251
  // Everything past this point has already spawned a game and copied a
@@ -289,6 +309,10 @@ export class GodotDriver {
289
309
  this.exitCode = code;
290
310
  this.exitSignal = signal;
291
311
  if (log) log.end();
312
+ if (!this._stopped && this._recordingStartedAt != null) {
313
+ this._endCapture(code === 0 ? 'game_exited' : 'game_crashed');
314
+ void this._finalizeRecording().catch(() => {});
315
+ }
292
316
  });
293
317
  this.child.on('error', (error) => {
294
318
  this._consoleQueue.push({ kind: 'driver', text: `failed to launch Godot: ${error.message}` });
@@ -501,43 +525,114 @@ export class GodotDriver {
501
525
  this.chapters.push({ title, at: this._startedAt === null ? 0 : this._now() - this._startedAt });
502
526
  }
503
527
 
504
- /**
505
- * Video on a local Godot run is a sequence of stills, not a container: the
506
- * frames land in the run dir and whoever wants a webm stitches them with
507
- * ffmpeg, which stays optional for the CLI.
508
- */
509
- async _startFrameCapture({ fps = 4 } = {}) {
510
- if (this.headless || !this.runDir) return;
511
- this._framesDir = path.join(this.runDir, 'frames');
512
- await fs.mkdir(this._framesDir, { recursive: true });
513
- this._videoTimer = setInterval(() => {
514
- void this._captureFrame();
515
- }, Math.round(1000 / fps));
516
- if (typeof this._videoTimer.unref === 'function') this._videoTimer.unref();
528
+ // Capture only this project's viewport. No screen/audio device is opened.
529
+ async _startFrameCapture() {
530
+ this._recordingStartedAt = this._now();
531
+ if (this.runDir) this._framesDir = await recordingDirectory(this.runDir);
532
+ if (this.headless || !this.runDir) {
533
+ this._recordingUnsupported = this.headless ? 'headless_no_viewport' : 'run_directory_required';
534
+ return;
535
+ }
536
+ this._encoder = await this._detectEncoder();
537
+ if (!this._encoder?.path) {
538
+ this._recordingUnsupported = 'encoder_unavailable';
539
+ this._consoleQueue.push({ kind: 'driver', text: 'Video unavailable: install optional ffmpeg or set RAVENSIGHT_FFMPEG. No video was recorded.' });
540
+ return;
541
+ }
542
+ this._recordingStartedAt = this._now();
543
+ await this._captureFrame();
544
+ if (this._captureEndedAt != null) return;
545
+ this._videoTimer = setInterval(() => { void this._captureFrame(); }, Math.round(1000 / this._recordingLimits.fps));
546
+ this._videoTimer.unref?.();
547
+ this._videoDeadline = setTimeout(() => this._endCapture('duration_limit'), this._recordingLimits.durationMs);
548
+ this._videoDeadline.unref?.();
549
+ }
550
+
551
+ _endCapture(reason) {
552
+ if (this._captureEndedAt != null) return;
553
+ this._captureEndedAt = Math.min(this._now(), this._recordingStartedAt + this._recordingLimits.durationMs);
554
+ this._captureReason = reason;
555
+ clearInterval(this._videoTimer);
556
+ clearTimeout(this._videoDeadline);
557
+ this._videoTimer = null;
517
558
  }
518
559
 
519
560
  async _captureFrame() {
520
- if (this._capturing || this._stopped) return;
521
- this._capturing = true;
522
- try {
523
- const shot = await this._rawScreenshot();
524
- if (!shot) return;
525
- this._frameIndex += 1;
526
- const file = path.join(this._framesDir, `${String(this._frameIndex).padStart(6, '0')}.png`);
527
- await fs.writeFile(file, Buffer.from(shot.data, 'base64'));
528
- } catch {
529
- // A dropped frame is never worth interrupting a run for.
530
- } finally {
531
- this._capturing = false;
561
+ if (this._capturePromise || this._stopped || this._captureEndedAt != null) return;
562
+ if (this._now() - this._recordingStartedAt >= this._recordingLimits.durationMs) {
563
+ this._endCapture('duration_limit'); return;
532
564
  }
565
+ const at = this._now();
566
+ this._capturePromise = (async () => {
567
+ try {
568
+ const shot = await this._rawScreenshot();
569
+ if (!shot) return;
570
+ const max = this._recordingLimits.singleFrameBytes;
571
+ if (typeof shot.data !== 'string' || shot.data.length > Math.ceil(max / 3) * 4) {
572
+ this._endCapture('frame_size_limit'); return;
573
+ }
574
+ const bytes = Buffer.from(shot.data, 'base64');
575
+ if (bytes.length > max || this._frameBytes + bytes.length > this._recordingLimits.frameBytes) {
576
+ this._endCapture('frame_bytes_limit'); return;
577
+ }
578
+ const name = `${String(this._frameIndex + 1).padStart(6, '0')}.png`;
579
+ await fs.writeFile(path.join(this._framesDir, name), bytes, { flag: 'wx', mode: 0o600 });
580
+ this._frames.push({ name, at });
581
+ this._frameIndex += 1;
582
+ this._frameBytes += bytes.length;
583
+ } catch {
584
+ // A disconnected/crashed game can still leave a useful partial recording.
585
+ this._endCapture('capture_failed');
586
+ }
587
+ })();
588
+ try { await this._capturePromise; } finally { this._capturePromise = null; }
533
589
  }
534
590
 
535
- async stop({ removeTemp = !this.keepTemp } = {}) {
591
+ _finalizeRecording() {
592
+ if (this._finalizePromise) return this._finalizePromise;
593
+ this._finalizePromise = (async () => {
594
+ if (this._recordingStartedAt == null) return {};
595
+ await this._capturePromise;
596
+ const endedAt = this._captureEndedAt ?? this._now();
597
+ let metadata = recordingMetadata({ driver: this.name, startedAt: this._recordingStartedAt, endedAt,
598
+ reason: this._recordingUnsupported ?? this._captureReason ?? 'stopped',
599
+ status: this._recordingUnsupported ? 'unsupported' : 'failed', frameCount: this._frameIndex,
600
+ captureFpsLimit: this._recordingLimits.fps, timeOrigin: 'first-viewport-frame',
601
+ });
602
+ let videoPath = null;
603
+ if (!this._recordingUnsupported && this._framesDir) {
604
+ try {
605
+ const encoded = await this._encode({ directory: this._framesDir, frames: this._frames, endedAt,
606
+ encoder: this._encoder, limits: this._recordingLimits });
607
+ videoPath = path.join(this.runDir, 'session.mp4');
608
+ await fs.rename(encoded.file, videoPath);
609
+ metadata = { ...metadata, status: ['stopped', 'completed', 'game_exited'].includes(metadata.reason) ? 'complete' : 'partial',
610
+ relativePath: 'session.mp4', mimeType: encoded.mimeType, bytes: encoded.bytes,
611
+ durationMs: encoded.durationMs, durationSource: encoded.durationSource ?? 'frame-timestamps',
612
+ firstFrameOffsetMs: this._frames[0].at - this._recordingStartedAt };
613
+ if (encoded.truncated) { metadata.status = 'partial'; metadata.reason = 'size_limit'; }
614
+ } catch (error) {
615
+ metadata.reason = ['no_frames', 'encoder_unavailable', 'encoding_timeout', 'encoding_failed'].includes(error.message) ? error.message : 'encoding_failed';
616
+ }
617
+ }
618
+ // Captured game frames are local temporary data, never upload artifacts.
619
+ if (this._framesDir) await fs.rm(this._framesDir, { recursive: true, force: true });
620
+ if (this.runDir) await saveRecording(this.runDir, metadata);
621
+ return { videoPath, recording: metadata };
622
+ })();
623
+ return this._finalizePromise;
624
+ }
625
+
626
+ stop(options = {}) {
627
+ if (!this._stopPromise) this._stopPromise = Promise.resolve().then(() => this._stop(options));
628
+ return this._stopPromise;
629
+ }
630
+
631
+ async _stop({ removeTemp = !this.keepTemp, reason = 'stopped' } = {}) {
536
632
  this._stopped = true;
537
- if (this._videoTimer) {
538
- clearInterval(this._videoTimer);
539
- this._videoTimer = null;
540
- }
633
+ this._signal?.removeEventListener('abort', this._onAbort);
634
+ if (this._recordingStartedAt != null) this._endCapture(reason);
635
+ await this._capturePromise;
541
636
  if (this.rpc && !this.rpc.closed) {
542
637
  await this.rpc.call('quit', { code: 0 }, { timeoutMs: 2000 }).catch(() => {});
543
638
  }
@@ -549,12 +644,9 @@ export class GodotDriver {
549
644
  this.projectCopy = null;
550
645
  }
551
646
  return {
552
- videoPath: null,
553
- framesDir: this._framesDir,
554
- frameCount: this._frameIndex,
555
- chapters: this.chapters,
556
- logPath: this.logPath,
557
- exitCode: this.exitCode,
647
+ videoPath: null, ...await this._finalizeRecording(),
648
+ frameCount: this._frameIndex, chapters: this.chapters,
649
+ logPath: this.logPath, exitCode: this.exitCode,
558
650
  };
559
651
  }
560
652
 
@@ -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
+ }
@@ -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>} */
@@ -1598,33 +1718,8 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
1598
1718
  return attachNetworkFailures(consoleBuffer.splice(0, consoleBuffer.length), networkFailures);
1599
1719
  },
1600
1720
 
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
- }
1721
+ /** Idempotent: cancellation, crashes and finally share one finalization. */
1722
+ stop
1628
1723
  };
1629
1724
  }
1630
1725
 
@@ -1694,37 +1789,4 @@ export function attachNetworkFailures(entries, failures) {
1694
1789
  });
1695
1790
  }
1696
1791
 
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
1792
  export default createWebDriver;