@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 +24 -5
- package/daemon.js +75 -39
- package/package.json +20 -1
- package/studio-load.d.ts +11 -0
- package/studio-load.js +70 -0
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(
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
2
|
-
// so the package publishes cleanly: it spawns the
|
|
3
|
-
// (see cli-command.ts) and drives teardown
|
|
4
|
-
// carrying no dependency on norsk-ctl's
|
|
5
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
}, {
|
|
102
|
-
|
|
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,
|
|
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,
|
|
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.
|
|
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": {
|
package/studio-load.d.ts
ADDED
|
@@ -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
|
+
}
|