@norskvideo/ctl-test-harness 0.1.24 → 0.1.25

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/demo/cli.js CHANGED
@@ -25,8 +25,9 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
25
25
  // under bun only: the spec is TypeScript and so is every product's dev shell.
26
26
  import { resolve } from "node:path";
27
27
  import { devLoopDown, runDevLoop } from "./dev-loop.js";
28
+ import { runDemo } from "./harness.js";
28
29
  import { demoPaneList, devLoopPaneList, launchLayout, renderLayout, standaloneDirs, } from "./panes.js";
29
- import { demoDown, demoPorts, demoSlug, runDemo, runExportCheck, } from "./run.js";
30
+ import { demoDown, demoPorts, demoSlug, runExportCheck } from "./run.js";
30
31
  const USAGE = `usage: demo <up|check|down|spec|ui> [--mode dev|image|standalone] [--daemon private|reuse] [--export-only] [--json]
31
32
  [--ui zellij|tmux|none] [--public-host <host>] [--spec <path>]
32
33
  demo dev-loop <up|refresh|down|ui> [--ui zellij|tmux|none] [--public-host <host>] [--spec <path>]
@@ -0,0 +1,108 @@
1
+ import { type StudioTarget } from "../container-net.js";
2
+ import type { SourceHandle } from "../source-pump.js";
3
+ import { type CliStep, type DemoDeps, type DemoPorts, type DemoRunOptions, type DemoRunResult, DemoSession, type DemoTimeouts, type IngestPortRow } from "./run.js";
4
+ import type { DemoContext, DemoDaemonPolicy, DemoIngest, DemoMode, DemoReady, DemoSpec, DemoTemplate } from "./spec.js";
5
+ export interface ProductHarnessOptions {
6
+ /** The product repo root: dev command cwd, relative inputs, manifest.seed.json. */
7
+ cwd: string;
8
+ /** Default: the product name without its `norsk-` prefix. */
9
+ slug?: string;
10
+ /** Default: the spec's `mode`, else `dev`. */
11
+ mode?: DemoMode;
12
+ /** Default private. */
13
+ daemon?: DemoDaemonPolicy;
14
+ daemonPort?: number;
15
+ timeouts?: DemoTimeouts;
16
+ publicHost?: string;
17
+ deps?: DemoDeps;
18
+ /** What the spec's hooks see as `ctx.action`: `check` (the default — a
19
+ * fixture, CI) or `up` (the held demo, where `launch.workingDirectory` applies). */
20
+ action?: "up" | "check";
21
+ /** Private daemon: init the proxy with a basic-auth user (a browser signs
22
+ * in) instead of `--proxy-auth none`. */
23
+ proxy?: {
24
+ user: string;
25
+ password: string;
26
+ };
27
+ /** Private daemon: extra environment for the daemon process (a doc guide
28
+ * names its own proxy container so a run cannot adopt the developer's). */
29
+ daemonEnv?: Record<string, string>;
30
+ /** Containers teardown must also wait out — an overridden proxy's pair. */
31
+ extraContainers?: string[];
32
+ }
33
+ export interface LaunchOverrides {
34
+ /** Default `demo-<slug>`. */
35
+ instanceId?: string;
36
+ /** Default: the spec's template (the product's first default when it names none). */
37
+ template?: DemoTemplate;
38
+ /** Merged over the spec's `launch.params`. */
39
+ params?: Record<string, string | number>;
40
+ hardware?: "nvidia" | "none";
41
+ workingDirectory?: string;
42
+ }
43
+ /** An instance `launch()` brought to running: its resolved ports and URLs,
44
+ * and the spec's sources and gates, on demand. */
45
+ export interface LaunchedInstance {
46
+ readonly instanceId: string;
47
+ readonly templateName: string;
48
+ readonly studioHostPort: number;
49
+ readonly ctx: DemoContext;
50
+ /** The daemon's `ingestPorts` rows for this instance (`instance describe`). */
51
+ readonly ingestPorts: IngestPortRow[];
52
+ /** A port the way the spec names one: resolved from the instance, never restated. */
53
+ ingestPort(ingest: DemoIngest): number;
54
+ /** The reach studio-state's fetchers take. */
55
+ studioTarget(): StudioTarget;
56
+ /** Pump the spec's sources (all, or the named subset) at this instance.
57
+ * The handles are stopped at teardown; stop one early via its `stop()`. */
58
+ startSources(names?: readonly string[]): Promise<SourceHandle[]>;
59
+ /** Poll the spec's `ready` gates (or the given ones) in order. */
60
+ awaitReady(gates?: readonly DemoReady[]): Promise<void>;
61
+ }
62
+ export declare class ProductHarness {
63
+ readonly spec: DemoSpec;
64
+ readonly slug: string;
65
+ readonly session: DemoSession;
66
+ readonly action: "up" | "check";
67
+ /** Every instance `launch()` brought to running, in order. */
68
+ readonly instances: LaunchedInstance[];
69
+ /** Every instance id a launch named, running or not: what teardown deletes. */
70
+ private readonly launchedIds;
71
+ private handles;
72
+ private readonly deps;
73
+ constructor(spec: DemoSpec, opts: ProductHarnessOptions);
74
+ /** Every CLI step this fixture ran, as a reader would type it, with its output. */
75
+ get transcript(): readonly CliStep[];
76
+ get ports(): DemoPorts;
77
+ get storeDir(): string;
78
+ get mode(): DemoMode;
79
+ get policy(): DemoDaemonPolicy;
80
+ /** The demo's instance id: what `launch()` uses when not told otherwise. */
81
+ get defaultInstanceId(): string;
82
+ cli: (argv: string[], opts?: {
83
+ output?: "json" | "yaml";
84
+ }) => Promise<import("./run.js").CliResult>;
85
+ cliOk: (argv: string[], opts?: {
86
+ output?: "json" | "yaml";
87
+ }) => Promise<import("./run.js").CliResult>;
88
+ /** The daemon (private on its band, or the developer's), the dev backend
89
+ * on the driver's port, and the registration. Nothing is launched. */
90
+ setup(): Promise<void>;
91
+ /** Store a template now — built from an input, or checked against what the
92
+ * product publishes — for a launch by `{ name }` later. What a product
93
+ * harness's buildAndImport does before its launchScenario. */
94
+ storeTemplate(template: DemoTemplate): Promise<string>;
95
+ /** Store and launch a template, wait until the instance reports running,
96
+ * and resolve what a test needs from it. Extras start and `beforeLaunch`
97
+ * runs before each launch, exactly as the demo does for its one. */
98
+ launch(overrides?: LaunchOverrides): Promise<LaunchedInstance>;
99
+ /** The demo journey on the fixture: launch, pump, gate, `after`, `open`. */
100
+ run(): Promise<DemoRunResult>;
101
+ /** Sources, every instance a launch named, then — private only — the
102
+ * daemon, the dev backend and the store. Throws if the store outlives the
103
+ * root-container nuke: a leak a runner would otherwise accumulate. */
104
+ teardown(): Promise<void>;
105
+ private nextStudioHostPort;
106
+ }
107
+ /** The demo: a ProductHarness set up, run, held (`up`) or not (`check`), torn down. */
108
+ export declare function runDemo(spec: DemoSpec, opts: DemoRunOptions, deps?: DemoDeps): Promise<DemoRunResult>;
@@ -0,0 +1,386 @@
1
+ // ProductHarness (05-demo s6 last paragraph, s7 step 6): the integration
2
+ // fixture built from the demo spec. A product's `harness.ts` used to hand-roll
3
+ // the same skeleton the demo driver runs — store, daemon, dev backend,
4
+ // registration, template, launch, wait-for-running, teardown — five times
5
+ // across the fleet, each copy drifting (container naming, root-owned-store
6
+ // cleanup, ports). Here the skeleton is one class the spec constructs:
7
+ // `setup()` brings the daemon and the registration up, `launch()` puts an
8
+ // instance on it (the spec's template, or an override — a matrix test builds
9
+ // its own), `run()` is the demo journey on top of that, and `runDemo` is
10
+ // `setup → run → hold → teardown` — so the demo and the test fixture cannot
11
+ // rot apart: they are the same code path.
12
+ //
13
+ // What stays in a product's harness.ts: its probes (a probe-api status, a
14
+ // program SRT listener, an HLS ladder), its config-schema port remap for a
15
+ // built template, and whatever sidecar its output points at.
16
+ import { netReachMode, STUDIO_INTERNAL_PORT, studioBaseFrom } from "../container-net.js";
17
+ import { pollUntil } from "../poll.js";
18
+ import { DemoGateFailure } from "./gates.js";
19
+ import { DemoSession, defaultDemoDeps, demoSlug, expandHome, resolveIngestPort, } from "./run.js";
20
+ const STUDIO_HOST_PORT_PARAM = "STUDIO_HOST_PORT";
21
+ /** Launches after the first take studio host ports above the proxy's, inside
22
+ * the band and below `instancePortBase`: six of them. */
23
+ const EXTRA_STUDIO_PORTS = 6;
24
+ export class ProductHarness {
25
+ spec;
26
+ slug;
27
+ session;
28
+ action;
29
+ /** Every instance `launch()` brought to running, in order. */
30
+ instances = [];
31
+ /** Every instance id a launch named, running or not: what teardown deletes. */
32
+ launchedIds = [];
33
+ handles = [];
34
+ deps;
35
+ constructor(spec, opts) {
36
+ const mode = opts.mode ?? spec.mode ?? "dev";
37
+ if (mode === "image" && !spec.image) {
38
+ throw new Error(`--mode image needs the spec's \`image\` (the built product image) — ${spec.product} declares none`);
39
+ }
40
+ this.spec = spec;
41
+ this.slug = opts.slug ?? demoSlug(spec.product);
42
+ this.action = opts.action ?? "check";
43
+ this.deps = opts.deps ?? defaultDemoDeps(opts.cwd);
44
+ this.session = new DemoSession(spec, this.slug, opts.cwd, this.deps, {
45
+ mode,
46
+ daemon: opts.daemon ?? "private",
47
+ ...(opts.daemonPort !== undefined ? { daemonPort: opts.daemonPort } : {}),
48
+ ...(opts.timeouts !== undefined ? { timeouts: opts.timeouts } : {}),
49
+ ...(opts.publicHost !== undefined ? { publicHost: opts.publicHost } : {}),
50
+ ...(opts.proxy !== undefined ? { proxy: opts.proxy } : {}),
51
+ ...(opts.daemonEnv !== undefined ? { daemonEnv: opts.daemonEnv } : {}),
52
+ ...(opts.extraContainers !== undefined ? { extraContainers: opts.extraContainers } : {}),
53
+ });
54
+ }
55
+ /** Every CLI step this fixture ran, as a reader would type it, with its output. */
56
+ get transcript() {
57
+ return this.session.transcript;
58
+ }
59
+ get ports() {
60
+ return this.session.ports;
61
+ }
62
+ get storeDir() {
63
+ return this.session.storeDir;
64
+ }
65
+ get mode() {
66
+ return this.session.mode;
67
+ }
68
+ get policy() {
69
+ return this.session.policy;
70
+ }
71
+ /** The demo's instance id: what `launch()` uses when not told otherwise. */
72
+ get defaultInstanceId() {
73
+ return `demo-${this.slug}`;
74
+ }
75
+ cli = (argv, opts) => this.session.cli(argv, opts);
76
+ cliOk = (argv, opts) => this.session.cliOk(argv, opts);
77
+ /** The daemon (private on its band, or the developer's), the dev backend
78
+ * on the driver's port, and the registration. Nothing is launched. */
79
+ async setup() {
80
+ const s = this.session;
81
+ await s.attachDaemon();
82
+ if (s.mode === "dev")
83
+ await s.startDev();
84
+ if (s.policy === "reuse")
85
+ await s.resetRegistration(this.defaultInstanceId);
86
+ await s.register({ licence: true });
87
+ }
88
+ /** Store a template now — built from an input, or checked against what the
89
+ * product publishes — for a launch by `{ name }` later. What a product
90
+ * harness's buildAndImport does before its launchScenario. */
91
+ async storeTemplate(template) {
92
+ return this.session.resolveTemplate(template);
93
+ }
94
+ /** Store and launch a template, wait until the instance reports running,
95
+ * and resolve what a test needs from it. Extras start and `beforeLaunch`
96
+ * runs before each launch, exactly as the demo does for its one. */
97
+ async launch(overrides = {}) {
98
+ const { spec, session: s, deps } = this;
99
+ const instanceId = overrides.instanceId ?? this.defaultInstanceId;
100
+ const templateName = await s.resolveTemplate(overrides.template ?? spec.template);
101
+ await s.pinGuard(templateName);
102
+ const launchCtx = {
103
+ action: this.action,
104
+ mode: s.mode,
105
+ daemon: s.policy,
106
+ product: spec.product,
107
+ instanceId,
108
+ daemonPort: s.ports.daemonPort,
109
+ storeDir: s.storeDir,
110
+ templateName,
111
+ templateDir: s.templateDir(templateName),
112
+ cli: s.cli,
113
+ log: deps.log,
114
+ };
115
+ s.startExtras(instanceId);
116
+ if (spec.beforeLaunch)
117
+ await spec.beforeLaunch(launchCtx);
118
+ const declared = await s.declaredParams(templateName);
119
+ const specParams = { ...(spec.launch?.params ?? {}), ...(overrides.params ?? {}) };
120
+ const studioHostPort = specParams[STUDIO_HOST_PORT_PARAM] !== undefined
121
+ ? Number(specParams[STUDIO_HOST_PORT_PARAM])
122
+ : this.nextStudioHostPort();
123
+ const params = [`INSTANCE_NAME=${instanceId}`, ...Object.entries(specParams).map(([k, v]) => `${k}=${v}`)];
124
+ const launchArgv = ["instance", "launch-template", instanceId, "--template", templateName];
125
+ const internalOnly = netReachMode() === "direct" && (await deps.supportsNoPublish());
126
+ if (declared.has(STUDIO_HOST_PORT_PARAM)) {
127
+ if (specParams[STUDIO_HOST_PORT_PARAM] === undefined)
128
+ params.push(`${STUDIO_HOST_PORT_PARAM}=${studioHostPort}`);
129
+ }
130
+ else if (!internalOnly) {
131
+ launchArgv.push("--host-ports", `${studioHostPort}:${STUDIO_INTERNAL_PORT}@studio`);
132
+ }
133
+ for (const p of params)
134
+ launchArgv.push("--param", p);
135
+ const hardware = overrides.hardware ?? spec.launch?.hardware;
136
+ if (hardware)
137
+ launchArgv.push("--hardware", hardware);
138
+ const workingDirectory = overrides.workingDirectory ?? spec.launch?.workingDirectory;
139
+ if (this.action === "up" && workingDirectory) {
140
+ launchArgv.push("--working-directory", expandHome(workingDirectory));
141
+ }
142
+ const user = deps.containerUser();
143
+ if (user)
144
+ launchArgv.push("--container-user", user);
145
+ if (internalOnly)
146
+ launchArgv.push("--internal-only");
147
+ if (!this.launchedIds.includes(instanceId))
148
+ this.launchedIds.push(instanceId);
149
+ await s.cliOk(launchArgv);
150
+ await s.waitRunning(instanceId);
151
+ const rows = await s.ingestPorts(instanceId);
152
+ const templateParams = { declared, overrides: specParams };
153
+ const ctx = {
154
+ ...launchCtx,
155
+ url: urlResolver({ instanceId, ports: s.ports, studioHostPort, proxyBase: s.proxyBase, host: s.host }),
156
+ fetch: deps.fetch,
157
+ };
158
+ const instance = {
159
+ instanceId,
160
+ templateName,
161
+ studioHostPort,
162
+ ctx,
163
+ ingestPorts: rows,
164
+ ingestPort: (ingest) => resolveIngestPort(ingest, rows, templateParams),
165
+ studioTarget: () => ({ instanceId, studioHostPort }),
166
+ startSources: async (names) => {
167
+ const chosen = (spec.sources ?? []).filter((src) => !names || names.includes(src.name));
168
+ const missing = (names ?? []).filter((n) => !chosen.some((src) => src.name === n));
169
+ if (missing.length)
170
+ throw new Error(`no source named ${missing.join(", ")} in the spec`);
171
+ const targets = chosen.map((src) => ({
172
+ port: resolveIngestPort(src.ingest, rows, templateParams),
173
+ name: src.name,
174
+ asset: src.asset ?? { preset: "camera1" },
175
+ ...(src.streamId !== undefined ? { streamId: src.streamId } : {}),
176
+ }));
177
+ if (!targets.length)
178
+ return [];
179
+ const secret = s.proxySecret();
180
+ const handles = await deps.startSources({
181
+ daemonPort: s.ports.daemonPort,
182
+ instanceId,
183
+ targets,
184
+ ...(secret !== undefined ? { proxySecret: secret } : {}),
185
+ });
186
+ this.handles.push(...handles);
187
+ deps.log(`sources: ${targets.map((t) => `${t.name} -> :${t.port}`).join(", ")}`);
188
+ return handles;
189
+ },
190
+ awaitReady: async (gates = spec.ready ?? []) => {
191
+ for (const gate of gates) {
192
+ const name = await awaitGate(gate, ctx, s.gateTimeout(gate.timeoutMs));
193
+ deps.log(`ready: ${name}`);
194
+ }
195
+ },
196
+ };
197
+ this.instances.push(instance);
198
+ return instance;
199
+ }
200
+ /** The demo journey on the fixture: launch, pump, gate, `after`, `open`. */
201
+ async run() {
202
+ const { spec, session: s, deps } = this;
203
+ const inst = await this.launch();
204
+ // Sources first: a gate such as "the switcher is composing" or "the probe
205
+ // is analysing" can only hold with something on the wire.
206
+ if (spec.sources?.length)
207
+ await inst.startSources();
208
+ await inst.awaitReady();
209
+ if (spec.after)
210
+ await spec.after(inst.ctx);
211
+ const open = await resolveOpen(spec, inst.ctx);
212
+ const width = Math.max(0, ...open.map((o) => o.name.length));
213
+ deps.log(`${spec.product} demo is ${this.action === "up" ? "UP" : "up (check)"} — instance ${inst.instanceId}`);
214
+ for (const o of open)
215
+ deps.log(` ${o.name.padEnd(width)} ${o.value}`);
216
+ return { instanceId: inst.instanceId, daemonPort: s.ports.daemonPort, storeDir: s.storeDir, open };
217
+ }
218
+ /** Sources, every instance a launch named, then — private only — the
219
+ * daemon, the dev backend and the store. Throws if the store outlives the
220
+ * root-container nuke: a leak a runner would otherwise accumulate. */
221
+ async teardown() {
222
+ const s = this.session;
223
+ const handles = this.handles;
224
+ this.handles = [];
225
+ await s.teardown({ instances: [...this.launchedIds], handles });
226
+ if (s.policy === "private" && this.deps.storeExists(s.storeDir)) {
227
+ throw new Error(`store ${s.storeDir} still present after the root-container nuke`);
228
+ }
229
+ }
230
+ nextStudioHostPort() {
231
+ const n = this.launchedIds.length;
232
+ if (n === 0)
233
+ return this.ports.studioHostPort;
234
+ if (n > EXTRA_STUDIO_PORTS) {
235
+ throw new Error(`launch ${n + 1}: the band holds ${EXTRA_STUDIO_PORTS + 1} studio host ports — pass params.${STUDIO_HOST_PORT_PARAM}`);
236
+ }
237
+ return this.ports.proxyPort + n;
238
+ }
239
+ }
240
+ function urlResolver(o) {
241
+ const host = o.host;
242
+ const mode = netReachMode();
243
+ return (ref) => {
244
+ if ("url" in ref)
245
+ return ref.url;
246
+ if ("control" in ref)
247
+ return `http://${host}:${o.ports.backendPort}${ref.control}`;
248
+ if ("proxy" in ref)
249
+ return `${o.proxyBase}${ref.proxy.replaceAll("{id}", o.instanceId)}`;
250
+ return `${studioBaseFrom({ instanceId: o.instanceId, studioHostPort: o.studioHostPort }, host, mode)}${ref.studio}`;
251
+ };
252
+ }
253
+ async function gateHolds(gate, ctx) {
254
+ if ("custom" in gate)
255
+ return gate.custom(ctx);
256
+ const r = await ctx.fetch(ctx.url(gate.http));
257
+ if (r.status !== (gate.status ?? 200))
258
+ return false;
259
+ if (gate.bodyIncludes === undefined)
260
+ return true;
261
+ return (await r.text()).includes(gate.bodyIncludes);
262
+ }
263
+ function gateName(gate, ctx) {
264
+ if ("custom" in gate)
265
+ return gate.label ?? "custom gate";
266
+ return `${ctx.url(gate.http)} -> ${gate.status ?? 200}${gate.bodyIncludes ? ` containing '${gate.bodyIncludes}'` : ""}`;
267
+ }
268
+ /** Poll a gate until it holds — or stop at once when it declares its failure
269
+ * final (DemoGateFailure), which pollUntil would otherwise keep retrying. */
270
+ async function awaitGate(gate, ctx, timeoutMs) {
271
+ const name = gateName(gate, ctx);
272
+ let fatal;
273
+ await pollUntil(async () => {
274
+ try {
275
+ return await gateHolds(gate, ctx);
276
+ }
277
+ catch (e) {
278
+ if (e instanceof DemoGateFailure) {
279
+ fatal = e;
280
+ return true;
281
+ }
282
+ throw e;
283
+ }
284
+ }, { timeoutMs, intervalMs: 1000, label: `ready gate never held: ${name}` });
285
+ if (fatal)
286
+ throw new Error(`ready gate failed: ${name}: ${fatal.message}`);
287
+ return name;
288
+ }
289
+ async function resolveOpen(spec, ctx) {
290
+ const out = [];
291
+ for (const o of spec.open ?? []) {
292
+ const url = ctx.url(o.url);
293
+ if (!o.pick) {
294
+ out.push({ name: o.name, value: url });
295
+ continue;
296
+ }
297
+ try {
298
+ const body = await (await ctx.fetch(url)).text();
299
+ const m = body.match(new RegExp(o.pick));
300
+ out.push({ name: o.name, value: m ? m[0] : `<no match for /${o.pick}/ at ${url}>` });
301
+ }
302
+ catch (e) {
303
+ out.push({ name: o.name, value: `<unavailable: ${e instanceof Error ? e.message : String(e)}>` });
304
+ }
305
+ }
306
+ return out;
307
+ }
308
+ /** `up` refuses to run beside a live earlier run of the same demo. Private:
309
+ * its daemon still answers. Reuse: the daemon always answers, so the record
310
+ * is live only while its instance still exists. A dead record is forgotten. */
311
+ async function refuseIfUp(spec, s, deps) {
312
+ const prev = deps.state.read(spec.product);
313
+ if (!prev)
314
+ return;
315
+ const answers = await deps.daemonAnswers(prev.daemonPort);
316
+ const live = prev.daemon === "reuse"
317
+ ? answers && prev.instanceId !== undefined && (await s.instanceListed(prev.instanceId))
318
+ : answers;
319
+ if (live) {
320
+ throw new Error(`demo '${spec.product}' is already up (daemon :${prev.daemonPort}, store ${prev.storeDir}) — run \`demo down\` first`);
321
+ }
322
+ deps.log(`forgetting a stale record of a run on :${prev.daemonPort} (nothing of it is left)`);
323
+ deps.state.remove(spec.product);
324
+ }
325
+ /** The demo: a ProductHarness set up, run, held (`up`) or not (`check`), torn down. */
326
+ export async function runDemo(spec, opts, deps = defaultDemoDeps(opts.cwd)) {
327
+ const policy = opts.daemon ?? "private";
328
+ const h = new ProductHarness(spec, {
329
+ cwd: opts.cwd,
330
+ mode: opts.mode,
331
+ daemon: policy,
332
+ deps,
333
+ action: opts.action,
334
+ ...(opts.slug !== undefined ? { slug: opts.slug } : {}),
335
+ ...(opts.daemonPort !== undefined ? { daemonPort: opts.daemonPort } : {}),
336
+ ...(opts.timeouts !== undefined ? { timeouts: opts.timeouts } : {}),
337
+ ...(opts.publicHost !== undefined ? { publicHost: opts.publicHost } : {}),
338
+ });
339
+ if (opts.action === "up") {
340
+ await refuseIfUp(spec, h.session, deps);
341
+ for (const p of spec.prerequisites ?? []) {
342
+ const path = expandHome(p.path);
343
+ if (!deps.fileExists(path))
344
+ throw new Error(`prerequisite missing: ${path}\n make it with: ${p.hint}`);
345
+ }
346
+ }
347
+ let recorded = false;
348
+ let result;
349
+ let journeyError;
350
+ try {
351
+ await h.setup();
352
+ result = await h.run();
353
+ if (opts.action === "up") {
354
+ const s = h.session;
355
+ deps.state.write({
356
+ product: spec.product,
357
+ storeDir: s.storeDir,
358
+ daemonPort: s.ports.daemonPort,
359
+ instanceId: result.instanceId,
360
+ daemon: policy,
361
+ ...(s.extras.length ? { extras: [...s.extras] } : {}),
362
+ ...(s.dev?.pid !== undefined ? { devPid: s.dev.pid } : {}),
363
+ });
364
+ recorded = true;
365
+ deps.log("holding — Ctrl-C (or `demo down` from another shell) tears down");
366
+ await deps.hold(opts.abort);
367
+ }
368
+ }
369
+ catch (e) {
370
+ journeyError = e;
371
+ }
372
+ let teardownError;
373
+ try {
374
+ await h.teardown();
375
+ }
376
+ catch (e) {
377
+ teardownError = e;
378
+ }
379
+ if (recorded)
380
+ deps.state.remove(spec.product);
381
+ if (journeyError !== undefined)
382
+ throw journeyError;
383
+ if (teardownError !== undefined)
384
+ throw teardownError;
385
+ return result;
386
+ }
package/demo/index.d.ts CHANGED
@@ -3,9 +3,11 @@ export { defaultDemoCliIo, demoMain, parseDemoArgs, resolvedSpecView } from "./c
3
3
  export type { DevLoopOptions, DevLoopResult, StandaloneSource } from "./dev-loop.js";
