@vestra-player/sdk 1.6.17 → 1.6.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/docs-tree.json.gz +0 -0
  3. package/dist/plugin/runtime.d.ts +26 -0
  4. package/dist/plugin/runtime.js +16 -0
  5. package/dist/plugin/testing/commands.d.ts +65 -0
  6. package/dist/plugin/testing/commands.js +412 -0
  7. package/dist/plugin/testing/core-actions.d.ts +39 -0
  8. package/dist/plugin/testing/core-actions.js +371 -0
  9. package/dist/plugin/testing/declarations.d.ts +37 -0
  10. package/dist/plugin/testing/declarations.js +80 -0
  11. package/dist/plugin/testing/i18n.d.ts +28 -0
  12. package/dist/plugin/testing/i18n.js +107 -0
  13. package/dist/plugin/testing/index.d.ts +3140 -0
  14. package/dist/plugin/testing/index.js +2796 -0
  15. package/dist/plugin/testing/internals.d.ts +76 -0
  16. package/dist/plugin/testing/internals.js +60 -0
  17. package/dist/plugin/testing/jobs.d.ts +9 -0
  18. package/dist/plugin/testing/jobs.js +304 -0
  19. package/dist/plugin/testing/layout.d.ts +85 -0
  20. package/dist/plugin/testing/layout.js +622 -0
  21. package/dist/plugin/testing/media.d.ts +55 -0
  22. package/dist/plugin/testing/media.js +615 -0
  23. package/dist/plugin/testing/osd.d.ts +4 -0
  24. package/dist/plugin/testing/osd.js +57 -0
  25. package/dist/plugin/testing/processing.d.ts +56 -0
  26. package/dist/plugin/testing/processing.js +491 -0
  27. package/dist/plugin/testing/services.d.ts +50 -0
  28. package/dist/plugin/testing/services.js +466 -0
  29. package/dist/plugin/testing/transport.d.ts +29 -0
  30. package/dist/plugin/testing/transport.js +140 -0
  31. package/dist/plugin/testing.d.ts +1 -3140
  32. package/dist/plugin/testing.js +1 -6203
  33. package/llms.txt +1 -1
  34. package/package.json +5 -3
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.6.18
4
+
5
+ **Who must change: nobody.** Characterisation tests for the SDK refactor (step 0 of the plan, `doc/specs/sdk-refactor.md` §4.0 in the player repository); no API change, no wire change, `apiVersion` stays `vestra/1`.
6
+
7
+ - **Golden wire traces** (`test/golden-traces.test.ts`): the ten examples, and the six built-ins named by the plan when `VESTRA_PLAYER` points at a player checkout, run one fixed script against `TestHost` (start, open a source, three state changes, one command, dispose); the `(method, params)` sequence, every `events.subscribe` line with its `mirror`/`why` marks (line numbers stripped) and the update-pass counts per phase are compared with `test/fixtures/traces/*.json`. Eight host scenarios (`host-*.json`) pin the test host's own edges: `disconnect()` with requests in flight, the `commands.keys` push after `commands.register` against `settle()`, `press`, `handle()`/`reset()`, a stream producer's temporary files, `startPlugin` with a `taken` conflict, the `VESTRA_PLUGIN_RESIDENCY=1` line and a host never started. `UPDATE_TRACES=1` rewrites them.
8
+ - **API surface snapshot** (`scripts/api-surface.mjs`, `npm run api-surface -- --check|--update`, `test/api-surface.test.ts`): every exported declaration and member of the `.` and `./testing` entries (the declaration files package.json `exports` names), sorted, in `test/fixtures/api-surface.txt`.
9
+ - **Pass counters**: `Runtime.counts` (internal, read-only: passes, full passes, safety passes, binding evaluations, view bind functions evaluated and skipped) for the SDK's own tests and benchmark; no behaviour change, nothing new on `Plugin` or `TestHost`.
10
+ - **Benchmark** (`npm run bench`, `scripts/bench.mjs`): a 20,000-row keyed list with 1 % and 10 % row changes through the diff engine, 1,000 passes over 50 bound views and 100 commands, and the state-change-to-send latency; medians of five runs, baseline in `docs/maintaining.md`.
11
+ - **`@vestra-player/sdk/testing` is split into modules** (step 1 of the refactor, batch A). **Who must change: nobody**: no API, behaviour or wire change. The single 7,600-line `src/plugin/testing.ts` is now the directory `src/plugin/testing/`: `index.ts` keeps the module page, every export and the one `TestHost` class with all its public members and doc comments; the class keeps its private state in one internal object (`internals.ts`) and delegates its wire handlers and longer methods to area modules (`transport`, `commands`, `declarations`, `core-actions`, `i18n`, `layout`, `osd`, `jobs`, `processing`, `media`, `services`). Error codes, messages and handler semantics are unchanged: every golden trace and host scenario, the API surface snapshot and the pass counts are identical, and the declarations (`dist/plugin/testing/index.d.ts`) equal the former `dist/plugin/testing.d.ts` apart from relative import paths. The `./testing` export now points at `dist/plugin/testing/index.js`; `dist/plugin/testing.js` stays as a re-export. The documentation tree (`dist/docs-tree.json.gz`) is unchanged.
12
+
3
13
  ## 1.6.17
