@ajnart/self-host-testkit 0.0.0-stage → 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 ADDED
@@ -0,0 +1,837 @@
1
+ import { spawn } from "node:child_process";
2
+ import { randomBytes } from "node:crypto";
3
+ import { mkdtempSync, rmdirSync, unlinkSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { isAbsolute, join } from "node:path";
6
+ import { inspect } from "node:util";
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
+ }
24
+ const MAX_MESSAGE = 65536;
25
+ const CLEANUP_TIMEOUT = 50000;
26
+ const messages = new Map([
27
+ ["PROTOCOL", "Invalid testkit protocol."],
28
+ ["STATE", "Fixture is not available."],
29
+ ["START", "Fixture startup failed."],
30
+ ["CREDENTIALS", "Fixture credentials are not available."],
31
+ ["INFO", "Fixture information is not available."],
32
+ ["CLEANUP", "Fixture cleanup failed; run manifest cleanup."],
33
+ ["TIMEOUT", "Fixture operation timed out."],
34
+ ["TRANSPORT", "Testkit helper transport failed."],
35
+ ["OPERATION", "Fixture pair action or inspection failed."],
36
+ ["CANCELED", "Fixture operation was canceled."],
37
+ [
38
+ "BINARY",
39
+ "Testkit helper not found. Run self-host-testkit-setup or set SELF_HOST_TESTKIT_BINARY.",
40
+ ],
41
+ ]);
42
+ export class TestkitError extends Error {
43
+ code;
44
+ reason;
45
+ cleanupFailed;
46
+ constructor(code, reason, recoveryPath, cleanupFailed = false) {
47
+ let message = messages.get(code);
48
+ if ((code === "START" || code === "OPERATION") && reason)
49
+ message = `${message} ${startupReasons[reason]}`;
50
+ super(message);
51
+ this.name = "TestkitError";
52
+ this.code = code;
53
+ this.reason = reason;
54
+ this.cleanupFailed = cleanupFailed;
55
+ Object.defineProperty(this, "recoveryPath", { value: recoveryPath });
56
+ }
57
+ }
58
+ function fault(code, reason, recoveryPath, cleanupFailed = false) {
59
+ return new TestkitError(code, reason, recoveryPath, cleanupFailed);
60
+ }
61
+ function record(value) {
62
+ return value !== null && typeof value === "object" && !Array.isArray(value);
63
+ }
64
+ function strings(value, fields) {
65
+ return (record(value) && fields.every((field) => typeof value[field] === "string"));
66
+ }
67
+ function privateCredentials(value) {
68
+ if (!strings(value, ["username", "password", "email", "apiKey"])) {
69
+ throw fault("PROTOCOL");
70
+ }
71
+ const credentials = Object.create(null);
72
+ for (const field of ["username", "password", "email", "apiKey"]) {
73
+ Object.defineProperty(credentials, field, { get: () => value[field] });
74
+ }
75
+ Object.defineProperty(credentials, "toJSON", {
76
+ value: () => "[Credentials]",
77
+ });
78
+ Object.defineProperty(credentials, inspect.custom, {
79
+ value: () => "[Credentials]",
80
+ });
81
+ return Object.freeze(credentials);
82
+ }
83
+ function runCleanup(binary, manifest, owner) {
84
+ return new Promise((resolve, reject) => {
85
+ const child = spawn(binary, ["--cleanup-manifest", manifest, "--owner", owner], { stdio: "ignore" });
86
+ let failed = false;
87
+ const timer = setTimeout(() => {
88
+ failed = true;
89
+ child.kill("SIGKILL");
90
+ }, CLEANUP_TIMEOUT);
91
+ child.once("error", () => {
92
+ failed = true;
93
+ });
94
+ child.once("close", (code) => {
95
+ clearTimeout(timer);
96
+ if (!failed && code === 0)
97
+ resolve(undefined);
98
+ else
99
+ reject(fault("CLEANUP", undefined, manifest));
100
+ });
101
+ });
102
+ }
103
+ class Bridge {
104
+ #child;
105
+ #binary;
106
+ #manifest;
107
+ #owner = randomBytes(16).toString("hex");
108
+ #pending = new Map();
109
+ #sequence = 0;
110
+ #buffer = Buffer.alloc(0);
111
+ #failed = false;
112
+ #closing;
113
+ #exited;
114
+ #spawned = false;
115
+ constructor(binary, manifest) {
116
+ this.#binary = binary;
117
+ this.#manifest = manifest;
118
+ this.#child = spawn(binary, ["--manifest", manifest, "--owner", this.#owner], { stdio: ["pipe", "pipe", "ignore"] });
119
+ this.#child.once("spawn", () => {
120
+ this.#spawned = true;
121
+ });
122
+ this.#exited = new Promise((resolve) => {
123
+ this.#child.once("exit", () => {
124
+ this.#fail("TRANSPORT");
125
+ resolve(undefined);
126
+ });
127
+ this.#child.once("error", (error) => {
128
+ let code = "TRANSPORT";
129
+ if (error.code === "ENOENT")
130
+ code = "BINARY";
131
+ this.#fail(code);
132
+ resolve(undefined);
133
+ });
134
+ });
135
+ this.#child.stdin.on("error", () => this.#fail("TRANSPORT"));
136
+ this.#child.stdout.on("data", (chunk) => this.#receive(chunk));
137
+ }
138
+ get recoveryPath() {
139
+ return this.#manifest;
140
+ }
141
+ #fault(code, reason, cleanupFailed = false) {
142
+ return fault(code, reason, this.#manifest, cleanupFailed);
143
+ }
144
+ #fail(code) {
145
+ this.#failed = true;
146
+ this.#buffer = Buffer.alloc(0);
147
+ for (const pending of this.#pending.values()) {
148
+ clearTimeout(pending.timer);
149
+ pending.reject(this.#fault(code));
150
+ }
151
+ this.#pending.clear();
152
+ this.#child.kill("SIGTERM");
153
+ }
154
+ #receive(chunk) {
155
+ if (this.#failed)
156
+ return;
157
+ if (this.#buffer.length + chunk.length > MAX_MESSAGE) {
158
+ this.#fail("PROTOCOL");
159
+ return;
160
+ }
161
+ this.#buffer = Buffer.concat([this.#buffer, chunk]);
162
+ let newline = this.#buffer.indexOf(10);
163
+ while (newline !== -1) {
164
+ const line = this.#buffer.subarray(0, newline).toString("utf8");
165
+ this.#buffer = this.#buffer.subarray(newline + 1);
166
+ try {
167
+ const response = JSON.parse(line);
168
+ if (!record(response) ||
169
+ response.version !== 1 ||
170
+ typeof response.id !== "number" ||
171
+ !Number.isSafeInteger(response.id) ||
172
+ !this.#pending.has(response.id) ||
173
+ Object.hasOwn(response, "error") === Object.hasOwn(response, "result")) {
174
+ throw this.#fault("PROTOCOL");
175
+ }
176
+ const pending = this.#pending.get(response.id);
177
+ if (!pending)
178
+ throw this.#fault("PROTOCOL");
179
+ this.#pending.delete(response.id);
180
+ clearTimeout(pending.timer);
181
+ if (Object.hasOwn(response, "error")) {
182
+ if (!record(response.error) ||
183
+ typeof response.error.code !== "string" ||
184
+ !isErrorCode(response.error.code)) {
185
+ pending.reject(this.#fault("PROTOCOL"));
186
+ this.#fail("PROTOCOL");
187
+ return;
188
+ }
189
+ let reason;
190
+ if (response.error.reason !== undefined) {
191
+ if ((response.error.code !== "START" &&
192
+ response.error.code !== "OPERATION") ||
193
+ typeof response.error.reason !== "string" ||
194
+ !isStartupReason(response.error.reason)) {
195
+ pending.reject(this.#fault("PROTOCOL"));
196
+ this.#fail("PROTOCOL");
197
+ return;
198
+ }
199
+ reason = response.error.reason;
200
+ }
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));
208
+ }
209
+ else {
210
+ pending.resolve(response.result);
211
+ }
212
+ }
213
+ catch {
214
+ this.#fail("PROTOCOL");
215
+ return;
216
+ }
217
+ newline = this.#buffer.indexOf(10);
218
+ }
219
+ }
220
+ request(method, params = {}, timeout = 35000) {
221
+ if (this.#failed)
222
+ return Promise.reject(this.#fault("TRANSPORT"));
223
+ if (this.#closing)
224
+ return Promise.reject(this.#fault("STATE"));
225
+ const id = ++this.#sequence;
226
+ return new Promise((resolve, reject) => {
227
+ const timer = setTimeout(() => this.#fail("TIMEOUT"), timeout);
228
+ this.#pending.set(id, { resolve, reject, timer });
229
+ const message = JSON.stringify({ version: 1, id, method, params });
230
+ if (Buffer.byteLength(message) >= MAX_MESSAGE) {
231
+ this.#fail("PROTOCOL");
232
+ return;
233
+ }
234
+ this.#child.stdin.write(`${message}\n`, (error) => {
235
+ if (error)
236
+ this.#fail("TRANSPORT");
237
+ });
238
+ });
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
+ }
255
+ close() {
256
+ if (!this.#closing) {
257
+ this.#closing = this.#close().catch((error) => {
258
+ this.#closing = undefined;
259
+ throw error;
260
+ });
261
+ }
262
+ return this.#closing;
263
+ }
264
+ async #close() {
265
+ if (!this.#failed) {
266
+ try {
267
+ await this.request("close", {}, CLEANUP_TIMEOUT);
268
+ }
269
+ catch {
270
+ this.#fail("TRANSPORT");
271
+ }
272
+ }
273
+ this.#child.stdin.end();
274
+ const kill = setTimeout(() => this.#child.kill("SIGKILL"), CLEANUP_TIMEOUT);
275
+ await this.#exited;
276
+ clearTimeout(kill);
277
+ if (!this.#spawned)
278
+ return;
279
+ await runCleanup(this.#binary, this.#manifest, this.#owner);
280
+ }
281
+ [inspect.custom]() {
282
+ return "[TestkitBridge]";
283
+ }
284
+ }
285
+ class AppHandle {
286
+ #bridge;
287
+ #url;
288
+ #closed = false;
289
+ #app;
290
+ constructor(bridge, url, app) {
291
+ this.#bridge = bridge;
292
+ this.#url = url;
293
+ this.#app = app;
294
+ }
295
+ get url() {
296
+ return this.#url;
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
+ }
311
+ async getApiKey(name = "") {
312
+ if (this.#closed || typeof name !== "string")
313
+ throw fault("STATE");
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
+ });
323
+ }
324
+ async credentials() {
325
+ if (this.#closed)
326
+ throw fault("STATE");
327
+ const value = await this.#bridge.request("credentials", { app: this.#app });
328
+ return this.#validate(() => privateCredentials(value));
329
+ }
330
+ async info() {
331
+ if (this.#closed)
332
+ throw fault("STATE");
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
+ });
353
+ }
354
+ close() {
355
+ this.#closed = true;
356
+ return this.#bridge.close();
357
+ }
358
+ toJSON() {
359
+ return "[App]";
360
+ }
361
+ [inspect.custom]() {
362
+ return "[App]";
363
+ }
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
+ }
599
+ class SelfHostHandle {
600
+ #binary;
601
+ #timeout;
602
+ #manifest;
603
+ #directory;
604
+ #bridges = new Set();
605
+ #starts = new Set();
606
+ #closed = false;
607
+ #closing;
608
+ constructor(options) {
609
+ this.#binary =
610
+ options.binary ??
611
+ process.env.SELF_HOST_TESTKIT_BINARY ??
612
+ installedBinary();
613
+ this.#timeout = options.timeoutMs ?? 720000;
614
+ if (typeof this.#binary !== "string" ||
615
+ this.#binary.length === 0 ||
616
+ !Number.isSafeInteger(this.#timeout) ||
617
+ this.#timeout < 1 ||
618
+ this.#timeout > 840000) {
619
+ throw fault("PROTOCOL");
620
+ }
621
+ const manifest = options.manifest ?? process.env.SELF_HOST_TESTKIT_MANIFEST;
622
+ if (manifest !== undefined &&
623
+ (typeof manifest !== "string" || !isAbsolute(manifest))) {
624
+ throw fault("PROTOCOL");
625
+ }
626
+ if (manifest) {
627
+ this.#manifest = manifest;
628
+ }
629
+ else {
630
+ try {
631
+ this.#directory = mkdtempSync(join(tmpdir(), "self-host-testkit-"));
632
+ this.#manifest = join(this.#directory, "manifest.jsonl");
633
+ }
634
+ catch {
635
+ throw fault("START");
636
+ }
637
+ }
638
+ }
639
+ get recoveryPath() {
640
+ return this.#manifest;
641
+ }
642
+ async spinup(app, options) {
643
+ const selected = options ?? {};
644
+ if (this.#closed)
645
+ throw fault("STATE");
646
+ if (!record(selected) ||
647
+ typeof app !== "string" ||
648
+ !/^[a-z0-9][a-z0-9-]{0,63}$/.test(app) ||
649
+ (selected.image !== undefined && typeof selected.image !== "string")) {
650
+ throw fault("PROTOCOL");
651
+ }
652
+ const bridge = new Bridge(this.#binary, this.#manifest);
653
+ this.#bridges.add(bridge);
654
+ const start = this.#spinup(bridge, app, selected);
655
+ this.#starts.add(start);
656
+ try {
657
+ return await start;
658
+ }
659
+ finally {
660
+ this.#starts.delete(start);
661
+ }
662
+ }
663
+ async #spinup(bridge, app, options) {
664
+ try {
665
+ const value = await bridge.request("spinup", {
666
+ app,
667
+ image: options.image,
668
+ timeoutMs: this.#timeout,
669
+ }, this.#timeout + 1000);
670
+ if (!strings(value, ["url"]) || !loopback(value.url)) {
671
+ throw fault("PROTOCOL", undefined, this.#manifest);
672
+ }
673
+ if (this.#closed)
674
+ throw fault("STATE", undefined, this.#manifest);
675
+ return new AppHandle(bridge, value.url);
676
+ }
677
+ catch (error) {
678
+ try {
679
+ await bridge.close();
680
+ }
681
+ catch {
682
+ throw error;
683
+ }
684
+ throw error;
685
+ }
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
+ }
756
+ close() {
757
+ if (!this.#closing) {
758
+ this.#closed = true;
759
+ this.#closing = this.#close().catch((error) => {
760
+ this.#closing = undefined;
761
+ throw error;
762
+ });
763
+ }
764
+ return this.#closing;
765
+ }
766
+ async #close() {
767
+ const results = await Promise.allSettled([...this.#bridges].map((bridge) => bridge.close()));
768
+ await Promise.allSettled([...this.#starts]);
769
+ if (results.some((result) => result.status === "rejected"))
770
+ throw fault("CLEANUP", undefined, this.#manifest);
771
+ if (this.#directory) {
772
+ try {
773
+ try {
774
+ unlinkSync(this.#manifest);
775
+ }
776
+ catch (error) {
777
+ if (!(error instanceof Error) ||
778
+ !("code" in error) ||
779
+ error.code !== "ENOENT")
780
+ throw error;
781
+ }
782
+ rmdirSync(this.#directory);
783
+ }
784
+ catch {
785
+ throw fault("CLEANUP", undefined, this.#manifest);
786
+ }
787
+ }
788
+ }
789
+ toJSON() {
790
+ return "[SelfHost]";
791
+ }
792
+ [inspect.custom]() {
793
+ return "[SelfHost]";
794
+ }
795
+ }
796
+ function isErrorCode(value) {
797
+ return [...messages.keys()].some((code) => code === value);
798
+ }
799
+ function isStartupReason(value) {
800
+ return Object.hasOwn(startupReasons, value);
801
+ }
802
+ export function createSelfHost(options = {}) {
803
+ if (!record(options))
804
+ throw fault("PROTOCOL");
805
+ return new SelfHostHandle(options);
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
+ }