@vestra-player/sdk 1.5.28 → 1.5.30

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.
@@ -32,7 +32,14 @@
32
32
  * Use it from `node:test` (what `vestra-plugin test` runs). Each test should
33
33
  * create its own host; `stop()` removes the host's temporary directories.
34
34
  *
35
- * @example
35
+ * @example Test that a menu item shows a message
36
+ * A complete test file: it activates the plugin against an in-memory
37
+ * player, picks its Hello menu item and checks what the OSD showed.
38
+ * `startPlugin` reads the plugin's `manifest.json` from `directory`, calls
39
+ * `activate` and waits for its registrations; `run()` executes the command
40
+ * as the menu would and waits until the host is idle. `stop()` disposes
41
+ * the plugin and removes the host's temporary files, even when an
42
+ * assertion fails.
36
43
  * ```ts
37
44
  * // tests/main.test.ts
38
45
  * import assert from "node:assert/strict";
@@ -66,7 +73,7 @@ import { extname, isAbsolute, join } from "node:path";
66
73
  import { fileURLToPath } from "node:url";
67
74
  import { HostError } from "../wire.js";
68
75
  import { localSocketPath } from "./local.js";
69
- import { BUILTIN_COMMANDS, BUILTIN_SUBMENUS, COMMAND_LIMITS, MENU_IDS, } from "../commands.js";
76
+ import { BAR_BUTTON_ICONS, BAR_BUTTON_LIMITS, BUILTIN_COMMANDS, BUILTIN_SUBMENUS, COMMAND_LIMITS, MENU_IDS, } from "../commands.js";
70
77
  import { CAPABILITY_INFO, MESSAGE_LIMIT } from "../capabilities.js";
71
78
  import { OSD_LIMITS } from "./osd.js";
72
79
  import { SECRET_LIMITS, SESSION_STORAGE_LIMITS } from "./services.js";
@@ -83,6 +90,8 @@ import { Catalog, format as formatTemplate, HOST_GROUPS, HOST_ZH_HANS, LOCALES,
83
90
  export { HostError };
84
91
  /** The player's command caps, which the test host enforces as well. */
85
92
  export { COMMAND_LIMITS, MENU_IDS };
93
+ /** The icons and limits of bottom-bar buttons, which the test host checks as well. */
94
+ export { BAR_BUTTON_ICONS, BAR_BUTTON_LIMITS };
86
95
  /** The submenus of the player's built-in plugins, by key path. */
87
96
  export { BUILTIN_COMMANDS, BUILTIN_SUBMENUS };
88
97
  /** The manifest capabilities: what each opens and how the store shows it. */
@@ -121,14 +130,27 @@ export { LIST_SEPARATORS };
121
130
  * The player's interface languages, English (the source) first, as the
122
131
  * player's own table: `["en", "zh-Hans", "ja", "ko", "de", "fr", "es"]`.
123
132
  *
124
- * @example
133
+ * @example Check that every language translates a menu item
134
+ * The test fails for each interface language in which the "Open…" item
135
+ * still shows English. It starts the plugin once per language except
136
+ * English (`LOCALES[0]`): the `locale` option loads the plugin's
137
+ * `locales/<locale>.po` and the host shows menu texts translated with it,
138
+ * which `display` reports.
125
139
  * ```ts
126
- * import { LOCALES } from "@vestra-player/sdk/testing";
140
+ * import type { Plugin } from "@vestra-player/sdk";
141
+ * declare function activate(plugin: Plugin): Promise<void>;
142
+ * // ---cut---
143
+ * import assert from "node:assert/strict";
144
+ * import { LOCALES, startPlugin } from "@vestra-player/sdk/testing";
127
145
  *
146
+ * const directory = new URL("..", import.meta.url);
128
147
  * for (const locale of LOCALES.slice(1)) {
129
148
  * const { host, stop } = await startPlugin(activate, { directory, locale });
130
- * assert.notEqual(host.command("open").display.title, "Open…");
131
- * await stop();
149
+ * try {
150
+ * assert.notEqual(host.command("open").display.title, "Open…", locale);
151
+ * } finally {
152
+ * await stop(); // also when the assertion fails
153
+ * }
132
154
  * }
133
155
  * ```
134
156
  */
@@ -259,7 +281,13 @@ const MAX_SIDEBAR_TABS = 8;
259
281
  * `calls`) use the protocol's shapes. They are fine for assertions but are
260
282
  * not part of the plugin API: their shape may change in minor versions.
261
283
  *
262
- * @example
284
+ * @example Preset the player, then react to another plugin's file
285
+ * The test starts the plugin in a player with the dark theme, opens a file
286
+ * the way another plugin would, and checks the plugin's panel title. A host
287
+ * created first can be preset with data the plugin reads during
288
+ * activation; `startPlugin` then uses it. `openElsewhere` changes the
289
+ * source and broadcasts it, the plugin's listener updates its panel, and
290
+ * `settle()` waits for those requests before the assertion.
263
291
  * ```ts
264
292
  * import assert from "node:assert/strict";
265
293
  * import test from "node:test";
@@ -310,8 +338,15 @@ export class TestHost {
310
338
  * is mounted there). You may change a slot directly and then call
311
339
  * `host.emit("window.state", {})` to simulate a change made elsewhere.
312
340
  *
