ravensight-playtest 0.1.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.
Files changed (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +380 -0
  3. package/addons/ravensight_driver/driver.gd +836 -0
  4. package/addons/ravensight_driver/export_plugin.gd +51 -0
  5. package/addons/ravensight_driver/plugin.cfg +7 -0
  6. package/addons/ravensight_driver/plugin.gd +36 -0
  7. package/bin/ravensight-playtest.js +31 -0
  8. package/package.json +45 -0
  9. package/src/api/README.md +500 -0
  10. package/src/api/client.js +340 -0
  11. package/src/api/errors.js +115 -0
  12. package/src/api/http.js +194 -0
  13. package/src/api/index.js +107 -0
  14. package/src/auth/deviceCode.js +79 -0
  15. package/src/auth/keychain.js +159 -0
  16. package/src/auth/session.js +128 -0
  17. package/src/cli.js +335 -0
  18. package/src/commands/brief.js +303 -0
  19. package/src/commands/check.js +318 -0
  20. package/src/commands/fakeCore.js +379 -0
  21. package/src/commands/init.js +120 -0
  22. package/src/commands/login.js +90 -0
  23. package/src/commands/logout.js +70 -0
  24. package/src/commands/open.js +125 -0
  25. package/src/commands/profile.js +262 -0
  26. package/src/commands/resume.js +156 -0
  27. package/src/commands/run.js +1015 -0
  28. package/src/commands/upload.js +137 -0
  29. package/src/config.js +100 -0
  30. package/src/dashboard.js +97 -0
  31. package/src/detect.js +77 -0
  32. package/src/errors.js +44 -0
  33. package/src/fsutil.js +77 -0
  34. package/src/godot.js +85 -0
  35. package/src/packs/index.js +191 -0
  36. package/src/paths.js +129 -0
  37. package/src/run/aggregate.js +658 -0
  38. package/src/run/args.js +111 -0
  39. package/src/run/context.js +181 -0
  40. package/src/run/deps.js +184 -0
  41. package/src/run/drivers/driver.js +183 -0
  42. package/src/run/drivers/godot-observation.js +138 -0
  43. package/src/run/drivers/godot-project.js +475 -0
  44. package/src/run/drivers/godot-rpc.js +225 -0
  45. package/src/run/drivers/godot.js +587 -0
  46. package/src/run/drivers/index.js +52 -0
  47. package/src/run/drivers/web.js +385 -0
  48. package/src/run/exit.js +21 -0
  49. package/src/run/heartbeat.js +131 -0
  50. package/src/run/index.js +31 -0
  51. package/src/run/json.js +56 -0
  52. package/src/run/model.js +384 -0
  53. package/src/run/paths.js +88 -0
  54. package/src/run/personaLoop.js +871 -0
  55. package/src/run/profile.js +214 -0
  56. package/src/run/regenerate.js +149 -0
  57. package/src/run/repoTools.js +286 -0
  58. package/src/run/report.js +222 -0
  59. package/src/run/resume.js +272 -0
  60. package/src/run/secretScan.js +171 -0
  61. package/src/run/state.js +198 -0
  62. package/src/run/synthetic.js +206 -0
  63. package/src/run/tools.js +344 -0
  64. package/src/run/transcript.js +93 -0
  65. package/src/run/usage.js +115 -0
  66. package/src/state/index.js +105 -0
  67. package/src/states.js +104 -0
  68. package/src/ui/index.js +195 -0
  69. package/src/upload/allowlist.js +116 -0
  70. package/src/upload/index.js +467 -0
  71. package/src/upload/queue.js +114 -0
  72. package/src/version.js +63 -0
@@ -0,0 +1,587 @@
1
+ // The godot_driver Driver implementation (spec 07, spec 03's Driver interface).
2
+ //
3
+ // ./driver.js is the interface: the five methods (launch, observe, act,
4
+ // screenshot, stop), the optional consoleErrors() and chapter(title), the
5
+ // Observation and Action typedefs, and the two places the drivers are allowed
6
+ // to differ. This class implements all of it, and `assertDriver` checks it at
7
+ // load time. Read that file for the contract; there is deliberately no second
8
+ // copy of it here, because two copies drift.
9
+ //
10
+ // Both of the differences ./driver.js names fall on this driver's side: a
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.
14
+ //
15
+ // What happens at launch, in order:
16
+ // 1. find the developer's own Godot binary (spec 17 detection order),
17
+ // 2. copy the project to a temp dir and inject the driver addon there, so
18
+ // the real project directory is never written to,
19
+ // 3. spawn Godot with --ravensight-driver plus a per run loopback token, and
20
+ // with RAVENSIGHT_PLAYTEST_TOKEN / _RUN_ID / _JOB_ID / _PERSONA set so the
21
+ // game's own Ravensight SDK tags every event it tracks as synthetic,
22
+ // 4. dial 127.0.0.1, say hello{token}, and poll ping until the game answers.
23
+ import { spawn } from 'node:child_process';
24
+ import { randomBytes } from 'node:crypto';
25
+ import { createWriteStream } from 'node:fs';
26
+ import fs from 'node:fs/promises';
27
+ import path from 'node:path';
28
+
29
+ import { buildObservation, needsVision } from './godot-observation.js';
30
+ import {
31
+ detectGodotBinary,
32
+ findFreePort,
33
+ packagedAddonDir,
34
+ prepareProjectCopy,
35
+ } from './godot-project.js';
36
+ import { DriverRpcError, GodotRpcClient } from './godot-rpc.js';
37
+
38
+ export const DRIVER_NAME = 'godot_driver';
39
+ export const BOOT_TIMEOUT_MS = 60000;
40
+ export const BOOT_POLL_MS = 250;
41
+ const FRAMES_PER_SECOND = 60;
42
+
43
+ /**
44
+ * The only environment variables forwarded to Godot.
45
+ *
46
+ * Godot needs a real environment to start, but it is also running the
47
+ * customer's game code, and that code has no business seeing the developer's
48
+ * ANTHROPIC_API_KEY, RAVENSIGHT_TOKEN, cloud credentials or CI secrets. So the
49
+ * environment is built from this list rather than inherited wholesale. Names are
50
+ * matched case insensitively, because Windows environment blocks are.
51
+ */
52
+ export const ENV_ALLOWLIST = [
53
+ 'PATH',
54
+ 'HOME',
55
+ 'USER',
56
+ 'LOGNAME',
57
+ 'SHELL',
58
+ 'TMPDIR',
59
+ 'TMP',
60
+ 'TEMP',
61
+ 'LANG',
62
+ 'LC_ALL',
63
+ 'LC_CTYPE',
64
+ 'TERM',
65
+ 'TZ',
66
+ // X11 and Wayland, so a Linux run can find a real or a virtual display. The
67
+ // last two are what makes the Xvfb path work: a software GL stack usually
68
+ // lives somewhere LD_LIBRARY_PATH points, and LIBGL_ALWAYS_SOFTWARE is how a
69
+ // headless CI box is told to use it rather than hunting for a GPU.
70
+ 'DISPLAY',
71
+ 'WAYLAND_DISPLAY',
72
+ 'LD_LIBRARY_PATH',
73
+ 'LIBGL_ALWAYS_SOFTWARE',
74
+ 'XAUTHORITY',
75
+ 'XDG_RUNTIME_DIR',
76
+ 'XDG_DATA_HOME',
77
+ 'XDG_CONFIG_HOME',
78
+ 'XDG_CACHE_HOME',
79
+ 'XDG_SESSION_TYPE',
80
+ 'DBUS_SESSION_BUS_ADDRESS',
81
+ // Windows.
82
+ 'SystemRoot',
83
+ 'SystemDrive',
84
+ 'windir',
85
+ 'ComSpec',
86
+ 'PATHEXT',
87
+ 'NUMBER_OF_PROCESSORS',
88
+ 'PROCESSOR_ARCHITECTURE',
89
+ 'OS',
90
+ 'USERPROFILE',
91
+ 'USERNAME',
92
+ 'APPDATA',
93
+ 'LOCALAPPDATA',
94
+ 'PROGRAMFILES',
95
+ 'PROGRAMFILES(X86)',
96
+ 'PROGRAMDATA',
97
+ 'HOMEDRIVE',
98
+ 'HOMEPATH',
99
+ ];
100
+ const ERROR_LINE = /^(?:USER )?(?:SCRIPT )?ERROR:|^SCRIPT ERROR/;
101
+ const SOURCE_LINE = /^\s+at:\s*(.+)$/;
102
+
103
+ export class GodotDriver {
104
+ constructor({
105
+ projectDir,
106
+ runDir,
107
+ godotPath,
108
+ addonDir = packagedAddonDir(),
109
+ headless = false,
110
+ viewport = { w: 1280, h: 720 },
111
+ playtest = {},
112
+ driverToken,
113
+ port,
114
+ bootTimeoutMs = BOOT_TIMEOUT_MS,
115
+ callTimeoutMs = 15000,
116
+ exitTimeoutMs = 5000,
117
+ treeDepth = 6,
118
+ settleFrames = 2,
119
+ keepTemp = false,
120
+ env = process.env,
121
+ extraEnv = {},
122
+ spawnFn = spawn,
123
+ detect = detectGodotBinary,
124
+ prepare = prepareProjectCopy,
125
+ choosePort = findFreePort,
126
+ now = () => Date.now(),
127
+ } = {}) {
128
+ this.name = DRIVER_NAME;
129
+ this.projectDir = projectDir;
130
+ this.runDir = runDir;
131
+ this.godotPath = godotPath;
132
+ this.addonDir = addonDir;
133
+ this.headless = headless;
134
+ this.viewport = viewport;
135
+ this.playtest = playtest;
136
+ this.driverToken = driverToken ?? randomBytes(24).toString('hex');
137
+ this.port = port;
138
+ this.bootTimeoutMs = bootTimeoutMs;
139
+ this.callTimeoutMs = callTimeoutMs;
140
+ this.exitTimeoutMs = exitTimeoutMs;
141
+ this.treeDepth = treeDepth;
142
+ this.settleFrames = settleFrames;
143
+ this.keepTemp = keepTemp;
144
+ this.env = env;
145
+ this.extraEnv = extraEnv;
146
+ this._spawn = spawnFn;
147
+ this._detect = detect;
148
+ this._prepare = prepare;
149
+ this._choosePort = choosePort;
150
+ this._now = now;
151
+
152
+ this.rpc = null;
153
+ this.child = null;
154
+ this.godot = null;
155
+ this.projectCopy = null;
156
+ this.tempRoot = null;
157
+ this.logPath = null;
158
+ this.ping = null;
159
+ this.exitCode = null;
160
+ this.exitSignal = null;
161
+ this.chapters = [];
162
+
163
+ this._startedAt = null;
164
+ this._step = 0;
165
+ this._shotIndex = 0;
166
+ this._frameIndex = 0;
167
+ this._lastHash = null;
168
+ this._logTail = [];
169
+ this._logPending = '';
170
+ this._consoleQueue = [];
171
+ this._videoTimer = null;
172
+ this._framesDir = null;
173
+ this._stopped = false;
174
+ }
175
+
176
+ /** True once the game process exited on its own, which is a finding, not a driver failure. */
177
+ get crashed() {
178
+ return !this._stopped && this.exitCode !== null && this.exitCode !== 0;
179
+ }
180
+
181
+ async launch({ recordVideo = false, viewport } = {}) {
182
+ if (viewport) this.viewport = viewport;
183
+ if (!this.projectDir) throw new Error('godot driver needs a projectDir');
184
+ this.godot = this.godotPath
185
+ ? await this._detect({ explicitPath: this.godotPath, env: this.env, projectDir: this.projectDir })
186
+ : await this._detect({ env: this.env, projectDir: this.projectDir });
187
+
188
+ const prepared = await this._prepare({
189
+ projectDir: this.projectDir,
190
+ addonDir: this.addonDir,
191
+ });
192
+ this.tempRoot = prepared.tempRoot;
193
+ this.projectCopy = prepared.projectCopy;
194
+ try {
195
+ this.port = this.port ?? (await this._choosePort());
196
+
197
+ if (this.runDir) await fs.mkdir(this.runDir, { recursive: true });
198
+ this.logPath = this.runDir ? path.join(this.runDir, 'godot.log') : null;
199
+
200
+ const args = ['--path', this.projectCopy];
201
+ if (this.headless) args.push('--headless');
202
+ else args.push('--resolution', `${this.viewport.w}x${this.viewport.h}`);
203
+ args.push('--', '--ravensight-driver', `--ravensight-driver-port=${this.port}`);
204
+
205
+ this.child = this._spawn(this.godot.path, args, {
206
+ cwd: this.projectCopy,
207
+ env: this.launchEnv(),
208
+ stdio: ['ignore', 'pipe', 'pipe'],
209
+ });
210
+ this._attachChild();
211
+
212
+ this.rpc = new GodotRpcClient({ port: this.port, timeoutMs: this.callTimeoutMs });
213
+ await this._connectWithinBootTimeout();
214
+ await this.rpc.call('hello', { token: this.driverToken });
215
+ this.ping = await this.rpc.call('ping');
216
+ this.headless = Boolean(this.ping.headless);
217
+ this._startedAt = this._now();
218
+ if (this.headless) {
219
+ this._consoleQueue.push({
220
+ kind: 'driver',
221
+ text:
222
+ 'headless Godot has no viewport and does not route synthesized mouse or GUI input: '
223
+ + 'screenshots are unavailable and clicking a Control will not press it. '
224
+ + 'Run windowed for anything with a user interface.',
225
+ });
226
+ }
227
+
228
+ if (recordVideo) await this._startFrameCapture();
229
+ return {};
230
+ } catch (error) {
231
+ // Everything past this point has already spawned a game and copied a
232
+ // project, and neither tidies itself up. Without this, a project whose
233
+ // driver never answers leaves a stray Godot process and a temp copy behind
234
+ // on every attempt, and the runner retries.
235
+ await this.stop({ removeTemp: !this.keepTemp }).catch(() => {});
236
+ throw error;
237
+ }
238
+ }
239
+
240
+ /**
241
+ * The environment Godot is launched with. RAVENSIGHT_DRIVER* activate and
242
+ * authenticate the driver; the RAVENSIGHT_PLAYTEST_* set is read by the
243
+ * customer's own Ravensight.gd in _ready(), which is what makes every event
244
+ * the game tracks during this run carry synthetic / persona / pt_run tags.
245
+ */
246
+ launchEnv() {
247
+ const { token, runId, jobId, persona } = this.playtest ?? {};
248
+ const allowed = new Set(ENV_ALLOWLIST.map((name) => name.toLowerCase()));
249
+ const out = {};
250
+ for (const [key, value] of Object.entries(this.env ?? {})) {
251
+ if (allowed.has(key.toLowerCase())) out[key] = value;
252
+ }
253
+ Object.assign(out, this.extraEnv ?? {});
254
+ Object.assign(out, {
255
+ RAVENSIGHT_DRIVER: '1',
256
+ RAVENSIGHT_DRIVER_PORT: String(this.port),
257
+ RAVENSIGHT_DRIVER_TOKEN: this.driverToken,
258
+ });
259
+ if (token) out.RAVENSIGHT_PLAYTEST_TOKEN = token;
260
+ if (runId) out.RAVENSIGHT_PLAYTEST_RUN_ID = runId;
261
+ if (jobId) out.RAVENSIGHT_PLAYTEST_JOB_ID = jobId;
262
+ if (persona) out.RAVENSIGHT_PLAYTEST_PERSONA = persona;
263
+ return out;
264
+ }
265
+
266
+ async _connectWithinBootTimeout() {
267
+ const attempts = Math.max(1, Math.ceil(this.bootTimeoutMs / BOOT_POLL_MS));
268
+ try {
269
+ await this.rpc.connect({ attempts, delayMs: BOOT_POLL_MS });
270
+ } catch (error) {
271
+ const tail = this._logTail.slice(-15).join('\n');
272
+ const exited = this.exitCode === null ? '' : ` Godot exited with code ${this.exitCode}.`;
273
+ throw new Error(
274
+ `${error.message}.${exited}${tail ? `\nLast Godot output:\n${tail}` : ''}`,
275
+ );
276
+ }
277
+ }
278
+
279
+ _attachChild() {
280
+ const log = this.logPath ? createWriteStream(this.logPath, { flags: 'a' }) : null;
281
+ for (const stream of [this.child.stdout, this.child.stderr]) {
282
+ if (!stream) continue;
283
+ stream.on('data', (chunk) => {
284
+ if (log) log.write(chunk);
285
+ this._ingestLog(chunk.toString('utf8'));
286
+ });
287
+ }
288
+ this.child.on('exit', (code, signal) => {
289
+ this.exitCode = code;
290
+ this.exitSignal = signal;
291
+ if (log) log.end();
292
+ });
293
+ this.child.on('error', (error) => {
294
+ this._consoleQueue.push({ kind: 'driver', text: `failed to launch Godot: ${error.message}` });
295
+ });
296
+ }
297
+
298
+ /**
299
+ * Godot's own errors only reach us as stdout text: the engine's Logger hook
300
+ * is not available on every 4.x version the CLI supports, so spec 07's
301
+ * fallback (parse stdout) is the path taken here. An "at:" continuation line
302
+ * becomes the source of the error above it.
303
+ */
304
+ _ingestLog(text) {
305
+ this._logPending += text;
306
+ const lines = this._logPending.split('\n');
307
+ this._logPending = lines.pop() ?? '';
308
+ for (const line of lines) {
309
+ this._logTail.push(line);
310
+ if (this._logTail.length > 200) this._logTail.shift();
311
+ const source = line.match(SOURCE_LINE);
312
+ if (source && this._consoleQueue.length > 0) {
313
+ const last = this._consoleQueue[this._consoleQueue.length - 1];
314
+ if (!last.source) last.source = source[1].trim();
315
+ continue;
316
+ }
317
+ if (ERROR_LINE.test(line.trim())) {
318
+ this._consoleQueue.push({ kind: 'error', text: line.trim() });
319
+ }
320
+ }
321
+ }
322
+
323
+ async observe({ vision } = {}) {
324
+ this._step += 1;
325
+ const snapshot = await this._snapshot();
326
+ const { state } = snapshot;
327
+ let gameState = null;
328
+ try {
329
+ gameState = await this.rpc.call('get_game_state');
330
+ } catch (error) {
331
+ // A broken _ravensight_state() hook in the customer's game is a finding
332
+ // about their game, not a reason to end the run.
333
+ if (error instanceof DriverRpcError) {
334
+ this._consoleQueue.push({ kind: 'error', text: `get_game_state: ${error.rpcMessage}`, source: error.data?.path });
335
+ } else {
336
+ throw error;
337
+ }
338
+ }
339
+ let observation = buildObservation({
340
+ state,
341
+ gameState,
342
+ step: this._step,
343
+ elapsedMs: this._startedAt === null ? 0 : this._now() - this._startedAt,
344
+ });
345
+ const wantVision = vision ?? needsVision(observation);
346
+ if (wantVision && !this.headless) {
347
+ const shot = await this._rawScreenshot();
348
+ if (shot) {
349
+ observation = { ...observation, screenshot_b64: shot.data };
350
+ }
351
+ }
352
+ // changed detection compares outline hashes only, never this observation's
353
+ // own hash: the observation text also carries the game state section, so
354
+ // comparing the two kinds of hash reported "changed" on every single act.
355
+ this._lastHash = snapshot.hash;
356
+ return observation;
357
+ }
358
+
359
+ async act(action) {
360
+ if (!action || typeof action.kind !== 'string') {
361
+ return { ok: false, error: 'action needs a kind', changed: false };
362
+ }
363
+ try {
364
+ await this._perform(action);
365
+ } catch (error) {
366
+ if (error instanceof DriverRpcError) {
367
+ return { ok: false, error: error.rpcMessage, code: error.code, data: error.data, changed: false };
368
+ }
369
+ throw error;
370
+ }
371
+ if (action.kind === 'quit') return { ok: true, changed: false };
372
+ // Input reaches the game a frame after it is parsed, and a handler that
373
+ // changes a label runs a frame after that, so a snapshot taken the instant
374
+ // an action returns reports the screen as it was one action ago. Settling
375
+ // first is what makes "changed" mean what the persona loop thinks it means.
376
+ if (this.settleFrames > 0) {
377
+ await this.rpc.call('wait_frames', { n: this.settleFrames }).catch(() => {});
378
+ }
379
+ const { hash } = await this._snapshot();
380
+ const changed = this._lastHash !== null && hash !== this._lastHash;
381
+ this._lastHash = hash;
382
+ return { ok: true, changed };
383
+ }
384
+
385
+ async _perform(action) {
386
+ switch (action.kind) {
387
+ case 'key':
388
+ await this.rpc.call('key', { keycode: action.key, pressed: true });
389
+ await this.rpc.call('wait_frames', { n: 1 });
390
+ await this.rpc.call('key', { keycode: action.key, pressed: false });
391
+ return;
392
+ case 'type':
393
+ for (const char of String(action.text ?? '')) {
394
+ await this.rpc.call('key', { keycode: char, pressed: true, unicode: char.codePointAt(0) });
395
+ await this.rpc.call('key', { keycode: char, pressed: false, unicode: char.codePointAt(0) });
396
+ }
397
+ if (action.submit) {
398
+ await this.rpc.call('key', { keycode: 'Enter', pressed: true });
399
+ await this.rpc.call('key', { keycode: 'Enter', pressed: false });
400
+ }
401
+ await this.rpc.call('wait_frames', { n: 2 });
402
+ return;
403
+ case 'click': {
404
+ const target = action.target;
405
+ if (typeof target === 'string') {
406
+ await this.rpc.call('click', { node_path: target, button: action.button, double: action.double });
407
+ } else {
408
+ await this.rpc.call('click', {
409
+ x: target?.x ?? 0,
410
+ y: target?.y ?? 0,
411
+ button: action.button,
412
+ double: action.double,
413
+ });
414
+ }
415
+ await this.rpc.call('wait_frames', { n: 2 });
416
+ return;
417
+ }
418
+ case 'press_action':
419
+ await this.rpc.call(
420
+ 'press_action',
421
+ { action: action.action, duration_ms: action.duration_ms ?? 50 },
422
+ { timeoutMs: this.callTimeoutMs + (action.duration_ms ?? 50) },
423
+ );
424
+ return;
425
+ case 'wait': {
426
+ const frames = Math.max(1, Math.round(((action.ms ?? 0) / 1000) * FRAMES_PER_SECOND));
427
+ await this.rpc.call('wait_frames', { n: frames }, { timeoutMs: this.callTimeoutMs + (action.ms ?? 0) });
428
+ return;
429
+ }
430
+ case 'set_time_scale':
431
+ await this.rpc.call('set_time_scale', { scale: action.scale ?? 1 });
432
+ return;
433
+ case 'quit':
434
+ this._stopped = true;
435
+ await this.rpc.call('quit', { code: 0 }).catch(() => {});
436
+ return;
437
+ default:
438
+ throw new Error(`godot driver cannot perform action kind "${action.kind}"`);
439
+ }
440
+ }
441
+
442
+ /**
443
+ * One get_state, plus the hash of the outline it produces.
444
+ *
445
+ * Never cached. An earlier version reused the snapshot act() had just taken,
446
+ * to save a round trip, and that handed the persona a screen one action out of
447
+ * date: the point of observe() is what is on screen now.
448
+ */
449
+ async _snapshot() {
450
+ const state = await this.rpc.call('get_state', { max_depth: this.treeDepth, visible_only: true });
451
+ const outline = buildObservation({ state, step: this._step });
452
+ return { state, hash: outline.hash };
453
+ }
454
+
455
+ async _rawScreenshot() {
456
+ if (this.headless) return null;
457
+ try {
458
+ return await this.rpc.call('screenshot', { max_width: this.viewport.w });
459
+ } catch (error) {
460
+ if (error instanceof DriverRpcError) {
461
+ this._consoleQueue.push({ kind: 'driver', text: `screenshot: ${error.rpcMessage}` });
462
+ return null;
463
+ }
464
+ throw error;
465
+ }
466
+ }
467
+
468
+ /**
469
+ * Writes screenshots/NN-slug.png inside the run dir, the layout the report
470
+ * writer links to. Headless runs have no viewport to capture, so the call
471
+ * reports that rather than failing the step.
472
+ */
473
+ async screenshot(name) {
474
+ if (this.headless) return { path: null, skipped: 'headless' };
475
+ const shot = await this._rawScreenshot();
476
+ if (!shot) return { path: null, skipped: 'unavailable' };
477
+ this._shotIndex += 1;
478
+ const dir = path.join(this.runDir ?? '.', 'screenshots');
479
+ await fs.mkdir(dir, { recursive: true });
480
+ const file = path.join(dir, `${String(this._shotIndex).padStart(2, '0')}-${slug(name)}.png`);
481
+ await fs.writeFile(file, Buffer.from(shot.data, 'base64'));
482
+ return { path: file, width: shot.width, height: shot.height };
483
+ }
484
+
485
+ /** Console errors since the previous call, from Godot stdout and driver events. */
486
+ async consoleErrors() {
487
+ const fromEvents = (this.rpc?.drainEvents() ?? [])
488
+ .filter((event) => event.type === 'error')
489
+ .map((event) => ({ kind: 'error', text: event.message, source: event.source, frame: event.frame }));
490
+ const out = [...this._consoleQueue, ...fromEvents];
491
+ this._consoleQueue = [];
492
+ return out;
493
+ }
494
+
495
+ async chapter(title) {
496
+ this.chapters.push({ title, at: this._startedAt === null ? 0 : this._now() - this._startedAt });
497
+ }
498
+
499
+ /**
500
+ * Video on a local Godot run is a sequence of stills, not a container: the
501
+ * frames land in the run dir and whoever wants a webm stitches them with
502
+ * ffmpeg, which stays optional for the CLI.
503
+ */
504
+ async _startFrameCapture({ fps = 4 } = {}) {
505
+ if (this.headless || !this.runDir) return;
506
+ this._framesDir = path.join(this.runDir, 'frames');
507
+ await fs.mkdir(this._framesDir, { recursive: true });
508
+ this._videoTimer = setInterval(() => {
509
+ void this._captureFrame();
510
+ }, Math.round(1000 / fps));
511
+ if (typeof this._videoTimer.unref === 'function') this._videoTimer.unref();
512
+ }
513
+
514
+ async _captureFrame() {
515
+ if (this._capturing || this._stopped) return;
516
+ this._capturing = true;
517
+ try {
518
+ const shot = await this._rawScreenshot();
519
+ if (!shot) return;
520
+ this._frameIndex += 1;
521
+ const file = path.join(this._framesDir, `${String(this._frameIndex).padStart(6, '0')}.png`);
522
+ await fs.writeFile(file, Buffer.from(shot.data, 'base64'));
523
+ } catch {
524
+ // A dropped frame is never worth interrupting a run for.
525
+ } finally {
526
+ this._capturing = false;
527
+ }
528
+ }
529
+
530
+ async stop({ removeTemp = !this.keepTemp } = {}) {
531
+ this._stopped = true;
532
+ if (this._videoTimer) {
533
+ clearInterval(this._videoTimer);
534
+ this._videoTimer = null;
535
+ }
536
+ if (this.rpc && !this.rpc.closed) {
537
+ await this.rpc.call('quit', { code: 0 }, { timeoutMs: 2000 }).catch(() => {});
538
+ }
539
+ await this._waitForExit(this.exitTimeoutMs);
540
+ this.rpc?.close();
541
+ if (removeTemp && this.tempRoot) {
542
+ await fs.rm(this.tempRoot, { recursive: true, force: true }).catch(() => {});
543
+ this.tempRoot = null;
544
+ this.projectCopy = null;
545
+ }
546
+ return {
547
+ videoPath: null,
548
+ framesDir: this._framesDir,
549
+ frameCount: this._frameIndex,
550
+ chapters: this.chapters,
551
+ logPath: this.logPath,
552
+ exitCode: this.exitCode,
553
+ };
554
+ }
555
+
556
+ async _waitForExit(timeoutMs) {
557
+ if (!this.child || this.exitCode !== null) return;
558
+ const exited = await new Promise((resolve) => {
559
+ const timer = setTimeout(() => resolve(false), timeoutMs);
560
+ if (typeof timer.unref === 'function') timer.unref();
561
+ this.child.once('exit', () => {
562
+ clearTimeout(timer);
563
+ resolve(true);
564
+ });
565
+ });
566
+ if (exited) return;
567
+ try {
568
+ this.child.kill('SIGKILL');
569
+ } catch {
570
+ // Already gone.
571
+ }
572
+ }
573
+ }
574
+
575
+ export function createGodotDriver(options) {
576
+ return new GodotDriver(options);
577
+ }
578
+
579
+ function slug(name) {
580
+ return (
581
+ String(name ?? 'shot')
582
+ .toLowerCase()
583
+ .replace(/[^a-z0-9]+/g, '-')
584
+ .replace(/^-+|-+$/g, '')
585
+ .slice(0, 40) || 'shot'
586
+ );
587
+ }
@@ -0,0 +1,52 @@
1
+ import { assertDriver, DriverError } from './driver.js';
2
+ import { createWebDriver } from './web.js';
3
+
4
+ /**
5
+ * The driver registry. `playwright_web` is here; `godot_driver` lives in
6
+ * ./godot.js and is imported lazily so this package still loads on a
7
+ * machine with no Godot and no addon, and `cli_stdio` is not in v1.
8
+ *
9
+ * The names are the server's `PlaytestRun.driver` enum and the report
10
+ * schema's `driver` enum, not ours to invent.
11
+ */
12
+
13
+ export const DRIVERS = Object.freeze(['playwright_web', 'godot_driver', 'cli_stdio']);
14
+
15
+ /**
16
+ * @param {string} name
17
+ * @param {Object} [options]
18
+ * @param {(specifier: string) => Promise<any>} [options.importer] test seam
19
+ * @returns {Promise<Object>} a driver satisfying ./driver.js
20
+ */
21
+ export async function createDriver(name, options = {}) {
22
+ const load = options.importer || (specifier => import(specifier));
23
+ switch (name) {
24
+ case 'playwright_web':
25
+ return assertDriver(createWebDriver(options), name);
26
+ case 'godot_driver': {
27
+ let module;
28
+ try {
29
+ module = await load('./godot.js');
30
+ } catch (error) {
31
+ throw new DriverError(
32
+ 'The godot_driver is not available in this build of ravensight-playtest. Use --driver playwright_web against a web export, or upgrade the CLI.',
33
+ { fatal: true }
34
+ );
35
+ }
36
+ const factory = module.createGodotDriver || module.default;
37
+ if (typeof factory !== 'function') {
38
+ throw new DriverError('drivers/godot.js does not export createGodotDriver', { fatal: true });
39
+ }
40
+ return assertDriver(await factory(options), name);
41
+ }
42
+ case 'cli_stdio':
43
+ throw new DriverError(
44
+ 'The cli_stdio driver is not in this release. In v1 a text build is played through its web front end with --driver playwright_web.',
45
+ { fatal: true }
46
+ );
47
+ default:
48
+ throw new DriverError(`unknown driver "${name}"; one of: ${DRIVERS.join(', ')}`, { fatal: true });
49
+ }
50
+ }
51
+
52
+ export default createDriver;