4
4
  export { devLoopDown, pumpScript, runDevLoop } from "./dev-loop.js";
5
5
  export { composeAdvancingGate, DemoGateFailure, logsCleanGate } from "./gates.js";
6
+ export type { LaunchedInstance, LaunchOverrides, ProductHarnessOptions } from "./harness.js";
7
+ export { ProductHarness, runDemo } from "./harness.js";
6
8
  export type { DemoPane, DemoPaneOptions, DemoUi, DevLoopPaneOptions, PaneLayout, StandaloneDirs } from "./panes.js";
7
9
  export { DEV_LOOP_SOURCES, DEV_LOOP_STAMP, demoPaneList, devLoopPaneList, launchLayout, paneArgv, paneCommandLine, paneShellLine, renderLayout, shellQuote, standaloneDirs, } from "./panes.js";
8
- export type { CliResult, DemoDeps, DemoPorts, DemoRunOptions, DemoRunResult, DemoState, DemoStateKind, DemoStateStore, DemoTimeouts, ExportCheckOptions, IngestPortRow, PinMismatch, ProcessHandle, TemplateParams, } from "./run.js";
9
- export { checkTemplatePins, defaultDemoDeps, demoDown, demoPorts, demoSlug, expandHome, exportStandaloneWorkdir, fileStateStore, findBrokenSymlinks, killGroup, resolveIngestPort, runDemo, runExportCheck, } from "./run.js";
10
+ export type { CliResult, CliStep, DemoDeps, DemoPorts, DemoRunOptions, DemoRunResult, DemoState, DemoStateKind, DemoStateStore, DemoTimeouts, ExportCheckOptions, IngestPortRow, PinMismatch, ProcessHandle, TemplateParams, } from "./run.js";
11
+ export { checkTemplatePins, defaultDemoDeps, demoDown, demoPorts, demoSlug, expandHome, exportStandaloneWorkdir, fileStateStore, findBrokenSymlinks, killGroup, resolveIngestPort, runExportCheck, } from "./run.js";
10
12
  export type { DemoContext, DemoDaemonPolicy, DemoExtra, DemoIngest, DemoLaunchContext, DemoMode, DemoOpen, DemoReady, DemoSource, DemoSpec, DemoTemplate, DemoUrl, } from "./spec.js";
