@norskvideo/ctl-test-harness 0.1.13 → 0.1.15
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/image-surface.d.ts +45 -0
- package/image-surface.js +117 -0
- package/index.d.ts +4 -0
- package/index.js +6 -0
- package/package.json +15 -1
- package/smoke.d.ts +147 -0
- package/smoke.js +335 -0
- package/source-pump.d.ts +42 -5
- package/source-pump.js +59 -13
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export declare const DEFAULT_TEMPLATE_TAR_ENTRIES: string[];
|
|
2
|
+
type RunResult = {
|
|
3
|
+
status: number | null;
|
|
4
|
+
stdout: string;
|
|
5
|
+
stderr: string;
|
|
6
|
+
};
|
|
7
|
+
type Runner = (cmd: string, args: string[]) => RunResult;
|
|
8
|
+
type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;
|
|
9
|
+
export interface ImageSurfaceExpect {
|
|
10
|
+
/** Always polled to 200; the trimmed body is compared when `body` is given. */
|
|
11
|
+
healthz?: {
|
|
12
|
+
body?: string;
|
|
13
|
+
};
|
|
14
|
+
manifest?: {
|
|
15
|
+
name: string;
|
|
16
|
+
};
|
|
17
|
+
/** URL path of the template tar, e.g. `/api/product-template/default/funke-live`.
|
|
18
|
+
* `entries` default to the four template files; each must be a substring of
|
|
19
|
+
* some entry name, so a `dashboards/` prefix matches the directory entry. */
|
|
20
|
+
templateTar?: {
|
|
21
|
+
path: string;
|
|
22
|
+
entries?: string[];
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
export interface ImageSurfaceResult {
|
|
26
|
+
manifest?: unknown;
|
|
27
|
+
tarEntries?: string[];
|
|
28
|
+
}
|
|
29
|
+
export interface AssertImageSurfaceOptions {
|
|
30
|
+
image: string;
|
|
31
|
+
/** Host port to publish on 127.0.0.1. Default: a free ephemeral port. */
|
|
32
|
+
port?: number;
|
|
33
|
+
containerPort?: number;
|
|
34
|
+
expect: ImageSurfaceExpect;
|
|
35
|
+
healthTimeoutMs?: number;
|
|
36
|
+
pollIntervalMs?: number;
|
|
37
|
+
/** Test seams. */
|
|
38
|
+
run?: Runner;
|
|
39
|
+
fetch?: FetchLike;
|
|
40
|
+
}
|
|
41
|
+
/** Entry names from a ustar stream: walk the 512-byte headers, skipping each
|
|
42
|
+
* body. Throws when the first block carries no ustar magic. */
|
|
43
|
+
export declare function tarEntryNames(tar: Uint8Array): string[];
|
|
44
|
+
export declare function assertImageSurface(opts: AssertImageSurfaceOptions): Promise<ImageSurfaceResult>;
|
|
45
|
+
export {};
|
package/image-surface.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// The FAST half of a product's image tier: `docker run` the built control-plane
|
|
2
|
+
// image loopback-published (mirroring docker-runner's `-p 127.0.0.1:<host>:4321`)
|
|
3
|
+
// and check the HTTP surface `product add` probes — /healthz, /manifest.json
|
|
4
|
+
// and a product-template tar. Docker only: no daemon, no licence. Catches the
|
|
5
|
+
// bundling and asset-layout regressions (a flat layout silently drops
|
|
6
|
+
// dashboards/ + components/ + assets/ from the tar) in seconds, before any
|
|
7
|
+
// launch. The docker runner and fetch are injectable so the logic is tested
|
|
8
|
+
// without a container.
|
|
9
|
+
import { spawnSync } from "node:child_process";
|
|
10
|
+
import { createServer } from "node:net";
|
|
11
|
+
import { pollUntil } from "./poll.js";
|
|
12
|
+
const DEFAULT_CONTAINER_PORT = 4321;
|
|
13
|
+
const DEFAULT_HEALTH_TIMEOUT_MS = 30_000;
|
|
14
|
+
export const DEFAULT_TEMPLATE_TAR_ENTRIES = ["manifest.json", "compose.yml", "workflow.yml", "parameters.yaml"];
|
|
15
|
+
const defaultRunner = (cmd, args) => {
|
|
16
|
+
const r = spawnSync(cmd, args, { encoding: "utf-8" });
|
|
17
|
+
return { status: r.status, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
|
|
18
|
+
};
|
|
19
|
+
/** Entry names from a ustar stream: walk the 512-byte headers, skipping each
|
|
20
|
+
* body. Throws when the first block carries no ustar magic. */
|
|
21
|
+
export function tarEntryNames(tar) {
|
|
22
|
+
const ascii = (from, len) => {
|
|
23
|
+
const slice = tar.subarray(from, from + len);
|
|
24
|
+
const end = slice.indexOf(0);
|
|
25
|
+
return new TextDecoder("latin1").decode(end === -1 ? slice : slice.subarray(0, end));
|
|
26
|
+
};
|
|
27
|
+
if (tar.byteLength < 512 || ascii(257, 5) !== "ustar") {
|
|
28
|
+
throw new Error("not a ustar tar: missing magic at offset 257 of the first header block");
|
|
29
|
+
}
|
|
30
|
+
const names = [];
|
|
31
|
+
let offset = 0;
|
|
32
|
+
while (offset + 512 <= tar.byteLength) {
|
|
33
|
+
const name = ascii(offset, 100);
|
|
34
|
+
if (name === "")
|
|
35
|
+
break;
|
|
36
|
+
const prefix = ascii(offset + 345, 155);
|
|
37
|
+
names.push(prefix ? `${prefix}/${name}` : name);
|
|
38
|
+
const size = Number.parseInt(ascii(offset + 124, 12).trim() || "0", 8);
|
|
39
|
+
offset += 512 + Math.ceil(size / 512) * 512;
|
|
40
|
+
}
|
|
41
|
+
return names;
|
|
42
|
+
}
|
|
43
|
+
function freePort() {
|
|
44
|
+
return new Promise((resolve, reject) => {
|
|
45
|
+
const srv = createServer();
|
|
46
|
+
srv.once("error", reject);
|
|
47
|
+
srv.listen(0, "127.0.0.1", () => {
|
|
48
|
+
const address = srv.address();
|
|
49
|
+
const port = typeof address === "object" && address ? address.port : 0;
|
|
50
|
+
srv.close(() => resolve(port));
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
export async function assertImageSurface(opts) {
|
|
55
|
+
const run = opts.run ?? defaultRunner;
|
|
56
|
+
const fetchImpl = opts.fetch ?? ((url, init) => fetch(url, init));
|
|
57
|
+
if (run("docker", ["image", "inspect", opts.image]).status !== 0) {
|
|
58
|
+
throw new Error(`image ${opts.image} not found — run: bun run build:image`);
|
|
59
|
+
}
|
|
60
|
+
const port = opts.port ?? (await freePort());
|
|
61
|
+
const containerPort = opts.containerPort ?? DEFAULT_CONTAINER_PORT;
|
|
62
|
+
const started = run("docker", ["run", "-d", "--rm", "-p", `127.0.0.1:${port}:${containerPort}`, opts.image]);
|
|
63
|
+
if (started.status !== 0)
|
|
64
|
+
throw new Error(`docker run ${opts.image} failed: ${started.stderr}`);
|
|
65
|
+
const containerId = started.stdout.trim();
|
|
66
|
+
const base = `http://127.0.0.1:${port}`;
|
|
67
|
+
try {
|
|
68
|
+
let healthBody = "";
|
|
69
|
+
await pollUntil(async () => {
|
|
70
|
+
const r = await fetchImpl(`${base}/healthz`, { signal: AbortSignal.timeout(1000) });
|
|
71
|
+
if (!r.ok)
|
|
72
|
+
return false;
|
|
73
|
+
healthBody = (await r.text()).trim();
|
|
74
|
+
return true;
|
|
75
|
+
}, {
|
|
76
|
+
timeoutMs: opts.healthTimeoutMs ?? DEFAULT_HEALTH_TIMEOUT_MS,
|
|
77
|
+
intervalMs: opts.pollIntervalMs ?? 500,
|
|
78
|
+
label: `container ${base}/healthz never became healthy`,
|
|
79
|
+
});
|
|
80
|
+
const expectedHealth = opts.expect.healthz?.body;
|
|
81
|
+
if (expectedHealth !== undefined && healthBody !== expectedHealth) {
|
|
82
|
+
throw new Error(`/healthz body was "${healthBody}", expected "${expectedHealth}"`);
|
|
83
|
+
}
|
|
84
|
+
const result = {};
|
|
85
|
+
if (opts.expect.manifest) {
|
|
86
|
+
const r = await fetchImpl(`${base}/manifest.json`);
|
|
87
|
+
if (r.status !== 200)
|
|
88
|
+
throw new Error(`/manifest.json returned ${r.status}`);
|
|
89
|
+
const manifest = (await r.json());
|
|
90
|
+
if (manifest.name !== opts.expect.manifest.name) {
|
|
91
|
+
throw new Error(`manifest name was ${JSON.stringify(manifest.name)}, expected "${opts.expect.manifest.name}"`);
|
|
92
|
+
}
|
|
93
|
+
result.manifest = manifest;
|
|
94
|
+
}
|
|
95
|
+
if (opts.expect.templateTar) {
|
|
96
|
+
const path = opts.expect.templateTar.path.startsWith("/")
|
|
97
|
+
? opts.expect.templateTar.path
|
|
98
|
+
: `/${opts.expect.templateTar.path}`;
|
|
99
|
+
const r = await fetchImpl(`${base}${path}`);
|
|
100
|
+
if (r.status !== 200)
|
|
101
|
+
throw new Error(`${path} returned ${r.status}`);
|
|
102
|
+
const contentType = r.headers.get("content-type") ?? "";
|
|
103
|
+
if (!contentType.includes("x-tar"))
|
|
104
|
+
throw new Error(`${path} content-type was "${contentType}", expected x-tar`);
|
|
105
|
+
const entries = tarEntryNames(new Uint8Array(await r.arrayBuffer()));
|
|
106
|
+
const missing = (opts.expect.templateTar.entries ?? DEFAULT_TEMPLATE_TAR_ENTRIES).filter((wanted) => !entries.some((e) => e.includes(wanted)));
|
|
107
|
+
if (missing.length) {
|
|
108
|
+
throw new Error(`${path} tar is missing ${missing.join(", ")}; entries: ${entries.join(", ")}`);
|
|
109
|
+
}
|
|
110
|
+
result.tarEntries = entries;
|
|
111
|
+
}
|
|
112
|
+
return result;
|
|
113
|
+
}
|
|
114
|
+
finally {
|
|
115
|
+
run("docker", ["rm", "-f", containerId]);
|
|
116
|
+
}
|
|
117
|
+
}
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export type { AssertImageSurfaceOptions, ImageSurfaceExpect, ImageSurfaceResult } from "./image-surface.js";
|
|
2
|
+
export { assertImageSurface, DEFAULT_TEMPLATE_TAR_ENTRIES, tarEntryNames } from "./image-surface.js";
|
|
3
|
+
export { pollUntil } from "./poll.js";
|
|
4
|
+
export { makeStoreDir, makeTempDir, makeTempDirUnder, TEST_TMP_BASE } from "./temp-dir.js";
|
package/index.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Package root: the runtime-agnostic helpers every tier reaches for, without
|
|
2
|
+
// going through `./daemon` (which drags in the CLI spawner). The subpath
|
|
3
|
+
// exports stay the primary surface; this is the short way to the primitives.
|
|
4
|
+
export { assertImageSurface, DEFAULT_TEMPLATE_TAR_ENTRIES, tarEntryNames } from "./image-surface.js";
|
|
5
|
+
export { pollUntil } from "./poll.js";
|
|
6
|
+
export { makeStoreDir, makeTempDir, makeTempDirUnder, TEST_TMP_BASE } from "./temp-dir.js";
|
package/package.json
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@norskvideo/ctl-test-harness",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.15",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
|
+
".": {
|
|
7
|
+
"types": "./index.d.ts",
|
|
8
|
+
"default": "./index.js"
|
|
9
|
+
},
|
|
6
10
|
"./daemon": {
|
|
7
11
|
"types": "./daemon.d.ts",
|
|
8
12
|
"default": "./daemon.js"
|
|
9
13
|
},
|
|
14
|
+
"./image-surface": {
|
|
15
|
+
"types": "./image-surface.d.ts",
|
|
16
|
+
"default": "./image-surface.js"
|
|
17
|
+
},
|
|
10
18
|
"./launch": {
|
|
11
19
|
"types": "./launch.d.ts",
|
|
12
20
|
"default": "./launch.js"
|
|
@@ -30,8 +38,14 @@
|
|
|
30
38
|
"./container-net": {
|
|
31
39
|
"types": "./container-net.d.ts",
|
|
32
40
|
"default": "./container-net.js"
|
|
41
|
+
},
|
|
42
|
+
"./smoke": {
|
|
43
|
+
"types": "./smoke.d.ts",
|
|
44
|
+
"default": "./smoke.js"
|
|
33
45
|
}
|
|
34
46
|
},
|
|
47
|
+
"main": "./index.js",
|
|
48
|
+
"types": "./index.d.ts",
|
|
35
49
|
"dependencies": {
|
|
36
50
|
"@norskvideo/ctl-sdk": "^0.1.0"
|
|
37
51
|
},
|
package/smoke.d.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { ensureRunnerOnNetwork, type StudioTarget } from "./container-net.js";
|
|
2
|
+
import { type DaemonProcess, type StartDaemonOptions } from "./daemon.js";
|
|
3
|
+
import { type BaseHarnessPorts } from "./harness-config.js";
|
|
4
|
+
import { type SourceHandle, type SrtPumpTarget } from "./source-pump.js";
|
|
5
|
+
import { type ComponentsResponse, type StreamMappingEntry } from "./studio-state.js";
|
|
6
|
+
export type SmokeRegistration = {
|
|
7
|
+
image: string;
|
|
8
|
+
} | {
|
|
9
|
+
/** The dev backend's URL; a function receives the banded backend port. */
|
|
10
|
+
devUrl: string | ((backendPort: number) => string);
|
|
11
|
+
/** Start the dev backend on the banded port; awaited before `product add`. */
|
|
12
|
+
start(backendPort: number): Promise<void>;
|
|
13
|
+
};
|
|
14
|
+
export type SmokeTemplate = {
|
|
15
|
+
name: string;
|
|
16
|
+
} | {
|
|
17
|
+
/** Name for the built template (`template build <build> --product … --input …`). */
|
|
18
|
+
build: string;
|
|
19
|
+
input: Record<string, unknown>;
|
|
20
|
+
};
|
|
21
|
+
export interface CliResult {
|
|
22
|
+
stdout: string;
|
|
23
|
+
stderr: string;
|
|
24
|
+
exitCode: number;
|
|
25
|
+
}
|
|
26
|
+
export interface SmokeContext {
|
|
27
|
+
daemonPort: number;
|
|
28
|
+
instanceId: string;
|
|
29
|
+
storeDir: string;
|
|
30
|
+
/** Resolved via netReachMode(): published port, or `<id>-studio-1:8000` in direct mode. */
|
|
31
|
+
studio: StudioTarget;
|
|
32
|
+
cli(argv: string[], opts?: {
|
|
33
|
+
output?: "json" | "yaml";
|
|
34
|
+
}): Promise<CliResult>;
|
|
35
|
+
fetchStudio(path: string): Promise<Response>;
|
|
36
|
+
}
|
|
37
|
+
export type SmokeObservation = {
|
|
38
|
+
kind: "components";
|
|
39
|
+
ids: string[];
|
|
40
|
+
} | {
|
|
41
|
+
kind: "srt-connected";
|
|
42
|
+
componentId: string;
|
|
43
|
+
} | {
|
|
44
|
+
kind: "stream-output";
|
|
45
|
+
componentId: string;
|
|
46
|
+
media: "video" | "audio" | "playlist";
|
|
47
|
+
renditionName?: string;
|
|
48
|
+
} | {
|
|
49
|
+
kind: "multivariant";
|
|
50
|
+
componentId: string;
|
|
51
|
+
expectedRenditionLabels: string[];
|
|
52
|
+
/** mediaDirectUrl for proxy-less harnesses; default applyTestHost. */
|
|
53
|
+
resolveFetchUrl?: (advertised: string) => string;
|
|
54
|
+
} | {
|
|
55
|
+
kind: "http";
|
|
56
|
+
url: (ctx: SmokeContext) => string;
|
|
57
|
+
status?: number;
|
|
58
|
+
bodyIncludes?: string;
|
|
59
|
+
} | {
|
|
60
|
+
kind: "custom";
|
|
61
|
+
label: string;
|
|
62
|
+
satisfied: (ctx: SmokeContext) => Promise<boolean>;
|
|
63
|
+
};
|
|
64
|
+
export interface SmokeSpec {
|
|
65
|
+
product: {
|
|
66
|
+
/** Manifest name, e.g. "norsk-probe". The registration must report it. */
|
|
67
|
+
name: string;
|
|
68
|
+
/** Chosen by the caller from the environment: SMOKE_IMAGE_TAG set -> image
|
|
69
|
+
* (main / candidate / nightly); unset -> devUrl (PRs, local loop). */
|
|
70
|
+
register: SmokeRegistration;
|
|
71
|
+
/** Default: requireLicenseFile(). */
|
|
72
|
+
licenseFile?: string;
|
|
73
|
+
};
|
|
74
|
+
template: SmokeTemplate;
|
|
75
|
+
/** Extra `--param NAME=value`. INSTANCE_NAME is always passed; STUDIO_HOST_PORT
|
|
76
|
+
* when the template declares it (else the port is pinned with --host-ports). */
|
|
77
|
+
params?: string[];
|
|
78
|
+
hardware?: "nvidia" | "none";
|
|
79
|
+
networkMode?: "docker";
|
|
80
|
+
/** `port` is the container-internal listener port. May be empty. */
|
|
81
|
+
sources: SrtPumpTarget[];
|
|
82
|
+
/** Evaluated in order; see the phase rule above. */
|
|
83
|
+
observe: SmokeObservation[];
|
|
84
|
+
timeouts?: {
|
|
85
|
+
healthyMs?: number;
|
|
86
|
+
observeMs?: number;
|
|
87
|
+
clearedMs?: number;
|
|
88
|
+
};
|
|
89
|
+
/** Override the banded ports (see smokePorts). */
|
|
90
|
+
ports?: Partial<SmokePorts>;
|
|
91
|
+
}
|
|
92
|
+
/** What the observation kinds read. Injected so the phase rule is tested
|
|
93
|
+
* without Studio; defaults are studio-state's fetchers. */
|
|
94
|
+
export interface SmokeReaders {
|
|
95
|
+
components(studio: StudioTarget): Promise<ComponentsResponse | null>;
|
|
96
|
+
componentState(studio: StudioTarget, componentId: string): Promise<unknown | null>;
|
|
97
|
+
streamMappings(studio: StudioTarget, componentId: string): Promise<StreamMappingEntry[] | null>;
|
|
98
|
+
fetch(url: string): Promise<Response>;
|
|
99
|
+
}
|
|
100
|
+
export interface SmokeDeps {
|
|
101
|
+
licenseFile(): string;
|
|
102
|
+
storeDir(slug: string): string;
|
|
103
|
+
writeFile(path: string, contents: string): void;
|
|
104
|
+
startDaemon(storeDir: string, options: StartDaemonOptions): {
|
|
105
|
+
daemon: DaemonProcess;
|
|
106
|
+
ready: Promise<void>;
|
|
107
|
+
};
|
|
108
|
+
cli(storeDir: string, argv: string[]): Promise<CliResult>;
|
|
109
|
+
startSources(opts: {
|
|
110
|
+
daemonPort: number;
|
|
111
|
+
instanceId: string;
|
|
112
|
+
targets: readonly SrtPumpTarget[];
|
|
113
|
+
timeoutMs?: number;
|
|
114
|
+
}): Promise<SourceHandle[]>;
|
|
115
|
+
stopSources(handles: SourceHandle[]): Promise<void>;
|
|
116
|
+
readers: SmokeReaders;
|
|
117
|
+
cleanup(opts: {
|
|
118
|
+
deleteInstance: (id: string) => Promise<unknown>;
|
|
119
|
+
stopDaemon: () => Promise<unknown>;
|
|
120
|
+
instances: string[];
|
|
121
|
+
daemon: DaemonProcess | null;
|
|
122
|
+
storeDir: string;
|
|
123
|
+
containers: string[];
|
|
124
|
+
}): Promise<void>;
|
|
125
|
+
/** Docker leaves root-owned bind-mount targets under the store; a non-root
|
|
126
|
+
* runner cannot rm them, so they go from a throwaway root container. */
|
|
127
|
+
nukeStoreAsRoot(storeDir: string): void;
|
|
128
|
+
ensureNetwork(): ReturnType<typeof ensureRunnerOnNetwork>;
|
|
129
|
+
supportsNoPublish(): Promise<boolean>;
|
|
130
|
+
containerUser(): string | undefined;
|
|
131
|
+
log(line: string): void;
|
|
132
|
+
}
|
|
133
|
+
/** Per-slug port band, above the product integration harnesses' own bands:
|
|
134
|
+
* daemon, dev backend, published Studio port and an instance base, twenty
|
|
135
|
+
* apart per slug. */
|
|
136
|
+
export interface SmokePorts extends BaseHarnessPorts {
|
|
137
|
+
/** The daemon's oauth2 proxy; banded so co-located runs never meet on :9443. */
|
|
138
|
+
proxyPort: number;
|
|
139
|
+
}
|
|
140
|
+
export declare function smokePorts(slug: string, overrides?: Partial<SmokePorts>): SmokePorts;
|
|
141
|
+
export declare const defaultSmokeDeps: SmokeDeps;
|
|
142
|
+
/** Whether an observation depends on a source being pumped. Structural ones
|
|
143
|
+
* run the satisfied phase only. */
|
|
144
|
+
export declare function sourceDependent(o: SmokeObservation): boolean;
|
|
145
|
+
/** One sample of an observation — the predicate the three phases poll. */
|
|
146
|
+
export declare function observationSatisfied(o: SmokeObservation, ctx: SmokeContext, readers: SmokeReaders): Promise<boolean>;
|
|
147
|
+
export declare function runProductSmoke(slug: string, spec: SmokeSpec, deps?: SmokeDeps): Promise<void>;
|
package/smoke.js
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
// The `smoke` tier (fleet review 04b s7.2, 04a s4.3): one parametrised journey
|
|
2
|
+
// every product satisfies — launch from the stored template, accept the source
|
|
3
|
+
// the manifest advertises, produce the observable it advertises. A product's
|
|
4
|
+
// tests/smoke.spec.ts is ten to twenty lines of SmokeSpec; the runner owns the
|
|
5
|
+
// daemon, the registration, the launch, the source pump, the three-phase
|
|
6
|
+
// evaluation and the teardown.
|
|
7
|
+
//
|
|
8
|
+
// The three phases are the incident's lesson (04a s0: Studio reported healthy
|
|
9
|
+
// while nothing listened). Every source-dependent observation must be FALSE
|
|
10
|
+
// before any source exists (a latched or stubbed observable cannot pass), TRUE
|
|
11
|
+
// while the pump runs, and FALSE again after stopAll (the observable is
|
|
12
|
+
// watching, not latched). `components` is structural — the graph exists with
|
|
13
|
+
// or without a source — so it runs the satisfied phase only.
|
|
14
|
+
//
|
|
15
|
+
// Every side effect sits behind SmokeDeps so the sequencing is unit-tested with
|
|
16
|
+
// fakes; the defaults wire to the real harness (daemon, source-pump,
|
|
17
|
+
// studio-state, container-net). Argv, not Command: @norskvideo/ctl-commands is
|
|
18
|
+
// not a dependency of this package (daemon.ts's cleanupDaemon has the same
|
|
19
|
+
// rule); a product wanting typed commands bridges with toArgv in its own spec.
|
|
20
|
+
import { spawnSync } from "node:child_process";
|
|
21
|
+
import { writeFileSync } from "node:fs";
|
|
22
|
+
import { basename, dirname, join } from "node:path";
|
|
23
|
+
import { ensureRunnerOnNetwork, netReachMode, STUDIO_INTERNAL_PORT, studioBaseFrom, } from "./container-net.js";
|
|
24
|
+
import { cleanupDaemon, requireLicenseFile, runCli, startDaemon, } from "./daemon.js";
|
|
25
|
+
import { hashSlot } from "./harness-config.js";
|
|
26
|
+
import { ctlSupportsNoPublish, runnerContainerUser } from "./launch.js";
|
|
27
|
+
import { pollUntil } from "./poll.js";
|
|
28
|
+
import { startSrtSources } from "./source-pump.js";
|
|
29
|
+
import { applyTestHost, fetchComponentState, fetchComponents, fetchStreamMappings, isSrtListenerState, } from "./studio-state.js";
|
|
30
|
+
import { makeStoreDir } from "./temp-dir.js";
|
|
31
|
+
const SMOKE_PORT_BASE = 33000;
|
|
32
|
+
const SMOKE_BAND_WIDTH = 20;
|
|
33
|
+
const SMOKE_BANDS = 50;
|
|
34
|
+
const STUDIO_HOST_PORT_PARAM = "STUDIO_HOST_PORT";
|
|
35
|
+
const NUKE_IMAGE = "alpine:3";
|
|
36
|
+
export function smokePorts(slug, overrides = {}) {
|
|
37
|
+
const daemonPort = SMOKE_PORT_BASE + hashSlot(`smoke:${slug}`, SMOKE_BANDS) * SMOKE_BAND_WIDTH;
|
|
38
|
+
return {
|
|
39
|
+
daemonPort,
|
|
40
|
+
backendPort: daemonPort + 1,
|
|
41
|
+
studioHostPort: daemonPort + 2,
|
|
42
|
+
proxyPort: daemonPort + 3,
|
|
43
|
+
instancePortBase: daemonPort + 10,
|
|
44
|
+
...overrides,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
export const defaultSmokeDeps = {
|
|
48
|
+
licenseFile: () => requireLicenseFile({ missing: "throw" }),
|
|
49
|
+
storeDir: (slug) => makeStoreDir(`norsk-smoke-${slug}-`),
|
|
50
|
+
writeFile: (path, contents) => writeFileSync(path, contents),
|
|
51
|
+
startDaemon,
|
|
52
|
+
cli: (storeDir, argv) => runCli(storeDir, ...argv),
|
|
53
|
+
startSources: startSrtSources,
|
|
54
|
+
stopSources: async (handles) => {
|
|
55
|
+
for (const h of handles)
|
|
56
|
+
await h.stop();
|
|
57
|
+
},
|
|
58
|
+
readers: {
|
|
59
|
+
components: fetchComponents,
|
|
60
|
+
componentState: fetchComponentState,
|
|
61
|
+
streamMappings: fetchStreamMappings,
|
|
62
|
+
fetch: (url) => fetch(url, { signal: AbortSignal.timeout(5000) }),
|
|
63
|
+
},
|
|
64
|
+
cleanup: (opts) => cleanupDaemon({ ...opts, stopProxy: async () => { }, proxy: false }),
|
|
65
|
+
nukeStoreAsRoot: (storeDir) => {
|
|
66
|
+
spawnSync("docker", [
|
|
67
|
+
"run",
|
|
68
|
+
"--rm",
|
|
69
|
+
"--user",
|
|
70
|
+
"0:0",
|
|
71
|
+
"-v",
|
|
72
|
+
`${dirname(storeDir)}:/base`,
|
|
73
|
+
"--entrypoint",
|
|
74
|
+
"sh",
|
|
75
|
+
NUKE_IMAGE,
|
|
76
|
+
"-c",
|
|
77
|
+
`rm -rf /base/${basename(storeDir)}`,
|
|
78
|
+
]);
|
|
79
|
+
},
|
|
80
|
+
ensureNetwork: () => ensureRunnerOnNetwork(),
|
|
81
|
+
supportsNoPublish: ctlSupportsNoPublish,
|
|
82
|
+
containerUser: runnerContainerUser,
|
|
83
|
+
log: (line) => console.log(`[smoke] ${line}`),
|
|
84
|
+
};
|
|
85
|
+
function describeObservation(o) {
|
|
86
|
+
switch (o.kind) {
|
|
87
|
+
case "components":
|
|
88
|
+
return `components ${o.ids.join(",")}`;
|
|
89
|
+
case "srt-connected":
|
|
90
|
+
return `srt-connected ${o.componentId}`;
|
|
91
|
+
case "stream-output":
|
|
92
|
+
return `stream-output ${o.componentId} ${o.media}${o.renditionName ? ` ${o.renditionName}` : ""}`;
|
|
93
|
+
case "multivariant":
|
|
94
|
+
return `multivariant ${o.componentId} [${o.expectedRenditionLabels.join(",")}]`;
|
|
95
|
+
case "http":
|
|
96
|
+
return "http";
|
|
97
|
+
case "custom":
|
|
98
|
+
return `custom ${o.label}`;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/** Whether an observation depends on a source being pumped. Structural ones
|
|
102
|
+
* run the satisfied phase only. */
|
|
103
|
+
export function sourceDependent(o) {
|
|
104
|
+
return o.kind !== "components";
|
|
105
|
+
}
|
|
106
|
+
/** One sample of an observation — the predicate the three phases poll. */
|
|
107
|
+
export async function observationSatisfied(o, ctx, readers) {
|
|
108
|
+
switch (o.kind) {
|
|
109
|
+
case "components": {
|
|
110
|
+
const r = await readers.components(ctx.studio);
|
|
111
|
+
if (!r)
|
|
112
|
+
return false;
|
|
113
|
+
const ids = new Set(r.components.map((c) => c.componentId));
|
|
114
|
+
return o.ids.every((id) => ids.has(id));
|
|
115
|
+
}
|
|
116
|
+
case "srt-connected": {
|
|
117
|
+
const state = await readers.componentState(ctx.studio, o.componentId);
|
|
118
|
+
if (!isSrtListenerState(state) || state.connectedStreams.length < 1)
|
|
119
|
+
return false;
|
|
120
|
+
return Object.values(state.connectedAt).some((t) => typeof t === "number" && t > 0);
|
|
121
|
+
}
|
|
122
|
+
case "stream-output": {
|
|
123
|
+
const mappings = await readers.streamMappings(ctx.studio, o.componentId);
|
|
124
|
+
return (mappings ?? []).some((m) => m.output?.media === o.media && (o.renditionName === undefined || m.output?.renditionName === o.renditionName));
|
|
125
|
+
}
|
|
126
|
+
case "multivariant": {
|
|
127
|
+
const state = (await readers.componentState(ctx.studio, o.componentId));
|
|
128
|
+
if (!state?.url)
|
|
129
|
+
return false;
|
|
130
|
+
const r = await readers.fetch((o.resolveFetchUrl ?? applyTestHost)(state.url));
|
|
131
|
+
if (!r.ok)
|
|
132
|
+
return false;
|
|
133
|
+
const body = await r.text();
|
|
134
|
+
return o.expectedRenditionLabels.every((label) => body.includes(label));
|
|
135
|
+
}
|
|
136
|
+
case "http": {
|
|
137
|
+
const r = await readers.fetch(o.url(ctx));
|
|
138
|
+
if (r.status !== (o.status ?? 200))
|
|
139
|
+
return false;
|
|
140
|
+
if (o.bodyIncludes === undefined)
|
|
141
|
+
return true;
|
|
142
|
+
return (await r.text()).includes(o.bodyIncludes);
|
|
143
|
+
}
|
|
144
|
+
case "custom":
|
|
145
|
+
return o.satisfied(ctx);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
function parseJson(r, what) {
|
|
149
|
+
try {
|
|
150
|
+
return JSON.parse(r.stdout);
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
throw new Error(`${what}: not JSON: ${r.stdout.slice(0, 200)}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
export async function runProductSmoke(slug, spec, deps = defaultSmokeDeps) {
|
|
157
|
+
const ports = smokePorts(slug, spec.ports);
|
|
158
|
+
const storeDir = deps.storeDir(slug);
|
|
159
|
+
const instanceId = `smoke-${slug}`;
|
|
160
|
+
const timeouts = {
|
|
161
|
+
healthyMs: spec.timeouts?.healthyMs ?? 180_000,
|
|
162
|
+
observeMs: spec.timeouts?.observeMs ?? 120_000,
|
|
163
|
+
clearedMs: spec.timeouts?.clearedMs ?? 60_000,
|
|
164
|
+
};
|
|
165
|
+
const mode = netReachMode();
|
|
166
|
+
const studio = { instanceId, studioHostPort: ports.studioHostPort };
|
|
167
|
+
const cli = async (argv, opts = {}) => {
|
|
168
|
+
const full = ["--port", String(ports.daemonPort), ...argv, ...(opts.output ? ["-o", opts.output] : [])];
|
|
169
|
+
return deps.cli(storeDir, full);
|
|
170
|
+
};
|
|
171
|
+
const cliOk = async (argv, opts = {}) => {
|
|
172
|
+
const r = await cli(argv, opts);
|
|
173
|
+
if (r.exitCode !== 0) {
|
|
174
|
+
throw new Error(`norsk-ctl ${argv.join(" ")} failed (exit ${r.exitCode}):\n${r.stderr || r.stdout}`);
|
|
175
|
+
}
|
|
176
|
+
return r;
|
|
177
|
+
};
|
|
178
|
+
const ctx = {
|
|
179
|
+
daemonPort: ports.daemonPort,
|
|
180
|
+
instanceId,
|
|
181
|
+
storeDir,
|
|
182
|
+
studio,
|
|
183
|
+
cli,
|
|
184
|
+
fetchStudio: (path) => deps.readers.fetch(`${studioBaseFrom(studio, process.env.NORSK_TEST_HOST ?? "localhost", mode)}${path}`),
|
|
185
|
+
};
|
|
186
|
+
let daemon = null;
|
|
187
|
+
let launched = false;
|
|
188
|
+
let handles = [];
|
|
189
|
+
try {
|
|
190
|
+
// The same zero point as the guides: `init` on a virgin store, not a seeded
|
|
191
|
+
// config.yaml. No http redirect (port 80 needs root) and a banded proxy port
|
|
192
|
+
// (the default :9443 would meet a co-located integration run).
|
|
193
|
+
await cliOk([
|
|
194
|
+
"init",
|
|
195
|
+
"--network-mode",
|
|
196
|
+
spec.networkMode ?? "docker",
|
|
197
|
+
"--working-directory",
|
|
198
|
+
join(storeDir, "norsk-runtime"),
|
|
199
|
+
"--proxy-port",
|
|
200
|
+
String(ports.proxyPort),
|
|
201
|
+
"--no-http-redirect",
|
|
202
|
+
"--no-start-server",
|
|
203
|
+
]);
|
|
204
|
+
const started = deps.startDaemon(storeDir, { port: ports.daemonPort, seedConfig: false });
|
|
205
|
+
daemon = started.daemon;
|
|
206
|
+
await started.ready;
|
|
207
|
+
if (mode === "direct" && deps.ensureNetwork() === "failed") {
|
|
208
|
+
throw new Error("could not join the runner to norsk-net for direct reach");
|
|
209
|
+
}
|
|
210
|
+
// Register.
|
|
211
|
+
const licenseFile = spec.product.licenseFile ?? deps.licenseFile();
|
|
212
|
+
let addArgv;
|
|
213
|
+
if ("image" in spec.product.register) {
|
|
214
|
+
addArgv = ["product", "add", "--image", spec.product.register.image];
|
|
215
|
+
}
|
|
216
|
+
else {
|
|
217
|
+
const reg = spec.product.register;
|
|
218
|
+
await reg.start(ports.backendPort);
|
|
219
|
+
const url = typeof reg.devUrl === "function" ? reg.devUrl(ports.backendPort) : reg.devUrl;
|
|
220
|
+
addArgv = ["product", "add", "--dev-url", url];
|
|
221
|
+
}
|
|
222
|
+
const added = parseJson(await cliOk([...addArgv, "--license-file", licenseFile], { output: "json" }), "product add");
|
|
223
|
+
if (added.name !== spec.product.name) {
|
|
224
|
+
throw new Error(`product add registered '${added.name}', expected '${spec.product.name}'`);
|
|
225
|
+
}
|
|
226
|
+
// Template.
|
|
227
|
+
let templateName;
|
|
228
|
+
if ("name" in spec.template) {
|
|
229
|
+
templateName = spec.template.name;
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
const inputPath = join(storeDir, `smoke-${spec.template.build}-input.json`);
|
|
233
|
+
deps.writeFile(inputPath, JSON.stringify(spec.template.input));
|
|
234
|
+
await cliOk([
|
|
235
|
+
"template",
|
|
236
|
+
"build",
|
|
237
|
+
spec.template.build,
|
|
238
|
+
"--product",
|
|
239
|
+
spec.product.name,
|
|
240
|
+
"--input",
|
|
241
|
+
inputPath,
|
|
242
|
+
"--replace",
|
|
243
|
+
]);
|
|
244
|
+
templateName = spec.template.build;
|
|
245
|
+
}
|
|
246
|
+
// Launch. The published Studio port is a template parameter where the
|
|
247
|
+
// template declares one (the generator's shape); otherwise it is pinned
|
|
248
|
+
// with a host-port binding on the studio service. In direct mode with an
|
|
249
|
+
// internal-only launch the target dials the service name instead.
|
|
250
|
+
const shown = await cli(["template", "show", templateName], { output: "json" });
|
|
251
|
+
const declared = new Set(shown.exitCode === 0
|
|
252
|
+
? (parseJson(shown, "template show").parameters ?? []).map((p) => p.name)
|
|
253
|
+
: []);
|
|
254
|
+
const params = [`INSTANCE_NAME=${instanceId}`, ...(spec.params ?? [])];
|
|
255
|
+
const launchArgv = ["instance", "launch-template", instanceId, "--template", templateName];
|
|
256
|
+
const internalOnly = mode === "direct" && (await deps.supportsNoPublish());
|
|
257
|
+
if (declared.has(STUDIO_HOST_PORT_PARAM))
|
|
258
|
+
params.push(`${STUDIO_HOST_PORT_PARAM}=${ports.studioHostPort}`);
|
|
259
|
+
else if (!internalOnly)
|
|
260
|
+
launchArgv.push("--host-ports", `${ports.studioHostPort}:${STUDIO_INTERNAL_PORT}@studio`);
|
|
261
|
+
for (const p of params)
|
|
262
|
+
launchArgv.push("--param", p);
|
|
263
|
+
if (spec.hardware)
|
|
264
|
+
launchArgv.push("--hardware", spec.hardware);
|
|
265
|
+
if (spec.networkMode)
|
|
266
|
+
launchArgv.push("--network-mode", spec.networkMode);
|
|
267
|
+
const user = deps.containerUser();
|
|
268
|
+
if (user)
|
|
269
|
+
launchArgv.push("--container-user", user);
|
|
270
|
+
if (internalOnly)
|
|
271
|
+
launchArgv.push("--internal-only");
|
|
272
|
+
launched = true;
|
|
273
|
+
await cliOk(launchArgv);
|
|
274
|
+
await pollUntil(async () => {
|
|
275
|
+
const r = await cli(["instance", "list"], { output: "json" });
|
|
276
|
+
if (r.exitCode !== 0)
|
|
277
|
+
return false;
|
|
278
|
+
const inst = parseJson(r, "instance list").instances?.find((i) => i.id === instanceId);
|
|
279
|
+
return inst?.status === "running" || inst?.status === "healthy";
|
|
280
|
+
}, { timeoutMs: timeouts.healthyMs, intervalMs: 1000, label: `instance ${instanceId} never reported running` });
|
|
281
|
+
// Phases.
|
|
282
|
+
const vacuous = spec.sources.length === 0;
|
|
283
|
+
if (vacuous) {
|
|
284
|
+
deps.log(`${slug}: no sources declared — the not-yet and cleared phases are vacuous; observations run once`);
|
|
285
|
+
}
|
|
286
|
+
const phased = spec.observe.filter((o) => sourceDependent(o) && !vacuous);
|
|
287
|
+
for (const o of phased) {
|
|
288
|
+
if (await observationSatisfied(o, ctx, deps.readers)) {
|
|
289
|
+
throw new Error(`${describeObservation(o)} was already satisfied before any source existed — a latched or stubbed observable cannot prove ingest`);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
deps.log(`${slug}: not-yet phase passed for ${phased.length} observation(s)`);
|
|
293
|
+
if (!vacuous) {
|
|
294
|
+
handles = await deps.startSources({
|
|
295
|
+
daemonPort: ports.daemonPort,
|
|
296
|
+
instanceId,
|
|
297
|
+
targets: spec.sources,
|
|
298
|
+
timeoutMs: timeouts.observeMs,
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
for (const o of spec.observe) {
|
|
302
|
+
await pollUntil(() => observationSatisfied(o, ctx, deps.readers), {
|
|
303
|
+
timeoutMs: timeouts.observeMs,
|
|
304
|
+
intervalMs: 1000,
|
|
305
|
+
label: `${describeObservation(o)} never satisfied${vacuous ? "" : " while the sources pumped"}`,
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
deps.log(`${slug}: satisfied phase passed for ${spec.observe.length} observation(s)`);
|
|
309
|
+
if (!vacuous) {
|
|
310
|
+
await deps.stopSources(handles);
|
|
311
|
+
handles = [];
|
|
312
|
+
for (const o of phased) {
|
|
313
|
+
await pollUntil(async () => !(await observationSatisfied(o, ctx, deps.readers)), {
|
|
314
|
+
timeoutMs: timeouts.clearedMs,
|
|
315
|
+
intervalMs: 1000,
|
|
316
|
+
label: `${describeObservation(o)} still satisfied after stopAll — the observable is latched, not watching`,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
deps.log(`${slug}: cleared phase passed for ${phased.length} observation(s)`);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
finally {
|
|
323
|
+
if (handles.length)
|
|
324
|
+
await deps.stopSources(handles).catch(() => { });
|
|
325
|
+
await deps.cleanup({
|
|
326
|
+
deleteInstance: (id) => cli(["instance", "delete", id, "--purge"]),
|
|
327
|
+
stopDaemon: () => cli(["shutdown", "--daemon-only"]),
|
|
328
|
+
instances: launched ? [instanceId] : [],
|
|
329
|
+
daemon,
|
|
330
|
+
storeDir,
|
|
331
|
+
containers: [`norsk-inst-${instanceId}-studio`, `norsk-inst-${instanceId}-media`],
|
|
332
|
+
});
|
|
333
|
+
deps.nukeStoreAsRoot(storeDir);
|
|
334
|
+
}
|
|
335
|
+
}
|
package/source-pump.d.ts
CHANGED
|
@@ -2,18 +2,51 @@ export interface SourceHandle {
|
|
|
2
2
|
name: string;
|
|
3
3
|
stop(): Promise<void>;
|
|
4
4
|
}
|
|
5
|
-
|
|
5
|
+
/** The daemon's generated-source kinds (`generate` on POST /api/sources). */
|
|
6
|
+
export type GeneratedPattern = "bars" | "testsrc";
|
|
7
|
+
/** An asset the daemon can stream without a file on the harness host. */
|
|
8
|
+
export type BuiltinSourceAsset = {
|
|
9
|
+
preset: string;
|
|
10
|
+
} | {
|
|
11
|
+
generate: GeneratedPattern;
|
|
12
|
+
};
|
|
13
|
+
/** What a source streams. `mediaFile` is a path on the daemon's host; because
|
|
14
|
+
* a harness cannot assume the file is checked out (a customer capture, a
|
|
15
|
+
* large fixture), the fallback is REQUIRED and is used when the file is
|
|
16
|
+
* absent, so the spec runs anywhere and only the picture differs. */
|
|
17
|
+
export type SourceAsset = BuiltinSourceAsset | {
|
|
18
|
+
mediaFile: string;
|
|
19
|
+
fallback: BuiltinSourceAsset;
|
|
20
|
+
};
|
|
21
|
+
export type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;
|
|
22
|
+
/** Mirrors the daemon's default source name (shared/src/source-name.ts) so a
|
|
23
|
+
* caller can predict the identity it will poll and stop by. The harness
|
|
24
|
+
* always SENDS the name it derived, so the two cannot drift. */
|
|
25
|
+
export declare function defaultSourceName(asset: SourceAsset): string;
|
|
26
|
+
export interface StartCameraSourceOptions {
|
|
6
27
|
daemonPort: number;
|
|
7
28
|
instanceId: string;
|
|
29
|
+
/** Today's shorthand for `asset: { preset }`. Mutually exclusive with `asset`. */
|
|
8
30
|
preset?: string;
|
|
31
|
+
asset?: SourceAsset;
|
|
9
32
|
port: number;
|
|
10
33
|
streamId?: string;
|
|
11
34
|
/** Override the source identity (container name + management key).
|
|
12
|
-
* Default: preset name
|
|
13
|
-
*
|
|
35
|
+
* Default: the preset / pattern name, or the media file's cleaned stem
|
|
36
|
+
* (kept even when the fallback is used, so the handle is stable). Required
|
|
37
|
+
* when the same asset must run multiple times within an instance. */
|
|
14
38
|
name?: string;
|
|
39
|
+
/** Sent as `x-norsk-proxy`; needed when the daemon sits behind the oauth2
|
|
40
|
+
* proxy, which 307s `/api/*` without it. The daemon writes it to
|
|
41
|
+
* `<stateDir>/proxy-secret` (`<storeDir>/proxy-secret` under
|
|
42
|
+
* NORSK_CTL_STORE_DIR). */
|
|
43
|
+
proxySecret?: string;
|
|
15
44
|
timeoutMs?: number;
|
|
16
|
-
|
|
45
|
+
/** Test seams. */
|
|
46
|
+
fetch?: FetchLike;
|
|
47
|
+
fileExists?: (path: string) => boolean;
|
|
48
|
+
}
|
|
49
|
+
export declare function startCameraSource(opts: StartCameraSourceOptions): Promise<SourceHandle>;
|
|
17
50
|
export interface SrtPumpTarget {
|
|
18
51
|
port: number;
|
|
19
52
|
/** Source identity within the instance. Must be unique per instance. */
|
|
@@ -22,13 +55,17 @@ export interface SrtPumpTarget {
|
|
|
22
55
|
* listener that filters by streamid, pass the expected id here. */
|
|
23
56
|
streamId?: string;
|
|
24
57
|
/** Preset to source from. Defaults to camera1; pick camera2 (or rotate)
|
|
25
|
-
* when you want visually distinct streams. */
|
|
58
|
+
* when you want visually distinct streams. Shorthand for `asset`. */
|
|
26
59
|
preset?: string;
|
|
60
|
+
asset?: SourceAsset;
|
|
27
61
|
}
|
|
28
62
|
export declare function startSrtSources(opts: {
|
|
29
63
|
daemonPort: number;
|
|
30
64
|
instanceId: string;
|
|
31
65
|
targets: readonly SrtPumpTarget[];
|
|
66
|
+
proxySecret?: string;
|
|
32
67
|
timeoutMs?: number;
|
|
68
|
+
fetch?: FetchLike;
|
|
69
|
+
fileExists?: (path: string) => boolean;
|
|
33
70
|
}): Promise<SourceHandle[]>;
|
|
34
71
|
export declare function stopAll(handles: readonly SourceHandle[]): Promise<void>;
|
package/source-pump.js
CHANGED
|
@@ -1,26 +1,61 @@
|
|
|
1
1
|
// Thin wrapper over the daemon's SourceService (`POST /api/sources`).
|
|
2
|
-
// Spawns a daemon-managed `linuxserver/ffmpeg` sidecar that loops
|
|
3
|
-
// MP4/TS
|
|
2
|
+
// Spawns a daemon-managed `linuxserver/ffmpeg` sidecar that loops an asset
|
|
3
|
+
// (a preset MP4/TS, a generated pattern, or a host media file) over SRT into
|
|
4
|
+
// the target instance's media container.
|
|
4
5
|
//
|
|
5
6
|
// Polls `/api/sources?instanceId=<id>` for `running` (avoids the SSE-client
|
|
6
7
|
// boilerplate). The daemon's stopAll(instanceId) sweep catches anything the
|
|
7
8
|
// test forgets to release on its own.
|
|
8
9
|
//
|
|
9
10
|
// `name` overrides the source identity (container name + management key) so
|
|
10
|
-
// multiple sources on the same
|
|
11
|
+
// multiple sources on the same asset can co-exist within one instance. See
|
|
11
12
|
// `startSrtSources` for the multi-port fan-out helper that uses it.
|
|
12
|
-
import {
|
|
13
|
+
import { existsSync } from "node:fs";
|
|
14
|
+
import { basename, extname } from "node:path";
|
|
15
|
+
import { pollUntil } from "./poll.js";
|
|
16
|
+
/** Mirrors the daemon's default source name (shared/src/source-name.ts) so a
|
|
17
|
+
* caller can predict the identity it will poll and stop by. The harness
|
|
18
|
+
* always SENDS the name it derived, so the two cannot drift. */
|
|
19
|
+
export function defaultSourceName(asset) {
|
|
20
|
+
if ("preset" in asset)
|
|
21
|
+
return asset.preset;
|
|
22
|
+
if ("generate" in asset)
|
|
23
|
+
return asset.generate;
|
|
24
|
+
const stem = basename(asset.mediaFile, extname(asset.mediaFile));
|
|
25
|
+
const cleaned = stem
|
|
26
|
+
.toLowerCase()
|
|
27
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
28
|
+
.replace(/^-+|-+$/g, "");
|
|
29
|
+
return cleaned || "source";
|
|
30
|
+
}
|
|
31
|
+
function assetFields(asset, fileExists) {
|
|
32
|
+
if ("mediaFile" in asset) {
|
|
33
|
+
if (fileExists(asset.mediaFile))
|
|
34
|
+
return { mediaFile: asset.mediaFile };
|
|
35
|
+
return assetFields(asset.fallback, fileExists);
|
|
36
|
+
}
|
|
37
|
+
return { ...asset };
|
|
38
|
+
}
|
|
13
39
|
export async function startCameraSource(opts) {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
40
|
+
if (opts.preset !== undefined && opts.asset !== undefined) {
|
|
41
|
+
throw new Error("startCameraSource: pass either `preset` or `asset`, not both");
|
|
42
|
+
}
|
|
43
|
+
const asset = opts.asset ?? { preset: opts.preset ?? "camera1" };
|
|
44
|
+
const name = opts.name ?? defaultSourceName(asset);
|
|
45
|
+
const fetchImpl = opts.fetch ?? ((url, init) => fetch(url, init));
|
|
46
|
+
const headers = opts.proxySecret ? { "x-norsk-proxy": opts.proxySecret } : {};
|
|
47
|
+
const base = `http://localhost:${opts.daemonPort}`;
|
|
48
|
+
const body = {
|
|
49
|
+
instanceId: opts.instanceId,
|
|
50
|
+
...assetFields(asset, opts.fileExists ?? existsSync),
|
|
51
|
+
port: opts.port,
|
|
52
|
+
name,
|
|
53
|
+
};
|
|
17
54
|
if (opts.streamId !== undefined)
|
|
18
55
|
body.streamId = opts.streamId;
|
|
19
|
-
|
|
20
|
-
body.name = opts.name;
|
|
21
|
-
const startRes = await fetch(`http://localhost:${opts.daemonPort}/api/sources`, {
|
|
56
|
+
const startRes = await fetchImpl(`${base}/api/sources`, {
|
|
22
57
|
method: "POST",
|
|
23
|
-
headers: { "Content-Type": "application/json" },
|
|
58
|
+
headers: { ...headers, "Content-Type": "application/json" },
|
|
24
59
|
body: JSON.stringify(body),
|
|
25
60
|
signal: AbortSignal.timeout(10_000),
|
|
26
61
|
});
|
|
@@ -28,7 +63,10 @@ export async function startCameraSource(opts) {
|
|
|
28
63
|
throw new Error(`POST /api/sources failed (${startRes.status}): ${await startRes.text()}`);
|
|
29
64
|
}
|
|
30
65
|
await pollUntil(async () => {
|
|
31
|
-
const r = await
|
|
66
|
+
const r = await fetchImpl(`${base}/api/sources?instanceId=${encodeURIComponent(opts.instanceId)}`, {
|
|
67
|
+
headers,
|
|
68
|
+
signal: AbortSignal.timeout(5000),
|
|
69
|
+
});
|
|
32
70
|
if (!r.ok)
|
|
33
71
|
return false;
|
|
34
72
|
// GET /api/sources returns a bare array of SampleSource — not { sources: [...] }.
|
|
@@ -43,7 +81,11 @@ export async function startCameraSource(opts) {
|
|
|
43
81
|
return {
|
|
44
82
|
name,
|
|
45
83
|
async stop() {
|
|
46
|
-
await
|
|
84
|
+
await fetchImpl(`${base}/api/sources/${encodeURIComponent(opts.instanceId)}/${encodeURIComponent(name)}`, {
|
|
85
|
+
method: "DELETE",
|
|
86
|
+
headers,
|
|
87
|
+
signal: AbortSignal.timeout(10_000),
|
|
88
|
+
}).catch(() => { });
|
|
47
89
|
},
|
|
48
90
|
};
|
|
49
91
|
}
|
|
@@ -56,8 +98,12 @@ export async function startSrtSources(opts) {
|
|
|
56
98
|
name: target.name,
|
|
57
99
|
port: target.port,
|
|
58
100
|
...(target.preset !== undefined ? { preset: target.preset } : {}),
|
|
101
|
+
...(target.asset !== undefined ? { asset: target.asset } : {}),
|
|
59
102
|
...(target.streamId !== undefined ? { streamId: target.streamId } : {}),
|
|
103
|
+
...(opts.proxySecret !== undefined ? { proxySecret: opts.proxySecret } : {}),
|
|
60
104
|
...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}),
|
|
105
|
+
...(opts.fetch !== undefined ? { fetch: opts.fetch } : {}),
|
|
106
|
+
...(opts.fileExists !== undefined ? { fileExists: opts.fileExists } : {}),
|
|
61
107
|
});
|
|
62
108
|
handles.push(handle);
|
|
63
109
|
}
|