@vestra-player/sdk 1.5.6 → 1.5.8

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 CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.5.8
4
+
5
+ **Trial loading is removed; `resolvers.fileTypes`; the CLI under `--permission`.** `apiVersion` stays `vestra/1`. The removal breaks code that called `PackageCandidate.trial()`: only Vestra Player AI did, and it moves to installing (an install can be rolled back).
6
+
7
+ - **Removed: `PackageCandidate.trial()`, the `PackageTrial` type, `PluginInfo.origin` `"trial"` and the error code `cleanup`** (only `PackageTrial.stop` used it). The player (0.4.2) no longer answers `packages.trial`, `packages.trialState`, `packages.trialStateByRequest` or `packages.stopTrial`, and never lists a plugin with origin `"trial"`. Trial loads only supported plugins using `commands`, `ui.window` and `events`, listed the plugin under another id (`<id>.trial.<n>`), and `trial()` returned before activation ended, so the feature was removed instead of extended. Check a package with `validate` and install it; `rollback` restores the previous version.
8
+ - **`plugin.resolvers.fileTypes`** (player 0.4.2): the extensions some plugin's file resolver handles, lower case, sorted, each once, without who registered them; `[]` from older players. Reading needs no capability. **`plugin.resolvers.onFileTypesChange(listener)`** follows it when a file resolver comes or goes (needs `events`). For a playlist that accepts ROMs while an emulator plugin runs. `TestHost`: `host.fileTypes()` (this plugin's file resolvers plus `otherResolvers`) and `host.updateFileTypes()` after changing `otherResolvers`.
9
+ - **`vestra-plugin` runs under Node's permission model** (`--permission`, with `--allow-child-process` for the type checker): looking for `node_modules/@types`, `tsconfig.json` and `.git` in parent folders it may not read threw `ERR_ACCESS_DENIED`; such a folder now counts as absent.
10
+ - `Packages.validate`: the player (0.4.2) keeps the 32 most recent candidates and drops the oldest, whose `install` then fails with `stale`; before, the 33rd validation of a run failed with `busy` until the player restarted.
11
+
12
+ ## 1.5.7
13
+
14
+ **Replaced or closed streams stop with `null`.** A fix; no API change, `apiVersion` stays `vestra/1`, any player.
15
+
16
+ - **`Stream.onStop(null)` when another source replaced the stream or it was closed**, as documented. The player retires a stream's producer at once in both cases, and the SDK then passed the player's answer (`unavailable`, "producer absent") to `onStop` as an error. The SDK now reads the player's state to tell the two apart: replaced or closed is `null`; a failure is a `stream` error with the player's text (also when the player retired the producer right after failing it, which used to give "producer absent" too).
17
+ - The same in the start-up paths: `openStream` replaced or closed before it ran rejects with `replaced` (was `unavailable`); a file resolver's stream replaced after the player selected it is a cancellation and no longer reaches `onError`.
18
+ - **A file resolver's stream that cannot start after `resolve` returned** fails the open with Chinese text instead of the SDK's English (`"audio device is not ready: deviceLost"`): `device` "音频输出设备不可用", `timeout` "启动超时", `busy` "播放器正忙,请稍后重试", other start-up failures "无法启动播放"; the English detail goes to the plugin's log. The plugin never saw these errors, so the user read the SDK's words. An error thrown by `frame()` keeps its message; text for a file the plugin can judge (a bad header) belongs in `resolve`.
19
+ - **`TestHost` retires producers like the player**: on `open`, `close`, another `openProvider` or resolved stream, a retired producer's `producer.state` answers `unavailable` and `producer.ack` `stale`. New `FakeProducer.retired`, and **`host.failStream(producerId, message)`** to fail the playing stream the way the player does (`phase: "error"` with `message`, producer retired). Tests that replaced a stream used to pass where the player failed.
20
+
3
21
  ## 1.5.6
4
22
 
5
23
  **A reference for AI assistants.** Documentation and packaging only; no API change, `apiVersion` stays `vestra/1`. (1.5.5 was not published to npm; this release includes its changes.)
@@ -34,7 +34,7 @@
34
34
  */