4
14
 
5
15
  **Who must change: nobody.** Tables follow the slim context menu (B5): a data refresh of `BUILTIN_COMMANDS`, `BUILTIN_SUBMENUS` and the test host's core commands for the player's right-click menu (player 0.7.6); no API change, `apiVersion` stays `vestra/1`. A plugin whose tests compare a path or a key against the tables sees the player's current ones.
@@ -25,11 +35,13 @@
25
35
  - **Wire** (additive): `ui.setMount`, `ui.interaction`, `ui.menu` and `ui.unmount` take an optional `slot` naming the panel (without it: the plugin's only panel; `ui.unmount` without it removes every panel); `ui.callback` and `ui.preparing` carry the panel's `slot`, and `ui.callback` keeps `view: "$mount"`. Older players ignore `slot`.
26
36
  - **`BUILTIN_COMMANDS`**: the player's seek, frame and speed actions (`host/seek-backward`, `seek-forward`, `frame-back`, `frame-forward`, `rate-down`, `rate-up`, `rate-normal`) leave Playback ▸ Seek and Speed for the command palette (player 0.7.6, with the playback plugin's panel); their keys and categories stay.
27
37
  - **`TestHost`**: `panels` (by slot, in mount order) and `mounted(slot?)`; without a slot `mounted()` returns the only panel and throws when there are several. `panel` stays, as the last panel mounted. The handlers take `slot` as the player does; `ui.interaction` stores the locks without it.
38
+
28
39
  ## 1.6.14
29
40
 
30
41
  **Who must change: nobody.** A data refresh: `BUILTIN_COMMANDS` lists the built-in capture plugin's commands as the player registers them since its capture tab; no API change, `apiVersion` stays `vestra/1`.
31
42
 
32
43
  - **`BUILTIN_COMMANDS`**: `vestra.capture` has **Video ▸ Snapshot ▸ Capture…** (Alt+S), which opens its sidebar tab; Save/Copy Rendered Frame, Save Current Frame As…, Export Tasks…, the capture formats, Snapshot Settings…, Snapshot Folder… and Open Snapshot Folder left the menu for the command palette, and new palette commands came: Export Clip Range (Ctrl+Shift+E), Save and Copy Video Frame Only, Show Last Capture in Folder, Copy Last Snapshot, Set Clip Start, Set Clip End and Export Last 30 Seconds (Ctrl+Shift+R). A plugin that checks a key or menu path against the table (`vestra-plugin check`, `TestHost`) sees the player's current ones.
44
+
33
45
  ## 1.6.13
34
46
 
35
47
  **Who must change: plugin tests that toggle a column in fullscreen.** In fullscreen `toggle()` now only switches the column on or off (user ruling 2026-11-08); a test that asserted `effective: true` after toggling a column on in fullscreen now sees `effective: false`, `reason: "fullscreen"` (on, revealed by the right edge). Plugin code needs no change; `apiVersion` stays `vestra/1`, and the wire gains one optional field.
@@ -45,6 +57,7 @@
45
57
  - **`Panel.toggle` and `PanelSlot.toggle` slide a collapsed column out.** In fullscreen a sidebar or adjust column the user wants shown is collapsed at the right edge (`reason: "fullscreen"`). Toggling it then turned it off; it now shows it, which player 0.7.6 answers by sliding the column out, as its own Adjust button and Alt+1…4 do. Toggling a column that is on screen still turns it off. With players before 0.7.6 a show of a collapsed column changes nothing on screen, as before.
46
58
  - **[adjust-panel](docs/examples/adjust-panel.md)** declares `events`: without it `plugin.player.state` changes only after the plugin's own calls, so a panel activated before a file was opened stayed disabled at 1×. A new test opens the file after activation. Its presets are four (0.5×, 1×, 1.5×, 2×), which fit the adjust column at its least width. No other example reads `player.state` without `events`.
47
59
  - **A development warning for state followed without `events`.** A plugin that registers an `onChange` or a `bind` whose function reads changing state (`player.state`, a panel position, and the like) without the `events` capability gets one line in its log, once per plugin: changes made elsewhere never reach it. Only a warning; behaviour is unchanged. `TestHost` already behaved like the player here (it delivers `source.state` and the other state events only to a plugin that subscribed, which needs `events`): a new test pins that down. The example's old tests passed because they opened the file before starting the plugin or did not check `enabled`. No SDK example or test relied on the opposite, so nobody must change.
60
+
48
61
  ## 1.6.11
49
62
 
