@vestra-player/sdk 1.6.17 → 1.6.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/docs-tree.json.gz +0 -0
  3. package/dist/plugin/appearance.js +1 -1
  4. package/dist/plugin/bindings.js +1 -1
  5. package/dist/plugin/commands.js +3 -2
  6. package/dist/plugin/connection.d.ts +119 -0
  7. package/dist/plugin/connection.js +402 -0
  8. package/dist/plugin/drops.d.ts +3 -3
  9. package/dist/plugin/drops.js +1 -1
  10. package/dist/plugin/errors.d.ts +9 -0
  11. package/dist/plugin/errors.js +35 -0
  12. package/dist/plugin/filters.js +1 -1
  13. package/dist/plugin/index.d.ts +1 -1
  14. package/dist/plugin/index.js +3 -1
  15. package/dist/plugin/language.js +1 -1
  16. package/dist/plugin/latest.d.ts +12 -0
  17. package/dist/plugin/latest.js +52 -0
  18. package/dist/plugin/local.js +1 -2
  19. package/dist/plugin/markdown.d.ts +2 -2
  20. package/dist/plugin/markdown.js +1 -1
  21. package/dist/plugin/media.js +1 -1
  22. package/dist/plugin/osd.js +2 -2
  23. package/dist/plugin/packages.js +1 -1
  24. package/dist/plugin/passes.d.ts +111 -0
  25. package/dist/plugin/passes.js +341 -0
  26. package/dist/plugin/player.d.ts +2 -3
  27. package/dist/plugin/player.js +2 -7
  28. package/dist/plugin/resolvers.js +1 -1
  29. package/dist/plugin/runtime.d.ts +26 -185
  30. package/dist/plugin/runtime.js +35 -755
  31. package/dist/plugin/scope.d.ts +2 -1
  32. package/dist/plugin/scope.js +5 -2
  33. package/dist/plugin/services/dialogs.d.ts +3 -0
  34. package/dist/plugin/services/dialogs.js +62 -0
  35. package/dist/plugin/services.js +1 -1
  36. package/dist/plugin/settings.js +2 -2
  37. package/dist/plugin/shaders.js +2 -1
  38. package/dist/plugin/stream.js +1 -2
  39. package/dist/plugin/subtitles.js +2 -2
  40. package/dist/plugin/testing/commands.d.ts +65 -0
  41. package/dist/plugin/testing/commands.js +412 -0
  42. package/dist/plugin/testing/core-actions.d.ts +39 -0
  43. package/dist/plugin/testing/core-actions.js +371 -0
  44. package/dist/plugin/testing/declarations.d.ts +37 -0
  45. package/dist/plugin/testing/declarations.js +80 -0
  46. package/dist/plugin/testing/i18n.d.ts +28 -0
  47. package/dist/plugin/testing/i18n.js +107 -0
  48. package/dist/plugin/testing/index.d.ts +3140 -0
  49. package/dist/plugin/testing/index.js +2796 -0
  50. package/dist/plugin/testing/internals.d.ts +76 -0
  51. package/dist/plugin/testing/internals.js +60 -0
  52. package/dist/plugin/testing/jobs.d.ts +9 -0
  53. package/dist/plugin/testing/jobs.js +304 -0
  54. package/dist/plugin/testing/layout.d.ts +85 -0
  55. package/dist/plugin/testing/layout.js +622 -0
  56. package/dist/plugin/testing/media.d.ts +55 -0
  57. package/dist/plugin/testing/media.js +615 -0
  58. package/dist/plugin/testing/osd.d.ts +4 -0
  59. package/dist/plugin/testing/osd.js +57 -0
  60. package/dist/plugin/testing/processing.d.ts +56 -0
  61. package/dist/plugin/testing/processing.js +491 -0
  62. package/dist/plugin/testing/services.d.ts +50 -0
  63. package/dist/plugin/testing/services.js +466 -0
  64. package/dist/plugin/testing/transport.d.ts +29 -0
  65. package/dist/plugin/testing/transport.js +140 -0
  66. package/dist/plugin/testing.d.ts +1 -3140
  67. package/dist/plugin/testing.js +1 -6203
  68. package/dist/plugin/timeline.js +2 -1
  69. package/dist/plugin/view/base.d.ts +29 -0
  70. package/dist/plugin/view/base.js +88 -0
  71. package/dist/plugin/view/binder.d.ts +22 -0
  72. package/dist/plugin/view/binder.js +57 -0
  73. package/dist/plugin/view/image.d.ts +21 -0
  74. package/dist/plugin/view/image.js +60 -0
  75. package/dist/plugin/view/layout.d.ts +47 -0
  76. package/dist/plugin/view/layout.js +171 -0
  77. package/dist/plugin/view/panel.d.ts +3 -0
  78. package/dist/plugin/view/panel.js +178 -0
  79. package/dist/plugin/view/window.d.ts +3 -0
  80. package/dist/plugin/view/window.js +99 -0
  81. package/dist/plugin/view/writer.d.ts +58 -0
  82. package/dist/plugin/view/writer.js +634 -0
  83. package/dist/plugin/view.js +2 -1
  84. package/dist/plugin/window.js +1 -1
  85. package/llms.txt +1 -1
  86. package/package.json +6 -4
  87. package/dist/plugin/ui.d.ts +0 -74
  88. package/dist/plugin/ui.js +0 -1234