313
- * @example
341
+ * @example Hide the sidebar and check the player's layout
342
+ * With the plugin's panel in the sidebar, toggling the sidebar records
343
+ * that the user prefers it hidden. `toggle()` reads the slot from the
344
+ * player, asks for the opposite of its `preferred` flag and resolves once
345
+ * the player answered, so the host's `layout` already shows the new value.
314
346
  * ```ts
347
+ * import assert from "node:assert/strict";
348
+ * declare const host: TestHost;
349
+ * // ---cut---
315
350
  * assert.equal(host.layout.sidebar?.preferred, true);
316
351
  * await plugin.panels.sidebar.toggle();
317
352
  * assert.equal(host.layout.sidebar?.preferred, false);
@@ -344,8 +379,17 @@ export class TestHost {
344
379
  * players up to 0.4.2 showed them, so tests written for SDK 1.5.8 keep
345
380
  * passing; set `locale` to test what a user sees now.
346
381
  *
347
- * @example
382
+ * @example Check a menu path in Simplified Chinese
383
+ * The test starts the plugin with a Chinese interface and checks the
384
+ * menu path its Blur item shows. The `locale` option sets
385
+ * `host.locale` before activation, so the plugin's `locales/zh-Hans.po`
386
+ * is loaded; the built-in menu `@video` shows in the player's own words
387
+ * (视频), the item's title in the plugin's translation.
348
388
  * ```ts
389
+ * import assert from "node:assert/strict";
390
+ * declare function activate(plugin: Plugin): Promise<void>;
391
+ * declare const directory: URL;
392
+ * // ---cut---
349
393
  * const { host, stop } = await startPlugin(activate, { directory, locale: "zh-Hans" });
350
394
  * assert.deepEqual(host.command("blur").display.path, ["视频", "模糊…"]);
351
395
  * await stop();
