@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.
- package/dist/.tsbuildinfo +1 -1
- package/dist/game-client/bridge-transport.d.ts +8 -4
- package/dist/game-client/capture-notes.d.ts +56 -0
- package/dist/game-client/capture-notes.js +43 -0
- package/dist/game-client/client.d.ts +20 -1
- package/dist/game-client/client.js +41 -1
- package/dist/game-client/index.d.ts +2 -0
- package/dist/game-client/index.js +1 -0
- package/dist/game-client/relay-transport.d.ts +2 -1
- package/dist/game-client/relay-transport.js +15 -11
- package/package.json +5 -4
- package/src/game-client/bridge-transport.ts +9 -4
- package/src/game-client/capture-notes.ts +74 -0
- package/src/game-client/client.ts +42 -2
- package/src/game-client/index.ts +2 -0
- package/src/game-client/relay-transport.ts +15 -13
|
@@ -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)
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
|
|
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
|
-
|
|
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<
|
|
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
|
|
200
|
-
// indistinguishable from a good one until somebody opens the file —
|
|
201
|
-
// is exactly how two probes cited blank captures as evidence. The
|
|
202
|
-
//
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
+
"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.
|
|
34
|
-
"@vgai/sdk": "0.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)
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
|
|
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
|
-
|
|
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
|
package/src/game-client/index.ts
CHANGED
|
@@ -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<
|
|
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
|
|
272
|
-
// indistinguishable from a good one until somebody opens the file —
|
|
273
|
-
// is exactly how two probes cited blank captures as evidence. The
|
|
274
|
-
//
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
/**
|