@vestra-player/sdk 1.5.31 → 1.5.33

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.
@@ -307,6 +307,19 @@ const MAX_SIDEBAR_TABS = 8;
307
307
  * await stop();
308
308
  * });
309
309
  * ```
310
+ *
311
+ * @remarks
312
+ * An in-memory player that speaks the real wire protocol, so the SDK's own
313
+ * logic runs as it does in the player; only the player side is simulated
314
+ * (no decoding, no Slint, no windows). Make one per test, preset it if the
315
+ * plugin reads something during activation, start the plugin with
316
+ * {@link startPlugin}, drive it, `settle()`, and assert on what it
317
+ * recorded.
318
+ *
319
+ * @capability None.
320
+ * @see [Test a plugin](../../docs/how-to/testing.md)
321
+ * @since 1.0.0
322
+ * @category Testing
310
323
  */
311
324
  export class TestHost {
312
325
  /**
@@ -416,6 +429,20 @@ export class TestHost {
416
429
  *
417
430
  * @param directory - The package directory (holding `locales/`).
418
431
  * @since 1.5.9
432
+ *
433
+ * @capability None.
434
+ *
435
+ * @example Load the translations after changing the language
436
+ * The test switches the host to German before starting the plugin and
437
+ * loads that catalog itself (`startPlugin` does the same). Texts are then
438
+ * shown in German.
439
+ * ```ts
440
+ * declare const host: TestHost;
441
+ * declare const directory: string;
442
+ * // ---cut---
443
+ * host.locale = "de";
444
+ * host.loadCatalog(directory); // locales/de.po (or de.json)
445
+ * ```
419
446
  */
420
447
  loadCatalog(directory) {
421
448
  this.#catalog = loadPackageCatalog(directory, this.locale);
@@ -441,6 +468,8 @@ export class TestHost {
441
468
  * ```
442
469
  *
443
470
  * @since 1.5.9
471
+ *
472
+ * @capability None.
444
473
  */