50
63
  **Who must change: nobody.** Additions only: a mount slot, a panel position, a button icon and `TestHost` support; `apiVersion` stays `vestra/1`, no new capability. A plugin that mounts `"adjust"` sets `minPlayerVersion` to `0.7.6`: older players refuse the slot (`invalid`), which fails the activation like any refused mount.
Binary file
@@ -37,6 +37,30 @@ export type Change = ReadonlyMap<string, ReadonlySet<string>>;
37
37
  * without `update()`) still shows within about this long.
38
38
  */
39
39
  export declare const SAFETY_PASS_MS = 1000;
40
+ /**
41
+ * Read-only counters of the update passes, for the SDK's own
42
+ * characterisation tests and benchmark (`test/golden-traces.test.ts`,
43
+ * `scripts/bench.mjs`): how many passes ran, how many were full, how many
44
+ * bindings and view bind functions (parts) were evaluated or skipped. Not
45
+ * part of the plugin API; counting changes no behaviour.
46
+ */
47
+ export interface PassCounts {
48
+ /** Passes run (`Runtime.#run`), precise and full. */
49
+ passes: number;
50
+ /** Of them, full passes (every binding evaluated). */
51
+ fullPasses: number;
52
+ /** Bindings evaluated (`Runtime.#evaluate`). */
53
+ evaluations: number;
54
+ /** View bind functions evaluated inside a binding (`Runtime.part`). */
55
+ parts: number;
56
+ /** View bind functions a precise pass skipped (`Runtime.part`). */
57
+ skippedParts: number;
58
+ /**
59
+ * Full passes the safety timer requested (`SAFETY_PASS_MS` after a
60
+ * precise pass skipped something), counted when the timer fires.
61
+ */
62
+ safetyPasses: number;
63
+ }
40
64
  /** How the plugin is run: the player's runner, or `TestHost`. */
41
65
  export interface RuntimeOptions {
42
66
  /**
@@ -72,6 +96,8 @@ export declare class Runtime {
72
96
  readonly local: LocalServices | undefined;
73
97
  /** Registrations that activation must wait for before reporting ready. */
74
98
  readonly startup: Set<Promise<unknown>>;
99
+ /** Pass counters, read by the SDK's own tests and benchmark ({@link PassCounts}). */
100
+ readonly counts: PassCounts;
75
101
  readonly views: Map<string, (callback: string, args: Json[]) => void>;
76
102
  readonly commands: Map<string, () => void>;
77
103
  readonly services: Map<string, unknown>;
@@ -209,6 +209,15 @@ export class Runtime {
209
209
  #skipped = false;
210
210
  /** The plugin was disposed: no safety pass is armed any more. */
211
211
  #stopped = false;
212
+ /** Pass counters, read by the SDK's own tests and benchmark ({@link PassCounts}). */
213
+ counts = {
214
+ passes: 0,
215
+ fullPasses: 0,
216
+ evaluations: 0,
217
+ parts: 0,
218
+ skippedParts: 0,
219
+ safetyPasses: 0,
220
+ };
212
221
  #events = new Map();
213
222
  /** The missing `events` warning was given (once per plugin). */
214
223
  #warnedEvents = false;
@@ -389,6 +398,7 @@ export class Runtime {
389
398
  const outerDeps = this.#deps;
390
399
  if (last && this.#pass && !affected(last.deps, this.#pass)) {
391
400
  this.#skipped = true;
401
+ this.counts.skippedParts++;
392
402
  if (outerReads)
393
403
  for (const [event, why] of last.reads)
394
404
  if (!outerReads.has(event))
@@ -397,6 +407,7 @@ export class Runtime {
397
407
  merge(outerDeps, last.deps);
398
408
  return undefined;
399
409
  }
410
+ this.counts.parts++;
400
411
  const reads = new Map();
401
412
  const deps = new Map();
402
413
  this.#reads = outerReads && reads;
@@ -512,6 +523,7 @@ export class Runtime {
512
523
  this.#reads = reads;
513
524
  this.#deps = deps;
514
525
  this.#reader = undefined;
526
+ this.counts.evaluations++;
515
527
  try {
516
528
  evaluate();
517
529
  }
@@ -570,6 +582,9 @@ export class Runtime {
570
582
  const change = this.#pending;
571
583
  this.#full = false;
572
584
  this.#pending = new Map();
585
+ this.counts.passes++;
586
+ if (full)
587
+ this.counts.fullPasses++;
573
588
  if (full) {
574
589
  clearTimeout(this.#safety);
575
590
  this.#safety = undefined;
@@ -596,6 +611,7 @@ export class Runtime {
596
611
  if (this.#skipped && !this.#safety && !this.#stopped) {
597
612
  this.#safety = setTimeout(() => {
598
613
  this.#safety = undefined;
614
+ this.counts.safetyPasses++;
599
615
  this.invalidate();
600
616
  }, SAFETY_PASS_MS);
601
617
  this.#safety.unref?.();
@@ -0,0 +1,65 @@
1
+ import type { Json } from "../../wire.ts";
2
+ import type { CommandWhen } from "../../commands.ts";
3
+ import type { FakeCommand, FakeBarButton, TestHost } from "./index.ts";
4
+ import type { Handler, Internals } from "./internals.ts";
5
+ /** A command state's reason, refused as the player refuses it. */
6
+ export declare function checkReason(host: TestHost, inner: Internals, state: Json | undefined): string | null;
7
+ export declare function bindingClaims(host: TestHost, inner: Internals): Map<string, string[]>;
8
+ /** `host/<id>` for core commands, `<manifest id>/<id>` for the plugin's. */
9
+ export declare function bindingKey(host: TestHost, _inner: Internals, id: string): string;
10
+ /**
11
+ * `commands.keys` as the player answers it: each of the plugin's own
12
+ * commands, and the player's core commands as `host/<id>` (SDK 1.6.16),
13
+ * with the labels of the keys it holds now (the overlay, else its
14
+ * defaults; not a key an earlier command holds), as menus write them on
15
+ * `systemInfo.os`.
16
+ */
17
+ export declare function commandKeys(host: TestHost, inner: Internals): {
18
+ keys: Record<string, string[]>;
19
+ revision: number;
20
+ };
21
+ /** Push `commands.keys` when the labels changed and the plugin listens. */
22
+ export declare function keysChanged(host: TestHost, inner: Internals): void;
23
+ export declare function splitCommandKey(_host: TestHost, _inner: Internals, key: string): {
24
+ plugin: string;
25
+ command: string;
26
+ };
27
+ export declare function bindingTable(host: TestHost, inner: Internals): {
28
+ commands: {
29
+ title: string;
30
+ path: string[] | null;
31
+ key: string[] | null;
32
+ category: string | null;
33
+ scope: "panel" | "global";
34
+ defaults: string[];
35
+ current: string[];
36
+ overridden: boolean;
37
+ shadowed: {
38
+ input: string;
39
+ by: {
40
+ plugin: string;
41
+ command: string;
42
+ };
43
+ }[];
44
+ labels: string[];
45
+ plugin: string;
46
+ command: string;
47
+ }[];
48
+ orphans: {
49
+ ref: {
50
+ plugin: string;
51
+ command: string;
52
+ };
53
+ inputs: string[];
54
+ }[];
55
+ };
56
+ /** A command's state as the player shows it: its conditions evaluated. */
57
+ export declare function commandShown(host: TestHost, _inner: Internals, sent: FakeCommand["state"], when: CommandWhen | undefined): FakeCommand["state"];
58
+ /** Implements `TestHost.press`. */
59
+ export declare function press(host: TestHost, inner: Internals, binding: string, options?: {
60
+ panel?: boolean;
61
+ }): Promise<"ran" | "reason" | "passed">;
62
+ /** Implements `TestHost.barButtons`. */
63
+ export declare function barButtons(host: TestHost, _inner: Internals): readonly FakeBarButton[];
64
+ /** The default handlers of this area's wire methods, by method name. */
65
+ export declare function commandsHandlers(host: TestHost, inner: Internals): Record<string, Handler>;
@@ -0,0 +1,412 @@
1
+ /**
2
+ * Plugin commands and key bindings: `commands.*` and `bindings.*`, the
3
+ * binding table, conflicts and the bar buttons.
4
+ *
5
+ * Part of `TestHost` (`./index.ts`): functions over the host and its private
6
+ * state (`Internals`, `./internals.ts`) that the class delegates to.
7
+ * Internal to the SDK; not exported from `@vestra-player/sdk/testing`.
8
+ */
9
+ import { HostError } from "../../wire.js";
10
+ import { BAR_BUTTON_ICONS, BAR_BUTTON_LIMITS, BUILTIN_COMMANDS, COMMAND_LIMITS, MENU_IDS, } from "../../commands.js";
11
+ import { canonical, keyLabel } from "../bindings.js";
12
+ import { evaluate } from "../when.js";
13
+ import { menuKeys, textKey } from "../../i18n.js";
14
+ import { commandInvalid, commandConflict, commandText, menuTextKey, checkDeclaration, } from "./declarations.js";
15
+ import { declaredText, commandDisplay } from "./i18n.js";
16
+ /** A command state's reason, refused as the player refuses it. */
17
+ export function checkReason(host, inner, state) {
18
+ const reason = state?.reason;
19
+ return reason !== null && reason !== undefined
20
+ ? declaredText(host, inner, reason, "reason")
21
+ : null;
22
+ }
23
+ export function bindingClaims(host, inner) {
24
+ const claims = new Map();
25
+ // The player's own commands win a conflict, as in the binding table.
26
+ for (const [id, { declaration }] of [
27
+ ...host.coreCommands,
28
+ ...host.commands,
29
+ ]) {
30
+ const scope = declaration.focus ? "panel" : "global";
31
+ // The user overlay (`bindings.set`) replaces a command's own bindings.
32
+ const overridden = host.bindings.overlay.get(bindingKey(host, inner, id));
33
+ for (const binding of overridden ?? declaration.bindings ?? []) {
34
+ const key = `${scope} ${binding}`;
35
+ claims.set(key, [...(claims.get(key) ?? []), id]);
36
+ }
37
+ }
38
+ return claims;
39
+ }
40
+ /** `host/<id>` for core commands, `<manifest id>/<id>` for the plugin's. */
41
+ export function bindingKey(host, _inner, id) {
42
+ return host.coreCommands.has(id) ? id : `${host.manifestId}/${id}`;
43
+ }
44
+ /**
45
+ * `commands.keys` as the player answers it: each of the plugin's own
46
+ * commands, and the player's core commands as `host/<id>` (SDK 1.6.16),
47
+ * with the labels of the keys it holds now (the overlay, else its
48
+ * defaults; not a key an earlier command holds), as menus write them on
49
+ * `systemInfo.os`.
50
+ */
51
+ export function commandKeys(host, inner) {
52
+ const claims = bindingClaims(host, inner);
53
+ const mac = host.systemInfo.os === "macos";
54
+ const keys = {};
55
+ for (const [id, { declaration }] of [
56
+ ...host.commands,
57
+ ...host.coreCommands,
58
+ ]) {
59
+ const scope = declaration.focus ? "panel" : "global";
60
+ const current = host.bindings.overlay.get(bindingKey(host, inner, id)) ??
61
+ declaration.bindings ??
62
+ [];
63
+ const labels = [];
64
+ for (const input of current) {
65
+ if (claims.get(`${scope} ${input}`)?.[0] !== id)
66
+ continue;
67
+ const label = keyLabel(input, mac);
68
+ if (label !== null && !labels.includes(label))
69
+ labels.push(label);
70
+ }
71
+ keys[id] = labels;
72
+ }
73
+ return { keys, revision: inner.keysRevision };
74
+ }
75
+ /** Push `commands.keys` when the labels changed and the plugin listens. */
76
+ export function keysChanged(host, inner) {
77
+ inner.keysRevision++;
78
+ if (!host.subscriptions.has("commands.keys"))
79
+ return;
80
+ const now = commandKeys(host, inner);
81
+ const text = JSON.stringify(now.keys);
82
+ if (text === inner.keysSent)
83
+ return;
84
+ inner.keysSent = text;
85
+ host.emit("commands.keys", now);
86
+ }
87
+ export function splitCommandKey(_host, _inner, key) {
88
+ const at = key.indexOf("/");
89
+ return { plugin: key.slice(0, at), command: key.slice(at + 1) };
90
+ }
91
+ export function bindingTable(host, inner) {
92
+ const claims = bindingClaims(host, inner);
93
+ const commands = [...host.coreCommands, ...host.commands].map(([id, { declaration }]) => {
94
+ const key = bindingKey(host, inner, id);
95
+ const scope = declaration.focus ? "panel" : "global";
96
+ const current = host.bindings.overlay.get(key) ?? declaration.bindings;
97
+ const shown = host.commands.get(id) ?? host.coreCommands.get(id);
98
+ return {
99
+ ...splitCommandKey(host, inner, key),
100
+ // What the user sees, like the player's binding table.
101
+ title: shown?.display.title ?? String(declaration.title),
102
+ path: declaration.path?.length
103
+ ? (shown?.display.path ?? declaration.path.map(String))
104
+ : null,
105
+ key: declaration.path?.length
106
+ ? menuKeys(declaration.path.map((segment) => typeof segment === "string" ? segment : textKey(segment)))
107
+ : null,
108
+ // Player 0.7.6: the category as shown, `null` without one.
109
+ category: shown?.display.category ?? null,
110
+ scope: scope,
111
+ defaults: declaration.bindings,
112
+ current,
113
+ overridden: host.bindings.overlay.has(key),
114
+ shadowed: current.flatMap((input) => {
115
+ const winner = claims.get(`${scope} ${input}`)?.[0];
116
+ return !winner || winner === id
117
+ ? []
118
+ : [
119
+ {
120
+ input,
121
+ by: splitCommandKey(host, inner, bindingKey(host, inner, winner)),
122
+ },
123
+ ];
124
+ }),
125
+ labels: [...current],
126
+ };
127
+ });
128
+ const declared = new Set(commands.map((c) => `${c.plugin}/${c.command}`));
129
+ const orphans = [...host.bindings.overlay]
130
+ .filter(([key]) => !declared.has(key))
131
+ .map(([key, inputs]) => ({
132
+ ref: splitCommandKey(host, inner, key),
133
+ inputs,
134
+ }));
135
+ return { commands, orphans };
136
+ }
137
+ /** A command's state as the player shows it: its conditions evaluated. */
138
+ export function commandShown(host, _inner, sent, when) {
139
+ if (!when)
140
+ return sent;
141
+ const source = host.source;
142
+ const capabilities = source.capabilities;
143
+ const facts = {
144
+ phase: source.phase,
145
+ paused: source.paused,
146
+ kind: source.kind === "producer" ? "stream" : "media",
147
+ can: {
148
+ pause: capabilities.pause,
149
+ seek: capabilities.seek,
150
+ rate: capabilities.rate,
151
+ restart: capabilities.restart,
152
+ close: capabilities.close,
153
+ snapshot: capabilities.snapshotSource,
154
+ // As the player: the flags merged into the source state.
155
+ next: source.sequence?.next === true,
156
+ previous: source.sequence?.previous === true,
157
+ },
158
+ fullscreen: host.mainWindow.fullscreen,
159
+ theme: host.appearance,
160
+ language: host.language,
161
+ };
162
+ const shown = { ...sent };
163
+ if (when.enabled) {
164
+ shown.enabled = evaluate(when.enabled, facts);
165
+ if (shown.enabled && !shown.busy)
166
+ shown.reason = null;
167
+ }
168
+ if (when.checked)
169
+ shown.checked = evaluate(when.checked, facts);
170
+ return shown;
171
+ }
172
+ /** Implements `TestHost.press`. */
173
+ export async function press(host, inner, binding, options = {}) {
174
+ const claims = bindingClaims(host, inner);
175
+ const ids = [
176
+ ...(options.panel ? (claims.get(`panel ${binding}`) ?? []) : []),
177
+ ...(claims.get(`global ${binding}`) ?? []),
178
+ ];
179
+ if (!ids.length)
180
+ throw new Error(`test host: nothing is bound to ${binding}`);
181
+ const command = (id) => host.coreCommands.get(id) ?? host.command(id);
182
+ const enabled = ids.find((id) => command(id).state.enabled);
183
+ if (enabled !== undefined) {
184
+ await command(enabled).run();
185
+ return "ran";
186
+ }
187
+ // Like the player: only the first disabled command counts; without a
188
+ // reason the key goes on to the focused control.
189
+ const first = ids[0];
190
+ const reason = host.commands.has(first)
191
+ ? command(first).display.reason
192
+ : command(first).state.reason;
193
+ if (typeof reason === "string" && reason !== "") {
194
+ host.osd.push(reason);
195
+ return "reason";
196
+ }
197
+ return "passed";
198
+ }
199
+ /** Implements `TestHost.barButtons`. */
200
+ export function barButtons(host, _inner) {
201
+ return [...host.commands]
202
+ .filter(([, command]) => command.declaration.button !== undefined)
203
+ .slice(0, BAR_BUTTON_LIMITS.perPlugin)
204
+ .map(([id, command]) => {
205
+ const button = command.declaration.button;
206
+ const image = typeof button.icon !== "string";
207
+ const reason = command.display.reason;
208
+ const title = command.display.title.replace(/(…|\.\.\.)\s*$/, "");
209
+ const enabled = command.state.enabled && !command.state.busy;
210
+ return Object.freeze({
211
+ command: id,
212
+ icon: image
213
+ ? String(button.icon.$vestraImage)
214
+ : button.icon,
215
+ image,
216
+ order: button.order,
217
+ enabled,
218
+ checked: command.state.checked === true,
219
+ tooltip: !enabled && reason ? `${title}\n${reason}` : title,
220
+ async click() {
221
+ const current = host.commands.get(id);
222
+ if (current !== command)
223
+ throw new HostError("missing", `command ${id} is gone`);
224
+ await current.run();
225
+ },
226
+ });
227
+ });
228
+ }
229
+ /** The default handlers of this area's wire methods, by method name. */
230
+ export function commandsHandlers(host, inner) {
231
+ return {
232
+ "commands.register": (p) => {
233
+ const id = String(p.id);
234
+ // The player's checks, in its order (capabilities/commands.rs
235
+ // `register`, then commands.rs `Registry::register`), with its codes
236
+ // and messages.
237
+ if (!Number.isInteger(p.revision) || Number(p.revision) <= 0)
238
+ throw new HostError("invalid", "positive revision required");
239
+ // The reason is checked (and shown) when it arrives.
240
+ const reason = checkReason(host, inner, p.state);
241
+ const declaration = p.declaration;
242
+ const button = declaration.button;
243
+ const maxOrder = BAR_BUTTON_LIMITS.maxOrder;
244
+ // Player 0.5.10: a bar button is { icon, order }, its order 0–999,
245
+ // its icon a name or an image of the plugin.
246
+ if (button !== undefined && button !== null) {
247
+ if (typeof button !== "object" ||
248
+ Array.isArray(button) ||
249
+ !Object.keys(button).every((key) => key === "icon" || key === "order"))
250
+ throw new HostError("invalid", "button is { icon, order }");
251
+ const { icon, order } = button;
252
+ if (!Number.isInteger(order) || +order < 0 || +order > maxOrder)
253
+ throw new HostError("invalid", `button order must be an integer 0–${maxOrder}`);
254
+ if (typeof icon !== "string" && host.imagePath(icon) === null)
255
+ throw new HostError("invalid", "button icon is an icon name or an image from plugin.ui.image");
256
+ }
257
+ // Text values in the title and path are keyed by their English
258
+ // rendering; the built-in menu ids (MENU_IDS) and the player's old
259
+ // Chinese menu titles by the menus' keys.
260
+ const title = menuTextKey(declaration.title);
261
+ const declared = (declaration.path ?? []);
262
+ if (!Array.isArray(declared))
263
+ throw new HostError("invalid", "path is a list of menu texts");
264
+ const keys = declared.map(menuTextKey);
265
+ const menu = keys[0]?.startsWith("@") ? keys[0].slice(1) : null;
266
+ if (menu !== null && !MENU_IDS.includes(menu))
267
+ throw commandInvalid(`unknown menu id @${menu}; the built-in menus are ${MENU_IDS.map((known) => `@${known}`).join(", ")}`);
268
+ if (menu !== null && keys.length < 2)
269
+ throw commandInvalid(`menu id @${menu} needs an item after it`);
270
+ const path = menuKeys(keys);
271
+ // Player 0.7.6: the category, keyed like a menu text.
272
+ const category = declaration.category === undefined || declaration.category === null
273
+ ? null
274
+ : menuTextKey(declaration.category);
275
+ checkDeclaration(id, title, path, declaration, category);
276
+ if (button !== undefined && button !== null) {
277
+ if (path.length === 0)
278
+ throw commandInvalid("button needs a menu path: a bar button is also a menu item");
279
+ const { icon } = button;
280
+ if (typeof icon === "string" &&
281
+ !BAR_BUTTON_ICONS.includes(icon))
282
+ throw commandInvalid(`unknown button icon ${JSON.stringify(icon)}: a name of @vestra/icons.slint (${BAR_BUTTON_ICONS.join(", ")}) or an image from plugin.ui.image`);
283
+ }
284
+ // The binding table's `Table::canonical` (player 0.6.1): each binding
285
+ // parsed, and each input once, compared parsed, before the reason,
286
+ // conflicts and limits.
287
+ const bindings = (declaration.bindings ?? []);
288
+ const seen = new Set();
289
+ for (const binding of bindings.map(String)) {
290
+ let input;
291
+ try {
292
+ input = canonical(binding);
293
+ }
294
+ catch (error) {
295
+ throw commandInvalid(error.message);
296
+ }
297
+ if (seen.has(input))
298
+ throw commandInvalid(`binding ${binding} is listed twice`);
299
+ seen.add(input);
300
+ }
301
+ if (reason !== null)
302
+ commandText(reason, "reason", COMMAND_LIMITS.reasonBytes);
303
+ if (host.commands.has(id))
304
+ throw commandConflict("owner/localId already registered");
305
+ // The player's own commands beside the core actions (host/actions.rs
306
+ // `host_declarations`: Toggle Fullscreen, Command Palette…, Quit and
307
+ // four outside the menu), which take part in conflicts and the total
308
+ // like any other.
309
+ const hostOnly = BUILTIN_COMMANDS.filter((row) => row.owner === "host" && !host.coreCommands.has(`host/${row.id}`));
310
+ // A path may not repeat another command's (the player's own
311
+ // included) nor be a submenu of one, or one of its submenus.
312
+ if (path.length > 0)
313
+ for (const existing of [
314
+ ...hostOnly.map((row) => row.path),
315
+ ...[...host.coreCommands.values()].map((command) => command.declaration.path),
316
+ ...[...host.commands.values()].map((command) => menuKeys((command.declaration.path ?? []).map(menuTextKey))),
317
+ ]) {
318
+ const shared = Math.min(existing.length, path.length);
319
+ if (existing.length > 0 &&
320
+ existing.slice(0, shared).every((s, i) => s === path[i]))
321
+ throw commandConflict("menu path duplicates a command or collides with a leaf/group");
322
+ }
323
+ // The player's caps (COMMAND_LIMITS): the total counts all of the
324
+ // player's own commands too.
325
+ if (host.commands.size >= COMMAND_LIMITS.perPlugin ||
326
+ host.commands.size + host.coreCommands.size + hostOnly.length >=
327
+ COMMAND_LIMITS.total)
328
+ throw new HostError("limit", "command registry limit reached");
329
+ let sent = p.state;
330
+ host.commands.set(id, {
331
+ declaration,
332
+ get display() {
333
+ return commandDisplay(host, inner, declaration.path ?? [], declaration.title, this.state.reason, declaration.category ?? null);
334
+ },
335
+ ...(p.when ? { when: p.when } : {}),
336
+ get state() {
337
+ return commandShown(host, inner, sent, this.when);
338
+ },
339
+ set state(state) {
340
+ sent = state;
341
+ },
342
+ revision: Number(p.revision),
343
+ async run() {
344
+ if (this.state.busy || !this.state.enabled)
345
+ throw new HostError("command", `${id} is ${this.state.busy ? "busy" : "disabled"}`);
346
+ host.emit("commands.execute", { id, revision: this.revision });
347
+ await host.settle();
348
+ },
349
+ });
350
+ // The player pushes the new command's keys after the answer.
351
+ queueMicrotask(() => keysChanged(host, inner));
352
+ return { revision: 1 };
353
+ },
354
+ "commands.unregister": (p) => {
355
+ if (!host.commands.delete(String(p.id)))
356
+ throw new HostError("missing", `command ${p.id} is not registered`);
357
+ queueMicrotask(() => keysChanged(host, inner));
358
+ },
359
+ // Player 0.7.6: the labels of the plugin's own commands.
360
+ "commands.keys": () => {
361
+ const now = commandKeys(host, inner);
362
+ inner.keysSent = JSON.stringify(now.keys);
363
+ return now;
364
+ },
365
+ "commands.update": (p) => {
366
+ // The request itself, as the player reads it (capabilities/commands.rs
367
+ // `parsed`): `invalid`.
368
+ if (!Number.isInteger(p.revision) || Number(p.revision) <= 0)
369
+ throw new HostError("invalid", "positive revision required");
370
+ const reason = checkReason(host, inner, p.state);
371
+ // The registry's refusals of an update (commands.rs `Registry::update`)
372
+ // all come back as `command`; the test host's one plugin is owner 1.
373
+ const command = host.commands.get(String(p.id));
374
+ if (!command)
375
+ throw new HostError("command", `command missing: owner 1 / ${p.id}`);
376
+ if (Number(p.revision) <= command.revision)
377
+ throw new HostError("command", `stale command revision ${p.revision}; current is ${command.revision}`);
378
+ if (reason !== null)
379
+ try {
380
+ commandText(reason, "reason", COMMAND_LIMITS.reasonBytes);
381
+ }
382
+ catch (error) {
383
+ throw new HostError("command", error.message);
384
+ }
385
+ command.revision = Number(p.revision);
386
+ command.state = p.state;
387
+ if (p.when)
388
+ command.when = p.when;
389
+ else
390
+ delete command.when;
391
+ return { revision: command.revision };
392
+ },
393
+ "bindings.list": () => bindingTable(host, inner),
394
+ "bindings.set": (p) => {
395
+ const key = `${p.plugin}/${p.command}`;
396
+ const known = bindingTable(host, inner).commands.some((c) => `${c.plugin}/${c.command}` === key);
397
+ if (!known && !host.bindings.overlay.has(key))
398
+ throw new HostError("missing", `no command ${key}`);
399
+ host.bindings.overlay.set(key, [...p.inputs]);
400
+ host.emit("bindings.changed", null);
401
+ keysChanged(host, inner);
402
+ },
403
+ "bindings.reset": (p) => {
404
+ if (p.plugin === undefined)
405
+ host.bindings.overlay.clear();
406
+ else
407
+ host.bindings.overlay.delete(`${p.plugin}/${p.command}`);
408
+ host.emit("bindings.changed", null);
409
+ keysChanged(host, inner);
410
+ },
411
+ };
412
+ }
@@ -0,0 +1,39 @@
1
+ import type { TestHost } from "./index.ts";
2
+ import type { Handler, Internals } from "./internals.ts";
3
+ export declare function coreFacts(host: TestHost, _inner: Internals): {
4
+ ready: boolean;
5
+ ended: boolean;
6
+ toggle: boolean;
7
+ step: boolean;
8
+ rate: boolean;
9
+ close: boolean;
10
+ };
11
+ /** Why one frame that way is impossible (B07), or null; the player's rules. */
12
+ export declare function frameCheck(host: TestHost, inner: Internals, forward: boolean): string | null;
13
+ /** The same enabled rules and reasons as the player's core actions. */
14
+ export declare function coreState(host: TestHost, inner: Internals, action: string): {
15
+ enabled: boolean;
16
+ checked: boolean | null;
17
+ busy: boolean;
18
+ reason: string | null;
19
+ };
20
+ export declare function registerCoreActions(host: TestHost, inner: Internals): void;
21
+ export declare function runCore(host: TestHost, inner: Internals, action: string, seconds?: number): void;
22
+ export declare function windowFacts(host: TestHost, _inner: Internals): {
23
+ fullscreen: boolean;
24
+ maximized: boolean;
25
+ videoArea: {
26
+ width: number;
27
+ height: number;
28
+ };
29
+ pictureInPicture: {
30
+ supported: boolean;
31
+ active: boolean;
32
+ };
33
+ appearance: import("../types.ts").ThemeId;
34
+ fileTypes: string[];
35
+ language: import("../types.ts").LanguagePreference;
36
+ locale: import("../types.ts").Locale;
37
+ };
38
+ /** The default handlers of this area's wire methods, by method name. */
39
+ export declare function coreActionsHandlers(host: TestHost, inner: Internals): Record<string, Handler>;