@vgai/live 0.5.2 → 0.5.4

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 (62) hide show
  1. package/dist/.tsbuildinfo +1 -0
  2. package/dist/editor.d.ts +78 -55
  3. package/dist/editor.js +129 -76
  4. package/dist/game-client/bridge-heartbeat.d.ts +49 -0
  5. package/dist/game-client/bridge-heartbeat.js +46 -0
  6. package/dist/game-client/bridge-transport.d.ts +75 -0
  7. package/dist/game-client/bridge-transport.js +19 -0
  8. package/dist/game-client/client.d.ts +293 -0
  9. package/dist/game-client/client.js +706 -0
  10. package/dist/game-client/errors.d.ts +57 -0
  11. package/dist/game-client/errors.js +76 -0
  12. package/dist/game-client/events-matcher.d.ts +41 -0
  13. package/dist/game-client/events-matcher.js +68 -0
  14. package/dist/game-client/failure-block.d.ts +93 -0
  15. package/dist/game-client/failure-block.js +97 -0
  16. package/dist/game-client/fast-forward.d.ts +125 -0
  17. package/dist/game-client/fast-forward.js +122 -0
  18. package/dist/game-client/hidden-recovery.d.ts +85 -0
  19. package/dist/game-client/hidden-recovery.js +105 -0
  20. package/dist/game-client/index.d.ts +40 -0
  21. package/dist/game-client/index.js +26 -0
  22. package/dist/game-client/perf-sampling.d.ts +56 -0
  23. package/dist/game-client/perf-sampling.js +85 -0
  24. package/dist/game-client/relay-transport.d.ts +100 -0
  25. package/dist/game-client/relay-transport.js +237 -0
  26. package/dist/game-client/screenshot-target.d.ts +60 -0
  27. package/dist/game-client/screenshot-target.js +68 -0
  28. package/dist/game-client/state-cap.d.ts +7 -0
  29. package/dist/game-client/state-cap.js +21 -0
  30. package/dist/game-client/types.d.ts +128 -0
  31. package/dist/game-client/types.js +15 -0
  32. package/dist/game-client/wait-for.d.ts +155 -0
  33. package/dist/game-client/wait-for.js +229 -0
  34. package/dist/game.d.ts +47 -18
  35. package/dist/game.js +59 -16
  36. package/dist/index.d.ts +46 -21
  37. package/dist/index.js +51 -20
  38. package/dist/session.d.ts +4 -4
  39. package/dist/session.js +7 -7
  40. package/dist/tools.d.ts +12 -3
  41. package/dist/tools.js +15 -6
  42. package/package.json +10 -5
  43. package/src/editor.ts +142 -96
  44. package/src/game-client/bridge-heartbeat.ts +61 -0
  45. package/src/game-client/bridge-transport.ts +73 -0
  46. package/src/game-client/client.ts +836 -0
  47. package/src/game-client/errors.ts +96 -0
  48. package/src/game-client/events-matcher.ts +106 -0
  49. package/src/game-client/failure-block.ts +199 -0
  50. package/src/game-client/fast-forward.ts +175 -0
  51. package/src/game-client/hidden-recovery.ts +149 -0
  52. package/src/game-client/index.ts +98 -0
  53. package/src/game-client/perf-sampling.ts +94 -0
  54. package/src/game-client/relay-transport.ts +311 -0
  55. package/src/game-client/screenshot-target.ts +91 -0
  56. package/src/game-client/state-cap.ts +29 -0
  57. package/src/game-client/types.ts +137 -0
  58. package/src/game-client/wait-for.ts +327 -0
  59. package/src/game.ts +96 -16
  60. package/src/index.ts +68 -31
  61. package/src/session.ts +8 -10
  62. package/src/tools.ts +19 -6