11
13
  export { DemoSpecSchema, defineDemo, formatSpecIssues } from "./spec.js";
package/demo/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  export { defaultDemoCliIo, demoMain, parseDemoArgs, resolvedSpecView } from "./cli.js";
2
2
  export { devLoopDown, pumpScript, runDevLoop } from "./dev-loop.js";
3
3
  export { composeAdvancingGate, DemoGateFailure, logsCleanGate } from "./gates.js";
4
+ export { ProductHarness, runDemo } from "./harness.js";
4
5
  export { DEV_LOOP_SOURCES, DEV_LOOP_STAMP, demoPaneList, devLoopPaneList, launchLayout, paneArgv, paneCommandLine, paneShellLine, renderLayout, shellQuote, standaloneDirs, } from "./panes.js";
5
- export { checkTemplatePins, defaultDemoDeps, demoDown, demoPorts, demoSlug, expandHome, exportStandaloneWorkdir, fileStateStore, findBrokenSymlinks, killGroup, resolveIngestPort, runDemo, runExportCheck, } from "./run.js";
6
+ export { checkTemplatePins, defaultDemoDeps, demoDown, demoPorts, demoSlug, expandHome, exportStandaloneWorkdir, fileStateStore, findBrokenSymlinks, killGroup, resolveIngestPort, runExportCheck, } from "./run.js";
6
7
  export { DemoSpecSchema, defineDemo, formatSpecIssues } from "./spec.js";
