@vestra-player/sdk 1.6.17 → 1.6.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/dist/docs-tree.json.gz +0 -0
- package/dist/plugin/runtime.d.ts +26 -0
- package/dist/plugin/runtime.js +16 -0
- package/dist/plugin/testing/commands.d.ts +65 -0
- package/dist/plugin/testing/commands.js +412 -0
- package/dist/plugin/testing/core-actions.d.ts +39 -0
- package/dist/plugin/testing/core-actions.js +371 -0
- package/dist/plugin/testing/declarations.d.ts +37 -0
- package/dist/plugin/testing/declarations.js +80 -0
- package/dist/plugin/testing/i18n.d.ts +28 -0
- package/dist/plugin/testing/i18n.js +107 -0
- package/dist/plugin/testing/index.d.ts +3140 -0
- package/dist/plugin/testing/index.js +2796 -0
- package/dist/plugin/testing/internals.d.ts +76 -0
- package/dist/plugin/testing/internals.js +60 -0
- package/dist/plugin/testing/jobs.d.ts +9 -0
- package/dist/plugin/testing/jobs.js +304 -0
- package/dist/plugin/testing/layout.d.ts +85 -0
- package/dist/plugin/testing/layout.js +622 -0
- package/dist/plugin/testing/media.d.ts +55 -0
- package/dist/plugin/testing/media.js +615 -0
- package/dist/plugin/testing/osd.d.ts +4 -0
- package/dist/plugin/testing/osd.js +57 -0
- package/dist/plugin/testing/processing.d.ts +56 -0
- package/dist/plugin/testing/processing.js +491 -0
- package/dist/plugin/testing/services.d.ts +50 -0
- package/dist/plugin/testing/services.js +466 -0
- package/dist/plugin/testing/transport.d.ts +29 -0
- package/dist/plugin/testing/transport.js +140 -0
- package/dist/plugin/testing.d.ts +1 -3140
- package/dist/plugin/testing.js +1 -6203
- package/llms.txt +1 -1
- package/package.json +5 -3
|
@@ -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
|
+
}
|