@norskvideo/ctl-test-harness 0.1.4 → 0.1.6

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
@@ -3,7 +3,9 @@ import { makeStoreDir, makeTempDir, TEST_TMP_BASE } from "./temp-dir.js";
3
3
  export declare const HEALTH_TIMEOUT_MS = 120000;
4
4
  export declare const DAEMON_READY_TIMEOUT_MS = 60000;
5
5
  export { makeStoreDir, makeTempDir, pollUntil, TEST_TMP_BASE };
6
- export declare function requireLicenseFile(): string;
6
+ export declare function requireLicenseFile(opts?: {
7
+ missing?: "exit" | "throw";
8
+ }): string;
7
9
  export declare function runCli(storeDir: string, ...args: string[]): Promise<{
8
10
  stdout: string;
9
11
  stderr: string;
@@ -11,11 +13,28 @@ export declare function runCli(storeDir: string, ...args: string[]): Promise<{
11
13
  }>;
12
14
  export declare function isPortFree(port: number): Promise<boolean>;
13
15
  export declare function killProcessOnPort(port: number): void;
14
- export declare function startDaemon(storeDir: string, options?: {
16
+ /** The daemon handle callers hold: enough to kill it and await its exit,
17
+ * without tying the interface to one runtime's spawn return type. */
18
+ export interface DaemonProcess {
19
+ pid: number | undefined;
20
+ kill(signal?: number | NodeJS.Signals): void;
21
+ /** Resolves with the exit code once the process exits. */
22
+ exited: Promise<number | null>;
23
+ }
24
+ export interface StartDaemonOptions {
15
25
  port?: number;
16
26
  env?: Record<string, string>;
17
- }): {
18
- daemon: ReturnType<typeof Bun.spawn>;
27
+ /** Spawn in its own process group so a caller can group-kill the whole
28
+ * `bun run` tree (the doc-guide teardown). Default false. */
29
+ detached?: boolean;
30
+ /** Seed <storeDir>/config.yaml with networkMode + a store-rooted working
31
+ * directory (see below). Guides drive `init` themselves and must start
32
+ * from a virgin store, so they turn this off. Default true. */
33
+ seedConfig?: boolean;
34
+ readyTimeoutMs?: number;
35
+ }
36
+ export declare function startDaemon(storeDir: string, options?: StartDaemonOptions): {
37
+ daemon: DaemonProcess;
19
38
  ready: Promise<void>;
20
39
  };
21
40
  /**
@@ -30,7 +49,7 @@ export declare function cleanupDaemon(opts: {
30
49
  stopDaemon: () => Promise<unknown>;
31
50
  instances?: string[];
32
51
  proxy?: boolean;
33
- daemon: ReturnType<typeof Bun.spawn> | null;
52
+ daemon: DaemonProcess | null;
34
53
  storeDir: string;
35
54
  containers: string[];
36
55
  }): Promise<void>;
package/daemon.js CHANGED
@@ -1,15 +1,19 @@
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";
1
+ // Daemon-lifecycle helpers for the product integration harnesses and the doc
2
+ // guides. Self-contained so the package publishes cleanly: it spawns the
3
+ // norsk-ctl CLI by command prefix (see cli-command.ts) and drives teardown
4
+ // through an injected command runner, carrying no dependency on norsk-ctl's
5
+ // generated typed `commands` builder.
6
+ //
7
+ // Runtime-portable on purpose: everything here uses node:child_process, which
8
+ // works under bun AND under Playwright's Node workers — so the doc-guide tier
9
+ // consumes these same bodies instead of keeping a third fork.
10
+ import { spawn, spawnSync } from "node:child_process";
6
11
  import { existsSync, rmSync, writeFileSync } from "node:fs";
7
12
  import { createServer } from "node:net";
8
13
  import { join } from "node:path";
9
14
  import { cliCommand } from "./cli-command.js";
10
15
  import { pollUntil } from "./poll.js";
11
16
  import { makeStoreDir, makeTempDir, TEST_TMP_BASE } from "./temp-dir.js";
12
- process.env.NORSK_CTL_NO_MEDIA_DOWNLOAD = "1";
13
17
  export const HEALTH_TIMEOUT_MS = 120_000;
14
18
  // The daemon's readiness probe (/api/ready) cannot answer until `serve` has
15
19
  // brought up its oauth2-proxy + nginx sidecar CONTAINERS — /api/* redirects
@@ -21,37 +25,49 @@ export const HEALTH_TIMEOUT_MS = 120_000;
21
25
  // genuinely slow.
22
26
  export const DAEMON_READY_TIMEOUT_MS = 60_000;
23
27
  export { makeStoreDir, makeTempDir, pollUntil, TEST_TMP_BASE };
28
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
24
29
  function splitLines(text) {
25
30
  return text
26
31
  .split("\n")
27
32
  .map((l) => l.trim())
28
33
  .filter(Boolean);
29
34
  }
30
- export function requireLicenseFile() {
35
+ export function requireLicenseFile(opts = {}) {
31
36
  const licenseFile = process.env.NORSK_LICENSE_FILE;
32
- // Some tests call this at module-load (in describe()/test() bodies); others
33
- // call it at execution time. A throw at module-load gets wrapped by bun's
34
- // "Unhandled error between tests" reporter and produces a cascade of red
35
- // alongside genuinely-unrelated failures. Instead, print one clean message
36
- // and exit so the suite stops the moment we hit a license-needing test.
37
37
  if (!licenseFile || !existsSync(licenseFile)) {
38
38
  const reason = !licenseFile
39
39
  ? "NORSK_LICENSE_FILE env var is not set."
40
40
  : `License file not found at ${licenseFile} (from NORSK_LICENSE_FILE).`;
41
+ // Playwright workers ("throw") want a per-test failure. Under bun ("exit",
42
+ // the default) some tests call this at module-load, and a throw there gets
43
+ // wrapped by bun's "Unhandled error between tests" reporter into a cascade
44
+ // of red alongside genuinely-unrelated failures — print one clean message
45
+ // and stop the suite instead.
46
+ if (opts.missing === "throw")
47
+ throw new Error(reason);
41
48
  console.error(`\nIntegration tests require a Norsk license:\n ${reason}\n\n NORSK_LICENSE_FILE=/path/to/license.json bun run test:integration\n`);
42
49
  process.exit(1);
43
50
  }
44
51
  return licenseFile;
45
52
  }
46
53
  export async function runCli(storeDir, ...args) {
47
- const proc = Bun.spawn([...cliCommand, ...args], {
48
- stdout: "pipe",
49
- stderr: "pipe",
50
- env: { ...process.env, NORSK_CTL_STORE_DIR: storeDir },
54
+ return new Promise((res) => {
55
+ const [cmd, ...cmdArgs] = cliCommand;
56
+ const proc = spawn(cmd, [...cmdArgs, ...args], {
57
+ env: { ...process.env, NORSK_CTL_STORE_DIR: storeDir },
58
+ });
59
+ const stdout = [];
60
+ const stderr = [];
61
+ proc.stdout.on("data", (d) => stdout.push(d));
62
+ proc.stderr.on("data", (d) => stderr.push(d));
63
+ proc.on("close", (code) => {
64
+ res({
65
+ stdout: Buffer.concat(stdout).toString(),
66
+ stderr: Buffer.concat(stderr).toString(),
67
+ exitCode: code ?? 1,
68
+ });
69
+ });
51
70
  });
52
- const [stdout, stderr] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
53
- const exitCode = await proc.exited;
54
- return { stdout, stderr, exitCode };
55
71
  }
56
72
  export function isPortFree(port) {
57
73
  return new Promise((resolve) => {
@@ -73,33 +89,53 @@ export function killProcessOnPort(port) {
73
89
  }
74
90
  catch { }
75
91
  }
92
+ function daemonHandle(proc) {
93
+ const exited = new Promise((resolve) => proc.once("exit", (code) => resolve(code)));
94
+ return {
95
+ pid: proc.pid,
96
+ kill: (signal) => {
97
+ try {
98
+ proc.kill(signal);
99
+ }
100
+ catch { }
101
+ },
102
+ exited,
103
+ };
104
+ }
76
105
  export function startDaemon(storeDir, options) {
77
106
  const port = options?.port ?? 8333;
78
107
  killProcessOnPort(port);
79
- // Root the instance working directory under the store dir (itself under
80
- // TEST_TMP_BASE) rather than the daemon default ~/norsk-runtime. The launcher
81
- // bind-mounts the workdir as /data and then mounts the stored template's
82
- // workflow.yml (which lives under the store dir) *inside* it Docker Desktop
83
- // rejects a nested bind mount whose inner source resolves to a different host
84
- // share-root than the outer, so both mounts must share the TEST_TMP_BASE root.
85
- // With NORSK_CTL_STORE_DIR set, config.yaml is read from <storeDir>/config.yaml.
86
- // networkMode is the one required config.yaml field omitting it makes the
87
- // whole file fail to parse (ConfigParseError), silently dropping the workdir
88
- // override. Seed both.
89
- const configPath = join(storeDir, "config.yaml");
90
- if (!existsSync(configPath)) {
91
- writeFileSync(configPath, `networkMode: docker\ndefaultWorkingDirectory: ${JSON.stringify(join(storeDir, "norsk-runtime"))}\n`);
108
+ if (options?.seedConfig !== false) {
109
+ // Root the instance working directory under the store dir (itself under
110
+ // TEST_TMP_BASE) rather than the daemon default ~/norsk-runtime. The launcher
111
+ // bind-mounts the workdir as /data and then mounts the stored template's
112
+ // workflow.yml (which lives under the store dir) *inside* it Docker Desktop
113
+ // rejects a nested bind mount whose inner source resolves to a different host
114
+ // share-root than the outer, so both mounts must share the TEST_TMP_BASE root.
115
+ // With NORSK_CTL_STORE_DIR set, config.yaml is read from <storeDir>/config.yaml.
116
+ // networkMode is the one required config.yaml field omitting it makes the
117
+ // whole file fail to parse (ConfigParseError), silently dropping the workdir
118
+ // override. Seed both.
119
+ const configPath = join(storeDir, "config.yaml");
120
+ if (!existsSync(configPath)) {
121
+ writeFileSync(configPath, `networkMode: docker\ndefaultWorkingDirectory: ${JSON.stringify(join(storeDir, "norsk-runtime"))}\n`);
122
+ }
92
123
  }
93
- const daemon = Bun.spawn([...cliCommand, "serve"], {
94
- stdout: "inherit",
95
- stderr: "inherit",
124
+ const [cmd, ...cmdArgs] = cliCommand;
125
+ const proc = spawn(cmd, [...cmdArgs, "serve"], {
126
+ stdio: "inherit",
127
+ detached: options?.detached === true,
96
128
  env: { ...process.env, NORSK_CTL_STORE_DIR: storeDir, NORSK_CTL_PORT: String(port), ...options?.env },
97
129
  });
98
130
  const ready = pollUntil(async () => {
99
131
  const res = await fetch(`http://localhost:${port}/api/ready`, { signal: AbortSignal.timeout(1000) });
100
132
  return res.ok;
101
- }, { timeoutMs: DAEMON_READY_TIMEOUT_MS, intervalMs: 500, label: "Daemon did not become ready" });
102
- return { daemon, ready };
133
+ }, {
134
+ timeoutMs: options?.readyTimeoutMs ?? DAEMON_READY_TIMEOUT_MS,
135
+ intervalMs: 500,
136
+ label: "Daemon did not become ready",
137
+ });
138
+ return { daemon: daemonHandle(proc), ready };
103
139
  }
104
140
  function runningContainers(names) {
105
141
  const result = spawnSync("docker", ["ps", "--format", "{{.Names}}"], { encoding: "utf-8" });
@@ -149,10 +185,10 @@ export async function cleanupDaemon(opts) {
149
185
  if (opts.daemon) {
150
186
  opts.daemon.kill();
151
187
  // Escalate to SIGKILL after 3s if process hasn't exited
152
- const race = Promise.race([opts.daemon.exited, Bun.sleep(3000).then(() => "timeout")]);
188
+ const race = Promise.race([opts.daemon.exited, sleep(3000).then(() => "timeout")]);
153
189
  if ((await race) === "timeout") {
154
190
  opts.daemon.kill(9);
155
- await Promise.race([opts.daemon.exited, Bun.sleep(2000)]);
191
+ await Promise.race([opts.daemon.exited, sleep(2000)]);
156
192
  }
157
193
  }
158
194
  await awaitContainerShutdown(opts.containers);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@norskvideo/ctl-test-harness",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./daemon": {
@@ -18,6 +18,25 @@
18
18
  "./harness-config": {
19
19
  "types": "./harness-config.d.ts",
20
20
  "default": "./harness-config.js"
21
+ },
22
+ "./studio-load": {
23
+ "types": "./studio-load.d.ts",
24
+ "default": "./studio-load.js"
25
+ }
26
+ },
27
+ "dependencies": {
28
+ "@norskvideo/ctl-sdk": "^0.1.0"
29
+ },
30
+ "peerDependencies": {
31
+ "@norskvideo/norsk-studio": "*",
32
+ "@norskvideo/norsk-studio-built-ins": "*"
33
+ },
34
+ "peerDependenciesMeta": {
35
+ "@norskvideo/norsk-studio": {
36
+ "optional": true
37
+ },
38
+ "@norskvideo/norsk-studio-built-ins": {
39
+ "optional": true
21
40
  }
22
41
  },
23
42
  "publishConfig": {
@@ -0,0 +1,11 @@
1
+ import { load } from "@norskvideo/norsk-studio/lib/runtime/document";
2
+ import { RuntimeSystem } from "@norskvideo/norsk-studio/lib/runtime/system";
3
+ export type CompiledDocument = ReturnType<typeof load>;
4
+ export interface StudioLoadOptions {
5
+ stubs?: string[];
6
+ register?: (runtime: RuntimeSystem) => void | Promise<void>;
7
+ filename?: string;
8
+ loadOptions?: Parameters<typeof load>[3];
9
+ }
10
+ export declare function loadsInStudio(yamlText: string, opts?: StudioLoadOptions): Promise<CompiledDocument>;
11
+ export declare function assertLoadsInStudio(yamlText: string, opts?: StudioLoadOptions): Promise<CompiledDocument>;
package/studio-load.js ADDED
@@ -0,0 +1,70 @@
1
+ // The cheap Layer 4 tier, packaged: run an emitted workflow YAML through
2
+ // Studio's REAL document.load() so every built-ins component's zod schema
3
+ // validates what the composer actually emits — not just the local factory
4
+ // typing. Synchronous validation path only; no Norsk media engine, no Docker.
5
+ //
6
+ // This generalises probe's _util/studio-load.ts (11 scenarios) so no product
7
+ // has to re-derive the fiddly parts:
8
+ // - built-ins registerAll sits at .default.default when bundled and .default
9
+ // when externalised; callableDefault tolerates both.
10
+ // - document.load() records "Unknown node type" and STOPS at the first
11
+ // component it can't resolve, so one custom component would mask every
12
+ // error after it. Out-of-library components (a product's own, or alpha
13
+ // ones absent from @norskvideo/norsk-studio-built-ins) are stub-registered
14
+ // via `stubs` — permissive, config not validated (there is no schema to
15
+ // validate against), passing input streams through so downstream built-ins
16
+ // still resolve subscriptions.
17
+ //
18
+ // Lives in ctl-test-harness (not dev-kit: it needs the norsk-studio packages,
19
+ // which are optional peers here — the products that use it already pin them;
20
+ // dev-kit stays dependency-lean).
21
+ import { callableDefault } from "@norskvideo/ctl-sdk";
22
+ import { load } from "@norskvideo/norsk-studio/lib/runtime/document";
23
+ import { RuntimeSystem } from "@norskvideo/norsk-studio/lib/runtime/system";
24
+ import * as builtInsModule from "@norskvideo/norsk-studio-built-ins";
25
+ const registerAll = callableDefault(builtInsModule, "@norskvideo/norsk-studio-built-ins");
26
+ function registerStub(runtime, identifier) {
27
+ const info = {
28
+ identifier,
29
+ name: identifier,
30
+ category: "Output",
31
+ // load() reads info.configForm.global (global-config map) and iterates
32
+ // info.configForm.form (per-field config patching); an empty non-global
33
+ // form is all a stub needs.
34
+ configForm: { global: false, form: {} },
35
+ subscription: {
36
+ accepts: {
37
+ type: "simple-stream",
38
+ video: true,
39
+ audio: true,
40
+ subtitle: true,
41
+ ancillary: true,
42
+ playlist: true,
43
+ acceptsTransient: true,
44
+ },
45
+ produces: { type: "dynamic-streams", streams: (_cfg, inputs) => inputs },
46
+ },
47
+ };
48
+ const definition = {
49
+ create: async () => { },
50
+ schemas: async () => ({ config: { type: "object", additionalProperties: true } }),
51
+ };
52
+ runtime.registerComponent(definition, info, "", undefined, "studio-load-stubs");
53
+ }
54
+ export async function loadsInStudio(yamlText, opts = {}) {
55
+ const runtime = new RuntimeSystem();
56
+ await registerAll(runtime);
57
+ for (const id of opts.stubs ?? [])
58
+ registerStub(runtime, id);
59
+ await opts.register?.(runtime);
60
+ return load(opts.filename ?? "workflow.yml", runtime, yamlText, { resolveConfig: true, ...opts.loadOptions });
61
+ }
62
+ // Throwing form for tests: one assertion per scenario, every load error listed.
63
+ export async function assertLoadsInStudio(yamlText, opts = {}) {
64
+ const compiled = await loadsInStudio(yamlText, opts);
65
+ if (compiled.errors.length > 0) {
66
+ const listed = compiled.errors.map((e, i) => ` [${i}] ${e.message}`).join("\n");
67
+ throw new Error(`${opts.filename ?? "workflow.yml"} failed Studio document.load():\n${listed}`);
68
+ }
69
+ return compiled;
70
+ }