@norskvideo/ctl-test-harness 0.1.17 → 0.1.19

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/daemon.d.ts CHANGED
@@ -6,6 +6,15 @@ export { makeStoreDir, makeTempDir, pollUntil, TEST_TMP_BASE };
6
6
  export declare function requireLicenseFile(opts?: {
7
7
  missing?: "exit" | "throw";
8
8
  }): string;
9
+ /** What a caller sees when the CLI itself cannot be started: in a product
10
+ * repo outside its nix shell nothing resolves (cli-command.ts), and spawn's
11
+ * ENOENT must read as one line, not an uncaught 'error' event. */
12
+ export declare function cliUnavailableMessage(command: readonly string[], cause: string): string;
13
+ export declare function runCliWith(command: readonly string[], storeDir: string, ...args: string[]): Promise<{
14
+ stdout: string;
15
+ stderr: string;
16
+ exitCode: number;
17
+ }>;
9
18
  export declare function runCli(storeDir: string, ...args: string[]): Promise<{
10
19
  stdout: string;
11
20
  stderr: string;
package/daemon.js CHANGED
@@ -50,17 +50,30 @@ export function requireLicenseFile(opts = {}) {
50
50
  }
51
51
  return licenseFile;
52
52
  }
53
- export async function runCli(storeDir, ...args) {
53
+ /** What a caller sees when the CLI itself cannot be started: in a product
54
+ * repo outside its nix shell nothing resolves (cli-command.ts), and spawn's
55
+ * ENOENT must read as one line, not an uncaught 'error' event. */
56
+ export function cliUnavailableMessage(command, cause) {
57
+ return `could not run ${command.join(" ")}: ${cause} — set NORSK_CTL_BINARY to a norsk-ctl binary, or run inside the product's \`nix develop .#dev\` shell, which puts the pinned release on PATH`;
58
+ }
59
+ export async function runCliWith(command, storeDir, ...args) {
54
60
  return new Promise((res) => {
55
- const [cmd, ...cmdArgs] = cliCommand;
61
+ const [cmd, ...cmdArgs] = command;
56
62
  const proc = spawn(cmd, [...cmdArgs, ...args], {
57
63
  env: { ...process.env, NORSK_CTL_STORE_DIR: storeDir },
58
64
  });
59
65
  const stdout = [];
60
66
  const stderr = [];
67
+ let failed = false;
61
68
  proc.stdout.on("data", (d) => stdout.push(d));
62
69
  proc.stderr.on("data", (d) => stderr.push(d));
70
+ proc.on("error", (e) => {
71
+ failed = true;
72
+ res({ stdout: "", stderr: cliUnavailableMessage(command, e.message), exitCode: 127 });
73
+ });
63
74
  proc.on("close", (code) => {
75
+ if (failed)
76
+ return;
64
77
  res({
65
78
  stdout: Buffer.concat(stdout).toString(),
66
79
  stderr: Buffer.concat(stderr).toString(),
@@ -69,6 +82,9 @@ export async function runCli(storeDir, ...args) {
69
82
  });
70
83
  });
71
84
  }
85
+ export async function runCli(storeDir, ...args) {
86
+ return runCliWith(cliCommand, storeDir, ...args);
87
+ }
72
88
  export function isPortFree(port) {
73
89
  return new Promise((resolve) => {
74
90
  const srv = createServer();
@@ -127,14 +143,20 @@ export function startDaemon(storeDir, options) {
127
143
  detached: options?.detached === true,
128
144
  env: { ...process.env, NORSK_CTL_STORE_DIR: storeDir, NORSK_CTL_PORT: String(port), ...options?.env },
129
145
  });
130
- const ready = pollUntil(async () => {
131
- const res = await fetch(`http://localhost:${port}/api/ready`, { signal: AbortSignal.timeout(1000) });
132
- return res.ok;
133
- }, {
134
- timeoutMs: options?.readyTimeoutMs ?? DAEMON_READY_TIMEOUT_MS,
135
- intervalMs: 500,
136
- label: "Daemon did not become ready",
146
+ const spawnFailed = new Promise((_, reject) => {
147
+ proc.once("error", (e) => reject(new Error(cliUnavailableMessage(cliCommand, e.message))));
137
148
  });
149
+ const ready = Promise.race([
150
+ spawnFailed,
151
+ pollUntil(async () => {
152
+ const res = await fetch(`http://localhost:${port}/api/ready`, { signal: AbortSignal.timeout(1000) });
153
+ return res.ok;
154
+ }, {
155
+ timeoutMs: options?.readyTimeoutMs ?? DAEMON_READY_TIMEOUT_MS,
156
+ intervalMs: 500,
157
+ label: "Daemon did not become ready",
158
+ }),
159
+ ]);
138
160
  return { daemon: daemonHandle(proc), ready };
139
161
  }
140
162
  function runningContainers(names) {
package/demo/cli.d.ts ADDED
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env bun
2
+ import { type DemoRunOptions, type DemoRunResult, demoPorts } from "./run.js";
3
+ import type { DemoSpec } from "./spec.js";
4
+ export interface DemoArgs {
5
+ action: "up" | "check" | "down" | "spec";
6
+ mode: "dev" | "standalone";
7
+ exportOnly: boolean;
8
+ json: boolean;
9
+ spec: string;
10
+ }
11
+ export declare function parseDemoArgs(argv: string[]): DemoArgs;
12
+ export interface ResolvedSpecView {
13
+ product: string;
14
+ slug: string;
15
+ instanceId: string;
16
+ mode: string;
17
+ ports: ReturnType<typeof demoPorts>;
18
+ dev: DemoSpec["dev"];
19
+ image?: string;
20
+ template: {
21
+ name: string;
22
+ } | {
23
+ build: {
24
+ name: string;
25
+ input: string | Record<string, unknown>;
26
+ };
27
+ };
28
+ launch?: DemoSpec["launch"];
29
+ prerequisites?: DemoSpec["prerequisites"];
30
+ sources: Array<{
31
+ name: string;
32
+ asset: NonNullable<DemoSpec["sources"]>[number]["asset"];
33
+ ingest: unknown;
34
+ streamId?: string;
35
+ }>;
36
+ ready: Array<{
37
+ http: string;
38
+ status: number;
39
+ bodyIncludes?: string;
40
+ } | {
41
+ custom: string;
42
+ }>;
43
+ after: boolean;
44
+ open: Array<{
45
+ name: string;
46
+ url: string;
47
+ pick?: string;
48
+ }>;
49
+ standalone?: DemoSpec["standalone"];
50
+ }
51
+ /** `demo spec --json`: what will run, resolved as far as it can be without a
52
+ * daemon. Ingest ports are shown by their reference — they are the instance's
53
+ * to give, at launch. JSON-safe (hooks become their labels). */
54
+ export declare function resolvedSpecView(spec: DemoSpec, opts: {
55
+ mode: string;
56
+ slug?: string;
57
+ }): ResolvedSpecView;
58
+ /** Everything `demoMain` touches, injectable so the dispatch is unit-tested. */
59
+ export interface DemoCliIo {
60
+ cwd: string;
61
+ loadSpec(path: string): Promise<DemoSpec>;
62
+ runDemo(spec: DemoSpec, opts: DemoRunOptions): Promise<DemoRunResult>;
63
+ runExportCheck(spec: DemoSpec, opts: {
64
+ cwd: string;
65
+ }): Promise<{
66
+ exportDir: string;
67
+ }>;
68
+ demoDown(product: string): Promise<void>;
69
+ stdout(line: string): void;
70
+ stderr(line: string): void;
71
+ abort?: AbortSignal;
72
+ }
73
+ export declare function demoMain(argv: string[], io: DemoCliIo): Promise<number>;
74
+ export declare function defaultDemoCliIo(): DemoCliIo;
package/demo/cli.js ADDED
@@ -0,0 +1,232 @@
1
+ #!/usr/bin/env bun
2
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
3
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
4
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
5
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
6
+ });
7
+ }
8
+ return path;
9
+ };
10
+ // `ctl-demo`, the bin behind every product's `bun run demo` (05-demo s4):
11
+ //
12
+ // demo up [--mode dev] launch, print the URLs, hold; Ctrl-C tears down
13
+ // demo check [--mode dev] the same, then tear down and exit 0/1 (what CI runs)
14
+ // demo check --mode standalone --export-only build + export-workdir + every symlink resolves
15
+ // demo down tear down what `up` recorded, from any shell
16
+ // demo spec [--json] the resolved spec: ports, template, sources, URLs
17
+ //
18
+ // The spec is `tests/demo.spec.ts` in the cwd (`--spec <path>` otherwise), a
19
+ // TypeScript module whose default export is `defineDemo({...})`. This runs
20
+ // under bun only: the spec is TypeScript and so is every product's dev shell.
21
+ import { resolve } from "node:path";
22
+ import { demoDown, demoPorts, demoSlug, runDemo, runExportCheck, } from "./run.js";
23
+ const USAGE = `usage: demo <up|check|down|spec> [--mode dev|standalone] [--export-only] [--json] [--spec <path>]
24
+
25
+ up launch on a private daemon, print the URLs, hold (Ctrl-C tears down)
26
+ check up, then tear down and exit 0/1 — what CI runs
27
+ check --mode standalone --export-only
28
+ build the template, export the standalone workdir, assert every symlink resolves
29
+ down tear down what \`up\` recorded
30
+ spec [--json] print the resolved spec without launching anything
31
+
32
+ --spec <path> the demo spec (default tests/demo.spec.ts)`;
33
+ const DEFAULT_SPEC = "tests/demo.spec.ts";
34
+ export function parseDemoArgs(argv) {
35
+ const [action, ...rest] = argv;
36
+ if (action !== "up" && action !== "check" && action !== "down" && action !== "spec") {
37
+ throw new Error(action ? `unknown action '${action}'\n${USAGE}` : USAGE);
38
+ }
39
+ const args = { action, mode: "dev", exportOnly: false, json: false, spec: DEFAULT_SPEC };
40
+ for (let i = 0; i < rest.length; i++) {
41
+ const flag = rest[i];
42
+ const value = () => {
43
+ const v = rest[++i];
44
+ if (v === undefined)
45
+ throw new Error(`${flag} needs a value\n${USAGE}`);
46
+ return v;
47
+ };
48
+ switch (flag) {
49
+ case "--mode": {
50
+ const m = value();
51
+ if (m === "image")
52
+ throw new Error("--mode image is not yet implemented (05-demo s7 step 2); use --mode dev");
53
+ if (m !== "dev" && m !== "standalone")
54
+ throw new Error(`unknown mode '${m}'\n${USAGE}`);
55
+ args.mode = m;
56
+ break;
57
+ }
58
+ case "--export-only":
59
+ args.exportOnly = true;
60
+ break;
61
+ case "--json":
62
+ args.json = true;
63
+ break;
64
+ case "--spec":
65
+ args.spec = value();
66
+ break;
67
+ default:
68
+ throw new Error(`unknown flag '${flag}'\n${USAGE}`);
69
+ }
70
+ }
71
+ if (args.mode === "standalone" && !args.exportOnly && (args.action === "up" || args.action === "check")) {
72
+ throw new Error("the full standalone tier (engine + Studio panes) is not yet implemented (05-demo s7 step 3); this slice has `check --mode standalone --export-only`");
73
+ }
74
+ if (args.exportOnly && args.mode !== "standalone") {
75
+ throw new Error("--export-only belongs to --mode standalone");
76
+ }
77
+ return args;
78
+ }
79
+ /** `demo spec --json`: what will run, resolved as far as it can be without a
80
+ * daemon. Ingest ports are shown by their reference — they are the instance's
81
+ * to give, at launch. JSON-safe (hooks become their labels). */
82
+ export function resolvedSpecView(spec, opts) {
83
+ const slug = opts.slug ?? demoSlug(spec.product);
84
+ const instanceId = `demo-${slug}`;
85
+ const ports = demoPorts(slug);
86
+ const pinned = spec.launch?.params?.STUDIO_HOST_PORT;
87
+ const studioHostPort = pinned !== undefined ? Number(pinned) : ports.studioHostPort;
88
+ const url = (ref) => {
89
+ if ("url" in ref)
90
+ return ref.url;
91
+ if ("control" in ref)
92
+ return `http://localhost:${ports.backendPort}${ref.control}`;
93
+ if ("proxy" in ref)
94
+ return `http://localhost:${ports.proxyPort}${ref.proxy.replaceAll("{id}", instanceId)}`;
95
+ return `http://localhost:${studioHostPort}${ref.studio}`;
96
+ };
97
+ const t = spec.template;
98
+ const template = t && "build" in t
99
+ ? { build: { name: t.build.name ?? `${spec.product}-demo`, input: t.build.input } }
100
+ : { name: t?.name ?? "<the product's first default template>" };
101
+ return {
102
+ product: spec.product,
103
+ slug,
104
+ instanceId,
105
+ mode: opts.mode,
106
+ ports,
107
+ dev: spec.dev,
108
+ ...(spec.image !== undefined ? { image: spec.image } : {}),
109
+ template,
110
+ ...(spec.launch !== undefined ? { launch: spec.launch } : {}),
111
+ ...(spec.prerequisites !== undefined ? { prerequisites: spec.prerequisites } : {}),
112
+ sources: (spec.sources ?? []).map((s) => ({
113
+ name: s.name,
114
+ asset: s.asset ?? { preset: "camera1" },
115
+ ingest: s.ingest,
116
+ ...(s.streamId !== undefined ? { streamId: s.streamId } : {}),
117
+ })),
118
+ ready: (spec.ready ?? []).map((g) => "custom" in g
119
+ ? { custom: g.label ?? "custom gate" }
120
+ : {
121
+ http: url(g.http),
122
+ status: g.status ?? 200,
123
+ ...(g.bodyIncludes !== undefined ? { bodyIncludes: g.bodyIncludes } : {}),
124
+ }),
125
+ after: spec.after !== undefined,
126
+ open: (spec.open ?? []).map((o) => ({
127
+ name: o.name,
128
+ url: url(o.url),
129
+ ...(o.pick !== undefined ? { pick: o.pick } : {}),
130
+ })),
131
+ ...(spec.standalone !== undefined ? { standalone: spec.standalone } : {}),
132
+ };
133
+ }
134
+ function isModuleNotFound(e) {
135
+ const code = e?.code;
136
+ return code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND" || /cannot find module/i.test(String(e));
137
+ }
138
+ export async function demoMain(argv, io) {
139
+ let args;
140
+ try {
141
+ args = parseDemoArgs(argv);
142
+ }
143
+ catch (e) {
144
+ io.stderr(e instanceof Error ? e.message : String(e));
145
+ return 2;
146
+ }
147
+ const specPath = resolve(io.cwd, args.spec);
148
+ let spec;
149
+ try {
150
+ spec = await io.loadSpec(specPath);
151
+ }
152
+ catch (e) {
153
+ if (isModuleNotFound(e)) {
154
+ io.stderr(`no demo spec at ${specPath}\n write ${args.spec}: \`export default defineDemo({ product, dev, ... })\` from @norskvideo/ctl-test-harness/demo`);
155
+ }
156
+ else {
157
+ io.stderr(`could not load ${specPath}: ${e instanceof Error ? e.message : String(e)}`);
158
+ }
159
+ return 1;
160
+ }
161
+ try {
162
+ switch (args.action) {
163
+ case "spec": {
164
+ const view = resolvedSpecView(spec, { mode: args.mode });
165
+ io.stdout(args.json ? JSON.stringify(view, null, 2) : renderView(view));
166
+ return 0;
167
+ }
168
+ case "down":
169
+ await io.demoDown(spec.product);
170
+ return 0;
171
+ case "up":
172
+ case "check": {
173
+ if (args.exportOnly) {
174
+ await io.runExportCheck(spec, { cwd: io.cwd });
175
+ return 0;
176
+ }
177
+ await io.runDemo(spec, {
178
+ action: args.action,
179
+ mode: "dev",
180
+ cwd: io.cwd,
181
+ ...(io.abort ? { abort: io.abort } : {}),
182
+ });
183
+ return 0;
184
+ }
185
+ }
186
+ }
187
+ catch (e) {
188
+ io.stderr(`demo ${args.action} failed: ${e instanceof Error ? e.message : String(e)}`);
189
+ return 1;
190
+ }
191
+ }
192
+ function renderView(v) {
193
+ const lines = [
194
+ `${v.product} (${v.mode}) -> instance ${v.instanceId}`,
195
+ ` daemon :${v.ports.daemonPort} dev backend :${v.ports.backendPort} proxy :${v.ports.proxyPort} studio :${v.ports.studioHostPort}`,
196
+ ` template ${"name" in v.template ? v.template.name : `build ${v.template.build.name}`}`,
197
+ ];
198
+ for (const s of v.sources)
199
+ lines.push(` source ${s.name}: ${JSON.stringify(s.asset)} -> ingest ${JSON.stringify(s.ingest)}`);
200
+ for (const g of v.ready)
201
+ lines.push(` ready ${"custom" in g ? g.custom : `${g.http} -> ${g.status}`}`);
202
+ for (const o of v.open)
203
+ lines.push(` open ${o.name}: ${o.url}${o.pick ? ` (pick /${o.pick}/)` : ""}`);
204
+ return lines.join("\n");
205
+ }
206
+ async function loadSpecModule(path) {
207
+ const mod = (await import(__rewriteRelativeImportExtension(path)));
208
+ const spec = mod.default;
209
+ if (!spec || typeof spec !== "object" || typeof spec.product !== "string") {
210
+ throw new Error(`${path} must default-export defineDemo({...})`);
211
+ }
212
+ return spec;
213
+ }
214
+ export function defaultDemoCliIo() {
215
+ const controller = new AbortController();
216
+ const release = () => controller.abort();
217
+ process.once("SIGINT", release);
218
+ process.once("SIGTERM", release);
219
+ return {
220
+ cwd: process.cwd(),
221
+ loadSpec: loadSpecModule,
222
+ runDemo: (spec, opts) => runDemo(spec, opts),
223
+ runExportCheck: (spec, opts) => runExportCheck(spec, opts),
224
+ demoDown: (product) => demoDown(product),
225
+ stdout: (line) => console.log(line),
226
+ stderr: (line) => console.error(line),
227
+ abort: controller.signal,
228
+ };
229
+ }
230
+ if (import.meta.main) {
231
+ process.exit(await demoMain(process.argv.slice(2), defaultDemoCliIo()));
232
+ }
@@ -0,0 +1,6 @@
1
+ export type { DemoArgs, DemoCliIo, ResolvedSpecView } from "./cli.js";
2
+ export { defaultDemoCliIo, demoMain, parseDemoArgs, resolvedSpecView } from "./cli.js";
3
+ export type { CliResult, DemoDeps, DemoPorts, DemoRunOptions, DemoRunResult, DemoState, DemoStateStore, DemoTimeouts, ExportCheckOptions, IngestPortRow, ProcessHandle, TemplateParams, } from "./run.js";
4
+ export { defaultDemoDeps, demoDown, demoPorts, demoSlug, expandHome, fileStateStore, findBrokenSymlinks, killGroup, resolveIngestPort, runDemo, runExportCheck, } from "./run.js";
5
+ export type { DemoContext, DemoIngest, DemoOpen, DemoReady, DemoSource, DemoSpec, DemoTemplate, DemoUrl, } from "./spec.js";
6
+ export { DemoSpecSchema, defineDemo, formatSpecIssues } from "./spec.js";
package/demo/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { defaultDemoCliIo, demoMain, parseDemoArgs, resolvedSpecView } from "./cli.js";
2
+ export { defaultDemoDeps, demoDown, demoPorts, demoSlug, expandHome, fileStateStore, findBrokenSymlinks, killGroup, resolveIngestPort, runDemo, runExportCheck, } from "./run.js";
3
+ export { DemoSpecSchema, defineDemo, formatSpecIssues } from "./spec.js";
package/demo/run.d.ts ADDED
@@ -0,0 +1,160 @@
1
+ import { ensureRunnerOnNetwork } from "../container-net.js";
2
+ import { type DaemonProcess, type StartDaemonOptions } from "../daemon.js";
3
+ import { type BaseHarnessPorts } from "../harness-config.js";
4
+ import { type SourceHandle, type SrtPumpTarget } from "../source-pump.js";
5
+ import type { DemoIngest, DemoSpec } from "./spec.js";
6
+ export interface CliResult {
7
+ stdout: string;
8
+ stderr: string;
9
+ exitCode: number;
10
+ }
11
+ export interface ProcessHandle {
12
+ pid: number | undefined;
13
+ kill(): void;
14
+ exited: Promise<number | null>;
15
+ }
16
+ /** What `up` records so `down` (or a refused second `up`) can find the run. */
17
+ export interface DemoState {
18
+ product: string;
19
+ storeDir: string;
20
+ daemonPort: number;
21
+ instanceId?: string;
22
+ devPid?: number;
23
+ }
24
+ export interface DemoStateStore {
25
+ read(product: string): DemoState | null;
26
+ write(state: DemoState): void;
27
+ remove(product: string): void;
28
+ }
29
+ export interface DemoDeps {
30
+ licenseFile(): string;
31
+ storeDir(slug: string): string;
32
+ writeFile(path: string, contents: string): void;
33
+ fileExists(path: string): boolean;
34
+ startDaemon(storeDir: string, options: StartDaemonOptions): {
35
+ daemon: DaemonProcess;
36
+ ready: Promise<void>;
37
+ };
38
+ /** Does a daemon answer /api/ready on this port? (A stale state file otherwise.) */
39
+ daemonAnswers(port: number): Promise<boolean>;
40
+ cli(storeDir: string, argv: string[]): Promise<CliResult>;
41
+ startProcess(command: string[], opts: {
42
+ cwd: string;
43
+ env: Record<string, string>;
44
+ }): ProcessHandle;
45
+ fetch(url: string, init?: RequestInit): Promise<Response>;
46
+ startSources(opts: {
47
+ daemonPort: number;
48
+ instanceId: string;
49
+ targets: readonly SrtPumpTarget[];
50
+ timeoutMs?: number;
51
+ }): Promise<SourceHandle[]>;
52
+ stopSources(handles: SourceHandle[]): Promise<void>;
53
+ cleanup(opts: {
54
+ deleteInstance: (id: string) => Promise<unknown>;
55
+ stopDaemon: () => Promise<unknown>;
56
+ instances: string[];
57
+ daemon: DaemonProcess | null;
58
+ storeDir: string;
59
+ containers: string[];
60
+ }): Promise<void>;
61
+ /** Docker leaves root-owned bind-mount targets under the store (smoke.ts). */
62
+ nukeStoreAsRoot(storeDir: string): void;
63
+ storeExists(storeDir: string): boolean;
64
+ ensureNetwork(): ReturnType<typeof ensureRunnerOnNetwork>;
65
+ supportsNoPublish(): Promise<boolean>;
66
+ containerUser(): string | undefined;
67
+ /** Symlinks under an exported workdir whose target does not exist, as
68
+ * `<link> -> <target>` lines. */
69
+ brokenSymlinks(dir: string): string[];
70
+ state: DemoStateStore;
71
+ /** `up`: block until released (Ctrl-C, or the abort signal). */
72
+ hold(abort?: AbortSignal): Promise<void>;
73
+ killPid?(pid: number): void;
74
+ log(line: string): void;
75
+ }
76
+ export interface DemoTimeouts {
77
+ /** The dev backend answering its readyPath. */
78
+ devReadyMs?: number;
79
+ /** The instance reporting running. */
80
+ healthyMs?: number;
81
+ /** Each `ready` gate, unless the gate says otherwise. */
82
+ readyMs?: number;
83
+ }
84
+ export interface DemoRunOptions {
85
+ action: "up" | "check";
86
+ mode: "dev";
87
+ /** The product repo root: dev command cwd, relative inputs and links. */
88
+ cwd: string;
89
+ /** Default: the product name without its `norsk-` prefix. */
90
+ slug?: string;
91
+ /** Releases an `up` hold. */
92
+ abort?: AbortSignal;
93
+ timeouts?: DemoTimeouts;
94
+ }
95
+ export interface DemoRunResult {
96
+ instanceId: string;
97
+ daemonPort: number;
98
+ storeDir: string;
99
+ /** The `open` block as printed: name and resolved value. */
100
+ open: Array<{
101
+ name: string;
102
+ value: string;
103
+ }>;
104
+ }
105
+ export interface DemoPorts extends BaseHarnessPorts {
106
+ proxyPort: number;
107
+ }
108
+ /** Per-slug port band above the smoke tier's (33000-34000), so a demo and a
109
+ * smoke run of the same product never meet. */
110
+ export declare function demoPorts(slug: string, overrides?: Partial<DemoPorts>): DemoPorts;
111
+ export declare function demoSlug(product: string): string;
112
+ export declare function expandHome(path: string): string;
113
+ /** The daemon's `ingestPorts` row (ProductInstance.ingestPorts in the contract). */
114
+ export interface IngestPortRow {
115
+ port: number;
116
+ proto: "tcp" | "udp";
117
+ service: string;
118
+ hostPort?: number;
119
+ param?: string;
120
+ label?: string;
121
+ origin: "allocated" | "operator" | "default";
122
+ }
123
+ /** The template's declared parameters (name -> stringified default) and the
124
+ * spec's overrides: together, a parameter's effective value at launch. */
125
+ export interface TemplateParams {
126
+ declared: Map<string, string | undefined>;
127
+ overrides: Record<string, string | number>;
128
+ }
129
+ /** Ports come from the instance and its template, not the spec (05-demo s4).
130
+ * A `param` is first an allocated port the instance reports, else an
131
+ * ordinary template parameter whose effective value (the spec's override,
132
+ * else the template default) is the container port — the `${P}:${P}/udp`
133
+ * idiom every product's SRT ingest uses. A bare number is accepted only when
134
+ * no parameter carries it and the instance offers it as the conventional
135
+ * default, so the demo follows the template when the template moves. */
136
+ export declare function resolveIngestPort(ingest: DemoIngest, rows: IngestPortRow[], t?: TemplateParams): number;
137
+ export declare function findBrokenSymlinks(dir: string): string[];
138
+ /** `<cwd>/test-temp/demo/<product>.json` — beside the store dirs, inside the
139
+ * consumer's repo, where `down` from another shell can find it. */
140
+ export declare function fileStateStore(cwd: string): DemoStateStore;
141
+ /** SIGTERM a process group (a pid startProcess spawned detached leads its
142
+ * own), falling back to the pid alone if it is not a group leader. */
143
+ export declare function killGroup(pid: number): void;
144
+ export declare function defaultDemoDeps(cwd: string): DemoDeps;
145
+ export declare function runDemo(spec: DemoSpec, opts: DemoRunOptions, deps?: DemoDeps): Promise<DemoRunResult>;
146
+ export interface ExportCheckOptions {
147
+ cwd: string;
148
+ slug?: string;
149
+ timeouts?: DemoTimeouts;
150
+ }
151
+ /** `demo check --mode standalone --export-only` (05-demo s4): build the
152
+ * template, export the standalone workdir with the spec's live-source links,
153
+ * assert every symlink resolves. No instance, no licence — the daemon's own
154
+ * preflight still wants a Docker socket, but nothing is launched. This is the
155
+ * path that rotted (05-demo s1) and it is cheap enough to gate every push. */
156
+ export declare function runExportCheck(spec: DemoSpec, opts: ExportCheckOptions, deps?: DemoDeps): Promise<{
157
+ exportDir: string;
158
+ }>;
159
+ /** `demo down`: tear down the run `up` recorded, from any shell. */
160
+ export declare function demoDown(product: string, deps?: DemoDeps): Promise<void>;