package/demo/run.d.ts CHANGED
@@ -188,6 +188,15 @@ export declare function fileStateStore(cwd: string): DemoStateStore;
188
188
  * own), falling back to the pid alone if it is not a group leader. */
189
189
  export declare function killGroup(pid: number): void;
190
190
  export declare function defaultDemoDeps(cwd: string): DemoDeps;
191
+ /** A CLI step as it ran: the argv a reader would type (no daemon port, no
192
+ * output format) and what it printed — what a doc guide renders verbatim
193
+ * instead of hand-typing (and drifting from) the command. */
194
+ export interface CliStep {
195
+ argv: string[];
196
+ stdout: string;
197
+ stderr: string;
198
+ exitCode: number;
199
+ }
191
200
  export interface SessionOptions {
192
201
  mode: DemoMode;
193
202
  daemon: DemoDaemonPolicy;
@@ -196,6 +205,17 @@ export interface SessionOptions {
196
205
  publicHost?: string;
197
206
  /** Private: attach to an existing private store (a recorded run) instead of making one. */
198
207
  storeDir?: string;
208
+ /** Private: init the proxy with a basic-auth user (a browser signs in)
209
+ * instead of `--proxy-auth none`. */
210
+ proxy?: {
211
+ user: string;
212
+ password: string;
213
+ };
214
+ /** Private: extra environment for the daemon process (a doc guide names its
215
+ * own proxy container so a run cannot adopt the developer's). */
216
+ daemonEnv?: Record<string, string>;
217
+ /** Containers teardown must also wait out — an overridden proxy's pair. */
218
+ extraContainers?: string[];
199
219
  }
200
220
  /** The pieces of a run that `runDemo`, `runExportCheck` and the dev-loop
201
221
  * share: the daemon (private on its band, or the developer's), the dev
@@ -218,7 +238,12 @@ export declare class DemoSession {
218
238
  daemon: DaemonProcess | null;
219
239
  dev: ProcessHandle | null;
220
240
  extras: string[];
241
+ /** Every CLI call this session made, in order. */
242
+ readonly transcript: CliStep[];
221
243
  private readonly timeouts;
244
+ private readonly proxyAuth;
245
+ private readonly daemonEnv;
246
+ private readonly extraContainers;
222
247
  constructor(spec: DemoSpec, slug: string, cwd: string, deps: DemoDeps, opts: SessionOptions);
223
248
  get devUrl(): string;
224
249
  templateDir(name: string): string;
@@ -266,7 +291,6 @@ export declare class DemoSession {
266
291
  handles: SourceHandle[];
267
292
  }): Promise<void>;
