@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/README.md +311 -227
- package/binary.mjs +24 -12
- package/examples/homarr-sonarr.mjs +66 -0
- package/examples/sonarr.mjs +3 -8
- package/index.d.mts +65 -3
- package/index.mjs +480 -84
- package/package.json +4 -1
- package/reasons.d.mts +15 -0
- package/reasons.mjs +14 -0
- package/setup.mjs +58 -34
package/binary.mjs
CHANGED
|
@@ -1,21 +1,34 @@
|
|
|
1
1
|
import { existsSync, lstatSync, readFileSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { isAbsolute, join } from "node:path";
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
4
|
+
export function packageVersion(path) {
|
|
5
|
+
try {
|
|
6
|
+
const metadata = JSON.parse(readFileSync(path, "utf8"));
|
|
7
|
+
if (typeof metadata === "object" &&
|
|
8
|
+
metadata !== null &&
|
|
9
|
+
"name" in metadata &&
|
|
10
|
+
metadata.name === "@ajnart/self-host-testkit" &&
|
|
11
|
+
"version" in metadata &&
|
|
12
|
+
typeof metadata.version === "string" &&
|
|
13
|
+
/^\d+\.\d+\.\d+$/.test(metadata.version)) {
|
|
14
|
+
return metadata.version;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
throw new Error("Invalid testkit package metadata.");
|
|
19
|
+
}
|
|
20
|
+
throw new Error("Invalid testkit package metadata.");
|
|
11
21
|
}
|
|
12
|
-
export const version =
|
|
13
|
-
export function binaryDirectory() {
|
|
22
|
+
export const version = packageVersion(new URL("./package.json", import.meta.url));
|
|
23
|
+
export function binaryDirectory(exactVersion = version) {
|
|
24
|
+
if (!/^\d+\.\d+\.\d+$/.test(exactVersion)) {
|
|
25
|
+
throw new Error("Invalid testkit helper version.");
|
|
26
|
+
}
|
|
14
27
|
let cache = join(homedir(), ".cache");
|
|
15
28
|
if (process.env.XDG_CACHE_HOME && isAbsolute(process.env.XDG_CACHE_HOME)) {
|
|
16
29
|
cache = process.env.XDG_CACHE_HOME;
|
|
17
30
|
}
|
|
18
|
-
return join(cache, "self-host-testkit",
|
|
31
|
+
return join(cache, "self-host-testkit", exactVersion, `${process.platform}-${process.arch}`);
|
|
19
32
|
}
|
|
20
33
|
export function privateDirectory(path) {
|
|
21
34
|
const info = lstatSync(path);
|
|
@@ -23,8 +36,7 @@ export function privateDirectory(path) {
|
|
|
23
36
|
info.uid === process.getuid?.() &&
|
|
24
37
|
(info.mode & 0o077) === 0);
|
|
25
38
|
}
|
|
26
|
-
export function installedBinary() {
|
|
27
|
-
const directory = binaryDirectory();
|
|
39
|
+
export function installedBinary(directory = binaryDirectory()) {
|
|
28
40
|
const binary = join(directory, "self-host-testkit");
|
|
29
41
|
if (!existsSync(binary))
|
|
30
42
|
return "self-host-testkit";
|
|
@@ -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
|
+
);
|
package/examples/sonarr.mjs
CHANGED
|
@@ -1,9 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { withApp } from "@ajnart/self-host-testkit";
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
try {
|
|
6
|
-
const sonarr = await selfHost.spinup("sonarr");
|
|
3
|
+
await withApp("sonarr", async (sonarr) => {
|
|
7
4
|
const response = await fetch(`${sonarr.url}/api/v3/system/status`, {
|
|
8
5
|
headers: { "X-Api-Key": await sonarr.getApiKey() },
|
|
9
6
|
signal: AbortSignal.timeout(10_000),
|
|
@@ -15,6 +12,4 @@ try {
|
|
|
15
12
|
throw new Error("Sonarr returned an unexpected identity.");
|
|
16
13
|
}
|
|
17
14
|
console.log(`Connected to Sonarr ${status.version}.`);
|
|
18
|
-
}
|
|
19
|
-
await selfHost.close();
|
|
20
|
-
}
|
|
15
|
+
});
|
package/index.d.mts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { type TestkitStartupReason } from "./reasons.mjs";
|
|
2
|
+
export type { TestkitStartupReason } from "./reasons.mjs";
|
|
1
3
|
export interface Credentials {
|
|
2
4
|
readonly username: string;
|
|
3
5
|
readonly password: string;
|
|
@@ -13,6 +15,7 @@ export interface AppInfo {
|
|
|
13
15
|
}
|
|
14
16
|
export interface App {
|
|
15
17
|
readonly url: string;
|
|
18
|
+
readonly recoveryPath?: string;
|
|
16
19
|
getApiKey(name?: string): Promise<string>;
|
|
17
20
|
credentials(): Promise<Credentials>;
|
|
18
21
|
info(): Promise<AppInfo>;
|
|
@@ -22,7 +25,60 @@ export interface SpinupOptions {
|
|
|
22
25
|
image?: string;
|
|
23
26
|
}
|
|
24
27
|
export interface SelfHost {
|
|
28
|
+
readonly recoveryPath?: string;
|
|
25
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>;
|
|
26
82
|
close(): Promise<void>;
|
|
27
83
|
}
|
|
28
84
|
export interface SelfHostOptions {
|
|
@@ -30,11 +86,17 @@ export interface SelfHostOptions {
|
|
|
30
86
|
timeoutMs?: number;
|
|
31
87
|
manifest?: string;
|
|
32
88
|
}
|
|
33
|
-
export type TestkitErrorCode = "PROTOCOL" | "STATE" | "START" | "CREDENTIALS" | "INFO" | "CLEANUP" | "TIMEOUT" | "TRANSPORT" | "BINARY";
|
|
34
|
-
export type TestkitStartupReason = "APP" | "OPTIONS" | "DOCKER" | "CAPABILITIES" | "PORT" | "OWNERSHIP" | "LIMITS" | "LOCK" | "UNSUPPORTED" | "STORAGE" | "CONTAINER" | "SETUP";
|
|
89
|
+
export type TestkitErrorCode = "PROTOCOL" | "STATE" | "START" | "CREDENTIALS" | "INFO" | "CLEANUP" | "TIMEOUT" | "TRANSPORT" | "OPERATION" | "CANCELED" | "BINARY";
|
|
35
90
|
export declare class TestkitError extends Error {
|
|
36
91
|
readonly code: TestkitErrorCode;
|
|
37
92
|
readonly reason: TestkitStartupReason | undefined;
|
|
38
|
-
|
|
93
|
+
readonly cleanupFailed: boolean;
|
|
94
|
+
readonly recoveryPath?: string;
|
|
95
|
+
constructor(code: TestkitErrorCode, reason?: TestkitStartupReason, recoveryPath?: string, cleanupFailed?: boolean);
|
|
39
96
|
}
|
|
40
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>;
|