@norskvideo/ctl-test-harness 0.1.7 → 0.1.8

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,110 @@
1
+ export declare const DOCKER_NETWORK_NAME = "norsk-net";
2
+ export declare const STUDIO_INTERNAL_PORT = 8000;
3
+ export declare const MEDIA_HTTP_INTERNAL_PORT = 8080;
4
+ export type NetReachMode = "publish" | "direct";
5
+ /** How the suite reaches instance containers. `direct` = norsk-net service DNS
6
+ * (no host publish, no collision) — only viable on a Linux DooD runner whose
7
+ * test container is on norsk-net. `publish` = today's host-published port,
8
+ * the Docker-Desktop-safe default. Read from NORSK_TEST_NET. */
9
+ export declare function netReachMode(env?: Record<string, string | undefined>): NetReachMode;
10
+ export interface Endpoint {
11
+ host: string;
12
+ port: number;
13
+ }
14
+ export declare const authority: (e: Endpoint) => string;
15
+ /** Compose's container DNS for a template-launched instance: `<id>-<service>-1`.
16
+ * Byte-identical to norsk-ctl's productInstanceHost — the compose project is the
17
+ * instance id and each service runs a single replica. */
18
+ export declare function productServiceHost(instanceId: string, service: string): string;
19
+ export interface ResolveOpts {
20
+ instanceId: string;
21
+ service: "studio" | "media";
22
+ /** Port the listener binds INSIDE the container (studio 8000, media SRT its
23
+ * own configured port). Used in `direct` mode. */
24
+ internalPort: number;
25
+ /** Host + port the container is published on today. Used in `publish` mode. */
26
+ publishHost: string;
27
+ publishPort: number;
28
+ /** Defaults to netReachMode() so a suite flips the whole harness with one env var. */
29
+ mode?: NetReachMode;
30
+ }
31
+ /** The seam: one function every reach decision goes through. `publish` returns
32
+ * the host-published endpoint unchanged (zero behaviour change); `direct`
33
+ * returns the unique service DNS at the internal port. */
34
+ export declare function resolveEndpoint(o: ResolveOpts): Endpoint;
35
+ /** Base URL for Studio's `/live/api/*` control surface. Publish mode reaches the
36
+ * host-published studioHostPort (as studio-state.ts does today); direct mode
37
+ * reaches `<id>-studio-1:8000`. Drop-in for the `http://${STUDIO_HOST}:${port}`
38
+ * prefix the studio fetchers build. */
39
+ export declare function studioApiBase(o: {
40
+ instanceId: string;
41
+ publishHost: string;
42
+ publishPort: number;
43
+ mode?: NetReachMode;
44
+ }): string;
45
+ /** Base URL for the Norsk media container's HTTP surface (HLS/CMAF pull —
46
+ * `/ts/*.m3u8`, `/cmaf/*`). Publish mode dials the host-published media port on
47
+ * TEST_HOST (as funke's hls-capture does today); direct mode dials
48
+ * `<id>-media-1:8080`. Mirrors studio-state.mediaDirectUrl, generalised for the
49
+ * direct-pull capture path. */
50
+ export declare function mediaHttpBase(o: {
51
+ instanceId: string;
52
+ publishHost: string;
53
+ publishPort: number;
54
+ mode?: NetReachMode;
55
+ }): string;
56
+ /** Caller-mode SRT URL for an output.srt egress listener. Publish mode dials the
57
+ * host-published egress port on TEST_HOST (as output-capture.ts does today);
58
+ * direct mode dials `<id>-media-1:<internalPort>`. Because remapConfigPorts
59
+ * currently sets the internal listener port == the published port, `internalPort`
60
+ * and `publishPort` are the same band value today — going direct is what later
61
+ * lets that band be dropped entirely. */
62
+ export declare function srtEgressUrl(o: {
63
+ instanceId: string;
64
+ internalPort: number;
65
+ publishHost: string;
66
+ publishPort: number;
67
+ mode?: NetReachMode;
68
+ }): string;
69
+ /** What a caller needs to reach an instance's Studio control surface under
70
+ * either mode: the published port (publish) plus the instance id (direct, to
71
+ * form `<id>-studio-1`). Threaded in place of a bare `studioHostPort: number`. */
72
+ export interface StudioTarget {
73
+ instanceId: string;
74
+ studioHostPort: number;
75
+ }
76
+ /** What a fetcher/control helper accepts to reach Studio: EITHER a bare published
77
+ * port (legacy — every existing caller, other products included) OR a
78
+ * StudioTarget (instanceId-aware, opts the call into direct-net). */
79
+ export type StudioReach = number | StudioTarget;
80
+ /** Resolve Studio's base URL from EITHER a bare published port (legacy — dials
81
+ * `publishHost:port`, exactly today's behaviour) OR a StudioTarget (instanceId-
82
+ * aware, so direct mode dials `<id>-studio-1:8000`). The union is the whole
83
+ * migration story: every existing `studioHostPort: number` caller — including
84
+ * other products' suites — keeps working untouched, and a suite opts a call into
85
+ * direct-net simply by passing the target instead of the number. */
86
+ export declare function studioBaseFrom(studio: StudioReach, publishHost: string, mode?: NetReachMode): string;
87
+ /** The 64-hex container id from a /proc/self/cgroup dump, or null on a bare host.
88
+ * In a container the id also appears as the hostname, but cgroup is present in
89
+ * both cgroup v1 (`/docker/<id>`) and v2 (`docker-<id>.scope`) layouts. */
90
+ export declare function parseOwnContainerId(cgroupText: string): string | null;
91
+ export declare function ownContainerId(read?: () => string): string | null;
92
+ /** `docker network connect` is not idempotent — a second attach errors. Treat
93
+ * the "already attached" variants as success so setup can call this blindly. */
94
+ export declare function isAlreadyOnNetworkError(stderr: string): boolean;
95
+ type RunResult = {
96
+ status: number | null;
97
+ stderr: string;
98
+ };
99
+ type Runner = (cmd: string, args: string[]) => RunResult;
100
+ /** Idempotently join the test container to norsk-net so `direct` service DNS
101
+ * resolves. Returns a verdict rather than throwing: `not-in-container` (nothing
102
+ * to attach — publish mode is the only sane choice), `attached`, `already`, or
103
+ * `failed` (a real error the caller should surface). */
104
+ export declare function ensureRunnerOnNetwork(opts?: {
105
+ network?: string;
106
+ /** Explicit id (tests). Omitted -> read from /proc/self/cgroup. `null` forces the not-in-container branch. */
107
+ containerId?: string | null;
108
+ run?: Runner;
109
+ }): "attached" | "already" | "not-in-container" | "failed";
110
+ export {};
@@ -0,0 +1,172 @@
1
+ // Reach launched product instances by their norsk-net service DNS instead of a
2
+ // host-published port. Dissolves the cross-product host-port collision the
3
+ // release gate trips on. Consumed by the playout, funke, and commentary
4
+ // integration harnesses (studioApiBase / mediaHttpBase / srtEgressUrl /
5
+ // studioBaseFrom); commentary's WHIP path already reaches media directly.
6
+ //
7
+ // WHY host ports collide: in the docker-outside-of-docker CI runner the test
8
+ // process is itself a container and studio/media run as HOST-sibling containers,
9
+ // so their published ports live on the shared host. Two products' harnesses band
10
+ // their ports off `hashSlot(slug, 50)` over an IDENTICAL base, so two slugs that
11
+ // land in the same slot publish the same host port and the second launch dies
12
+ // with "Bind for 0.0.0.0:<port> failed: port is already allocated". The slug->slot
13
+ // map is deterministic, so re-running never dodges it.
14
+ //
15
+ // WHY direct-net removes the whole class: every product instance is its own
16
+ // compose project on the shared external `norsk-net`, so compose names its
17
+ // containers `<id>-studio-1` / `<id>-media-1` (see norsk-ctl's productInstanceHost).
18
+ // Reaching those by name at the container-INTERNAL port (studio 8000, media
19
+ // 8080, the SRT egress listener's own port) needs NO host publish: internal
20
+ // ports live in per-container network namespaces, so a thousand instances can
21
+ // all bind 8000 internally without clashing, and the unique service DNS keeps
22
+ // them addressable. This is the same move `studio-state.mediaDirectUrl` already
23
+ // makes for media HTTP — generalised to the studio-control + SRT-egress ports
24
+ // that are the actual colliders.
25
+ //
26
+ // THE ONE CONSTRAINT: container-DNS reachability needs the test container joined
27
+ // to norsk-net (bridge DNS is per-network). On Docker Desktop / OrbStack the
28
+ // bridge is hidden inside a VM and is not routable from the host at all, so the
29
+ // direct path can't work there — those runs MUST keep publishing. Hence the mode
30
+ // switch: `direct` on the Linux DooD CI box, `publish` (the default) everywhere
31
+ // else. Nothing here changes behaviour until a caller opts a suite into `direct`.
32
+ import { spawnSync } from "node:child_process";
33
+ import { readFileSync } from "node:fs";
34
+ // Mirrors of norsk-ctl's @norsk-ctl/shared constants — re-declared (not imported)
35
+ // because this package doesn't depend on the ctl backend, exactly as
36
+ // studio-state.ts re-declares MEDIA_HTTP_PORT. Source of truth:
37
+ // packages/norsk-ctl/shared/src/base-constants.ts.
38
+ export const DOCKER_NETWORK_NAME = "norsk-net";
39
+ export const STUDIO_INTERNAL_PORT = 8000;
40
+ export const MEDIA_HTTP_INTERNAL_PORT = 8080;
41
+ /** How the suite reaches instance containers. `direct` = norsk-net service DNS
42
+ * (no host publish, no collision) — only viable on a Linux DooD runner whose
43
+ * test container is on norsk-net. `publish` = today's host-published port,
44
+ * the Docker-Desktop-safe default. Read from NORSK_TEST_NET. */
45
+ export function netReachMode(env = process.env) {
46
+ return env.NORSK_TEST_NET === "direct" ? "direct" : "publish";
47
+ }
48
+ export const authority = (e) => `${e.host}:${e.port}`;
49
+ /** Compose's container DNS for a template-launched instance: `<id>-<service>-1`.
50
+ * Byte-identical to norsk-ctl's productInstanceHost — the compose project is the
51
+ * instance id and each service runs a single replica. */
52
+ export function productServiceHost(instanceId, service) {
53
+ return `${instanceId}-${service}-1`;
54
+ }
55
+ /** The seam: one function every reach decision goes through. `publish` returns
56
+ * the host-published endpoint unchanged (zero behaviour change); `direct`
57
+ * returns the unique service DNS at the internal port. */
58
+ export function resolveEndpoint(o) {
59
+ const mode = o.mode ?? netReachMode();
60
+ return mode === "direct"
61
+ ? { host: productServiceHost(o.instanceId, o.service), port: o.internalPort }
62
+ : { host: o.publishHost, port: o.publishPort };
63
+ }
64
+ /** Base URL for Studio's `/live/api/*` control surface. Publish mode reaches the
65
+ * host-published studioHostPort (as studio-state.ts does today); direct mode
66
+ * reaches `<id>-studio-1:8000`. Drop-in for the `http://${STUDIO_HOST}:${port}`
67
+ * prefix the studio fetchers build. */
68
+ export function studioApiBase(o) {
69
+ const e = resolveEndpoint({
70
+ instanceId: o.instanceId,
71
+ service: "studio",
72
+ internalPort: STUDIO_INTERNAL_PORT,
73
+ publishHost: o.publishHost,
74
+ publishPort: o.publishPort,
75
+ ...(o.mode !== undefined ? { mode: o.mode } : {}),
76
+ });
77
+ return `http://${authority(e)}`;
78
+ }
79
+ /** Base URL for the Norsk media container's HTTP surface (HLS/CMAF pull —
80
+ * `/ts/*.m3u8`, `/cmaf/*`). Publish mode dials the host-published media port on
81
+ * TEST_HOST (as funke's hls-capture does today); direct mode dials
82
+ * `<id>-media-1:8080`. Mirrors studio-state.mediaDirectUrl, generalised for the
83
+ * direct-pull capture path. */
84
+ export function mediaHttpBase(o) {
85
+ const e = resolveEndpoint({
86
+ instanceId: o.instanceId,
87
+ service: "media",
88
+ internalPort: MEDIA_HTTP_INTERNAL_PORT,
89
+ publishHost: o.publishHost,
90
+ publishPort: o.publishPort,
91
+ ...(o.mode !== undefined ? { mode: o.mode } : {}),
92
+ });
93
+ return `http://${authority(e)}`;
94
+ }
95
+ /** Caller-mode SRT URL for an output.srt egress listener. Publish mode dials the
96
+ * host-published egress port on TEST_HOST (as output-capture.ts does today);
97
+ * direct mode dials `<id>-media-1:<internalPort>`. Because remapConfigPorts
98
+ * currently sets the internal listener port == the published port, `internalPort`
99
+ * and `publishPort` are the same band value today — going direct is what later
100
+ * lets that band be dropped entirely. */
101
+ export function srtEgressUrl(o) {
102
+ const e = resolveEndpoint({
103
+ instanceId: o.instanceId,
104
+ service: "media",
105
+ internalPort: o.internalPort,
106
+ publishHost: o.publishHost,
107
+ publishPort: o.publishPort,
108
+ ...(o.mode !== undefined ? { mode: o.mode } : {}),
109
+ });
110
+ return `srt://${authority(e)}?mode=caller`;
111
+ }
112
+ /** Resolve Studio's base URL from EITHER a bare published port (legacy — dials
113
+ * `publishHost:port`, exactly today's behaviour) OR a StudioTarget (instanceId-
114
+ * aware, so direct mode dials `<id>-studio-1:8000`). The union is the whole
115
+ * migration story: every existing `studioHostPort: number` caller — including
116
+ * other products' suites — keeps working untouched, and a suite opts a call into
117
+ * direct-net simply by passing the target instead of the number. */
118
+ export function studioBaseFrom(studio, publishHost, mode) {
119
+ if (typeof studio === "number")
120
+ return `http://${publishHost}:${studio}`;
121
+ return studioApiBase({
122
+ instanceId: studio.instanceId,
123
+ publishHost,
124
+ publishPort: studio.studioHostPort,
125
+ ...(mode !== undefined ? { mode } : {}),
126
+ });
127
+ }
128
+ // ── Runner network attach ───────────────────────────────────────────────────
129
+ // `direct` DNS only resolves if the test container is on norsk-net. These
130
+ // helpers attach it idempotently; all pure/injectable so the logic is tested
131
+ // without touching docker.
132
+ /** The 64-hex container id from a /proc/self/cgroup dump, or null on a bare host.
133
+ * In a container the id also appears as the hostname, but cgroup is present in
134
+ * both cgroup v1 (`/docker/<id>`) and v2 (`docker-<id>.scope`) layouts. */
135
+ export function parseOwnContainerId(cgroupText) {
136
+ const m = cgroupText.match(/[0-9a-f]{64}/);
137
+ return m ? m[0] : null;
138
+ }
139
+ export function ownContainerId(read = () => readFileSync("/proc/self/cgroup", "utf-8")) {
140
+ try {
141
+ return parseOwnContainerId(read());
142
+ }
143
+ catch {
144
+ return null;
145
+ }
146
+ }
147
+ /** `docker network connect` is not idempotent — a second attach errors. Treat
148
+ * the "already attached" variants as success so setup can call this blindly. */
149
+ export function isAlreadyOnNetworkError(stderr) {
150
+ return /already exists in network|is already connected|endpoint with name .* already exists/i.test(stderr);
151
+ }
152
+ const defaultRunner = (cmd, args) => {
153
+ const r = spawnSync(cmd, args, { encoding: "utf-8" });
154
+ return { status: r.status, stderr: r.stderr ?? "" };
155
+ };
156
+ /** Idempotently join the test container to norsk-net so `direct` service DNS
157
+ * resolves. Returns a verdict rather than throwing: `not-in-container` (nothing
158
+ * to attach — publish mode is the only sane choice), `attached`, `already`, or
159
+ * `failed` (a real error the caller should surface). */
160
+ export function ensureRunnerOnNetwork(opts = {}) {
161
+ const network = opts.network ?? DOCKER_NETWORK_NAME;
162
+ const id = opts.containerId === undefined ? ownContainerId() : opts.containerId;
163
+ if (!id)
164
+ return "not-in-container";
165
+ const run = opts.run ?? defaultRunner;
166
+ const r = run("docker", ["network", "connect", network, id]);
167
+ if (r.status === 0)
168
+ return "attached";
169
+ if (isAlreadyOnNetworkError(r.stderr))
170
+ return "already";
171
+ return "failed";
172
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@norskvideo/ctl-test-harness",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./daemon": {
@@ -26,6 +26,10 @@
26
26
  "./studio-load": {
27
27
  "types": "./studio-load.d.ts",
28
28
  "default": "./studio-load.js"
29
+ },
30
+ "./container-net": {
31
+ "types": "./container-net.d.ts",
32
+ "default": "./container-net.js"
29
33
  }
30
34
  },
31
35
  "dependencies": {
package/studio-state.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { type StudioReach, type StudioTarget } from "./container-net.js";
2
+ export type { StudioReach, StudioTarget };
1
3
  export interface ComponentSummary {
2
4
  componentId: string;
3
5
  componentType: string;
@@ -9,8 +11,8 @@ export interface ComponentsResponse {
9
11
  }
10
12
  export declare function applyTestHost(rawUrl: string): string;
11
13
  export declare function mediaDirectUrl(rawUrl: string): string;
12
- export declare function fetchComponents(studioHostPort: number): Promise<ComponentsResponse | null>;
13
- export declare function fetchComponentState(studioHostPort: number, componentId: string): Promise<unknown | null>;
14
+ export declare function fetchComponents(studio: StudioReach): Promise<ComponentsResponse | null>;
15
+ export declare function fetchComponentState(studio: StudioReach, componentId: string): Promise<unknown | null>;
14
16
  /** SRT-listener state shape — see
15
17
  * ~/src/norsk-studio/workspaces/built-ins/src/input.srt-listener/types.source.yaml:192-235.
16
18
  * Required fields per the YAML schema; harness asserts on the first two. */
@@ -50,12 +52,12 @@ export interface StreamMappingEntry {
50
52
  preview_jpeg?: string;
51
53
  [k: string]: unknown;
52
54
  }
53
- export declare function fetchStreamMappings(studioHostPort: number, componentId: string): Promise<StreamMappingEntry[] | null>;
54
- export declare function fetchStreamSources(studioHostPort: number, componentId: string): Promise<unknown | null>;
55
- export declare function fetchStreamUnmapped(studioHostPort: number, componentId: string): Promise<unknown | null>;
56
- export declare function fetchStreamFill(studioHostPort: number, componentId: string): Promise<unknown | null>;
55
+ export declare function fetchStreamMappings(studioHostPort: StudioReach, componentId: string): Promise<StreamMappingEntry[] | null>;
56
+ export declare function fetchStreamSources(studioHostPort: StudioReach, componentId: string): Promise<unknown | null>;
57
+ export declare function fetchStreamUnmapped(studioHostPort: StudioReach, componentId: string): Promise<unknown | null>;
58
+ export declare function fetchStreamFill(studioHostPort: StudioReach, componentId: string): Promise<unknown | null>;
57
59
  export interface AssertStreamOutputOpts {
58
- studioHostPort: number;
60
+ studioHostPort: StudioReach;
59
61
  componentId: string;
60
62
  media: "video" | "audio" | "playlist";
61
63
  /** Optional — pin the stream's name. For input.srt-listener it is the
@@ -72,7 +74,7 @@ export declare function assertStreamOutput(opts: AssertStreamOutputOpts): Promis
72
74
  /** Polls Studio's per-component state for an SRT-listener until at least one
73
75
  * stream is reported as connected with a non-zero connectedAt timestamp. */
74
76
  export declare function assertSrtConnected(opts: {
75
- studioHostPort: number;
77
+ studioHostPort: StudioReach;
76
78
  componentId: string;
77
79
  timeoutMs?: number;
78
80
  intervalMs?: number;
@@ -89,7 +91,7 @@ export interface CmafMultiVariantState {
89
91
  * `#EXT-X-MEDIA:TYPE=AUDIO ... URI="<rendition>/norsk.m3u8"` line. The caller
90
92
  * passes the set of expected labels; the body must reference every one. */
91
93
  export declare function assertMultivariantHasRenditions(opts: {
92
- studioHostPort: number;
94
+ studioHostPort: StudioReach;
93
95
  componentId: string;
94
96
  expectedRenditionLabels: readonly string[];
95
97
  timeoutMs?: number;
@@ -97,7 +99,7 @@ export declare function assertMultivariantHasRenditions(opts: {
97
99
  resolveFetchUrl?: (url: string) => string;
98
100
  }): Promise<void>;
99
101
  export declare function assertNoUnmapped(opts: {
100
- studioHostPort: number;
102
+ studioHostPort: StudioReach;
101
103
  componentId: string;
102
104
  timeoutMs?: number;
103
105
  intervalMs?: number;
package/studio-state.js CHANGED
@@ -15,6 +15,10 @@
15
15
  // fetchers). Extracted verbatim from the playout copy — the canonical text.
16
16
  // Commentary's WHIP-driver tail (RemoteCommentary*, discoverCommentaryWhipUrls,
17
17
  // assertCommentaryChannelOccupied) stays local to commentary.
18
+ // Studio's /live/api/components returns each entry as `{ componentId, componentType, capabilities }`
19
+ // — NOT `{ id, type }` (which is what the source-yaml schema uses internally).
20
+ // Verified empirically against a live Studio instance.
21
+ import { studioBaseFrom } from "./container-net.js";
18
22
  // The host the studio port is reachable on. Normally the product template
19
23
  // publishes it on the loopback of the machine running the tests, so localhost is
20
24
  // right. When the test process itself runs inside a container that launches the
@@ -74,8 +78,8 @@ export function mediaDirectUrl(rawUrl) {
74
78
  }
75
79
  return applyTestHost(rawUrl);
76
80
  }
77
- export async function fetchComponents(studioHostPort) {
78
- const r = await fetch(`http://${STUDIO_HOST}:${studioHostPort}/live/api/components`, {
81
+ export async function fetchComponents(studio) {
82
+ const r = await fetch(`${studioBaseFrom(studio, STUDIO_HOST)}/live/api/components`, {
79
83
  signal: AbortSignal.timeout(5000),
80
84
  });
81
85
  if (r.status === 503)
@@ -84,8 +88,8 @@ export async function fetchComponents(studioHostPort) {
84
88
  throw new Error(`GET /live/api/components -> ${r.status}: ${await r.text()}`);
85
89
  return (await r.json());
86
90
  }
87
- export async function fetchComponentState(studioHostPort, componentId) {
88
- const r = await fetch(`http://${STUDIO_HOST}:${studioHostPort}/live/api/${encodeURIComponent(componentId)}/state`, {
91
+ export async function fetchComponentState(studio, componentId) {
92
+ const r = await fetch(`${studioBaseFrom(studio, STUDIO_HOST)}/live/api/${encodeURIComponent(componentId)}/state`, {
89
93
  signal: AbortSignal.timeout(5000),
90
94
  });
91
95
  if (r.status === 503 || r.status === 404)
@@ -100,8 +104,8 @@ export function isSrtListenerState(value) {
100
104
  const v = value;
101
105
  return Array.isArray(v.connectedStreams) && typeof v.connectedAt === "object" && v.connectedAt !== null;
102
106
  }
103
- async function fetchComponentStreamRoute(studioHostPort, componentId, route) {
104
- const r = await fetch(`http://${STUDIO_HOST}:${studioHostPort}/live/api/${encodeURIComponent(componentId)}/streams/${route}`, { signal: AbortSignal.timeout(5000) });
107
+ async function fetchComponentStreamRoute(studio, componentId, route) {
108
+ const r = await fetch(`${studioBaseFrom(studio, STUDIO_HOST)}/live/api/${encodeURIComponent(componentId)}/streams/${route}`, { signal: AbortSignal.timeout(5000) });
105
109
  if (r.status === 503 || r.status === 404)
106
110
  return null;
107
111
  if (!r.ok)