@norskvideo/ctl-test-harness 0.1.5 → 0.1.7

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/launch.d.ts ADDED
@@ -0,0 +1,14 @@
1
+ /** The `uid:gid` the current process runs as, for docker `--container-user`, or
2
+ * undefined where POSIX uids don't exist (e.g. Windows, or Docker Desktop's VM
3
+ * which maps bind-mount ownership itself and needs no explicit spec). */
4
+ export declare function runnerContainerUser(): string | undefined;
5
+ /** Default a launch's `containerUser` to the runner's uid:gid so bind-mounted
6
+ * writes are user-owned, not root. An explicit containerUser always wins, and a
7
+ * platform without POSIX uids is left untouched (returns opts unchanged). The
8
+ * intersection parameter lets T absorb the caller's own launch-opts shape
9
+ * without constraining it, so any launch options object passes through typed. */
10
+ export declare function withContainerUser<T>(opts: T & {
11
+ containerUser?: string;
12
+ }): T & {
13
+ containerUser?: string;
14
+ };
package/launch.js ADDED
@@ -0,0 +1,32 @@
1
+ // A launched product instance bind-mounts host dirs into the Studio + media
2
+ // containers, which run as root by default -- so every file the container writes
3
+ // under the working directory lands root-owned on the host. On a CI runner the
4
+ // non-root user then cannot remove them, and the next checkout's `git clean`
5
+ // EACCESes on the leftover tree (worst on a shared runner pool, where an
6
+ // innocent sibling workflow inherits the mess). Launching the container as the
7
+ // runner's own uid:gid keeps those writes user-owned, so cleanup stays clean.
8
+ //
9
+ // This is the prevention half; @norskvideo/ctl-test-harness/daemon's
10
+ // cleanupDaemon is the tolerance half (root-nuke on teardown). Pairing the two
11
+ // is the harness's whole "launch clean, tear down clean" story.
12
+ /** The `uid:gid` the current process runs as, for docker `--container-user`, or
13
+ * undefined where POSIX uids don't exist (e.g. Windows, or Docker Desktop's VM
14
+ * which maps bind-mount ownership itself and needs no explicit spec). */
15
+ export function runnerContainerUser() {
16
+ const getuid = process.getuid?.bind(process);
17
+ const getgid = process.getgid?.bind(process);
18
+ if (!getuid || !getgid)
19
+ return undefined;
20
+ return `${getuid()}:${getgid()}`;
21
+ }
22
+ /** Default a launch's `containerUser` to the runner's uid:gid so bind-mounted
23
+ * writes are user-owned, not root. An explicit containerUser always wins, and a
24
+ * platform without POSIX uids is left untouched (returns opts unchanged). The
25
+ * intersection parameter lets T absorb the caller's own launch-opts shape
26
+ * without constraining it, so any launch options object passes through typed. */
27
+ export function withContainerUser(opts) {
28
+ if (opts.containerUser !== undefined)
29
+ return opts;
30
+ const user = runnerContainerUser();
31
+ return user === undefined ? opts : { ...opts, containerUser: user };
32
+ }
package/package.json CHANGED
@@ -1,12 +1,16 @@
1
1
  {
2
2
  "name": "@norskvideo/ctl-test-harness",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./daemon": {
7
7
  "types": "./daemon.d.ts",
8
8
  "default": "./daemon.js"
9
9
  },
10
+ "./launch": {
11
+ "types": "./launch.d.ts",
12
+ "default": "./launch.js"
13
+ },
10
14
  "./source-pump": {
11
15
  "types": "./source-pump.d.ts",
12
16
  "default": "./source-pump.js"