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.
- package/README.md +60 -4
- package/docs/SIMULATION.md +1 -1
- package/docs/godot-driver.md +310 -0
- package/package.json +2 -1
- package/src/api/client.js +2 -2
- package/src/cli.js +3 -1
- package/src/commands/run.js +51 -19
- package/src/run/actionSelector.js +3 -2
- package/src/run/aggregate.js +4 -1
- package/src/run/args.js +2 -0
- package/src/run/comparison.js +32 -0
- package/src/run/controllers.js +17 -0
- package/src/run/drivers/driver.js +5 -5
- package/src/run/drivers/godot.js +131 -39
- package/src/run/drivers/recording.js +148 -0
- package/src/run/drivers/stdio.js +24 -8
- package/src/run/drivers/web.js +131 -67
- package/src/run/drivers/webdriver.js +1 -1
- package/src/run/personaLoop.js +140 -49
- package/src/run/resume.js +16 -5
- package/src/upload/index.js +4 -2
- package/src/upload/recording.js +56 -0
|
@@ -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
|
+
}
|
package/src/run/drivers/stdio.js
CHANGED
|
@@ -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
|
};
|
package/src/run/drivers/web.js
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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
|
-
/**
|
|
1602
|
-
|
|
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;
|