@vestra-player/sdk 1.5.31 → 1.5.34

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.
@@ -77,6 +77,7 @@ import { BAR_BUTTON_ICONS, BAR_BUTTON_LIMITS, BUILTIN_COMMANDS, BUILTIN_SUBMENUS
77
77
  import { CAPABILITY_INFO, MESSAGE_LIMIT } from "../capabilities.js";
78
78
  import { OSD_LIMITS } from "./osd.js";
79
79
  import { SECRET_LIMITS, SESSION_STORAGE_LIMITS } from "./services.js";
80
+ import { SUBTITLE_LIMITS } from "./subtitles.js";
80
81
  import { BINDING_INPUTS } from "./bindings.js";
81
82
  import { THEME_PALETTES } from "../palettes.js";
82
83
  import { IMAGE_LIMITS, UI_VALUE_LIMITS, valueLimitProblem } from "./values.js";
@@ -104,6 +105,8 @@ export { OSD_LIMITS };
104
105
  export { SECRET_LIMITS };
105
106
  /** The player's limits on `plugin.storage.session`. */
106
107
  export { SESSION_STORAGE_LIMITS };
108
+ /** The player's limits on loaded subtitle files, which the test host enforces as well. */
109
+ export { SUBTITLE_LIMITS };
107
110
  /** The names bindings are made of. */
108
111
  export { BINDING_INPUTS };
109
112
  /** The colours of the player's palettes, by `Theme` token. */
@@ -307,6 +310,19 @@ const MAX_SIDEBAR_TABS = 8;
307
310
  * await stop();
308
311
  * });
309
312
  * ```
313
+ *
314
+ * @remarks
315
+ * An in-memory player that speaks the real wire protocol, so the SDK's own
316
+ * logic runs as it does in the player; only the player side is simulated
317
+ * (no decoding, no Slint, no windows). Make one per test, preset it if the
318
+ * plugin reads something during activation, start the plugin with
319
+ * {@link startPlugin}, drive it, `settle()`, and assert on what it
320
+ * recorded.
321
+ *
322
+ * @capability None.
323
+ * @see [Test a plugin](../../docs/how-to/testing.md)
324
+ * @since 1.0.0
325
+ * @category Testing
310
326
  */
311
327
  export class TestHost {
312
328
  /**
@@ -416,6 +432,20 @@ export class TestHost {
416
432
  *
417
433
  * @param directory - The package directory (holding `locales/`).
418
434
  * @since 1.5.9
435
+ *
436
+ * @capability None.
437
+ *
438
+ * @example Load the translations after changing the language
439
+ * The test switches the host to German before starting the plugin and
440
+ * loads that catalog itself (`startPlugin` does the same). Texts are then
441
+ * shown in German.
442
+ * ```ts
443
+ * declare const host: TestHost;
444
+ * declare const directory: string;
445
+ * // ---cut---
446
+ * host.locale = "de";
447
+ * host.loadCatalog(directory); // locales/de.po (or de.json)
448
+ * ```
419
449
  */
420
450
  loadCatalog(directory) {
421
451
  this.#catalog = loadPackageCatalog(directory, this.locale);
@@ -441,6 +471,8 @@ export class TestHost {
441
471
  * ```
442
472
  *
443
473
  * @since 1.5.9
474
+ *
475
+ * @capability None.
444
476
  */