@@ -0,0 +1,2796 @@
1
+ /**
2
+ * Test tools for Vestra Player plugins: an in-memory fake player
3
+ * ({@link TestHost}) and {@link startPlugin}, which activates a plugin against
4
+ * it.
5
+ *
6
+ * `TestHost` speaks the real wire protocol, so the SDK's own logic (revisions,
7
+ * conditional commits, recovery after lost replies, coalescing of updates)
8
+ * runs exactly as it does inside the player. Only the player side is faked,
9
+ * and its semantics are simplified:
10
+ *
11
+ * - no media decoding: sources are facts you set with
12
+ * {@link TestHost.setSource} or {@link TestHost.openElsewhere};
13
+ * - no Slint and no native windows: views are records of their properties
14
+ * and callbacks ({@link FakeWindow}), and you fire callbacks yourself;
15
+ * - export jobs advance one step on every state query
16
+ * (`queued` → `running` → `completed`);
17
+ * - video and audio processing nodes are validated with the player's own
18
+ * value ranges (an out-of-range `filter.set` ends in `failed` with
19
+ * `invalid`), an empty chain removes the plugin's chain, a chain on a
20
+ * generated stream fails `unsupported` (the player's own conformance
21
+ * cases check all three), and the command limits ({@link COMMAND_LIMITS}),
22
+ * property value limits ({@link UI_VALUE_LIMITS}) and menu ids
23
+ * ({@link MENU_IDS}) are enforced.
24
+ *
25
+ * Typical workflow: start the plugin with {@link startPlugin} (it reads the
26
+ * plugin's `manifest.json`), drive it the way the user or another plugin
27
+ * would (run commands, press bindings, fire view callbacks, change the
28
+ * source), wait with {@link TestHost.settle}, assert on what the host
29
+ * recorded (`host.osd`, `host.window(id).props`, `host.sent(method)`, …), and
30
+ * finally call `stop()`.
31
+ *
32
+ * Use it from `node:test` (what `vestra-plugin test` runs). Each test should
33
+ * create its own host; `stop()` removes the host's temporary directories.
34
+ *
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.
43
+ * ```ts
44
+ * // tests/main.test.ts
45
+ * import assert from "node:assert/strict";
46
+ * import test from "node:test";
47
+ * import { startPlugin } from "@vestra-player/sdk/testing";
48
+ * import activate from "../main.ts";
49
+ *
50
+ * test("the Hello command shows a message", async () => {
51
+ * const { host, stop } = await startPlugin(activate, {
52
+ * directory: new URL("..", import.meta.url),
53
+ * });
54
+ * try {
55
+ * assert.equal(host.command("hello").state.enabled, true);
56
+ * await host.command("hello").run();
57
+ * assert.deepEqual(host.osd, ["Hello"]);
58
+ * } finally {
59
+ * await stop();
60
+ * }
61
+ * });
62
+ * ```
63
+ *
64
+ * @packageDocumentation
65
+ * @module @vestra-player/sdk/testing
66
+ */
67
+ import { readFileSync } from "node:fs";
68
+ import { mkdirSync } from "node:fs";
69
+ import { join } from "node:path";
70
+ import { fileURLToPath } from "node:url";
71
+ import { HostError } from "../../wire.js";
72
+ import { BAR_BUTTON_ICONS, BAR_BUTTON_LIMITS, BUILTIN_COMMANDS, BUILTIN_SUBMENUS, COMMAND_LIMITS, MENU_IDS, } from "../../commands.js";
73
+ import { CAPABILITY_INFO, MESSAGE_LIMIT } from "../../capabilities.js";
74
+ import { OSD_LIMITS } from "../osd.js";
75
+ import { TIMELINE_LIMITS } from "../timeline.js";
76
+ import { SECRET_LIMITS, SESSION_STORAGE_LIMITS } from "../services.js";
77
+ import { SUBTITLE_LIMITS } from "../subtitles.js";
78
+ import { BINDING_INPUTS, canonical } from "../bindings.js";
79
+ import { THEME_PALETTES } from "../../palettes.js";
80
+ import { IMAGE_LIMITS, UI_VALUE_LIMITS } from "../values.js";
81
+ import { VIDEO_PROCESSING_LIMITS } from "../../processing.js";
82
+ import { RENDER_DEFAULTS, VIEW_DEFAULTS } from "../view.js";
83
+ import { createPlugin } from "../index.js";
84
+ import { PATCH_FEATURE } from "../patch.js";
85
+ import { LOCALES, LIST_SEPARATORS, loadPackageCatalog, TEXT_LIMITS, } from "../../i18n.js";
86
+ import { Internals } from "./internals.js";
87
+ import { on, emit, settle, close, keepsRunning, transportHandlers, tick, takenErrors, tellResidency, } from "./transport.js";
88
+ import { bindingClaims, bindingKey, keysChanged, press, barButtons, commandsHandlers, } from "./commands.js";
89
+ import { registerCoreActions, windowFacts, coreActionsHandlers, } from "./core-actions.js";
90
+ import { translate, i18nHandlers } from "./i18n.js";
91
+ import { mountOther, unmountOther, mounted, setTimelineRanges, setTimelineLayer, layoutHandlers, } from "./layout.js";
92
+ import { osdHandlers } from "./osd.js";
93
+ import { failStream, jobsHandlers } from "./jobs.js";
94
+ import { upscalingState, failVideoRender, failVideoShaders, upscalingDecoding, processingHandlers, } from "./processing.js";
95
+ import { sourceIntent, startResolution, setSource, mediaHandlers, initialSource, } from "./media.js";
96
+ import { drop, setMainWindow, servicesHandlers, sortedJson, } from "./services.js";
97
+ /** Throw one of these from `host.handle(...)` to simulate a definite refusal. */
98
+ export { HostError };
99
+ /** The player's command caps, which the test host enforces as well. */
100
+ export { COMMAND_LIMITS, MENU_IDS };
101
+ /** The icons and limits of bottom-bar buttons, which the test host checks as well. */
102
+ export { BAR_BUTTON_ICONS, BAR_BUTTON_LIMITS };
103
+ /** The submenus of the player's built-in plugins, by key path. */
104
+ export { BUILTIN_COMMANDS, BUILTIN_SUBMENUS };
105
+ /** The manifest capabilities: what each opens and how the store shows it. */
106
+ export { CAPABILITY_INFO };
107
+ /** The longest message a plugin may send the player. */
108
+ export { MESSAGE_LIMIT };
109
+ /** The player's limits on the on-screen display. */
110
+ export { OSD_LIMITS };
111
+ /** The player's limits on a timeline layer. */
112
+ export { TIMELINE_LIMITS };
113
+ /** The player's limits on stored secrets. */
114
+ export { SECRET_LIMITS };
115
+ /** The player's limits on `plugin.storage.session`. */
116
+ export { SESSION_STORAGE_LIMITS };
117
+ /** The player's limits on loaded subtitle files, which the test host enforces as well. */
118
+ export { SUBTITLE_LIMITS };
119
+ /** The player's bounds on convolution, lut3d and shader-channel requests, which the test host enforces as well. */
120
+ export { VIDEO_PROCESSING_LIMITS };
121
+ /** The names bindings are made of. */
122
+ export { BINDING_INPUTS };
123
+ /** The colours of the player's palettes, by `Theme` token. */
124
+ export { THEME_PALETTES };
125
+ /** The player's property value limits, which the test host enforces as well. */
126
+ export { IMAGE_LIMITS, UI_VALUE_LIMITS };
127
+ /**
128
+ * The player's limits on one text value ({@link Text}, {@link TextList},
129
+ * with everything nested in it), which the test host enforces as well:
130
+ * nesting depth 8, 1024 texts, lists and arguments in all, 16 arguments per
131
+ * text, 256 items per list, 4096 bytes per source text or string argument.
132
+ *
133
+ * @since 1.5.11
134
+ */
135
+ export { TEXT_LIMITS };
136
+ /**
137
+ * The separator of each {@link ListStyle} per interface language, as the
138
+ * player joins a {@link TextList}.
139
+ *
140
+ * @since 1.5.11
141
+ */
142
+ export { LIST_SEPARATORS };
143
+ /**
144
+ * The player's interface languages, English (the source) first, as the
145
+ * player's own table: `["en", "zh-Hans", "ja", "ko", "de", "fr", "es"]`.
146
+ *
147
+ * @example Check that every language translates a menu item
148
+ * The test fails for each interface language in which the "Open…" item
149
+ * still shows English. It starts the plugin once per language except
150
+ * English (`LOCALES[0]`): the `locale` option loads the plugin's
151
+ * `locales/<locale>.po` and the host shows menu texts translated with it,
152
+ * which `display` reports.
153
+ * ```ts
154
+ * import type { Plugin } from "@vestra-player/sdk";
155
+ * declare function activate(plugin: Plugin): Promise<void>;
156
+ * // ---cut---
157
+ * import assert from "node:assert/strict";
158
+ * import { LOCALES, startPlugin } from "@vestra-player/sdk/testing";
159
+ *
160
+ * const directory = new URL("..", import.meta.url);
161
+ * for (const locale of LOCALES.slice(1)) {
162
+ * const { host, stop } = await startPlugin(activate, { directory, locale });
163
+ * try {
164
+ * assert.notEqual(host.command("open").display.title, "Open…", locale);
165
+ * } finally {
166
+ * await stop(); // also when the assertion fails
167
+ * }
168
+ * }
169
+ * ```
170
+ */
171
+ export { LOCALES };
172
+ /**
173
+ * An in-memory fake Vestra Player for plugin tests.
174
+ *
175
+ * The plugin under test talks to it over the real wire protocol (the host is
176
+ * the plugin's transport), so everything the SDK does runs unchanged. The
177
+ * host answers each request with simplified player behaviour, records what
178
+ * the plugin did, and lets the test act as the user or another plugin.
179
+ *
180
+ * Members fall into a few groups:
181
+ *
182
+ * - **Drive the plugin**: {@link TestHost.command}`(id).run()`,
183
+ * {@link TestHost.press}, {@link TestHost.window}`(id).emit(...)`,
184
+ * {@link TestHost.mounted}`().emit(...)`, {@link TestHost.drop},
185
+ * {@link TestHost.step}, {@link TestHost.dialogAnswers}.
186
+ * - **Simulate the player and other plugins**: {@link TestHost.setSource},
187
+ * {@link TestHost.openElsewhere}, {@link TestHost.setMainWindow},
188
+ * {@link TestHost.setTimelineLayer}, {@link TestHost.setAppearance},
189
+ * {@link TestHost.setPackages}, {@link TestHost.emit}.
190
+ * - **Preset data** the plugin reads: {@link TestHost.stats},
191
+ * {@link TestHost.app}, {@link TestHost.systemInfo},
192
+ * {@link TestHost.chapters}, {@link TestHost.audioDevices},
193
+ * {@link TestHost.secrets}, {@link TestHost.clipboard},
194
+ * {@link TestHost.tools}, {@link TestHost.rendering},
195
+ * {@link TestHost.upscaling}.
196
+ * - **Assert** on records: {@link TestHost.osd}, {@link TestHost.calls},
197
+ * {@link TestHost.sent}, {@link TestHost.conflicts},
198
+ * {@link TestHost.layout}, {@link TestHost.menus}, and many more.
199
+ * - **Inject failures**: {@link TestHost.handle} / {@link TestHost.reset}.
200
+ * - **Synchronise**: {@link TestHost.settle}.
201
+ *
202
+ * Usually created by {@link startPlugin}. Create one yourself only to preset
203
+ * data that the plugin reads during activation (`app`, `appearance`,
204
+ * `secrets`, ...), then pass it as `startPlugin(activate, { host })`.
205
+ *
206
+ * Fields that mirror player state at the wire level (`source`, `subtitles`,
207
+ * `processing`, `snapshots`, `jobs`, `producers`, `uploads`, `resolvers`,
208
+ * `calls`) use the protocol's shapes. They are fine for assertions but are
209
+ * not part of the plugin API: their shape may change in minor versions.
210
+ *
211
+ * @example Preset the player, then react to another plugin's file
212
+ * The test starts the plugin in a player with the dark theme, opens a file
213
+ * the way another plugin would, and checks the plugin's panel title. A host
214
+ * created first can be preset with data the plugin reads during
215
+ * activation; `startPlugin` then uses it. `openElsewhere` changes the
216
+ * source and broadcasts it, the plugin's listener updates its panel, and
217
+ * `settle()` waits for those requests before the assertion.
218
+ * ```ts
219
+ * import assert from "node:assert/strict";
220
+ * import test from "node:test";
221
+ * import { startPlugin, TestHost } from "@vestra-player/sdk/testing";
222
+ * import activate from "../main.ts";
223
+ *
224
+ * test("shows the playing file in the panel", async () => {
225
+ * const host = new TestHost();
226
+ * host.appearance = "vestra"; // read during activation
227
+ * const { stop } = await startPlugin(activate, {
228
+ * directory: new URL("..", import.meta.url),
229
+ * host,
230
+ * });
231
+ * host.openElsewhere("/media/clip.mp4"); // another plugin opened a file
232
+ * await host.settle();
233
+ * assert.equal(host.mounted().props["title"], "clip.mp4");
234
+ * await stop();
235
+ * });
236
+ * ```
237
+ *
238
+ * @remarks
239
+ * An in-memory player that speaks the real wire protocol, so the SDK's own
240
+ * logic runs as it does in the player; only the player side is simulated
241
+ * (no decoding, no Slint, no windows). Make one per test, preset it if the
242
+ * plugin reads something during activation, start the plugin with
243
+ * {@link startPlugin}, drive it, `settle()`, and assert on what it
244
+ * recorded.
245
+ *
246
+ * @capability None.
247
+ * @see [Test a plugin](../../../docs/how-to/testing.md)
248
+ * @since 1.0.0
249
+ * @category Testing
250
+ */
251
+ export class TestHost {
252
+ #inner = new Internals();
253
+ /**
254
+ * Every request the plugin sent, in order: `{ method, params }`.
255
+ * Wire-level; prefer {@link TestHost.sent} for one method.
256
+ */
257
+ calls = [];
258
+ /** The host events the plugin subscribed to (needs `events` etc.). */
259
+ subscriptions = new Set();
260
+ /**
261
+ * The subscribed events the plugin really listens to: an `onChange` and
262
+ * the like, or a binding that reads the state. The SDK's own copies of
263
+ * the state (what keeps `plugin.player.state` current) are left out.
264
+ * Empty means the player may stop the plugin while nothing uses it
265
+ * (unless it holds something else: a window, a panel, a filter…).
266
+ *
267
+ * @since 1.5.38
268
+ */
269
+ listening = new Set();
270
+ /**
271
+ * Why the plugin listens to each event of {@link TestHost.listening}:
272
+ * every listener and every binding that reads its state, as the
273
+ * player's log says them (`a listener added at main.ts:12`,
274
+ * `command "Save" enabled is a function that reads plugin.player.state
275
+ * (main.ts:40) — use plugin.when`), in the order they came.
276
+ *
277
+ * @since 1.5.40
278
+ */
279
+ listeningWhy = new Map();
280
+ /**
281
+ * The player's source facts (phase, position, duration, paused, rate,
282
+ * audio tracks, loop, ...). Wire-level shape; change it with
283
+ * {@link TestHost.setSource}, which also broadcasts it.
284
+ */
285
+ source = initialSource();
286
+ /** Tool windows by id (see {@link TestHost.window}). */
287
+ windows = new Map();
288
+ /**
289
+ * The plugin's mounted panels by slot, in mount order (SDK 1.6.15: one
290
+ * panel per slot). See {@link TestHost.mounted}.
291
+ */
292
+ panels = new Map();
293
+ /**
294
+ * The plugin's mounted panel (bottom bar, sidebar, adjust column or
295
+ * backdrop), the last mounted when it has several, or `null`. See
296
+ * {@link TestHost.mounted}.
297
+ */
298
+ get panel() {
299
+ return [...this.panels.values()].at(-1) ?? null;
300
+ }
301
+ /** The plugin's commands by local id (see {@link TestHost.command}). */
302
+ commands = new Map();
303
+ /** The player's 14 core actions (`host/<id>`), kept apart from the plugin's `commands`. */
304
+ coreCommands = new Map();
305
+ /**
306
+ * Both panel positions as the player reports them (`null` while no panel
307
+ * is mounted there). You may change a slot directly and then call
308
+ * `host.emit("window.state", {})` to simulate a change made elsewhere.
309
+ *
310
+ * @example Hide the sidebar and check the player's layout
311
+ * With the plugin's panel in the sidebar, toggling the sidebar records
312
+ * that the user prefers it hidden. `toggle()` reads the slot from the
313
+ * player, asks for the opposite of its `preferred` flag and resolves once
314
+ * the player answered, so the host's `layout` already shows the new value.
315
+ * ```ts
316
+ * import assert from "node:assert/strict";
317
+ * declare const host: TestHost;
318
+ * // ---cut---
319
+ * assert.equal(host.layout.sidebar?.preferred, true);
320
+ * await plugin.panels.sidebar.toggle();
321
+ * assert.equal(host.layout.sidebar?.preferred, false);
322
+ * ```
323
+ */
324
+ layout = {
325
+ bottom: null,
326
+ sidebar: null,
327
+ adjust: null,
328
+ };
329
+ /**
330
+ * Every `osd.show` text as the player shows it (translated with the
331
+ * plugin's catalog and formatted with its args), newest last. The
332
+ * template and args the plugin sent are in `host.sent("osd.show")`.
333
+ */
334
+ osd = [];
335
+ /**
336
+ * The action buttons of each `osd.show` message, in step with `osd`
337
+ * (index for index): each button's title as shown and its command; `[]`
338
+ * for a message without actions (SDK 1.6.9).
339
+ */
340
+ osdActions = [];
341
+ /**
342
+ * Press button `index` of the newest OSD message, as the user does
343
+ * (player 0.7.6): its command runs as if picked from a menu, and settles.
344
+ * A disabled or busy command does nothing, as in the player.
345
+ *
346
+ * @param index - 0 or 1.
347
+ *
348
+ * @throws `Error` (rejects) The newest message has no button `index`.
349
+ *
350
+ * @example Press "Show in Folder" on the saved message
351
+ * The test checks the button the plugin put on its newest message, then
352
+ * presses it as the user would: the plugin's `reveal` command runs.
353
+ * ```ts
354
+ * import assert from "node:assert/strict";
355
+ * declare const host: TestHost;
356
+ * // ---cut---
357
+ * assert.deepEqual(host.osdActions.at(-1), [
358
+ * { title: "Show in Folder", command: "reveal" },
359
+ * ]);
360
+ * await host.pressOsdAction(0); // runs the plugin's `reveal` command
361
+ * ```
362
+ *
363
+ * @capability None.
364
+ * @since 1.6.9
365
+ */
366
+ async pressOsdAction(index) {
367
+ const action = this.osdActions.at(-1)?.[index];
368
+ if (!action)
369
+ throw new Error(`the newest OSD message has no button ${index}`);
370
+ const command = this.commands.get(action.command);
371
+ if (!command || command.state.busy || !command.state.enabled)
372
+ return;
373
+ await command.run();
374
+ }
375
+ /** The text of this plugin's osd panel as shown, `null` when hidden. */
376
+ osdPanel = null;
377
+ /**
378
+ * The interface language the test host plays (`plugin.i18n.locale`), `"en"`
379
+ * by default. Set it before {@link startPlugin} (or pass its `locale`
380
+ * option): the plugin's catalog for it is read from the package's
381
+ * `locales/` then, and the menus ({@link FakeCommand.display}), OSD
382
+ * messages, dialog texts, settings page and panel titles show its
383
+ * translations, the way the player shows them.
384
+ *
385
+ * The player's own texts (core actions' messages and menus, its
386
+ * refusals) are English, or the player's Simplified Chinese for
387
+ * `"zh-Hans"`. While `locale` was never set they stay in Chinese, as
388
+ * players up to 0.4.2 showed them, so tests written for SDK 1.5.8 keep
389
+ * passing; set `locale` to test what a user sees now.
390
+ *
391
+ * @example Check a menu path in Simplified Chinese
392
+ * The test starts the plugin with a Chinese interface and checks the
393
+ * menu path its Blur item shows. The `locale` option sets
394
+ * `host.locale` before activation, so the plugin's `locales/zh-Hans.po`
395
+ * is loaded; the built-in menu `@video` shows in the player's own words
396
+ * (视频), the item's title in the plugin's translation.
397
+ * ```ts
398
+ * import assert from "node:assert/strict";
399
+ * declare function activate(plugin: Plugin): Promise<void>;
400
+ * declare const directory: URL;
401
+ * // ---cut---
402
+ * const { host, stop } = await startPlugin(activate, { directory, locale: "zh-Hans" });
403
+ * assert.deepEqual(host.command("blur").display.path, ["视频", "模糊…"]);
404
+ * await stop();
405
+ * ```
406
+ *
407
+ * @since 1.5.9
408
+ */
409
+ get locale() {
410
+ return this.#inner.locale ?? "en";
411
+ }
412
+ set locale(locale) {
413
+ if (!LOCALES.includes(locale))
414
+ throw new Error(`test host: unknown locale ${locale}`);
415
+ this.#inner.locale = locale;
416
+ }
417
+ /**
418
+ * Read the plugin's translations for {@link TestHost.locale} from a
419
+ * package directory, as the player would (`locales/<locale>.json` of a
420
+ * built package, else the source `locales/<locale>.po`); `startPlugin`
421
+ * does this. None for English; a missing file means none; a broken `.po`
422
+ * throws.
423
+ *
424
+ * @param directory - The package directory (holding `locales/`).
425
+ * @since 1.5.9
426
+ *
427
+ * @capability None.
428
+ *
429
+ * @example Load the translations after changing the language
430
+ * The test switches the host to German before starting the plugin and
431
+ * loads that catalog itself (`startPlugin` does the same). Texts are then
432
+ * shown in German.
433
+ * ```ts
434
+ * declare const host: TestHost;
435
+ * declare const directory: string;
436
+ * // ---cut---
437
+ * host.locale = "de";
438
+ * host.loadCatalog(directory); // locales/de.po (or de.json)
439
+ * ```
440
+ */
441
+ loadCatalog(directory) {
442
+ this.#inner.catalog = loadPackageCatalog(directory, this.locale);
443
+ }
444
+ /**
445
+ * A text of the plugin as the player shows it: translated with the
446
+ * plugin's catalog ({@link TestHost.loadCatalog}), then formatted with
447
+ * `args` when given.
448
+ *
449
+ * @param text - English source text or template.
450
+ * @param args - Values for its placeholders.
451
+ * @returns The shown text.
452
+ *
453
+ * @example Compare with a translated message
454
+ * The test expects the text a Chinese user sees for a template the plugin
455
+ * shows. `translate` looks the English source up in the plugin's catalog
456
+ * for `host.locale` and fills in the arguments, the way the player does.
457
+ * ```ts
458
+ * import assert from "node:assert/strict";
459
+ * declare const host: TestHost;
460
+ * // ---cut---
461
+ * assert.equal(host.translate("Speed {}×", [2]), "倍速 2×");
462
+ * ```
463
+ *
464
+ * @since 1.5.9
465
+ *
466
+ * @capability None.
467
+ */
468
+ translate(text, args) {
469
+ return translate(this, this.#inner, text, args);
470
+ }
471
+ /**
472
+ * The saved interface language (`plugin.appearance.language`), changed by
473
+ * `appearance.setLanguage` or {@link TestHost.setLanguage}.
474
+ *
475
+ * @since 1.5.9
476
+ */
477
+ language = "system";
478
+ /**
479
+ * Simulate another plugin (or the user) saving the interface language:
480
+ * pushes `appearance.changed`.
481
+ *
482
+ * @param language - `"system"` or a locale.
483
+ * @since 1.5.9
484
+ *
485
+ * @capability None.
486
+ *
487
+ * @example Simulate the user choosing French for the next start
488
+ * The plugin hears that the saved interface language changed. The host
489
+ * pushes `appearance.changed`, which a plugin with `events` sees in
490
+ * `appearance.language` once it has read it and subscribed; this run's
491
+ * language does not change.
492
+ * ```ts
493
+ * import assert from "node:assert/strict";
494
+ * declare const host: TestHost;
495
+ * // ---cut---
496
+ * void plugin.appearance.language; // read once: the SDK then follows it
497
+ * plugin.appearance.onChange(() => {}); // subscribes to appearance events
498
+ * host.setLanguage("fr");
499
+ * await host.settle();
500
+ * assert.equal(plugin.appearance.language, "fr");
501
+ * ```
502
+ */
503
+ setLanguage(language) {
504
+ this.language = language;
505
+ this.emit("appearance.changed", { theme: this.appearance, language });
506
+ }
507
+ /**
508
+ * Every `dialogs.confirm` as the player shows it: its texts translated
509
+ * (message and detail formatted with `args`), newest last. The options
510
+ * the plugin sent are in {@link TestHost.confirms}.
511
+ *
512
+ * @since 1.5.9
513
+ */
514
+ confirmTexts = [];
515
+ /**
516
+ * Every file dialog (`openFile`, `openFiles`, `openDirectory`,
517
+ * `saveFile`) as the player shows it: the title translated, a text file
518
+ * name translated, newest last. The options the plugin sent are in
519
+ * `host.sent("dialogs.open")`.
520
+ *
521
+ * @since 1.5.11
522
+ */
523
+ fileDialogs = [];
524
+ /** The sequence provider's flags, `null` without a provider. */
525
+ sequence = null;
526
+ /**
527
+ * What `player.stats()` returns. Fields added in later rounds may be left
528
+ * out; the SDK fills them with `null`.
529
+ *
530
+ * @example Pretend a 1080p H.264 video is playing
531
+ * The plugin's `player.stats()` now reports a 1920×1080 H.264 stream at
532
+ * 24 fps. The host answers `stats()` with this object as it stands, so a
533
+ * test sets it before the plugin asks; fields it leaves out come back as
534
+ * `null` from the SDK.
535
+ * ```ts
536
+ * declare const host: TestHost;
537
+ * // ---cut---
538
+ * host.stats = {
539
+ * ...host.stats, // keep the other fields
540
+ * video: { codec: "h264", width: 1920, height: 1080, fps: 24 },
541
+ * } as typeof host.stats;
542
+ * ```
543
+ */
544
+ stats = {
545
+ backend: "test",
546
+ session: 0,
547
+ framesShown: 0,
548
+ framesDropped: 0,
549
+ presentedFps: null,
550
+ video: null,
551
+ audio: null,
552
+ avSyncMs: null,
553
+ cacheSeconds: null,
554
+ };
555
+ /** Every `subtitles.load`: its options and bytes, newest last. */
556
+ subtitleLoads = [];
557
+ /**
558
+ * The fake system clipboard. Preset `text` to test `readText`;
559
+ * `writeText` replaces it and `writeImage` appends the image path.
560
+ */
561
+ clipboard = {
562
+ text: null,
563
+ images: [],
564
+ };
565
+ /** The effective `power.preventSleep` mode. */
566
+ power = "off";
567
+ /**
568
+ * The fake `ffmpeg` / `ffprobe` behind `plugin.tools`. A tool without a
569
+ * handler is not found (`available()` is false, `run()` rejects with
570
+ * `unavailable`).
571
+ *
572
+ * @example Fake ffprobe and a failing ffmpeg
573
+ * The plugin's Probe command gets an empty stream list from ffprobe, and
574
+ * any ffmpeg run fails as it would on a broken file. `handle` makes the
575
+ * tool available and answers each `tools.run` with the handler's result;
576
+ * a thrown `HostError` makes the run reject with that code. `calls`
577
+ * records every run with its arguments.
578
+ * ```ts
579
+ * import assert from "node:assert/strict";
580
+ * declare const host: TestHost;
581
+ * // ---cut---
582
+ * host.tools.handle("ffprobe", (args) => {
583
+ * assert.ok(args.includes("-show_streams")); // what the plugin asked for
584
+ * return { stdout: JSON.stringify({ streams: [] }) }; // a file with no streams
585
+ * });
586
+ * // Every ffmpeg run rejects with code `failed`.
587
+ * host.tools.handle("ffmpeg", () => {
588
+ * throw new HostError("failed", "Invalid data found when processing input");
589
+ * });
590
+ * await host.command("probe").run();
591
+ * assert.equal(host.tools.calls[0]?.tool, "ffprobe");
592
+ * ```
593
+ */
594
+ tools = {
595
+ /** Every run: the tool name and its arguments, newest last. */
596
+ calls: [],
597
+ /** @internal The installed handlers by tool; use `handle()`. */
598
+ handlers: new Map(),
599
+ /**
600
+ * Make `tool` available and answer each run with `handler(args)`. Return
601
+ * `{ stdout?, stderr? }` (a string `stdout` is UTF-8 encoded), or throw a
602
+ * {@link HostError} to fail the run with that code. A later call
603
+ * replaces the handler.
604
+ */
605
+ handle(tool, handler) {
606
+ this.handlers.set(tool, handler);
607
+ },
608
+ };
609
+ /**
610
+ * Paths given to `system.reveal` / `system.openPath`, newest last; inject
611
+ * failures with `host.handle("system.openPath", …)`.
612
+ */
613
+ system = {
614
+ /** Paths given to `system.reveal`, newest last. */
615
+ revealed: [],
616
+ /** Paths given to `system.openPath`, newest last. */
617
+ opened: [],
618
+ /** `system.openUrl` addresses, newest last. */
619
+ urls: [],
620
+ };
621
+ /** @internal Local processes, faked: see {@link TestHost.tools}. */
622
+ local = {
623
+ download: async (url) => {
624
+ const file = this.remoteImages.get(url);
625
+ if (!file)
626
+ throw new HostError("failed", `no test image for ${url}`);
627
+ return new Uint8Array(readFileSync(file));
628
+ },
629
+ locate: async (tool) => this.tools.handlers.has(tool) ? `/test-host/${tool}` : null,
630
+ run: async (path, args, options) => {
631
+ const tool = path.split("/").pop();
632
+ const handler = this.tools.handlers.get(tool);
633
+ if (!handler)
634
+ throw new HostError("unavailable", `${tool} was not found`);
635
+ this.tools.calls.push({ tool, args });
636
+ if (options.signal.aborted) {
637
+ const error = new HostError("aborted", "aborted");
638
+ error.name = "AbortError";
639
+ throw error;
640
+ }
641
+ const result = await handler(args);
642
+ const stdout = typeof result.stdout === "string"
643
+ ? new Uint8Array(Buffer.from(result.stdout))
644
+ : (result.stdout ?? new Uint8Array());
645
+ if (stdout.length > options.maxOutput)
646
+ throw new HostError("invalid", `output exceeds ${options.maxOutput} bytes`);
647
+ return { stdout, stderr: result.stderr ?? "" };
648
+ },
649
+ };
650
+ /** Every `panel.showMenu`, newest last. */
651
+ menus = [];
652
+ // ------------------------------------------------------------ round 3b
653
+ /**
654
+ * Timeline layers by plugin id (this plugin's is `manifestId`) and the
655
+ * merged list `plugin.timeline.marks` reads. A new source clears them.
656
+ */
657
+ timeline = {
658
+ /** Each plugin's layer by plugin id; layers with no marks are absent. */
659
+ layers: new Map(),
660
+ /** Every layer merged, sorted by time, with the publishing plugin's id. */
661
+ get marks() {
662
+ return [...this.layers]
663
+ .flatMap(([plugin, marks]) => marks.map((mark) => ({
664
+ ...mark,
665
+ /** The manifest id of the plugin that published the mark. */
666
+ plugin,
667
+ })))
668
+ .sort((a, b) => a.time - b.time);
669
+ },
670
+ /**
671
+ * Each plugin's ranges by plugin id; layers without ranges are absent
672
+ * (SDK 1.6.9).
673
+ */
674
+ rangeLayers: new Map(),
675
+ /** Every layer's ranges merged, sorted by start, with the plugin's id. */
676
+ get ranges() {
677
+ return [...this.rangeLayers]
678
+ .flatMap(([plugin, ranges]) => ranges.map((range) => ({
679
+ ...range,
680
+ /** The manifest id of the plugin that published the range. */
681
+ plugin,
682
+ })))
683
+ .sort((a, b) => a.start - b.start);
684
+ },
685
+ };
686
+ /**
687
+ * Simulate another plugin publishing its timeline ranges (or clearing
688
+ * them with `[]`); pushes `timeline.changed` when the plugin subscribed.
689
+ *
690
+ * @param pluginId - The other plugin's manifest id.
691
+ * @param ranges - All its ranges.
692
+ *
693
+ * @example Let another plugin publish a clip range
694
+ * The plugin under test sees a range another plugin put on the seek bar:
695
+ * `setTimelineRanges` stores that plugin's ranges and pushes
696
+ * `timeline.changed`, and `settle()` lets it arrive.
697
+ * ```ts
698
+ * import assert from "node:assert/strict";
699
+ * declare const host: TestHost;
700
+ * // ---cut---
701
+ * host.setTimelineRanges("community.clips", [
702
+ * { start: 40, end: 52.5, style: "selection", title: "Clip" },
703
+ * ]);
704
+ * await host.settle();
705
+ * assert.equal(plugin.timeline.ranges[0]?.plugin, "community.clips");
706
+ * ```
707
+ *
708
+ * @capability None.
709
+ * @since 1.6.9
710
+ */
711
+ setTimelineRanges(pluginId, ranges) {
712
+ return setTimelineRanges(this, this.#inner, pluginId, ranges);
713
+ }
714
+ /**
715
+ * Simulate another plugin publishing its timeline layer (or clearing it
716
+ * with `[]`); pushes `timeline.changed` when the plugin subscribed.
717
+ *
718
+ * @param pluginId - The other plugin's manifest id.
719
+ * @param marks - Its whole layer.
720
+ *
721
+ * @example Let another plugin publish chapters
722
+ * The plugin under test sees two chapter marks another plugin put on the
723
+ * timeline. `setTimelineLayer` stores that plugin's whole layer and pushes
724
+ * `timeline.changed`; once the plugin uses `plugin.timeline` (with
725
+ * `events`) the SDK keeps `marks` current from that event, and `settle()`
726
+ * lets it arrive.
727
+ * ```ts
728
+ * import assert from "node:assert/strict";
729
+ * declare const host: TestHost;
730
+ * // ---cut---
731
+ * host.setTimelineLayer("community.chapters", [
732
+ * { time: 0, kind: "chapter", title: "Intro" },
733
+ * { time: 95, kind: "chapter", title: "Part 1" },
734
+ * ]); // replaces that plugin's whole layer; [] would clear it
735
+ * await host.settle();
736
+ * assert.equal(plugin.timeline.marks.length, 2);
737
+ * ```
738
+ *
739
+ * @capability None.
740
+ * @since 1.0.0
741
+ */
742
+ setTimelineLayer(pluginId, marks) {
743
+ return setTimelineLayer(this, this.#inner, pluginId, marks);
744
+ }
745
+ /**
746
+ * Runtime images written to views (`plugin.ui.image`,
747
+ * `plugin.ui.remoteImage`), whole values and (SDK 1.5.19) images nested
748
+ * in rows, newest last. The SDK refuses images of released scopes before
749
+ * anything is sent. The test host does not read the files, so it does not
750
+ * check the player's per-view budget (`IMAGE_LIMITS.viewBytes`).
751
+ */
752
+ images = [];
753
+ /**
754
+ * The file an image property shows, or `null` when the value is not a
755
+ * runtime image. View properties hold images in their wire form; this
756
+ * reads the path back.
757
+ *
758
+ * @example Check which file a window shows as its cover
759
+ * The test checks that the Cover window's image comes from the plugin's
760
+ * data directory. An image property holds the SDK's wire form of
761
+ * `plugin.ui.image(path)`, not a path; `imagePath` reads the path back.
762
+ * ```ts
763
+ * import assert from "node:assert/strict";
764
+ * import { join } from "node:path";
765
+ * declare const host: TestHost;
766
+ * // ---cut---
767
+ * assert.equal(
768
+ * host.imagePath(host.window("cover").props["cover"]),
769
+ * join(plugin.dataDirectory, "cover.png"),
770
+ * );
771
+ * ```
772
+ *
773
+ * @capability None.
774
+ * @since 1.0.0
775
+ */
776
+ imagePath(value) {
777
+ const image = value;
778
+ return image &&
779
+ typeof image === "object" &&
780
+ typeof image.$vestraImage === "string"
781
+ ? image.$vestraImage
782
+ : null;
783
+ }
784
+ // ------------------------------------------------------------ round 3b batch 2
785
+ /** `drops.provide` registrations of this plugin, in order (`id` is the SDK's). */
786
+ dropProviders = [];
787
+ /** The panel has an `onDrop` listener. */
788
+ panelDrops = false;
789
+ /**
790
+ * Drop files like the player: on a panel with an `onDrop` listener the
791
+ * whole list goes there; otherwise (and on the video area) each path goes
792
+ * to the first provider of its extension (`folder` for existing
793
+ * directories), one call per provider with all of its paths.
794
+ * Files nobody takes are ignored. Resolves once the host is idle again.
795
+ *
796
+ * @param paths - The dropped paths, in the order the file manager gives.
797
+ * @param options - `target`: `"video"` (default) or `"panel"`; `at`: the
798
+ * drop point in panel coordinates (default `{ x: 0, y: 0 }`);
799
+ * `modifiers`: keys held (default all `false`).
800
+ *
801
+ * @example Drop files on the video and on the panel
802
+ * The first drop lands on the video area: each extension belongs to the
803
+ * provider that registered it first, and each provider gets one call with
804
+ * all of its paths. The second lands on the panel at a point, with Alt
805
+ * held, and the panel's `onDrop` gets the whole list. `drop` resolves
806
+ * once the host is idle.
807
+ * ```ts
808
+ * declare const host: TestHost;
809
+ * // ---cut---
810
+ * await host.drop(["/media/a.srt", "/media/b.mkv"]); // on the video area
811
+ * // On the panel, 10 px from its left and 48 px from its top, Alt held.
812
+ * await host.drop(["/media/list.m3u"], {
813
+ * target: "panel",
814
+ * at: { x: 10, y: 48 },
815
+ * modifiers: { alt: true },
816
+ * });
817
+ * ```
818
+ *
819
+ * @capability None.
820
+ * @throws Never: files nobody takes are ignored, as in the player.
821
+ * @since 1.0.0
822
+ */
823
+ drop(paths, options = {}) {
824
+ return drop(this, this.#inner, paths, options);
825
+ }
826
+ /** The palette (`plugin.appearance.theme`); `setAppearance` changes it elsewhere. */
827
+ appearance = "vestra";
828
+ /**
829
+ * Simulate another plugin switching the palette: updates
830
+ * {@link TestHost.appearance} and pushes `appearance.changed`.
831
+ *
832
+ * @capability None.
833
+ *
834
+ * @example Simulate another plugin switching to the light palette
835
+ * The plugin hears the palette change. The host updates `appearance` and
836
+ * pushes `appearance.changed` to a plugin that listens with `events`.
837
+ * ```ts
838
+ * import assert from "node:assert/strict";
839
+ * declare const host: TestHost;
840
+ * // ---cut---
841
+ * const themes: ThemeId[] = [];
842
+ * plugin.appearance.onChange((theme) => themes.push(theme)); // subscribes (`events`)
843
+ * host.setAppearance("vestra-light");
844
+ * await host.settle();
845
+ * assert.deepEqual(themes, ["vestra-light"]);
846
+ * ```
847
+ *
848
+ * @since 1.0.0
849
+ */
850
+ setAppearance(theme) {
851
+ this.appearance = theme;
852
+ this.emit("appearance.changed", { theme });
853
+ }
854
+ /**
855
+ * Settings pages by id (`plugin.settings.page`), every `show` (page id or
856
+ * null), and the page the options window shows.
857
+ */
858
+ settings = {
859
+ /** Registered pages by page id. */
860
+ pages: new Map(),
861
+ /** Every `settings.show(pageId)`: the page id, `null` when omitted. */
862
+ shows: [],
863
+ /** The page the options window shows, `null` before any `show`. */
864
+ current: null,
865
+ /** Whether the options window is open. */
866
+ open: false,
867
+ };
868
+ /**
869
+ * One settings page by id. Throws when it was never registered.
870
+ *
871
+ * @example Change a value on the plugin's settings page
872
+ * The test sets the step on the General page and checks the page shows
873
+ * it. `emit` fires the page's callback as the user's input would; the
874
+ * plugin's handler stores the value and its `bind` function sends it back
875
+ * as a property, which `settle()` waits for.
876
+ * ```ts
877
+ * import assert from "node:assert/strict";
878
+ * declare const host: TestHost;
879
+ * // ---cut---
880
+ * const page = host.settingsPage("general");
881
+ * page.emit("setStep", 10);
882
+ * await host.settle();
883
+ * assert.equal(page.props["step"], 10);
884
+ * ```
885
+ *
886
+ * @capability None.
887
+ * @since 1.0.0
888
+ */
889
+ settingsPage(id) {
890
+ const page = this.settings.pages.get(id);
891
+ if (!page)
892
+ throw new Error(`settings page ${id} is not registered`);
893
+ return page;
894
+ }
895
+ /** The backdrop: who mounted it and whether it shows (`panels.backdrop.state`). */
896
+ backdrop = null;
897
+ /**
898
+ * Simulate the backdrop's `effective` changing (`false`: a video frame
899
+ * appeared; `true`: there is none again); pushed to the plugin.
900
+ *
901
+ * @capability None.
902
+ *
903
+ * @example Make a video picture replace the backdrop
904
+ * The plugin's backdrop hears that a video frame now covers it. `false`
905
+ * means a picture appeared; the change is pushed like the player's
906
+ * `window.state`.
907
+ * ```ts
908
+ * import assert from "node:assert/strict";
909
+ * declare const host: TestHost;
910
+ * // ---cut---
911
+ * host.setBackdropEffective(false);
912
+ * await host.settle();
913
+ * assert.equal(plugin.panels.backdrop.state?.effective, false);
914
+ * ```
915
+ *
916
+ * @since 1.0.0
917
+ */
918
+ setBackdropEffective(effective) {
919
+ if (this.backdrop)
920
+ this.backdrop.effective = effective;
921
+ this.emit("window.state", windowFacts(this, this.#inner));
922
+ }
923
+ /**
924
+ * Simulate another plugin mounting a panel, before or after the plugin
925
+ * under test, as the player handles it: in the sidebar it is another tab
926
+ * (after the existing ones; the first panel's tab is current); the bottom
927
+ * bar and the backdrop take one panel, so the plugin's own mount there
928
+ * then rejects with `conflict` (not an activation failure, as in the
929
+ * player). Pushed as a `window.state` change.
930
+ *
931
+ * @param slot - The position.
932
+ * @param plugin - The other plugin's manifest id.
933
+ * @param options - `title`: its tab label (default: `plugin`).
934
+ *
935
+ * @example Put the playlist next to this plugin's sidebar panel
936
+ * The sidebar ends up with two tabs: the playlist, mounted first, and the
937
+ * plugin's own panel after it. `mountOther` before `startPlugin` mounts
938
+ * another plugin's panel as the player would, so the plugin's mount
939
+ * during activation becomes the second tab (in the bottom bar or the
940
+ * backdrop it would be refused with `conflict` instead).
941
+ * ```ts
942
+ * import assert from "node:assert/strict";
943
+ * declare function activate(plugin: Plugin): Promise<void>;
944
+ * declare const directory: URL;
945
+ * // ---cut---
946
+ * const host = new TestHost();
947
+ * host.mountOther("sidebar", "vestra.playlist", { title: "播放列表" });
948
+ * await startPlugin(activate, { host, directory });
949
+ * assert.equal(host.layout.sidebar?.panels?.length, 2);
950
+ * ```
951
+ *
952
+ * @since 1.5.2
953
+ *
954
+ * @capability None.
955
+ */
956
+ mountOther(slot, plugin, options = {}) {
957
+ return mountOther(this, this.#inner, slot, plugin, options);
958
+ }
959
+ /**
960
+ * Simulate another plugin's panel going away ({@link TestHost.mountOther}):
961
+ * when its tab was current, the next tab (else the previous one) is.
962
+ *
963
+ * @since 1.5.2
964
+ *
965
+ * @capability None.
966
+ *
967
+ * @example Let the playlist's tab go away
968
+ * The other plugin's sidebar tab disappears and the plugin's own tab
969
+ * becomes current. When the removed tab was current, the next tab (else
970
+ * the previous one) becomes current, as in the player.
971
+ * ```ts
972
+ * import assert from "node:assert/strict";
973
+ * declare const host: TestHost;
974
+ * // ---cut---
975
+ * host.unmountOther("vestra.playlist");
976
+ * await host.settle();
977
+ * assert.equal(host.layout.sidebar?.panels?.length, 1);
978
+ * ```
979
+ */
980
+ unmountOther(plugin) {
981
+ return unmountOther(this, this.#inner, plugin);
982
+ }
983
+ /**
984
+ * Simulate the user clicking a sidebar tab: `plugin`'s panel (the plugin
985
+ * under test's `manifestId`, or one of {@link TestHost.mountOther})
986
+ * becomes the current tab. Pushed as a `window.state` change.
987
+ *
988
+ * @since 1.5.2
989
+ *
990
+ * @capability None.
991
+ *
992
+ * @example Click another plugin's sidebar tab
993
+ * The user switches the sidebar to the playlist; the plugin's panel is in
994
+ * a background tab now. The change is pushed as `window.state`.
995
+ * ```ts
996
+ * import assert from "node:assert/strict";
997
+ * declare const host: TestHost;
998
+ * // ---cut---
999
+ * host.selectTab("vestra.playlist");
1000
+ * await host.settle();
1001
+ * assert.notEqual(host.layout.sidebar?.owner, 1); // the plugin under test is owner 1
1002
+ * ```
1003
+ */
1004
+ selectTab(plugin) {
1005
+ const slot = [this.layout.sidebar, this.layout.adjust].find((each) => each?.panels?.some((entry) => entry.plugin === plugin));
1006
+ const panel = slot?.panels?.find((entry) => entry.plugin === plugin);
1007
+ if (!slot || !panel)
1008
+ throw new Error(`${plugin} has no tab`);
1009
+ slot.owner = panel.owner;
1010
+ this.emit("window.state", windowFacts(this, this.#inner));
1011
+ }
1012
+ /**
1013
+ * The display layer (`plugin.video.view()`): who owns it (this plugin is
1014
+ * `manifestId`) and the values the player holds.
1015
+ */
1016
+ videoView = {
1017
+ owner: null,
1018
+ values: { ...VIEW_DEFAULTS },
1019
+ };
1020
+ /**
1021
+ * Simulate another plugin taking the display layer first (the plugin's
1022
+ * `video.view()` then fails with `conflict`), or, with `null`, releasing
1023
+ * it (values reset to the defaults).
1024
+ *
1025
+ * @capability None.
1026
+ *
1027
+ * @example Let another plugin own the display layer
1028
+ * The plugin's `video.view()` fails with `conflict` because another plugin
1029
+ * took the display layer first. `null` releases it again.
1030
+ * ```ts
1031
+ * import assert from "node:assert/strict";
1032
+ * declare const host: TestHost;
1033
+ * // ---cut---
1034
+ * host.setVideoViewOwner("someone.zoom");
1035
+ * const view = plugin.video.view();
1036
+ * view.set({ zoom: 2 });
1037
+ * await host.settle();
1038
+ * assert.equal(view.status.state, "failed");
1039
+ * ```
1040
+ *
1041
+ * @since 1.0.0
1042
+ */
1043
+ setVideoViewOwner(pluginId) {
1044
+ this.videoView.owner = pluginId;
1045
+ if (pluginId === null)
1046
+ this.videoView.values = { ...VIEW_DEFAULTS };
1047
+ }
1048
+ /**
1049
+ * The render options (`plugin.video.render()`): who owns them, the values
1050
+ * the player holds, and whether it renders on the GPU (`gpu: false` is
1051
+ * software rendering, where every `set` fails with `unsupported`).
1052
+ */
1053
+ videoRender = { owner: null, values: { ...RENDER_DEFAULTS, shaders: [] }, gpu: true };
1054
+ /**
1055
+ * Simulate another plugin owning the render options (the plugin's
1056
+ * `video.render()` then fails with `conflict`), or, with `null`, releasing
1057
+ * them (values reset to the defaults).
1058
+ *
1059
+ * @capability None.
1060
+ *
1061
+ * @example Let another plugin own the render options
1062
+ * The plugin's render options report `conflict` while another plugin owns
1063
+ * them; `null` releases them and resets the values to the defaults.
1064
+ * ```ts
1065
+ * import assert from "node:assert/strict";
1066
+ * declare const host: TestHost;
1067
+ * // ---cut---
1068
+ * host.setVideoRenderOwner("someone.hdr");
1069
+ * const render = plugin.video.render();
1070
+ * render.set({ toneMapping: "bt.2390" });
1071
+ * await host.settle();
1072
+ * assert.equal(render.status.state, "failed");
1073
+ * ```
1074
+ *
1075
+ * @since 1.0.0
1076
+ */
1077
+ setVideoRenderOwner(pluginId) {
1078
+ this.videoRender.owner = pluginId;
1079
+ if (pluginId === null)
1080
+ this.videoRender.values = { ...RENDER_DEFAULTS, shaders: [] };
1081
+ }
1082
+ /**
1083
+ * What the player pushes when mpv cannot compile a shader (code `failed`)
1084
+ * or switches to software rendering (`unsupported`, which also sets
1085
+ * `videoRender.gpu` to false and `rendering.reason` to `window-fallback`
1086
+ * with `message`). Pushed only when this plugin is the owner.
1087
+ * `previous-crash` (SDK 1.5.54) is a shortcut that only sets the handle's
1088
+ * status and reaches `onError`: the real player never pushes it, it
1089
+ * refuses the `set` request itself. To test that, set
1090
+ * {@link TestHost.previousCrash}.
1091
+ *
1092
+ * @example Make a shader fail to compile
1093
+ * The plugin's render options report `failed`, as when mpv rejects a
1094
+ * shader. The host pushes the failure only while this plugin owns the
1095
+ * render options, which its first accepted `set` made it; the SDK updates
1096
+ * the handle's `status` when the push arrives, so the test settles first.
1097
+ * ```ts
1098
+ * import assert from "node:assert/strict";
1099
+ * declare const host: TestHost;
1100
+ * // ---cut---
1101
+ * const render = plugin.video.render();
1102
+ * render.set({ toneMapping: "bt.2390" }); // makes the plugin the owner
1103
+ * await host.settle();
1104
+ * host.failVideoRender("failed", "shader compile failed: ERROR: 0:39: ...");
1105
+ * await host.settle();
1106
+ * assert.equal(render.status.state, "failed");
1107
+ * ```
1108
+ *
1109
+ * @capability None.
1110
+ * @since 1.0.0
1111
+ */
1112
+ failVideoRender(code, message) {
1113
+ return failVideoRender(this, this.#inner, code, message);
1114
+ }
1115
+ /**
1116
+ * The shader channel (`plugin.video.shaders(id)`, SDK 1.5.39): the lists
1117
+ * the player holds for this plugin's handles by id (the real paths, after
1118
+ * following links), and how many shaders other plugins hold, which count
1119
+ * against {@link VIDEO_PROCESSING_LIMITS}`.shaders` (`limit`). Files must
1120
+ * be under {@link TestHost.fileRoots}; under software rendering
1121
+ * (`videoRender.gpu = false`) a non-empty list fails with `unsupported`.
1122
+ *
1123
+ * @since 1.5.39
1124
+ */
1125
+ videoShaders = { handles: new Map(), others: 0 };
1126
+ /**
1127
+ * Where the plugin's own files may be: its package directory and its
1128
+ * data directory ({@link startPlugin} sets both when the list is still
1129
+ * empty). `lut3d` files and shader-channel files outside them fail with
1130
+ * `invalid`, as in the player.
1131
+ *
1132
+ * @since 1.5.39
1133
+ */
1134
+ fileRoots = [];
1135
+ /**
1136
+ * What the player pushes to a shader handle when mpv cannot compile a
1137
+ * shader (code `failed`; the player cannot tell whose shader failed, so
1138
+ * every handle with shaders hears it) or the player switches to software
1139
+ * rendering (`unsupported`; use {@link TestHost.failVideoRender}, which
1140
+ * also tells the handles). Pushed only to a handle that holds shaders.
1141
+ *
1142
+ * `code` (SDK 1.5.54, default `failed`) is the error code pushed.
1143
+ * `previous-crash` is a shortcut that only sets the handle's status and
1144
+ * reaches `onError`: the real player (0.6.3) never pushes it, it refuses
1145
+ * the `set` request itself after an abnormal end. To test that, set
1146
+ * {@link TestHost.previousCrash}.
1147
+ *
1148
+ * @capability None.
1149
+ *
1150
+ * @example Make the plugin's shader fail to compile
1151
+ * The plugin's shader handle reports `failed`, as when mpv rejects a
1152
+ * shader. The host pushes the failure only to a handle that holds
1153
+ * shaders, which its accepted `set` made it; the SDK updates the
1154
+ * handle's `status` when the push arrives, so the test settles first.
1155
+ * ```ts
1156
+ * import assert from "node:assert/strict";
1157
+ * import { join } from "node:path";
1158
+ * import { writeFileSync } from "node:fs";
1159
+ * declare const host: TestHost;
1160
+ * // ---cut---
1161
+ * const file = join(plugin.dataDirectory, "sharpen.glsl");
1162
+ * writeFileSync(file, "//!HOOK MAIN\n");
1163
+ * const shaders = plugin.video.shaders("sharpen");
1164
+ * shaders.set([file]); // the handle now holds a shader
1165
+ * await host.settle();
1166
+ * host.failVideoShaders("sharpen", "shader compile failed: ERROR: 0:3: ...");
1167
+ * await host.settle();
1168
+ * assert.equal(shaders.status.state, "failed");
1169
+ * ```
1170
+ *
1171
+ * @since 1.5.39
1172
+ */
1173
+ failVideoShaders(id, message, code = "failed") {
1174
+ return failVideoShaders(this, this.#inner, id, message, code);
1175
+ }
1176
+ /**
1177
+ * Play a run after an abnormal end with plugin shaders on (player 0.6.3):
1178
+ * while set, a non-empty `video.shaders` list and a render options
1179
+ * `set` with a non-empty `shaders` list are refused with
1180
+ * `previous-crash` and this message; clearing still works. `null`
1181
+ * (default): shaders apply.
1182
+ *
1183
+ * @example Check that the plugin stops retrying while shaders are off
1184
+ * The plugin's shader handle turns `failed` with `previous-crash`, as
1185
+ * in the player after a crash while plugin shaders were drawing. The
1186
+ * player refuses the list itself (and shows its own OSD line), so the
1187
+ * plugin should report it once and not set the list again.
1188
+ * ```ts
1189
+ * import assert from "node:assert/strict";
1190
+ * import { join } from "node:path";
1191
+ * import { writeFileSync } from "node:fs";
1192
+ * declare const host: TestHost;
1193
+ * // ---cut---
1194
+ * host.previousCrash = "plugin shaders are off for this start";
1195
+ * const file = join(plugin.dataDirectory, "sharpen.glsl");
1196
+ * writeFileSync(file, "//!HOOK MAIN\n");
1197
+ * const shaders = plugin.video.shaders("sharpen");
1198
+ * shaders.set([file]); // refused: the previous run ended abnormally
1199
+ * await host.settle();
1200
+ * assert.equal(shaders.status.state, "failed");
1201
+ * ```
1202
+ *
1203
+ * @since 1.5.54
1204
+ */
1205
+ previousCrash = null;
1206
+ /**
1207
+ * The player's processing (filter) state. Wire-level; may change in minor
1208
+ * versions. `resources` holds each `video.filter` / `audio.filter` chain the
1209
+ * plugin applied, keyed by the SDK's resource id.
1210
+ *
1211
+ * @example Check the filter chain the plugin applied
1212
+ * After the plugin set its picture chain, the test reads the one chain
1213
+ * the host holds and compares its nodes. `resources` keeps each applied
1214
+ * `video.filter` / `audio.filter` chain by the SDK's resource id, after
1215
+ * the host validated it with the player's value ranges.
1216
+ * ```ts
1217
+ * import assert from "node:assert/strict";
1218
+ * declare const host: TestHost;
1219
+ * // ---cut---
1220
+ * const [chain] = [...host.processing.resources.values()];
1221
+ * assert.deepEqual(chain?.nodes, [{ kind: "hue", degrees: 30 }]);
1222
+ * ```
1223
+ */
1224
+ processing = {
1225
+ /** Increases with every accepted change. */
1226
+ configurationRevision: 0,
1227
+ /** Set a stage here to simulate a blocked backend (`backend-uncertain`). */
1228
+ backendUncertain: {},
1229
+ /** Applied chains by resource id; an empty chain removes its entry, as in the player. */
1230
+ resources: new Map(),
1231
+ /** Outcomes of processing operations, for the SDK's recovery queries. */
1232
+ book: new Map(),
1233
+ };
1234
+ /**
1235
+ * The player's subtitle state: tracks, selection, style layers, fonts and
1236
+ * loaded resources. Wire-level shape (may change in minor versions); add
1237
+ * tracks to `subtitles.tracks` (entries may carry `codec`, `text`,
1238
+ * `streamIndex`) to simulate a source's subtitles.
1239
+ */
1240
+ subtitles;
1241
+ /** Subtitle uploads in progress, by upload id. Wire-level. */
1242
+ uploads = new Map();
1243
+ /** Snapshots taken with `media.capture`, by snapshot id. Wire-level. */
1244
+ snapshots = new Map();
1245
+ /** Every `jobs.start` request (`kind`, `destination`, `quality`, …), newest last. */
1246
+ exports = [];
1247
+ /**
1248
+ * Export jobs by job id. Each state query advances a job one step
1249
+ * (`queued` → `running` → `completed`; `cancelling` → `cancelled`).
1250
+ * A running clip reports half its `progress`; a finished clip's
1251
+ * `outcome` has `start` and `end` (a stream copy starts 1 s before the
1252
+ * snapshot time, as if snapped to a keyframe, and its destination gets
1253
+ * the extension of its container: `.mkv` for `container: "mkv"`, else
1254
+ * `.mp4`). Wire-level.
1255
+ */
1256
+ jobs = new Map();
1257
+ /**
1258
+ * Set it to make every running export end `failed` with this error on
1259
+ * its next state query, as the player reports one (`unsupported` for a
1260
+ * stream copy it cannot make, `export-failed` otherwise); `null` (the
1261
+ * default) completes them.
1262
+ *
1263
+ * @since 1.6.10
1264
+ */
1265
+ exportFailure = null;
1266
+ /**
1267
+ * `openStream`'s transport: a local stream (a Unix socket on macOS and
1268
+ * Linux, a named pipe on Windows) or, with `"tcp"`, loopback TCP where the
1269
+ * plugin must send the token first, as the player on Windows answers.
1270
+ * Set it before the plugin calls `openStream`.
1271
+ */
1272
+ producerTransport = "unix";
1273
+ /**
1274
+ * Generated streams (`player.openStream`) by producer id: the fake player
1275
+ * end of each frame connection. Wire-level beyond the
1276
+ * {@link FakeProducer} fields.
1277
+ */
1278
+ producers = new Map();
1279
+ /** Registered source resolvers (`resolvers.add`) by id: their declarations. */
1280
+ resolvers = new Map();
1281
+ /**
1282
+ * Resolvers other plugins hold, as `resolvers.add` declares them plus the
1283
+ * holder's id: a registration of this plugin that claims one of their
1284
+ * extensions (or scheme + host pairs) is refused with `conflict`, which
1285
+ * does not fail activation, like the player.
1286
+ *
1287
+ * @example Start with another plugin holding a file type
1288
+ * The plugin starts in a player where another plugin already resolves
1289
+ * `.ch8` files. Pushed before `startPlugin`, the entry makes the plugin's
1290
+ * own resolver for `.ch8` fail with `conflict` during activation, which
1291
+ * (as in the player) does not fail the activation.
1292
+ * ```ts
1293
+ * const host = new TestHost();
1294
+ * host.otherResolvers.push({ owner: "example.emulator", extensions: ["ch8"] });
1295
+ * ```
1296
+ */
1297
+ otherResolvers = [];
1298
+ /**
1299
+ * What `plugin.resolvers.fileTypes` reports: the extensions of this
1300
+ * plugin's file resolvers and of {@link TestHost.otherResolvers}, sorted,
1301
+ * each once.
1302
+ *
1303
+ * @capability None.
1304
+ *
1305
+ * @example Check the file types a resolver adds
1306
+ * The test checks the list the plugin will read as
1307
+ * `resolvers.fileTypes`: the extensions of its own file resolvers and of
1308
+ * `otherResolvers`, sorted, each once.
1309
+ * ```ts
1310
+ * import assert from "node:assert/strict";
1311
+ * declare const host: TestHost;
1312
+ * // ---cut---
1313
+ * assert.deepEqual(host.fileTypes(), ["ch8"]);
1314
+ * ```
1315
+ *
1316
+ * @since 1.5.8
1317
+ */
1318
+ fileTypes() {
1319
+ const types = new Set();
1320
+ for (const declaration of [
1321
+ ...this.resolvers.values(),
1322
+ ...this.otherResolvers,
1323
+ ])
1324
+ for (const type of declaration.extensions ?? [])
1325
+ types.add(type);
1326
+ return [...types].sort();
1327
+ }
1328
+ /**
1329
+ * Push `resolvers.fileTypes` when {@link TestHost.fileTypes} changed:
1330
+ * called after this plugin's resolvers come and go; call it yourself
1331
+ * after changing {@link TestHost.otherResolvers} (another plugin started
1332
+ * or stopped).
1333
+ *
1334
+ * @example Let another plugin's file type appear while running
1335
+ * Another plugin starts and adds `.ch8`; the plugin under test then sees
1336
+ * it in `resolvers.fileTypes`. Changing `otherResolvers` alone pushes
1337
+ * nothing: `updateFileTypes` sends the new list as the player does when
1338
+ * a plugin starts or stops, and `settle()` lets the SDK apply it.
1339
+ * ```ts
1340
+ * import assert from "node:assert/strict";
1341
+ * declare const host: TestHost;
1342
+ * // ---cut---
1343
+ * host.otherResolvers.push({ owner: "example.emulator", extensions: ["ch8"] });
1344
+ * host.updateFileTypes();
1345
+ * await host.settle();
1346
+ * assert.deepEqual(plugin.resolvers.fileTypes, ["ch8"]);
1347
+ * ```
1348
+ *
1349
+ * @capability None.
1350
+ * @since 1.5.8
1351
+ */
1352
+ updateFileTypes() {
1353
+ const types = this.fileTypes();
1354
+ if (JSON.stringify(types) === this.#inner.sentTypes)
1355
+ return;
1356
+ this.#inner.sentTypes = JSON.stringify(types);
1357
+ this.emit("resolvers.fileTypes", { fileTypes: types });
1358
+ }
1359
+ /**
1360
+ * File opens waiting for this plugin's file resolver (`extensions`), by
1361
+ * request id: the path and how it was opened. Opening a matching path
1362
+ * (`player.open`, {@link TestHost.openElsewhere}) sends
1363
+ * `source.resolveRequest`; the answer selects the stream, fails the open
1364
+ * or, once the source changed, is `stale`. Any later source change
1365
+ * cancels it (`source.resolveCancel`). Web resolvers are not simulated:
1366
+ * emit `source.resolveRequest` yourself.
1367
+ */
1368
+ resolutions = new Map();
1369
+ /**
1370
+ * Answers for `dialogs.open` (paths, `null` = cancelled) and
1371
+ * `dialogs.confirm` (`{ confirmed, checked }`), consumed in order. A
1372
+ * dialog with no queued answer, or with the wrong kind, fails the request
1373
+ * (as a lost reply).
1374
+ *
1375
+ * For `openFile` / `openDirectory` / `saveFile` queue a one-path array;
1376
+ * for `openFiles` any number of paths.
1377
+ *
1378
+ * @example Answer the plugin's dialogs in advance
1379
+ * The plugin's Open command gets a picked file, then a cancelled file
1380
+ * dialog, then a confirmed question. All answers share one queue, taken
1381
+ * from the front in the order the plugin opens dialogs of any kind: if
1382
+ * the next queued answer is missing or of the wrong kind, the dialog
1383
+ * fails like a lost reply, so queue them in the order the plugin asks.
1384
+ * ```ts
1385
+ * declare const host: TestHost;
1386
+ * // ---cut---
1387
+ * host.dialogAnswers.push(["/media/movie.mkv"]); // openFile picks it
1388
+ * host.dialogAnswers.push(null); // the next file dialog is cancelled
1389
+ * host.dialogAnswers.push({ confirmed: true, checked: false }); // confirm
1390
+ * await host.command("open").run();
1391
+ * ```
1392
+ */
1393
+ dialogAnswers = [];
1394
+ /** Every `dialogs.confirm` request (its options), newest last. */
1395
+ confirms = [];
1396
+ /**
1397
+ * The main window as the player would show it. Change it with
1398
+ * `setMainWindow` (the user resized or maximized it, entered fullscreen,
1399
+ * closed the picture-in-picture window); `onTop`, `cursorAutoHide` and
1400
+ * `pictureInPicture.active` record the plugin's `window.set…` calls.
1401
+ * `pictureInPicture.supported: false` stands for Windows and Linux.
1402
+ */
1403
+ mainWindow = {
1404
+ /** Whether the main window is fullscreen. */
1405
+ fullscreen: false,
1406
+ /**
1407
+ * Whether the main window is maximized (default `false`). Like the
1408
+ * player, `window.state` reports `false` while fullscreen and this
1409
+ * value again after leaving it.
1410
+ *
1411
+ * @since 1.5.52
1412
+ */
1413
+ maximized: false,
1414
+ /** The video area in logical points (default 780×609). */
1415
+ videoArea: {
1416
+ /** Width in logical points. */
1417
+ width: 780,
1418
+ /** Height in logical points. */
1419
+ height: 609,
1420
+ },
1421
+ /** The effective `window.setAlwaysOnTop` value. */
1422
+ onTop: false,
1423
+ /** The effective `window.setCursorAutoHide` mode. */
1424
+ cursorAutoHide: "fullscreen",
1425
+ /** Picture-in-picture: `supported` (preset) and `active`. */
1426
+ pictureInPicture: {
1427
+ /** Set to `false` to simulate Windows or Linux. */
1428
+ supported: true,
1429
+ /** Whether the picture-in-picture window shows the video. */
1430
+ active: false,
1431
+ },
1432
+ };
1433
+ /**
1434
+ * The player's secret store for this plugin: name → value. Preset entries
1435
+ * to simulate stored secrets; inject failures with
1436
+ * `host.handle("secrets.get", ...)`. Like the player, `secrets.set` of a
1437
+ * new name fails `limit` once 256 names are stored.
1438
+ */
1439
+ secrets = new Map();
1440
+ /**
1441
+ * `plugin.storage.session`: key → value, as the player keeps them. Preset
1442
+ * entries to simulate values an earlier run of the plugin stored in the
1443
+ * same player session, or pass `startPlugin(..., { sessionStorage })`.
1444
+ * Like the player, a key is 1–1024 bytes, a `set` that would make the
1445
+ * entries larger than 1 MiB (keys, values as compact JSON and
1446
+ * `SESSION_STORAGE_LIMITS.entryBytes` per key) fails `limit`, and an
1447
+ * object's properties are stored sorted, as the player hands them back.
1448
+ *
1449
+ * @since 1.5.25
1450
+ */
1451
+ sessionStorage = new Map();
1452
+ /**
1453
+ * What `plugin.app.system()` reports. Its `rendering` follows
1454
+ * {@link TestHost.videoRender | videoRender.gpu} (`"gpu"` or
1455
+ * `"software"`), so one switch covers the render options and the fact;
1456
+ * its `renderingReason` is {@link TestHost.rendering | rendering.reason}.
1457
+ */
1458
+ systemInfo = {
1459
+ os: "macos",
1460
+ name: "macOS",
1461
+ version: "26.2",
1462
+ build: "25C56",
1463
+ model: "Mac15,6",
1464
+ arch: "aarch64",
1465
+ displays: [
1466
+ {
1467
+ name: "Built-in Retina Display",
1468
+ width: 3024,
1469
+ height: 1964,
1470
+ scale: 2,
1471
+ refreshRate: 120,
1472
+ primary: true,
1473
+ },
1474
+ ],
1475
+ };
1476
+ /**
1477
+ * What `plugin.app` reports. Set it before {@link startPlugin}: the plugin
1478
+ * receives it once at start-up.
1479
+ */
1480
+ app = {
1481
+ version: "0.1.0",
1482
+ build: "test",
1483
+ components: {
1484
+ mpv: "mpv 0.41.0",
1485
+ libav: { ffmpeg: "7.1" },
1486
+ slint: "1.18.1",
1487
+ },
1488
+ };
1489
+ /**
1490
+ * The player's rendering choice behind `plugin.rendering` (player
1491
+ * 0.5.13). The mode drawing now is {@link TestHost.videoRender |
1492
+ * videoRender.gpu}, as for `plugin.app.system()`; the rest is preset here
1493
+ * and changed by the plugin's calls, as the player would:
1494
+ *
1495
+ * - `setPreference` saves `preference` and never changes the mode. It
1496
+ * refuses `"gpu"` while `gpu.available` is false: with `unsupported`
1497
+ * when `gpu.reason.code` is `no-gpu-backend` or `headless`, otherwise
1498
+ * with `unavailable`, the reason's message in both cases.
1499
+ * - The next start uses `"software"` for the `"software"` choice, else
1500
+ * software with `gpu.reason` while the GPU is not available, else the
1501
+ * GPU (code `preference` for a `"gpu"` choice, `default` for
1502
+ * `"auto"`). `restartPending` compares that with `startedWith`.
1503
+ * - `restartPlayer` counts in `restarts` and leaves the plugin running
1504
+ * (call `stop()` yourself); a second one rejects with `busy`, as a
1505
+ * restart is then under way.
1506
+ *
1507
+ * Inject other refusals (`failed` for an unwritable settings file or a
1508
+ * new process that could not start) with `host.handle("rendering.setPreference", …)`
1509
+ * or `host.handle("rendering.restart", …)`. The defaults are a macOS
1510
+ * player on the GPU: `"auto"`, available, started on the GPU.
1511
+ *
1512
+ * @example Test a GPU switch on a system without a GPU renderer
1513
+ * The plugin's GPU menu item is greyed out with the player's reason when
1514
+ * the GPU cannot be chosen. The host is preset before the plugin starts,
1515
+ * because the plugin reads the state while activating; software
1516
+ * rendering is `videoRender.gpu = false`, and `gpu` says why the GPU
1517
+ * cannot be chosen.
1518
+ * ```ts
1519
+ * import assert from "node:assert/strict";
1520
+ * import { startPlugin, TestHost } from "@vestra-player/sdk/testing";
1521
+ * import activate from "../main.ts";
1522
+ *
1523
+ * const host = new TestHost();
1524
+ * host.videoRender.gpu = false; // software draws now (Linux)
1525
+ * host.rendering.startedWith = "software";
1526
+ * host.rendering.reason = { code: "no-gpu-backend", message: "this player has no GPU renderer on Linux yet" };
1527
+ * host.rendering.gpu = { available: false, reason: host.rendering.reason };
1528
+ * const { stop } = await startPlugin(activate, {
1529
+ * directory: new URL("..", import.meta.url),
1530
+ * host,
1531
+ * });
1532
+ * const item = host.command("gpu-rendering");
1533
+ * assert.equal(item.state.enabled, false);
1534
+ * assert.equal(item.state.reason, "this player has no GPU renderer on Linux yet");
1535
+ * await stop();
1536
+ * ```
1537
+ *
1538
+ * @since 1.5.35
1539
+ */
1540
+ rendering = {
1541
+ reason: { code: "default", message: "nothing rules GPU rendering out" },
1542
+ preference: "auto",
1543
+ startedWith: "gpu",
1544
+ gpu: { available: true, reason: null },
1545
+ restarts: 0,
1546
+ };
1547
+ /**
1548
+ * The GPU's video upscaler behind `plugin.video.upscaling` (player
1549
+ * 0.5.16), answered as the player would:
1550
+ *
1551
+ * - `state()` and `set(true)` set `checked` (the player's one GPU check; a
1552
+ * plugin that should not wake the GPU while it starts can assert it is
1553
+ * still `false`) and report `available` from `reason` (`null`: it
1554
+ * works), this plugin's `enabled`, and `requested` with `factor` while
1555
+ * enabled, available and `factor` is above 1.
1556
+ * - `set(true)` refuses with `unsupported` and the reason's message while
1557
+ * `reason` is set, unless its code is `decoding`, or `rendering` with
1558
+ * `renderingStarting` (GPU rendering still starting): those can pass
1559
+ * during the run, so the player keeps the request. Nothing changes when
1560
+ * it refuses.
1561
+ * - `set(false)` always succeeds and does not check the GPU: before any
1562
+ * check it reports `available: false` with the reason `unchecked`.
1563
+ *
1564
+ * Inject other answers with `host.handle("video.upscaling.set", …)`. The
1565
+ * defaults are a player where upscaling works and a 1080p video shown
1566
+ * full screen on a 4K display (`factor` 2), whatever `systemInfo` says.
1567
+ *
1568
+ * @example Test the menu item on a computer without an RTX GPU
1569
+ * The plugin's upscaling item is greyed out with the player's reason after
1570
+ * the user first picks it. Starting the plugin checks nothing (`checked`
1571
+ * stays false, so no GPU wakes up); the first pick reads the state, and
1572
+ * `set(true)` would reject with `unsupported`, as in the player.
1573
+ * ```ts
1574
+ * import assert from "node:assert/strict";
1575
+ * import { startPlugin, TestHost } from "@vestra-player/sdk/testing";
1576
+ * import activate from "../main.ts";
1577
+ *
1578
+ * const host = new TestHost();
1579
+ * host.upscaling.reason = { code: "no-gpu", message: "no NVIDIA GPU" };
1580
+ * const { stop } = await startPlugin(activate, {
1581
+ * directory: new URL("..", import.meta.url),
1582
+ * host,
1583
+ * });
1584
+ * assert.equal(host.upscaling.checked, false); // starting woke no GPU
1585
+ * const item = host.command("upscaling");
1586
+ * await item.run(); // the first pick checks the GPU
1587
+ * assert.equal(item.state.enabled, false); // greyed out
1588
+ * assert.equal(item.state.reason, "no NVIDIA GPU"); // the player's message
1589
+ * await stop();
1590
+ * ```
1591
+ *
1592
+ * @since 1.5.37
1593
+ */
1594
+ upscaling = {
1595
+ reason: null,
1596
+ enabled: false,
1597
+ factor: 2,
1598
+ checked: false,
1599
+ renderingStarting: false,
1600
+ video: {
1601
+ width: 1920,
1602
+ height: 1080,
1603
+ displayWidth: 1920,
1604
+ displayHeight: 1080,
1605
+ },
1606
+ area: { width: 3840, height: 2160 },
1607
+ gpu: "NVIDIA GeForce RTX 4070 Laptop GPU",
1608
+ hwdecCurrent: "d3d11va",
1609
+ };
1610
+ /**
1611
+ * Make the upscaler's reason `decoding` with `cause`, with the player's
1612
+ * message (player 0.5.25), and set `upscaling.hwdecCurrent` to match
1613
+ * (`d3d11va-copy`, `no`, `nvdec`). `plugins` are named for `copy-back`
1614
+ * only, as the player does. Returns the reason; call
1615
+ * {@link TestHost.upscalingChanged} to tell a listening plugin.
1616
+ *
1617
+ * @example Test the window's advice for each cause
1618
+ * The plugin's window explains why upscaling waits. The test fakes the
1619
+ * copy-back cause that Picture Adjustments' filters cause and checks
1620
+ * that the window names the plugin.
1621
+ * ```ts
1622
+ * import assert from "node:assert/strict";
1623
+ * declare const host: TestHost;
1624
+ * // ---cut---
1625
+ * host.upscalingDecoding("copy-back", [{ id: "vestra.video", name: "Picture Adjustments" }]);
1626
+ * host.upscalingChanged();
1627
+ * await host.settle();
1628
+ * assert.match(String(host.window("upscale").props.blocker), /Picture Adjustments/);
1629
+ * ```
1630
+ *
1631
+ * @capability None.
1632
+ * @since 1.5.43
1633
+ */
1634
+ upscalingDecoding(cause, plugins = []) {
1635
+ return upscalingDecoding(this, this.#inner, cause, plugins);
1636
+ }
1637
+ /**
1638
+ * Send `video.upscaling.changed` with the state {@link TestHost.upscaling}
1639
+ * describes now (without `enabled`), as the player does when it changes
1640
+ * (player 0.5.25); a plugin listening with
1641
+ * `plugin.video.upscaling.onChange` receives it. Nothing reaches a plugin
1642
+ * that does not listen.
1643
+ *
1644
+ * @capability None.
1645
+ *
1646
+ * @example Show the new factor in the plugin's window
1647
+ * The window resized: the test changes the area and the factor, sends
1648
+ * the change, and checks that the plugin's window shows the new factor.
1649
+ * ```ts
1650
+ * import assert from "node:assert/strict";
1651
+ * declare const host: TestHost;
1652
+ * // ---cut---
1653
+ * host.upscaling.area = { width: 2560, height: 1440 };
1654
+ * host.upscaling.factor = 1.33; // 1080p in a 1440p area
1655
+ * host.upscalingChanged(); // as the player after the resize settled
1656
+ * await host.settle();
1657
+ * assert.equal(host.window("upscale").props.factor, "×1.33");
1658
+ * ```
1659
+ *
1660
+ * @since 1.5.43
1661
+ */
1662
+ upscalingChanged() {
1663
+ if (!this.subscriptions.has("video.upscaling.changed"))
1664
+ return;
1665
+ const { enabled: _, ...state } = upscalingState(this, this.#inner, false);
1666
+ this.emit("video.upscaling.changed", state);
1667
+ }
1668
+ /** What `player.chapters()` lists while media is ready. */
1669
+ chapters = [];
1670
+ /**
1671
+ * @internal `startPlugin`'s `disconnect()`: refuse every request, the
1672
+ * ones in flight included, as the runner does once the player is gone.
1673
+ */
1674
+ loseConnection() {
1675
+ this.#inner.lost = true;
1676
+ this.#inner.lose(new HostError("disabled", "plugin stopped"));
1677
+ }
1678
+ /**
1679
+ * `plugin.dataDirectory`: a fresh empty temporary directory, removed with
1680
+ * the host on `stop()`. `plugin.storage.json` reads and writes real files
1681
+ * here. Pass `startPlugin(..., { dataDirectory })` to use another one.
1682
+ */
1683
+ dataDirectory = join(this.#inner.scratch, "data");
1684
+ /**
1685
+ * The manifest id of the plugin under test (set by {@link startPlugin};
1686
+ * `"test.plugin"` by default).
1687
+ */
1688
+ manifestId = "test.plugin";
1689
+ /**
1690
+ * A fresh in-memory player: nothing open, no plugin, the player's core
1691
+ * actions registered. Preset what the plugin reads during activation, then
1692
+ * pass it to {@link startPlugin} as `host`.
1693
+ */
1694
+ constructor() {
1695
+ this.subtitles = {
1696
+ presentation: {
1697
+ revision: 1,
1698
+ referenceHeight: 1080,
1699
+ layers: [],
1700
+ effective: null,
1701
+ confirmation: "applied",
1702
+ fonts: [
1703
+ {
1704
+ id: "sans",
1705
+ family: "Sans",
1706
+ resolution: "fixedFace",
1707
+ snapshotEligible: true,
1708
+ digest: "0",
1709
+ faceIndex: 0,
1710
+ },
1711
+ ],
1712
+ actualFont: {
1713
+ fontId: "sans",
1714
+ family: "Sans",
1715
+ postscript: "Sans",
1716
+ faceIndex: 0,
1717
+ digest: "0",
1718
+ sizeBytes: 0,
1719
+ },
1720
+ snapshotEligible: true,
1721
+ coverageCheckedOnCapture: true,
1722
+ },
1723
+ source: { generation: 0, phase: "empty" },
1724
+ confirmation: "applied",
1725
+ error: null,
1726
+ selection: { stamp: { generation: 0, revision: 0 }, target: null },
1727
+ tracks: [],
1728
+ resources: [],
1729
+ };
1730
+ // The player always owns these, as `host/<id>` commands.
1731
+ registerCoreActions(this, this.#inner);
1732
+ }
1733
+ // ------------------------------------------------------------ core actions
1734
+ /** The `player.setStepSeek` value in effect (default 5). */
1735
+ stepSeek = 5;
1736
+ /** The `player.setStepSeek` `keyframes` in effect (default `false`, SDK 1.5.19). */
1737
+ stepSeekKeyframes = false;
1738
+ /**
1739
+ * Test data: the keyframe times (seconds, ascending) of the current
1740
+ * source, for keyframe step seeks. `null` (the default): none modelled,
1741
+ * and step seeks are exact even with `stepSeekKeyframes`.
1742
+ *
1743
+ * @example Step forward to the next keyframe
1744
+ * With keyframe step seeks on, Forward from 0:12 lands on the keyframe at
1745
+ * 0:20, not at 0:17, and the OSD says so. `keyframes` gives the source's
1746
+ * keyframe times; `setStepSeek` turns on keyframe steps of 5 s, and the
1747
+ * core action `seek-forward` seeks to the first keyframe at or after
1748
+ * the target, as the player does.
1749
+ * ```ts
1750
+ * import assert from "node:assert/strict";
1751
+ * declare const host: TestHost;
1752
+ * // ---cut---
1753
+ * host.setSource({ phase: "ready", duration: 45, position: 12 });
1754
+ * host.keyframes = [0, 10, 20, 30, 40];
1755
+ * await plugin.player.setStepSeek({ seconds: 5, keyframes: true });
1756
+ * await plugin.host.run("seek-forward"); // target 17 s, next keyframe 20 s
1757
+ * assert.equal(host.source.position, 20);
1758
+ * assert.equal(host.osd.at(-1), "Forward 5 s: 00:20 / 00:45");
1759
+ * ```
1760
+ */
1761
+ keyframes = null;
1762
+ /**
1763
+ * Every step seek the core ran (`seek-backward` / `seek-forward`, from a
1764
+ * key, the menu or `host.run`), newest last: the step, whether it went to
1765
+ * a keyframe, and where it landed.
1766
+ */
1767
+ coreSeeks = [];
1768
+ /**
1769
+ * Every seek a plugin submitted (`player.seek`, `Scrub.seek`), newest
1770
+ * last, with the mode it asked for. The test host lands on the exact
1771
+ * target either way (the player lands a `keyframes` seek on the last
1772
+ * keyframe at or before it); the core's step seeks are in `coreSeeks`.
1773
+ */
1774
+ seeks = [];
1775
+ /** Every `player.step`, newest last. */
1776
+ steps = [];
1777
+ /** Every core action run (`host.run`, a core command, a pressed core binding), newest last. */
1778
+ coreRuns = [];
1779
+ /**
1780
+ * One of the 14 core actions (`play-pause`, `stop`, `seek-forward`, ...)
1781
+ * that the test host registers like the player, as `host/<id>`. Throws for
1782
+ * an unknown id. `run()` executes it like its menu item: a disabled
1783
+ * action rejects with `command`, as in the player (`plugin.host.run`
1784
+ * rejects with `unavailable` instead); otherwise it runs with the
1785
+ * player's checks and writes its on-screen text to {@link TestHost.osd}.
1786
+ *
1787
+ * @example Pause through the player's own action
1788
+ * The test pauses playback the way the Space key does and checks that the
1789
+ * host recorded it. Core actions belong to the player (`host/<id>`);
1790
+ * `run()` applies the player's checks, so a source must be ready first,
1791
+ * and every run is appended to `coreRuns`.
1792
+ * ```ts
1793
+ * import assert from "node:assert/strict";
1794
+ * declare const host: TestHost;
1795
+ * // ---cut---
1796
+ * host.setSource({ phase: "ready", duration: 100, position: 10 });
1797
+ * await host.coreAction("play-pause").run();
1798
+ * assert.equal(host.source.paused, true);
1799
+ * assert.deepEqual(host.coreRuns, ["play-pause"]);
1800
+ * ```
1801
+ *
1802
+ * @capability None.
1803
+ * @since 1.0.0
1804
+ */
1805
+ coreAction(id) {
1806
+ const command = this.coreCommands.get(`host/${id}`);
1807
+ if (!command)
1808
+ throw new Error(`no core action ${id}`);
1809
+ return command;
1810
+ }
1811
+ // ------------------------------------------------------------ transport
1812
+ /**
1813
+ * @internal The transport side the SDK calls: answer one request with the
1814
+ * override from {@link TestHost.handle} or the default behaviour.
1815
+ */
1816
+ async request(method, params = {}) {
1817
+ // After `disconnect()`: refused as the runner refuses them once the
1818
+ // player is gone.
1819
+ if (this.#inner.lost)
1820
+ throw new HostError("disabled", "plugin is stopping");
1821
+ const record = (params ?? {});
1822
+ // The plugin runner refuses a request over the message limit before
1823
+ // sending it (runtime/runner.ts): the player never sees it.
1824
+ const line = JSON.stringify({ id: this.calls.length + 1, method, params });
1825
+ if (Buffer.byteLength(line, "utf8") + 1 > MESSAGE_LIMIT)
1826
+ throw new HostError("limit", `${method}: request exceeds the player's 1 MiB message limit`);
1827
+ this.calls.push({ method, params: record });
1828
+ this.#inner.inFlight++;
1829
+ try {
1830
+ await tick();
1831
+ if (this.#inner.lost)
1832
+ throw new HostError("disabled", "plugin stopped");
1833
+ const handler = this.#inner.overrides.get(method) ?? this.#defaults[method];
1834
+ if (!handler)
1835
+ throw new HostError("unsupported", `test host has no ${method}`);
1836
+ const work = Promise.resolve(handler(record));
1837
+ // `disconnect()` fails the requests still waiting, as the runner does.
1838
+ work.catch(() => { });
1839
+ return (await Promise.race([work, this.#inner.gone]));
1840
+ }
1841
+ finally {
1842
+ this.#inner.inFlight--;
1843
+ }
1844
+ }
1845
+ /** @internal The transport side the SDK calls: listen to a host event. */
1846
+ on(event, listener) {
1847
+ return on(this, this.#inner, event, listener);
1848
+ }
1849
+ // ------------------------------------------------------------ test controls
1850
+ /**
1851
+ * Replace the host's behaviour for one wire method until
1852
+ * {@link TestHost.reset}. The handler receives the request parameters and
1853
+ * returns the reply (or a promise of it).
1854
+ *
1855
+ * Throw a {@link HostError} to simulate a definite refusal (the plugin's
1856
+ * call rejects with that `code`); throw a plain `Error` to simulate a lost
1857
+ * reply (the SDK then runs its recovery path). Method names are protocol
1858
+ * names, e.g. `"clipboard.writeText"`, `"secrets.get"`,
1859
+ * `"system.openPath"`, `"audio.addTrack"`; see {@link TestHost.sent} to
1860
+ * find the one a call uses.
1861
+ *
1862
+ * @param method - The wire method name.
1863
+ * @param handler - Produces the reply from the request parameters.
1864
+ *
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.
1871
+ * ```ts
1872
+ * import assert from "node:assert/strict";
1873
+ * declare const host: TestHost;
1874
+ * // ---cut---
1875
+ * host.handle("system.openPath", () => {
1876
+ * throw new HostError("missing", "no such file");
1877
+ * });
1878
+ * await host.command("open-folder").run(); // the plugin catches `missing`
1879
+ * assert.deepEqual(host.osd, ["Folder not found"]);
1880
+ * host.reset("system.openPath");
1881
+ * ```
1882
+ *
1883
+ * @capability None.
1884
+ * @since 1.0.0
1885
+ */
1886
+ handle(method, handler) {
1887
+ this.#inner.overrides.set(method, handler);
1888
+ }
1889
+ /**
1890
+ * Restore the default behaviour of a method replaced with {@link TestHost.handle}.
1891
+ *
1892
+ * @capability None.
1893
+ *
1894
+ * @example Restore the default answer after a simulated failure
1895
+ * The first write fails, the second succeeds. `reset` removes the handler
1896
+ * that `handle` installed, so the method is answered by the test host
1897
+ * again.
1898
+ * ```ts
1899
+ * import assert from "node:assert/strict";
1900
+ * declare const host: TestHost;
1901
+ * // ---cut---
1902
+ * host.handle("clipboard.writeText", () => {
1903
+ * throw new HostError("failed", "denied");
1904
+ * });
1905
+ * await assert.rejects(plugin.clipboard.writeText("x"));
1906
+ * host.reset("clipboard.writeText");
1907
+ * await plugin.clipboard.writeText("x"); // the default handler again
1908
+ * ```
1909
+ *
1910
+ * @since 1.0.0
1911
+ */
1912
+ reset(method) {
1913
+ this.#inner.overrides.delete(method);
1914
+ }
1915
+ /**
1916
+ * Deliver a host event to the plugin. Like the player, subscription-based
1917
+ * events (`source.state`, `playback.backend`, `window.state`,
1918
+ * `timeline.changed`, `appearance.changed`) are delivered only when the
1919
+ * plugin subscribed (it declared `events` and listens). Most tests use the
1920
+ * higher-level helpers (`setSource`, `setMainWindow`, ...) instead.
1921
+ *
1922
+ * @param event - The wire event name.
1923
+ * @param data - The event payload (wire-level shape).
1924
+ *
1925
+ * @capability None.
1926
+ *
1927
+ * @example Deliver a raw event the helpers do not cover
1928
+ * The test pushes a `window.state` event after changing `layout` by hand.
1929
+ * Like the player, the host delivers it only when the plugin subscribed;
1930
+ * most tests use `setSource`, `setMainWindow` and the other helpers
1931
+ * instead.
1932
+ * ```ts
1933
+ * declare const host: TestHost;
1934
+ * // ---cut---
1935
+ * host.emit("window.state", {}); // no facts: the SDK reads window.state again
1936
+ * await host.settle();
1937
+ * ```
1938
+ *
1939
+ * @since 1.0.0
1940
+ */
1941
+ emit(event, data) {
1942
+ return emit(this, this.#inner, event, data);
1943
+ }
1944
+ /**
1945
+ * Change the source facts and broadcast them, as if the player's state
1946
+ * changed (the user paused, playback reached the end, a file finished
1947
+ * loading). Any phase other than `empty` also turns on the source
1948
+ * capabilities (pause, seek, rate, ...) unless the patch sets them.
1949
+ * `phase: "loading"` makes a mounted backdrop effective, like the player.
1950
+ *
1951
+ * @param patch - Wire-level source fields, e.g. `phase`, `position`,
1952
+ * `duration`, `paused`, `audioTracks`.
1953
+ *
1954
+ * @example Load a file, then let it play to the end
1955
+ * The plugin sees a one-minute source become ready, then end on its own.
1956
+ * `setSource` changes the host's source facts and broadcasts them like
1957
+ * the player's events; the SDK updates `player.state` when the event
1958
+ * arrives, so the test settles before reading it.
1959
+ * ```ts
1960
+ * import assert from "node:assert/strict";
1961
+ * declare const host: TestHost;
1962
+ * // ---cut---
1963
+ * host.setSource({ phase: "ready", duration: 60, position: 0 });
1964
+ * await host.settle();
1965
+ * assert.equal(plugin.player.state.phase, "ready");
1966
+ * host.setSource({ phase: "ended", terminalReason: "natural" });
1967
+ * await host.settle();
1968
+ * ```
1969
+ *
1970
+ * @capability None.
1971
+ * @since 1.0.0
1972
+ */
1973
+ setSource(patch) {
1974
+ return setSource(this, this.#inner, patch);
1975
+ }
1976
+ /**
1977
+ * Simulate the user changing the main window (resizing, maximizing or
1978
+ * restoring it, entering or leaving fullscreen, closing the
1979
+ * picture-in-picture window) and broadcast it. `maximized` (SDK 1.5.52)
1980
+ * reads `false` while fullscreen, as player 0.6.3 reports it. Like the player, entering fullscreen collapses an attached, visible
1981
+ * sidebar (`effective: false`, `reason: "fullscreen"`) and leaving
1982
+ * fullscreen docks it again.
1983
+ *
1984
+ * @example Go fullscreen, report picture-in-picture support, maximize
1985
+ * The plugin sees the main window fullscreen at 1920×1080, then learns
1986
+ * that picture-in-picture is available, then sees the window leave
1987
+ * fullscreen maximized. `setMainWindow` changes the
1988
+ * host's window facts and broadcasts them as the player does when the
1989
+ * user resizes or toggles fullscreen; entering fullscreen also collapses
1990
+ * an attached sidebar.
1991
+ * ```ts
1992
+ * import assert from "node:assert/strict";
1993
+ * declare const host: TestHost;
1994
+ * // ---cut---
1995
+ * host.setMainWindow({ fullscreen: true, videoArea: { width: 1920, height: 1080 } });
1996
+ * await host.settle();
1997
+ * assert.equal(plugin.window.state.fullscreen, true);
1998
+ * host.setMainWindow({ pictureInPicture: { supported: true, active: false } });
1999
+ * // Leaving fullscreen into a maximized window: `maximized` reads true again.
2000
+ * host.setMainWindow({ fullscreen: false, maximized: true });
2001
+ * await host.settle();
2002
+ * assert.equal(plugin.window.state.maximized, true);
2003
+ * ```
2004
+ *
2005
+ * @capability None.
2006
+ * @since 1.0.0
2007
+ */
2008
+ setMainWindow(patch) {
2009
+ return setMainWindow(this, this.#inner, patch);
2010
+ }
2011
+ /**
2012
+ * The pointer reached the right edge in fullscreen (`true`) or left the
2013
+ * popped-out columns (`false`): the attached sidebar and the adjust column
2014
+ * (SDK 1.6.13), those switched on, pop out or collapse (reference §6.3). No
2015
+ * effect outside fullscreen, or on a column switched off or floating.
2016
+ *
2017
+ * @capability None.
2018
+ *
2019
+ * @example Pop the sidebar out in fullscreen
2020
+ * The pointer reaches the right edge in fullscreen and the attached
2021
+ * sidebar pops out over the video. Outside fullscreen, or with the
2022
+ * sidebar hidden or floating, nothing happens.
2023
+ * ```ts
2024
+ * declare const host: TestHost;
2025
+ * // ---cut---
2026
+ * host.setMainWindow({ fullscreen: true });
2027
+ * host.popSidebar(true);
2028
+ * await host.settle();
2029
+ * ```
2030
+ *
2031
+ * @since 1.0.0
2032
+ */
2033
+ popSidebar(popped) {
2034
+ if (!this.mainWindow.fullscreen)
2035
+ return;
2036
+ for (const column of [this.layout.sidebar, this.layout.adjust]) {
2037
+ if (!column?.preferred || column.detached)
2038
+ continue;
2039
+ column.effective = popped;
2040
+ column.reason = popped ? null : "fullscreen";
2041
+ }
2042
+ this.emit("window.state", windowFacts(this, this.#inner));
2043
+ }
2044
+ /**
2045
+ * Simulate another plugin (or the user) opening a source: a new
2046
+ * `sourceId`, then the given phase. Calls the plugin made against the old
2047
+ * state now fail with `stale`.
2048
+ *
2049
+ * @param source - The path or URL.
2050
+ * @param phase - The phase to end in (default `"ready"`).
2051
+ *
2052
+ * @example Let the user open another file meanwhile
2053
+ * A state the plugin captured before stops being current once another
2054
+ * file opens. `openElsewhere` gives the source a new `sourceId`, so
2055
+ * `isCurrent` turns false. Promise-returning calls made with
2056
+ * `ifCurrent: before` then reject with `stale`; calls that return nothing
2057
+ * drop the write silently instead (`TimelineLayer.set` with `ifCurrent`).
2058
+ * ```ts
2059
+ * import assert from "node:assert/strict";
2060
+ * declare const host: TestHost;
2061
+ * // ---cut---
2062
+ * const before = plugin.player.state;
2063
+ * host.openElsewhere("/media/other.mp4");
2064
+ * await host.settle();
2065
+ * assert.equal(plugin.player.isCurrent(before), false);
2066
+ * ```
2067
+ *
2068
+ * @capability None.
2069
+ * @since 1.0.0
2070
+ */
2071
+ openElsewhere(source, phase = "ready") {
2072
+ sourceIntent(this, this.#inner, {
2073
+ kind: "open",
2074
+ source,
2075
+ startPaused: false,
2076
+ });
2077
+ // A file this plugin's file resolver claims waits for its answer.
2078
+ if (startResolution(this, this.#inner, source, false) === null)
2079
+ this.setSource({ phase });
2080
+ }
2081
+ /**
2082
+ * Simulate a generated stream failing in the player (its audio device
2083
+ * lost, its frame connection broken), like the player: the source fails
2084
+ * with `message` (`phase: "error"`) and the producer is retired at once.
2085
+ * The stream's `onStop` listeners get a `stream` error with `message`.
2086
+ *
2087
+ * @param producerId - The producer, from {@link TestHost.producers}.
2088
+ * @param message - The player's failure text.
2089
+ *
2090
+ * @example Break the plugin's generated stream
2091
+ * The plugin's stream stops with an error, as when the player loses its
2092
+ * audio device. `failStream` puts the source into `error` with the
2093
+ * message and retires the producer at once; the stream's `onStop`
2094
+ * listeners get a `stream` error carrying the message.
2095
+ * ```ts
2096
+ * declare const host: TestHost;
2097
+ * // ---cut---
2098
+ * const [producer] = host.producers.values();
2099
+ * host.failStream(producer!.id, "producer transport closed");
2100
+ * ```
2101
+ *
2102
+ * @capability None.
2103
+ * @since 1.5.7
2104
+ */
2105
+ failStream(producerId, message) {
2106
+ return failStream(this, this.#inner, producerId, message);
2107
+ }
2108
+ /**
2109
+ * Press a binding like the host's table would: panel-focus bindings first
2110
+ * when `panel` is true, then global ones; within a scope the earliest
2111
+ * registration first (the player's own commands before the plugin's), as
2112
+ * the conflict rule orders them. A disabled command lets the key pass to
2113
+ * the next command bound to it (SDK 1.5.12, as player 0.4.3): the one it
2114
+ * shadowed, then the global scope. The first enabled one runs; `press`
2115
+ * resolves after it ran (`"ran"`). When only disabled commands hold the
2116
+ * key, the first of them decides, as in the player: with a `reason` it
2117
+ * shows the reason on the OSD and eats the key (`"reason"`, see
2118
+ * `host.osd`); without one the key goes on to the focused control
2119
+ * (`"passed"`, nothing runs). Unlike the player, which only logs a key
2120
+ * that reaches a busy command, `press` then rejects, so a test notices. Rejects when nothing is bound. Bindings use the
2121
+ * reference syntax (`"Space"`, `"Ctrl+O"`, `"Mouse:WheelUp"`), and user
2122
+ * overrides in `bindings.overlay` apply.
2123
+ *
2124
+ * @returns What the key did: `"ran"`, `"reason"` or `"passed"`.
2125
+ *
2126
+ * @example Press a global key and a panel key
2127
+ * Ctrl+B runs whatever is bound globally; Delete is pressed while the
2128
+ * plugin's panel has focus, so panel bindings are tried first. `press`
2129
+ * follows the player's binding table and resolves with what happened:
2130
+ * `"ran"`, `"reason"` (a disabled command showed why) or `"passed"`.
2131
+ * ```ts
2132
+ * declare const host: TestHost;
2133
+ * // ---cut---
2134
+ * await host.press("Ctrl+B"); // a global binding
2135
+ * await host.press("Delete", { panel: true }); // while the panel has focus
2136
+ * ```
2137
+ *
2138
+ * @capability None.
2139
+ * @throws `command` (rejects) A `HostError`: the command that would run is
2140
+ * busy (the player only logs this).
2141
+ * @throws `Error` (rejects) Nothing is bound to the key.
2142
+ * @since 1.0.0
2143
+ */
2144
+ press(binding, options = {}) {
2145
+ return press(this, this.#inner, binding, options);
2146
+ }
2147
+ /**
2148
+ * Bindings declared by more than one command, each as
2149
+ * `[binding, winner, ...ignored]` (command ids; core actions as
2150
+ * `host/<id>`). Empty when there are none.
2151
+ *
2152
+ * @example Check that no default keys collide
2153
+ * The test fails when two commands, the player's or the plugin's, declare
2154
+ * the same binding. Each entry is `[binding, winner, ...ignored]`, so an
2155
+ * empty list means every key does what its command says.
2156
+ * ```ts
2157
+ * import assert from "node:assert/strict";
2158
+ * declare const host: TestHost;
2159
+ * // ---cut---
2160
+ * assert.deepEqual(host.conflicts, []);
2161
+ * ```
2162
+ */
2163
+ get conflicts() {
2164
+ return [...bindingClaims(this, this.#inner)]
2165
+ .filter(([, ids]) => ids.length > 1)
2166
+ .map(([key, ids]) => [key.split(" ")[1], ...ids]);
2167
+ }
2168
+ /**
2169
+ * Change the keys of one of the plugin's commands, or of one of the
2170
+ * player's core commands by its `host/<id>` (SDK 1.6.16), as the user
2171
+ * does in Keyboard Shortcuts (SDK 1.6.6): `inputs` replaces its keys (`[]`
2172
+ * clears them, as Ignore Default Shortcuts does for every default),
2173
+ * `null` gives it its own defaults back. It writes
2174
+ * {@link TestHost.bindings | bindings.overlay}, and the plugin hears it
2175
+ * as from the player: `bindings.changed` and, when what
2176
+ * `plugin.commands.keys` reads changed, `commands.keys`.
2177
+ *
2178
+ * @example Check that a key hint follows a rebinding
2179
+ * The view showed W for Brightness −; after the user moves it to
2180
+ * Shift+W, it shows the new key. `rebind` changes the overlay and
2181
+ * pushes the new labels, and `settle` lets the binding run again. The
2182
+ * test host is a Mac unless `systemInfo.os` says otherwise, hence ⇧W.
2183
+ * ```ts
2184
+ * import assert from "node:assert/strict";
2185
+ * declare const host: TestHost;
2186
+ * // ---cut---
2187
+ * host.rebind("brightness.down", ["Shift+W"]);
2188
+ * await host.settle();
2189
+ * assert.equal(host.window("picture").props["brightness-down"], "⇧W");
2190
+ * ```
2191
+ *
2192
+ * @capability None.
2193
+ * @throws `Error` `command` is neither a command of the plugin nor a
2194
+ * core command.
2195
+ * @since 1.6.6
2196
+ */
2197
+ rebind(command, inputs) {
2198
+ if (!this.commands.has(command) && !this.coreCommands.has(command))
2199
+ throw new Error(`test host: no command ${command}`);
2200
+ const key = bindingKey(this, this.#inner, command);
2201
+ if (inputs === null)
2202
+ this.bindings.overlay.delete(key);
2203
+ else
2204
+ this.bindings.overlay.set(key, inputs.map(canonical));
2205
+ this.emit("bindings.changed", null);
2206
+ keysChanged(this, this.#inner);
2207
+ }
2208
+ /**
2209
+ * The binding overlay `plugin.bindings.set` writes (`"<plugin>/<command>"`
2210
+ * to inputs; preset it to simulate saved overrides). `press` honours it.
2211
+ */
2212
+ bindings = {
2213
+ /** `"<plugin>/<command>"` → the user's inputs for it. */
2214
+ overlay: new Map(),
2215
+ };
2216
+ /**
2217
+ * What `packages.list` reports; `setEnabled`, `uninstall` and `install`
2218
+ * update it. `builtin`, `source` and `dataDirectory` are optional here
2219
+ * (default `false`, `null`, `null`).
2220
+ */
2221
+ packages = [];
2222
+ /** Every `packages.setEnabled` / `uninstall` / `install`, newest last. */
2223
+ packageCalls = [];
2224
+ /**
2225
+ * Packages `validate` and `validateArchive` accept: path (directory or
2226
+ * `.vxp` file) → its manifest. Other paths fail with `missing`. `install`
2227
+ * adds or upgrades the entry in {@link TestHost.packages} following the
2228
+ * player's source rules: the same source upgrades, another source needs
2229
+ * `replace` (`conflict` otherwise), a built-in id is `unsupported`. As
2230
+ * player 0.6.4 and later, the installed entry's `state` follows
2231
+ * {@link "@vestra-player/sdk"!InstallOptions.enabled}: a first install
2232
+ * runs, an update of a `disabled` entry stays `disabled` unless `enabled`
2233
+ * is `true`, and `enabled: false` installs it `disabled`. Set
2234
+ * {@link TestHost.installKeepsDisabled} to `false` for the older players'
2235
+ * rule (every install runs).
2236
+ *
2237
+ * @example Offer a package file the plugin can install
2238
+ * The plugin can validate and install `/downloads/someone.demo-1.0.0.vxp`
2239
+ * as if the file existed. The host accepts only paths listed here (others
2240
+ * fail with `missing`) and returns this manifest from `validate`; an
2241
+ * install then follows the player's source rules.
2242
+ * ```ts
2243
+ * declare const host: TestHost;
2244
+ * // ---cut---
2245
+ * host.candidates.set("/downloads/someone.demo-1.0.0.vxp", {
2246
+ * id: "someone.demo", version: "1.0.0", apiVersion: "vestra/1",
2247
+ * entry: "main.js", capabilities: [],
2248
+ * });
2249
+ * ```
2250
+ */
2251
+ candidates = new Map();
2252
+ /**
2253
+ * Which player's install rule `packages.install` plays. `true` (default,
2254
+ * SDK 1.5.55): player 0.6.4 and later, where an update or reinstall
2255
+ * keeps a `disabled` plugin disabled unless the plugin passes
2256
+ * {@link "@vestra-player/sdk"!InstallOptions.enabled}. `false`: players
2257
+ * up to 0.6.3, where every install runs the plugin and `enabled` is
2258
+ * ignored (it is still checked and recorded in
2259
+ * {@link TestHost.packageCalls}). A test written for the older rule that
2260
+ * updates a disabled plugin sets it to `false`; one that keeps testing
2261
+ * both runs once with each.
2262
+ *
2263
+ * @example Keep a test written for players up to 0.6.3
2264
+ * The plugin under test installs an update over a plugin the user
2265
+ * disabled and expects it to run afterwards, as players up to 0.6.3 do.
2266
+ * With `false` the host plays that rule; the default plays player 0.6.4,
2267
+ * where the update stays disabled.
2268
+ * ```ts
2269
+ * declare const host: TestHost;
2270
+ * // ---cut---
2271
+ * host.installKeepsDisabled = false; // every install runs, as before 0.6.4
2272
+ * ```
2273
+ *
2274
+ * @capability None.
2275
+ * @since 1.5.55
2276
+ */
2277
+ installKeepsDisabled = true;
2278
+ /**
2279
+ * Remote images (`plugin.ui.markdown`, `plugin.ui.remoteImage`): address
2280
+ * → a local PNG or JPEG file served for it, or `null` for a failing
2281
+ * download. Nothing is fetched from the network in tests; unknown
2282
+ * addresses fail.
2283
+ */
2284
+ remoteImages = new Map();
2285
+ /**
2286
+ * The Markdown document a value holds (its wire form read back), or
2287
+ * `null` when the value is not one. `images` maps each remote address to
2288
+ * the downloaded file. Pass a whole property value or a field of one: a
2289
+ * document nested in rows is read the same way.
2290
+ *
2291
+ * @example Read back a Markdown message in a chat window
2292
+ * The test checks the Markdown source of the first chat message. Rows
2293
+ * hold the document in its wire form, as `plugin.ui.markdown` produced
2294
+ * it; `markdown` reads it back, nested in a row or not.
2295
+ * ```ts
2296
+ * import assert from "node:assert/strict";
2297
+ * declare const host: TestHost;
2298
+ * // ---cut---
2299
+ * const rows = host.window("chat").props["messages"] as { body: Json }[];
2300
+ * assert.equal(host.markdown(rows[0]!.body)?.text, "**Hello**");
2301
+ * ```
2302
+ *
2303
+ * @capability None.
2304
+ * @since 1.1.0
2305
+ */
2306
+ markdown(value) {
2307
+ const doc = value
2308
+ ?.$vestraMarkdown;
2309
+ return doc && typeof doc === "object" && typeof doc.text === "string"
2310
+ ? { text: doc.text, base: doc.base, images: { ...doc.images } }
2311
+ : null;
2312
+ }
2313
+ /**
2314
+ * What `player.audioDevices()` lists. `setAudioDevice` updates
2315
+ * `host.source.audioOutput`.
2316
+ */
2317
+ audioDevices = [];
2318
+ /**
2319
+ * Replace the plugin list and push `packages.changed` (another plugin
2320
+ * started, stopped, was installed or removed).
2321
+ *
2322
+ * @capability None.
2323
+ *
2324
+ * @example Simulate another plugin starting
2325
+ * The plugin's store window hears that a plugin is running now. The list
2326
+ * replaces `packages` and is pushed as `packages.changed`, which the
2327
+ * plugin's `packages.onChange` listeners receive.
2328
+ * ```ts
2329
+ * declare const host: TestHost;
2330
+ * // ---cut---
2331
+ * host.setPackages([...host.packages]); // edit a copy, then push it
2332
+ * await host.settle();
2333
+ * ```
2334
+ *
2335
+ * @since 1.0.0
2336
+ */
2337
+ setPackages(list) {
2338
+ this.packages = list;
2339
+ this.emit("packages.changed", structuredClone(this.packages));
2340
+ }
2341
+ /**
2342
+ * Simulate another plugin calling `player.next()` / `previous()`: the host
2343
+ * asks this plugin's sequence provider. Rejects with `unavailable` when
2344
+ * there is no provider or its flag is false.
2345
+ *
2346
+ * @capability None.
2347
+ * @throws `unavailable` (rejects) A `HostError`: there is no sequence
2348
+ * provider, or its flag is false.
2349
+ *
2350
+ * @example Ask the plugin's provider for the next item
2351
+ * Another plugin calls `player.next()`; the host asks the plugin under
2352
+ * test, which is the sequence provider, and the test checks what it
2353
+ * opened.
2354
+ * ```ts
2355
+ * import assert from "node:assert/strict";
2356
+ * declare const host: TestHost;
2357
+ * // ---cut---
2358
+ * await host.step("next");
2359
+ * await host.settle();
2360
+ * assert.equal(host.source.source, "/media/b.mp4");
2361
+ * ```
2362
+ *
2363
+ * @since 1.0.0
2364
+ */
2365
+ step(direction) {
2366
+ return this.request("sequence.step", { direction });
2367
+ }
2368
+ /**
2369
+ * The plugin's command with this local id. Throws when it is not
2370
+ * registered (never added, or disposed).
2371
+ *
2372
+ * @capability None.
2373
+ *
2374
+ * @example Read a command's state after a change
2375
+ * The test checks that Export becomes enabled once a file is ready. The
2376
+ * record is updated as the plugin sends new state; read it after
2377
+ * `settle()`.
2378
+ * ```ts
2379
+ * import assert from "node:assert/strict";
2380
+ * declare const host: TestHost;
2381
+ * // ---cut---
2382
+ * host.setSource({ phase: "ready", duration: 60 });
2383
+ * await host.settle();
2384
+ * assert.equal(host.command("export").state.enabled, true); // throws if not registered
2385
+ * ```
2386
+ *
2387
+ * @since 1.0.0
2388
+ */
2389
+ command(id) {
2390
+ const command = this.commands.get(id);
2391
+ if (!command)
2392
+ throw new Error(`command ${id} is not registered`);
2393
+ return command;
2394
+ }
2395
+ /**
2396
+ * The plugin's bottom-bar buttons (`CommandOptions.button`) as the player
2397
+ * shows them: at most one, the first declared (`BAR_BUTTON_LIMITS`; a
2398
+ * later one stays a menu item only). Empty once the command is disposed.
2399
+ * Whether one is in the strip or its » menu is the player's business
2400
+ * (the room and the other plugins' buttons).
2401
+ *
2402
+ * @example Click the plugin's bottom-bar button in a test
2403
+ * The test finds the plugin's Store button, clicks it and checks that the
2404
+ * Store window opened. `barButtons` lists the buttons of the plugin's
2405
+ * commands declared with `button`, as the bar shows them; `click()` runs
2406
+ * the command like its menu item and waits until the host is idle, and
2407
+ * rejects while the command is disabled or busy.
2408
+ * ```ts
2409
+ * import assert from "node:assert/strict";
2410
+ * declare const host: TestHost;
2411
+ * // ---cut---
2412
+ * const [button] = host.barButtons; // one button per plugin
2413
+ * assert.equal(button?.icon, "store");
2414
+ * await button?.click();
2415
+ * assert.equal(host.window("store").visible, true);
2416
+ * ```
2417
+ *
2418
+ * @since 1.5.29
2419
+ */
2420
+ get barButtons() {
2421
+ return barButtons(this, this.#inner);
2422
+ }
2423
+ /**
2424
+ * The plugin's tool window with this id (by default the file name without
2425
+ * extension: `ui/picture.slint` → `picture`). Throws when it was never
2426
+ * opened with `show()`.
2427
+ *
2428
+ * @example Press a window's Reset button from a test
2429
+ * The Picture window's Reset callback runs and the brightness returns to
2430
+ * 0. The window is found by its id (the `.slint` file name by default);
2431
+ * `emit` fires the callback the plugin bound, and `settle()` waits for
2432
+ * the property the handler wrote.
2433
+ * ```ts
2434
+ * import assert from "node:assert/strict";
2435
+ * declare const host: TestHost;
2436
+ * // ---cut---
2437
+ * host.window("picture").emit("reset");
2438
+ * await host.settle();
2439
+ * assert.equal(host.window("picture").props["brightness"], 0);
2440
+ * ```
2441
+ *
2442
+ * @capability None.
2443
+ * @since 1.0.0
2444
+ */
2445
+ window(id) {
2446
+ const window = this.windows.get(id);
2447
+ if (!window)
2448
+ throw new Error(`window ${id} was never opened`);
2449
+ return window;
2450
+ }
2451
+ /**
2452
+ * The plugin's mounted panel (bottom bar, sidebar, adjust column or
2453
+ * backdrop): the one in `slot`, or without it the only one (SDK 1.6.15: a
2454
+ * plugin mounts one panel per slot; with several, name the slot). Throws
2455
+ * when there is none. Its `props` use Slint (kebab-case) names; `emit`
2456
+ * fires a callback on the panel's global.
2457
+ *
2458
+ * @example Select a row in the plugin's sidebar panel
2459
+ * The test checks the panel sits in the sidebar and selects its third
2460
+ * row. `emit` fires a callback on the panel's global as a click would;
2461
+ * the plugin's handler runs, and `settle()` waits for what it sent.
2462
+ * ```ts
2463
+ * import assert from "node:assert/strict";
2464
+ * declare const host: TestHost;
2465
+ * // ---cut---
2466
+ * const panel = host.mounted();
2467
+ * assert.equal(panel.slot, "sidebar");
2468
+ * panel.emit("select", 2);
2469
+ * await host.settle();
2470
+ * ```
2471
+ *
2472
+ * @capability None.
2473
+ * @since 1.0.0
2474
+ */
2475
+ mounted(slot) {
2476
+ return mounted(this, this.#inner, slot);
2477
+ }
2478
+ /**
2479
+ * The parameters of every request of one wire method, oldest first.
2480
+ *
2481
+ * @example Check the request behind an OSD message
2482
+ * The test checks the last OSD request the plugin sent, before
2483
+ * translation. `sent` lists the parameters of every request of one wire
2484
+ * method, oldest first; `host.osd` holds the texts as shown instead.
2485
+ * ```ts
2486
+ * import assert from "node:assert/strict";
2487
+ * declare const host: TestHost;
2488
+ * // ---cut---
2489
+ * assert.deepEqual(host.sent("osd.show").at(-1), { text: "Saved" });
2490
+ * ```
2491
+ *
2492
+ * @capability None.
2493
+ * @since 1.0.0
2494
+ */
2495
+ sent(method) {
2496
+ return this.calls
2497
+ .filter((call) => call.method === method)
2498
+ .map((call) => call.params);
2499
+ }
2500
+ /**
2501
+ * Wait until no request is in flight (and the SDK's follow-up passes ran),
2502
+ * and at least `ms` milliseconds have passed so that timers due by then
2503
+ * fired. Call it after every action that makes the plugin talk to the host,
2504
+ * before asserting.
2505
+ *
2506
+ * It only waits for host calls. Work the plugin does on its own (reading
2507
+ * or parsing a file, a child process, a network request) is not waited
2508
+ * for: on a loaded machine it can finish after `settle()` returns. Poll
2509
+ * the result you assert on instead (second example).
2510
+ *
2511
+ * `settle()` does not wait for the SDK's once-a-second full update pass
2512
+ * (SDK 1.5.45). After `setSource` (or another player state change) only
2513
+ * the bindings that read a changed field of `plugin.player.state` run
2514
+ * again; state the plugin changed outside a handler without
2515
+ * `plugin.update()` shows only in the next full pass. Have the plugin
2516
+ * call `plugin.update()` after such a change, or wait `settle(1000)`.
2517
+ *
2518
+ * @param ms - Extra time to wait, for debounces and timers (default 0).
2519
+ *
2520
+ * @example Wait past a debounced save
2521
+ * The plugin's store writes its file 300 ms after the last `save`, so
2522
+ * the test waits 400 ms before it checks what the plugin did next.
2523
+ * `settle(ms)` returns when no request is in flight and at least `ms`
2524
+ * have passed, so the store's timer has fired. The file write itself is
2525
+ * the plugin's own work, which `settle` does not wait for: a test that
2526
+ * reads the file calls `await store.flush()` instead.
2527
+ * ```ts
2528
+ * declare const host: TestHost;
2529
+ * // ---cut---
2530
+ * const store = plugin.storage.json("prefs", { defaults: { step: 5 } });
2531
+ * store.save({ step: 10 });
2532
+ * await host.settle(400); // past storage.json's 300 ms debounce
2533
+ * ```
2534
+ *
2535
+ * @example Poll for work the plugin does on its own
2536
+ * The plugin reads and parses a file before it shows a status; `settle`
2537
+ * does not wait for that (it only waits for host requests), so the test
2538
+ * polls the property it asserts on, 20 ms at a time, for up to 2 seconds.
2539
+ * ```ts
2540
+ * import assert from "node:assert/strict";
2541
+ * declare const host: TestHost;
2542
+ * declare const expected: string;
2543
+ * // ---cut---
2544
+ * const importWindow = host.window("import");
2545
+ * importWindow.emit("load-file"); // the plugin reads and parses the file itself
2546
+ * for (let i = 0; i < 100 && importWindow.props["status-text"] !== expected; i++)
2547
+ * await host.settle(20);
2548
+ * assert.equal(importWindow.props["status-text"], expected);
2549
+ * ```
2550
+ *
2551
+ * @capability None.
2552
+ * @throws Never.
2553
+ * @since 1.0.0
2554
+ */
2555
+ settle(ms = 0) {
2556
+ return settle(this, this.#inner, ms);
2557
+ }
2558
+ /**
2559
+ * Close the fake stream servers and delete the host's temporary
2560
+ * directories. `stop()` from {@link startPlugin} calls it.
2561
+ *
2562
+ * @capability None.
2563
+ *
2564
+ * @example Clean up a host that was never started
2565
+ * A test that made a host but failed before `startPlugin` removes the
2566
+ * host's temporary directories itself. `stop()` from `startPlugin` calls
2567
+ * `close` for you otherwise.
2568
+ * ```ts
2569
+ * const host = new TestHost();
2570
+ * try {
2571
+ * // … preset the host; the test fails here …
2572
+ * } finally {
2573
+ * host.close(); // deletes the host's temporary data
2574
+ * }
2575
+ * ```
2576
+ *
2577
+ * @since 1.0.0
2578
+ */
2579
+ close() {
2580
+ return close(this, this.#inner);
2581
+ }
2582
+ // ------------------------------------------------------------ simulated host
2583
+ /**
2584
+ * Why the player would keep this plugin running while nothing uses it
2585
+ * (lazy activation, player 0.5.21), one line per reason: an event it
2586
+ * listens to that is not one the player wakes it for (with what listens
2587
+ * and where it was added), its panel in the bottom bar or the backdrop,
2588
+ * its open windows (these as the player's log says them), and, from
2589
+ * player 0.7.3, its sidebar tab while that is the current tab. The
2590
+ * player counts the current tab as busy work, not as something live: it
2591
+ * keeps the plugin running only while the tab is shown, and a sidebar
2592
+ * panel in a background tab is no reason at all (the plugin may go to
2593
+ * standby; showing the tab starts it again). Empty: the player may stop
2594
+ * it while idle and start it again on its next use. Filter chains,
2595
+ * streams, jobs and the like (which also keep a plugin running) are not
2596
+ * listed.
2597
+ *
2598
+ * @capability None.
2599
+ * @since 1.5.40
2600
+ *
2601
+ * @example Check that a plugin can be stopped while idle
2602
+ * The test fails if the plugin keeps running for no reason after it
2603
+ * starts, and names the reason (a command whose `enabled` reads
2604
+ * `plugin.player.state`, say: give it a `plugin.when` condition). The
2605
+ * player stops such a plugin while nothing uses it and starts it again
2606
+ * on its next use, so it costs no process meanwhile.
2607
+ * ```ts
2608
+ * import assert from "node:assert/strict";
2609
+ * declare const host: TestHost;
2610
+ * // ---cut---
2611
+ * assert.deepEqual(host.keepsRunning, []);
2612
+ * ```
2613
+ */
2614
+ get keepsRunning() {
2615
+ return keepsRunning(this, this.#inner);
2616
+ }
2617
+ #defaults = {
2618
+ ...transportHandlers(this, this.#inner),
2619
+ ...commandsHandlers(this, this.#inner),
2620
+ ...coreActionsHandlers(this, this.#inner),
2621
+ ...i18nHandlers(this, this.#inner),
2622
+ ...layoutHandlers(this, this.#inner),
2623
+ ...osdHandlers(this, this.#inner),
2624
+ ...jobsHandlers(this, this.#inner),
2625
+ ...processingHandlers(this, this.#inner),
2626
+ ...mediaHandlers(this, this.#inner),
2627
+ ...servicesHandlers(this, this.#inner),
2628
+ };
2629
+ }
2630
+ /**
2631
+ * Activate a plugin against a test host, the way the player's runner does:
2632
+ * build the `plugin` object, call `activate(plugin)`, wait for every
2633
+ * registration started during activation (commands, panels, resolvers, ...),
2634
+ * then wait until the host is idle.
2635
+ *
2636
+ * The manifest id and capabilities come from `directory/manifest.json`
2637
+ * unless `capabilities` is given; then `id` (default `"test.plugin"`) and
2638
+ * `capabilities` are used and no manifest is read. Calls that need an
2639
+ * undeclared capability fail with `capability`, as in the player.
2640
+ *
2641
+ * Rejects when `manifest.json` cannot be read or parsed, when `activate`
2642
+ * throws, or when a registration made during activation fails (for example
2643
+ * `limit`, `conflict`, `invalid`). Stricter than the player's runner for
2644
+ * commands (SDK 1.5.49): there the SDK logs a command declaration the
2645
+ * player refuses during activation, skips it and activates the rest of the
2646
+ * plugin (`onError` hears it); `startPlugin` fails, so a test shows the
2647
+ * bad declaration.
2648
+ *
2649
+ * @param activate - The plugin's default-exported `activate` function.
2650
+ * @param options - Where the plugin lives and how to start it.
2651
+ * @returns `host`: the {@link TestHost}; `plugin`: the object passed to
2652
+ * `activate` (call the plugin API on it directly); `stop()`: dispose the
2653
+ * plugin like the player stopping it (scopes released, stores flushed),
2654
+ * wait until the host is idle and remove the host's temporary files;
2655
+ * `stop({ reason })` (SDK 1.5.54): `reason` is the `StopReason` the player
2656
+ * sends; `disconnect()` (SDK 1.5.54) stops it as when the player is lost
2657
+ * (`host-lost`, requests refused with `disabled`). Removing the
2658
+ * temporary files never fails a test.
2659
+ *
2660
+ * @example Test that a setting survives a restart
2661
+ * The test turns on the 10-step option, stops the plugin, starts it again
2662
+ * on the same data directory and checks the option is still on. Both runs
2663
+ * get a directory the test made (`dataDirectory`): `stop()` disposes the
2664
+ * plugin like the player, which writes its pending stores there, and only
2665
+ * removes the host's own temporary directory, so the second run reads what
2666
+ * the first one saved.
2667
+ * ```ts
2668
+ * import assert from "node:assert/strict";
2669
+ * import { mkdtempSync, rmSync } from "node:fs";
2670
+ * import { tmpdir } from "node:os";
2671
+ * import { join } from "node:path";
2672
+ * import test from "node:test";
2673
+ * import { startPlugin } from "@vestra-player/sdk/testing";
2674
+ * import activate from "../main.ts";
2675
+ *
2676
+ * test("remembers the step across restarts", async () => {
2677
+ * const directory = new URL("..", import.meta.url);
2678
+ * const data = mkdtempSync(join(tmpdir(), "data-"));
2679
+ * try {
2680
+ * const first = await startPlugin(activate, { directory, dataDirectory: data });
2681
+ * try {
2682
+ * await first.host.command("step-10").run();
2683
+ * } finally {
2684
+ * await first.stop(); // flushes the plugin's stores into `data`
2685
+ * }
2686
+ *
2687
+ * // A restart: a new host and a new plugin instance on the same data.
2688
+ * const second = await startPlugin(activate, { directory, dataDirectory: data });
2689
+ * try {
2690
+ * assert.equal(second.host.command("step-10").state.checked, true);
2691
+ * } finally {
2692
+ * await second.stop();
2693
+ * }
2694
+ * } finally {
2695
+ * rmSync(data, { recursive: true, force: true });
2696
+ * }
2697
+ * });
2698
+ * ```
2699
+ *
2700
+ * @remarks
2701
+ * The way into a plugin's tests: one call starts the plugin the way the
2702
+ * player's runner does and returns the host to drive, the `plugin` object
2703
+ * to call directly, and `stop` to end it like the player.
2704
+ *
2705
+ * @capability None.
2706
+ * @see [Test a plugin](../../../docs/how-to/testing.md)
2707
+ * @since 1.0.0
2708
+ * @category Testing
2709
+ */
2710
+ export async function startPlugin(activate, options = {}) {
2711
+ const host = options.host ?? new TestHost();
2712
+ if (options.locale !== undefined)
2713
+ host.locale = options.locale;
2714
+ const directory = options.directory instanceof URL
2715
+ ? fileURLToPath(options.directory)
2716
+ : (options.directory ?? process.cwd());
2717
+ let manifest = {
2718
+ id: options.id ?? "test.plugin",
2719
+ capabilities: options.capabilities ?? [],
2720
+ };
2721
+ if (options.directory && !options.capabilities) {
2722
+ const read = JSON.parse(readFileSync(join(directory, "manifest.json"), "utf8"));
2723
+ manifest = { id: read.id, capabilities: read.capabilities };
2724
+ }
2725
+ host.manifestId = manifest.id;
2726
+ host.loadCatalog(directory);
2727
+ const data = options.dataDirectory ?? host.dataDirectory;
2728
+ for (const [key, value] of options.sessionStorage ?? [])
2729
+ host.sessionStorage.set(key, sortedJson(value));
2730
+ mkdirSync(data, { recursive: true });
2731
+ if (host.fileRoots.length === 0)
2732
+ host.fileRoots = [directory, data];
2733
+ const instance = await createPlugin(host, manifest, directory, data,
2734
+ // Like the player: protocol additions it takes (`app.features` set on
2735
+ // the host replaces them, e.g. `[]` for an older player's behaviour).
2736
+ { features: [PATCH_FEATURE], ...host.app },
2737
+ // Unlike the player's runner, which logs a refused command declaration
2738
+ // and activates the rest: a plugin's tests show the refusal.
2739
+ { strictDeclarations: true });
2740
+ await activate(instance.plugin);
2741
+ // Like the player: a position or resolver another plugin holds is not an
2742
+ // activation failure.
2743
+ for (;;) {
2744
+ try {
2745
+ await instance.activated();
2746
+ break;
2747
+ }
2748
+ catch (error) {
2749
+ if (!(error instanceof Error && takenErrors.has(error)))
2750
+ throw error;
2751
+ }
2752
+ }
2753
+ await host.settle();
2754
+ tellResidency(host);
2755
+ // Zero declared parameters (`stop.length === 0`) and a `()` overload, so
2756
+ // `t.after(stop)`, `promise.then(stop)` and done-callback test runners
2757
+ // see the same function as before 1.5.54.
2758
+ const stop = async (...args) => {
2759
+ // Only an object with `reason` counts (a value `then` or a test
2760
+ // runner passes is ignored; an unknown reason reads as none).
2761
+ const first = args[0];
2762
+ const reason = typeof first === "object" && first !== null && "reason" in first
2763
+ ? first.reason
2764
+ : undefined;
2765
+ await instance.dispose({ reason });
2766
+ await host.settle();
2767
+ host.close();
2768
+ };
2769
+ return {
2770
+ /** The test host the plugin talks to. */
2771
+ host,
2772
+ /** The `plugin` object passed to `activate`. */
2773
+ plugin: instance.plugin,
2774
+ /**
2775
+ * Stop the plugin, wait for the host, and remove temporary files.
2776
+ * `stop({ reason })` (SDK 1.5.54): `reason` is the reason the player
2777
+ * gives (`plugin.stopReason`, the `onDispose` cleanups'
2778
+ * `info.reason`); `stop()`: none (`undefined`), as with players up to
2779
+ * 0.6.3. It still takes no declared parameter, so it can be passed as
2780
+ * a callback (`t.after(stop)`, `promise.then(stop)`).
2781
+ */
2782
+ stop,
2783
+ /**
2784
+ * Lose the player (SDK 1.5.54): as when it crashes or is killed, the
2785
+ * runner stops the plugin with reason `host-lost`; requests still
2786
+ * waiting for their answer and every later request are refused with
2787
+ * `disabled`. Then waits and removes the temporary files like `stop`.
2788
+ */
2789
+ async disconnect() {
2790
+ host.loseConnection();
2791
+ await instance.dispose({ reason: "host-lost" });
2792
+ await host.settle();
2793
+ host.close();
2794
+ },
2795
+ };
2796
+ }