@norskvideo/ctl-test-harness 0.1.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 @@
1
+ export declare const cliCommand: string[];
package/cli-command.js ADDED
@@ -0,0 +1,45 @@
1
+ // Resolve the command prefix used to spawn the norsk-ctl CLI, in priority order:
2
+ //
3
+ // 1. NORSK_CTL_BINARY=/path/to/norsk-ctl-<os>-<cpu> — an explicit compiled
4
+ // binary, or "auto" to pick the platform binary under <cwd>/dist.
5
+ // 2. The in-workspace CLI source, if this harness sits beside a norsk-ctl
6
+ // package (monorepo dev + in-repo product tests) — run via `bun run`.
7
+ // 3. `norsk-ctl` on PATH — the fallback for a consumer of the published
8
+ // harness that has the CLI installed (the split-repo case).
9
+ //
10
+ // Spread into spawn args: [...cliCommand, "serve"].
11
+ import { existsSync } from "node:fs";
12
+ import { arch, platform } from "node:os";
13
+ import { dirname, resolve } from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+ const here = dirname(fileURLToPath(import.meta.url));
16
+ function platformBinaryName() {
17
+ const os = platform() === "darwin" ? "darwin" : "linux";
18
+ const cpu = arch() === "arm64" ? "arm64" : "x64";
19
+ return `norsk-ctl-${os}-${cpu}`;
20
+ }
21
+ function resolveBinary() {
22
+ const env = process.env.NORSK_CTL_BINARY;
23
+ if (!env)
24
+ return null;
25
+ const candidate = env === "auto" ? resolve(process.cwd(), "dist", platformBinaryName()) : resolve(env);
26
+ if (!existsSync(candidate)) {
27
+ throw new Error(`NORSK_CTL_BINARY not found: ${candidate}`);
28
+ }
29
+ return candidate;
30
+ }
31
+ function resolveWorkspaceSource() {
32
+ // packages/test-harness/{src,dist} -> packages/norsk-ctl/cli/src/index.ts.
33
+ const source = resolve(here, "../../norsk-ctl/cli/src/index.ts");
34
+ return existsSync(source) ? source : null;
35
+ }
36
+ function resolveCliCommand() {
37
+ const binary = resolveBinary();
38
+ if (binary)
39
+ return [binary];
40
+ const source = resolveWorkspaceSource();
41
+ if (source)
42
+ return ["bun", "run", source];
43
+ return ["norsk-ctl"];
44
+ }
45
+ export const cliCommand = resolveCliCommand();
package/daemon.d.ts ADDED
@@ -0,0 +1,47 @@
1
+ import { pollUntil } from "./poll.js";
2
+ import { makeStoreDir, makeTempDir, TEST_TMP_BASE } from "./temp-dir.js";
3
+ export declare const HEALTH_TIMEOUT_MS = 120000;
4
+ export { makeStoreDir, makeTempDir, pollUntil, TEST_TMP_BASE };
5
+ export declare function requireLicenseFile(): string;
6
+ export declare function runCli(storeDir: string, ...args: string[]): Promise<{
7
+ stdout: string;
8
+ stderr: string;
9
+ exitCode: number;
10
+ }>;
11
+ export declare function isPortFree(port: number): Promise<boolean>;
12
+ export declare function killProcessOnPort(port: number): void;
13
+ export declare function startDaemon(storeDir: string, options?: {
14
+ port?: number;
15
+ env?: Record<string, string>;
16
+ }): {
17
+ daemon: ReturnType<typeof Bun.spawn>;
18
+ ready: Promise<void>;
19
+ };
20
+ /**
21
+ * Tear down a daemon-based test: delete instances, stop proxy, stop daemon, kill
22
+ * process, rm store. The teardown actions are injected as pre-bound thunks
23
+ * (`deleteInstance`/`stopProxy`/`stopDaemon`) — the caller binds them to
24
+ * norsk-ctl's typed CLI, so no command type leaks into this package.
25
+ */
26
+ export declare function cleanupDaemon(opts: {
27
+ deleteInstance: (id: string) => Promise<unknown>;
28
+ stopProxy: () => Promise<unknown>;
29
+ stopDaemon: () => Promise<unknown>;
30
+ instances?: string[];
31
+ proxy?: boolean;
32
+ daemon: ReturnType<typeof Bun.spawn> | null;
33
+ storeDir: string;
34
+ containers: string[];
35
+ }): Promise<void>;
36
+ /** Tear down a direct-API test: stop proxy, remove instances, rm temp dir. */
37
+ export declare function cleanupDirect(opts: {
38
+ proxy?: {
39
+ stop(): Promise<unknown>;
40
+ };
41
+ instances?: {
42
+ remove(id: string): Promise<unknown>;
43
+ };
44
+ instanceIds?: string[];
45
+ tempDir?: string;
46
+ containers: string[];
47
+ }): Promise<void>;
package/daemon.js ADDED
@@ -0,0 +1,168 @@
1
+ // Daemon-lifecycle helpers for the product integration harnesses. Self-contained
2
+ // so the package publishes cleanly: it spawns the norsk-ctl CLI by command prefix
3
+ // (see cli-command.ts) and drives teardown through an injected command runner,
4
+ // carrying no dependency on norsk-ctl's generated typed `commands` builder.
5
+ import { spawnSync } from "node:child_process";
6
+ import { existsSync, rmSync, writeFileSync } from "node:fs";
7
+ import { createServer } from "node:net";
8
+ import { join } from "node:path";
9
+ import { cliCommand } from "./cli-command.js";
10
+ import { pollUntil } from "./poll.js";
11
+ import { makeStoreDir, makeTempDir, TEST_TMP_BASE } from "./temp-dir.js";
12
+ process.env.NORSK_CTL_NO_MEDIA_DOWNLOAD = "1";
13
+ export const HEALTH_TIMEOUT_MS = 120_000;
14
+ export { makeStoreDir, makeTempDir, pollUntil, TEST_TMP_BASE };
15
+ function splitLines(text) {
16
+ return text
17
+ .split("\n")
18
+ .map((l) => l.trim())
19
+ .filter(Boolean);
20
+ }
21
+ export function requireLicenseFile() {
22
+ const licenseFile = process.env.NORSK_LICENSE_FILE;
23
+ // Some tests call this at module-load (in describe()/test() bodies); others
24
+ // call it at execution time. A throw at module-load gets wrapped by bun's
25
+ // "Unhandled error between tests" reporter and produces a cascade of red
26
+ // alongside genuinely-unrelated failures. Instead, print one clean message
27
+ // and exit so the suite stops the moment we hit a license-needing test.
28
+ if (!licenseFile || !existsSync(licenseFile)) {
29
+ const reason = !licenseFile
30
+ ? "NORSK_LICENSE_FILE env var is not set."
31
+ : `License file not found at ${licenseFile} (from NORSK_LICENSE_FILE).`;
32
+ console.error(`\nIntegration tests require a Norsk license:\n ${reason}\n\n NORSK_LICENSE_FILE=/path/to/license.json bun run test:integration\n`);
33
+ process.exit(1);
34
+ }
35
+ return licenseFile;
36
+ }
37
+ export async function runCli(storeDir, ...args) {
38
+ const proc = Bun.spawn([...cliCommand, ...args], {
39
+ stdout: "pipe",
40
+ stderr: "pipe",
41
+ env: { ...process.env, NORSK_CTL_STORE_DIR: storeDir },
42
+ });
43
+ const [stdout, stderr] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
44
+ const exitCode = await proc.exited;
45
+ return { stdout, stderr, exitCode };
46
+ }
47
+ export function isPortFree(port) {
48
+ return new Promise((resolve) => {
49
+ const srv = createServer();
50
+ srv.once("error", () => resolve(false));
51
+ srv.listen(port, () => srv.close(() => resolve(true)));
52
+ });
53
+ }
54
+ export function killProcessOnPort(port) {
55
+ try {
56
+ const result = spawnSync("lsof", ["-ti", `:${port}`], { encoding: "utf-8" });
57
+ const pids = splitLines(result.stdout ?? "");
58
+ for (const pid of pids) {
59
+ try {
60
+ process.kill(Number(pid), "SIGKILL");
61
+ }
62
+ catch { }
63
+ }
64
+ }
65
+ catch { }
66
+ }
67
+ export function startDaemon(storeDir, options) {
68
+ const port = options?.port ?? 8333;
69
+ killProcessOnPort(port);
70
+ // Root the instance working directory under the store dir (itself under
71
+ // TEST_TMP_BASE) rather than the daemon default ~/norsk-runtime. The launcher
72
+ // bind-mounts the workdir as /data and then mounts the stored template's
73
+ // workflow.yml (which lives under the store dir) *inside* it — Docker Desktop
74
+ // rejects a nested bind mount whose inner source resolves to a different host
75
+ // share-root than the outer, so both mounts must share the TEST_TMP_BASE root.
76
+ // With NORSK_CTL_STORE_DIR set, config.yaml is read from <storeDir>/config.yaml.
77
+ // networkMode is the one required config.yaml field — omitting it makes the
78
+ // whole file fail to parse (ConfigParseError), silently dropping the workdir
79
+ // override. Seed both.
80
+ const configPath = join(storeDir, "config.yaml");
81
+ if (!existsSync(configPath)) {
82
+ writeFileSync(configPath, `networkMode: docker\ndefaultWorkingDirectory: ${JSON.stringify(join(storeDir, "norsk-runtime"))}\n`);
83
+ }
84
+ const daemon = Bun.spawn([...cliCommand, "serve"], {
85
+ stdout: "inherit",
86
+ stderr: "inherit",
87
+ env: { ...process.env, NORSK_CTL_STORE_DIR: storeDir, NORSK_CTL_PORT: String(port), ...options?.env },
88
+ });
89
+ const ready = pollUntil(async () => {
90
+ const res = await fetch(`http://localhost:${port}/api/ready`, { signal: AbortSignal.timeout(1000) });
91
+ return res.ok;
92
+ }, { timeoutMs: 10_000, intervalMs: 500, label: "Daemon did not become ready" });
93
+ return { daemon, ready };
94
+ }
95
+ function runningContainers(names) {
96
+ const result = spawnSync("docker", ["ps", "--format", "{{.Names}}"], { encoding: "utf-8" });
97
+ if (result.status !== 0)
98
+ return [];
99
+ const running = splitLines(result.stdout);
100
+ return names.filter((n) => running.includes(n));
101
+ }
102
+ async function awaitContainerShutdown(containers) {
103
+ await pollUntil(async () => runningContainers(containers).length === 0, {
104
+ timeoutMs: 10_000,
105
+ intervalMs: 1000,
106
+ label: "Containers did not stop",
107
+ });
108
+ const still = runningContainers(containers);
109
+ if (still.length)
110
+ throw new Error(`Containers still running after cleanup: ${still.join(", ")}`);
111
+ }
112
+ /**
113
+ * Tear down a daemon-based test: delete instances, stop proxy, stop daemon, kill
114
+ * process, rm store. The teardown actions are injected as pre-bound thunks
115
+ * (`deleteInstance`/`stopProxy`/`stopDaemon`) — the caller binds them to
116
+ * norsk-ctl's typed CLI, so no command type leaks into this package.
117
+ */
118
+ export async function cleanupDaemon(opts) {
119
+ for (const id of opts.instances ?? []) {
120
+ try {
121
+ await opts.deleteInstance(id);
122
+ }
123
+ catch (e) {
124
+ console.error(`cleanup: delete ${id} failed: ${e}`);
125
+ }
126
+ }
127
+ if (opts.proxy)
128
+ try {
129
+ await opts.stopProxy();
130
+ }
131
+ catch (e) {
132
+ console.error(`cleanup: proxy stop failed: ${e}`);
133
+ }
134
+ try {
135
+ await opts.stopDaemon();
136
+ }
137
+ catch (e) {
138
+ console.error(`cleanup: daemon stop failed: ${e}`);
139
+ }
140
+ if (opts.daemon) {
141
+ opts.daemon.kill();
142
+ // Escalate to SIGKILL after 3s if process hasn't exited
143
+ const race = Promise.race([opts.daemon.exited, Bun.sleep(3000).then(() => "timeout")]);
144
+ if ((await race) === "timeout") {
145
+ opts.daemon.kill(9);
146
+ await Promise.race([opts.daemon.exited, Bun.sleep(2000)]);
147
+ }
148
+ }
149
+ await awaitContainerShutdown(opts.containers);
150
+ rmSync(opts.storeDir, { recursive: true, force: true });
151
+ }
152
+ /** Tear down a direct-API test: stop proxy, remove instances, rm temp dir. */
153
+ export async function cleanupDirect(opts) {
154
+ if (opts.proxy)
155
+ try {
156
+ await opts.proxy.stop();
157
+ }
158
+ catch { }
159
+ for (const id of opts.instanceIds ?? []) {
160
+ try {
161
+ await opts.instances?.remove(id);
162
+ }
163
+ catch { }
164
+ }
165
+ await awaitContainerShutdown(opts.containers);
166
+ if (opts.tempDir)
167
+ rmSync(opts.tempDir, { recursive: true, force: true });
168
+ }
@@ -0,0 +1,18 @@
1
+ /** Deterministic rolling hash of a slug, unsigned 32-bit. Identical inputs
2
+ * always map to the same value so a test slug lands in the same port band on
3
+ * every run. */
4
+ export declare function slugHash(slug: string): number;
5
+ /** The per-slug slot each product's `allocatePorts` bands off — `slugHash(slug)
6
+ * % mod`. Products pick their own `mod` (playout/commentary 50, funke 40) so
7
+ * their bands stay disjoint. */
8
+ export declare function hashSlot(slug: string, mod: number): number;
9
+ /** Fields every product harness allocates. Each product extends this with its
10
+ * own ingest/egress ports (playout `ingestPort`/`egressPortBase`, funke
11
+ * `primaryPort`/`backupPort`, …) — those know the product's config schema and
12
+ * stay in each product's local `HarnessPorts`. */
13
+ export interface BaseHarnessPorts {
14
+ daemonPort: number;
15
+ backendPort: number;
16
+ studioHostPort: number;
17
+ instancePortBase: number;
18
+ }
@@ -0,0 +1,28 @@
1
+ // Pure port-allocation seam shared across the product integration harnesses.
2
+ //
3
+ // The class-of-bug this whole tier guards against is a mis-mapped ingest port:
4
+ // the launched product template's SRT listener binds one port while the pumped
5
+ // source dials another, so every matrix row silently times out on
6
+ // `connectedStreams.length === 0`. Keeping the hash + slot math pure lets a unit
7
+ // test catch a mis-allocation before any container starts.
8
+ //
9
+ // `slugHash` is byte-identical to the copies previously inlined in each
10
+ // product's harness-config.ts / harness.ts; `hashSlot` names the `% mod` step
11
+ // each product's `allocatePorts` performs before banding. The per-product port
12
+ // bands + `remapConfigPorts` stay local (they know each config schema), but they
13
+ // build on this seam.
14
+ /** Deterministic rolling hash of a slug, unsigned 32-bit. Identical inputs
15
+ * always map to the same value so a test slug lands in the same port band on
16
+ * every run. */
17
+ export function slugHash(slug) {
18
+ let h = 0;
19
+ for (let i = 0; i < slug.length; i++)
20
+ h = (h * 31 + slug.charCodeAt(i)) >>> 0;
21
+ return h;
22
+ }
23
+ /** The per-slug slot each product's `allocatePorts` bands off — `slugHash(slug)
24
+ * % mod`. Products pick their own `mod` (playout/commentary 50, funke 40) so
25
+ * their bands stay disjoint. */
26
+ export function hashSlot(slug, mod) {
27
+ return slugHash(slug) % mod;
28
+ }
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "@norskvideo/ctl-test-harness",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "exports": {
6
+ "./daemon": {
7
+ "types": "./daemon.d.ts",
8
+ "default": "./daemon.js"
9
+ },
10
+ "./source-pump": {
11
+ "types": "./source-pump.d.ts",
12
+ "default": "./source-pump.js"
13
+ },
14
+ "./studio-state": {
15
+ "types": "./studio-state.d.ts",
16
+ "default": "./studio-state.js"
17
+ },
18
+ "./harness-config": {
19
+ "types": "./harness-config.d.ts",
20
+ "default": "./harness-config.js"
21
+ }
22
+ },
23
+ "publishConfig": {
24
+ "access": "public"
25
+ }
26
+ }
package/poll.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ export declare function pollUntil(fn: () => Promise<boolean>, opts: {
2
+ timeoutMs: number;
3
+ intervalMs?: number;
4
+ label?: string;
5
+ }): Promise<void>;
package/poll.js ADDED
@@ -0,0 +1,23 @@
1
+ const DEFAULT_INTERVAL_MS = 500;
2
+ export async function pollUntil(fn, opts) {
3
+ const deadline = Date.now() + opts.timeoutMs;
4
+ const interval = opts.intervalMs ?? DEFAULT_INTERVAL_MS;
5
+ let lastError;
6
+ while (Date.now() < deadline) {
7
+ try {
8
+ if (await fn())
9
+ return;
10
+ }
11
+ catch (e) {
12
+ lastError = e;
13
+ }
14
+ await new Promise((r) => setTimeout(r, interval));
15
+ }
16
+ // Surface the last thrown error so the timeout message points at the real
17
+ // cause instead of a tautological "predicate never returned true".
18
+ const base = opts.label
19
+ ? `${opts.label} (${opts.timeoutMs / 1000}s)`
20
+ : `pollUntil timed out after ${opts.timeoutMs / 1000}s`;
21
+ const suffix = lastError instanceof Error ? `; last error: ${lastError.message}` : "";
22
+ throw new Error(`${base}${suffix}`);
23
+ }
@@ -0,0 +1,34 @@
1
+ export interface SourceHandle {
2
+ name: string;
3
+ stop(): Promise<void>;
4
+ }
5
+ export declare function startCameraSource(opts: {
6
+ daemonPort: number;
7
+ instanceId: string;
8
+ preset?: string;
9
+ port: number;
10
+ streamId?: string;
11
+ /** Override the source identity (container name + management key).
12
+ * Default: preset name. Required when the same preset must run multiple
13
+ * times within an instance. */
14
+ name?: string;
15
+ timeoutMs?: number;
16
+ }): Promise<SourceHandle>;
17
+ export interface SrtPumpTarget {
18
+ port: number;
19
+ /** Source identity within the instance. Must be unique per instance. */
20
+ name: string;
21
+ /** Sent as the SRT `streamid` query param. When targeting a specific
22
+ * listener that filters by streamid, pass the expected id here. */
23
+ streamId?: string;
24
+ /** Preset to source from. Defaults to camera1; pick camera2 (or rotate)
25
+ * when you want visually distinct streams. */
26
+ preset?: string;
27
+ }
28
+ export declare function startSrtSources(opts: {
29
+ daemonPort: number;
30
+ instanceId: string;
31
+ targets: readonly SrtPumpTarget[];
32
+ timeoutMs?: number;
33
+ }): Promise<SourceHandle[]>;
34
+ export declare function stopAll(handles: readonly SourceHandle[]): Promise<void>;
package/source-pump.js ADDED
@@ -0,0 +1,68 @@
1
+ // Thin wrapper over the daemon's SourceService (`POST /api/sources`).
2
+ // Spawns a daemon-managed `linuxserver/ffmpeg` sidecar that loops a preset
3
+ // MP4/TS over SRT into the target instance's media container.
4
+ //
5
+ // Polls `/api/sources?instanceId=<id>` for `running` (avoids the SSE-client
6
+ // boilerplate). The daemon's stopAll(instanceId) sweep catches anything the
7
+ // test forgets to release on its own.
8
+ //
9
+ // `name` overrides the source identity (container name + management key) so
10
+ // multiple sources on the same preset can co-exist within one instance. See
11
+ // `startSrtSources` for the multi-port fan-out helper that uses it.
12
+ import { pollUntil } from "./daemon.js";
13
+ export async function startCameraSource(opts) {
14
+ const preset = opts.preset ?? "camera1";
15
+ const name = opts.name ?? preset;
16
+ const body = { instanceId: opts.instanceId, preset, port: opts.port };
17
+ if (opts.streamId !== undefined)
18
+ body.streamId = opts.streamId;
19
+ if (opts.name !== undefined)
20
+ body.name = opts.name;
21
+ const startRes = await fetch(`http://localhost:${opts.daemonPort}/api/sources`, {
22
+ method: "POST",
23
+ headers: { "Content-Type": "application/json" },
24
+ body: JSON.stringify(body),
25
+ signal: AbortSignal.timeout(10_000),
26
+ });
27
+ if (!startRes.ok) {
28
+ throw new Error(`POST /api/sources failed (${startRes.status}): ${await startRes.text()}`);
29
+ }
30
+ await pollUntil(async () => {
31
+ const r = await fetch(`http://localhost:${opts.daemonPort}/api/sources?instanceId=${encodeURIComponent(opts.instanceId)}`, { signal: AbortSignal.timeout(5000) });
32
+ if (!r.ok)
33
+ return false;
34
+ // GET /api/sources returns a bare array of SampleSource — not { sources: [...] }.
35
+ const data = (await r.json());
36
+ const src = data.find((s) => s.name === name);
37
+ return src?.status === "running" || src?.status === "healthy";
38
+ }, {
39
+ timeoutMs: opts.timeoutMs ?? 120_000,
40
+ intervalMs: 1000,
41
+ label: `source ${name} for ${opts.instanceId} did not reach running`,
42
+ });
43
+ return {
44
+ name,
45
+ async stop() {
46
+ await fetch(`http://localhost:${opts.daemonPort}/api/sources/${encodeURIComponent(opts.instanceId)}/${encodeURIComponent(name)}`, { method: "DELETE", signal: AbortSignal.timeout(10_000) }).catch(() => { });
47
+ },
48
+ };
49
+ }
50
+ export async function startSrtSources(opts) {
51
+ const handles = [];
52
+ for (const target of opts.targets) {
53
+ const handle = await startCameraSource({
54
+ daemonPort: opts.daemonPort,
55
+ instanceId: opts.instanceId,
56
+ name: target.name,
57
+ port: target.port,
58
+ ...(target.preset !== undefined ? { preset: target.preset } : {}),
59
+ ...(target.streamId !== undefined ? { streamId: target.streamId } : {}),
60
+ ...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}),
61
+ });
62
+ handles.push(handle);
63
+ }
64
+ return handles;
65
+ }
66
+ export async function stopAll(handles) {
67
+ await Promise.all(handles.map((h) => h.stop()));
68
+ }
@@ -0,0 +1,101 @@
1
+ export interface ComponentSummary {
2
+ componentId: string;
3
+ componentType: string;
4
+ capabilities?: Record<string, unknown>;
5
+ }
6
+ export interface ComponentsResponse {
7
+ components: ComponentSummary[];
8
+ totalComponents: number;
9
+ }
10
+ export declare function fetchComponents(studioHostPort: number): Promise<ComponentsResponse | null>;
11
+ export declare function fetchComponentState(studioHostPort: number, componentId: string): Promise<unknown | null>;
12
+ /** SRT-listener state shape — see
13
+ * ~/src/norsk-studio/workspaces/built-ins/src/input.srt-listener/types.source.yaml:192-235.
14
+ * Required fields per the YAML schema; harness asserts on the first two. */
15
+ export interface SrtListenerState {
16
+ connectedStreams: string[];
17
+ connectedAt: Record<string, number>;
18
+ disabledStreams?: string[];
19
+ metrics?: unknown;
20
+ names?: string[];
21
+ recentRejections?: unknown[];
22
+ }
23
+ export declare function isSrtListenerState(value: unknown): value is SrtListenerState;
24
+ /** One entry in OutputStreamResult — the streams a component is currently
25
+ * producing. Empirically observed shape from /live/api/<id>/streams/mappings:
26
+ * {
27
+ * output: { media, displayName, sourceName, programNumber, streamId, renditionName },
28
+ * metadata: { case: "video"|"audio", value: { ... } },
29
+ * preview_url?: string,
30
+ * preview_jpeg?: string,
31
+ * }
32
+ * `output.sourceName` is the stream's name, propagated through the wire
33
+ * graph — for an input.srt-listener it is the configured sourceName. Sink
34
+ * components (output.srt-listener, output.autoCmaf) typically don't produce
35
+ * output streams and therefore return an empty array. */
36
+ export interface StreamMappingEntry {
37
+ output: {
38
+ media?: string;
39
+ displayName?: string;
40
+ sourceName?: string;
41
+ renditionName?: string;
42
+ programNumber?: number;
43
+ streamId?: number;
44
+ [k: string]: unknown;
45
+ };
46
+ metadata?: unknown;
47
+ preview_url?: string;
48
+ preview_jpeg?: string;
49
+ [k: string]: unknown;
50
+ }
51
+ export declare function fetchStreamMappings(studioHostPort: number, componentId: string): Promise<StreamMappingEntry[] | null>;
52
+ export declare function fetchStreamSources(studioHostPort: number, componentId: string): Promise<unknown | null>;
53
+ export declare function fetchStreamUnmapped(studioHostPort: number, componentId: string): Promise<unknown | null>;
54
+ export declare function fetchStreamFill(studioHostPort: number, componentId: string): Promise<unknown | null>;
55
+ export interface AssertStreamOutputOpts {
56
+ studioHostPort: number;
57
+ componentId: string;
58
+ media: "video" | "audio" | "playlist";
59
+ /** Optional — pin the stream's name. For input.srt-listener it is the
60
+ * configured sourceName; re-encoders that preserve the source name keep the
61
+ * same value. Some processors drop the source name on their output, so omit
62
+ * this filter when asserting downstream of them. */
63
+ expectedSourceName?: string;
64
+ /** Optional filter — pin a specific encoded rendition (e.g. "h264_1280x720"). */
65
+ renditionName?: string;
66
+ timeoutMs?: number;
67
+ intervalMs?: number;
68
+ }
69
+ export declare function assertStreamOutput(opts: AssertStreamOutputOpts): Promise<void>;
70
+ /** Polls Studio's per-component state for an SRT-listener until at least one
71
+ * stream is reported as connected with a non-zero connectedAt timestamp. */
72
+ export declare function assertSrtConnected(opts: {
73
+ studioHostPort: number;
74
+ componentId: string;
75
+ timeoutMs?: number;
76
+ intervalMs?: number;
77
+ }): Promise<void>;
78
+ export interface CmafMultiVariantState {
79
+ url?: string;
80
+ enabled?: boolean;
81
+ }
82
+ /** Fetches the multivariant m3u8 URL from state, then GETs it. Fails if the URL
83
+ * never appears within the timeout OR the body does not contain a reference to
84
+ * every expected rendition label. Rendition labels are the child playlists'
85
+ * filenames — Studio's autoCmaf lists each rendition as a
86
+ * `#EXT-X-STREAM-INF ... URI="<rendition>/norsk.m3u8"` (video) or a
87
+ * `#EXT-X-MEDIA:TYPE=AUDIO ... URI="<rendition>/norsk.m3u8"` line. The caller
88
+ * passes the set of expected labels; the body must reference every one. */
89
+ export declare function assertMultivariantHasRenditions(opts: {
90
+ studioHostPort: number;
91
+ componentId: string;
92
+ expectedRenditionLabels: readonly string[];
93
+ timeoutMs?: number;
94
+ intervalMs?: number;
95
+ }): Promise<void>;
96
+ export declare function assertNoUnmapped(opts: {
97
+ studioHostPort: number;
98
+ componentId: string;
99
+ timeoutMs?: number;
100
+ intervalMs?: number;
101
+ }): Promise<void>;
@@ -0,0 +1,218 @@
1
+ // Studio runtime-state fetchers, hit directly on the product-template-published
2
+ // STUDIO_HOST_PORT (the product template publishes `deployment.studioHostPort`
3
+ // on the studio service). No proxy/auth required — host-published port bypasses
4
+ // nginx and oauth2-proxy entirely.
5
+ //
6
+ // Two endpoints power the topology + ingest assertions:
7
+ // GET /live/api/components — topology: which component ids exist
8
+ // GET /live/api/<componentId>/state — per-component runtime state
9
+ //
10
+ // 503 ("Workflow is not running yet") is normal during boot; both helpers
11
+ // return null in that case so the caller's pollUntil keeps polling.
12
+ //
13
+ // This is the product-agnostic head shared by the playout & commentary
14
+ // integration tiers (funke's file was a strict subset of the first two
15
+ // fetchers). Extracted verbatim from the playout copy — the canonical text.
16
+ // Commentary's WHIP-driver tail (RemoteCommentary*, discoverCommentaryWhipUrls,
17
+ // assertCommentaryChannelOccupied) stays local to commentary.
18
+ export async function fetchComponents(studioHostPort) {
19
+ const r = await fetch(`http://localhost:${studioHostPort}/live/api/components`, {
20
+ signal: AbortSignal.timeout(5000),
21
+ });
22
+ if (r.status === 503)
23
+ return null;
24
+ if (!r.ok)
25
+ throw new Error(`GET /live/api/components -> ${r.status}: ${await r.text()}`);
26
+ return (await r.json());
27
+ }
28
+ export async function fetchComponentState(studioHostPort, componentId) {
29
+ const r = await fetch(`http://localhost:${studioHostPort}/live/api/${encodeURIComponent(componentId)}/state`, {
30
+ signal: AbortSignal.timeout(5000),
31
+ });
32
+ if (r.status === 503 || r.status === 404)
33
+ return null;
34
+ if (!r.ok)
35
+ throw new Error(`GET /live/api/${componentId}/state -> ${r.status}: ${await r.text()}`);
36
+ return await r.json();
37
+ }
38
+ export function isSrtListenerState(value) {
39
+ if (!value || typeof value !== "object")
40
+ return false;
41
+ const v = value;
42
+ return Array.isArray(v.connectedStreams) && typeof v.connectedAt === "object" && v.connectedAt !== null;
43
+ }
44
+ async function fetchComponentStreamRoute(studioHostPort, componentId, route) {
45
+ const r = await fetch(`http://localhost:${studioHostPort}/live/api/${encodeURIComponent(componentId)}/streams/${route}`, { signal: AbortSignal.timeout(5000) });
46
+ if (r.status === 503 || r.status === 404)
47
+ return null;
48
+ if (!r.ok)
49
+ throw new Error(`GET /live/api/${componentId}/streams/${route} -> ${r.status}: ${await r.text()}`);
50
+ return await r.json();
51
+ }
52
+ export async function fetchStreamMappings(studioHostPort, componentId) {
53
+ const data = await fetchComponentStreamRoute(studioHostPort, componentId, "mappings");
54
+ if (data === null)
55
+ return null;
56
+ if (Array.isArray(data))
57
+ return data;
58
+ // Studio wraps the result; tolerate either { mappings: [...] } or { streams: [...] }
59
+ const obj = data;
60
+ for (const key of ["mappings", "streams", "result"]) {
61
+ if (Array.isArray(obj[key]))
62
+ return obj[key];
63
+ }
64
+ return null;
65
+ }
66
+ export async function fetchStreamSources(studioHostPort, componentId) {
67
+ return fetchComponentStreamRoute(studioHostPort, componentId, "sources");
68
+ }
69
+ export async function fetchStreamUnmapped(studioHostPort, componentId) {
70
+ return fetchComponentStreamRoute(studioHostPort, componentId, "unmapped");
71
+ }
72
+ export async function fetchStreamFill(studioHostPort, componentId) {
73
+ return fetchComponentStreamRoute(studioHostPort, componentId, "fill");
74
+ }
75
+ // ── Data-flow assertion helpers ─────────────────────────────────────────────
76
+ // Poll-until-true wrappers around fetchStreamMappings + fetchStreamUnmapped so
77
+ // per-template tests can assert "component S is producing an output stream
78
+ // with media=X, sourceName=Y" without restating the polling boilerplate.
79
+ //
80
+ // Studio's /streams/mappings exposes OUTPUT-stream metadata, not subscription
81
+ // edges — the visible signal is `output.sourceName` (the stream's name as it
82
+ // propagates through the graph). For an input.srt-listener that is the
83
+ // configured sourceName; downstream encoders that preserve the stream name
84
+ // (re-encode without rename) keep the same value.
85
+ //
86
+ // The timeout error includes the last-seen mappings payload so a failure tells
87
+ // the reader what Studio was actually serving.
88
+ import { pollUntil } from "./daemon.js";
89
+ export async function assertStreamOutput(opts) {
90
+ let lastSeen = null;
91
+ const matches = (m) => {
92
+ if (m.output?.media !== opts.media)
93
+ return false;
94
+ if (opts.expectedSourceName !== undefined && m.output?.sourceName !== opts.expectedSourceName)
95
+ return false;
96
+ if (opts.renditionName !== undefined && m.output?.renditionName !== opts.renditionName)
97
+ return false;
98
+ return true;
99
+ };
100
+ try {
101
+ await pollUntil(async () => {
102
+ lastSeen = await fetchStreamMappings(opts.studioHostPort, opts.componentId);
103
+ if (!lastSeen)
104
+ return false;
105
+ return lastSeen.some(matches);
106
+ }, {
107
+ timeoutMs: opts.timeoutMs ?? 60_000,
108
+ intervalMs: opts.intervalMs ?? 1000,
109
+ label: opts.componentId,
110
+ });
111
+ }
112
+ catch (e) {
113
+ const filterTail = [
114
+ opts.expectedSourceName !== undefined ? `sourceName="${opts.expectedSourceName}"` : null,
115
+ opts.renditionName !== undefined ? `rendition="${opts.renditionName}"` : null,
116
+ ]
117
+ .filter(Boolean)
118
+ .join(" ");
119
+ const tail = filterTail ? ` (${filterTail})` : "";
120
+ throw new Error(`assertStreamOutput: ${opts.componentId} has no ${opts.media} output${tail}. Last mappings: ${JSON.stringify(lastSeen)}`, { cause: e });
121
+ }
122
+ }
123
+ /** Polls Studio's per-component state for an SRT-listener until at least one
124
+ * stream is reported as connected with a non-zero connectedAt timestamp. */
125
+ export async function assertSrtConnected(opts) {
126
+ let lastSeen = null;
127
+ try {
128
+ await pollUntil(async () => {
129
+ lastSeen = await fetchComponentState(opts.studioHostPort, opts.componentId);
130
+ if (!isSrtListenerState(lastSeen))
131
+ return false;
132
+ if (lastSeen.connectedStreams.length < 1)
133
+ return false;
134
+ return Object.values(lastSeen.connectedAt).some((t) => typeof t === "number" && t > 0);
135
+ }, {
136
+ timeoutMs: opts.timeoutMs ?? 60_000,
137
+ intervalMs: opts.intervalMs ?? 1000,
138
+ label: opts.componentId,
139
+ });
140
+ }
141
+ catch (e) {
142
+ throw new Error(`assertSrtConnected: ${opts.componentId} never reported a connected stream. Last state: ${JSON.stringify(lastSeen)}`, { cause: e });
143
+ }
144
+ }
145
+ /** Fetches the multivariant m3u8 URL from state, then GETs it. Fails if the URL
146
+ * never appears within the timeout OR the body does not contain a reference to
147
+ * every expected rendition label. Rendition labels are the child playlists'
148
+ * filenames — Studio's autoCmaf lists each rendition as a
149
+ * `#EXT-X-STREAM-INF ... URI="<rendition>/norsk.m3u8"` (video) or a
150
+ * `#EXT-X-MEDIA:TYPE=AUDIO ... URI="<rendition>/norsk.m3u8"` line. The caller
151
+ * passes the set of expected labels; the body must reference every one. */
152
+ export async function assertMultivariantHasRenditions(opts) {
153
+ let lastState = null;
154
+ let lastBody = null;
155
+ let lastFetchStatus = null;
156
+ const missingLabels = (body) => opts.expectedRenditionLabels.filter((label) => !body.includes(label));
157
+ try {
158
+ await pollUntil(async () => {
159
+ const state = (await fetchComponentState(opts.studioHostPort, opts.componentId));
160
+ lastState = state;
161
+ if (!state?.url)
162
+ return false;
163
+ try {
164
+ const r = await fetch(state.url, { signal: AbortSignal.timeout(5000) });
165
+ lastFetchStatus = r.status;
166
+ if (!r.ok)
167
+ return false;
168
+ lastBody = await r.text();
169
+ }
170
+ catch (e) {
171
+ lastFetchStatus = e instanceof Error ? e.message : String(e);
172
+ return false;
173
+ }
174
+ return missingLabels(lastBody).length === 0;
175
+ }, {
176
+ timeoutMs: opts.timeoutMs ?? 90_000,
177
+ intervalMs: opts.intervalMs ?? 2000,
178
+ label: `${opts.componentId}.m3u8`,
179
+ });
180
+ }
181
+ catch (e) {
182
+ // lastBody is mutated inside the pollUntil closure, so control-flow analysis
183
+ // narrows it back to its `null` initializer here. Re-widen for the dump.
184
+ const body = lastBody;
185
+ const missing = body ? missingLabels(body) : opts.expectedRenditionLabels.slice();
186
+ throw new Error(`assertMultivariantHasRenditions: ${opts.componentId} m3u8 did not list every rendition. Missing: ${JSON.stringify(missing)}. Last state: ${JSON.stringify(lastState)}. Last fetch: ${JSON.stringify(lastFetchStatus)}. Last body: ${body ? body.slice(0, 800) : "<none>"}.`, { cause: e });
187
+ }
188
+ }
189
+ export async function assertNoUnmapped(opts) {
190
+ let lastSeen = null;
191
+ const isEmpty = (value) => {
192
+ if (value === null)
193
+ return false;
194
+ if (Array.isArray(value))
195
+ return value.length === 0;
196
+ if (typeof value === "object") {
197
+ const obj = value;
198
+ for (const key of ["unmapped", "streams", "result"]) {
199
+ if (Array.isArray(obj[key]))
200
+ return obj[key].length === 0;
201
+ }
202
+ }
203
+ return false;
204
+ };
205
+ try {
206
+ await pollUntil(async () => {
207
+ lastSeen = await fetchStreamUnmapped(opts.studioHostPort, opts.componentId);
208
+ return isEmpty(lastSeen);
209
+ }, {
210
+ timeoutMs: opts.timeoutMs ?? 30_000,
211
+ intervalMs: opts.intervalMs ?? 1000,
212
+ label: opts.componentId,
213
+ });
214
+ }
215
+ catch (e) {
216
+ throw new Error(`assertNoUnmapped: ${opts.componentId} still has unmapped streams. Last payload: ${JSON.stringify(lastSeen)}`, { cause: e });
217
+ }
218
+ }
package/temp-dir.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ /** Create a uniquely-named temp dir under an explicit base. */
2
+ export declare function makeTempDirUnder(base: string, prefix: string): string;
3
+ export declare const TEST_TMP_BASE: string;
4
+ /** Create a uniquely-named temp dir under the consumer-local test base. */
5
+ export declare function makeTempDir(prefix?: string): string;
6
+ export declare function makeStoreDir(prefix?: string): string;
package/temp-dir.js ADDED
@@ -0,0 +1,51 @@
1
+ // Temp-dir helper for tests that bind-mount a dir into a container. As a shared
2
+ // package it cannot anchor to one repo's tree, so the base is the CONSUMER's:
3
+ // NORSK_CTL_TEST_TMP if set, else `<cwd>/test-temp`. Every test tier runs with
4
+ // cwd at its own package root, so each consumer's dirs land in its own repo.
5
+ //
6
+ // Why NOT os.tmpdir(): OrbStack does NOT forward host->guest filesystem
7
+ // (inotify) events for /tmp / /private/tmp — only for the normally-shared macOS
8
+ // filesystem (the repo, $HOME, etc.). oauth2-proxy's htpasswd watcher and other
9
+ // reload paths depend on those events — under /tmp the watcher never fires and
10
+ // every proxy login 401s, which looks exactly like a code bug. Docker Desktop
11
+ // forwards /tmp events too, which is why that env never surfaced it.
12
+ // Native-Linux Docker is unaffected (real kernel inotify). A repo-local
13
+ // `test-temp/` is on the shared filesystem so events forward.
14
+ import { mkdirSync, mkdtempSync, readdirSync, rmSync, statSync } from "node:fs";
15
+ import { join } from "node:path";
16
+ /** Create a uniquely-named temp dir under an explicit base. */
17
+ export function makeTempDirUnder(base, prefix) {
18
+ mkdirSync(base, { recursive: true });
19
+ pruneStaleTempDirs(base);
20
+ return mkdtempSync(join(base, prefix));
21
+ }
22
+ export const TEST_TMP_BASE = process.env.NORSK_CTL_TEST_TMP ?? join(process.cwd(), "test-temp");
23
+ // Best-effort cleanup of dirs orphaned by a crashed run whose teardown never got
24
+ // to rm them (a repo-local base isn't OS-reclaimed like /tmp, so prune here).
25
+ // The 1h cutoff never races a live run — no test runs near that long — so this
26
+ // can't delete a sibling run's active dir.
27
+ function pruneStaleTempDirs(base) {
28
+ try {
29
+ const cutoff = Date.now() - 60 * 60 * 1000;
30
+ for (const name of readdirSync(base)) {
31
+ const p = join(base, name);
32
+ try {
33
+ if (statSync(p).mtimeMs < cutoff)
34
+ rmSync(p, { recursive: true, force: true });
35
+ }
36
+ catch {
37
+ // ignore — racing teardown of a sibling run, or a transient stat error
38
+ }
39
+ }
40
+ }
41
+ catch {
42
+ // base doesn't exist yet — nothing to prune
43
+ }
44
+ }
45
+ /** Create a uniquely-named temp dir under the consumer-local test base. */
46
+ export function makeTempDir(prefix = "norsk-ctl-") {
47
+ return makeTempDirUnder(TEST_TMP_BASE, prefix);
48
+ }
49
+ export function makeStoreDir(prefix = "norsk-ctl-test-") {
50
+ return makeTempDir(prefix);
51
+ }