@ajnart/self-host-testkit 1.2.1 → 1.3.0

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/index.mjs CHANGED
@@ -5,6 +5,22 @@ import { tmpdir } from "node:os";
5
5
  import { isAbsolute, join } from "node:path";
6
6
  import { inspect } from "node:util";
7
7
  import { installedBinary } from "./binary.mjs";
8
+ import { startupReasons } from "./reasons.mjs";
9
+ const pairStates = [
10
+ "running",
11
+ "exited",
12
+ "created",
13
+ "restarting",
14
+ "paused",
15
+ "missing",
16
+ "unknown",
17
+ "dead",
18
+ "removing",
19
+ "degraded",
20
+ ];
21
+ function pairState(value) {
22
+ return pairStates.includes(value);
23
+ }
8
24
  const MAX_MESSAGE = 65536;
9
25
  const CLEANUP_TIMEOUT = 50000;
10
26
  const messages = new Map([
@@ -16,49 +32,31 @@ const messages = new Map([
16
32
  ["CLEANUP", "Fixture cleanup failed; run manifest cleanup."],
17
33
  ["TIMEOUT", "Fixture operation timed out."],
18
34
  ["TRANSPORT", "Testkit helper transport failed."],
35
+ ["OPERATION", "Fixture pair action or inspection failed."],
36
+ ["CANCELED", "Fixture operation was canceled."],
19
37
  [
20
38
  "BINARY",
21
39
  "Testkit helper not found. Run self-host-testkit-setup or set SELF_HOST_TESTKIT_BINARY.",
22
40
  ],
23
41
  ]);
24
- const startupReasons = new Map([
25
- ["APP", "Choose one supported Catalog App."],
26
- ["OPTIONS", "Check the selected image and lifetime options."],
27
- ["DOCKER", "Check the local Unix Docker socket, daemon and Compose plugin."],
28
- ["CAPABILITIES", "Docker must support memory, CPU quota and PID limits."],
29
- ["PORT", "A required local port could not be reserved."],
30
- ["OWNERSHIP", "Fixture ownership or isolation could not be verified."],
31
- [
32
- "LIMITS",
33
- "Container resource caps or selected image could not be verified.",
34
- ],
35
- ["LOCK", "The private startup lock is unavailable."],
36
- [
37
- "UNSUPPORTED",
38
- "This App's Compose configuration or operations are unsupported.",
39
- ],
40
- [
41
- "STORAGE",
42
- "Private fixture storage or the recovery manifest is unavailable.",
43
- ],
44
- ["CONTAINER", "The selected App container is absent."],
45
- ["SETUP", "App Setup failed or requires a Manual step."],
46
- ]);
47
42
  export class TestkitError extends Error {
48
43
  code;
49
44
  reason;
50
- constructor(code, reason) {
45
+ cleanupFailed;
46
+ constructor(code, reason, recoveryPath, cleanupFailed = false) {
51
47
  let message = messages.get(code);
52
- if (code === "START" && reason)
53
- message = `${message} ${startupReasons.get(reason)}`;
48
+ if ((code === "START" || code === "OPERATION") && reason)
49
+ message = `${message} ${startupReasons[reason]}`;
54
50
  super(message);
55
51
  this.name = "TestkitError";
56
52
  this.code = code;
57
53
  this.reason = reason;
54
+ this.cleanupFailed = cleanupFailed;
55
+ Object.defineProperty(this, "recoveryPath", { value: recoveryPath });
58
56
  }
59
57
  }
60
- function fault(code, reason) {
61
- return new TestkitError(code, reason);
58
+ function fault(code, reason, recoveryPath, cleanupFailed = false) {
59
+ return new TestkitError(code, reason, recoveryPath, cleanupFailed);
62
60
  }
63
61
  function record(value) {
64
62
  return value !== null && typeof value === "object" && !Array.isArray(value);
@@ -85,20 +83,20 @@ function privateCredentials(value) {
85
83
  function runCleanup(binary, manifest, owner) {
86
84
  return new Promise((resolve, reject) => {
87
85
  const child = spawn(binary, ["--cleanup-manifest", manifest, "--owner", owner], { stdio: "ignore" });
86
+ let failed = false;
88
87
  const timer = setTimeout(() => {
88
+ failed = true;
89
89
  child.kill("SIGKILL");
90
- reject(fault("CLEANUP"));
91
90
  }, CLEANUP_TIMEOUT);
92
91
  child.once("error", () => {
93
- clearTimeout(timer);
94
- reject(fault("CLEANUP"));
92
+ failed = true;
95
93
  });
96
- child.once("exit", (code) => {
94
+ child.once("close", (code) => {
97
95
  clearTimeout(timer);
98
- if (code === 0)
96
+ if (!failed && code === 0)
99
97
  resolve(undefined);
100
98
  else
101
- reject(fault("CLEANUP"));
99
+ reject(fault("CLEANUP", undefined, manifest));
102
100
  });
103
101
  });
104
102
  }
@@ -137,12 +135,18 @@ class Bridge {
137
135
  this.#child.stdin.on("error", () => this.#fail("TRANSPORT"));
138
136
  this.#child.stdout.on("data", (chunk) => this.#receive(chunk));
139
137
  }
138
+ get recoveryPath() {
139
+ return this.#manifest;
140
+ }
141
+ #fault(code, reason, cleanupFailed = false) {
142
+ return fault(code, reason, this.#manifest, cleanupFailed);
143
+ }
140
144
  #fail(code) {
141
145
  this.#failed = true;
142
146
  this.#buffer = Buffer.alloc(0);
143
147
  for (const pending of this.#pending.values()) {
144
148
  clearTimeout(pending.timer);
145
- pending.reject(fault(code));
149
+ pending.reject(this.#fault(code));
146
150
  }
147
151
  this.#pending.clear();
148
152
  this.#child.kill("SIGTERM");
@@ -167,33 +171,40 @@ class Bridge {
167
171
  !Number.isSafeInteger(response.id) ||
168
172
  !this.#pending.has(response.id) ||
169
173
  Object.hasOwn(response, "error") === Object.hasOwn(response, "result")) {
170
- throw fault("PROTOCOL");
174
+ throw this.#fault("PROTOCOL");
171
175
  }
172
176
  const pending = this.#pending.get(response.id);
173
177
  if (!pending)
174
- throw fault("PROTOCOL");
178
+ throw this.#fault("PROTOCOL");
175
179
  this.#pending.delete(response.id);
176
180
  clearTimeout(pending.timer);
177
181
  if (Object.hasOwn(response, "error")) {
178
182
  if (!record(response.error) ||
179
183
  typeof response.error.code !== "string" ||
180
184
  !isErrorCode(response.error.code)) {
181
- pending.reject(fault("PROTOCOL"));
185
+ pending.reject(this.#fault("PROTOCOL"));
182
186
  this.#fail("PROTOCOL");
183
187
  return;
184
188
  }
185
189
  let reason;
186
190
  if (response.error.reason !== undefined) {
187
- if (response.error.code !== "START" ||
191
+ if ((response.error.code !== "START" &&
192
+ response.error.code !== "OPERATION") ||
188
193
  typeof response.error.reason !== "string" ||
189
194
  !isStartupReason(response.error.reason)) {
190
- pending.reject(fault("PROTOCOL"));
195
+ pending.reject(this.#fault("PROTOCOL"));
191
196
  this.#fail("PROTOCOL");
192
197
  return;
193
198
  }
194
199
  reason = response.error.reason;
195
200
  }
196
- pending.reject(fault(response.error.code, reason));
201
+ if (response.error.cleanupFailed !== undefined &&
202
+ typeof response.error.cleanupFailed !== "boolean") {
203
+ pending.reject(this.#fault("PROTOCOL"));
204
+ this.#fail("PROTOCOL");
205
+ return;
206
+ }
207
+ pending.reject(this.#fault(response.error.code, reason, response.error.cleanupFailed === true));
197
208
  }
198
209
  else {
199
210
  pending.resolve(response.result);
@@ -208,7 +219,9 @@ class Bridge {
208
219
  }
209
220
  request(method, params = {}, timeout = 35000) {
210
221
  if (this.#failed)
211
- return Promise.reject(fault("TRANSPORT"));
222
+ return Promise.reject(this.#fault("TRANSPORT"));
223
+ if (this.#closing)
224
+ return Promise.reject(this.#fault("STATE"));
212
225
  const id = ++this.#sequence;
213
226
  return new Promise((resolve, reject) => {
214
227
  const timer = setTimeout(() => this.#fail("TIMEOUT"), timeout);
@@ -224,19 +237,37 @@ class Bridge {
224
237
  });
225
238
  });
226
239
  }
240
+ async failAndClose(primary) {
241
+ let code = "PROTOCOL";
242
+ if (primary instanceof TestkitError)
243
+ code = primary.code;
244
+ this.#fail(code);
245
+ try {
246
+ await this.close();
247
+ }
248
+ catch {
249
+ if (primary instanceof TestkitError)
250
+ throw this.#fault(primary.code, primary.reason, true);
251
+ throw primary;
252
+ }
253
+ throw primary;
254
+ }
227
255
  close() {
228
- if (!this.#closing)
229
- this.#closing = this.#close();
256
+ if (!this.#closing) {
257
+ this.#closing = this.#close().catch((error) => {
258
+ this.#closing = undefined;
259
+ throw error;
260
+ });
261
+ }
230
262
  return this.#closing;
231
263
  }
232
264
  async #close() {
233
- let failure;
234
265
  if (!this.#failed) {
235
266
  try {
236
267
  await this.request("close", {}, CLEANUP_TIMEOUT);
237
268
  }
238
269
  catch {
239
- failure = fault("CLEANUP");
270
+ this.#fail("TRANSPORT");
240
271
  }
241
272
  }
242
273
  this.#child.stdin.end();
@@ -245,14 +276,7 @@ class Bridge {
245
276
  clearTimeout(kill);
246
277
  if (!this.#spawned)
247
278
  return;
248
- try {
249
- await runCleanup(this.#binary, this.#manifest, this.#owner);
250
- }
251
- catch {
252
- failure = fault("CLEANUP");
253
- }
254
- if (failure)
255
- throw failure;
279
+ await runCleanup(this.#binary, this.#manifest, this.#owner);
256
280
  }
257
281
  [inspect.custom]() {
258
282
  return "[TestkitBridge]";
@@ -262,46 +286,74 @@ class AppHandle {
262
286
  #bridge;
263
287
  #url;
264
288
  #closed = false;
265
- constructor(bridge, url) {
289
+ #app;
290
+ constructor(bridge, url, app) {
266
291
  this.#bridge = bridge;
267
292
  this.#url = url;
293
+ this.#app = app;
268
294
  }
269
295
  get url() {
270
296
  return this.#url;
271
297
  }
298
+ get recoveryPath() {
299
+ return this.#bridge.recoveryPath;
300
+ }
301
+ #validate(read) {
302
+ try {
303
+ return read();
304
+ }
305
+ catch (error) {
306
+ if (this.#app !== undefined)
307
+ return this.#bridge.failAndClose(error);
308
+ throw error;
309
+ }
310
+ }
272
311
  async getApiKey(name = "") {
273
312
  if (this.#closed || typeof name !== "string")
274
313
  throw fault("STATE");
275
- const value = await this.#bridge.request("getApiKey", { name });
276
- if (!strings(value, ["apiKey"]))
277
- throw fault("PROTOCOL");
278
- return value.apiKey;
314
+ const value = await this.#bridge.request("getApiKey", {
315
+ name,
316
+ app: this.#app,
317
+ });
318
+ return this.#validate(() => {
319
+ if (!strings(value, ["apiKey"]))
320
+ throw fault("PROTOCOL");
321
+ return value.apiKey;
322
+ });
279
323
  }
280
324
  async credentials() {
281
325
  if (this.#closed)
282
326
  throw fault("STATE");
283
- return privateCredentials(await this.#bridge.request("credentials"));
327
+ const value = await this.#bridge.request("credentials", { app: this.#app });
328
+ return this.#validate(() => privateCredentials(value));
284
329
  }
285
330
  async info() {
286
331
  if (this.#closed)
287
332
  throw fault("STATE");
288
- const value = await this.#bridge.request("info");
289
- if (!strings(value, ["id", "url", "image", "imageId"]) ||
290
- !Array.isArray(value.repoDigests) ||
291
- !value.repoDigests.every((digest) => typeof digest === "string")) {
292
- throw fault("PROTOCOL");
293
- }
294
- return {
295
- id: value.id,
296
- url: value.url,
297
- image: value.image,
298
- imageId: value.imageId,
299
- repoDigests: [...value.repoDigests],
300
- };
333
+ const value = await this.#bridge.request("info", { app: this.#app });
334
+ return this.#validate(() => {
335
+ if (!strings(value, ["id", "url", "image", "imageId"]) ||
336
+ !Array.isArray(value.repoDigests) ||
337
+ !value.repoDigests.every((digest) => typeof digest === "string")) {
338
+ throw fault("PROTOCOL");
339
+ }
340
+ if (this.#app !== undefined &&
341
+ (value.id !== this.#app ||
342
+ !loopback(value.url) ||
343
+ value.url !== this.#url))
344
+ throw fault("PROTOCOL");
345
+ return {
346
+ id: value.id,
347
+ url: value.url,
348
+ image: value.image,
349
+ imageId: value.imageId,
350
+ repoDigests: [...value.repoDigests],
351
+ };
352
+ });
301
353
  }
302
- async close() {
354
+ close() {
303
355
  this.#closed = true;
304
- await this.#bridge.close();
356
+ return this.#bridge.close();
305
357
  }
306
358
  toJSON() {
307
359
  return "[App]";
@@ -310,6 +362,240 @@ class AppHandle {
310
362
  return "[App]";
311
363
  }
312
364
  }
365
+ function appID(value) {
366
+ return typeof value === "string" && /^[a-z0-9][a-z0-9-]{0,63}$/.test(value);
367
+ }
368
+ function loopback(value) {
369
+ try {
370
+ const address = new URL(value);
371
+ return (address.protocol === "http:" &&
372
+ address.hostname === "127.0.0.1" &&
373
+ address.port !== "" &&
374
+ address.username === "" &&
375
+ address.password === "" &&
376
+ address.search === "" &&
377
+ address.hash === "");
378
+ }
379
+ catch {
380
+ return false;
381
+ }
382
+ }
383
+ function pairAppStatus(value, id, url) {
384
+ if (!strings(value, ["id", "url", "state", "setup"]) ||
385
+ value.id !== id ||
386
+ !loopback(value.url) ||
387
+ value.url !== url ||
388
+ typeof value.installed !== "boolean" ||
389
+ !pairState(value.state) ||
390
+ (value.setup !== "ok" &&
391
+ value.setup !== "manual" &&
392
+ value.setup !== "incomplete" &&
393
+ value.setup !== "pending") ||
394
+ !Array.isArray(value.steps))
395
+ throw fault("PROTOCOL");
396
+ const seen = new Set();
397
+ const steps = value.steps.map((step) => {
398
+ if (!strings(step, ["id", "status"]) ||
399
+ !appID(step.id) ||
400
+ (step.status !== "ok" &&
401
+ step.status !== "manual" &&
402
+ step.status !== "fail" &&
403
+ step.status !== "skip") ||
404
+ (step.verified !== undefined && typeof step.verified !== "boolean") ||
405
+ (step.verified === true && step.status !== "skip"))
406
+ throw fault("PROTOCOL");
407
+ if (seen.has(step.id))
408
+ throw fault("PROTOCOL");
409
+ seen.add(step.id);
410
+ return {
411
+ id: step.id,
412
+ status: step.status,
413
+ verified: step.verified === true,
414
+ };
415
+ });
416
+ return {
417
+ id,
418
+ url: value.url,
419
+ installed: value.installed,
420
+ state: value.state,
421
+ setup: value.setup,
422
+ steps,
423
+ };
424
+ }
425
+ function linked(step) {
426
+ return step.status === "ok" || (step.status === "skip" && step.verified);
427
+ }
428
+ function pairInspection(value, ids, urls) {
429
+ if (!strings(value, ["link", "outcome", "reason"]) ||
430
+ !appID(value.link) ||
431
+ !Array.isArray(value.apps) ||
432
+ value.apps.length !== 2)
433
+ throw fault("PROTOCOL");
434
+ const rawApps = value.apps;
435
+ const apps = ids.map((id, index) => pairAppStatus(rawApps[index], id, urls[index] ?? ""));
436
+ const base = { link: value.link, apps };
437
+ const failed = apps.some((app) => app.steps.some((step) => step.status === "fail"));
438
+ if ((value.outcome === "failed") !== failed)
439
+ throw fault("PROTOCOL");
440
+ if (!failed &&
441
+ (value.outcome === "manual") !== apps.some((app) => app.setup === "manual"))
442
+ throw fault("PROTOCOL");
443
+ if (value.outcome === "complete" && value.reason === "verified") {
444
+ if (apps.some((app) => !app.installed ||
445
+ app.setup !== "ok" ||
446
+ app.steps.some((step) => step.status === "fail" || step.status === "manual")) ||
447
+ !apps[0]?.steps.some((step) => step.id === base.link && linked(step)))
448
+ throw fault("PROTOCOL");
449
+ return { ...base, outcome: "complete", reason: "verified" };
450
+ }
451
+ if ((value.outcome === "manual" || value.outcome === "failed") &&
452
+ value.reason === "setup")
453
+ return { ...base, outcome: value.outcome, reason: "setup" };
454
+ if (value.outcome === "prerequisite" &&
455
+ (value.reason === "absent" ||
456
+ value.reason === "unready" ||
457
+ value.reason === "not-run")) {
458
+ const absent = apps.some((app) => !app.installed);
459
+ const unready = apps.some((app) => app.setup !== "ok");
460
+ if ((value.reason === "absent") !== absent ||
461
+ (!absent && (value.reason === "unready") !== unready) ||
462
+ (!absent &&
463
+ !unready &&
464
+ apps[0]?.steps.some((step) => step.id === base.link && linked(step))))
465
+ throw fault("PROTOCOL");
466
+ return { ...base, outcome: "prerequisite", reason: value.reason };
467
+ }
468
+ throw fault("PROTOCOL");
469
+ }
470
+ function sonarrFilterWitness(value) {
471
+ if (!record(value) ||
472
+ Object.keys(value).length !== 3 ||
473
+ value.kind !== "sonarr-custom-filter-v1" ||
474
+ typeof value.objectId !== "number" ||
475
+ !Number.isSafeInteger(value.objectId) ||
476
+ value.objectId < 1 ||
477
+ value.objectId > 2147483647 ||
478
+ typeof value.contentSha256 !== "string" ||
479
+ !/^[a-f0-9]{64}$/.test(value.contentSha256))
480
+ throw fault("PROTOCOL");
481
+ return Object.freeze({
482
+ kind: "sonarr-custom-filter-v1",
483
+ objectId: value.objectId,
484
+ contentSha256: value.contentSha256,
485
+ });
486
+ }
487
+ class PairAppHandle {
488
+ #view;
489
+ constructor(bridge, id, url) {
490
+ this.#view = new AppHandle(bridge, url, id);
491
+ }
492
+ get url() {
493
+ return this.#view.url;
494
+ }
495
+ get recoveryPath() {
496
+ return this.#view.recoveryPath;
497
+ }
498
+ getApiKey(name) {
499
+ return this.#view.getApiKey(name);
500
+ }
501
+ credentials() {
502
+ return this.#view.credentials();
503
+ }
504
+ info() {
505
+ return this.#view.info();
506
+ }
507
+ toJSON() {
508
+ return "[PairApp]";
509
+ }
510
+ [inspect.custom]() {
511
+ return "[PairApp]";
512
+ }
513
+ }
514
+ class PairHandle {
515
+ #bridge;
516
+ #ids;
517
+ #initial;
518
+ #timeout;
519
+ #consumer;
520
+ #target;
521
+ #witnessEnabled;
522
+ constructor(bridge, consumer, target, consumerURL, targetURL, initial, timeout, witnessEnabled) {
523
+ this.#bridge = bridge;
524
+ this.#ids = [consumer, target];
525
+ this.#initial = initial;
526
+ this.#timeout = timeout;
527
+ this.#witnessEnabled = witnessEnabled;
528
+ this.#consumer = new PairAppHandle(bridge, consumer, consumerURL);
529
+ this.#target = new PairAppHandle(bridge, target, targetURL);
530
+ }
531
+ get consumer() {
532
+ return this.#consumer;
533
+ }
534
+ get target() {
535
+ return this.#target;
536
+ }
537
+ get initial() {
538
+ return pairInspection(this.#initial, this.#ids, this.#initial.apps.map((app) => app.url));
539
+ }
540
+ get recoveryPath() {
541
+ return this.#bridge.recoveryPath;
542
+ }
543
+ async #action(method, app) {
544
+ if (app !== undefined && !this.#ids.includes(app))
545
+ throw fault("STATE");
546
+ const result = await this.#bridge.request(method, { app }, this.#timeout + 1000);
547
+ try {
548
+ if (!record(result) || result.link !== this.#initial.link)
549
+ throw fault("PROTOCOL");
550
+ return pairInspection(result, this.#ids, this.#initial.apps.map((app) => app.url));
551
+ }
552
+ catch (error) {
553
+ return this.#bridge.failAndClose(error);
554
+ }
555
+ }
556
+ inspect() {
557
+ return this.#action("inspect");
558
+ }
559
+ setup(app) {
560
+ return this.#action("setup", app);
561
+ }
562
+ remove(app) {
563
+ if (!this.#ids.includes(app))
564
+ return Promise.reject(fault("STATE"));
565
+ return this.#action("remove", app);
566
+ }
567
+ install(app) {
568
+ if (!this.#ids.includes(app))
569
+ return Promise.reject(fault("STATE"));
570
+ return this.#action("install", app);
571
+ }
572
+ async #sonarrWitness(method) {
573
+ if (!this.#witnessEnabled)
574
+ throw fault("STATE");
575
+ const value = await this.#bridge.request(method);
576
+ try {
577
+ return sonarrFilterWitness(value);
578
+ }
579
+ catch (error) {
580
+ return this.#bridge.failAndClose(error);
581
+ }
582
+ }
583
+ createSonarrFilterWitness() {
584
+ return this.#sonarrWitness("createSonarrFilterWitness");
585
+ }
586
+ readSonarrFilterWitness() {
587
+ return this.#sonarrWitness("readSonarrFilterWitness");
588
+ }
589
+ close() {
590
+ return this.#bridge.close();
591
+ }
592
+ toJSON() {
593
+ return "[Pair]";
594
+ }
595
+ [inspect.custom]() {
596
+ return "[Pair]";
597
+ }
598
+ }
313
599
  class SelfHostHandle {
314
600
  #binary;
315
601
  #timeout;
@@ -350,6 +636,9 @@ class SelfHostHandle {
350
636
  }
351
637
  }
352
638
  }
639
+ get recoveryPath() {
640
+ return this.#manifest;
641
+ }
353
642
  async spinup(app, options) {
354
643
  const selected = options ?? {};
355
644
  if (this.#closed)
@@ -378,23 +667,99 @@ class SelfHostHandle {
378
667
  image: options.image,
379
668
  timeoutMs: this.#timeout,
380
669
  }, this.#timeout + 1000);
381
- if (!strings(value, ["url"]) ||
382
- !/^http:\/\/127\.0\.0\.1:\d+\/?$/.test(value.url)) {
383
- throw fault("PROTOCOL");
670
+ if (!strings(value, ["url"]) || !loopback(value.url)) {
671
+ throw fault("PROTOCOL", undefined, this.#manifest);
384
672
  }
385
673
  if (this.#closed)
386
- throw fault("STATE");
674
+ throw fault("STATE", undefined, this.#manifest);
387
675
  return new AppHandle(bridge, value.url);
388
676
  }
389
677
  catch (error) {
390
- await bridge.close();
678
+ try {
679
+ await bridge.close();
680
+ }
681
+ catch {
682
+ throw error;
683
+ }
391
684
  throw error;
392
685
  }
393
686
  }
687
+ async spinupPair(consumer, target, options) {
688
+ if (this.#closed)
689
+ throw fault("STATE");
690
+ if (!appID(consumer) ||
691
+ !appID(target) ||
692
+ consumer === target ||
693
+ !record(options) ||
694
+ (options.order !== "consumer-first" && options.order !== "target-first"))
695
+ throw fault("PROTOCOL");
696
+ if (options.sonarrFilterWitness !== undefined &&
697
+ (options.sonarrFilterWitness !== true ||
698
+ consumer !== "homarr" ||
699
+ target !== "sonarr" ||
700
+ typeof options.images?.sonarr !== "string" ||
701
+ !/^[a-zA-Z0-9][a-zA-Z0-9._/:-]*@sha256:[a-f0-9]{64}$/.test(options.images.sonarr) ||
702
+ options.images.sonarr.length > 512))
703
+ throw fault("PROTOCOL");
704
+ if (options.images !== undefined &&
705
+ (!record(options.images) ||
706
+ Object.entries(options.images).some(([id, image]) => (id !== consumer && id !== target) || typeof image !== "string")))
707
+ throw fault("PROTOCOL");
708
+ const bridge = new Bridge(this.#binary, this.#manifest);
709
+ this.#bridges.add(bridge);
710
+ const start = this.#spinupPair(bridge, consumer, target, options);
711
+ this.#starts.add(start);
712
+ try {
713
+ return await start;
714
+ }
715
+ finally {
716
+ this.#starts.delete(start);
717
+ }
718
+ }
719
+ async #spinupPair(bridge, consumer, target, options) {
720
+ try {
721
+ if (options.sonarrFilterWitness) {
722
+ const capabilities = await bridge.request("capabilities", {}, Math.min(35000, this.#timeout));
723
+ if (!record(capabilities) ||
724
+ Object.keys(capabilities).length !== 1 ||
725
+ capabilities.sonarrFilterWitness !== 1)
726
+ throw fault("PROTOCOL");
727
+ }
728
+ const value = await bridge.request("spinupPair", {
729
+ consumer,
730
+ target,
731
+ order: options.order,
732
+ images: options.images,
733
+ timeoutMs: this.#timeout,
734
+ sonarrFilterWitness: options.sonarrFilterWitness,
735
+ }, this.#timeout + 1000);
736
+ if (!record(value) ||
737
+ !strings(value.consumer, ["id", "url"]) ||
738
+ !strings(value.target, ["id", "url"]) ||
739
+ value.consumer.id !== consumer ||
740
+ value.target.id !== target ||
741
+ !loopback(value.consumer.url) ||
742
+ !loopback(value.target.url) ||
743
+ value.consumer.url === value.target.url)
744
+ throw fault("PROTOCOL", undefined, this.#manifest);
745
+ const initial = pairInspection(value.initial, [consumer, target], [value.consumer.url, value.target.url]);
746
+ if (options.sonarrFilterWitness && value.sonarrFilterWitness !== 1)
747
+ throw fault("PROTOCOL");
748
+ if (this.#closed)
749
+ throw fault("STATE", undefined, this.#manifest);
750
+ return new PairHandle(bridge, consumer, target, value.consumer.url, value.target.url, initial, this.#timeout, options.sonarrFilterWitness === true);
751
+ }
752
+ catch (error) {
753
+ return bridge.failAndClose(error);
754
+ }
755
+ }
394
756
  close() {
395
757
  if (!this.#closing) {
396
758
  this.#closed = true;
397
- this.#closing = this.#close();
759
+ this.#closing = this.#close().catch((error) => {
760
+ this.#closing = undefined;
761
+ throw error;
762
+ });
398
763
  }
399
764
  return this.#closing;
400
765
  }
@@ -402,7 +767,7 @@ class SelfHostHandle {
402
767
  const results = await Promise.allSettled([...this.#bridges].map((bridge) => bridge.close()));
403
768
  await Promise.allSettled([...this.#starts]);
404
769
  if (results.some((result) => result.status === "rejected"))
405
- throw fault("CLEANUP");
770
+ throw fault("CLEANUP", undefined, this.#manifest);
406
771
  if (this.#directory) {
407
772
  try {
408
773
  try {
@@ -417,7 +782,7 @@ class SelfHostHandle {
417
782
  rmdirSync(this.#directory);
418
783
  }
419
784
  catch {
420
- throw fault("CLEANUP");
785
+ throw fault("CLEANUP", undefined, this.#manifest);
421
786
  }
422
787
  }
423
788
  }
@@ -432,10 +797,41 @@ function isErrorCode(value) {
432
797
  return [...messages.keys()].some((code) => code === value);
433
798
  }
434
799
  function isStartupReason(value) {
435
- return [...startupReasons.keys()].some((reason) => reason === value);
800
+ return Object.hasOwn(startupReasons, value);
436
801
  }
437
802
  export function createSelfHost(options = {}) {
438
803
  if (!record(options))
439
804
  throw fault("PROTOCOL");
440
805
  return new SelfHostHandle(options);
441
806
  }
807
+ async function scopedLifetime(work, close, onCleanupError) {
808
+ let result;
809
+ try {
810
+ result = { ok: true, value: await work() };
811
+ }
812
+ catch (error) {
813
+ result = { ok: false, error };
814
+ }
815
+ try {
816
+ await close();
817
+ }
818
+ catch (error) {
819
+ if (result.ok)
820
+ throw error;
821
+ if (onCleanupError && error instanceof TestkitError)
822
+ await Promise.allSettled([
823
+ Promise.resolve().then(() => onCleanupError(error)),
824
+ ]);
825
+ }
826
+ if (!result.ok)
827
+ throw result.error;
828
+ return result.value;
829
+ }
830
+ export async function withApp(app, callback, options = {}) {
831
+ const selfHost = createSelfHost(options);
832
+ return scopedLifetime(async () => callback(await selfHost.spinup(app, options)), () => selfHost.close(), options.onCleanupError);
833
+ }
834
+ export async function withPair(consumer, target, callback, options) {
835
+ const selfHost = createSelfHost(options);
836
+ return scopedLifetime(async () => callback(await selfHost.spinupPair(consumer, target, options)), () => selfHost.close(), options.onCleanupError);
837
+ }