268
293
  }
269
- export declare function runDemo(spec: DemoSpec, opts: DemoRunOptions, deps?: DemoDeps): Promise<DemoRunResult>;
270
294
  export interface ExportCheckOptions {
271
295
  cwd: string;
272
296
  slug?: string;
package/demo/run.js CHANGED
@@ -29,20 +29,18 @@ import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSy
29
29
  import { homedir } from "node:os";
30
30
  import { basename, dirname, isAbsolute, join, resolve } from "node:path";
31
31
  import { parseManifestSeed, repoOf } from "@norskvideo/ctl-sdk/manifest-seed";
32
- import { DOCKER_NETWORK_NAME, ensureRunnerOnNetwork, netReachMode, STUDIO_INTERNAL_PORT, studioBaseFrom, } from "../container-net.js";
32
+ import { DOCKER_NETWORK_NAME, ensureRunnerOnNetwork, netReachMode } from "../container-net.js";
33
33
  import { cleanupDaemon, requireLicenseFile, runCli, startDaemon, } from "../daemon.js";
34
34
  import { hashSlot } from "../harness-config.js";
35
35
  import { ctlSupportsNoPublish, runnerContainerUser } from "../launch.js";
36
36
  import { pollUntil } from "../poll.js";
37
37
  import { startSrtSources } from "../source-pump.js";
38
38
  import { makeStoreDir } from "../temp-dir.js";
39
- import { DemoGateFailure } from "./gates.js";
40
39
  const DEMO_PORT_BASE = 35000;
41
40
  const DEMO_BAND_WIDTH = 20;
42
41
  const DEMO_BANDS = 50;
43
42
  const DEFAULT_DAEMON_PORT = 8333;
44
43
  const DEFAULT_PROXY_PORT = 443;
45
- const STUDIO_HOST_PORT_PARAM = "STUDIO_HOST_PORT";
46
44
  const NUKE_IMAGE = "alpine:3";
47
45
  const DEFAULT_DEV_READY_PATH = "/manifest.json";
48
46
  const SEED_FILE = "manifest.seed.json";
@@ -348,7 +346,12 @@ export class DemoSession {
348
346
  daemon = null;
349
347
  dev = null;
350
348
  extras = [];
349
+ /** Every CLI call this session made, in order. */
350
+ transcript = [];
351
351
  timeouts;
352
+ proxyAuth;
353
+ daemonEnv;
354
+ extraContainers;
352
355
  constructor(spec, slug, cwd, deps, opts) {
353
356
  this.spec = spec;
354
357
  this.slug = slug;
@@ -373,6 +376,9 @@ export class DemoSession {
373
376
  // speaks plain http (seen live: https:// gave 000, http:// served the page).
374
377
  this.proxyBase = `http://${host}:${this.ports.proxyPort}`;
375
378
  }
379
+ this.proxyAuth = opts.proxy;
380
+ this.daemonEnv = opts.daemonEnv ?? {};
381
+ this.extraContainers = opts.extraContainers ?? [];
376
382
  this.timeouts = {
377
383
  devReadyMs: opts.timeouts?.devReadyMs ?? 120_000,
378
384
  healthyMs: opts.timeouts?.healthyMs ?? 180_000,
@@ -391,12 +397,16 @@ export class DemoSession {
391
397
  return undefined;
392
398
  return this.deps.readFile(join(this.storeDir, PROXY_SECRET_FILE))?.trim() || undefined;
393
399
  }
394
- cli = async (argv, opts = {}) => this.deps.cli(this.storeDir, [
395
- "--port",
396
- String(this.ports.daemonPort),
397
- ...argv,
398
- ...(opts.output ? ["-o", opts.output] : []),
399
- ]);
400
+ cli = async (argv, opts = {}) => {
401
+ const r = await this.deps.cli(this.storeDir, [
402
+ "--port",
403
+ String(this.ports.daemonPort),
404
+ ...argv,
405
+ ...(opts.output ? ["-o", opts.output] : []),
406
+ ]);
407
+ this.transcript.push({ argv: [...argv], stdout: r.stdout, stderr: r.stderr, exitCode: r.exitCode });
408
+ return r;
409
+ };
400
410
  cliOk = async (argv, opts = {}) => {
401
411
  const r = await this.cli(argv, opts);
402
412
  if (r.exitCode !== 0) {
@@ -425,13 +435,22 @@ export class DemoSession {
425
435
  // /instance/<id>/... routes (the visualiser a gate reads, the `open`
426
436
  // URLs) must answer without a session: with oauth on, the compose gate
427
437
  // read the sign-in page as "0 frames" for 180s (playout CI, 2026-08-29).
428
- "--proxy-auth",
429
- "none",
438
+ // A doc guide that shows the sign-in asks for a user instead.
439
+ ...(this.proxyAuth
440
+ ? ["--proxy-user", this.proxyAuth.user, "--proxy-password", this.proxyAuth.password]
441
+ : ["--proxy-auth", "none"]),
430
442
  "--no-http-redirect",
431
443
  "--no-start-server",
432
444
  ...(this.publicHost ? ["--public-host", this.publicHost] : []),
433
445
  ]);
434
- const started = this.deps.startDaemon(this.storeDir, { port: this.ports.daemonPort, seedConfig: false });
446
+ // The instance ports the daemon allocates come from this band too, so two
447
+ // products' fixtures on one box never share one (what every product
448
+ // harness passed by hand).
449
+ const started = this.deps.startDaemon(this.storeDir, {
450
+ port: this.ports.daemonPort,
451
+ seedConfig: false,
452
+ env: { NORSK_CTL_INSTANCE_PORT_BASE: String(this.ports.instancePortBase), ...this.daemonEnv },
453
+ });
435
454
  this.daemon = started.daemon;
436
455
  await started.ready;
437
456
  if (netReachMode() === "direct" && this.deps.ensureNetwork() === "failed") {
@@ -634,7 +653,7 @@ export class DemoSession {
634
653
  instances: opts.instances,
635
654
  daemon: this.daemon,
636
655
  storeDir: this.storeDir,
637
- containers: opts.instances.flatMap(instanceContainerNames),
656
+ containers: [...opts.instances.flatMap(instanceContainerNames), ...this.extraContainers],
638
657
  });
639
658
  }
640
659
  catch (e) {
@@ -645,238 +664,6 @@ export class DemoSession {
645
664
  this.deps.nukeStoreAsRoot(this.storeDir);
646
665
  }
647
666
  }
648
- function urlResolver(o) {
649
- const host = o.host;
650
- const mode = netReachMode();
651
- return (ref) => {
652
- if ("url" in ref)
653
- return ref.url;
654
- if ("control" in ref)
655
- return `http://${host}:${o.ports.backendPort}${ref.control}`;
656
- if ("proxy" in ref)
657
- return `${o.proxyBase}${ref.proxy.replaceAll("{id}", o.instanceId)}`;
658
- return `${studioBaseFrom({ instanceId: o.instanceId, studioHostPort: o.studioHostPort }, host, mode)}${ref.studio}`;
659
- };
660
- }
661
- async function gateHolds(gate, ctx) {
662
- if ("custom" in gate)
663
- return gate.custom(ctx);
664
- const r = await ctx.fetch(ctx.url(gate.http));
665
- if (r.status !== (gate.status ?? 200))
666
- return false;
667
- if (gate.bodyIncludes === undefined)
668
- return true;
669
- return (await r.text()).includes(gate.bodyIncludes);
670
- }
671
- function gateName(gate, ctx) {
672
- if ("custom" in gate)
673
- return gate.label ?? "custom gate";
674
- return `${ctx.url(gate.http)} -> ${gate.status ?? 200}${gate.bodyIncludes ? ` containing '${gate.bodyIncludes}'` : ""}`;
675
- }
676
- /** Poll a gate until it holds — or stop at once when it declares its failure
677
- * final (DemoGateFailure), which pollUntil would otherwise keep retrying. */
678
- async function awaitGate(gate, ctx, timeoutMs) {
679
- const name = gateName(gate, ctx);
680
- let fatal;
681
- await pollUntil(async () => {
682
- try {
683
- return await gateHolds(gate, ctx);
684
- }
685
- catch (e) {
686
- if (e instanceof DemoGateFailure) {
687
- fatal = e;
688
- return true;
689
- }
690
- throw e;
691
- }
692
- }, { timeoutMs, intervalMs: 1000, label: `ready gate never held: ${name}` });
693
- if (fatal)
694
- throw new Error(`ready gate failed: ${name}: ${fatal.message}`);
695
- return name;
696
- }
697
- async function resolveOpen(spec, ctx) {
698
- const out = [];
699
- for (const o of spec.open ?? []) {
700
- const url = ctx.url(o.url);
701
- if (!o.pick) {
702
- out.push({ name: o.name, value: url });
703
- continue;
704
- }
705
- try {
706
- const body = await (await ctx.fetch(url)).text();
707
- const m = body.match(new RegExp(o.pick));
708
- out.push({ name: o.name, value: m ? m[0] : `<no match for /${o.pick}/ at ${url}>` });
709
- }
710
- catch (e) {
711
- out.push({ name: o.name, value: `<unavailable: ${e instanceof Error ? e.message : String(e)}>` });
712
- }
713
- }
714
- return out;
715
- }
716
- /** `up` refuses to run beside a live earlier run of the same demo. Private:
717
- * its daemon still answers. Reuse: the daemon always answers, so the record
718
- * is live only while its instance still exists. A dead record is forgotten. */
719
- async function refuseIfUp(spec, s, deps) {
720
- const prev = deps.state.read(spec.product);
721
- if (!prev)
722
- return;
723
- const answers = await deps.daemonAnswers(prev.daemonPort);
724
- const live = prev.daemon === "reuse"
725
- ? answers && prev.instanceId !== undefined && (await s.instanceListed(prev.instanceId))
726
- : answers;
727
- if (live) {
728
- throw new Error(`demo '${spec.product}' is already up (daemon :${prev.daemonPort}, store ${prev.storeDir}) — run \`demo down\` first`);
729
- }
730
- deps.log(`forgetting a stale record of a run on :${prev.daemonPort} (nothing of it is left)`);
731
- deps.state.remove(spec.product);
732
- }
733
- export async function runDemo(spec, opts, deps = defaultDemoDeps(opts.cwd)) {
734
- const slug = opts.slug ?? demoSlug(spec.product);
735
- const instanceId = `demo-${slug}`;
736
- const policy = opts.daemon ?? "private";
737
- if (opts.mode === "image" && !spec.image) {
738
- throw new Error(`--mode image needs the spec's \`image\` (the built product image) — ${spec.product} declares none`);
739
- }
740
- const s = new DemoSession(spec, slug, opts.cwd, deps, {
741
- mode: opts.mode,
742
- daemon: policy,
743
- ...(opts.daemonPort !== undefined ? { daemonPort: opts.daemonPort } : {}),
744
- ...(opts.timeouts !== undefined ? { timeouts: opts.timeouts } : {}),
745
- ...(opts.publicHost !== undefined ? { publicHost: opts.publicHost } : {}),
746
- });
747
- if (opts.action === "up") {
748
- await refuseIfUp(spec, s, deps);
749
- for (const p of spec.prerequisites ?? []) {
750
- const path = expandHome(p.path);
751
- if (!deps.fileExists(path))
752
- throw new Error(`prerequisite missing: ${path}\n make it with: ${p.hint}`);
753
- }
754
- }
755
- let launched = false;
756
- let handles = [];
757
- let recorded = false;
758
- let result;
759
- let journeyError;
760
- try {
761
- await s.attachDaemon();
762
- if (opts.mode === "dev")
763
- await s.startDev();
764
- if (policy === "reuse")
765
- await s.resetRegistration(instanceId);
766
- await s.register({ licence: true });
767
- const templateName = await s.resolveTemplate();
768
- await s.pinGuard(templateName);
769
- const launchCtx = {
770
- action: opts.action,
771
- mode: opts.mode,
772
- daemon: policy,
773
- product: spec.product,
774
- instanceId,
775
- daemonPort: s.ports.daemonPort,
776
- storeDir: s.storeDir,
777
- templateName,
778
- templateDir: s.templateDir(templateName),
779
- cli: s.cli,
780
- log: deps.log,
781
- };
782
- s.startExtras(instanceId);
783
- if (spec.beforeLaunch)
784
- await spec.beforeLaunch(launchCtx);
785
- const declared = await s.declaredParams(templateName);
786
- const specParams = spec.launch?.params ?? {};
787
- const studioHostPort = specParams[STUDIO_HOST_PORT_PARAM] !== undefined
788
- ? Number(specParams[STUDIO_HOST_PORT_PARAM])
789
- : s.ports.studioHostPort;
790
- const params = [`INSTANCE_NAME=${instanceId}`, ...Object.entries(specParams).map(([k, v]) => `${k}=${v}`)];
791
- const launchArgv = ["instance", "launch-template", instanceId, "--template", templateName];
792
- const internalOnly = netReachMode() === "direct" && (await deps.supportsNoPublish());
793
- if (declared.has(STUDIO_HOST_PORT_PARAM)) {
794
- if (specParams[STUDIO_HOST_PORT_PARAM] === undefined)
795
- params.push(`${STUDIO_HOST_PORT_PARAM}=${studioHostPort}`);
796
- }
797
- else if (!internalOnly) {
798
- launchArgv.push("--host-ports", `${studioHostPort}:${STUDIO_INTERNAL_PORT}@studio`);
799
- }
800
- for (const p of params)
801
- launchArgv.push("--param", p);
802
- if (spec.launch?.hardware)
803
- launchArgv.push("--hardware", spec.launch.hardware);
804
- if (opts.action === "up" && spec.launch?.workingDirectory) {
805
- launchArgv.push("--working-directory", expandHome(spec.launch.workingDirectory));
806
- }
807
- const user = deps.containerUser();
808
- if (user)
809
- launchArgv.push("--container-user", user);
810
- if (internalOnly)
811
- launchArgv.push("--internal-only");
812
- launched = true;
813
- await s.cliOk(launchArgv);
814
- await s.waitRunning(instanceId);
815
- const rows = await s.ingestPorts(instanceId);
816
- const templateParams = { declared, overrides: specParams };
817
- const targets = (spec.sources ?? []).map((src) => ({
818
- port: resolveIngestPort(src.ingest, rows, templateParams),
819
- name: src.name,
820
- asset: src.asset ?? { preset: "camera1" },
821
- ...(src.streamId !== undefined ? { streamId: src.streamId } : {}),
822
- }));
823
- const ctx = {
824
- ...launchCtx,
825
- url: urlResolver({ instanceId, ports: s.ports, studioHostPort, proxyBase: s.proxyBase, host: s.host }),
826
- fetch: deps.fetch,
827
- };
828
- // Sources first: a gate such as "the switcher is composing" or "the probe
829
- // is analysing" can only hold with something on the wire.
830
- if (targets.length) {
831
- const secret = s.proxySecret();
832
- handles = await deps.startSources({
833
- daemonPort: s.ports.daemonPort,
834
- instanceId,
835
- targets,
836
- ...(secret !== undefined ? { proxySecret: secret } : {}),
837
- });
838
- deps.log(`sources: ${targets.map((t) => `${t.name} -> :${t.port}`).join(", ")}`);
839
- }
840
- for (const gate of spec.ready ?? []) {
841
- const name = await awaitGate(gate, ctx, s.gateTimeout(gate.timeoutMs));
842
- deps.log(`ready: ${name}`);
843
- }
844
- if (spec.after)
845
- await spec.after(ctx);
846
- const open = await resolveOpen(spec, ctx);
847
- const width = Math.max(0, ...open.map((o) => o.name.length));
848
- deps.log(`${spec.product} demo is ${opts.action === "up" ? "UP" : "up (check)"} — instance ${instanceId}`);
849
- for (const o of open)
850
- deps.log(` ${o.name.padEnd(width)} ${o.value}`);
851
- result = { instanceId, daemonPort: s.ports.daemonPort, storeDir: s.storeDir, open };
852
- if (opts.action === "up") {
853
- deps.state.write({
854
- product: spec.product,
855
- storeDir: s.storeDir,
856
- daemonPort: s.ports.daemonPort,
857
- instanceId,
858
- daemon: policy,
859
- ...(s.extras.length ? { extras: [...s.extras] } : {}),
860
- ...(s.dev?.pid !== undefined ? { devPid: s.dev.pid } : {}),
861
- });
862
- recorded = true;
863
- deps.log("holding — Ctrl-C (or `demo down` from another shell) tears down");
864
- await deps.hold(opts.abort);
865
- }
866
- }
867
- catch (e) {
868
- journeyError = e;
869
- }
870
- await s.teardown({ instances: launched ? [instanceId] : [], handles });
871
- if (recorded)
872
- deps.state.remove(spec.product);
873
- if (journeyError !== undefined)
874
- throw journeyError;
875
- if (policy === "private" && deps.storeExists(s.storeDir)) {
876
- throw new Error(`store ${s.storeDir} still present after the root-container nuke`);
877
- }
878
- return result;
879
- }
880
667
  /** `demo check --mode standalone --export-only` (05-demo s4): build the
881
668
  * template, export the standalone workdir with the spec's live-source links,
882
669
  * assert every symlink resolves. No instance, no licence — the daemon's own
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@norskvideo/ctl-test-harness",
3
- "version": "0.1.24",
3
+ "version": "0.1.25",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {