@vgai/live 0.5.5 → 0.5.6

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.
@@ -16,6 +16,7 @@
16
16
  * `@playwright/test` import here, nor in `relay-transport.ts` — only
17
17
  * `client.ts` is allowed to touch a live `Page` (see its own module doc).
18
18
  */
19
+ import type { CaptureNotes } from './capture-notes.js';
19
20
  /** Result of one generic bridge-method call — thrown page/relay-side errors
20
21
  * never cross either transport boundary AS themselves (Node only keeps
21
22
  * `.message` across `page.evaluate`; HTTP/JSON strips everything but what
@@ -43,10 +44,13 @@ export interface BridgeTransport {
43
44
  isHidden(): Promise<boolean>;
44
45
  /** Bring the surface showing the game to the foreground. */
45
46
  bringToFront(): Promise<void>;
46
- /** Capture a screenshot to `path` (PNG). A transport that cannot support
47
- * this should reject with a descriptive error rather than write a blank/
48
- * corrupt file. */
49
- screenshot(path: string): Promise<void>;
47
+ /** Capture a screenshot to `path` (PNG), returning whatever the surface knows
48
+ * about the conditions the frame was taken under ({@link CaptureNotes} a
49
+ * hidden surface, a near-blank frame). `{}` is the honest answer for a
50
+ * transport that cannot observe either. A transport that cannot support the
51
+ * capture at all should reject with a descriptive error rather than write a
52
+ * blank/corrupt file. */
53
+ screenshot(path: string): Promise<CaptureNotes>;
50
54
  /**
51
55
  * Wave-2 "one dialect, full capability" — runs a UI-automation step
52
56
  * written as a literal `async (page) => {...}` (`GameClient.page()`,
@@ -0,0 +1,56 @@
1
+ /**
2
+ * What a capture knows about ITSELF beyond its pixels — and the one place the
3
+ * caveat sentence is spelled.
4
+ *
5
+ * A PNG is silent about the conditions it was taken under. Two of those
6
+ * conditions change what the frame is worth as evidence, and both are already
7
+ * measured elsewhere in the stack: the editor page reports `hiddenFrame` when
8
+ * the surface was hidden and the runtime had to render one deterministic tick
9
+ * on demand (`command-listener.ts`'s `handleBridgeScreenshot`), and it reports
10
+ * a `flatness.warning` sentence when the frame is nine-tenths one flat surface
11
+ * (`composite-screenshot.ts`'s `measureFlatness`). Until now both stopped at a
12
+ * `console.warn` inside the relay transport — visible to a human watching a
13
+ * terminal, invisible to anything that later reads the file.
14
+ *
15
+ * So the transport seam carries them back as {@link CaptureNotes}, and
16
+ * {@link describeCaptureCaveat} turns them into the ONE sentence every surface
17
+ * says. Its two callers are the transport's own console warning and
18
+ * `GameClient.screenshot`, which hands the composed caveat to every capture
19
+ * listener — so the words a human reads in the terminal and the words a run
20
+ * record carries beside the frame cannot drift apart.
21
+ */
22
+ /** The conditions a capture was taken under, as facts. Both fields are
23
+ * optional and absent means "not so": an ordinary frame carries no notes. */
24
+ export interface CaptureNotes {
25
+ /** The surface showing the game was hidden, so the frame exists only because
26
+ * the runtime was asked for one deterministic tick. */
27
+ readonly hiddenFrame?: boolean;
28
+ /** The page's own near-blank-frame sentence, verbatim (it owns the wording;
29
+ * see `composite-screenshot.ts`'s `CaptureFlatness.warning`). */
30
+ readonly flatnessWarning?: string;
31
+ }
32
+ /** One capture, as reported to a {@link CaptureListener} after the bytes are
33
+ * on disk. `caveat` is already composed — see {@link describeCaptureCaveat} —
34
+ * so a listener never has to know how a degraded frame is detected, only that
35
+ * this one is and what to say about it. */
36
+ export interface CaptureRecord {
37
+ /** The caller's own `screenshot()` argument, label or path, unaltered. */
38
+ readonly label: string;
39
+ /** The absolute file the bytes landed at. */
40
+ readonly path: string;
41
+ /** What a reader must know about this frame, or `null` for an ordinary one. */
42
+ readonly caveat: string | null;
43
+ }
44
+ /** Notified after every capture a `GameClient` writes. May be async — the
45
+ * client awaits it, so a listener that needs to read the game (to stamp the
46
+ * capture with where the run was standing, say) can. */
47
+ export type CaptureListener = (capture: CaptureRecord) => void | Promise<void>;
48
+ /**
49
+ * What a reader must be told about this frame, or `null` when there is nothing
50
+ * to tell.
51
+ *
52
+ * Both notes can be true at once (a hidden tab's on-demand tick that also came
53
+ * out near-blank), and both are said — a capture that is degraded twice over
54
+ * must not report only the first reason.
55
+ */
56
+ export declare function describeCaptureCaveat(notes: CaptureNotes): string | null;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * What a capture knows about ITSELF beyond its pixels — and the one place the
3
+ * caveat sentence is spelled.
4
+ *
5
+ * A PNG is silent about the conditions it was taken under. Two of those
6
+ * conditions change what the frame is worth as evidence, and both are already
7
+ * measured elsewhere in the stack: the editor page reports `hiddenFrame` when
8
+ * the surface was hidden and the runtime had to render one deterministic tick
9
+ * on demand (`command-listener.ts`'s `handleBridgeScreenshot`), and it reports
10
+ * a `flatness.warning` sentence when the frame is nine-tenths one flat surface
11
+ * (`composite-screenshot.ts`'s `measureFlatness`). Until now both stopped at a
12
+ * `console.warn` inside the relay transport — visible to a human watching a
13
+ * terminal, invisible to anything that later reads the file.
14
+ *
15
+ * So the transport seam carries them back as {@link CaptureNotes}, and
16
+ * {@link describeCaptureCaveat} turns them into the ONE sentence every surface
17
+ * says. Its two callers are the transport's own console warning and
18
+ * `GameClient.screenshot`, which hands the composed caveat to every capture
19
+ * listener — so the words a human reads in the terminal and the words a run
20
+ * record carries beside the frame cannot drift apart.
21
+ */
22
+ /** The hidden-surface sentence. Spelled once because it is said in two places
23
+ * (a live console warning and a persisted record) and a second copy is a
24
+ * second wording. */
25
+ const HIDDEN_FRAME_CAVEAT = 'HIDDEN FRAME — the surface showing the game was hidden, so the runtime rendered one ' +
26
+ 'deterministic tick on demand. It is current, not stale; it is not a frame anyone was watching.';
27
+ /**
28
+ * What a reader must be told about this frame, or `null` when there is nothing
29
+ * to tell.
30
+ *
31
+ * Both notes can be true at once (a hidden tab's on-demand tick that also came
32
+ * out near-blank), and both are said — a capture that is degraded twice over
33
+ * must not report only the first reason.
34
+ */
35
+ export function describeCaptureCaveat(notes) {
36
+ const parts = [];
37
+ if (notes.hiddenFrame === true)
38
+ parts.push(HIDDEN_FRAME_CAVEAT);
39
+ if (typeof notes.flatnessWarning === 'string' && notes.flatnessWarning !== '') {
40
+ parts.push(notes.flatnessWarning);
41
+ }
42
+ return parts.length === 0 ? null : parts.join(' ');
43
+ }
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import type { Page } from '@playwright/test';
10
10
  import type { BridgeCallOutcome, BridgeTransport } from './bridge-transport.js';
11
+ import { type CaptureListener, type CaptureNotes } from './capture-notes.js';
11
12
  import { type FastForwardBudget, type FastForwardOptions } from './fast-forward.js';
12
13
  import { type TpsStats } from './perf-sampling.js';
13
14
  import type { DebugCommandInfo, DebugSnapshot, ProviderInfo, VirtualActionValue } from './types.js';
@@ -27,7 +28,11 @@ export declare class PageTransport implements BridgeTransport {
27
28
  callAsync(method: string, callArgs: unknown[]): Promise<BridgeCallOutcome>;
28
29
  isHidden(): Promise<boolean>;
29
30
  bringToFront(): Promise<void>;
30
- screenshot(path: string): Promise<void>;
31
+ /** No notes: Playwright drives its own foregrounded page, so neither the
32
+ * hidden-surface nor the near-blank measurement the editor page makes
33
+ * (`capture-notes.ts`) exists on this leg. `{}` says that honestly rather
34
+ * than inventing a clean bill of health. */
35
+ screenshot(path: string): Promise<CaptureNotes>;
31
36
  /** Wave-2: the ONE transport that runs a `game.page()` step against a REAL
32
37
  * Playwright `Page` — no serialization, so `step`'s own closures work
33
38
  * here (see `bridge-transport.ts`'s `runPageScript` doc comment for the
@@ -258,6 +263,20 @@ export declare class GameClient {
258
263
  * an empty path.
259
264
  */
260
265
  screenshot(labelOrPath: string): Promise<string>;
266
+ /**
267
+ * Watch every capture this client writes, and get back the unsubscribe.
268
+ *
269
+ * The reason this exists rather than each caller wrapping `screenshot()`:
270
+ * a run does not take all of its own captures. `waitFor`'s timeout path and
271
+ * `events.expect`'s failure path each shoot a frame on their own
272
+ * (`toSessionFailure`, `GameEvents.expect`), and a route's `whileHeld`
273
+ * observer may shoot one from inside a callback the driver never sees. The
274
+ * only place that sees ALL of them is here, which is why an autoplay run
275
+ * stamps its captures by listening rather than by intercepting
276
+ * (`src/tools/route-context.ts` in a scaffolded project is the shipped
277
+ * listener).
278
+ */
279
+ onCapture(listener: CaptureListener): () => void;
261
280
  /**
262
281
  * Wave-2 "one dialect, full capability" — runs a UI-automation step
263
282
  * written as a literal Playwright `async (page) => {...}` (interface
@@ -9,6 +9,7 @@
9
9
  import { mkdir } from 'node:fs/promises';
10
10
  import { dirname, resolve } from 'node:path';
11
11
  import { maybeBridgeHeartbeat } from './bridge-heartbeat.js';
12
+ import { describeCaptureCaveat } from './capture-notes.js';
12
13
  import { appendWarmSessionHint, inputGatedError, SessionError, SessionFailure } from './errors.js';
13
14
  import { describeEventsExpectation, matchEventsSubsequence } from './events-matcher.js';
14
15
  import { assembleFailureBlock } from './failure-block.js';
@@ -101,9 +102,14 @@ export class PageTransport {
101
102
  async bringToFront() {
102
103
  await this.page.bringToFront();
103
104
  }
105
+ /** No notes: Playwright drives its own foregrounded page, so neither the
106
+ * hidden-surface nor the near-blank measurement the editor page makes
107
+ * (`capture-notes.ts`) exists on this leg. `{}` says that honestly rather
108
+ * than inventing a clean bill of health. */
104
109
  async screenshot(path) {
105
110
  await mkdir(dirname(path), { recursive: true });
106
111
  await this.page.screenshot({ path });
112
+ return {};
107
113
  }
108
114
  /** Wave-2: the ONE transport that runs a `game.page()` step against a REAL
109
115
  * Playwright `Page` — no serialization, so `step`'s own closures work
@@ -405,6 +411,8 @@ export class GameClient {
405
411
  /** See `GameClientOptions.testTitle`. */
406
412
  #testTitle;
407
413
  #screenshotCounter = 0;
414
+ /** Everyone watching this client's captures — see {@link GameClient.onCapture}. */
415
+ #captureListeners = new Set();
408
416
  /** Per-test tick-rate samples, fed by every `snapshot()` read (a poll the
409
417
  * client was making anyway — zero extra page.evaluate round trips). */
410
418
  #tps = new TpsAccumulator();
@@ -608,9 +616,41 @@ export class GameClient {
608
616
  if (target.consumedSequence)
609
617
  this.#screenshotCounter += 1;
610
618
  await mkdir(dirname(target.path), { recursive: true });
611
- await this.#transport.screenshot(target.path);
619
+ const notes = await this.#transport.screenshot(target.path);
620
+ const capture = {
621
+ label: labelOrPath,
622
+ path: target.path,
623
+ caveat: describeCaptureCaveat(notes),
624
+ };
625
+ // Sequential and AWAITED: a listener may need to read the game to stamp
626
+ // this capture, and it must have finished before the path is handed back —
627
+ // a caller that files the path is entitled to assume the record of it is
628
+ // already complete. A throwing listener fails the call rather than being
629
+ // swallowed: a capture whose bookkeeping silently did not happen is the
630
+ // fabricated-evidence shape this whole seam exists to prevent.
631
+ for (const listener of this.#captureListeners)
632
+ await listener(capture);
612
633
  return target.path;
613
634
  }
635
+ /**
636
+ * Watch every capture this client writes, and get back the unsubscribe.
637
+ *
638
+ * The reason this exists rather than each caller wrapping `screenshot()`:
639
+ * a run does not take all of its own captures. `waitFor`'s timeout path and
640
+ * `events.expect`'s failure path each shoot a frame on their own
641
+ * (`toSessionFailure`, `GameEvents.expect`), and a route's `whileHeld`
642
+ * observer may shoot one from inside a callback the driver never sees. The
643
+ * only place that sees ALL of them is here, which is why an autoplay run
644
+ * stamps its captures by listening rather than by intercepting
645
+ * (`src/tools/route-context.ts` in a scaffolded project is the shipped
646
+ * listener).
647
+ */
648
+ onCapture(listener) {
649
+ this.#captureListeners.add(listener);
650
+ return () => {
651
+ this.#captureListeners.delete(listener);
652
+ };
653
+ }
614
654
  /**
615
655
  * Wave-2 "one dialect, full capability" — runs a UI-automation step
616
656
  * written as a literal Playwright `async (page) => {...}` (interface
@@ -16,6 +16,8 @@
16
16
  export type { BridgeHeartbeatState } from './bridge-heartbeat.js';
17
17
  export { BRIDGE_HEARTBEAT_INTERVAL_MS, formatBridgeHeartbeatLine } from './bridge-heartbeat.js';
18
18
  export type { BridgeCallOutcome, BridgeTransport } from './bridge-transport.js';
19
+ export type { CaptureListener, CaptureNotes, CaptureRecord } from './capture-notes.js';
20
+ export { describeCaptureCaveat } from './capture-notes.js';
19
21
  export type { GameClientOptions } from './client.js';
20
22
  export { GameClient, GameEvents, GameInput, PageTransport } from './client.js';
21
23
  export type { SessionErrorCode } from './errors.js';
@@ -14,6 +14,7 @@
14
14
  * a dozen sibling paths; nothing here imports anything from `../`.
15
15
  */
16
16
  export { BRIDGE_HEARTBEAT_INTERVAL_MS, formatBridgeHeartbeatLine } from './bridge-heartbeat.js';
17
+ export { describeCaptureCaveat } from './capture-notes.js';
17
18
  export { GameClient, GameEvents, GameInput, PageTransport } from './client.js';
18
19
  export { SESSION_ERROR_CODES, SessionError, SessionFailure } from './errors.js';
19
20
  export { matchEventsSubsequence } from './events-matcher.js';
@@ -19,6 +19,7 @@
19
19
  * anywhere in this path).
20
20
  */
21
21
  import type { BridgeCallOutcome, BridgeTransport } from './bridge-transport.js';
22
+ import { type CaptureNotes } from './capture-notes.js';
22
23
  export interface RelayTransportOptions {
23
24
  /** The live editor session's dev-server port (e.g. from
24
25
  * `findLiveEditorSession`/`vgai edit`). */
@@ -86,7 +87,7 @@ export declare class RelayTransport implements BridgeTransport {
86
87
  * path `call('snapshot')` normally activates from `/__editor/state`.
87
88
  */
88
89
  bringToFront(): Promise<void>;
89
- screenshot(path: string): Promise<void>;
90
+ screenshot(path: string): Promise<CaptureNotes>;
90
91
  /**
91
92
  * Wave-2: ships `src` (`step.toString()`) to the editor dev server's
92
93
  * `page-script` op — a STANDALONE relay command (like `bridge-screenshot`
@@ -20,6 +20,7 @@
20
20
  */
21
21
  import { mkdir, writeFile } from 'node:fs/promises';
22
22
  import { dirname } from 'node:path';
23
+ import { describeCaptureCaveat } from './capture-notes.js';
23
24
  const DEFAULT_TIMEOUT_MS = 10_000;
24
25
  /** `invoke` dispatches to arbitrary game-registered debug commands — give it
25
26
  * real headroom rather than the ordinary sync-call budget above. */
@@ -196,18 +197,21 @@ export class RelayTransport {
196
197
  }
197
198
  await mkdir(dirname(path), { recursive: true });
198
199
  await writeFile(path, Buffer.from(body['base64'], 'base64'));
199
- // `game.screenshot()` hands back a path, so a near-blank frame is
200
- // indistinguishable from a good one until somebody opens the file — which
201
- // is exactly how two probes cited blank captures as evidence. The page
202
- // wrote the sentence; say it where the caller is looking.
200
+ // `game.screenshot()` hands back a path, so a near-blank or on-demand frame
201
+ // is indistinguishable from a good one until somebody opens the file —
202
+ // which is exactly how two probes cited blank captures as evidence. The
203
+ // page measured both conditions; carry them back so the caller can persist
204
+ // them beside the frame, and say the composed sentence here too, where a
205
+ // human watching this terminal is looking.
203
206
  const warning = body['flatness']?.warning;
204
- if (typeof warning === 'string')
205
- console.warn(`vgai screenshot: warning ${warning}`);
206
- if (body['hiddenFrame'] === true) {
207
- console.warn(`vgai screenshot: ${path} is a HIDDEN FRAME — the editor tab is hidden, so the runtime ` +
208
- 'rendered one deterministic tick on demand. It is current, not stale; it is not a ' +
209
- 'frame anyone was watching.');
210
- }
207
+ const notes = {
208
+ ...(body['hiddenFrame'] === true ? { hiddenFrame: true } : {}),
209
+ ...(typeof warning === 'string' ? { flatnessWarning: warning } : {}),
210
+ };
211
+ const caveat = describeCaptureCaveat(notes);
212
+ if (caveat !== null)
213
+ console.warn(`vgai screenshot: ${path} — ${caveat}`);
214
+ return notes;
211
215
  }
212
216
  /**
213
217
  * Wave-2: ships `src` (`step.toString()`) to the editor dev server's
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@vgai/live",
3
3
  "author": "Volter AI, Inc.",
4
4
  "license": "Apache-2.0",
5
- "version": "0.5.5",
5
+ "version": "0.5.6",
6
6
  "type": "module",
7
7
  "repository": {
8
8
  "type": "git",
@@ -27,11 +27,12 @@
27
27
  "node": ">=22.0.0"
28
28
  },
29
29
  "scripts": {
30
- "build": "tsc -p tsconfig.build.json"
30
+ "build": "tsc -p tsconfig.build.json",
31
+ "dev": "tsc -p tsconfig.build.json --watch --preserveWatchOutput"
31
32
  },
32
33
  "dependencies": {
33
- "@vgai/editor-sdk": "0.5.5",
34
- "@vgai/sdk": "0.5.5"
34
+ "@vgai/editor-sdk": "0.5.6",
35
+ "@vgai/sdk": "0.5.6"
35
36
  },
36
37
  "peerDependencies": {
37
38
  "@playwright/test": ">=1.58.2 <2"
@@ -17,6 +17,8 @@
17
17
  * `client.ts` is allowed to touch a live `Page` (see its own module doc).
18
18
  */
19
19
 
20
+ import type { CaptureNotes } from './capture-notes.js';
21
+
20
22
  /** Result of one generic bridge-method call — thrown page/relay-side errors
21
23
  * never cross either transport boundary AS themselves (Node only keeps
22
24
  * `.message` across `page.evaluate`; HTTP/JSON strips everything but what
@@ -41,10 +43,13 @@ export interface BridgeTransport {
41
43
  isHidden(): Promise<boolean>;
42
44
  /** Bring the surface showing the game to the foreground. */
43
45
  bringToFront(): Promise<void>;
44
- /** Capture a screenshot to `path` (PNG). A transport that cannot support
45
- * this should reject with a descriptive error rather than write a blank/
46
- * corrupt file. */
47
- screenshot(path: string): Promise<void>;
46
+ /** Capture a screenshot to `path` (PNG), returning whatever the surface knows
47
+ * about the conditions the frame was taken under ({@link CaptureNotes} a
48
+ * hidden surface, a near-blank frame). `{}` is the honest answer for a
49
+ * transport that cannot observe either. A transport that cannot support the
50
+ * capture at all should reject with a descriptive error rather than write a
51
+ * blank/corrupt file. */
52
+ screenshot(path: string): Promise<CaptureNotes>;
48
53
  /**
49
54
  * Wave-2 "one dialect, full capability" — runs a UI-automation step
50
55
  * written as a literal `async (page) => {...}` (`GameClient.page()`,
@@ -0,0 +1,74 @@
1
+ /**
2
+ * What a capture knows about ITSELF beyond its pixels — and the one place the
3
+ * caveat sentence is spelled.
4
+ *
5
+ * A PNG is silent about the conditions it was taken under. Two of those
6
+ * conditions change what the frame is worth as evidence, and both are already
7
+ * measured elsewhere in the stack: the editor page reports `hiddenFrame` when
8
+ * the surface was hidden and the runtime had to render one deterministic tick
9
+ * on demand (`command-listener.ts`'s `handleBridgeScreenshot`), and it reports
10
+ * a `flatness.warning` sentence when the frame is nine-tenths one flat surface
11
+ * (`composite-screenshot.ts`'s `measureFlatness`). Until now both stopped at a
12
+ * `console.warn` inside the relay transport — visible to a human watching a
13
+ * terminal, invisible to anything that later reads the file.
14
+ *
15
+ * So the transport seam carries them back as {@link CaptureNotes}, and
16
+ * {@link describeCaptureCaveat} turns them into the ONE sentence every surface
17
+ * says. Its two callers are the transport's own console warning and
18
+ * `GameClient.screenshot`, which hands the composed caveat to every capture
19
+ * listener — so the words a human reads in the terminal and the words a run
20
+ * record carries beside the frame cannot drift apart.
21
+ */
22
+
23
+ /** The conditions a capture was taken under, as facts. Both fields are
24
+ * optional and absent means "not so": an ordinary frame carries no notes. */
25
+ export interface CaptureNotes {
26
+ /** The surface showing the game was hidden, so the frame exists only because
27
+ * the runtime was asked for one deterministic tick. */
28
+ readonly hiddenFrame?: boolean;
29
+ /** The page's own near-blank-frame sentence, verbatim (it owns the wording;
30
+ * see `composite-screenshot.ts`'s `CaptureFlatness.warning`). */
31
+ readonly flatnessWarning?: string;
32
+ }
33
+
34
+ /** One capture, as reported to a {@link CaptureListener} after the bytes are
35
+ * on disk. `caveat` is already composed — see {@link describeCaptureCaveat} —
36
+ * so a listener never has to know how a degraded frame is detected, only that
37
+ * this one is and what to say about it. */
38
+ export interface CaptureRecord {
39
+ /** The caller's own `screenshot()` argument, label or path, unaltered. */
40
+ readonly label: string;
41
+ /** The absolute file the bytes landed at. */
42
+ readonly path: string;
43
+ /** What a reader must know about this frame, or `null` for an ordinary one. */
44
+ readonly caveat: string | null;
45
+ }
46
+
47
+ /** Notified after every capture a `GameClient` writes. May be async — the
48
+ * client awaits it, so a listener that needs to read the game (to stamp the
49
+ * capture with where the run was standing, say) can. */
50
+ export type CaptureListener = (capture: CaptureRecord) => void | Promise<void>;
51
+
52
+ /** The hidden-surface sentence. Spelled once because it is said in two places
53
+ * (a live console warning and a persisted record) and a second copy is a
54
+ * second wording. */
55
+ const HIDDEN_FRAME_CAVEAT =
56
+ 'HIDDEN FRAME — the surface showing the game was hidden, so the runtime rendered one ' +
57
+ 'deterministic tick on demand. It is current, not stale; it is not a frame anyone was watching.';
58
+
59
+ /**
60
+ * What a reader must be told about this frame, or `null` when there is nothing
61
+ * to tell.
62
+ *
63
+ * Both notes can be true at once (a hidden tab's on-demand tick that also came
64
+ * out near-blank), and both are said — a capture that is degraded twice over
65
+ * must not report only the first reason.
66
+ */
67
+ export function describeCaptureCaveat(notes: CaptureNotes): string | null {
68
+ const parts: string[] = [];
69
+ if (notes.hiddenFrame === true) parts.push(HIDDEN_FRAME_CAVEAT);
70
+ if (typeof notes.flatnessWarning === 'string' && notes.flatnessWarning !== '') {
71
+ parts.push(notes.flatnessWarning);
72
+ }
73
+ return parts.length === 0 ? null : parts.join(' ');
74
+ }
@@ -12,6 +12,7 @@ import { dirname, resolve } from 'node:path';
12
12
  import type { Page } from '@playwright/test';
13
13
  import { type BridgeHeartbeatState, maybeBridgeHeartbeat } from './bridge-heartbeat.js';
14
14
  import type { BridgeCallOutcome, BridgeTransport } from './bridge-transport.js';
15
+ import { type CaptureListener, type CaptureNotes, describeCaptureCaveat } from './capture-notes.js';
15
16
  import { appendWarmSessionHint, inputGatedError, SessionError, SessionFailure } from './errors.js';
16
17
  import { describeEventsExpectation, matchEventsSubsequence } from './events-matcher.js';
17
18
  import { assembleFailureBlock } from './failure-block.js';
@@ -130,9 +131,14 @@ export class PageTransport implements BridgeTransport {
130
131
  await this.page.bringToFront();
131
132
  }
132
133
 
133
- async screenshot(path: string): Promise<void> {
134
+ /** No notes: Playwright drives its own foregrounded page, so neither the
135
+ * hidden-surface nor the near-blank measurement the editor page makes
136
+ * (`capture-notes.ts`) exists on this leg. `{}` says that honestly rather
137
+ * than inventing a clean bill of health. */
138
+ async screenshot(path: string): Promise<CaptureNotes> {
134
139
  await mkdir(dirname(path), { recursive: true });
135
140
  await this.page.screenshot({ path });
141
+ return {};
136
142
  }
137
143
 
138
144
  /** Wave-2: the ONE transport that runs a `game.page()` step against a REAL
@@ -510,6 +516,8 @@ export class GameClient {
510
516
  /** See `GameClientOptions.testTitle`. */
511
517
  readonly #testTitle: string;
512
518
  #screenshotCounter = 0;
519
+ /** Everyone watching this client's captures — see {@link GameClient.onCapture}. */
520
+ readonly #captureListeners = new Set<CaptureListener>();
513
521
  /** Per-test tick-rate samples, fed by every `snapshot()` read (a poll the
514
522
  * client was making anyway — zero extra page.evaluate round trips). */
515
523
  readonly #tps = new TpsAccumulator();
@@ -731,10 +739,42 @@ export class GameClient {
731
739
  });
732
740
  if (target.consumedSequence) this.#screenshotCounter += 1;
733
741
  await mkdir(dirname(target.path), { recursive: true });
734
- await this.#transport.screenshot(target.path);
742
+ const notes = await this.#transport.screenshot(target.path);
743
+ const capture = {
744
+ label: labelOrPath,
745
+ path: target.path,
746
+ caveat: describeCaptureCaveat(notes),
747
+ };
748
+ // Sequential and AWAITED: a listener may need to read the game to stamp
749
+ // this capture, and it must have finished before the path is handed back —
750
+ // a caller that files the path is entitled to assume the record of it is
751
+ // already complete. A throwing listener fails the call rather than being
752
+ // swallowed: a capture whose bookkeeping silently did not happen is the
753
+ // fabricated-evidence shape this whole seam exists to prevent.
754
+ for (const listener of this.#captureListeners) await listener(capture);
735
755
  return target.path;
736
756
  }
737
757
 
758
+ /**
759
+ * Watch every capture this client writes, and get back the unsubscribe.
760
+ *
761
+ * The reason this exists rather than each caller wrapping `screenshot()`:
762
+ * a run does not take all of its own captures. `waitFor`'s timeout path and
763
+ * `events.expect`'s failure path each shoot a frame on their own
764
+ * (`toSessionFailure`, `GameEvents.expect`), and a route's `whileHeld`
765
+ * observer may shoot one from inside a callback the driver never sees. The
766
+ * only place that sees ALL of them is here, which is why an autoplay run
767
+ * stamps its captures by listening rather than by intercepting
768
+ * (`src/tools/route-context.ts` in a scaffolded project is the shipped
769
+ * listener).
770
+ */
771
+ onCapture(listener: CaptureListener): () => void {
772
+ this.#captureListeners.add(listener);
773
+ return () => {
774
+ this.#captureListeners.delete(listener);
775
+ };
776
+ }
777
+
738
778
  /**
739
779
  * Wave-2 "one dialect, full capability" — runs a UI-automation step
740
780
  * written as a literal Playwright `async (page) => {...}` (interface
@@ -17,6 +17,8 @@
17
17
  export type { BridgeHeartbeatState } from './bridge-heartbeat.js';
18
18
  export { BRIDGE_HEARTBEAT_INTERVAL_MS, formatBridgeHeartbeatLine } from './bridge-heartbeat.js';
19
19
  export type { BridgeCallOutcome, BridgeTransport } from './bridge-transport.js';
20
+ export type { CaptureListener, CaptureNotes, CaptureRecord } from './capture-notes.js';
21
+ export { describeCaptureCaveat } from './capture-notes.js';
20
22
  export type { GameClientOptions } from './client.js';
21
23
  export { GameClient, GameEvents, GameInput, PageTransport } from './client.js';
22
24
  export type { SessionErrorCode } from './errors.js';
@@ -22,6 +22,7 @@
22
22
  import { mkdir, writeFile } from 'node:fs/promises';
23
23
  import { dirname } from 'node:path';
24
24
  import type { BridgeCallOutcome, BridgeTransport } from './bridge-transport.js';
25
+ import { type CaptureNotes, describeCaptureCaveat } from './capture-notes.js';
25
26
 
26
27
  export interface RelayTransportOptions {
27
28
  /** The live editor session's dev-server port (e.g. from
@@ -246,7 +247,7 @@ export class RelayTransport implements BridgeTransport {
246
247
  this.forceHiddenDrive = true;
247
248
  }
248
249
 
249
- async screenshot(path: string): Promise<void> {
250
+ async screenshot(path: string): Promise<CaptureNotes> {
250
251
  // Same preflight as every other leg — a hidden tab's canvas is a provably
251
252
  // stale frame, and `refreshHiddenFrame` is what asks the relay for a
252
253
  // deterministic one-tick refresh instead of a `BRIDGE_SCREENSHOT_STALE`
@@ -268,19 +269,20 @@ export class RelayTransport implements BridgeTransport {
268
269
  }
269
270
  await mkdir(dirname(path), { recursive: true });
270
271
  await writeFile(path, Buffer.from(body['base64'] as string, 'base64'));
271
- // `game.screenshot()` hands back a path, so a near-blank frame is
272
- // indistinguishable from a good one until somebody opens the file — which
273
- // is exactly how two probes cited blank captures as evidence. The page
274
- // wrote the sentence; say it where the caller is looking.
272
+ // `game.screenshot()` hands back a path, so a near-blank or on-demand frame
273
+ // is indistinguishable from a good one until somebody opens the file —
274
+ // which is exactly how two probes cited blank captures as evidence. The
275
+ // page measured both conditions; carry them back so the caller can persist
276
+ // them beside the frame, and say the composed sentence here too, where a
277
+ // human watching this terminal is looking.
275
278
  const warning = (body['flatness'] as { warning?: string } | undefined)?.warning;
276
- if (typeof warning === 'string') console.warn(`vgai screenshot: warning ${warning}`);
277
- if (body['hiddenFrame'] === true) {
278
- console.warn(
279
- `vgai screenshot: ${path} is a HIDDEN FRAME — the editor tab is hidden, so the runtime ` +
280
- 'rendered one deterministic tick on demand. It is current, not stale; it is not a ' +
281
- 'frame anyone was watching.',
282
- );
283
- }
279
+ const notes: CaptureNotes = {
280
+ ...(body['hiddenFrame'] === true ? { hiddenFrame: true } : {}),
281
+ ...(typeof warning === 'string' ? { flatnessWarning: warning } : {}),
282
+ };
283
+ const caveat = describeCaptureCaveat(notes);
284
+ if (caveat !== null) console.warn(`vgai screenshot: ${path} — ${caveat}`);
285
+ return notes;
284
286
  }
285
287
 
286
288
  /**