@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.
@@ -0,0 +1,66 @@
1
+ import { withPair } from "@ajnart/self-host-testkit";
2
+
3
+ const order = process.argv[2] ?? "consumer-first";
4
+ if (order !== "consumer-first" && order !== "target-first") {
5
+ throw new Error("Choose consumer-first or target-first.");
6
+ }
7
+
8
+ await withPair(
9
+ "homarr",
10
+ "sonarr",
11
+ async (pair) => {
12
+ if ((await pair.inspect()).outcome !== "complete") {
13
+ throw new Error(
14
+ "Production Setup/Link has a failed or Manual prerequisite.",
15
+ );
16
+ }
17
+ const response = await fetch(`${pair.target.url}/api/v3/system/status`, {
18
+ headers: { "X-Api-Key": await pair.target.getApiKey() },
19
+ signal: AbortSignal.timeout(10_000),
20
+ });
21
+ if (!response.ok || (await response.json()).appName !== "Sonarr") {
22
+ throw new Error("Sonarr identity did not authenticate.");
23
+ }
24
+ const integrationId = await pair.consumer.getApiKey("linked_sonarr");
25
+ const apiKey = await pair.consumer.getApiKey();
26
+ const snapshot = async () => {
27
+ const input = encodeURIComponent(
28
+ JSON.stringify({ json: { integrationId } }),
29
+ );
30
+ const result = await fetch(
31
+ `${pair.consumer.url}/api/trpc/widget.stats.snapshot?input=${input}`,
32
+ {
33
+ headers: { ApiKey: apiKey },
34
+ signal: AbortSignal.timeout(10_000),
35
+ },
36
+ );
37
+ if (!result.ok) throw new Error("Homarr Stats could not be read.");
38
+ const envelope = await result.json();
39
+ const sample = envelope.result?.data?.json;
40
+ if (
41
+ envelope.error ||
42
+ !sample ||
43
+ sample.error !== false ||
44
+ sample.stale !== false ||
45
+ !Number.isFinite(sample.updatedAt) ||
46
+ sample.updatedAt <= 0 ||
47
+ !["shows", "downloaded", "missing", "queued"].every(
48
+ (key) =>
49
+ Number.isInteger(sample.values?.[key]) && sample.values[key] >= 0,
50
+ )
51
+ ) {
52
+ throw new Error("Homarr did not return meaningful Sonarr Stats.");
53
+ }
54
+ return sample.updatedAt;
55
+ };
56
+ const before = await snapshot();
57
+ if (
58
+ (await pair.setup("homarr")).outcome !== "complete" ||
59
+ (await snapshot()) <= before
60
+ ) {
61
+ throw new Error("Production Link verification did not advance Stats.");
62
+ }
63
+ console.log("Production Homarr/Sonarr Link and advancing Stats verified.");
64
+ },
65
+ { order },
66
+ );
@@ -0,0 +1,15 @@
1
+ import { withApp } from "@ajnart/self-host-testkit";
2
+
3
+ await withApp("sonarr", async (sonarr) => {
4
+ const response = await fetch(`${sonarr.url}/api/v3/system/status`, {
5
+ headers: { "X-Api-Key": await sonarr.getApiKey() },
6
+ signal: AbortSignal.timeout(10_000),
7
+ });
8
+ if (!response.ok)
9
+ throw new Error("Sonarr rejected the authenticated request.");
10
+ const status = await response.json();
11
+ if (status.appName !== "Sonarr" || typeof status.version !== "string") {
12
+ throw new Error("Sonarr returned an unexpected identity.");
13
+ }
14
+ console.log(`Connected to Sonarr ${status.version}.`);
15
+ });
package/index.d.mts ADDED
@@ -0,0 +1,102 @@
1
+ import { type TestkitStartupReason } from "./reasons.mjs";
2
+ export type { TestkitStartupReason } from "./reasons.mjs";
3
+ export interface Credentials {
4
+ readonly username: string;
5
+ readonly password: string;
6
+ readonly email: string;
7
+ readonly apiKey: string;
8
+ }
9
+ export interface AppInfo {
10
+ readonly id: string;
11
+ readonly url: string;
12
+ readonly image: string;
13
+ readonly imageId: string;
14
+ readonly repoDigests: string[];
15
+ }
16
+ export interface App {
17
+ readonly url: string;
18
+ readonly recoveryPath?: string;
19
+ getApiKey(name?: string): Promise<string>;
20
+ credentials(): Promise<Credentials>;
21
+ info(): Promise<AppInfo>;
22
+ close(): Promise<void>;
23
+ }
24
+ export interface SpinupOptions {
25
+ image?: string;
26
+ }
27
+ export interface SelfHost {
28
+ readonly recoveryPath?: string;
29
+ spinup(app: string, options?: SpinupOptions): Promise<App>;
30
+ spinupPair(consumer: string, target: string, options: PairOptions): Promise<Pair>;
31
+ close(): Promise<void>;
32
+ }
33
+ export interface PairOptions {
34
+ order: "consumer-first" | "target-first";
35
+ images?: Readonly<Record<string, string>>;
36
+ sonarrFilterWitness?: true;
37
+ }
38
+ export interface SonarrFilterWitness {
39
+ readonly kind: "sonarr-custom-filter-v1";
40
+ readonly objectId: number;
41
+ readonly contentSha256: string;
42
+ }
43
+ declare const pairStates: readonly ["running", "exited", "created", "restarting", "paused", "missing", "unknown", "dead", "removing", "degraded"];
44
+ export type PairAppState = (typeof pairStates)[number];
45
+ export interface PairAppStatus {
46
+ readonly id: string;
47
+ readonly url: string;
48
+ readonly installed: boolean;
49
+ readonly state: PairAppState;
50
+ readonly setup: "ok" | "manual" | "incomplete" | "pending";
51
+ readonly steps: ReadonlyArray<{
52
+ readonly id: string;
53
+ readonly status: "ok" | "manual" | "fail" | "skip";
54
+ readonly verified: boolean;
55
+ }>;
56
+ }
57
+ export type PairInspection = {
58
+ readonly link: string;
59
+ readonly apps: readonly PairAppStatus[];
60
+ } & ({
61
+ readonly outcome: "complete";
62
+ readonly reason: "verified";
63
+ } | {
64
+ readonly outcome: "manual" | "failed";
65
+ readonly reason: "setup";
66
+ } | {
67
+ readonly outcome: "prerequisite";
68
+ readonly reason: "absent" | "unready" | "not-run";
69
+ });
70
+ export type PairApp = Omit<App, "close">;
71
+ export interface Pair {
72
+ readonly consumer: PairApp;
73
+ readonly target: PairApp;
74
+ readonly initial: PairInspection;
75
+ readonly recoveryPath?: string;
76
+ inspect(): Promise<PairInspection>;
77
+ setup(app?: string): Promise<PairInspection>;
78
+ remove(app: string): Promise<PairInspection>;
79
+ install(app: string): Promise<PairInspection>;
80
+ createSonarrFilterWitness(): Promise<SonarrFilterWitness>;
81
+ readSonarrFilterWitness(): Promise<SonarrFilterWitness>;
82
+ close(): Promise<void>;
83
+ }
84
+ export interface SelfHostOptions {
85
+ binary?: string;
86
+ timeoutMs?: number;
87
+ manifest?: string;
88
+ }
89
+ export type TestkitErrorCode = "PROTOCOL" | "STATE" | "START" | "CREDENTIALS" | "INFO" | "CLEANUP" | "TIMEOUT" | "TRANSPORT" | "OPERATION" | "CANCELED" | "BINARY";
90
+ export declare class TestkitError extends Error {
91
+ readonly code: TestkitErrorCode;
92
+ readonly reason: TestkitStartupReason | undefined;
93
+ readonly cleanupFailed: boolean;
94
+ readonly recoveryPath?: string;
95
+ constructor(code: TestkitErrorCode, reason?: TestkitStartupReason, recoveryPath?: string, cleanupFailed?: boolean);
96
+ }
97
+ export declare function createSelfHost(options?: SelfHostOptions): SelfHost;
98
+ export interface ScopedOptions {
99
+ onCleanupError?: (error: TestkitError) => void | Promise<void>;
100
+ }
101
+ export declare function withApp<T>(app: string, callback: (app: App) => T | Promise<T>, options?: SelfHostOptions & SpinupOptions & ScopedOptions): Promise<T>;
102
+ export declare function withPair<T>(consumer: string, target: string, callback: (pair: Pair) => T | Promise<T>, options: SelfHostOptions & PairOptions & ScopedOptions): Promise<T>;