@vgai/live 0.5.42 → 0.5.45

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.
@@ -2,17 +2,25 @@
2
2
  * What a capture knows about ITSELF beyond its pixels — and the one place the
3
3
  * caveat sentence is spelled.
4
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
5
+ * A PNG is silent about the conditions it was taken under. Four of those
6
+ * conditions matter, and each is already measured elsewhere in the stack.
7
+ * Three change what the frame is worth as EVIDENCE: the editor page reports
8
8
  * `loopRecoveryFrame` when the host loop was starved and the runtime had to
9
9
  * render one deterministic tick on demand (`command-listener.ts`'s
10
10
  * `handleBridgeScreenshot`), and it reports
11
11
  * a `flatness.warning` sentence when the frame is nine-tenths one flat surface
12
- * (`composite-screenshot.ts`'s `measureFlatness`). Until now both stopped at a
12
+ * (`composite-screenshot.ts`'s `measureFlatness`), and it names the CLIP a
13
+ * frame of a recorded run belongs to (`command-listener.ts`'s
14
+ * `screenshotRecordingNotice`). Until now these stopped at a
13
15
  * `console.warn` inside the relay transport — visible to a human watching a
14
16
  * terminal, invisible to anything that later reads the file.
15
17
  *
18
+ * The fourth is not about the frame at all but about what taking it COST: a
19
+ * capture written under the served project root goes through the dev server's
20
+ * file watcher on every shot (measured ~3x slower frame rates), and the
21
+ * out-path resolver is the only place that knows
22
+ * (`screenshot-target.ts`'s `underWatchedProjectRoot`).
23
+ *
16
24
  * So the transport seam carries them back as {@link CaptureNotes}, and
17
25
  * {@link describeCaptureCaveat} turns them into the ONE sentence every surface
18
26
  * says. Its two callers are the transport's own console warning and
@@ -23,15 +31,27 @@
23
31
  /** The loop-recovery sentence. Spelled once because it is said in two places
24
32
  * (a live console warning and a persisted record) and a second copy is a
25
33
  * second wording. */
34
+ /**
35
+ * The cost of writing a capture INSIDE the served project.
36
+ *
37
+ * The dev server watches the project root, and its ignore list names build
38
+ * output only — nothing about capture output — so every PNG written under the
39
+ * root goes through the watcher. Measured cost class: ~3x slower frame rates
40
+ * while a capture loop wrote there. Named, not fixed: where a capture goes is
41
+ * the caller's decision, and this door's job is to stop that decision being
42
+ * made blind.
43
+ */
44
+ const WATCHED_CAPTURE_PATH_CAVEAT = 'writing captures under the project root triggers the dev server’s file watcher; expect ~3× ' +
45
+ 'slower frame rates — write outside the project or to the session’s own capture dir';
26
46
  const LOOP_RECOVERY_FRAME_CAVEAT = 'LOOP-RECOVERY FRAME — the host loop was starved, so the runtime rendered one ' +
27
47
  'deterministic tick on demand. It is current, not stale; it was not produced by ordinary presentation.';
28
48
  /**
29
49
  * What a reader must be told about this frame, or `null` when there is nothing
30
50
  * to tell.
31
51
  *
32
- * Both notes can be true at once (an on-demand recovery tick that also came
33
- * out near-blank), and both are said — a capture that is degraded twice over
34
- * must not report only the first reason.
52
+ * Any of them can be true at once (an on-demand recovery tick that also came
53
+ * out near-blank, in a recorded run), and each is said — a capture that is
54
+ * degraded twice over must not report only the first reason.
35
55
  */
36
56
  export function describeCaptureCaveat(notes) {
37
57
  const parts = [];
@@ -40,5 +60,12 @@ export function describeCaptureCaveat(notes) {
40
60
  if (typeof notes.flatnessWarning === 'string' && notes.flatnessWarning !== '') {
41
61
  parts.push(notes.flatnessWarning);
42
62
  }
63
+ if (typeof notes.recordingPath === 'string' && notes.recordingPath !== '') {
64
+ parts.push(`ONE FRAME of a recorded run — ${notes.recordingPath} holds the whole of it. A still ` +
65
+ 'answers what it looks like; a temporal question (did the jump land) needs the clip.');
66
+ }
67
+ if (typeof notes.watchedProjectRoot === 'string' && notes.watchedProjectRoot !== '') {
68
+ parts.push(`${WATCHED_CAPTURE_PATH_CAVEAT} (${notes.watchedProjectRoot}).`);
69
+ }
43
70
  return parts.length === 0 ? null : parts.join(' ');
44
71
  }
@@ -87,6 +87,11 @@ export interface GameClientOptions {
87
87
  * actually running). */
88
88
  fenceWallMs: number;
89
89
  artifactsDir?: string | undefined;
90
+ /** The project root the editor session is SERVING, when the caller knows it
91
+ * (`@vgai/live`'s `connect()` does). Used for one thing: saying, in a
92
+ * capture's own notes, that its destination falls under the dev server's
93
+ * file watcher — see `capture-notes.ts`. Never used to resolve a path. */
94
+ projectRoot?: string | undefined;
90
95
  /** Set by the caller when it reused an already-running game server rather
91
96
  * than booting a fresh one for this run. Threaded through so `unwrap` can
92
97
  * append the warm-session staleness hint to a
@@ -332,10 +337,28 @@ export declare class GameClient {
332
337
  * mount's own url space, so what you touch IS the running game — never a
333
338
  * phantom second copy. Dev-server sessions only; a shipped build's curated
334
339
  * surface is its adapter exports.
340
+ *
341
+ * `modules(path)` IS ASYNC — it dynamic-imports that url — so the callback
342
+ * is `async` and the call is `await`ed, in every example on this page and
343
+ * everywhere else. Skipping it does not fail quietly: reading a member off
344
+ * the unawaited promise throws a message naming the fix, because
345
+ * `TypeError: modules(...).simHost is not a function` (measured, cold fox
346
+ * #3) says nothing about promises.
347
+ *
348
+ * `modules` IS A FUNCTION, not a table — there is no module registry. To ask
349
+ * what the mount has loaded, read `modules.loaded`, the project-relative
350
+ * paths you can pass straight back in:
351
+ *
352
+ * ```js
353
+ * await game.run(({ modules }) => modules.loaded)
354
+ * // → ['src/scenes/MainScene.tsx', 'src/world.tsx', …]
355
+ * ```
335
356
  */
336
357
  run<T = unknown>(step: (scope: {
337
358
  page: Page;
338
- modules: (path: string) => Promise<Record<string, unknown>>;
359
+ modules: ((path: string) => Promise<Record<string, unknown>>) & {
360
+ readonly loaded: string[];
361
+ };
339
362
  instanceId: string;
340
363
  }) => T | Promise<T>, opts?: {
341
364
  instance?: string;
@@ -454,6 +454,8 @@ export class GameClient {
454
454
  #transport;
455
455
  /** 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. */
456
456
  artifactsDir;
457
+ /** See `GameClientOptions.projectRoot`. */
458
+ #projectRoot;
457
459
  /** See `GameClientOptions.warmSession`. */
458
460
  #warmSession;
459
461
  /** See `GameClientOptions.testTitle`. */
@@ -484,6 +486,7 @@ export class GameClient {
484
486
  this.fenceSimSeconds = opts.fenceSimSeconds;
485
487
  this.fenceWallMs = opts.fenceWallMs;
486
488
  this.artifactsDir = opts.artifactsDir ?? resolve('.vgai/last-run');
489
+ this.#projectRoot = opts.projectRoot;
487
490
  this.#warmSession = opts.warmSession ?? false;
488
491
  this.#testTitle = opts.testTitle ?? 'test';
489
492
  this.#bridgeHeartbeat = { lastEmitWallMs: Date.now() };
@@ -705,16 +708,33 @@ export class GameClient {
705
708
  artifactsDir: this.artifactsDir,
706
709
  sequence: this.#screenshotCounter + 1,
707
710
  cwd: process.cwd(),
711
+ projectRoot: this.#projectRoot,
708
712
  });
709
713
  if (target.consumedSequence)
710
714
  this.#screenshotCounter += 1;
711
715
  await mkdir(dirname(target.path), { recursive: true });
712
- const notes = await this.#transport.screenshot(target.path);
716
+ const transportNotes = await this.#transport.screenshot(target.path);
717
+ // The out-path resolver is the only thing that knows the destination fell
718
+ // under the served project root, and the notes are where a capture says
719
+ // what it cost — so the two are joined here rather than at either end.
720
+ const notes = {
721
+ ...transportNotes,
722
+ ...(target.underWatchedProjectRoot === undefined
723
+ ? {}
724
+ : { watchedProjectRoot: target.underWatchedProjectRoot }),
725
+ };
713
726
  const capture = {
714
727
  label: labelOrPath,
715
728
  path: target.path,
716
729
  caveat: describeCaptureCaveat(notes),
717
730
  };
731
+ // ONE place says the sentence, with the WHOLE note set — a human watching
732
+ // this terminal and a run record read beside the frame must not be told
733
+ // different things (`capture-notes.ts`'s contract). The transport used to
734
+ // warn from its own partial set, which silently dropped every note this
735
+ // layer adds.
736
+ if (capture.caveat !== null)
737
+ console.warn(`vgai screenshot: ${capture.path} — ${capture.caveat}`);
718
738
  // Sequential and AWAITED: a listener may need to read the game to stamp
719
739
  // this capture, and it must have finished before the path is handed back —
720
740
  // a caller that files the path is entitled to assume the record of it is
@@ -780,6 +800,22 @@ export class GameClient {
780
800
  * mount's own url space, so what you touch IS the running game — never a
781
801
  * phantom second copy. Dev-server sessions only; a shipped build's curated
782
802
  * surface is its adapter exports.
803
+ *
804
+ * `modules(path)` IS ASYNC — it dynamic-imports that url — so the callback
805
+ * is `async` and the call is `await`ed, in every example on this page and
806
+ * everywhere else. Skipping it does not fail quietly: reading a member off
807
+ * the unawaited promise throws a message naming the fix, because
808
+ * `TypeError: modules(...).simHost is not a function` (measured, cold fox
809
+ * #3) says nothing about promises.
810
+ *
811
+ * `modules` IS A FUNCTION, not a table — there is no module registry. To ask
812
+ * what the mount has loaded, read `modules.loaded`, the project-relative
813
+ * paths you can pass straight back in:
814
+ *
815
+ * ```js
816
+ * await game.run(({ modules }) => modules.loaded)
817
+ * // → ['src/scenes/MainScene.tsx', 'src/world.tsx', …]
818
+ * ```
783
819
  */
784
820
  async run(step, opts) {
785
821
  const erased = (arg) => step(arg);
@@ -19,7 +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
+ import type { CaptureNotes } from './capture-notes.js';
23
23
  export interface RelayTransportOptions {
24
24
  /** The live editor session's dev-server port (e.g. from
25
25
  * `findLiveEditorSession`/`vgai edit`). */
@@ -20,7 +20,6 @@
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';
24
23
  const DEFAULT_TIMEOUT_MS = 10_000;
25
24
  /** `invoke` dispatches to arbitrary game-registered debug commands — give it
26
25
  * real headroom rather than the ordinary sync-call budget above. */
@@ -236,16 +235,20 @@ export class RelayTransport {
236
235
  // is indistinguishable from a good one until somebody opens the file —
237
236
  // which is exactly how two probes cited blank captures as evidence. The
238
237
  // page measured both conditions; carry them back so the caller can persist
239
- // them beside the frame, and say the composed sentence here too, where a
240
- // human watching this terminal is looking.
238
+ // them beside the frame.
239
+ //
240
+ // The SENTENCE is not said here. `GameClient.screenshot` composes it from
241
+ // these notes PLUS the ones only it has (the out-path's relation to the
242
+ // served project root), and `capture-notes.ts`'s whole contract is that
243
+ // the terminal and the persisted record cannot drift — which a second
244
+ // `console.warn` on a partial set is exactly how they would.
241
245
  const warning = body['flatness']?.warning;
246
+ const recordingPath = body['recording']?.path;
242
247
  const notes = {
243
248
  ...(body['loopRecoveryFrame'] === true ? { loopRecoveryFrame: true } : {}),
244
249
  ...(typeof warning === 'string' ? { flatnessWarning: warning } : {}),
250
+ ...(typeof recordingPath === 'string' ? { recordingPath } : {}),
245
251
  };
246
- const caveat = describeCaptureCaveat(notes);
247
- if (caveat !== null)
248
- console.warn(`vgai screenshot: ${path} — ${caveat}`);
249
252
  return notes;
250
253
  }
251
254
  /**
@@ -43,6 +43,10 @@ export interface ScreenshotTargetInput {
43
43
  readonly sequence: number;
44
44
  /** Base for resolving a relative path/artifactsDir — the process cwd. */
45
45
  readonly cwd: string;
46
+ /** The project root the dev server is SERVING, when it is known. Only used
47
+ * to say whether the destination lands under the file watcher — see
48
+ * {@link ScreenshotTarget.underWatchedProjectRoot}. */
49
+ readonly projectRoot?: string | undefined;
46
50
  }
47
51
  export interface ScreenshotTarget {
48
52
  readonly kind: ScreenshotArgKind;
@@ -51,6 +55,19 @@ export interface ScreenshotTarget {
51
55
  /** True when the caller's own ordinal was consumed (label form only), so a
52
56
  * path-form call never perturbs the numbering of the artifacts around it. */
53
57
  readonly consumedSequence: boolean;
58
+ /**
59
+ * The served project root this destination falls under, when it does.
60
+ *
61
+ * The dev server watches the project root for source changes and its
62
+ * `server.watch.ignored` list names build output only (`dist/`, `.vercel/`,
63
+ * `logs/`, `.claude/worktrees/`) — nothing about capture output. So a PNG
64
+ * written anywhere under the root goes through the watcher on every shot,
65
+ * and the cost is not theoretical: measured ~3x slower frame rates while a
66
+ * capture loop wrote under the project. This does not CHANGE where anything
67
+ * is written; it lets the capture say what it costs
68
+ * ({@link import('./capture-notes.js').describeCaptureCaveat}).
69
+ */
70
+ readonly underWatchedProjectRoot?: string;
54
71
  }
55
72
  /**
56
73
  * Resolve the absolute file a screenshot call must write. Pure — no I/O, no
@@ -19,7 +19,7 @@
19
19
  * resolved against the process cwd — the same rule `vgai screenshot --out`
20
20
  * already documents.
21
21
  */
22
- import { isAbsolute, resolve } from 'node:path';
22
+ import { isAbsolute, relative, resolve } from 'node:path';
23
23
  /**
24
24
  * PATH when the argument names a location: absolute, containing a `/` or `\`
25
25
  * separator, an explicit `./`-style relative prefix, or carrying a file
@@ -45,6 +45,17 @@ export function classifyScreenshotArg(arg) {
45
45
  export function sanitizeScreenshotLabel(label) {
46
46
  return label.replace(/[^a-zA-Z0-9-_]+/g, '-');
47
47
  }
48
+ /** Is `file` inside `root` (not merely sharing a path prefix)? */
49
+ function isUnder(root, file) {
50
+ const rel = relative(resolve(root), file);
51
+ return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
52
+ }
53
+ /** The watched-root note, when the destination has one. */
54
+ function watchedRoot(path, projectRoot) {
55
+ if (projectRoot === undefined || projectRoot === '')
56
+ return {};
57
+ return isUnder(projectRoot, path) ? { underWatchedProjectRoot: resolve(projectRoot) } : {};
58
+ }
48
59
  /**
49
60
  * Resolve the absolute file a screenshot call must write. Pure — no I/O, no
50
61
  * `process.cwd()` read — so the contract above is testable without a browser,
@@ -54,15 +65,23 @@ export function resolveScreenshotTarget(input) {
54
65
  const kind = classifyScreenshotArg(input.arg);
55
66
  if (kind === 'path') {
56
67
  const withExtension = /\.[a-zA-Z0-9]{1,8}$/.test(input.arg) ? input.arg : `${input.arg}.png`;
68
+ const path = isAbsolute(withExtension) ? withExtension : resolve(input.cwd, withExtension);
57
69
  return {
58
70
  kind,
59
- path: isAbsolute(withExtension) ? withExtension : resolve(input.cwd, withExtension),
71
+ path,
60
72
  consumedSequence: false,
73
+ ...watchedRoot(path, input.projectRoot),
61
74
  };
62
75
  }
63
76
  const fileName = `${String(input.sequence).padStart(3, '0')}-${sanitizeScreenshotLabel(input.arg)}.png`;
64
77
  const dir = isAbsolute(input.artifactsDir)
65
78
  ? input.artifactsDir
66
79
  : resolve(input.cwd, input.artifactsDir);
67
- return { kind, path: resolve(dir, fileName), consumedSequence: true };
80
+ const path = resolve(dir, fileName);
81
+ return {
82
+ kind,
83
+ path,
84
+ consumedSequence: true,
85
+ ...watchedRoot(path, input.projectRoot),
86
+ };
68
87
  }
package/dist/game.d.ts CHANGED
@@ -52,7 +52,7 @@ export interface LiveGame extends GameClient {
52
52
  /** The unaddressed `game` client — targets the sole live instance and refuses
53
53
  * when several are mounted. Kept as a named export for callers/tests that
54
54
  * want just the base client. */
55
- export declare function createGameClient(port: number, artifactsDir?: string): GameClient;
55
+ export declare function createGameClient(port: number, artifactsDir?: string, projectRoot?: string): GameClient;
56
56
  /** Build the `LiveGame` — the base `game` client plus its instance-addressing
57
57
  * surface. */
58
- export declare function createLiveGame(port: number, artifactsDir?: string): LiveGame;
58
+ export declare function createLiveGame(port: number, artifactsDir?: string, projectRoot?: string): LiveGame;
package/dist/game.js CHANGED
@@ -32,7 +32,7 @@ import { GameClient, RelayTransport } from './game-client/index.js';
32
32
  * fence. `pageErrors`/`consoleErrors` are always empty — relay mode has no
33
33
  * separate page handle to listen on.
34
34
  */
35
- function gameClientFor(port, artifactsDir, instance) {
35
+ function gameClientFor(port, artifactsDir, instance, projectRoot) {
36
36
  return new GameClient({
37
37
  transport: new RelayTransport(instance === undefined ? { port } : { port, instance }),
38
38
  pageErrors: [],
@@ -43,13 +43,14 @@ function gameClientFor(port, artifactsDir, instance) {
43
43
  fenceWallMs: Date.now(),
44
44
  warmSession: false,
45
45
  artifactsDir,
46
+ projectRoot,
46
47
  });
47
48
  }
48
49
  /** The unaddressed `game` client — targets the sole live instance and refuses
49
50
  * when several are mounted. Kept as a named export for callers/tests that
50
51
  * want just the base client. */
51
- export function createGameClient(port, artifactsDir) {
52
- return gameClientFor(port, artifactsDir);
52
+ export function createGameClient(port, artifactsDir, projectRoot) {
53
+ return gameClientFor(port, artifactsDir, undefined, projectRoot);
53
54
  }
54
55
  /** Query the editor's live instance ids over the session wire.
55
56
  *
@@ -73,12 +74,12 @@ async function listInstanceIds(port) {
73
74
  }
74
75
  /** Build the `LiveGame` — the base `game` client plus its instance-addressing
75
76
  * surface. */
76
- export function createLiveGame(port, artifactsDir) {
77
- const base = gameClientFor(port, artifactsDir);
77
+ export function createLiveGame(port, artifactsDir, projectRoot) {
78
+ const base = gameClientFor(port, artifactsDir, undefined, projectRoot);
78
79
  // Tag each addressed handle with the mount id it drives, so a caller can pass
79
80
  // `handle.id` to `tools.run(..., { instance })`. The id is already known here
80
81
  // (it is what parameterizes the relay); attaching it just hands it back.
81
- const instance = (id) => Object.assign(gameClientFor(port, artifactsDir, id), { id });
82
+ const instance = (id) => Object.assign(gameClientFor(port, artifactsDir, id, projectRoot), { id });
82
83
  const instances = async () => (await listInstanceIds(port)).map(instance);
83
84
  return Object.assign(base, { instance, instances });
84
85
  }
package/dist/index.d.ts CHANGED
@@ -29,7 +29,7 @@
29
29
  *
30
30
  * `editor.document` is the OTHER surface door and the one that is NOT
31
31
  * play-mode gated: `page(step)` is rooted at the running GAME container, so
32
- * `editor.document.{query,click,key,paste}` is how an editor surface that is
32
+ * `editor.document.{query,click,key,paste,select}` is how an editor surface that is
33
33
  * not a game — a capability's workspace document, the Data sheet — gets read
34
34
  * and driven through the product. It is scoped to the ACTIVE document and
35
35
  * refuses anything outside it by name (`editor-document.ts`).
package/dist/index.js CHANGED
@@ -29,7 +29,7 @@
29
29
  *
30
30
  * `editor.document` is the OTHER surface door and the one that is NOT
31
31
  * play-mode gated: `page(step)` is rooted at the running GAME container, so
32
- * `editor.document.{query,click,key,paste}` is how an editor surface that is
32
+ * `editor.document.{query,click,key,paste,select}` is how an editor surface that is
33
33
  * not a game — a capability's workspace document, the Data sheet — gets read
34
34
  * and driven through the product. It is scoped to the ACTIVE document and
35
35
  * refuses anything outside it by name (`editor-document.ts`).
@@ -67,7 +67,7 @@ export { LiveTools } from './tools.js';
67
67
  function bindTo(port, projectRoot) {
68
68
  const client = new EditorClient({ url: `http://127.0.0.1:${port}` });
69
69
  const editor = new LiveEditor(client);
70
- const game = createLiveGame(port, join(projectRoot, '.vgai', 'last-run'));
70
+ const game = createLiveGame(port, join(projectRoot, '.vgai', 'last-run'), projectRoot);
71
71
  const step = (fn) => game.page(fn);
72
72
  const page = Object.assign(step, { reload: () => game.reloadPage() });
73
73
  const tools = new LiveTools(client);
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.42",
5
+ "version": "0.5.45",
6
6
  "type": "module",
7
7
  "repository": {
8
8
  "type": "git",
@@ -32,8 +32,8 @@
32
32
  "prepack": "npm run build"
33
33
  },
34
34
  "dependencies": {
35
- "@vgai/editor-sdk": "0.5.42",
36
- "@vgai/sdk": "0.5.42"
35
+ "@vgai/editor-sdk": "0.5.45",
36
+ "@vgai/sdk": "0.5.45"
37
37
  },
38
38
  "peerDependencies": {
39
39
  "@playwright/test": ">=1.58.2 <2"
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * `editor.document` — the session binding for the scoped editor-chrome door.
3
3
  *
4
- * WHY IT IS A SEPARATE OBJECT, and why the verbs are these four, is recorded
4
+ * WHY IT IS A SEPARATE OBJECT, and why the verbs are these five, is recorded
5
5
  * once in the implementation's header
6
6
  * (`packages/editor/src/editor-document-probe.ts`); the short version is that
7
7
  * `game.page()` is play-mode-gated and rooted at the GAME container, so an
@@ -16,7 +16,7 @@
16
16
  * RENDERED rather than what a DOM scrape can find.
17
17
  *
18
18
  * A field on `LiveEditor` rather than methods on it, so `vgai eval --list`
19
- * shows the four verbs as one named surface — the same reason `game.input`
19
+ * shows the six verbs as one named surface — the same reason `game.input`
20
20
  * and `game.events` are instance fields.
21
21
  */
22
22
 
@@ -69,6 +69,41 @@ export class LiveEditorDocument {
69
69
  });
70
70
  }
71
71
 
72
+ /**
73
+ * A real pointer DRAG across one matched element — press at `from`, move,
74
+ * release at `to`, as fractions of the element's box (`[0.5, 0.5]` is its
75
+ * center). The gesture a direct-manipulation canvas needs; a zero-length
76
+ * drag is a click at that fraction, which `click` (always the center)
77
+ * cannot place.
78
+ */
79
+ async drag(
80
+ selector: string,
81
+ options: DocumentGestureOptions & {
82
+ from: [number, number];
83
+ to: [number, number];
84
+ via?: [number, number][];
85
+ steps?: number;
86
+ altKey?: boolean;
87
+ ctrlKey?: boolean;
88
+ metaKey?: boolean;
89
+ shiftKey?: boolean;
90
+ },
91
+ ): Promise<DocumentProbeResult> {
92
+ return this.#probe({
93
+ action: 'drag',
94
+ selector,
95
+ from: options.from,
96
+ to: options.to,
97
+ ...(options.via === undefined ? {} : { via: options.via }),
98
+ ...(options.steps === undefined ? {} : { steps: options.steps }),
99
+ ...(options.index === undefined ? {} : { index: options.index }),
100
+ ...(options.altKey === undefined ? {} : { altKey: options.altKey }),
101
+ ...(options.ctrlKey === undefined ? {} : { ctrlKey: options.ctrlKey }),
102
+ ...(options.metaKey === undefined ? {} : { metaKey: options.metaKey }),
103
+ ...(options.shiftKey === undefined ? {} : { shiftKey: options.shiftKey }),
104
+ });
105
+ }
106
+
72
107
  /** A real keydown/keyup on the target, or on whatever inside the document has focus. */
73
108
  async key(key: string, options?: DocumentKeyOptions): Promise<DocumentProbeResult> {
74
109
  return this.#probe({ action: 'key', key, ...(options ?? {}) });
@@ -80,6 +115,41 @@ export class LiveEditorDocument {
80
115
  return this.#probe({ action: 'paste', text, ...(options ?? {}) });
81
116
  }
82
117
 
118
+ /**
119
+ * Choose `value` on a `<select>` — a native dropdown's options are drawn by
120
+ * the OS, so `click` has nothing in the document to resolve, and a plain
121
+ * `element.value =` is invisible to React. Set through the prototype's own
122
+ * value setter plus `input`/`change`; `value` is the option's `value`, not
123
+ * its label. An unknown value is refused with the options it does offer.
124
+ */
125
+ async select(
126
+ selector: string,
127
+ value: string,
128
+ options?: DocumentGestureOptions,
129
+ ): Promise<DocumentProbeResult> {
130
+ return this.#probe({
131
+ action: 'select',
132
+ selector,
133
+ value,
134
+ ...(options?.index === undefined ? {} : { index: options.index }),
135
+ });
136
+ }
137
+
138
+ /**
139
+ * THE REPL over the open document: run `step` in the editor page against the
140
+ * object the ACTIVE document published as its context (the mesh document
141
+ * publishes its `MeshEditSession`, whose `ctx` is the bpy-shaped edit
142
+ * context — `ctx.ops.mesh.bevel({ offset: 0.1 })`, `ctx.selection`,
143
+ * `ctx.history`, `session.commit()`). Edit mode, no play. Serialized like
144
+ * `game.page`: the step's own source travels, so inline every value it
145
+ * needs and return plain data.
146
+ */
147
+ async run<T = unknown>(
148
+ step: (ctx: unknown, info: { documentId: string }) => T | Promise<T>,
149
+ ): Promise<T> {
150
+ return this.#client.documentScript<T>(step.toString());
151
+ }
152
+
83
153
  #probe(step: DocumentProbeStep): Promise<DocumentProbeResult> {
84
154
  return this.#client.documentProbe(step);
85
155
  }