@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.
- package/cli-command.d.ts +1 -0
- package/cli-command.js +45 -0
- package/daemon.d.ts +47 -0
- package/daemon.js +168 -0
- package/harness-config.d.ts +18 -0
- package/harness-config.js +28 -0
- package/package.json +26 -0
- package/poll.d.ts +5 -0
- package/poll.js +23 -0
- package/source-pump.d.ts +34 -0
- package/source-pump.js +68 -0
- package/studio-state.d.ts +101 -0
- package/studio-state.js +218 -0
- package/temp-dir.d.ts +6 -0
- package/temp-dir.js +51 -0
package/cli-command.d.ts
ADDED
|
@@ -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
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
|
+
}
|
package/source-pump.d.ts
ADDED
|
@@ -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>;
|
package/studio-state.js
ADDED
|
@@ -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
|
+
}
|