445
477
  translate(text, args) {
446
478
  if (typeof text !== "string")
@@ -520,6 +552,24 @@ export class TestHost {
520
552
  *
521
553
  * @param language - `"system"` or a locale.
522
554
  * @since 1.5.9
555
+ *
556
+ * @capability None.
557
+ *
558
+ * @example Simulate the user choosing French for the next start
559
+ * The plugin hears that the saved interface language changed. The host
560
+ * pushes `appearance.changed`, which a plugin with `events` sees in
561
+ * `appearance.language` once it has read it and subscribed; this run's
562
+ * language does not change.
563
+ * ```ts
564
+ * import assert from "node:assert/strict";
565
+ * declare const host: TestHost;
566
+ * // ---cut---
567
+ * void plugin.appearance.language; // read once: the SDK then follows it
568
+ * plugin.appearance.onChange(() => {}); // subscribes to appearance events
569
+ * host.setLanguage("fr");
570
+ * await host.settle();
571
+ * assert.equal(plugin.appearance.language, "fr");
572
+ * ```
523
573
  */
524
574
  setLanguage(language) {
525
575
  this.language = language;
@@ -713,6 +763,9 @@ export class TestHost {
713
763
  * await host.settle();
714
764
  * assert.equal(plugin.timeline.marks.length, 2);
715
765
  * ```
766
+ *
767
+ * @capability None.
768
+ * @since 1.0.0
716
769
  */
717
770
  setTimelineLayer(pluginId, marks) {
718
771
  if (marks.length)
@@ -755,6 +808,9 @@ export class TestHost {
755
808
  * join(plugin.dataDirectory, "cover.png"),
756
809
  * );
757
810
  * ```
811
+ *
812
+ * @capability None.
813
+ * @since 1.0.0
758
814
  */
759
815
  imagePath(value) {
760
816
  const image = value;
@@ -865,6 +921,10 @@ export class TestHost {
865
921
  * modifiers: { alt: true },
866
922
  * });
867
923
  * ```
924
+ *
925
+ * @capability None.
926
+ * @throws Never: files nobody takes are ignored, as in the player.
927
+ * @since 1.0.0
868
928
  */
869
929
  drop(paths, options = {}) {
870
930
  const modifiers = {
@@ -899,6 +959,24 @@ export class TestHost {
899
959
  /**
900
960
  * Simulate another plugin switching the palette: updates
901
961
  * {@link TestHost.appearance} and pushes `appearance.changed`.
962
+ *
963
+ * @capability None.
964
+ *
965
+ * @example Simulate another plugin switching to the light palette
966
+ * The plugin hears the palette change. The host updates `appearance` and
967
+ * pushes `appearance.changed` to a plugin that listens with `events`.
968
+ * ```ts
969
+ * import assert from "node:assert/strict";
970
+ * declare const host: TestHost;
971
+ * // ---cut---
972
+ * const themes: ThemeId[] = [];
973
+ * plugin.appearance.onChange((theme) => themes.push(theme)); // subscribes (`events`)
974
+ * host.setAppearance("vestra-light");
975
+ * await host.settle();
976
+ * assert.deepEqual(themes, ["vestra-light"]);
977
+ * ```
978
+ *
979
+ * @since 1.0.0
902
980
  */
903
981
  setAppearance(theme) {
904
982
  this.appearance = theme;
@@ -935,6 +1013,9 @@ export class TestHost {
935
1013
  * await host.settle();
936
1014
  * assert.equal(page.props["step"], 10);
937
1015
  * ```
1016
+ *
1017
+ * @capability None.
1018
+ * @since 1.0.0
938
1019
  */
939
1020
  settingsPage(id) {
940
1021
  const page = this.settings.pages.get(id);
@@ -947,6 +1028,23 @@ export class TestHost {
947
1028
  /**
948
1029
  * Simulate the backdrop's `effective` changing (`false`: a video frame
949
1030
  * appeared; `true`: there is none again); pushed to the plugin.
1031
+ *
1032
+ * @capability None.
1033
+ *
1034
+ * @example Make a video picture replace the backdrop
1035
+ * The plugin's backdrop hears that a video frame now covers it. `false`
1036
+ * means a picture appeared; the change is pushed like the player's
1037
+ * `window.state`.
1038
+ * ```ts
1039
+ * import assert from "node:assert/strict";
1040
+ * declare const host: TestHost;
1041
+ * // ---cut---
1042
+ * host.setBackdropEffective(false);
1043
+ * await host.settle();
1044
+ * assert.equal(plugin.panels.backdrop.state?.effective, false);
1045
+ * ```
1046
+ *
1047
+ * @since 1.0.0
950
1048
  */
951
1049
  setBackdropEffective(effective) {
952
1050
  if (this.backdrop)
@@ -986,6 +1084,8 @@ export class TestHost {
986
1084
  * ```
987
1085
  *
988
1086
  * @since 1.5.2
1087
+ *
1088
+ * @capability None.
989
1089
  */
990
1090
  mountOther(slot, plugin, options = {}) {
991
1091
  if (this.#others.has(plugin))
@@ -1009,6 +1109,21 @@ export class TestHost {
1009
1109
  * when its tab was current, the next tab (else the previous one) is.
1010
1110
  *
1011
1111
  * @since 1.5.2
1112
+ *
1113
+ * @capability None.
1114
+ *
1115
+ * @example Let the playlist's tab go away
1116
+ * The other plugin's sidebar tab disappears and the plugin's own tab
1117
+ * becomes current. When the removed tab was current, the next tab (else
1118
+ * the previous one) becomes current, as in the player.
1119
+ * ```ts
1120
+ * import assert from "node:assert/strict";
1121
+ * declare const host: TestHost;
1122
+ * // ---cut---
1123
+ * host.unmountOther("vestra.playlist");
1124
+ * await host.settle();
1125
+ * assert.equal(host.layout.sidebar?.panels?.length, 1);
1126
+ * ```
1012
1127
  */
1013
1128
  unmountOther(plugin) {
1014
1129
  const other = this.#others.get(plugin);
@@ -1027,6 +1142,20 @@ export class TestHost {
1027
1142
  * becomes the current tab. Pushed as a `window.state` change.
1028
1143
  *
1029
1144
  * @since 1.5.2
1145
+ *
1146
+ * @capability None.
1147
+ *
1148
+ * @example Click another plugin's sidebar tab
1149
+ * The user switches the sidebar to the playlist; the plugin's panel is in
1150
+ * a background tab now. The change is pushed as `window.state`.
1151
+ * ```ts
1152
+ * import assert from "node:assert/strict";
1153
+ * declare const host: TestHost;
1154
+ * // ---cut---
1155
+ * host.selectTab("vestra.playlist");
1156
+ * await host.settle();
1157
+ * assert.notEqual(host.layout.sidebar?.owner, 1); // the plugin under test is owner 1
1158
+ * ```
1030
1159
  */
1031
1160
  selectTab(plugin) {
1032
1161
  const slot = this.layout.sidebar;
@@ -1077,6 +1206,24 @@ export class TestHost {
1077
1206
  * Simulate another plugin taking the display layer first (the plugin's
1078
1207
  * `video.view()` then fails with `conflict`), or, with `null`, releasing
1079
1208
  * it (values reset to the defaults).
1209
+ *
1210
+ * @capability None.
1211
+ *
1212
+ * @example Let another plugin own the display layer
1213
+ * The plugin's `video.view()` fails with `conflict` because another plugin
1214
+ * took the display layer first. `null` releases it again.
1215
+ * ```ts
1216
+ * import assert from "node:assert/strict";
1217
+ * declare const host: TestHost;
1218
+ * // ---cut---
1219
+ * host.setVideoViewOwner("someone.zoom");
1220
+ * const view = plugin.video.view();
1221
+ * view.set({ zoom: 2 });
1222
+ * await host.settle();
1223
+ * assert.equal(view.status.state, "failed");
1224
+ * ```
1225
+ *
1226
+ * @since 1.0.0
1080
1227
  */
1081
1228
  setVideoViewOwner(pluginId) {
1082
1229
  this.videoView.owner = pluginId;
@@ -1093,6 +1240,24 @@ export class TestHost {
1093
1240
  * Simulate another plugin owning the render options (the plugin's
1094
1241
  * `video.render()` then fails with `conflict`), or, with `null`, releasing
1095
1242
  * them (values reset to the defaults).
1243
+ *
1244
+ * @capability None.
1245
+ *
1246
+ * @example Let another plugin own the render options
1247
+ * The plugin's render options report `conflict` while another plugin owns
1248
+ * them; `null` releases them and resets the values to the defaults.
1249
+ * ```ts
1250
+ * import assert from "node:assert/strict";
1251
+ * declare const host: TestHost;
1252
+ * // ---cut---
1253
+ * host.setVideoRenderOwner("someone.hdr");
1254
+ * const render = plugin.video.render();
1255
+ * render.set({ toneMapping: "bt.2390" });
1256
+ * await host.settle();
1257
+ * assert.equal(render.status.state, "failed");
1258
+ * ```
1259
+ *
1260
+ * @since 1.0.0
1096
1261
  */
1097
1262
  setVideoRenderOwner(pluginId) {
1098
1263
  this.videoRender.owner = pluginId;
@@ -1120,6 +1285,9 @@ export class TestHost {
1120
1285
  * await host.settle();
1121
1286
  * assert.equal(render.status.state, "failed");
1122
1287
  * ```
1288
+ *
1289
+ * @capability None.
1290
+ * @since 1.0.0
1123
1291
  */
1124
1292
  failVideoRender(code, message) {
1125
1293
  if (code === "unsupported")
@@ -1213,6 +1381,21 @@ export class TestHost {
1213
1381
  * What `plugin.resolvers.fileTypes` reports: the extensions of this
1214
1382
  * plugin's file resolvers and of {@link TestHost.otherResolvers}, sorted,
1215
1383
  * each once.
1384
+ *
1385
+ * @capability None.
1386
+ *
1387
+ * @example Check the file types a resolver adds
1388
+ * The test checks the list the plugin will read as
1389
+ * `resolvers.fileTypes`: the extensions of its own file resolvers and of
1390
+ * `otherResolvers`, sorted, each once.
1391
+ * ```ts
1392
+ * import assert from "node:assert/strict";
1393
+ * declare const host: TestHost;
1394
+ * // ---cut---
1395
+ * assert.deepEqual(host.fileTypes(), ["ch8"]);
1396
+ * ```
1397
+ *
1398
+ * @since 1.5.8
1216
1399
  */
1217
1400
  fileTypes() {
1218
1401
  const types = new Set();
@@ -1245,6 +1428,9 @@ export class TestHost {
1245
1428
  * await host.settle();
1246
1429
  * assert.deepEqual(plugin.resolvers.fileTypes, ["ch8"]);
1247
1430
  * ```
1431
+ *
1432
+ * @capability None.
1433
+ * @since 1.5.8
1248
1434
  */
1249
1435
  updateFileTypes() {
1250
1436
  const types = this.fileTypes();
@@ -1338,7 +1524,11 @@ export class TestHost {
1338
1524
  * @since 1.5.25
1339
1525
  */
1340
1526
  sessionStorage = new Map();
1341
- /** What `plugin.app.system()` reports. */
1527
+ /**
1528
+ * What `plugin.app.system()` reports. Its `rendering` follows
1529
+ * {@link TestHost.videoRender | videoRender.gpu} (`"gpu"` or
1530
+ * `"software"`), so one switch covers the render options and the fact.
1531
+ */
1342
1532
  systemInfo = {
1343
1533
  os: "macos",
1344
1534
  name: "macOS",
@@ -1393,6 +1583,11 @@ export class TestHost {
1393
1583
  * `"test.plugin"` by default).
1394
1584
  */
1395
1585
  manifestId = "test.plugin";
1586
+ /**
1587
+ * A fresh in-memory player: nothing open, no plugin, the player's core
1588
+ * actions registered. Preset what the plugin reads during activation, then
1589
+ * pass it to {@link startPlugin} as `host`.
1590
+ */
1396
1591
  constructor() {
1397
1592
  this.subtitles = {
1398
1593
  presentation: {
@@ -1481,8 +1676,10 @@ export class TestHost {
1481
1676
  /**
1482
1677
  * One of the 14 core actions (`play-pause`, `stop`, `seek-forward`, ...)
1483
1678
  * that the test host registers like the player, as `host/<id>`. Throws for
1484
- * an unknown id. `run()` executes it with the player's checks and writes
1485
- * its on-screen text to {@link TestHost.osd}.
1679
+ * an unknown id. `run()` executes it like its menu item: a disabled
1680
+ * action rejects with `command`, as in the player (`plugin.host.run`
1681
+ * rejects with `unavailable` instead); otherwise it runs with the
1682
+ * player's checks and writes its on-screen text to {@link TestHost.osd}.
1486
1683
  *
1487
1684
  * @example Pause through the player's own action
1488
1685
  * The test pauses playback the way the Space key does and checks that the
@@ -1498,6 +1695,9 @@ export class TestHost {
1498
1695
  * assert.equal(host.source.paused, true);
1499
1696
  * assert.deepEqual(host.coreRuns, ["play-pause"]);
1500
1697
  * ```
1698
+ *
1699
+ * @capability None.
1700
+ * @since 1.0.0
1501
1701
  */
1502
1702
  coreAction(id) {
1503
1703
  const command = this.coreCommands.get(`host/${id}`);
@@ -1703,6 +1903,10 @@ export class TestHost {
1703
1903
  set state(_value) { },
1704
1904
  revision: 1,
1705
1905
  async run() {
1906
+ // Like the player's menu: a disabled or busy command does not run.
1907
+ const state = host.#coreState(id);
1908
+ if (state.busy || !state.enabled)
1909
+ throw new HostError("command", `host/${id} is ${state.busy ? "busy" : "disabled"}`);
1706
1910
  await host.request("host.run", { action: id });
1707
1911
  await host.settle();
1708
1912
  },
@@ -1879,11 +2083,36 @@ export class TestHost {
1879
2083
  * assert.deepEqual(host.osd, ["Folder not found"]);
1880
2084
  * host.reset("system.openPath");
1881
2085
  * ```
2086
+ *
2087
+ * @capability None.
2088
+ * @since 1.0.0
1882
2089
  */
1883
2090
  handle(method, handler) {
1884
2091
  this.#overrides.set(method, handler);
1885
2092
  }
1886
- /** Restore the default behaviour of a method replaced with {@link TestHost.handle}. */
2093
+ /**
2094
+ * Restore the default behaviour of a method replaced with {@link TestHost.handle}.
2095
+ *
2096
+ * @capability None.
2097
+ *
2098
+ * @example Restore the default answer after a simulated failure
2099
+ * The first write fails, the second succeeds. `reset` removes the handler
2100
+ * that `handle` installed, so the method is answered by the test host
2101
+ * again.
2102
+ * ```ts
2103
+ * import assert from "node:assert/strict";
2104
+ * declare const host: TestHost;
2105
+ * // ---cut---
2106
+ * host.handle("clipboard.writeText", () => {
2107
+ * throw new HostError("failed", "denied");
2108
+ * });
2109
+ * await assert.rejects(plugin.clipboard.writeText("x"));
2110
+ * host.reset("clipboard.writeText");
2111
+ * await plugin.clipboard.writeText("x"); // the default handler again
2112
+ * ```
2113
+ *
2114
+ * @since 1.0.0
2115
+ */
1887
2116
  reset(method) {
1888
2117
  this.#overrides.delete(method);
1889
2118
  }
@@ -1896,6 +2125,22 @@ export class TestHost {
1896
2125
  *
1897
2126
  * @param event - The wire event name.
1898
2127
  * @param data - The event payload (wire-level shape).
2128
+ *
2129
+ * @capability None.
2130
+ *
2131
+ * @example Deliver a raw event the helpers do not cover
2132
+ * The test pushes a `window.state` event after changing `layout` by hand.
2133
+ * Like the player, the host delivers it only when the plugin subscribed;
2134
+ * most tests use `setSource`, `setMainWindow` and the other helpers
2135
+ * instead.
2136
+ * ```ts
2137
+ * declare const host: TestHost;
2138
+ * // ---cut---
2139
+ * host.emit("window.state", {}); // no facts: the SDK reads window.state again
2140
+ * await host.settle();
2141
+ * ```
2142
+ *
2143
+ * @since 1.0.0
1899
2144
  */
1900
2145
  emit(event, data) {
1901
2146
  if ([
@@ -1935,6 +2180,9 @@ export class TestHost {
1935
2180
  * host.setSource({ phase: "ended", terminalReason: "natural" });
1936
2181
  * await host.settle();
1937
2182
  * ```
2183
+ *
2184
+ * @capability None.
2185
+ * @since 1.0.0
1938
2186
  */
1939
2187
  setSource(patch) {
1940
2188
  this.source = {
@@ -1975,6 +2223,9 @@ export class TestHost {
1975
2223
  * assert.equal(plugin.window.state.fullscreen, true);
1976
2224
  * host.setMainWindow({ pictureInPicture: { supported: true, active: false } });
1977
2225
  * ```
2226
+ *
2227
+ * @capability None.
2228
+ * @since 1.0.0
1978
2229
  */
1979
2230
  setMainWindow(patch) {
1980
2231
  const entering = patch.fullscreen !== undefined &&
@@ -1993,6 +2244,22 @@ export class TestHost {
1993
2244
  * The pointer reached the right edge in fullscreen (`true`) or left the
1994
2245
  * popped-out sidebar (`false`): the attached sidebar pops out or collapses
1995
2246
  * (reference §6.3). No effect outside fullscreen, when hidden or floating.
2247
+ *
2248
+ * @capability None.
2249
+ *
2250
+ * @example Pop the sidebar out in fullscreen
2251
+ * The pointer reaches the right edge in fullscreen and the attached
2252
+ * sidebar pops out over the video. Outside fullscreen, or with the
2253
+ * sidebar hidden or floating, nothing happens.
2254
+ * ```ts
2255
+ * declare const host: TestHost;
2256
+ * // ---cut---
2257
+ * host.setMainWindow({ fullscreen: true });
2258
+ * host.popSidebar(true);
2259
+ * await host.settle();
2260
+ * ```
2261
+ *
2262
+ * @since 1.0.0
1996
2263
  */
1997
2264
  popSidebar(popped) {
1998
2265
  const sidebar = this.layout.sidebar;
@@ -2036,6 +2303,9 @@ export class TestHost {
2036
2303
  * await host.settle();
2037
2304
  * assert.equal(plugin.player.isCurrent(before), false);
2038
2305
  * ```
2306
+ *
2307
+ * @capability None.
2308
+ * @since 1.0.0
2039
2309
  */
2040
2310
  openElsewhere(source, phase = "ready") {
2041
2311
  this.#intent({ kind: "open", source, startPaused: false });
@@ -2063,6 +2333,9 @@ export class TestHost {
2063
2333
  * const [producer] = host.producers.values();
2064
2334
  * host.failStream(producer!.id, "producer transport closed");
2065
2335
  * ```
2336
+ *
2337
+ * @capability None.
2338
+ * @since 1.5.7
2066
2339
  */
2067
2340
  failStream(producerId, message) {
2068
2341
  const producer = this.producers.get(producerId);
@@ -2081,11 +2354,12 @@ export class TestHost {
2081
2354
  * the conflict rule orders them. A disabled command lets the key pass to
2082
2355
  * the next command bound to it (SDK 1.5.12, as player 0.4.3): the one it
2083
2356
  * shadowed, then the global scope. The first enabled one runs; `press`
2084
- * resolves after it ran (`"ran"`), or rejects when it is busy. When only
2085
- * disabled commands hold the key, the first one with a `reason` shows the
2086
- * reason on the OSD and eats the key (`"reason"`, see `host.osd`);
2087
- * without a reason the key goes on to the focused control (`"passed"`,
2088
- * nothing runs). Rejects when nothing is bound. Bindings use the
2357
+ * resolves after it ran (`"ran"`). When only disabled commands hold the
2358
+ * key, the first of them decides, as in the player: with a `reason` it
2359
+ * shows the reason on the OSD and eats the key (`"reason"`, see
2360
+ * `host.osd`); without one the key goes on to the focused control
2361
+ * (`"passed"`, nothing runs). Unlike the player, which only logs a key
2362
+ * that reaches a busy command, `press` then rejects, so a test notices. Rejects when nothing is bound. Bindings use the
2089
2363
  * reference syntax (`"Space"`, `"Ctrl+O"`, `"Mouse:WheelUp"`), and user
2090
2364
  * overrides in `bindings.overlay` apply.
2091
2365
  *
@@ -2102,6 +2376,12 @@ export class TestHost {
2102
2376
  * await host.press("Ctrl+B"); // a global binding
2103
2377
  * await host.press("Delete", { panel: true }); // while the panel has focus
2104
2378
  * ```
2379
+ *
2380
+ * @capability None.
2381
+ * @throws `command` (rejects) A `HostError`: the command that would run is
2382
+ * busy (the player only logs this).
2383
+ * @throws `Error` (rejects) Nothing is bound to the key.
2384
+ * @since 1.0.0
2105
2385
  */
2106
2386
  async press(binding, options = {}) {
2107
2387
  const claims = this.#claims();
@@ -2117,14 +2397,15 @@ export class TestHost {
2117
2397
  await command(enabled).run();
2118
2398
  return "ran";
2119
2399
  }
2120
- for (const id of ids) {
2121
- const reason = this.commands.has(id)
2122
- ? command(id).display.reason
2123
- : command(id).state.reason;
2124
- if (typeof reason === "string" && reason !== "") {
2125
- this.osd.push(reason);
2126
- return "reason";
2127
- }
2400
+ // Like the player: only the first disabled command counts; without a
2401
+ // reason the key goes on to the focused control.
2402
+ const first = ids[0];
2403
+ const reason = this.commands.has(first)
2404
+ ? command(first).display.reason
2405
+ : command(first).state.reason;
2406
+ if (typeof reason === "string" && reason !== "") {
2407
+ this.osd.push(reason);
2408
+ return "reason";
2128
2409
  }
2129
2410
  return "passed";
2130
2411
  }
@@ -2232,6 +2513,9 @@ export class TestHost {
2232
2513
  * const rows = host.window("chat").props["messages"] as { body: Json }[];
2233
2514
  * assert.equal(host.markdown(rows[0]!.body)?.text, "**Hello**");
2234
2515
  * ```
2516
+ *
2517
+ * @capability None.
2518
+ * @since 1.1.0
2235
2519
  */
2236
2520
  markdown(value) {
2237
2521
  const doc = value
@@ -2248,6 +2532,21 @@ export class TestHost {
2248
2532
  /**
2249
2533
  * Replace the plugin list and push `packages.changed` (another plugin
2250
2534
  * started, stopped, was installed or removed).
2535
+ *
2536
+ * @capability None.
2537
+ *
2538
+ * @example Simulate another plugin starting
2539
+ * The plugin's store window hears that a plugin is running now. The list
2540
+ * replaces `packages` and is pushed as `packages.changed`, which the
2541
+ * plugin's `packages.onChange` listeners receive.
2542
+ * ```ts
2543
+ * declare const host: TestHost;
2544
+ * // ---cut---
2545
+ * host.setPackages([...host.packages]); // edit a copy, then push it
2546
+ * await host.settle();
2547
+ * ```
2548
+ *
2549
+ * @since 1.0.0
2251
2550
  */
2252
2551
  setPackages(list) {
2253
2552
  this.packages = list;
@@ -2297,6 +2596,25 @@ export class TestHost {
2297
2596
  * Simulate another plugin calling `player.next()` / `previous()`: the host
2298
2597
  * asks this plugin's sequence provider. Rejects with `unavailable` when
2299
2598
  * there is no provider or its flag is false.
2599
+ *
2600
+ * @capability None.
2601
+ * @throws `unavailable` (rejects) A `HostError`: there is no sequence
2602
+ * provider, or its flag is false.
2603
+ *
2604
+ * @example Ask the plugin's provider for the next item
2605
+ * Another plugin calls `player.next()`; the host asks the plugin under
2606
+ * test, which is the sequence provider, and the test checks what it
2607
+ * opened.
2608
+ * ```ts
2609
+ * import assert from "node:assert/strict";
2610
+ * declare const host: TestHost;
2611
+ * // ---cut---
2612
+ * await host.step("next");
2613
+ * await host.settle();
2614
+ * assert.equal(host.source.source, "/media/b.mp4");
2615
+ * ```
2616
+ *
2617
+ * @since 1.0.0
2300
2618
  */
2301
2619
  step(direction) {
2302
2620
  return this.request("sequence.step", { direction });
@@ -2304,6 +2622,23 @@ export class TestHost {
2304
2622
  /**
2305
2623
  * The plugin's command with this local id. Throws when it is not
2306
2624
  * registered (never added, or disposed).
2625
+ *
2626
+ * @capability None.
2627
+ *
2628
+ * @example Read a command's state after a change
2629
+ * The test checks that Export becomes enabled once a file is ready. The
2630
+ * record is updated as the plugin sends new state; read it after
2631
+ * `settle()`.
2632
+ * ```ts
2633
+ * import assert from "node:assert/strict";
2634
+ * declare const host: TestHost;
2635
+ * // ---cut---
2636
+ * host.setSource({ phase: "ready", duration: 60 });
2637
+ * await host.settle();
2638
+ * assert.equal(host.command("export").state.enabled, true); // throws if not registered
2639
+ * ```
2640
+ *
2641
+ * @since 1.0.0
2307
2642
  */
2308
2643
  command(id) {
2309
2644
  const command = this.commands.get(id);
@@ -2384,6 +2719,9 @@ export class TestHost {
2384
2719
  * await host.settle();
2385
2720
  * assert.equal(host.window("picture").props["brightness"], 0);
2386
2721
  * ```
2722
+ *
2723
+ * @capability None.
2724
+ * @since 1.0.0
2387
2725
  */
2388
2726
  window(id) {
2389
2727
  const window = this.windows.get(id);
@@ -2409,6 +2747,9 @@ export class TestHost {
2409
2747
  * panel.emit("select", 2);
2410
2748
  * await host.settle();
2411
2749
  * ```
2750
+ *
2751
+ * @capability None.
2752
+ * @since 1.0.0
2412
2753
  */
2413
2754
  mounted() {
2414
2755
  if (!this.panel)
@@ -2428,6 +2769,9 @@ export class TestHost {
2428
2769
  * // ---cut---
2429
2770
  * assert.deepEqual(host.sent("osd.show").at(-1), { text: "Saved" });
2430
2771
  * ```
2772
+ *
2773
+ * @capability None.
2774
+ * @since 1.0.0
2431
2775
  */
2432
2776
  sent(method) {
2433
2777
  return this.calls
@@ -2477,6 +2821,10 @@ export class TestHost {
2477
2821
  * await host.settle(20);
2478
2822
  * assert.equal(window.props["status-text"], expected);
2479
2823
  * ```
2824
+ *
2825
+ * @capability None.
2826
+ * @throws Never.
2827
+ * @since 1.0.0
2480
2828
  */
2481
2829
  async settle(ms = 0) {
2482
2830
  const deadline = Date.now() + ms;
@@ -2489,6 +2837,23 @@ export class TestHost {
2489
2837
  /**
2490
2838
  * Close the fake stream servers and delete the host's temporary
2491
2839
  * directories. `stop()` from {@link startPlugin} calls it.
2840
+ *
2841
+ * @capability None.
2842
+ *
2843
+ * @example Clean up a host that was never started
2844
+ * A test that made a host but failed before `startPlugin` removes the
2845
+ * host's temporary directories itself. `stop()` from `startPlugin` calls
2846
+ * `close` for you otherwise.
2847
+ * ```ts
2848
+ * const host = new TestHost();
2849
+ * try {
2850
+ * // … preset the host; the test fails here …
2851
+ * } finally {
2852
+ * host.close(); // deletes the host's temporary data
2853
+ * }
2854
+ * ```
2855
+ *
2856
+ * @since 1.0.0
2492
2857
  */
2493
2858
  close() {
2494
2859
  for (const producer of this.producers.values())
@@ -2777,12 +3142,49 @@ export class TestHost {
2777
3142
  !["ready", "ended"].includes(this.source.phase))
2778
3143
  throw new HostError("unavailable", "audio tracks need ready media");
2779
3144
  }
2780
- #subtitleChange(expected) {
3145
+ /** `stale` unless `expected` is the current subtitle stamp. */
3146
+ #subtitleExpected(expected) {
2781
3147
  const stamp = this.subtitles.selection.stamp;
2782
3148
  const want = expected;
2783
3149
  if (want.generation !== stamp.generation ||
2784
3150
  want.revision !== stamp.revision)
2785
3151
  throw new HostError("stale", "subtitle selection changed");
3152
+ }
3153
+ /**
3154
+ * The resources this plugin loaded into the current source (resource id →
3155
+ * bytes), removed ones included: like the player, a removed track frees
3156
+ * its place among the live tracks but not among the loads.
3157
+ */
3158
+ #subtitleResources = { generation: -1, bytes: new Map() };
3159
+ #loadedSubtitles() {
3160
+ const generation = this.source.stamp.generation;
3161
+ if (this.#subtitleResources.generation !== generation)
3162
+ this.#subtitleResources = { generation, bytes: new Map() };
3163
+ return this.#subtitleResources.bytes;
3164
+ }
3165
+ /**
3166
+ * The player's `SUBTITLE_LIMITS` for a new resource (`busy`, as the
3167
+ * player reports them). External tracks in `subtitles.tracks` that this
3168
+ * plugin did not load count as other plugins'. `totalBytes` is not
3169
+ * modelled: one plugin's 4 files of at most 2 MiB stay below it, and the
3170
+ * bytes of other plugins' tracks are unknown.
3171
+ */
3172
+ #subtitleRoom(resourceId) {
3173
+ const loaded = this.#loadedSubtitles();
3174
+ if (loaded.has(resourceId))
3175
+ return;
3176
+ const external = this.subtitles.tracks.filter((t) => t.target.kind === "external");
3177
+ const own = external.filter((t) => t.target.kind === "external" && loaded.has(t.target.resourceId));
3178
+ const others = external.length - own.length;
3179
+ if (loaded.size >= SUBTITLE_LIMITS.loadsPerPlugin ||
3180
+ loaded.size + others >= SUBTITLE_LIMITS.loads ||
3181
+ own.length >= SUBTITLE_LIMITS.tracksPerPlugin ||
3182
+ external.length >= SUBTITLE_LIMITS.tracks)
3183
+ throw new HostError("busy", "subtitle resource or counter limit exceeded");
3184
+ }
3185
+ #subtitleChange(expected) {
3186
+ this.#subtitleExpected(expected);
3187
+ const stamp = this.subtitles.selection.stamp;
2786
3188
  this.subtitles.selection.stamp = {
2787
3189
  generation: this.source.stamp.generation,
2788
3190
  revision: stamp.revision + 1,
@@ -3101,7 +3503,10 @@ export class TestHost {
3101
3503
  },
3102
3504
  "storage.session.delete": (p) => void this.sessionStorage.delete(sessionKey(p.key)),
3103
3505
  "storage.session.keys": () => [...this.sessionStorage.keys()].sort(byCodePoint),
3104
- "app.system": () => structuredClone(this.systemInfo),
3506
+ "app.system": () => ({
3507
+ ...structuredClone(this.systemInfo),
3508
+ rendering: this.videoRender.gpu ? "gpu" : "software",
3509
+ }),
3105
3510
  "clipboard.readText": () => this.clipboard.text,
3106
3511
  "clipboard.writeText": (p) => {
3107
3512
  if (typeof p.text !== "string")
@@ -3785,9 +4190,24 @@ export class TestHost {
3785
4190
  "subtitles.upload.begin": (p) => {
3786
4191
  if (p.generation !== this.source.stamp.generation)
3787
4192
  throw new HostError("stale", "source generation changed");
4193
+ const expectedBytes = Number(p.expectedBytes);
4194
+ if (!Number.isSafeInteger(expectedBytes) ||
4195
+ expectedBytes < 1 ||
4196
+ expectedBytes > SUBTITLE_LIMITS.fileBytes)
4197
+ throw new HostError("invalid", `a subtitle file has 1..${SUBTITLE_LIMITS.fileBytes} bytes`);
4198
+ // Like the player: a new source drops the old one's uploads. A commit
4199
+ // the player refused leaves its upload for `subtitles.upload.discard`,
4200
+ // which the SDK sends after any failed load.
4201
+ for (const [id, upload] of this.uploads)
4202
+ if (upload.generation !== undefined &&
4203
+ upload.generation !== this.source.stamp.generation)
4204
+ this.uploads.delete(id);
4205
+ if (this.uploads.size >= SUBTITLE_LIMITS.uploadsPerPlugin)
4206
+ throw new HostError("busy", "upload memory, owner or active-token limit reached");
3788
4207
  this.uploads.set(Number(p.uploadId), {
3789
4208
  bytes: [],
3790
- expected: Number(p.expectedBytes),
4209
+ expected: expectedBytes,
4210
+ generation: this.source.stamp.generation,
3791
4211
  });
3792
4212
  return {
3793
4213
  generation: p.generation,
@@ -3805,9 +4225,8 @@ export class TestHost {
3805
4225
  },
3806
4226
  "subtitles.upload.discard": (p) => void this.uploads.delete(Number(p.uploadId)),
3807
4227
  "subtitles.commit": (p) => {
3808
- const upload = this.uploads.get(Number(p.uploadId));
3809
- if (!upload)
3810
- throw new HostError("missing", "upload");
4228
+ // The player's order: the request's own fields, then `stale`, then
4229
+ // the upload and its content, then the limits.
3811
4230
  const format = p.format === "ass" ? "ass" : "srt";
3812
4231
  const select = p.select === "none"
3813
4232
  ? "none"
@@ -3821,12 +4240,18 @@ export class TestHost {
3821
4240
  : null;
3822
4241
  if (title !== null && ([...title].length < 1 || [...title].length > 200))
3823
4242
  throw new HostError("invalid", "subtitle title needs 1..200 characters");
4243
+ this.#subtitleExpected(p.expected);
4244
+ const upload = this.uploads.get(Number(p.uploadId));
4245
+ if (!upload)
4246
+ throw new HostError("missing", "upload");
3824
4247
  const bytes = Buffer.concat(upload.bytes);
3825
4248
  if (format === "ass" &&
3826
4249
  !(/\[Script Info\]/i.test(bytes.toString()) &&
3827
4250
  /\[Events\]/i.test(bytes.toString())))
3828
4251
  throw new HostError("invalid", "ASS requires [Script Info] and [Events] sections");
4252
+ this.#subtitleRoom(String(p.resourceId));
3829
4253
  this.#subtitleChange(p.expected);
4254
+ this.#loadedSubtitles().set(String(p.resourceId), bytes.length);
3830
4255
  const target = {
3831
4256
  kind: "external",
3832
4257
  owner: 1,
@@ -4138,6 +4563,16 @@ export class TestHost {
4138
4563
  * }
4139
4564
  * });
4140
4565
  * ```
4566
+ *
4567
+ * @remarks
4568
+ * The way into a plugin's tests: one call starts the plugin the way the
4569
+ * player's runner does and returns the host to drive, the `plugin` object
4570
+ * to call directly, and `stop` to end it like the player.
4571
+ *
4572
+ * @capability None.
4573
+ * @see [Test a plugin](../../docs/how-to/testing.md)
4574
+ * @since 1.0.0
4575
+ * @category Testing
4141
4576
  */
4142
4577
  export async function startPlugin(activate, options = {}) {
4143
4578
  const host = options.host ?? new TestHost();