35
35
  import { spawnSync } from "node:child_process";
36
36
  import { createHash } from "node:crypto";
37
- import { existsSync } from "node:fs";
37
+ import { existsSync as exists } from "node:fs";
38
38
  import {
39
39
  cp,
40
40
  mkdir,
@@ -110,6 +110,20 @@ async function json(path, fallback) {
110
110
  }
111
111
 
112
112
  /** A module from the plugin's project first, else the SDK's own dependencies. */
113
+ /**
114
+ * Whether a path exists. Under Node's permission model (`--permission`)
115
+ * `existsSync` throws for a path outside the allowed ones; such a path
116
+ * counts as absent, as for a user who cannot read it.
117
+ */
118
+ function present(path) {
119
+ try {
120
+ return exists(path);
121
+ } catch (error) {
122
+ if (error?.code === "ERR_ACCESS_DENIED") return false;
123
+ throw error;
124
+ }
125
+ }
126
+
113
127
  function resolveFrom(directory, specifier) {
114
128
  for (const base of [directory, sdkRoot]) {
115
129
  try {
@@ -139,7 +153,7 @@ async function typescript() {
139
153
  async function plugin(path) {
140
154
  const directory = resolve(path);
141
155
  const name = relative(process.cwd(), directory) || basename(directory);
142
- if (!existsSync(join(directory, "manifest.json")))
156
+ if (!present(join(directory, "manifest.json")))
143
157
  fail(`${name}: no manifest.json in ${directory}`);
144
158
  const manifest = await json(join(directory, "manifest.json"));
145
159
  const { problems, hints } = manifestProblems(manifest, directory);
@@ -165,7 +179,7 @@ function displayFiles(target) {
165
179
  const field = name === "README.md" ? "readme" : "changelog";
166
180
  if (
167
181
  target.manifest[field] === undefined &&
168
- existsSync(join(target.directory, name))
182
+ present(join(target.directory, name))
169
183
  )
170
184
  files.add(name);
171
185
  }
@@ -200,7 +214,7 @@ function dependencies(target) {
200
214
  };
201
215
  if (
202
216
  !Object.keys(deps).length ||
203
- existsSync(join(target.directory, "node_modules"))
217
+ present(join(target.directory, "node_modules"))
204
218
  )
205
219
  return;
206
220
  console.log(`${target.name}: installing dependencies (npm ci)`);
@@ -221,7 +235,7 @@ function* upwards(directory) {
221
235
  for (let current = directory; ;) {
222
236
  yield current;
223
237
  const parent = dirname(current);
224
- if (parent === current || existsSync(join(current, ".git"))) return;
238
+ if (parent === current || present(join(current, ".git"))) return;
225
239
  current = parent;
226
240
  }
227
241
  }
@@ -235,13 +249,13 @@ function checkConfig(target, sources) {
235
249
  const roots = [];
236
250
  for (const directory of upwards(target.directory)) {
237
251
  const types = join(directory, "node_modules/@types");
238
- if (existsSync(types)) roots.push(types);
252
+ if (present(types)) roots.push(types);
239
253
  }
240
254
  const node = resolveFrom(target.directory, "@types/node/package.json");
241
255
  if (node) roots.push(dirname(dirname(node)));
242
256
  const base = [...upwards(target.directory)]
243
257
  .map((directory) => join(directory, "tsconfig.json"))
244
- .find((file) => existsSync(file));
258
+ .find((file) => present(file));
245
259
  return {
246
260
  ...(base ? { extends: base } : {}),
247
261
  compilerOptions: {
@@ -262,7 +276,7 @@ async function check(target) {
262
276
  const own = (await files(target.directory)).filter(
263
277
  (file) => isTypeScript(file) || file.endsWith(".mts"),
264
278
  );
265
- const tests = existsSync(join(target.directory, "tests"))
279
+ const tests = present(join(target.directory, "tests"))
266
280
  ? (await readdir(join(target.directory, "tests")))
267
281
  .filter(isTypeScript)
268
282
  .map((file) => join("tests", file))
@@ -308,7 +322,7 @@ function entries(target) {
308
322
  const entry = target.manifest.entry.replace(/^dist\//, "");
309
323
  const candidates = [entry.replace(/\.js$/, ".ts"), entry];
310
324
  const found = candidates.find((file) =>
311
- existsSync(join(target.directory, file)),
325
+ present(join(target.directory, file)),
312
326
  );
313
327
  if (!found)
314
328
  fail(`${target.name}: entry ${target.manifest.entry} has no source file`);
@@ -454,7 +468,7 @@ async function build(target, flag) {
454
468
 
455
469
  async function test(target) {
456
470
  const directory = join(target.directory, "tests");
457
- const tests = existsSync(directory)
471
+ const tests = present(directory)
458
472
  ? (await readdir(directory))
459
473
  .filter((file) => /\.test\.(ts|mjs)$/.test(file))
460
474
  .sort()
@@ -20,8 +20,7 @@ import type { Transport } from "./wire.ts";
20
20
  */
21
21
  export declare const COMMAND_LIMITS: {
22
22
  /**
23
- * Commands one plugin instance may have at the same time (160). A trial
24
- * package is its own instance.
23
+ * Commands one plugin instance may have at the same time (160).
25
24
  */
26
25
  readonly perPlugin: 160;
27
26
  /** Commands of every plugin plus the player's own, together (1024). */
package/dist/commands.js CHANGED
@@ -18,8 +18,7 @@
18
18
  */
19
19
  export const COMMAND_LIMITS = {
20
20
  /**
21
- * Commands one plugin instance may have at the same time (160). A trial
22
- * package is its own instance.
21
+ * Commands one plugin instance may have at the same time (160).
23
22
  */
24
23
  perPlugin: 160,
25
24
  /** Commands of every plugin plus the player's own, together (1024). */
@@ -1,4 +1,4 @@
1
- /** Package validation, trial loading and install/rollback (`plugins.manage`). */
1
+ /** Package validation and install/rollback (`plugins.manage`). */
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { resolve } from "node:path";
4
4
  import { failure, sleep, uncertain } from "./runtime.js";
@@ -9,51 +9,6 @@ const TERMINAL = [
9
9
  "failedDegraded",
10
10
  "tooLate",
11
11
  ];
12
- const STARTING = ["preparing", "activating", "starting"];
13
- class TrialHandle {
14
- #scope;
15
- #record;
16
- #stopped;
17
- constructor(scope, record) {
18
- this.#scope = scope;
19
- this.#record = record;
20
- scope.add(this);
21
- }
22
- get status() {
23
- return this.#record.status;
24
- }
25
- /**
26
- * Concurrent calls share one stop; a confirmed stop is final. A failed one
27
- * (code `cleanup`: the host did not confirm cleanup) is forgotten, so the
28
- * next call sends a new stop to the host.
29
- */
30
- stop() {
31
- const host = this.#scope.runtime.host.packages;
32
- if (this.#stopped)
33
- return this.#stopped;
34
- const stopping = (async () => {
35
- this.#record = await host.stopTrial(this.#record.trialId);
36
- while (!this.#record.cleanupConfirmed &&
37
- this.#record.status !== "stopped" &&
38
- this.#record.status !== "failed") {
39
- await sleep(250);
40
- this.#record = await host.trialState(this.#record.trialId);
41
- }
42
- if (!this.#record.cleanupConfirmed)
43
- throw failure("cleanup", `trial cleanup not confirmed: ${this.#record.status}`);
44
- })();
45
- this.#stopped = stopping;
46
- stopping.catch(() => {
47
- if (this.#stopped === stopping)
48
- this.#stopped = undefined;
49
- });
50
- return stopping;
51
- }
52
- async dispose() {
53
- this.#scope.forget(this);
54
- await this.stop().catch(() => { });
55
- }
56
- }
57
12
  class ChangeHandle {
58
13
  #scope;
59
14
  #id;
@@ -123,24 +78,6 @@ async function change(scope, submit, id) {
123
78
  }
124
79
  return new ChangeHandle(scope, operation);
125
80
  }
126
- async function trial(scope, runtime, token) {
127
- const host = runtime.host.packages;
128
- const requestId = randomUUID();
129
- let record;
130
- try {
131
- record = await host.trial(token, requestId);
132
- }
133
- catch (error) {
134
- if (!uncertain(error))
135
- throw error;
136
- record = await host.trialStateByRequest(requestId);
137
- }
138
- while (STARTING.includes(record.status)) {
139
- await sleep(250);
140
- record = await host.trialState(record.trialId);
141
- }
142
- return new TrialHandle(scope, record);
143
- }
144
81
  function installOptions(options) {
145
82
  if (options === undefined)
146
83
  return {};
@@ -171,7 +108,6 @@ function candidate(scope, directory, checked) {
171
108
  digest: checked.digest,
172
109
  manifest: checked.manifest,
173
110
  checks: checked.checks,
174
- trial: () => trial(scope, runtime, checked.token),
175
111
  install: async (options) => {
176
112
  const wire = installOptions(options);
177
113
  return change(scope, (requestId, revision) => runtime.host.packages.install(checked.token, revision, requestId, wire), checked.manifest.id);
@@ -5,8 +5,72 @@
5
5
  import { failure, quiet } from "./runtime.js";
6
6
  import { playerOf } from "./player.js";
7
7
  import { resolvedStream } from "./stream.js";
8
+ import { windowOf } from "./window.js";
8
9
  const isFiles = (options) => "extensions" in options;
10
+ const sameTypes = (a, b) => a.length === b.length && a.every((type, i) => type === b[i]);
11
+ /**
12
+ * One per plugin: the list comes with the `window.state` read before
13
+ * activation (`[]` from older players) and is read again when the plugin
14
+ * first uses it; with `events`, `resolvers.fileTypes` keeps it current.
15
+ */
16
+ class FileTypesService {
17
+ #runtime;
18
+ types;
19
+ #listeners = new Set();
20
+ #started = false;
21
+ constructor(runtime) {
22
+ this.#runtime = runtime;
23
+ this.types = Object.freeze([...(windowOf(runtime).fileTypes ?? [])]);
24
+ }
25
+ start() {
26
+ if (this.#started)
27
+ return;
28
+ this.#started = true;
29
+ this.#runtime.on("resolvers.fileTypes", (data) => {
30
+ const types = data?.fileTypes;
31
+ if (Array.isArray(types) && types.every((t) => typeof t === "string"))
32
+ this.accept(types);
33
+ });
34
+ // What changed between the start-up read and the subscription.
35
+ const window = windowOf(this.#runtime);
36
+ void window.read().then(() => {
37
+ if (window.fileTypes)
38
+ this.accept(window.fileTypes, this.#runtime.can("events"));
39
+ });
40
+ }
41
+ /** `notify`: false without `events`, whose listeners never fire. */
42
+ accept(types, notify = true) {
43
+ if (sameTypes(types, this.types))
44
+ return;
45
+ this.types = Object.freeze([...types]);
46
+ if (notify)
47
+ for (const listener of [...this.#listeners])
48
+ listener([...types]);
49
+ this.#runtime.invalidate();
50
+ }
51
+ watch(listener) {
52
+ this.#listeners.add(listener);
53
+ return { dispose: async () => void this.#listeners.delete(listener) };
54
+ }
55
+ }
56
+ const fileTypesOf = (runtime) => runtime.service("fileTypes", () => new FileTypesService(runtime));
9
57
  export const resolvers = (scope) => ({
58
+ get fileTypes() {
59
+ const service = fileTypesOf(scope.runtime);
60
+ service.start();
61
+ return service.types;
62
+ },
63
+ onFileTypesChange(listener) {
64
+ const service = fileTypesOf(scope.runtime);
65
+ service.start();
66
+ const watch = service.watch((types) => scope.run(listener, types));
67
+ return scope.add({
68
+ dispose: async () => {
69
+ scope.forget(watch);
70
+ await watch.dispose();
71
+ },
72
+ });
73
+ },
10
74
  add(options) {
11
75
  const runtime = scope.runtime;
12
76
  const registration = isFiles(options)
@@ -79,7 +79,8 @@ class StreamHandle {
79
79
  }, 5);
80
80
  }
81
81
  catch (error) {
82
- void this.#end(toError(error));
82
+ const retired = await retirement(this.#service, this.#generation, error);
83
+ void this.#end(retired === undefined ? toError(error) : retired);
83
84
  }
84
85
  }
85
86
  async #render() {
@@ -131,6 +132,28 @@ class StreamHandle {
131
132
  await this.stop().catch(() => { });
132
133
  }
133
134
  }
135
+ /**
136
+ * The player retires a producer the moment another source replaces it or
137
+ * the source closes, and after it failed, so its `producer.state` then
138
+ * answers `unavailable` (and `producer.ack` `stale`). The player's state
139
+ * says which: `null` (replaced or closed), the failure with the player's
140
+ * words, or `undefined` when the error is not a retirement.
141
+ */
142
+ async function retirement(service, generation, error) {
143
+ const code = error?.code;
144
+ if (code !== "unavailable" && code !== "stale")
145
+ return undefined;
146
+ const readable = service.runtime.can("playback.read");
147
+ if (readable)
148
+ await service.refresh().catch(() => { });
149
+ const raw = service.raw;
150
+ if (raw.stamp.generation !== generation || raw.phase === "empty")
151
+ return null;
152
+ if (raw.phase === "error")
153
+ return failure("stream", service.state.error ?? "无法打开此媒体");
154
+ // Without `playback.read` the state may be behind: no failure was seen.
155
+ return readable ? undefined : null;
156
+ }
134
157
  /** Start-up timeouts say which step ran out of time. */
135
158
  function timeout(stage, message) {
136
159
  return Object.assign(failure("timeout", message), { stage });
@@ -303,10 +326,40 @@ export async function openStream(scope, service, options) {
303
326
  // An abort can arrive before the host answered the selecting request: wait
304
327
  // for that answer so a source selected afterwards is still closed.
305
328
  await opened?.catch(() => { });
329
+ // Read before closing: replaced during activation is not a failure.
330
+ const retired = generation === undefined
331
+ ? undefined
332
+ : await retirement(service, generation, error);
306
333
  await abandon(service, p, generation);
307
- throw error;
334
+ if (retired === null)
335
+ throw failure("replaced", "另一个来源已替换此媒体");
336
+ throw retired ?? error;
308
337
  }
309
338
  }
339
+ /**
340
+ * What the user reads when a file resolver's stream cannot start. The
341
+ * player shows the answer as the open's failure, and the plugin never sees
342
+ * these errors: they happen after `resolve` returned. The SDK's and the
343
+ * player's own codes get the player's words; the detail goes to the log.
344
+ * An error the plugin threw from `frame()` keeps its own words.
345
+ */
346
+ const START_TEXT = {
347
+ device: "音频输出设备不可用",
348
+ timeout: "启动超时",
349
+ busy: "播放器正忙,请稍后重试",
350
+ stream: "无法启动播放",
351
+ producer: "无法启动播放",
352
+ unavailable: "无法启动播放",
353
+ invalid: "无法启动播放",
354
+ };
355
+ function startFailure(scope, error) {
356
+ const code = error?.code;
357
+ const text = code === undefined ? undefined : START_TEXT[code];
358
+ if (text === undefined)
359
+ return error;
360
+ console.error(`[${scope.id}] file resolver stream did not start:`, error.message);
361
+ return Object.assign(failure(code, text), { cause: error });
362
+ }
310
363
  /**
311
364
  * A file resolver's stream (`ResolvedStream`): prepared and prerolled while
312
365
  * the open waits, then handed to the host as the answer. Once the host
@@ -320,6 +373,8 @@ export async function resolvedStream(scope, service, stream, signal) {
320
373
  const p = await prepare(host, stream, () => {
321
374
  if (signal.aborted || scope.disposed)
322
375
  throw abortError();
376
+ }).catch((error) => {
377
+ throw startFailure(scope, error);
323
378
  });
324
379
  return {
325
380
  kind: "stream",
@@ -336,9 +391,12 @@ export async function resolvedStream(scope, service, stream, signal) {
336
391
  throw abortError();
337
392
  }
338
393
  catch (error) {
394
+ // Replaced or closed before it ran is a cancellation, not a failure.
395
+ const retired = await retirement(service, stamp.generation, error);
339
396
  await abandon(service, p, stamp.generation);
340
- if (error.code !== "aborted")
341
- scope.report(error);
397
+ const reported = retired === undefined ? error : retired;
398
+ if (reported && reported.code !== "aborted")
399
+ scope.report(reported);
342
400
  return;
343
401
  }
344
402
  const handle = handleOf(scope, service, p, stamp.generation);
@@ -117,6 +117,13 @@ export interface FakeProducer {
117
117
  videoFrames: number;
118
118
  /** `true` once the plugin released the producer (stream stopped or disposed). */
119
119
  released: boolean;
120
+ /**
121
+ * `true` once the player retired the producer, as the real player does at
122
+ * once: another source replaced it (`open`, another stream, a resolved
123
+ * file), the source was closed, or {@link TestHost.failStream} failed it.
124
+ * A retired producer's `producer.state` answers `unavailable`.
125
+ */
126
+ retired: boolean;
120
127
  /**
121
128
  * `true` if the frame connection closed while this stream was still the
122
129
  * player's source: the player reports that as a playback failure
@@ -766,6 +773,27 @@ export declare class TestHost implements Transport {
766
773
  /** Exact host names it resolves (a web resolver), with `schemes`. */
767
774
  hosts?: string[];
768
775
  }[];
776
+ /**
777
+ * What `plugin.resolvers.fileTypes` reports: the extensions of this
778
+ * plugin's file resolvers and of {@link TestHost.otherResolvers}, sorted,
779
+ * each once.
780
+ */
781
+ fileTypes(): string[];
782
+ /**
783
+ * Push `resolvers.fileTypes` when {@link TestHost.fileTypes} changed:
784
+ * called after this plugin's resolvers come and go; call it yourself
785
+ * after changing {@link TestHost.otherResolvers} (another plugin started
786
+ * or stopped).
787
+ *
788
+ * @example
789
+ * ```ts
790
+ * host.otherResolvers.push({ owner: "vestra.nes", extensions: ["nes"] });
791
+ * host.updateFileTypes();
792
+ * await host.settle();
793
+ * assert.deepEqual(plugin.resolvers.fileTypes, ["nes"]);
794
+ * ```
795
+ */
796
+ updateFileTypes(): void;
769
797
  /**
770
798
  * File opens waiting for this plugin's file resolver (`extensions`), by
771
799
  * request id: the path and how it was opened. Opening a matching path
@@ -1025,6 +1053,22 @@ export declare class TestHost implements Transport {
1025
1053
  * ```
1026
1054
  */
1027
1055
  openElsewhere(source: string, phase?: SourceState["phase"]): void;
1056
+ /**
1057
+ * Simulate a generated stream failing in the player (its audio device
1058
+ * lost, its frame connection broken), like the player: the source fails
1059
+ * with `message` (`phase: "error"`) and the producer is retired at once.
1060
+ * The stream's `onStop` listeners get a `stream` error with `message`.
1061
+ *
1062
+ * @param producerId - The producer, from {@link TestHost.producers}.
1063
+ * @param message - The player's failure text.
1064
+ *
1065
+ * @example
1066
+ * ```ts
1067
+ * const [producer] = host.producers.values();
1068
+ * host.failStream(producer.id, "producer transport closed");
1069
+ * ```
1070
+ */
1071
+ failStream(producerId: number, message: string): void;
1028
1072
  /**
1029
1073
  * Press a binding like the host's table would: panel-focus bindings first
1030
1074
  * when `panel` is true, then global ones; the earliest registration wins a
@@ -1072,7 +1116,7 @@ export declare class TestHost implements Transport {
1072
1116
  /** Its version, `null` when unknown. */
1073
1117
  version: string | null;
1074
1118
  /** Where it was loaded from. */
1075
- origin: "installed" | "development" | "trial";
1119
+ origin: "installed" | "development";
1076
1120
  /** Its run state. */
1077
1121
  state: "starting" | "running" | "disabled" | "failed" | "stopped";
1078
1122
  /** Why it failed (`state: "failed"`). */
@@ -773,6 +773,43 @@ export class TestHost {
773
773
  * ```
774
774
  */
775
775
  otherResolvers = [];
776
+ /**
777
+ * What `plugin.resolvers.fileTypes` reports: the extensions of this
778
+ * plugin's file resolvers and of {@link TestHost.otherResolvers}, sorted,
779
+ * each once.
780
+ */
781
+ fileTypes() {
782
+ const types = new Set();
783
+ for (const declaration of [
784
+ ...this.resolvers.values(),
785
+ ...this.otherResolvers,
786
+ ])
787
+ for (const type of declaration.extensions ?? [])
788
+ types.add(type);
789
+ return [...types].sort();
790
+ }
791
+ #sentTypes = "[]";
792
+ /**
793
+ * Push `resolvers.fileTypes` when {@link TestHost.fileTypes} changed:
794
+ * called after this plugin's resolvers come and go; call it yourself
795
+ * after changing {@link TestHost.otherResolvers} (another plugin started
796
+ * or stopped).
797
+ *
798
+ * @example
799
+ * ```ts
800
+ * host.otherResolvers.push({ owner: "vestra.nes", extensions: ["nes"] });
801
+ * host.updateFileTypes();
802
+ * await host.settle();
803
+ * assert.deepEqual(plugin.resolvers.fileTypes, ["nes"]);
804
+ * ```
805
+ */
806
+ updateFileTypes() {
807
+ const types = this.fileTypes();
808
+ if (JSON.stringify(types) === this.#sentTypes)
809
+ return;
810
+ this.#sentTypes = JSON.stringify(types);
811
+ this.emit("resolvers.fileTypes", { fileTypes: types });
812
+ }
776
813
  /**
777
814
  * File opens waiting for this plugin's file resolver (`extensions`), by
778
815
  * request id: the path and how it was opened. Opening a matching path
@@ -1361,6 +1398,7 @@ export class TestHost {
1361
1398
  videoArea: { ...this.mainWindow.videoArea },
1362
1399
  pictureInPicture: { ...this.mainWindow.pictureInPicture },
1363
1400
  appearance: this.appearance,
1401
+ fileTypes: this.fileTypes(),
1364
1402
  };
1365
1403
  }
1366
1404
  /**
@@ -1385,6 +1423,31 @@ export class TestHost {
1385
1423
  if (this.#resolve(source, false) === null)
1386
1424
  this.setSource({ phase });
1387
1425
  }
1426
+ /**
1427
+ * Simulate a generated stream failing in the player (its audio device
1428
+ * lost, its frame connection broken), like the player: the source fails
1429
+ * with `message` (`phase: "error"`) and the producer is retired at once.
1430
+ * The stream's `onStop` listeners get a `stream` error with `message`.
1431
+ *
1432
+ * @param producerId - The producer, from {@link TestHost.producers}.
1433
+ * @param message - The player's failure text.
1434
+ *
1435
+ * @example
1436
+ * ```ts
1437
+ * const [producer] = host.producers.values();
1438
+ * host.failStream(producer.id, "producer transport closed");
1439
+ * ```
1440
+ */
1441
+ failStream(producerId, message) {
1442
+ const producer = this.producers.get(producerId);
1443
+ if (!producer || !this.#current(producerId))
1444
+ throw new Error(`producer ${producerId} is not the player's source`);
1445
+ this.setSource({
1446
+ phase: "error",
1447
+ terminalReason: { failed: message },
1448
+ });
1449
+ producer.retired = true;
1450
+ }
1388
1451
  /**
1389
1452
  * Press a binding like the host's table would: panel-focus bindings first
1390
1453
  * when `panel` is true, then global ones; the earliest registration wins a
@@ -1721,6 +1784,10 @@ export class TestHost {
1721
1784
  if (control.kind === "rate")
1722
1785
  next.rate = Number(control.rate);
1723
1786
  }
1787
+ // Like the player: the selected producer is retired as soon as another
1788
+ // source replaces it or the source closes.
1789
+ if (["open", "openProvider", "close"].includes(String(intent.kind)))
1790
+ this.#retireSelected(intent.kind === "openProvider" ? Number(intent.producerId) : null);
1724
1791
  if (intent.kind === "openProvider")
1725
1792
  this.#selected = Number(intent.producerId);
1726
1793
  if (intent.kind === "open" || intent.kind === "close")
@@ -1748,6 +1815,14 @@ export class TestHost {
1748
1815
  }
1749
1816
  return null;
1750
1817
  }
1818
+ /** Retire the producer that is the player's source, unless it is `keep`. */
1819
+ #retireSelected(keep) {
1820
+ for (const producer of this.producers.values())
1821
+ if (producer.id !== keep &&
1822
+ !producer.retired &&
1823
+ this.#current(producer.id))
1824
+ producer.retired = true;
1825
+ }
1751
1826
  /** Whether producer `id` is the player's source. */
1752
1827
  #current(id) {
1753
1828
  return (this.source.kind === "producer" &&
@@ -1853,9 +1928,10 @@ export class TestHost {
1853
1928
  const producer = this.producers.get(Number(result.producerId));
1854
1929
  if (result.kind !== "stream")
1855
1930
  fail("invalid resolved stream descriptor");
1856
- if (!producer || producer.released)
1931
+ if (!producer || producer.released || producer.retired)
1857
1932
  fail("unavailable: producer absent");
1858
1933
  // Like openProvider, but the source keeps the path and `startPaused`.
1934
+ this.#retireSelected(producer.id);
1859
1935
  this.#selected = producer.id;
1860
1936
  producer.active = true;
1861
1937
  producer.acknowledged = false;
@@ -2645,8 +2721,6 @@ export class TestHost {
2645
2721
  const entry = this.packages.find((each) => each.id === id);
2646
2722
  if (!entry)
2647
2723
  throw new HostError("missing", `no plugin ${id}`);
2648
- if (entry.origin === "trial")
2649
- throw new HostError("unsupported", "a trial is stopped with its handle");
2650
2724
  this.packageCalls.push({
2651
2725
  method: "setEnabled",
2652
2726
  id,
@@ -2906,10 +2980,14 @@ export class TestHost {
2906
2980
  return { jobId: job.jobId, cancel: "requested" };
2907
2981
  },
2908
2982
  "jobs.operation": () => ({ status: "unknown" }),
2909
- "sourceResolvers.register": (p) => void this.#register(p),
2983
+ "sourceResolvers.register": (p) => {
2984
+ this.#register(p);
2985
+ this.updateFileTypes();
2986
+ },
2910
2987
  "sourceResolvers.unregister": (p) => {
2911
2988
  this.resolvers.delete(String(p.id));
2912
2989
  this.#cancelResolutions();
2990
+ this.updateFileTypes();
2913
2991
  },
2914
2992
  "sourceResolvers.respond": (p) => this.#respond(p),
2915
2993
  "producer.prepare": async () => {
@@ -2922,6 +3000,7 @@ export class TestHost {
2922
3000
  receivedFrames: 0,
2923
3001
  videoFrames: 0,
2924
3002
  released: false,
3003
+ retired: false,
2925
3004
  droppedWhileSelected: false,
2926
3005
  path,
2927
3006
  admitted: !tcp,
@@ -2988,6 +3067,8 @@ export class TestHost {
2988
3067
  const producer = this.producers.get(Number(p.producerId));
2989
3068
  if (!producer)
2990
3069
  throw new HostError("missing", "producer");
3070
+ if (producer.retired || producer.released)
3071
+ throw new HostError("unavailable", "producer absent");
2991
3072
  const current = this.#current(producer.id);
2992
3073
  return {
2993
3074
  input: null,
@@ -3017,6 +3098,8 @@ export class TestHost {
3017
3098
  const producer = this.producers.get(Number(control.producerId));
3018
3099
  if (!producer)
3019
3100
  throw new HostError("missing", "producer");
3101
+ if (producer.retired || producer.released)
3102
+ throw new HostError("stale", "producer control changed");
3020
3103
  producer.pending = null;
3021
3104
  producer.acknowledged = true;
3022
3105
  producer.epoch = Number(control.epoch);