@@ -0,0 +1,706 @@
1
+ /**
2
+ * The Playwright-page-bound wiring for `game`'s client methods (D17: specs
3
+ * drive `window.__vgai` via `page.evaluate` — the standalone page has no HTTP
4
+ * channel). This module is deliberately the ONLY place that touches `Page`;
5
+ * the budget math (`wait-for.ts`), the events matcher (`events-matcher.ts`),
6
+ * and the failure-block assembler (`failure-block.ts`) are plain modules
7
+ * that never import Playwright, per Task 3.2's architecture requirement.
8
+ */
9
+ import { mkdir } from 'node:fs/promises';
10
+ import { dirname, resolve } from 'node:path';
11
+ import { maybeBridgeHeartbeat } from './bridge-heartbeat.js';
12
+ import { appendWarmSessionHint, inputGatedError, SessionError, SessionFailure } from './errors.js';
13
+ import { describeEventsExpectation, matchEventsSubsequence } from './events-matcher.js';
14
+ import { assembleFailureBlock } from './failure-block.js';
15
+ import { runFastForward, } from './fast-forward.js';
16
+ import { HiddenRecoveryDriver } from './hidden-recovery.js';
17
+ import { TpsAccumulator } from './perf-sampling.js';
18
+ import { resolveScreenshotTarget } from './screenshot-target.js';
19
+ import { assertValidWaitForBudget, maybeHeartbeat, runWaitFor, WAIT_FOR_STALL_POLL_LIMIT, WaitForTimeoutError, } from './wait-for.js';
20
+ /** Runs inside the page. Kept as a single exported plain function (not a
21
+ * closure over Node state) because `page.evaluate(fn, arg)` only ships
22
+ * `fn`'s own source across the boundary. */
23
+ function bridgeCallInPage(args) {
24
+ const bridge = window.__vgai;
25
+ if (!bridge) {
26
+ return {
27
+ ok: false,
28
+ error: { code: undefined, message: 'window.__vgai is not installed on this page' },
29
+ };
30
+ }
31
+ const parts = args.method.split('.');
32
+ let parent = bridge;
33
+ for (let i = 0; i < parts.length - 1; i++) {
34
+ parent = parent[parts[i]];
35
+ }
36
+ const key = parts[parts.length - 1];
37
+ const fn = parent[key];
38
+ try {
39
+ const result = fn.apply(parent, args.callArgs);
40
+ return { ok: true, result };
41
+ }
42
+ catch (err) {
43
+ const e = err;
44
+ return {
45
+ ok: false,
46
+ error: { code: e?.code, message: e?.message ?? String(err), data: e?.data },
47
+ };
48
+ }
49
+ }
50
+ /** Same shape, but awaits the call — used for `invoke` (async debug
51
+ * commands). */
52
+ async function bridgeCallInPageAsync(args) {
53
+ const bridge = window.__vgai;
54
+ if (!bridge) {
55
+ return {
56
+ ok: false,
57
+ error: { code: undefined, message: 'window.__vgai is not installed on this page' },
58
+ };
59
+ }
60
+ const parts = args.method.split('.');
61
+ let parent = bridge;
62
+ for (let i = 0; i < parts.length - 1; i++) {
63
+ parent = parent[parts[i]];
64
+ }
65
+ const key = parts[parts.length - 1];
66
+ const fn = parent[key];
67
+ try {
68
+ const result = await fn.apply(parent, args.callArgs);
69
+ return { ok: true, result };
70
+ }
71
+ catch (err) {
72
+ const e = err;
73
+ return {
74
+ ok: false,
75
+ error: { code: e?.code, message: e?.message ?? String(err), data: e?.data },
76
+ };
77
+ }
78
+ }
79
+ /**
80
+ * #140 — the `BridgeTransport` `page.evaluate` implementation. This is the
81
+ * ONE place a `Page` is ever touched to drive `window.__vgai` (module doc
82
+ * above); `RelayTransport` (`relay-transport.ts`) is the sibling
83
+ * implementation for the live editor session, and imports no Playwright at
84
+ * all. Exported so `fixture.ts` (which already imports `@playwright/test`
85
+ * for its own `page.goto`/error-listener setup) can construct one.
86
+ */
87
+ export class PageTransport {
88
+ page;
89
+ constructor(page) {
90
+ this.page = page;
91
+ }
92
+ async call(method, callArgs) {
93
+ return this.page.evaluate(bridgeCallInPage, { method, callArgs });
94
+ }
95
+ async callAsync(method, callArgs) {
96
+ return this.page.evaluate(bridgeCallInPageAsync, { method, callArgs });
97
+ }
98
+ async isHidden() {
99
+ return this.page.evaluate(() => document.hidden);
100
+ }
101
+ async bringToFront() {
102
+ await this.page.bringToFront();
103
+ }
104
+ async screenshot(path) {
105
+ await mkdir(dirname(path), { recursive: true });
106
+ await this.page.screenshot({ path });
107
+ }
108
+ /** Wave-2: the ONE transport that runs a `game.page()` step against a REAL
109
+ * Playwright `Page` — no serialization, so `step`'s own closures work
110
+ * here (see `bridge-transport.ts`'s `runPageScript` doc comment for the
111
+ * full honesty-boundary contract; `src` is unused on this leg, kept only
112
+ * to satisfy the shared interface). */
113
+ async runPageScript(_src, step) {
114
+ try {
115
+ const result = await step(this.page);
116
+ return { ok: true, result };
117
+ }
118
+ catch (err) {
119
+ return {
120
+ ok: false,
121
+ error: { code: undefined, message: err instanceof Error ? err.message : String(err) },
122
+ };
123
+ }
124
+ }
125
+ }
126
+ /**
127
+ * A relay-transport call that overran its async-invoke timeout budget
128
+ * surfaces as a `RELAY_UNREACHABLE` `SessionError` whose message names the
129
+ * timeout (`relay-transport.ts`'s `postCommand` uses `AbortSignal.timeout`,
130
+ * and Node's fetch rejects an aborted-by-timeout signal with a message
131
+ * containing "timeout") — a genuine network-down failure gets the SAME code
132
+ * but never mentions timeout, so this narrows on the message text, not just
133
+ * the code, before deciding to append the split-the-hold hint. Appends
134
+ * rather than replaces so the underlying transport message stays visible.
135
+ * `action` is whatever the caller passed to `hold()` (a single name or an
136
+ * array of them) — `JSON.stringify` renders either shape sensibly in the
137
+ * hint.
138
+ */
139
+ function rethrowIfHoldTimedOut(err, action, simSeconds) {
140
+ if (err instanceof SessionError &&
141
+ err.code === 'RELAY_UNREACHABLE' &&
142
+ /timeout/i.test(err.message)) {
143
+ throw new SessionError(err.code, `${err.message}\nhint: game.input.hold(${JSON.stringify(action)}, { simSeconds: ${simSeconds} }) ` +
144
+ "may have exceeded the relay transport's async-invoke timeout budget — split it into " +
145
+ 'multiple shorter game.input.hold() calls instead of one long hold', err.data);
146
+ }
147
+ throw err;
148
+ }
149
+ /** {@link GameInput.tap}'s budget: one fixed tick at the default 60Hz
150
+ * timestep. `waitForHoldBudget` resolves on the FIRST reading that covers
151
+ * the budget, so this asks for the shortest press a tick can actually
152
+ * service — never a wall-clock duration. A project running a non-default
153
+ * fixed timestep simply covers it in its own single (longer) tick. */
154
+ const TAP_SIM_SECONDS = 1 / 60;
155
+ export class GameInput {
156
+ client;
157
+ constructor(client) {
158
+ this.client = client;
159
+ }
160
+ /**
161
+ * Holds one action, or SIMULTANEOUSLY holds several — `hold('jump')` and
162
+ * `hold(['move_right', 'move_forward'])` (the natural way to drive a
163
+ * diagonal) are both first-class. `action` is deliberately typed as
164
+ * `string | string[]`, never just `string`: a bare JS array silently
165
+ * stringifies to a comma-joined name (`['move_right','move_forward']` →
166
+ * `"move_right,move_forward"`) wherever it crosses a template literal or
167
+ * the bridge's JSON boundary, which used to surface as a baffling
168
+ * `unknown action "move_right,move_forward"` — the array was accepted
169
+ * structurally (nothing rejected it) and then silently mangled instead.
170
+ * Accepting the array for real, instead of merely rejecting it, is the
171
+ * useful behavior: every 2D/3D mover needs "hold two directions at once"
172
+ * as its ordinary case, not an edge case.
173
+ *
174
+ * Single action: one `holdFor` bridge call (`runtime/debug-bridge.ts`)
175
+ * instead of the old set → `waitSimTime`'s 150ms-interval snapshot poll
176
+ * loop → clear (15+ transport round trips over the editor relay for a
177
+ * multi-second hold). The wait uses ordinary host-loop ticks while visible.
178
+ * If the browser has hidden-paused that loop, the bridge drives the held
179
+ * action through the same game phases with deterministic ticks; this
180
+ * collapses transport cost without letting a background tab deadlock it.
181
+ *
182
+ * A relay call has a bounded async-invoke timeout budget (see
183
+ * `relay-transport.ts`'s `INVOKE_TIMEOUT_MS`) — a `simSeconds` long enough
184
+ * to exceed it at worst-case sim speed is NOT silently truncated; the
185
+ * transport timeout is caught and rethrown with a hint to split the hold
186
+ * into multiple shorter `hold()` calls instead.
187
+ *
188
+ * Multiple actions: `holdFor` is a single-action bridge primitive, so this
189
+ * presses every action (`setVirtualAction(action, true)`, in order),
190
+ * waits the shared `simSeconds` once via `waitSimTime`, and releases every
191
+ * action it managed to press — in a `finally`, so a gated press (a
192
+ * `delivered: false` result throws `INPUT_GATED` immediately) or a stalled
193
+ * clock during the wait still leaves no action stuck held.
194
+ */
195
+ async hold(action, budget) {
196
+ // `hold('left', 0.5)` would otherwise send `undefined` sim-seconds down the
197
+ // `holdFor` bridge call — the same options-object-only contract as
198
+ // `waitSimTime`, named for the method the caller actually wrote.
199
+ assertValidWaitForBudget(budget, 'input.hold');
200
+ const actions = Array.isArray(action) ? action : [action];
201
+ if (actions.length === 0) {
202
+ throw new Error('game.input.hold: action array must not be empty');
203
+ }
204
+ if (actions.length === 1) {
205
+ const single = actions[0];
206
+ let result;
207
+ try {
208
+ result = await this.client.callBridgeAsync('holdFor', single, budget.simSeconds);
209
+ }
210
+ catch (err) {
211
+ throw rethrowIfHoldTimedOut(err, single, budget.simSeconds);
212
+ }
213
+ if (!result.delivered)
214
+ throw inputGatedError(single, result.reason);
215
+ return;
216
+ }
217
+ const pressed = [];
218
+ try {
219
+ for (const single of actions) {
220
+ const result = await this.client.callBridge('input.setVirtualAction', single, true);
221
+ if (!result.delivered)
222
+ throw inputGatedError(single, result.reason);
223
+ pressed.push(single);
224
+ }
225
+ await this.client.waitSimTime(budget);
226
+ }
227
+ finally {
228
+ for (const single of pressed) {
229
+ await this.client
230
+ .callBridge('input.setVirtualAction', single, false)
231
+ .catch(() => {
232
+ // Best-effort release — a failure here must never mask whatever
233
+ // the try block itself threw (or replace a clean success with a
234
+ // spurious one), and there is nothing more this call can do
235
+ // about a bridge/session that is no longer reachable.
236
+ });
237
+ }
238
+ }
239
+ }
240
+ /**
241
+ * Keep one or more honest game actions pressed while `observe` runs, then
242
+ * release every action in a `finally` block. This is the evidence-oriented
243
+ * sibling of {@link hold}: `hold()` resolves after release, which is ideal
244
+ * for asserting distance travelled but cannot capture a moving pose or read
245
+ * transient "currently sprinting" state. `whileHeld()` makes that interval
246
+ * explicit without asking tests to hand-roll unsafe set/clear cleanup.
247
+ *
248
+ * The callback uses the same `GameClient` instance already in the test, so
249
+ * it may call `game.waitFor`, `game.state`, `game.screenshot`, or any other
250
+ * ordinary observation. No state is fabricated and the actions still enter
251
+ * through `input.setVirtualAction`/the project's real input map.
252
+ */
253
+ async whileHeld(action, observe) {
254
+ const actions = Array.isArray(action) ? action : [action];
255
+ if (actions.length === 0) {
256
+ throw new Error('game.input.whileHeld: action array must not be empty');
257
+ }
258
+ const pressed = [];
259
+ try {
260
+ for (const single of actions) {
261
+ const result = await this.client.callBridge('input.setVirtualAction', single, true);
262
+ if (!result.delivered)
263
+ throw inputGatedError(single, result.reason);
264
+ pressed.push(single);
265
+ }
266
+ return await observe();
267
+ }
268
+ finally {
269
+ for (const single of pressed) {
270
+ await this.client
271
+ .callBridge('input.setVirtualAction', single, false)
272
+ .catch(() => {
273
+ // Best-effort release: preserve the callback/gating failure while
274
+ // never leaving an action held merely because teardown lost the
275
+ // relay.
276
+ });
277
+ }
278
+ }
279
+ }
280
+ /**
281
+ * One honest one-shot press — pressed, carried through AT LEAST ONE FULL
282
+ * FIXED TICK, then released, as a single bridge call.
283
+ *
284
+ * The contract is deliberately "a tick services it", not "the bridge
285
+ * accepted it", because the raw primitive underneath (`InputManager.
286
+ * tapVirtualAction`) only QUEUES: `poll()` promotes the queue to that tick's
287
+ * just-pressed set, and nothing else ever does. On a visible tab a tick
288
+ * lands ~16ms later so the difference is invisible; on a HIDDEN tab the host
289
+ * loop is stopped outright, so a queue-only tap sat unserviced and then died
290
+ * — silently, having already reported `delivered: true` (measured:
291
+ * `input.trace: { ticks: [] }`, no event, while `whileHeld` on the same
292
+ * action worked every time). Routing through the `holdFor` primitive is what
293
+ * makes the press TICK-ALIGNED by construction: that call owns the wait, and
294
+ * its hidden-tab leg drives the tick deterministically
295
+ * (`runtime/debug-bridge.ts`'s `waitForHoldBudget`), so a tap can no longer
296
+ * fall between ticks on any transport or any tab state.
297
+ *
298
+ * A gated action still fails loudly and immediately with `INPUT_GATED`,
299
+ * exactly as {@link hold} does — same `delivered:false` contract, same
300
+ * error.
301
+ */
302
+ async tap(action) {
303
+ const result = await this.client.callBridgeAsync('holdFor', action, TAP_SIM_SECONDS);
304
+ if (!result.delivered)
305
+ throw inputGatedError(action, result.reason);
306
+ }
307
+ /**
308
+ * Sets one action, or the SAME value on several at once — same
309
+ * `string | string[]` shape as {@link hold}, and for the same reason: a
310
+ * bare array used to silently stringify into a single bogus action name
311
+ * instead of being rejected or honored.
312
+ */
313
+ async set(action, value) {
314
+ const actions = Array.isArray(action) ? action : [action];
315
+ if (actions.length === 0) {
316
+ throw new Error('game.input.set: action array must not be empty');
317
+ }
318
+ for (const single of actions) {
319
+ const result = await this.client.callBridge('input.setVirtualAction', single, value);
320
+ if (!result.delivered)
321
+ throw inputGatedError(single, result.reason);
322
+ }
323
+ }
324
+ }
325
+ export class GameEvents {
326
+ client;
327
+ constructor(client) {
328
+ this.client = client;
329
+ }
330
+ async expect(names, opts) {
331
+ // Run-4 friction #5: fence on `fenceSeq` (unambiguous — see its doc
332
+ // comment on `GameClientOptions`), not `fenceTick`. The old
333
+ // `snapshot(fenceTick)` call used the tick-based filter, which silently
334
+ // dropped an event emitted at the fence tick itself — exactly what a
335
+ // debug-command handler running right at test start produces.
336
+ const snapshot = await this.client.snapshot(this.client.fenceSeq);
337
+ // M9: pass the REAL fence tick (the test's own start), not
338
+ // `snapshot.events[0].tick` — see events-matcher.ts's doc comment.
339
+ const result = matchEventsSubsequence(names, snapshot.events, this.client.fenceTick, opts);
340
+ if (result.matched)
341
+ return;
342
+ const providers = await this.client.providers();
343
+ // D5: real deltas off the fence, not the absolute (since-boot)
344
+ // simSeconds + a hardcoded wallElapsedMs: 0 — the pre-fix code fabricated
345
+ // a "sim speed 0.00x — loop stalled" reading on every single
346
+ // events.expect failure regardless of actual game health.
347
+ const block = assembleFailureBlock({
348
+ headline: `game.events.expect failed: expected ${JSON.stringify(names)} as an ${opts?.exact ? 'exact' : 'ordered subsequence'} of events since test start`,
349
+ simElapsedSeconds: snapshot.time.simSeconds - this.client.fenceSimSeconds,
350
+ wallElapsedMs: Date.now() - this.client.fenceWallMs,
351
+ tick: snapshot.time.tick,
352
+ predicateSource: describeEventsExpectation(names, opts),
353
+ providers,
354
+ lastState: snapshot.state,
355
+ lastEvents: snapshot.events,
356
+ screenshotPath: await this.client.screenshot('events-expect-failure').catch(() => null),
357
+ consoleErrors: this.client.consoleErrors,
358
+ pageErrors: this.client.pageErrors,
359
+ recoveryNotices: this.client.recoveryNotices,
360
+ // Issue #175: same hidden-tab diagnosis toSessionFailure gets below —
361
+ // an events.expect failure deserves the same honesty about WHY the
362
+ // clock looks frozen.
363
+ loopLiveness: snapshot.time.loopLiveness,
364
+ });
365
+ throw new SessionFailure('EVENTS_EXPECT_FAILED', block);
366
+ }
367
+ }
368
+ /**
369
+ * The `game` fixture value. Every method reaches `window.__vgai` (or its
370
+ * relay-side equivalent) through `this.#transport` — see `bridge-transport.ts`'s
371
+ * module doc for the seam, and this file's own module doc for why it, alone,
372
+ * is allowed to import Playwright (via `PageTransport`, above).
373
+ */
374
+ export class GameClient {
375
+ input = new GameInput(this);
376
+ events = new GameEvents(this);
377
+ fenceTick;
378
+ /** Run-4 friction #5: see `GameClientOptions.fenceSeq`. */
379
+ fenceSeq;
380
+ /** D5: see `GameClientOptions.fenceSimSeconds`. */
381
+ fenceSimSeconds;
382
+ /** D5: see `GameClientOptions.fenceWallMs`. */
383
+ fenceWallMs;
384
+ pageErrors;
385
+ consoleErrors;
386
+ /** M13: hidden-tab recovery's own structured notices — kept SEPARATE from
387
+ * `consoleErrors` (see `hidden-recovery.ts`'s hook below and
388
+ * `failure-block.ts`'s `recoveryNotices` member). */
389
+ recoveryNotices = [];
390
+ // Everything below is `#`-PRIVATE, not `private` — deliberately, and the
391
+ // difference is observable. TypeScript's `private` is erased at compile
392
+ // time: `this.transport = …` leaves an ordinary own property that
393
+ // `Object.getOwnPropertyNames(game)` reports and `game.transport.call(…)`
394
+ // happily invokes. `vgai eval --list` enumerates this object's REAL members
395
+ // (it must — `input`/`events` are instance fields no prototype walk can
396
+ // see), so an erased-private field is a plumbing detail advertised to every
397
+ // agent as a door, right next to the doors it should actually use. `#` is
398
+ // the only privacy the runtime enforces, so it is the only privacy an
399
+ // introspecting listing can respect.
400
+ #transport;
401
+ /** Where a labelled `screenshot()` lands — project-scoped by `@vgai/live`, cwd-relative otherwise. Public: a caller reading it is asking a fair question, and `screenshot()` returns a path under it anyway. */
402
+ artifactsDir;
403
+ /** See `GameClientOptions.warmSession`. */
404
+ #warmSession;
405
+ /** See `GameClientOptions.testTitle`. */
406
+ #testTitle;
407
+ #screenshotCounter = 0;
408
+ /** Per-test tick-rate samples, fed by every `snapshot()` read (a poll the
409
+ * client was making anyway — zero extra page.evaluate round trips). */
410
+ #tps = new TpsAccumulator();
411
+ /** Hidden-tab recovery (hollowstone field lesson: the engine hard-stops
412
+ * while `document.hidden`). Client-lifetime state so `bringToFront()`
413
+ * fires at most once per test, across ALL waitFor/waitSimTime loops. */
414
+ #hiddenRecovery;
415
+ /** run-3 friction #2 — throttled "a bridge call is flowing" liveness
416
+ * heartbeat (see bridge-heartbeat.ts's module doc), client-lifetime so
417
+ * the 30s throttle applies across the whole test, not per call site. */
418
+ #bridgeHeartbeat;
419
+ constructor(opts) {
420
+ this.#transport = opts.transport;
421
+ this.pageErrors = opts.pageErrors;
422
+ this.consoleErrors = opts.consoleErrors;
423
+ this.fenceTick = opts.fenceTick;
424
+ this.fenceSeq = opts.fenceSeq;
425
+ this.fenceSimSeconds = opts.fenceSimSeconds;
426
+ this.fenceWallMs = opts.fenceWallMs;
427
+ this.artifactsDir = opts.artifactsDir ?? resolve('.vgai/last-run');
428
+ this.#warmSession = opts.warmSession ?? false;
429
+ this.#testTitle = opts.testTitle ?? 'test';
430
+ this.#bridgeHeartbeat = { lastEmitWallMs: Date.now() };
431
+ this.#hiddenRecovery = new HiddenRecoveryDriver({
432
+ sampleHidden: () => this.#transport.isHidden(),
433
+ bringToFront: () => this.#transport.bringToFront(),
434
+ log: (line) => {
435
+ // M13: structured line on stdout AND into its OWN recoveryNotices
436
+ // collection — NOT consoleErrors, so a real console error and a
437
+ // benign "we recovered a backgrounded tab" notice never conflate in
438
+ // the failure block's console/page-errors section.
439
+ console.log(line);
440
+ this.recoveryNotices.push(line);
441
+ },
442
+ });
443
+ }
444
+ async state(name) {
445
+ return this.callBridge('state', name);
446
+ }
447
+ async command(name, ...args) {
448
+ return this.callBridgeAsync('invoke', name, args);
449
+ }
450
+ async providers() {
451
+ return this.callBridge('providers');
452
+ }
453
+ async commands() {
454
+ return this.callBridge('commands');
455
+ }
456
+ async snapshot(sinceSeq) {
457
+ const snap = await this.callBridge('snapshot', sinceSeq);
458
+ // Both hardening seams hang off the read every poll loop already makes:
459
+ // one tick-rate sample per snapshot, and one hidden-tab-recovery
460
+ // observation (which only pays an extra evaluate once the tick has been
461
+ // frozen for HIDDEN_RECOVERY_STALL_POLLS consecutive reads).
462
+ this.#tps.record(snap.time.tick, Date.now());
463
+ await this.#hiddenRecovery.observeTick(snap.time.tick);
464
+ return snap;
465
+ }
466
+ /** Fixture-teardown perf read: p50/p95 effective ticks-per-second over all
467
+ * the snapshot polls this test performed. */
468
+ tpsStats() {
469
+ return this.#tps.stats();
470
+ }
471
+ /** True if the hidden-tab recovery fired (page.bringToFront was called). */
472
+ hiddenRecoveryTriggered() {
473
+ return this.#hiddenRecovery.wasTriggered();
474
+ }
475
+ /**
476
+ * D15/T-D15.4 — synchronously drives `budget` worth of sim time/ticks via
477
+ * the live `Game`'s `runTicks` (through the debug bridge), instead of
478
+ * waiting for real wall-clock time to pass. Doctrine (see
479
+ * `fast-forward.ts`'s module doc, which also documents the two honesty
480
+ * decisions this wraps): SETUP/STAGING traversal — reaching a known
481
+ * late-game state fast — not a substitute for real-input proofs, which
482
+ * still run in real ticks. Returns the final `{time, state, events,
483
+ * pageErrors}` snapshot (a completely ordinary `snapshot()` read, taken
484
+ * AFTER the tps baseline reset below, so it never itself corrupts the tps
485
+ * stats either).
486
+ */
487
+ async fastForward(budget, opts) {
488
+ // Same options-object-only contract as `waitSimTime` (`ticksForBudget`
489
+ // would otherwise fail on `'simTicks' in 0.5` with a raw TypeError that
490
+ // names neither the method nor the shape).
491
+ assertValidWaitForBudget(budget, 'fastForward');
492
+ const clock = {
493
+ runTicksBatch: (n, render) => this.callBridgeVoid('runTicks', n, { render }),
494
+ readTime: async () => {
495
+ // Raw bridge read — deliberately NOT `this.snapshot()`, which would
496
+ // feed the TpsAccumulator (see fast-forward.ts's module doc, point 2).
497
+ const snap = await this.callBridge('snapshot');
498
+ return { tick: snap.time.tick, simSeconds: snap.time.simSeconds };
499
+ },
500
+ heartbeat: (info) => {
501
+ console.log(`vgai fastForward: ${info.ticksDone}/${info.ticksTotal} ticks driven`);
502
+ },
503
+ };
504
+ await runFastForward(budget, opts ?? {}, clock);
505
+ // The burst is over — reset the baseline so the very next ordinary poll
506
+ // (including the `snapshot()` call right below) treats itself as a fresh
507
+ // "first observation" rather than diffing across the burst's enormous
508
+ // tick delta over a near-zero wall delta.
509
+ this.#tps.resetBaseline();
510
+ return this.snapshot();
511
+ }
512
+ async waitFor(pred, budget) {
513
+ try {
514
+ await runWaitFor(pred, budget, {
515
+ snapshot: () => this.snapshot(),
516
+ now: () => Date.now(),
517
+ sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
518
+ // Fixture heartbeat (Wave-6 findings ledger) — real stdout, the
519
+ // same channel a caller watching stdout already treats as
520
+ // liveness (see wait-for.ts's module doc for the emission
521
+ // invariant `maybeHeartbeat` enforces).
522
+ log: (line) => console.log(line),
523
+ }, this.#testTitle);
524
+ }
525
+ catch (err) {
526
+ if (err instanceof WaitForTimeoutError)
527
+ throw await this.toSessionFailure(err);
528
+ throw err;
529
+ }
530
+ }
531
+ /** Used by `input.hold` — waits for a sim-time delta to pass with no
532
+ * predicate (a predicate that is never true would misreport as a
533
+ * waitFor failure, so this is its own tiny loop, not `runWaitFor` with a
534
+ * false predicate).
535
+ *
536
+ * D2: this loop has the SAME frozen-clock bug `runWaitFor` had — with no
537
+ * predicate at all, a stalled sim clock would poll forever, since
538
+ * `current.time.simSeconds` would never advance. Carries the identical
539
+ * stall guard (`WAIT_FOR_STALL_POLL_LIMIT` consecutive polls with an
540
+ * unchanged tick — see wait-for.ts's doc comment for the rationale),
541
+ * throwing the same `WaitForTimeoutError` shape so it renders through the
542
+ * same honest failure block as an ordinary `waitFor`/`waitSimTime` budget
543
+ * exhaustion. */
544
+ async waitSimTime(budget) {
545
+ // A positional budget (`waitSimTime(0.5)`) reads `undefined` here and the
546
+ // exit comparison below is then false FOREVER — and against a hidden tab
547
+ // the client drives its own ticks, so the frozen-clock stall guard never
548
+ // fires either. Refuse in the caller's own vocabulary instead of hanging.
549
+ assertValidWaitForBudget(budget, 'waitSimTime');
550
+ const startWall = Date.now();
551
+ const start = await this.snapshot();
552
+ let lastTick = null;
553
+ let stalledPolls = 0;
554
+ // Fixture heartbeat (Wave-6 findings ledger) — same invariant as
555
+ // `wait-for.ts`'s `runWaitFor`: a heartbeat requires BOTH 60s of wall
556
+ // silence AND the tick having advanced since the last one emitted, so a
557
+ // genuinely stalled sim clock (caught by `stalledPolls` above, ~30s)
558
+ // goes heartbeat-silent well before this loop's own guard ever needs to.
559
+ let heartbeat = { lastEmitWallMs: startWall, lastEmitTick: start.time.tick };
560
+ for (;;) {
561
+ const current = await this.snapshot();
562
+ if (current.time.simSeconds - start.time.simSeconds >= budget.simSeconds)
563
+ return;
564
+ stalledPolls = lastTick !== null && current.time.tick === lastTick ? stalledPolls + 1 : 0;
565
+ lastTick = current.time.tick;
566
+ if (stalledPolls >= WAIT_FOR_STALL_POLL_LIMIT) {
567
+ throw await this.toSessionFailure(new WaitForTimeoutError({
568
+ budget,
569
+ startSnapshot: start,
570
+ lastSnapshot: current,
571
+ wallElapsedMs: Date.now() - startWall,
572
+ predicateSource: '(no predicate — game.input.hold is waiting for a sim-time delta to pass)',
573
+ touchedProviders: [],
574
+ }));
575
+ }
576
+ const heartbeatResult = maybeHeartbeat({
577
+ nowMs: Date.now(),
578
+ tick: current.time.tick,
579
+ simSeconds: current.time.simSeconds,
580
+ testTitle: this.#testTitle,
581
+ state: heartbeat,
582
+ });
583
+ heartbeat = heartbeatResult.state;
584
+ if (heartbeatResult.line)
585
+ console.log(heartbeatResult.line);
586
+ await new Promise((r) => setTimeout(r, 150));
587
+ }
588
+ }
589
+ /**
590
+ * Capture the running game to disk and return the ABSOLUTE path written.
591
+ *
592
+ * `labelOrPath` is read as a LABEL when it is a bare identifier
593
+ * (`'waitfor-timeout'`) — numbered into the artifacts directory, the
594
+ * long-standing spec behaviour — and as a DESTINATION PATH the moment it
595
+ * carries a separator or a file extension, in which case the bytes land
596
+ * exactly there (relative to `process.cwd()`). See
597
+ * `screenshot-target.ts`'s header for the live incident that made this
598
+ * distinction mandatory: a caller naming a path used to get "success" and
599
+ * an empty path.
600
+ */
601
+ async screenshot(labelOrPath) {
602
+ const target = resolveScreenshotTarget({
603
+ arg: labelOrPath,
604
+ artifactsDir: this.artifactsDir,
605
+ sequence: this.#screenshotCounter + 1,
606
+ cwd: process.cwd(),
607
+ });
608
+ if (target.consumedSequence)
609
+ this.#screenshotCounter += 1;
610
+ await mkdir(dirname(target.path), { recursive: true });
611
+ await this.#transport.screenshot(target.path);
612
+ return target.path;
613
+ }
614
+ /**
615
+ * Wave-2 "one dialect, full capability" — runs a UI-automation step
616
+ * written as a literal Playwright `async (page) => {...}` (interface
617
+ * doctrine §3.2/§4 rung 4: "the AI should think it is basically just
618
+ * executing Playwright"). Under `PageTransport` this drives the REAL
619
+ * `Page` (real closures, real hit-testing); under `RelayTransport` it
620
+ * posts the step's own `toString()` source to the editor dev server's
621
+ * `page-script` op, which reconstructs it in-page against
622
+ * `playwright-shim.ts`'s in-page shim (`isTrusted: false` synthetic
623
+ * DOM events, no real hit testing). This is the honest input path for a
624
+ * React-only DOM game; canvas gameplay continues to use `game.input.*`.
625
+ * Write specs as if ALWAYS serialized — inline every value the step needs,
626
+ * never close over imported `expect`/helpers/outer variables, and return
627
+ * observations to assert outside the callback. See `bridge-transport.ts`'s
628
+ * `runPageScript` doc comment for the full contract this method wraps.
629
+ */
630
+ async page(step) {
631
+ const erased = (arg) => step(arg);
632
+ const outcome = await this.#transport.runPageScript(step.toString(), erased);
633
+ return this.unwrap(outcome);
634
+ }
635
+ async toSessionFailure(err) {
636
+ const providers = await this.providers();
637
+ const screenshotPath = await this.screenshot('waitfor-timeout').catch(() => null);
638
+ const block = assembleFailureBlock({
639
+ headline: err.message,
640
+ simElapsedSeconds: err.info.lastSnapshot.time.simSeconds - err.info.startSnapshot.time.simSeconds,
641
+ wallElapsedMs: err.info.wallElapsedMs,
642
+ tick: err.info.lastSnapshot.time.tick,
643
+ predicateSource: err.info.predicateSource,
644
+ providers,
645
+ touchedProviders: err.info.touchedProviders,
646
+ lastState: err.info.lastSnapshot.state,
647
+ lastEvents: err.info.lastSnapshot.events,
648
+ screenshotPath,
649
+ consoleErrors: this.consoleErrors,
650
+ pageErrors: this.pageErrors,
651
+ recoveryNotices: this.recoveryNotices,
652
+ // Issue #175 — "state must never claim health it cannot observe": a
653
+ // waitFor/waitSimTime timeout with a ~0x sim-speed ratio used to read
654
+ // as a generic "loop is stalled" no matter WHY the clock was frozen.
655
+ // `HiddenRecoveryDriver` already tried ONE `bringToFront()` recovery
656
+ // before this failure fires (see this class's constructor) — if the
657
+ // loop is STILL hidden-paused here, recovery couldn't reach the tab
658
+ // (headless run, no-op bringToFront), and the failure block must say
659
+ // so explicitly rather than leaving an agent to guess "stalled" for a
660
+ // tab that is simply backgrounded.
661
+ loopLiveness: err.info.lastSnapshot.time.loopLiveness,
662
+ });
663
+ return new SessionFailure('WAIT_FOR_TIMEOUT', block);
664
+ }
665
+ /** run-3 friction #2 — called at the top of every bridge dispatch, before
666
+ * the await, so a burst of short calls (e.g. many `hold()`s) keeps
667
+ * printing throttled liveness even while individually near-silent. See
668
+ * bridge-heartbeat.ts's module doc for why this needs no sim-tick
669
+ * awareness the way `wait-for.ts`'s poll-loop heartbeat does. */
670
+ emitBridgeHeartbeatIfDue(method) {
671
+ const result = maybeBridgeHeartbeat({
672
+ nowMs: Date.now(),
673
+ method,
674
+ testTitle: this.#testTitle,
675
+ state: this.#bridgeHeartbeat,
676
+ });
677
+ this.#bridgeHeartbeat = result.state;
678
+ if (result.line)
679
+ console.log(result.line);
680
+ }
681
+ /** @internal exposed for GameInput/GameEvents in this module only. */
682
+ async callBridge(method, ...callArgs) {
683
+ this.emitBridgeHeartbeatIfDue(method);
684
+ const outcome = await this.#transport.call(method, callArgs);
685
+ return this.unwrap(outcome);
686
+ }
687
+ /** @internal like `callBridge`, but awaits the page-side call (for async
688
+ * bridge methods like `invoke`). */
689
+ async callBridgeAsync(method, ...callArgs) {
690
+ this.emitBridgeHeartbeatIfDue(method);
691
+ const outcome = await this.#transport.callAsync(method, callArgs);
692
+ return this.unwrap(outcome);
693
+ }
694
+ /** @internal void-returning convenience (clearVirtualActions). */
695
+ async callBridgeVoid(method, ...callArgs) {
696
+ await this.callBridge(method, ...callArgs);
697
+ }
698
+ unwrap(outcome) {
699
+ if (!outcome.ok) {
700
+ const error = outcome.error ?? { code: undefined, message: 'unknown bridge error' };
701
+ const message = appendWarmSessionHint(error.message, error.code, this.#warmSession);
702
+ throw new SessionError(error.code ?? 'DEBUG_COMMAND_FAILED', message, error.data);
703
+ }
704
+ return outcome.result;
705
+ }
706
+ }