445
474
  translate(text, args) {
446
475
  if (typeof text !== "string")
@@ -520,6 +549,24 @@ export class TestHost {
520
549
  *
521
550
  * @param language - `"system"` or a locale.
522
551
  * @since 1.5.9
552
+ *
553
+ * @capability None.
554
+ *
555
+ * @example Simulate the user choosing French for the next start
556
+ * The plugin hears that the saved interface language changed. The host
557
+ * pushes `appearance.changed`, which a plugin with `events` sees in
558
+ * `appearance.language` once it has read it and subscribed; this run's
559
+ * language does not change.
560
+ * ```ts
561
+ * import assert from "node:assert/strict";
562
+ * declare const host: TestHost;
563
+ * // ---cut---
564
+ * void plugin.appearance.language; // read once: the SDK then follows it
565
+ * plugin.appearance.onChange(() => {}); // subscribes to appearance events
566
+ * host.setLanguage("fr");
567
+ * await host.settle();
568
+ * assert.equal(plugin.appearance.language, "fr");
569
+ * ```
523
570
  */
524
571
  setLanguage(language) {
525
572
  this.language = language;
@@ -713,6 +760,9 @@ export class TestHost {
713
760
  * await host.settle();
714
761
  * assert.equal(plugin.timeline.marks.length, 2);
715
762
  * ```
763
+ *
764
+ * @capability None.
765
+ * @since 1.0.0
716
766
  */
717
767
  setTimelineLayer(pluginId, marks) {
718
768
  if (marks.length)
@@ -755,6 +805,9 @@ export class TestHost {
755
805
  * join(plugin.dataDirectory, "cover.png"),
756
806
  * );
757
807
  * ```
808
+ *
809
+ * @capability None.
810
+ * @since 1.0.0
758
811
  */
759
812
  imagePath(value) {
760
813
  const image = value;
@@ -865,6 +918,10 @@ export class TestHost {
865
918
  * modifiers: { alt: true },
866
919
  * });
867
920
  * ```
921
+ *
922
+ * @capability None.
923
+ * @throws Never: files nobody takes are ignored, as in the player.
924
+ * @since 1.0.0
868
925
  */
869
926
  drop(paths, options = {}) {
870
927
  const modifiers = {
@@ -899,6 +956,24 @@ export class TestHost {
899
956
  /**
900
957
  * Simulate another plugin switching the palette: updates
901
958
  * {@link TestHost.appearance} and pushes `appearance.changed`.
959
+ *
960
+ * @capability None.
961
+ *
962
+ * @example Simulate another plugin switching to the light palette
963
+ * The plugin hears the palette change. The host updates `appearance` and
964
+ * pushes `appearance.changed` to a plugin that listens with `events`.
965
+ * ```ts
966
+ * import assert from "node:assert/strict";
967
+ * declare const host: TestHost;
968
+ * // ---cut---
969
+ * const themes: ThemeId[] = [];
970
+ * plugin.appearance.onChange((theme) => themes.push(theme)); // subscribes (`events`)
971
+ * host.setAppearance("vestra-light");
972
+ * await host.settle();
973
+ * assert.deepEqual(themes, ["vestra-light"]);
974
+ * ```
975
+ *
976
+ * @since 1.0.0
902
977
  */
903
978
  setAppearance(theme) {
904
979
  this.appearance = theme;
@@ -935,6 +1010,9 @@ export class TestHost {
935
1010
  * await host.settle();
936
1011
  * assert.equal(page.props["step"], 10);
937
1012
  * ```
1013
+ *
1014
+ * @capability None.
1015
+ * @since 1.0.0
938
1016
  */
939
1017
  settingsPage(id) {
940
1018
  const page = this.settings.pages.get(id);
@@ -947,6 +1025,23 @@ export class TestHost {
947
1025
  /**
948
1026
  * Simulate the backdrop's `effective` changing (`false`: a video frame
949
1027
  * appeared; `true`: there is none again); pushed to the plugin.
1028
+ *
1029
+ * @capability None.
1030
+ *
1031
+ * @example Make a video picture replace the backdrop
1032
+ * The plugin's backdrop hears that a video frame now covers it. `false`
1033
+ * means a picture appeared; the change is pushed like the player's
1034
+ * `window.state`.
1035
+ * ```ts
1036
+ * import assert from "node:assert/strict";
1037
+ * declare const host: TestHost;
1038
+ * // ---cut---
1039
+ * host.setBackdropEffective(false);
1040
+ * await host.settle();
1041
+ * assert.equal(plugin.panels.backdrop.state?.effective, false);
1042
+ * ```
1043
+ *
1044
+ * @since 1.0.0
950
1045
  */
951
1046
  setBackdropEffective(effective) {
952
1047
  if (this.backdrop)
@@ -986,6 +1081,8 @@ export class TestHost {
986
1081
  * ```
987
1082
  *
988
1083
  * @since 1.5.2
1084
+ *
1085
+ * @capability None.
989
1086
  */
990
1087
  mountOther(slot, plugin, options = {}) {
991
1088
  if (this.#others.has(plugin))
@@ -1009,6 +1106,21 @@ export class TestHost {
1009
1106
  * when its tab was current, the next tab (else the previous one) is.
1010
1107
  *
1011
1108
  * @since 1.5.2
1109
+ *
1110
+ * @capability None.
1111
+ *
1112
+ * @example Let the playlist's tab go away
1113
+ * The other plugin's sidebar tab disappears and the plugin's own tab
1114
+ * becomes current. When the removed tab was current, the next tab (else
1115
+ * the previous one) becomes current, as in the player.
1116
+ * ```ts
1117
+ * import assert from "node:assert/strict";
1118
+ * declare const host: TestHost;
1119
+ * // ---cut---
1120
+ * host.unmountOther("vestra.playlist");
1121
+ * await host.settle();
1122
+ * assert.equal(host.layout.sidebar?.panels?.length, 1);
1123
+ * ```
1012
1124
  */
1013
1125
  unmountOther(plugin) {
1014
1126
  const other = this.#others.get(plugin);
@@ -1027,6 +1139,20 @@ export class TestHost {
1027
1139
  * becomes the current tab. Pushed as a `window.state` change.
1028
1140
  *
1029
1141
  * @since 1.5.2
1142
+ *
1143
+ * @capability None.
1144
+ *
1145
+ * @example Click another plugin's sidebar tab
1146
+ * The user switches the sidebar to the playlist; the plugin's panel is in
1147
+ * a background tab now. The change is pushed as `window.state`.
1148
+ * ```ts
1149
+ * import assert from "node:assert/strict";
1150
+ * declare const host: TestHost;
1151
+ * // ---cut---
1152
+ * host.selectTab("vestra.playlist");
1153
+ * await host.settle();
1154
+ * assert.notEqual(host.layout.sidebar?.owner, 1); // the plugin under test is owner 1
1155
+ * ```
1030
1156
  */
1031
1157
  selectTab(plugin) {
1032
1158
  const slot = this.layout.sidebar;
@@ -1077,6 +1203,24 @@ export class TestHost {
1077
1203
  * Simulate another plugin taking the display layer first (the plugin's
1078
1204
  * `video.view()` then fails with `conflict`), or, with `null`, releasing
1079
1205
  * it (values reset to the defaults).
1206
+ *
1207
+ * @capability None.
1208
+ *
1209
+ * @example Let another plugin own the display layer
1210
+ * The plugin's `video.view()` fails with `conflict` because another plugin
1211
+ * took the display layer first. `null` releases it again.
1212
+ * ```ts
1213
+ * import assert from "node:assert/strict";
1214
+ * declare const host: TestHost;
1215
+ * // ---cut---
1216
+ * host.setVideoViewOwner("someone.zoom");
1217
+ * const view = plugin.video.view();
1218
+ * view.set({ zoom: 2 });
1219
+ * await host.settle();
1220
+ * assert.equal(view.status.state, "failed");
1221
+ * ```
1222
+ *
1223
+ * @since 1.0.0
1080
1224
  */
1081
1225
  setVideoViewOwner(pluginId) {
1082
1226
  this.videoView.owner = pluginId;
@@ -1093,6 +1237,24 @@ export class TestHost {
1093
1237
  * Simulate another plugin owning the render options (the plugin's
1094
1238
  * `video.render()` then fails with `conflict`), or, with `null`, releasing
1095
1239
  * them (values reset to the defaults).
1240
+ *
1241
+ * @capability None.
1242
+ *
1243
+ * @example Let another plugin own the render options
1244
+ * The plugin's render options report `conflict` while another plugin owns
1245
+ * them; `null` releases them and resets the values to the defaults.
1246
+ * ```ts
1247
+ * import assert from "node:assert/strict";
1248
+ * declare const host: TestHost;
1249
+ * // ---cut---
1250
+ * host.setVideoRenderOwner("someone.hdr");
1251
+ * const render = plugin.video.render();
1252
+ * render.set({ toneMapping: "bt.2390" });
1253
+ * await host.settle();
1254
+ * assert.equal(render.status.state, "failed");
1255
+ * ```
1256
+ *
1257
+ * @since 1.0.0
1096
1258
  */
1097
1259
  setVideoRenderOwner(pluginId) {
1098
1260
  this.videoRender.owner = pluginId;
@@ -1120,6 +1282,9 @@ export class TestHost {
1120
1282
  * await host.settle();
1121
1283
  * assert.equal(render.status.state, "failed");
1122
1284
  * ```
1285
+ *
1286
+ * @capability None.
1287
+ * @since 1.0.0
1123
1288
  */
1124
1289
  failVideoRender(code, message) {
1125
1290
  if (code === "unsupported")
@@ -1213,6 +1378,21 @@ export class TestHost {
1213
1378
  * What `plugin.resolvers.fileTypes` reports: the extensions of this
1214
1379
  * plugin's file resolvers and of {@link TestHost.otherResolvers}, sorted,
1215
1380
  * each once.
1381
+ *
1382
+ * @capability None.
1383
+ *
1384
+ * @example Check the file types a resolver adds
1385
+ * The test checks the list the plugin will read as
1386
+ * `resolvers.fileTypes`: the extensions of its own file resolvers and of
1387
+ * `otherResolvers`, sorted, each once.
1388
+ * ```ts
1389
+ * import assert from "node:assert/strict";
1390
+ * declare const host: TestHost;
1391
+ * // ---cut---
1392
+ * assert.deepEqual(host.fileTypes(), ["ch8"]);
1393
+ * ```
1394
+ *
1395
+ * @since 1.5.8
1216
1396
  */
1217
1397
  fileTypes() {
1218
1398
  const types = new Set();
@@ -1245,6 +1425,9 @@ export class TestHost {
1245
1425
  * await host.settle();
1246
1426
  * assert.deepEqual(plugin.resolvers.fileTypes, ["ch8"]);
1247
1427
  * ```
1428
+ *
1429
+ * @capability None.
1430
+ * @since 1.5.8
1248
1431
  */
1249
1432
  updateFileTypes() {
1250
1433
  const types = this.fileTypes();
@@ -1393,6 +1576,11 @@ export class TestHost {
1393
1576
  * `"test.plugin"` by default).
1394
1577
  */
1395
1578
  manifestId = "test.plugin";
1579
+ /**
1580
+ * A fresh in-memory player: nothing open, no plugin, the player's core
1581
+ * actions registered. Preset what the plugin reads during activation, then
1582
+ * pass it to {@link startPlugin} as `host`.
1583
+ */
1396
1584
  constructor() {
1397
1585
  this.subtitles = {
1398
1586
  presentation: {
@@ -1481,8 +1669,10 @@ export class TestHost {
1481
1669
  /**
1482
1670
  * One of the 14 core actions (`play-pause`, `stop`, `seek-forward`, ...)
1483
1671
  * 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}.
1672
+ * an unknown id. `run()` executes it like its menu item: a disabled
1673
+ * action rejects with `command`, as in the player (`plugin.host.run`
1674
+ * rejects with `unavailable` instead); otherwise it runs with the
1675
+ * player's checks and writes its on-screen text to {@link TestHost.osd}.
1486
1676
  *
1487
1677
  * @example Pause through the player's own action
1488
1678
  * The test pauses playback the way the Space key does and checks that the
@@ -1498,6 +1688,9 @@ export class TestHost {
1498
1688
  * assert.equal(host.source.paused, true);
1499
1689
  * assert.deepEqual(host.coreRuns, ["play-pause"]);
1500
1690
  * ```
1691
+ *
1692
+ * @capability None.
1693
+ * @since 1.0.0
1501
1694
  */
1502
1695
  coreAction(id) {
1503
1696
  const command = this.coreCommands.get(`host/${id}`);
@@ -1703,6 +1896,10 @@ export class TestHost {
1703
1896
  set state(_value) { },
1704
1897
  revision: 1,
1705
1898
  async run() {
1899
+ // Like the player's menu: a disabled or busy command does not run.
1900
+ const state = host.#coreState(id);
1901
+ if (state.busy || !state.enabled)
1902
+ throw new HostError("command", `host/${id} is ${state.busy ? "busy" : "disabled"}`);
1706
1903
  await host.request("host.run", { action: id });
1707
1904
  await host.settle();
1708
1905
  },
@@ -1879,11 +2076,36 @@ export class TestHost {
1879
2076
  * assert.deepEqual(host.osd, ["Folder not found"]);
1880
2077
  * host.reset("system.openPath");
1881
2078
  * ```
2079
+ *
2080
+ * @capability None.
2081
+ * @since 1.0.0
1882
2082
  */
1883
2083
  handle(method, handler) {
1884
2084
  this.#overrides.set(method, handler);
1885
2085
  }
1886
- /** Restore the default behaviour of a method replaced with {@link TestHost.handle}. */
2086
+ /**
2087
+ * Restore the default behaviour of a method replaced with {@link TestHost.handle}.
2088
+ *
2089
+ * @capability None.
2090
+ *
2091
+ * @example Restore the default answer after a simulated failure
2092
+ * The first write fails, the second succeeds. `reset` removes the handler
2093
+ * that `handle` installed, so the method is answered by the test host
2094
+ * again.
2095
+ * ```ts
2096
+ * import assert from "node:assert/strict";
2097
+ * declare const host: TestHost;
2098
+ * // ---cut---
2099
+ * host.handle("clipboard.writeText", () => {
2100
+ * throw new HostError("failed", "denied");
2101
+ * });
2102
+ * await assert.rejects(plugin.clipboard.writeText("x"));
2103
+ * host.reset("clipboard.writeText");
2104
+ * await plugin.clipboard.writeText("x"); // the default handler again
2105
+ * ```
2106
+ *
2107
+ * @since 1.0.0
2108
+ */
1887
2109
  reset(method) {
1888
2110
  this.#overrides.delete(method);
1889
2111
  }
@@ -1896,6 +2118,22 @@ export class TestHost {
1896
2118
  *
1897
2119
  * @param event - The wire event name.
1898
2120
  * @param data - The event payload (wire-level shape).
2121
+ *
2122
+ * @capability None.
2123
+ *
2124
+ * @example Deliver a raw event the helpers do not cover
2125
+ * The test pushes a `window.state` event after changing `layout` by hand.
2126
+ * Like the player, the host delivers it only when the plugin subscribed;
2127
+ * most tests use `setSource`, `setMainWindow` and the other helpers
2128
+ * instead.
2129
+ * ```ts
2130
+ * declare const host: TestHost;
2131
+ * // ---cut---
2132
+ * host.emit("window.state", {}); // no facts: the SDK reads window.state again
2133
+ * await host.settle();
2134
+ * ```
2135
+ *
2136
+ * @since 1.0.0
1899
2137
  */
1900
2138
  emit(event, data) {
1901
2139
  if ([
@@ -1935,6 +2173,9 @@ export class TestHost {
1935
2173
  * host.setSource({ phase: "ended", terminalReason: "natural" });
1936
2174
  * await host.settle();
1937
2175
  * ```
2176
+ *
2177
+ * @capability None.
2178
+ * @since 1.0.0
1938
2179
  */
1939
2180
  setSource(patch) {
1940
2181
  this.source = {
@@ -1975,6 +2216,9 @@ export class TestHost {
1975
2216
  * assert.equal(plugin.window.state.fullscreen, true);
1976
2217
  * host.setMainWindow({ pictureInPicture: { supported: true, active: false } });
1977
2218
  * ```
2219
+ *
2220
+ * @capability None.
2221
+ * @since 1.0.0
1978
2222
  */
1979
2223
  setMainWindow(patch) {
1980
2224
  const entering = patch.fullscreen !== undefined &&
@@ -1993,6 +2237,22 @@ export class TestHost {
1993
2237
  * The pointer reached the right edge in fullscreen (`true`) or left the
1994
2238
  * popped-out sidebar (`false`): the attached sidebar pops out or collapses
1995
2239
  * (reference §6.3). No effect outside fullscreen, when hidden or floating.
2240
+ *
2241
+ * @capability None.
2242
+ *
2243
+ * @example Pop the sidebar out in fullscreen
2244
+ * The pointer reaches the right edge in fullscreen and the attached
2245
+ * sidebar pops out over the video. Outside fullscreen, or with the
2246
+ * sidebar hidden or floating, nothing happens.
2247
+ * ```ts
2248
+ * declare const host: TestHost;
2249
+ * // ---cut---
2250
+ * host.setMainWindow({ fullscreen: true });
2251
+ * host.popSidebar(true);
2252
+ * await host.settle();
2253
+ * ```
2254
+ *
2255
+ * @since 1.0.0
1996
2256
  */
1997
2257
  popSidebar(popped) {
1998
2258
  const sidebar = this.layout.sidebar;
@@ -2036,6 +2296,9 @@ export class TestHost {
2036
2296
  * await host.settle();
2037
2297
  * assert.equal(plugin.player.isCurrent(before), false);
2038
2298
  * ```
2299
+ *
2300
+ * @capability None.
2301
+ * @since 1.0.0
2039
2302
  */
2040
2303
  openElsewhere(source, phase = "ready") {
2041
2304
  this.#intent({ kind: "open", source, startPaused: false });
@@ -2063,6 +2326,9 @@ export class TestHost {
2063
2326
  * const [producer] = host.producers.values();
2064
2327
  * host.failStream(producer!.id, "producer transport closed");
2065
2328
  * ```
2329
+ *
2330
+ * @capability None.
2331
+ * @since 1.5.7
2066
2332
  */
2067
2333
  failStream(producerId, message) {
2068
2334
  const producer = this.producers.get(producerId);
@@ -2081,11 +2347,12 @@ export class TestHost {
2081
2347
  * the conflict rule orders them. A disabled command lets the key pass to
2082
2348
  * the next command bound to it (SDK 1.5.12, as player 0.4.3): the one it
2083
2349
  * 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
2350
+ * resolves after it ran (`"ran"`). When only disabled commands hold the
2351
+ * key, the first of them decides, as in the player: with a `reason` it
2352
+ * shows the reason on the OSD and eats the key (`"reason"`, see
2353
+ * `host.osd`); without one the key goes on to the focused control
2354
+ * (`"passed"`, nothing runs). Unlike the player, which only logs a key
2355
+ * that reaches a busy command, `press` then rejects, so a test notices. Rejects when nothing is bound. Bindings use the
2089
2356
  * reference syntax (`"Space"`, `"Ctrl+O"`, `"Mouse:WheelUp"`), and user
2090
2357
  * overrides in `bindings.overlay` apply.
2091
2358
  *
@@ -2102,6 +2369,12 @@ export class TestHost {
2102
2369
  * await host.press("Ctrl+B"); // a global binding
2103
2370
  * await host.press("Delete", { panel: true }); // while the panel has focus
2104
2371
  * ```
2372
+ *
2373
+ * @capability None.
2374
+ * @throws `command` (rejects) A `HostError`: the command that would run is
2375
+ * busy (the player only logs this).
2376
+ * @throws `Error` (rejects) Nothing is bound to the key.
2377
+ * @since 1.0.0
2105
2378
  */
2106
2379
  async press(binding, options = {}) {
2107
2380
  const claims = this.#claims();
@@ -2117,14 +2390,15 @@ export class TestHost {
2117
2390
  await command(enabled).run();
2118
2391
  return "ran";
2119
2392
  }
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
- }
2393
+ // Like the player: only the first disabled command counts; without a
2394
+ // reason the key goes on to the focused control.
2395
+ const first = ids[0];
2396
+ const reason = this.commands.has(first)
2397
+ ? command(first).display.reason
2398
+ : command(first).state.reason;
2399
+ if (typeof reason === "string" && reason !== "") {
2400
+ this.osd.push(reason);
2401
+ return "reason";
2128
2402
  }
2129
2403
  return "passed";
2130
2404
  }
@@ -2232,6 +2506,9 @@ export class TestHost {
2232
2506
  * const rows = host.window("chat").props["messages"] as { body: Json }[];
2233
2507
  * assert.equal(host.markdown(rows[0]!.body)?.text, "**Hello**");
2234
2508
  * ```
2509
+ *
2510
+ * @capability None.
2511
+ * @since 1.1.0
2235
2512
  */
2236
2513
  markdown(value) {
2237
2514
  const doc = value
@@ -2248,6 +2525,21 @@ export class TestHost {
2248
2525
  /**
2249
2526
  * Replace the plugin list and push `packages.changed` (another plugin
2250
2527
  * started, stopped, was installed or removed).
2528
+ *
2529
+ * @capability None.
2530
+ *
2531
+ * @example Simulate another plugin starting
2532
+ * The plugin's store window hears that a plugin is running now. The list
2533
+ * replaces `packages` and is pushed as `packages.changed`, which the
2534
+ * plugin's `packages.onChange` listeners receive.
2535
+ * ```ts
2536
+ * declare const host: TestHost;
2537
+ * // ---cut---
2538
+ * host.setPackages([...host.packages]); // edit a copy, then push it
2539
+ * await host.settle();
2540
+ * ```
2541
+ *
2542
+ * @since 1.0.0
2251
2543
  */
2252
2544
  setPackages(list) {
2253
2545
  this.packages = list;
@@ -2297,6 +2589,25 @@ export class TestHost {
2297
2589
  * Simulate another plugin calling `player.next()` / `previous()`: the host
2298
2590
  * asks this plugin's sequence provider. Rejects with `unavailable` when
2299
2591
  * there is no provider or its flag is false.
2592
+ *
2593
+ * @capability None.
2594
+ * @throws `unavailable` (rejects) A `HostError`: there is no sequence
2595
+ * provider, or its flag is false.
2596
+ *
2597
+ * @example Ask the plugin's provider for the next item
2598
+ * Another plugin calls `player.next()`; the host asks the plugin under
2599
+ * test, which is the sequence provider, and the test checks what it
2600
+ * opened.
2601
+ * ```ts
2602
+ * import assert from "node:assert/strict";
2603
+ * declare const host: TestHost;
2604
+ * // ---cut---
2605
+ * await host.step("next");
2606
+ * await host.settle();
2607
+ * assert.equal(host.source.source, "/media/b.mp4");
2608
+ * ```
2609
+ *
2610
+ * @since 1.0.0
2300
2611
  */
2301
2612
  step(direction) {
2302
2613
  return this.request("sequence.step", { direction });
@@ -2304,6 +2615,23 @@ export class TestHost {
2304
2615
  /**
2305
2616
  * The plugin's command with this local id. Throws when it is not
2306
2617
  * registered (never added, or disposed).
2618
+ *
2619
+ * @capability None.
2620
+ *
2621
+ * @example Read a command's state after a change
2622
+ * The test checks that Export becomes enabled once a file is ready. The
2623
+ * record is updated as the plugin sends new state; read it after
2624
+ * `settle()`.
2625
+ * ```ts
2626
+ * import assert from "node:assert/strict";
2627
+ * declare const host: TestHost;
2628
+ * // ---cut---
2629
+ * host.setSource({ phase: "ready", duration: 60 });
2630
+ * await host.settle();
2631
+ * assert.equal(host.command("export").state.enabled, true); // throws if not registered
2632
+ * ```
2633
+ *
2634
+ * @since 1.0.0
2307
2635
  */
2308
2636
  command(id) {
2309
2637
  const command = this.commands.get(id);
@@ -2384,6 +2712,9 @@ export class TestHost {
2384
2712
  * await host.settle();
2385
2713
  * assert.equal(host.window("picture").props["brightness"], 0);
2386
2714
  * ```
2715
+ *
2716
+ * @capability None.
2717
+ * @since 1.0.0
2387
2718
  */
2388
2719
  window(id) {
2389
2720
  const window = this.windows.get(id);
@@ -2409,6 +2740,9 @@ export class TestHost {
2409
2740
  * panel.emit("select", 2);
2410
2741
  * await host.settle();
2411
2742
  * ```
2743
+ *
2744
+ * @capability None.
2745
+ * @since 1.0.0
2412
2746
  */
2413
2747
  mounted() {
2414
2748
  if (!this.panel)
@@ -2428,6 +2762,9 @@ export class TestHost {
2428
2762
  * // ---cut---
2429
2763
  * assert.deepEqual(host.sent("osd.show").at(-1), { text: "Saved" });
2430
2764
  * ```
2765
+ *
2766
+ * @capability None.
2767
+ * @since 1.0.0
2431
2768
  */
2432
2769
  sent(method) {
2433
2770
  return this.calls
@@ -2477,6 +2814,10 @@ export class TestHost {
2477
2814
  * await host.settle(20);
2478
2815
  * assert.equal(window.props["status-text"], expected);
2479
2816
  * ```
2817
+ *
2818
+ * @capability None.
2819
+ * @throws Never.
2820
+ * @since 1.0.0
2480
2821
  */
2481
2822
  async settle(ms = 0) {
2482
2823
  const deadline = Date.now() + ms;
@@ -2489,6 +2830,23 @@ export class TestHost {
2489
2830
  /**
2490
2831
  * Close the fake stream servers and delete the host's temporary
2491
2832
  * directories. `stop()` from {@link startPlugin} calls it.
2833
+ *
2834
+ * @capability None.
2835
+ *
2836
+ * @example Clean up a host that was never started
2837
+ * A test that made a host but failed before `startPlugin` removes the
2838
+ * host's temporary directories itself. `stop()` from `startPlugin` calls
2839
+ * `close` for you otherwise.
2840
+ * ```ts
2841
+ * const host = new TestHost();
2842
+ * try {
2843
+ * // … preset the host; the test fails here …
2844
+ * } finally {
2845
+ * host.close(); // deletes the host's temporary data
2846
+ * }
2847
+ * ```
2848
+ *
2849
+ * @since 1.0.0
2492
2850
  */
2493
2851
  close() {
2494
2852
  for (const producer of this.producers.values())
@@ -4138,6 +4496,16 @@ export class TestHost {
4138
4496
  * }
4139
4497
  * });
4140
4498
  * ```
4499
+ *
4500
+ * @remarks
4501
+ * The way into a plugin's tests: one call starts the plugin the way the
4502
+ * player's runner does and returns the host to drive, the `plugin` object
4503
+ * to call directly, and `stop` to end it like the player.
4504
+ *
4505
+ * @capability None.
4506
+ * @see [Test a plugin](../../docs/how-to/testing.md)
4507
+ * @since 1.0.0
4508
+ * @category Testing
4141
4509
  */
4142
4510
  export async function startPlugin(activate, options = {}) {
4143
4511
  const host = options.host ?? new TestHost();