@@ -385,8 +429,14 @@ export class TestHost {
385
429
  * @param args - Values for its placeholders.
386
430
  * @returns The shown text.
387
431
  *
388
- * @example
432
+ * @example Compare with a translated message
433
+ * The test expects the text a Chinese user sees for a template the plugin
434
+ * shows. `translate` looks the English source up in the plugin's catalog
435
+ * for `host.locale` and fills in the arguments, the way the player does.
389
436
  * ```ts
437
+ * import assert from "node:assert/strict";
438
+ * declare const host: TestHost;
439
+ * // ---cut---
390
440
  * assert.equal(host.translate("Speed {}×", [2]), "倍速 2×");
391
441
  * ```
392
442
  *
@@ -498,10 +548,16 @@ export class TestHost {
498
548
  * What `player.stats()` returns. Fields added in later rounds may be left
499
549
  * out; the SDK fills them with `null`.
500
550
  *
501
- * @example
551
+ * @example Pretend a 1080p H.264 video is playing
552
+ * The plugin's `player.stats()` now reports a 1920×1080 H.264 stream at
553
+ * 24 fps. The host answers `stats()` with this object as it stands, so a
554
+ * test sets it before the plugin asks; fields it leaves out come back as
555
+ * `null` from the SDK.
502
556
  * ```ts
557
+ * declare const host: TestHost;
558
+ * // ---cut---
503
559
  * host.stats = {
504
- * ...host.stats,
560
+ * ...host.stats, // keep the other fields
505
561
  * video: { codec: "h264", width: 1920, height: 1080, fps: 24 },
506
562
  * } as typeof host.stats;
507
563
  * ```
@@ -534,12 +590,21 @@ export class TestHost {
534
590
  * handler is not found (`available()` is false, `run()` rejects with
535
591
  * `unavailable`).
536
592
  *
537
- * @example
593
+ * @example Fake ffprobe and a failing ffmpeg
594
+ * The plugin's Probe command gets an empty stream list from ffprobe, and
595
+ * any ffmpeg run fails as it would on a broken file. `handle` makes the
596
+ * tool available and answers each `tools.run` with the handler's result;
597
+ * a thrown `HostError` makes the run reject with that code. `calls`
598
+ * records every run with its arguments.
538
599
  * ```ts
600
+ * import assert from "node:assert/strict";
601
+ * declare const host: TestHost;
602
+ * // ---cut---
539
603
  * host.tools.handle("ffprobe", (args) => {
540
- * assert.ok(args.includes("-show_streams"));
541
- * return { stdout: JSON.stringify({ streams: [] }) };
604
+ * assert.ok(args.includes("-show_streams")); // what the plugin asked for
605
+ * return { stdout: JSON.stringify({ streams: [] }) }; // a file with no streams
542
606
  * });
607
+ * // Every ffmpeg run rejects with code `failed`.
543
608
  * host.tools.handle("ffmpeg", () => {
544
609
  * throw new HostError("failed", "Invalid data found when processing input");
545
610
  * });
@@ -631,12 +696,20 @@ export class TestHost {
631
696
  * @param pluginId - The other plugin's manifest id.
632
697
  * @param marks - Its whole layer.
633
698
  *
634
- * @example
699
+ * @example Let another plugin publish chapters
700
+ * The plugin under test sees two chapter marks another plugin put on the
701
+ * timeline. `setTimelineLayer` stores that plugin's whole layer and pushes
702
+ * `timeline.changed`; once the plugin uses `plugin.timeline` (with
703
+ * `events`) the SDK keeps `marks` current from that event, and `settle()`
704
+ * lets it arrive.
635
705
  * ```ts
706
+ * import assert from "node:assert/strict";
707
+ * declare const host: TestHost;
708
+ * // ---cut---
636
709
  * host.setTimelineLayer("community.chapters", [
637
710
  * { time: 0, kind: "chapter", title: "Intro" },
638
711
  * { time: 95, kind: "chapter", title: "Part 1" },
639
- * ]);
712
+ * ]); // replaces that plugin's whole layer; [] would clear it
640
713
  * await host.settle();
641
714
  * assert.equal(plugin.timeline.marks.length, 2);
642
715
  * ```
@@ -668,8 +741,15 @@ export class TestHost {
668
741
  * runtime image. View properties hold images in their wire form; this
669
742
  * reads the path back.
670
743
  *
671
- * @example
744
+ * @example Check which file a window shows as its cover
745
+ * The test checks that the Cover window's image comes from the plugin's
746
+ * data directory. An image property holds the SDK's wire form of
747
+ * `plugin.ui.image(path)`, not a path; `imagePath` reads the path back.
672
748
  * ```ts
749
+ * import assert from "node:assert/strict";
750
+ * import { join } from "node:path";
751
+ * declare const host: TestHost;
752
+ * // ---cut---
673
753
  * assert.equal(
674
754
  * host.imagePath(host.window("cover").props["cover"]),
675
755
  * join(plugin.dataDirectory, "cover.png"),
@@ -758,8 +838,9 @@ export class TestHost {
758
838
  panelDrops = false;
759
839
  /**
760
840
  * Drop files like the player: on a panel with an `onDrop` listener the
761
- * whole list goes there; otherwise (and on the video area) grouped by
762
- * extension to the first provider (`folder` for existing directories).
841
+ * whole list goes there; otherwise (and on the video area) each path goes
842
+ * to the first provider of its extension (`folder` for existing
843
+ * directories), one call per provider with all of its paths.
763
844
  * Files nobody takes are ignored. Resolves once the host is idle again.
764
845
  *
765
846
  * @param paths - The dropped paths, in the order the file manager gives.
@@ -767,9 +848,17 @@ export class TestHost {
767
848
  * drop point in panel coordinates (default `{ x: 0, y: 0 }`);
768
849
  * `modifiers`: keys held (default all `false`).
769
850
  *
770
- * @example
851
+ * @example Drop files on the video and on the panel
852
+ * The first drop lands on the video area: each extension belongs to the
853
+ * provider that registered it first, and each provider gets one call with
854
+ * all of its paths. The second lands on the panel at a point, with Alt
855
+ * held, and the panel's `onDrop` gets the whole list. `drop` resolves
856
+ * once the host is idle.
771
857
  * ```ts
772
- * await host.drop(["/media/a.srt", "/media/b.mkv"]);
858
+ * declare const host: TestHost;
859
+ * // ---cut---
860
+ * await host.drop(["/media/a.srt", "/media/b.mkv"]); // on the video area
861
+ * // On the panel, 10 px from its left and 48 px from its top, Alt held.
773
862
  * await host.drop(["/media/list.m3u"], {
774
863
  * target: "panel",
775
864
  * at: { x: 10, y: 48 },
@@ -832,8 +921,15 @@ export class TestHost {
832
921
  /**
833
922
  * One settings page by id. Throws when it was never registered.
834
923
  *
835
- * @example
924
+ * @example Change a value on the plugin's settings page
925
+ * The test sets the step on the General page and checks the page shows
926
+ * it. `emit` fires the page's callback as the user's input would; the
927
+ * plugin's handler stores the value and its `bind` function sends it back
928
+ * as a property, which `settle()` waits for.
836
929
  * ```ts
930
+ * import assert from "node:assert/strict";
931
+ * declare const host: TestHost;
932
+ * // ---cut---
837
933
  * const page = host.settingsPage("general");
838
934
  * page.emit("setStep", 10);
839
935
  * await host.settle();
@@ -872,11 +968,20 @@ export class TestHost {
872
968
  * @param plugin - The other plugin's manifest id.
873
969
  * @param options - `title`: its tab label (default: `plugin`).
874
970
  *
875
- * @example The playlist next to this plugin's sidebar panel
971
+ * @example Put the playlist next to this plugin's sidebar panel
972
+ * The sidebar ends up with two tabs: the playlist, mounted first, and the
973
+ * plugin's own panel after it. `mountOther` before `startPlugin` mounts
974
+ * another plugin's panel as the player would, so the plugin's mount
975
+ * during activation becomes the second tab (in the bottom bar or the
976
+ * backdrop it would be refused with `conflict` instead).
876
977
  * ```ts
978
+ * import assert from "node:assert/strict";
979
+ * declare function activate(plugin: Plugin): Promise<void>;
980
+ * declare const directory: URL;
981
+ * // ---cut---
877
982
  * const host = new TestHost();
878
983
  * host.mountOther("sidebar", "vestra.playlist", { title: "播放列表" });
879
- * const { plugin } = await startPlugin(activate, { host, directory });
984
+ * await startPlugin(activate, { host, directory });
880
985
  * assert.equal(host.layout.sidebar?.panels?.length, 2);
881
986
  * ```
882
987
  *
@@ -999,8 +1104,18 @@ export class TestHost {
999
1104
  * or switches to software rendering (`unsupported`, which also sets
1000
1105
  * `videoRender.gpu` to false). Pushed only when this plugin is the owner.
1001
1106
  *
1002
- * @example
1107
+ * @example Make a shader fail to compile
1108
+ * The plugin's render options report `failed`, as when mpv rejects a
1109
+ * shader. The host pushes the failure only while this plugin owns the
1110
+ * render options, which its first accepted `set` made it; the SDK updates
1111
+ * the handle's `status` when the push arrives, so the test settles first.
1003
1112
  * ```ts
1113
+ * import assert from "node:assert/strict";
1114
+ * declare const host: TestHost;
1115
+ * // ---cut---
1116
+ * const render = plugin.video.render();
1117
+ * render.set({ toneMapping: "bt.2390" }); // makes the plugin the owner
1118
+ * await host.settle();
1004
1119
  * host.failVideoRender("failed", "shader compile failed: ERROR: 0:39: ...");
1005
1120
  * await host.settle();
1006
1121
  * assert.equal(render.status.state, "failed");
@@ -1020,8 +1135,15 @@ export class TestHost {
1020
1135
  * versions. `resources` holds each `video.filter` / `audio.filter` chain the
1021
1136
  * plugin applied, keyed by the SDK's resource id.
1022
1137
  *
1023
- * @example
1138
+ * @example Check the filter chain the plugin applied
1139
+ * After the plugin set its picture chain, the test reads the one chain
1140
+ * the host holds and compares its nodes. `resources` keeps each applied
1141
+ * `video.filter` / `audio.filter` chain by the SDK's resource id, after
1142
+ * the host validated it with the player's value ranges.
1024
1143
  * ```ts
1144
+ * import assert from "node:assert/strict";
1145
+ * declare const host: TestHost;
1146
+ * // ---cut---
1025
1147
  * const [chain] = [...host.processing.resources.values()];
1026
1148
  * assert.deepEqual(chain?.nodes, [{ kind: "hue", degrees: 30 }]);
1027
1149
  * ```
@@ -1076,10 +1198,14 @@ export class TestHost {
1076
1198
  * extensions (or scheme + host pairs) is refused with `conflict`, which
1077
1199
  * does not fail activation, like the player.
1078
1200
  *
1079
- * @example
1201
+ * @example Start with another plugin holding a file type
1202
+ * The plugin starts in a player where another plugin already resolves
1203
+ * `.ch8` files. Pushed before `startPlugin`, the entry makes the plugin's
1204
+ * own resolver for `.ch8` fail with `conflict` during activation, which
1205
+ * (as in the player) does not fail the activation.
1080
1206
  * ```ts
1081
1207
  * const host = new TestHost();
1082
- * host.otherResolvers.push({ owner: "example.emulator", extensions: ["nes"] });
1208
+ * host.otherResolvers.push({ owner: "example.emulator", extensions: ["ch8"] });
1083
1209
  * ```
1084
1210
  */
1085
1211
  otherResolvers = [];
@@ -1105,12 +1231,19 @@ export class TestHost {
1105
1231
  * after changing {@link TestHost.otherResolvers} (another plugin started
1106
1232
  * or stopped).
1107
1233
  *
1108
- * @example
1234
+ * @example Let another plugin's file type appear while running
1235
+ * Another plugin starts and adds `.ch8`; the plugin under test then sees
1236
+ * it in `resolvers.fileTypes`. Changing `otherResolvers` alone pushes
1237
+ * nothing: `updateFileTypes` sends the new list as the player does when
1238
+ * a plugin starts or stops, and `settle()` lets the SDK apply it.
1109
1239
  * ```ts
1110
- * host.otherResolvers.push({ owner: "example.emulator", extensions: ["nes"] });
1240
+ * import assert from "node:assert/strict";
1241
+ * declare const host: TestHost;
1242
+ * // ---cut---
1243
+ * host.otherResolvers.push({ owner: "example.emulator", extensions: ["ch8"] });
1111
1244
  * host.updateFileTypes();
1112
1245
  * await host.settle();
1113
- * assert.deepEqual(plugin.resolvers.fileTypes, ["nes"]);
1246
+ * assert.deepEqual(plugin.resolvers.fileTypes, ["ch8"]);
1114
1247
  * ```
1115
1248
  */
1116
1249
  updateFileTypes() {
@@ -1139,8 +1272,15 @@ export class TestHost {
1139
1272
  * For `openFile` / `openDirectory` / `saveFile` queue a one-path array;
1140
1273
  * for `openFiles` any number of paths.
1141
1274
  *
1142
- * @example
1275
+ * @example Answer the plugin's dialogs in advance
1276
+ * The plugin's Open command gets a picked file, then a cancelled file
1277
+ * dialog, then a confirmed question. All answers share one queue, taken
1278
+ * from the front in the order the plugin opens dialogs of any kind: if
1279
+ * the next queued answer is missing or of the wrong kind, the dialog
1280
+ * fails like a lost reply, so queue them in the order the plugin asks.
1143
1281
  * ```ts
1282
+ * declare const host: TestHost;
1283
+ * // ---cut---
1144
1284
  * host.dialogAnswers.push(["/media/movie.mkv"]); // openFile picks it
1145
1285
  * host.dialogAnswers.push(null); // the next file dialog is cancelled
1146
1286
  * host.dialogAnswers.push({ confirmed: true, checked: false }); // confirm
@@ -1302,12 +1442,20 @@ export class TestHost {
1302
1442
  * source, for keyframe step seeks. `null` (the default): none modelled,
1303
1443
  * and step seeks are exact even with `stepSeekKeyframes`.
1304
1444
  *
1305
- * @example
1445
+ * @example Step forward to the next keyframe
1446
+ * With keyframe step seeks on, Forward from 0:12 lands on the keyframe at
1447
+ * 0:20, not at 0:17, and the OSD says so. `keyframes` gives the source's
1448
+ * keyframe times; `setStepSeek` turns on keyframe steps of 5 s, and the
1449
+ * core action `seek-forward` seeks to the first keyframe at or after
1450
+ * the target, as the player does.
1306
1451
  * ```ts
1452
+ * import assert from "node:assert/strict";
1453
+ * declare const host: TestHost;
1454
+ * // ---cut---
1307
1455
  * host.setSource({ phase: "ready", duration: 45, position: 12 });
1308
1456
  * host.keyframes = [0, 10, 20, 30, 40];
1309
1457
  * await plugin.player.setStepSeek({ seconds: 5, keyframes: true });
1310
- * await plugin.host.run("seek-forward");
1458
+ * await plugin.host.run("seek-forward"); // target 17 s, next keyframe 20 s
1311
1459
  * assert.equal(host.source.position, 20);
1312
1460
  * assert.equal(host.osd.at(-1), "Forward 5 s: 00:20 / 00:45");
1313
1461
  * ```
@@ -1336,8 +1484,15 @@ export class TestHost {
1336
1484
  * an unknown id. `run()` executes it with the player's checks and writes
1337
1485
  * its on-screen text to {@link TestHost.osd}.
1338
1486
  *
1339
- * @example
1487
+ * @example Pause through the player's own action
1488
+ * The test pauses playback the way the Space key does and checks that the
1489
+ * host recorded it. Core actions belong to the player (`host/<id>`);
1490
+ * `run()` applies the player's checks, so a source must be ready first,
1491
+ * and every run is appended to `coreRuns`.
1340
1492
  * ```ts
1493
+ * import assert from "node:assert/strict";
1494
+ * declare const host: TestHost;
1495
+ * // ---cut---
1341
1496
  * host.setSource({ phase: "ready", duration: 100, position: 10 });
1342
1497
  * await host.coreAction("play-pause").run();
1343
1498
  * assert.equal(host.source.paused, true);
@@ -1707,12 +1862,20 @@ export class TestHost {
1707
1862
  * @param method - The wire method name.
1708
1863
  * @param handler - Produces the reply from the request parameters.
1709
1864
  *
1710
- * @example
1865
+ * @example Make opening a folder fail
1866
+ * The plugin's Open Folder command meets a missing folder and must tell
1867
+ * the user. `handle` replaces the host's answer to one wire method; the
1868
+ * `HostError` it throws reaches the plugin as a rejection with code
1869
+ * `missing`, like the player's refusal. `reset` restores the default
1870
+ * behaviour for the rest of the test.
1711
1871
  * ```ts
1872
+ * import assert from "node:assert/strict";
1873
+ * declare const host: TestHost;
1874
+ * // ---cut---
1712
1875
  * host.handle("system.openPath", () => {
1713
1876
  * throw new HostError("missing", "no such file");
1714
1877
  * });
1715
- * await host.command("open-folder").run();
1878
+ * await host.command("open-folder").run(); // the plugin catches `missing`
1716
1879
  * assert.deepEqual(host.osd, ["Folder not found"]);
1717
1880
  * host.reset("system.openPath");
1718
1881
  * ```
@@ -1757,8 +1920,15 @@ export class TestHost {
1757
1920
  * @param patch - Wire-level source fields, e.g. `phase`, `position`,
1758
1921
  * `duration`, `paused`, `audioTracks`.
1759
1922
  *
1760
- * @example
1923
+ * @example Load a file, then let it play to the end
1924
+ * The plugin sees a one-minute source become ready, then end on its own.
1925
+ * `setSource` changes the host's source facts and broadcasts them like
1926
+ * the player's events; the SDK updates `player.state` when the event
1927
+ * arrives, so the test settles before reading it.
1761
1928
  * ```ts
1929
+ * import assert from "node:assert/strict";
1930
+ * declare const host: TestHost;
1931
+ * // ---cut---
1762
1932
  * host.setSource({ phase: "ready", duration: 60, position: 0 });
1763
1933
  * await host.settle();
1764
1934
  * assert.equal(plugin.player.state.phase, "ready");
@@ -1790,8 +1960,16 @@ export class TestHost {
1790
1960
  * sidebar (`effective: false`, `reason: "fullscreen"`) and leaving
1791
1961
  * fullscreen docks it again.
1792
1962
  *
1793
- * @example
1963
+ * @example Go fullscreen, then report picture-in-picture support
1964
+ * The plugin sees the main window fullscreen at 1920×1080, then learns
1965
+ * that picture-in-picture is available. `setMainWindow` changes the
1966
+ * host's window facts and broadcasts them as the player does when the
1967
+ * user resizes or toggles fullscreen; entering fullscreen also collapses
1968
+ * an attached sidebar.
1794
1969
  * ```ts
1970
+ * import assert from "node:assert/strict";
1971
+ * declare const host: TestHost;
1972
+ * // ---cut---
1795
1973
  * host.setMainWindow({ fullscreen: true, videoArea: { width: 1920, height: 1080 } });
1796
1974
  * await host.settle();
1797
1975
  * assert.equal(plugin.window.state.fullscreen, true);
@@ -1843,8 +2021,16 @@ export class TestHost {
1843
2021
  * @param source - The path or URL.
1844
2022
  * @param phase - The phase to end in (default `"ready"`).
1845
2023
  *
1846
- * @example
2024
+ * @example Let the user open another file meanwhile
2025
+ * A state the plugin captured before stops being current once another
2026
+ * file opens. `openElsewhere` gives the source a new `sourceId`, so
2027
+ * `isCurrent` turns false. Promise-returning calls made with
2028
+ * `ifCurrent: before` then reject with `stale`; calls that return nothing
2029
+ * drop the write silently instead (`TimelineLayer.set` with `ifCurrent`).
1847
2030
  * ```ts
2031
+ * import assert from "node:assert/strict";
2032
+ * declare const host: TestHost;
2033
+ * // ---cut---
1848
2034
  * const before = plugin.player.state;
1849
2035
  * host.openElsewhere("/media/other.mp4");
1850
2036
  * await host.settle();
@@ -1866,10 +2052,16 @@ export class TestHost {
1866
2052
  * @param producerId - The producer, from {@link TestHost.producers}.
1867
2053
  * @param message - The player's failure text.
1868
2054
  *
1869
- * @example
2055
+ * @example Break the plugin's generated stream
2056
+ * The plugin's stream stops with an error, as when the player loses its
2057
+ * audio device. `failStream` puts the source into `error` with the
2058
+ * message and retires the producer at once; the stream's `onStop`
2059
+ * listeners get a `stream` error carrying the message.
1870
2060
  * ```ts
2061
+ * declare const host: TestHost;
2062
+ * // ---cut---
1871
2063
  * const [producer] = host.producers.values();
1872
- * host.failStream(producer.id, "producer transport closed");
2064
+ * host.failStream(producer!.id, "producer transport closed");
1873
2065
  * ```
1874
2066
  */
1875
2067
  failStream(producerId, message) {
@@ -1899,8 +2091,14 @@ export class TestHost {
1899
2091
  *
1900
2092
  * @returns What the key did: `"ran"`, `"reason"` or `"passed"`.
1901
2093
  *
1902
- * @example
2094
+ * @example Press a global key and a panel key
2095
+ * Ctrl+B runs whatever is bound globally; Delete is pressed while the
2096
+ * plugin's panel has focus, so panel bindings are tried first. `press`
2097
+ * follows the player's binding table and resolves with what happened:
2098
+ * `"ran"`, `"reason"` (a disabled command showed why) or `"passed"`.
1903
2099
  * ```ts
2100
+ * declare const host: TestHost;
2101
+ * // ---cut---
1904
2102
  * await host.press("Ctrl+B"); // a global binding
1905
2103
  * await host.press("Delete", { panel: true }); // while the panel has focus
1906
2104
  * ```
@@ -1935,8 +2133,14 @@ export class TestHost {
1935
2133
  * `[binding, winner, ...ignored]` (command ids; core actions as
1936
2134
  * `host/<id>`). Empty when there are none.
1937
2135
  *
1938
- * @example
2136
+ * @example Check that no default keys collide
2137
+ * The test fails when two commands, the player's or the plugin's, declare
2138
+ * the same binding. Each entry is `[binding, winner, ...ignored]`, so an
2139
+ * empty list means every key does what its command says.
1939
2140
  * ```ts
2141
+ * import assert from "node:assert/strict";
2142
+ * declare const host: TestHost;
2143
+ * // ---cut---
1940
2144
  * assert.deepEqual(host.conflicts, []);
1941
2145
  * ```
1942
2146
  */
@@ -1989,8 +2193,14 @@ export class TestHost {
1989
2193
  * player's source rules: the same source upgrades, another source needs
1990
2194
  * `replace` (`conflict` otherwise), a built-in id is `unsupported`.
1991
2195
  *
1992
- * @example
2196
+ * @example Offer a package file the plugin can install
2197
+ * The plugin can validate and install `/downloads/someone.demo-1.0.0.vxp`
2198
+ * as if the file existed. The host accepts only paths listed here (others
2199
+ * fail with `missing`) and returns this manifest from `validate`; an
2200
+ * install then follows the player's source rules.
1993
2201
  * ```ts
2202
+ * declare const host: TestHost;
2203
+ * // ---cut---
1994
2204
  * host.candidates.set("/downloads/someone.demo-1.0.0.vxp", {
1995
2205
  * id: "someone.demo", version: "1.0.0", apiVersion: "vestra/1",
1996
2206
  * entry: "main.js", capabilities: [],
@@ -2011,8 +2221,14 @@ export class TestHost {
2011
2221
  * the downloaded file. Pass a whole property value or a field of one: a
2012
2222
  * document nested in rows is read the same way.
2013
2223
  *
2014
- * @example
2224
+ * @example Read back a Markdown message in a chat window
2225
+ * The test checks the Markdown source of the first chat message. Rows
2226
+ * hold the document in its wire form, as `plugin.ui.markdown` produced
2227
+ * it; `markdown` reads it back, nested in a row or not.
2015
2228
  * ```ts
2229
+ * import assert from "node:assert/strict";
2230
+ * declare const host: TestHost;
2231
+ * // ---cut---
2016
2232
  * const rows = host.window("chat").props["messages"] as { body: Json }[];
2017
2233
  * assert.equal(host.markdown(rows[0]!.body)?.text, "**Hello**");
2018
2234
  * ```
@@ -2095,13 +2311,75 @@ export class TestHost {
2095
2311
  throw new Error(`command ${id} is not registered`);
2096
2312
  return command;
2097
2313
  }
2314
+ /**
2315
+ * The plugin's bottom-bar buttons (`CommandOptions.button`) as the player
2316
+ * shows them: at most one, the first declared (`BAR_BUTTON_LIMITS`; a
2317
+ * later one stays a menu item only). Empty once the command is disposed.
2318
+ * Whether the bar places them is the bar's business: the list is what
2319
+ * the player hands to `BarButtons`.
2320
+ *
2321
+ * @example Click the plugin's bottom-bar button in a test
2322
+ * The test finds the plugin's Store button, clicks it and checks that the
2323
+ * Store window opened. `barButtons` lists the buttons of the plugin's
2324
+ * commands declared with `button`, as the bar shows them; `click()` runs
2325
+ * the command like its menu item and waits until the host is idle, and
2326
+ * rejects while the command is disabled or busy.
2327
+ * ```ts
2328
+ * import assert from "node:assert/strict";
2329
+ * declare const host: TestHost;
2330
+ * // ---cut---
2331
+ * const [button] = host.barButtons; // one button per plugin
2332
+ * assert.equal(button?.icon, "store");
2333
+ * await button?.click();
2334
+ * assert.equal(host.window("store").visible, true);
2335
+ * ```
2336
+ *
2337
+ * @since 1.5.29
2338
+ */
2339
+ get barButtons() {
2340
+ const host = this;
2341
+ return [...this.commands]
2342
+ .filter(([, command]) => command.declaration.button !== undefined)
2343
+ .slice(0, BAR_BUTTON_LIMITS.perPlugin)
2344
+ .map(([id, command]) => {
2345
+ const button = command.declaration.button;
2346
+ const image = typeof button.icon !== "string";
2347
+ const reason = command.display.reason;
2348
+ const title = command.display.title.replace(/(…|\.\.\.)\s*$/, "");
2349
+ const enabled = command.state.enabled && !command.state.busy;
2350
+ return Object.freeze({
2351
+ command: id,
2352
+ icon: image
2353
+ ? String(button.icon.$vestraImage)
2354
+ : button.icon,
2355
+ image,
2356
+ order: button.order,
2357
+ enabled,
2358
+ checked: command.state.checked === true,
2359
+ tooltip: !enabled && reason ? `${title}\n${reason}` : title,
2360
+ async click() {
2361
+ const current = host.commands.get(id);
2362
+ if (current !== command)
2363
+ throw new HostError("missing", `command ${id} is gone`);
2364
+ await current.run();
2365
+ },
2366
+ });
2367
+ });
2368
+ }
2098
2369
  /**
2099
2370
  * The plugin's tool window with this id (by default the file name without
2100
2371
  * extension: `ui/picture.slint` → `picture`). Throws when it was never
2101
2372
  * opened with `show()`.
2102
2373
  *
2103
- * @example
2374
+ * @example Press a window's Reset button from a test
2375
+ * The Picture window's Reset callback runs and the brightness returns to
2376
+ * 0. The window is found by its id (the `.slint` file name by default);
2377
+ * `emit` fires the callback the plugin bound, and `settle()` waits for
2378
+ * the property the handler wrote.
2104
2379
  * ```ts
2380
+ * import assert from "node:assert/strict";
2381
+ * declare const host: TestHost;
2382
+ * // ---cut---
2105
2383
  * host.window("picture").emit("reset");
2106
2384
  * await host.settle();
2107
2385
  * assert.equal(host.window("picture").props["brightness"], 0);
@@ -2118,8 +2396,14 @@ export class TestHost {
2118
2396
  * none is mounted. Its `props` use Slint (kebab-case) names; `emit` fires a
2119
2397
  * callback on the panel's global.
2120
2398
  *
2121
- * @example
2399
+ * @example Select a row in the plugin's sidebar panel
2400
+ * The test checks the panel sits in the sidebar and selects its third
2401
+ * row. `emit` fires a callback on the panel's global as a click would;
2402
+ * the plugin's handler runs, and `settle()` waits for what it sent.
2122
2403
  * ```ts
2404
+ * import assert from "node:assert/strict";
2405
+ * declare const host: TestHost;
2406
+ * // ---cut---
2123
2407
  * const panel = host.mounted();
2124
2408
  * assert.equal(panel.slot, "sidebar");
2125
2409
  * panel.emit("select", 2);
@@ -2134,8 +2418,14 @@ export class TestHost {
2134
2418
  /**
2135
2419
  * The parameters of every request of one wire method, oldest first.
2136
2420
  *
2137
- * @example
2421
+ * @example Check the request behind an OSD message
2422
+ * The test checks the last OSD request the plugin sent, before
2423
+ * translation. `sent` lists the parameters of every request of one wire
2424
+ * method, oldest first; `host.osd` holds the texts as shown instead.
2138
2425
  * ```ts
2426
+ * import assert from "node:assert/strict";
2427
+ * declare const host: TestHost;
2428
+ * // ---cut---
2139
2429
  * assert.deepEqual(host.sent("osd.show").at(-1), { text: "Saved" });
2140
2430
  * ```
2141
2431
  */
@@ -2157,14 +2447,31 @@ export class TestHost {
2157
2447
  *
2158
2448
  * @param ms - Extra time to wait, for debounces and timers (default 0).
2159
2449
  *
2160
- * @example
2450
+ * @example Wait past a debounced save
2451
+ * The plugin's store writes its file 300 ms after the last `save`, so
2452
+ * the test waits 400 ms before it checks what the plugin did next.
2453
+ * `settle(ms)` returns when no request is in flight and at least `ms`
2454
+ * have passed, so the store's timer has fired. The file write itself is
2455
+ * the plugin's own work, which `settle` does not wait for: a test that
2456
+ * reads the file calls `await store.flush()` instead.
2161
2457
  * ```ts
2458
+ * declare const host: TestHost;
2459
+ * // ---cut---
2460
+ * const store = plugin.storage.json("prefs", { defaults: { step: 5 } });
2162
2461
  * store.save({ step: 10 });
2163
2462
  * await host.settle(400); // past storage.json's 300 ms debounce
2164
2463
  * ```
2165
2464
  *
2166
- * @example
2465
+ * @example Poll for work the plugin does on its own
2466
+ * The plugin reads and parses a file before it shows a status; `settle`
2467
+ * does not wait for that (it only waits for host requests), so the test
2468
+ * polls the property it asserts on, 20 ms at a time, for up to 2 seconds.
2167
2469
  * ```ts
2470
+ * import assert from "node:assert/strict";
2471
+ * declare const host: TestHost;
2472
+ * declare const expected: string;
2473
+ * // ---cut---
2474
+ * const window = host.window("import");
2168
2475
  * window.emit("load-file"); // the plugin reads and parses the file itself
2169
2476
  * for (let i = 0; i < 100 && window.props["status-text"] !== expected; i++)
2170
2477
  * await host.settle(20);
@@ -2501,6 +2808,23 @@ export class TestHost {
2501
2808
  throw new HostError("limit", "command registry limit reached");
2502
2809
  const host = this;
2503
2810
  const declaration = p.declaration;
2811
+ // Player 0.5.10: a bar button needs a path, an order 0–999 and a
2812
+ // known icon or an image of the plugin.
2813
+ if (declaration.button !== undefined) {
2814
+ const { icon, order } = declaration.button;
2815
+ if (path.length === 0)
2816
+ throw new HostError("invalid", "button needs a menu path");
2817
+ if (!Number.isInteger(order) ||
2818
+ order < 0 ||
2819
+ order > BAR_BUTTON_LIMITS.maxOrder)
2820
+ throw new HostError("invalid", `button order must be an integer 0–${BAR_BUTTON_LIMITS.maxOrder}`);
2821
+ if (typeof icon === "string") {
2822
+ if (!BAR_BUTTON_ICONS.includes(icon))
2823
+ throw new HostError("invalid", `unknown button icon "${icon}"`);
2824
+ }
2825
+ else if (this.imagePath(icon) === null)
2826
+ throw new HostError("invalid", "button icon is an icon name or an image from plugin.ui.image");
2827
+ }
2504
2828
  // Like the player: the menu texts and the reason are checked (and
2505
2829
  // shown) when they arrive.
2506
2830
  for (const segment of [declaration.title, ...(declaration.path ?? [])])
@@ -3775,19 +4099,43 @@ export class TestHost {
3775
4099
  * plugin like the player stopping it (scopes released, stores flushed),
3776
4100
  * wait until the host is idle and remove the host's temporary files.
3777
4101
  *
3778
- * @example
4102
+ * @example Test that a setting survives a restart
4103
+ * The test turns on the 10-step option, stops the plugin, starts it again
4104
+ * on the same data directory and checks the option is still on. Both runs
4105
+ * get a directory the test made (`dataDirectory`): `stop()` disposes the
4106
+ * plugin like the player, which writes its pending stores there, and only
4107
+ * removes the host's own temporary directory, so the second run reads what
4108
+ * the first one saved.
3779
4109
  * ```ts
4110
+ * import assert from "node:assert/strict";
4111
+ * import { mkdtempSync, rmSync } from "node:fs";
4112
+ * import { tmpdir } from "node:os";
4113
+ * import { join } from "node:path";
4114
+ * import test from "node:test";
4115
+ * import { startPlugin } from "@vestra-player/sdk/testing";
4116
+ * import activate from "../main.ts";
4117
+ *
3780
4118
  * test("remembers the step across restarts", async () => {
3781
4119
  * const directory = new URL("..", import.meta.url);
3782
- * const first = await startPlugin(activate, { directory });
3783
- * await first.host.command("step-10").run();
3784
4120
  * const data = mkdtempSync(join(tmpdir(), "data-"));
3785
- * cpSync(first.host.dataDirectory, data, { recursive: true });
3786
- * await first.stop();
4121
+ * try {
4122
+ * const first = await startPlugin(activate, { directory, dataDirectory: data });
4123
+ * try {
4124
+ * await first.host.command("step-10").run();
4125
+ * } finally {
4126
+ * await first.stop(); // flushes the plugin's stores into `data`
4127
+ * }
3787
4128
  *
3788
- * const second = await startPlugin(activate, { directory, dataDirectory: data });
3789
- * assert.equal(second.host.command("step-10").state.checked, true);
3790
- * await second.stop();
4129
+ * // A restart: a new host and a new plugin instance on the same data.
4130
+ * const second = await startPlugin(activate, { directory, dataDirectory: data });
4131
+ * try {
4132
+ * assert.equal(second.host.command("step-10").state.checked, true);
4133
+ * } finally {
4134
+ * await second.stop();
4135
+ * }
4136
+ * } finally {
4137
+ * rmSync(data, { recursive: true, force: true });
4138
+ * }
3791
4139
  * });
3792
4140
  * ```
3793
4141
  */