@norskvideo/ctl-test-harness 0.1.20 → 0.1.22
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/compose-advancing.d.ts +26 -0
- package/compose-advancing.js +61 -0
- package/container-logs.d.ts +48 -0
- package/container-logs.js +101 -0
- package/demo/cli.d.ts +22 -3
- package/demo/cli.js +136 -21
- package/demo/dev-loop-cli.d.ts +2 -0
- package/demo/dev-loop-cli.js +8 -0
- package/demo/dev-loop.d.ts +33 -0
- package/demo/dev-loop.js +205 -0
- package/demo/gates.d.ts +22 -0
- package/demo/gates.js +56 -0
- package/demo/index.d.ts +8 -3
- package/demo/index.js +4 -1
- package/demo/panes.d.ts +81 -0
- package/demo/panes.js +236 -0
- package/demo/run.d.ts +132 -5
- package/demo/run.js +386 -94
- package/demo/spec.d.ts +48 -6
- package/demo/spec.js +37 -11
- package/package.json +11 -2
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;
|
|
2
|
+
export declare const DEFAULT_COMPOSE_NODE = "video_compose";
|
|
3
|
+
/** The highest mediaIn across every node of that name; 0 when none. */
|
|
4
|
+
export declare function maxNodeMediaIn(summary: unknown, node: string): number;
|
|
5
|
+
/** One sample: the highest mediaIn of `node` across every root workflow under
|
|
6
|
+
* `base` (`.../visualiser`). An unreachable visualiser reads as 0 — the
|
|
7
|
+
* caller's second sample, not a throw, decides. */
|
|
8
|
+
export declare function composeFrames(opts: {
|
|
9
|
+
fetch: FetchLike;
|
|
10
|
+
base: string;
|
|
11
|
+
node?: string;
|
|
12
|
+
}): Promise<number>;
|
|
13
|
+
export interface ComposeAdvancingResult {
|
|
14
|
+
advancing: boolean;
|
|
15
|
+
before: number;
|
|
16
|
+
after: number;
|
|
17
|
+
node: string;
|
|
18
|
+
}
|
|
19
|
+
/** Two samples `settleMs` apart: advancing when the second is above the first
|
|
20
|
+
* and above zero — the fallback card and a stalled feed both read flat. */
|
|
21
|
+
export declare function composeAdvancing(opts: {
|
|
22
|
+
fetch: FetchLike;
|
|
23
|
+
base: string;
|
|
24
|
+
node?: string;
|
|
25
|
+
settleMs?: number;
|
|
26
|
+
}): Promise<ComposeAdvancingResult>;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Is a programme really reaching the compositor? The daemon reports a source
|
|
2
|
+
// "running" as soon as its ffmpeg sidecar is up, even if the SRT never
|
|
3
|
+
// connected or the frames stalled; the only honest signal is the compose
|
|
4
|
+
// node's own mediaIn climbing between two samples of the engine's visualiser
|
|
5
|
+
// (`/visualiser/workflow` -> rootWorkflows[].wfid -> `/workflow/<wfid>/summary`
|
|
6
|
+
// -> nodes[].mediaIn). Playout's scripts/ensure-sources.sh:52-64 as TypeScript
|
|
7
|
+
// (05-demo s7 step 2), pure over an injected fetch so it is unit-tested and
|
|
8
|
+
// reachable through whichever base the caller has: the daemon proxy's
|
|
9
|
+
// `/instance/<id>/visualiser`, or `<id>-media-1:6791/visualiser` on norsk-net.
|
|
10
|
+
export const DEFAULT_COMPOSE_NODE = "video_compose";
|
|
11
|
+
const DEFAULT_SETTLE_MS = 6000;
|
|
12
|
+
/** The highest mediaIn across every node of that name; 0 when none. */
|
|
13
|
+
export function maxNodeMediaIn(summary, node) {
|
|
14
|
+
const nodes = summary?.nodes;
|
|
15
|
+
if (!Array.isArray(nodes))
|
|
16
|
+
return 0;
|
|
17
|
+
let max = 0;
|
|
18
|
+
for (const n of nodes) {
|
|
19
|
+
if (n?.name !== node)
|
|
20
|
+
continue;
|
|
21
|
+
const v = Number(n.mediaIn);
|
|
22
|
+
if (Number.isFinite(v) && v > max)
|
|
23
|
+
max = v;
|
|
24
|
+
}
|
|
25
|
+
return max;
|
|
26
|
+
}
|
|
27
|
+
async function getJson(fetch, url) {
|
|
28
|
+
const r = await fetch(url, { signal: AbortSignal.timeout(5000) });
|
|
29
|
+
if (!r.ok)
|
|
30
|
+
throw new Error(`GET ${url} -> ${r.status}`);
|
|
31
|
+
return r.json();
|
|
32
|
+
}
|
|
33
|
+
/** One sample: the highest mediaIn of `node` across every root workflow under
|
|
34
|
+
* `base` (`.../visualiser`). An unreachable visualiser reads as 0 — the
|
|
35
|
+
* caller's second sample, not a throw, decides. */
|
|
36
|
+
export async function composeFrames(opts) {
|
|
37
|
+
const node = opts.node ?? DEFAULT_COMPOSE_NODE;
|
|
38
|
+
try {
|
|
39
|
+
const wf = (await getJson(opts.fetch, `${opts.base}/workflow`));
|
|
40
|
+
let max = 0;
|
|
41
|
+
for (const root of wf?.rootWorkflows ?? []) {
|
|
42
|
+
if (typeof root?.wfid !== "string")
|
|
43
|
+
continue;
|
|
44
|
+
const summary = await getJson(opts.fetch, `${opts.base}/workflow/${encodeURIComponent(root.wfid)}/summary`);
|
|
45
|
+
max = Math.max(max, maxNodeMediaIn(summary, node));
|
|
46
|
+
}
|
|
47
|
+
return max;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return 0;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** Two samples `settleMs` apart: advancing when the second is above the first
|
|
54
|
+
* and above zero — the fallback card and a stalled feed both read flat. */
|
|
55
|
+
export async function composeAdvancing(opts) {
|
|
56
|
+
const node = opts.node ?? DEFAULT_COMPOSE_NODE;
|
|
57
|
+
const before = await composeFrames({ fetch: opts.fetch, base: opts.base, node });
|
|
58
|
+
await new Promise((r) => setTimeout(r, opts.settleMs ?? DEFAULT_SETTLE_MS));
|
|
59
|
+
const after = await composeFrames({ fetch: opts.fetch, base: opts.base, node });
|
|
60
|
+
return { advancing: after > before && after > 0, before, after, node };
|
|
61
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export interface SpawnResult {
|
|
2
|
+
stdout: string;
|
|
3
|
+
stderr: string;
|
|
4
|
+
error?: Error;
|
|
5
|
+
}
|
|
6
|
+
export type SpawnLike = (cmd: string, args: string[], opts: {
|
|
7
|
+
encoding: "utf8";
|
|
8
|
+
timeout: number;
|
|
9
|
+
}) => SpawnResult;
|
|
10
|
+
/** `docker ps` argv listing "<name>\t<state>" for ONE instance's containers. */
|
|
11
|
+
export declare function instanceContainersArgs(instanceId: string): string[];
|
|
12
|
+
/** `docker logs` argv tailing one container. Combined stdout+stderr at the call site. */
|
|
13
|
+
export declare function containerLogArgs(container: string, tail?: number): string[];
|
|
14
|
+
export interface ContainerLog {
|
|
15
|
+
name: string;
|
|
16
|
+
state: string;
|
|
17
|
+
log: string;
|
|
18
|
+
}
|
|
19
|
+
/** Parse `docker ps --format "{{.Names}}\t{{.State}}"` output into rows. */
|
|
20
|
+
export declare function parseContainerRows(psStdout: string): {
|
|
21
|
+
name: string;
|
|
22
|
+
state: string;
|
|
23
|
+
}[];
|
|
24
|
+
/** Pure: renders captured logs as an indented block per container. */
|
|
25
|
+
export declare function formatContainerLogs(instanceId: string, entries: ContainerLog[]): string;
|
|
26
|
+
/** Tail every container of one instance. Best-effort: docker unreachable is
|
|
27
|
+
* an empty list, never a throw, so a diagnostic cannot mask what it describes. */
|
|
28
|
+
export declare function collectInstanceContainerLogs(instanceId: string, tail?: number, spawn?: SpawnLike): ContainerLog[];
|
|
29
|
+
/** The ready-to-print diagnostic block (empty-marker when nothing is found). */
|
|
30
|
+
export declare function dumpInstanceContainerLogs(instanceId: string, tail?: number, spawn?: SpawnLike): string;
|
|
31
|
+
export interface LogHit {
|
|
32
|
+
container: string;
|
|
33
|
+
line: string;
|
|
34
|
+
}
|
|
35
|
+
/** A string forbids a substring; a RegExp is tested per line. */
|
|
36
|
+
export type LogPattern = string | RegExp;
|
|
37
|
+
/** Every line, in any container of the instance, that matches a forbidden
|
|
38
|
+
* pattern. `clean` when there are none — including when docker cannot be
|
|
39
|
+
* reached, which is absence of evidence, not a failure of the thing probed. */
|
|
40
|
+
export declare function containerLogsClean(opts: {
|
|
41
|
+
instanceId: string;
|
|
42
|
+
forbid: readonly LogPattern[];
|
|
43
|
+
tail?: number;
|
|
44
|
+
spawn?: SpawnLike;
|
|
45
|
+
}): {
|
|
46
|
+
clean: boolean;
|
|
47
|
+
hits: LogHit[];
|
|
48
|
+
};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// A launched instance's container logs, by the `norsk-ctl.instance=<id>`
|
|
2
|
+
// label every per-instance container carries (norsk-ctl docker/constants.ts)
|
|
3
|
+
// — so it finds them under either naming scheme (`<id>-media-1` on the
|
|
4
|
+
// released ctl, `norsk-inst-<id>-media` on older ones). Lifted from funke's
|
|
5
|
+
// tests/integration/support/container-logs.ts (05-demo s7 step 2): the
|
|
6
|
+
// diagnostic dump, plus containerLogsClean — iterate.sh's segfault guard
|
|
7
|
+
// generalised to "no forbidden line in any container of this instance".
|
|
8
|
+
// Pure argv/parse/format helpers; only the two exported probes shell out,
|
|
9
|
+
// through an injectable spawn.
|
|
10
|
+
import { spawnSync } from "node:child_process";
|
|
11
|
+
const defaultSpawn = (cmd, args, opts) => {
|
|
12
|
+
const r = spawnSync(cmd, args, opts);
|
|
13
|
+
return {
|
|
14
|
+
stdout: typeof r.stdout === "string" ? r.stdout : "",
|
|
15
|
+
stderr: typeof r.stderr === "string" ? r.stderr : "",
|
|
16
|
+
...(r.error ? { error: r.error } : {}),
|
|
17
|
+
};
|
|
18
|
+
};
|
|
19
|
+
const DEFAULT_TAIL = 80;
|
|
20
|
+
/** `docker ps` argv listing "<name>\t<state>" for ONE instance's containers. */
|
|
21
|
+
export function instanceContainersArgs(instanceId) {
|
|
22
|
+
return ["ps", "-a", "--filter", `label=norsk-ctl.instance=${instanceId}`, "--format", "{{.Names}}\t{{.State}}"];
|
|
23
|
+
}
|
|
24
|
+
/** `docker logs` argv tailing one container. Combined stdout+stderr at the call site. */
|
|
25
|
+
export function containerLogArgs(container, tail = DEFAULT_TAIL) {
|
|
26
|
+
return ["logs", "--tail", String(tail), container];
|
|
27
|
+
}
|
|
28
|
+
/** Parse `docker ps --format "{{.Names}}\t{{.State}}"` output into rows. */
|
|
29
|
+
export function parseContainerRows(psStdout) {
|
|
30
|
+
return psStdout
|
|
31
|
+
.split("\n")
|
|
32
|
+
.map((l) => l.trim())
|
|
33
|
+
.filter(Boolean)
|
|
34
|
+
.map((l) => {
|
|
35
|
+
const [name, state] = l.split("\t");
|
|
36
|
+
return { name: name ?? "?", state: state ?? "?" };
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
/** Pure: renders captured logs as an indented block per container. */
|
|
40
|
+
export function formatContainerLogs(instanceId, entries) {
|
|
41
|
+
if (entries.length === 0) {
|
|
42
|
+
return ` container logs for ${instanceId}: <none found / docker unreachable>`;
|
|
43
|
+
}
|
|
44
|
+
return entries
|
|
45
|
+
.map((e) => {
|
|
46
|
+
const body = e.log.trim() === ""
|
|
47
|
+
? " <empty>"
|
|
48
|
+
: e.log
|
|
49
|
+
.trimEnd()
|
|
50
|
+
.split("\n")
|
|
51
|
+
.map((l) => ` ${l}`)
|
|
52
|
+
.join("\n");
|
|
53
|
+
return ` --- ${e.name} (${e.state}) ---\n${body}`;
|
|
54
|
+
})
|
|
55
|
+
.join("\n");
|
|
56
|
+
}
|
|
57
|
+
/** Tail every container of one instance. Best-effort: docker unreachable is
|
|
58
|
+
* an empty list, never a throw, so a diagnostic cannot mask what it describes. */
|
|
59
|
+
export function collectInstanceContainerLogs(instanceId, tail = DEFAULT_TAIL, spawn = defaultSpawn) {
|
|
60
|
+
let rows = [];
|
|
61
|
+
try {
|
|
62
|
+
const ps = spawn("docker", instanceContainersArgs(instanceId), { encoding: "utf8", timeout: 15_000 });
|
|
63
|
+
if (ps.error)
|
|
64
|
+
return [];
|
|
65
|
+
rows = parseContainerRows(ps.stdout);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
const entries = [];
|
|
71
|
+
for (const row of rows) {
|
|
72
|
+
let log = "";
|
|
73
|
+
try {
|
|
74
|
+
const r = spawn("docker", containerLogArgs(row.name, tail), { encoding: "utf8", timeout: 15_000 });
|
|
75
|
+
log = r.error ? "<docker logs failed>" : `${r.stdout}${r.stderr}`;
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
log = "<docker logs failed>";
|
|
79
|
+
}
|
|
80
|
+
entries.push({ name: row.name, state: row.state, log });
|
|
81
|
+
}
|
|
82
|
+
return entries;
|
|
83
|
+
}
|
|
84
|
+
/** The ready-to-print diagnostic block (empty-marker when nothing is found). */
|
|
85
|
+
export function dumpInstanceContainerLogs(instanceId, tail = DEFAULT_TAIL, spawn = defaultSpawn) {
|
|
86
|
+
return formatContainerLogs(instanceId, collectInstanceContainerLogs(instanceId, tail, spawn));
|
|
87
|
+
}
|
|
88
|
+
const matches = (pattern, line) => typeof pattern === "string" ? line.includes(pattern) : pattern.test(line);
|
|
89
|
+
/** Every line, in any container of the instance, that matches a forbidden
|
|
90
|
+
* pattern. `clean` when there are none — including when docker cannot be
|
|
91
|
+
* reached, which is absence of evidence, not a failure of the thing probed. */
|
|
92
|
+
export function containerLogsClean(opts) {
|
|
93
|
+
const hits = [];
|
|
94
|
+
for (const entry of collectInstanceContainerLogs(opts.instanceId, opts.tail ?? DEFAULT_TAIL, opts.spawn)) {
|
|
95
|
+
for (const line of entry.log.split("\n")) {
|
|
96
|
+
if (opts.forbid.some((p) => matches(p, line)))
|
|
97
|
+
hits.push({ container: entry.name, line });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return { clean: hits.length === 0, hits };
|
|
101
|
+
}
|
package/demo/cli.d.ts
CHANGED
|
@@ -1,12 +1,22 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
+
import { type DevLoopOptions, type DevLoopResult } from "./dev-loop.js";
|
|
3
|
+
import { type DemoUi, type PaneLayout } from "./panes.js";
|
|
2
4
|
import { type DemoRunOptions, type DemoRunResult, demoPorts } from "./run.js";
|
|
3
|
-
import type { DemoSpec } from "./spec.js";
|
|
5
|
+
import type { DemoDaemonPolicy, DemoMode, DemoSpec } from "./spec.js";
|
|
4
6
|
export interface DemoArgs {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
+
/** `dev-loop <action>`: the standalone tier's verbs. */
|
|
8
|
+
devLoop: boolean;
|
|
9
|
+
action: "up" | "check" | "down" | "spec" | "ui" | "refresh";
|
|
10
|
+
/** Absent: the spec's `mode`, else dev. */
|
|
11
|
+
mode?: DemoMode | "standalone";
|
|
12
|
+
daemon: DemoDaemonPolicy;
|
|
7
13
|
exportOnly: boolean;
|
|
8
14
|
json: boolean;
|
|
9
15
|
spec: string;
|
|
16
|
+
/** `ui` only; zellij unless said. */
|
|
17
|
+
ui?: DemoUi;
|
|
18
|
+
/** Default: PUBLIC_HOST in the environment. */
|
|
19
|
+
publicHost?: string;
|
|
10
20
|
}
|
|
11
21
|
export declare function parseDemoArgs(argv: string[]): DemoArgs;
|
|
12
22
|
export interface ResolvedSpecView {
|
|
@@ -27,6 +37,8 @@ export interface ResolvedSpecView {
|
|
|
27
37
|
};
|
|
28
38
|
launch?: DemoSpec["launch"];
|
|
29
39
|
prerequisites?: DemoSpec["prerequisites"];
|
|
40
|
+
extras: string[];
|
|
41
|
+
beforeLaunch: boolean;
|
|
30
42
|
sources: Array<{
|
|
31
43
|
name: string;
|
|
32
44
|
asset: NonNullable<DemoSpec["sources"]>[number]["asset"];
|
|
@@ -54,6 +66,7 @@ export interface ResolvedSpecView {
|
|
|
54
66
|
export declare function resolvedSpecView(spec: DemoSpec, opts: {
|
|
55
67
|
mode: string;
|
|
56
68
|
slug?: string;
|
|
69
|
+
publicHost?: string;
|
|
57
70
|
}): ResolvedSpecView;
|
|
58
71
|
/** Everything `demoMain` touches, injectable so the dispatch is unit-tested. */
|
|
59
72
|
export interface DemoCliIo {
|
|
@@ -66,6 +79,12 @@ export interface DemoCliIo {
|
|
|
66
79
|
exportDir: string;
|
|
67
80
|
}>;
|
|
68
81
|
demoDown(product: string): Promise<void>;
|
|
82
|
+
runDevLoop(spec: DemoSpec, opts: DevLoopOptions): Promise<DevLoopResult>;
|
|
83
|
+
devLoopDown(product: string): Promise<void>;
|
|
84
|
+
/** Open the multiplexer on the rendered layout; returns its exit code. */
|
|
85
|
+
launchUi(ui: DemoUi, layout: PaneLayout): number;
|
|
86
|
+
/** For the standalone dirs (NORSK_DIR, STUDIO_DIR, TO, HOME). */
|
|
87
|
+
env?: Record<string, string | undefined>;
|
|
69
88
|
stdout(line: string): void;
|
|
70
89
|
stderr(line: string): void;
|
|
71
90
|
abort?: AbortSignal;
|
package/demo/cli.js
CHANGED
|
@@ -9,34 +9,80 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
|
|
|
9
9
|
};
|
|
10
10
|
// `ctl-demo`, the bin behind every product's `bun run demo` (05-demo s4):
|
|
11
11
|
//
|
|
12
|
-
// demo up [--mode dev]
|
|
13
|
-
//
|
|
12
|
+
// demo up [--mode dev|image] [--daemon private|reuse]
|
|
13
|
+
// launch, print the URLs, hold; Ctrl-C tears down
|
|
14
|
+
// demo check [--mode dev|image] the same, then tear down and exit 0/1 (what CI runs)
|
|
14
15
|
// demo check --mode standalone --export-only build + export-workdir + every symlink resolves
|
|
15
16
|
// demo down tear down what `up` recorded, from any shell
|
|
16
17
|
// demo spec [--json] the resolved spec: ports, template, sources, URLs
|
|
18
|
+
// demo ui [--ui zellij|tmux|none] [...] the same in a multiplexer: a gate pane running `demo up`,
|
|
19
|
+
// the instance's logs, a shell (05-demo s4 front-end 2)
|
|
20
|
+
// demo dev-loop up|refresh|down|ui the full standalone tier (engine + Studio from source);
|
|
21
|
+
// `ctl-dev-loop <verb>` is the same under a product's `bun run dev-loop`
|
|
17
22
|
//
|
|
18
23
|
// The spec is `tests/demo.spec.ts` in the cwd (`--spec <path>` otherwise), a
|
|
19
24
|
// TypeScript module whose default export is `defineDemo({...})`. This runs
|
|
20
25
|
// under bun only: the spec is TypeScript and so is every product's dev shell.
|
|
21
26
|
import { resolve } from "node:path";
|
|
27
|
+
import { devLoopDown, runDevLoop } from "./dev-loop.js";
|
|
28
|
+
import { demoPaneList, devLoopPaneList, launchLayout, renderLayout, standaloneDirs, } from "./panes.js";
|
|
22
29
|
import { demoDown, demoPorts, demoSlug, runDemo, runExportCheck, } from "./run.js";
|
|
23
|
-
const USAGE = `usage: demo <up|check|down|spec> [--mode dev|standalone] [--export-only] [--json]
|
|
30
|
+
const USAGE = `usage: demo <up|check|down|spec|ui> [--mode dev|image|standalone] [--daemon private|reuse] [--export-only] [--json]
|
|
31
|
+
[--ui zellij|tmux|none] [--public-host <host>] [--spec <path>]
|
|
32
|
+
demo dev-loop <up|refresh|down|ui> [--ui zellij|tmux|none] [--public-host <host>] [--spec <path>]
|
|
24
33
|
|
|
25
|
-
up launch
|
|
26
|
-
check up, then tear down and exit 0/1 — what CI runs
|
|
34
|
+
up launch, print the URLs, hold (Ctrl-C tears down)
|
|
35
|
+
check up, then tear down and exit 0/1 — what CI runs, always on a private daemon
|
|
27
36
|
check --mode standalone --export-only
|
|
28
37
|
build the template, export the standalone workdir, assert every symlink resolves
|
|
29
38
|
down tear down what \`up\` recorded
|
|
30
39
|
spec [--json] print the resolved spec without launching anything
|
|
40
|
+
ui open a multiplexer: a pane running \`up\`, the instance's logs, a shell
|
|
31
41
|
|
|
42
|
+
dev-loop up the standalone tier: private daemon + dev backend, template built from source,
|
|
43
|
+
workdir exported with the live-source links (NORSK_DIR, STUDIO_DIR, TO name the
|
|
44
|
+
engine and Studio checkouts and the workdir); holds
|
|
45
|
+
dev-loop refresh rebuild + re-export against the held \`up\` (after a component or dashboard edit)
|
|
46
|
+
dev-loop down end the held \`up\` from any shell; the workdir stays
|
|
47
|
+
dev-loop ui open a multiplexer: engine, \`dev-loop up\`, Studio, the sources (on Enter), a shell
|
|
48
|
+
|
|
49
|
+
--mode dev run the product from source (bun run dev) and add it by URL
|
|
50
|
+
--mode image add the built product image — the customer path
|
|
51
|
+
--daemon private a throwaway daemon on its own store (default)
|
|
52
|
+
--daemon reuse your listening daemon and real store; the driver deletes the instance,
|
|
53
|
+
removes the template and product, then adds again (stored templates
|
|
54
|
+
are immutable by name)
|
|
55
|
+
--ui zellij|tmux|none the multiplexer (\`none\` prints the pane commands); zellij unless said
|
|
56
|
+
--public-host <host> the name other machines reach this box by: every URL, the daemon's
|
|
57
|
+
publicHost, Studio's MoQ preview (default: PUBLIC_HOST)
|
|
32
58
|
--spec <path> the demo spec (default tests/demo.spec.ts)`;
|
|
33
59
|
const DEFAULT_SPEC = "tests/demo.spec.ts";
|
|
60
|
+
const DEMO_ACTIONS = ["up", "check", "down", "spec", "ui"];
|
|
61
|
+
const DEV_LOOP_ACTIONS = ["up", "refresh", "down", "ui"];
|
|
34
62
|
export function parseDemoArgs(argv) {
|
|
35
|
-
const [
|
|
36
|
-
|
|
63
|
+
const devLoop = argv[0] === "dev-loop";
|
|
64
|
+
const [action, ...rest] = devLoop ? argv.slice(1) : argv;
|
|
65
|
+
const actions = devLoop ? DEV_LOOP_ACTIONS : DEMO_ACTIONS;
|
|
66
|
+
if (action === undefined || !actions.includes(action)) {
|
|
67
|
+
if (devLoop)
|
|
68
|
+
throw new Error(`dev-loop takes one of up|refresh|down|ui${action ? `, not '${action}'` : ""}\n${USAGE}`);
|
|
69
|
+
if (action === "refresh")
|
|
70
|
+
throw new Error(`refresh is the dev-loop's: \`dev-loop refresh\`\n${USAGE}`);
|
|
37
71
|
throw new Error(action ? `unknown action '${action}'\n${USAGE}` : USAGE);
|
|
38
72
|
}
|
|
39
|
-
const args = {
|
|
73
|
+
const args = {
|
|
74
|
+
devLoop,
|
|
75
|
+
action: action,
|
|
76
|
+
daemon: "private",
|
|
77
|
+
exportOnly: false,
|
|
78
|
+
json: false,
|
|
79
|
+
spec: DEFAULT_SPEC,
|
|
80
|
+
};
|
|
81
|
+
if (action === "ui")
|
|
82
|
+
args.ui = "zellij";
|
|
83
|
+
const envHost = process.env.PUBLIC_HOST;
|
|
84
|
+
if (envHost)
|
|
85
|
+
args.publicHost = envHost;
|
|
40
86
|
for (let i = 0; i < rest.length; i++) {
|
|
41
87
|
const flag = rest[i];
|
|
42
88
|
const value = () => {
|
|
@@ -48,13 +94,18 @@ export function parseDemoArgs(argv) {
|
|
|
48
94
|
switch (flag) {
|
|
49
95
|
case "--mode": {
|
|
50
96
|
const m = value();
|
|
51
|
-
if (m
|
|
52
|
-
throw new Error("--mode image is not yet implemented (05-demo s7 step 2); use --mode dev");
|
|
53
|
-
if (m !== "dev" && m !== "standalone")
|
|
97
|
+
if (m !== "dev" && m !== "image" && m !== "standalone")
|
|
54
98
|
throw new Error(`unknown mode '${m}'\n${USAGE}`);
|
|
55
99
|
args.mode = m;
|
|
56
100
|
break;
|
|
57
101
|
}
|
|
102
|
+
case "--daemon": {
|
|
103
|
+
const d = value();
|
|
104
|
+
if (d !== "private" && d !== "reuse")
|
|
105
|
+
throw new Error(`unknown daemon policy '${d}'\n${USAGE}`);
|
|
106
|
+
args.daemon = d;
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
58
109
|
case "--export-only":
|
|
59
110
|
args.exportOnly = true;
|
|
60
111
|
break;
|
|
@@ -64,16 +115,34 @@ export function parseDemoArgs(argv) {
|
|
|
64
115
|
case "--spec":
|
|
65
116
|
args.spec = value();
|
|
66
117
|
break;
|
|
118
|
+
case "--ui": {
|
|
119
|
+
const u = value();
|
|
120
|
+
if (u !== "zellij" && u !== "tmux" && u !== "none")
|
|
121
|
+
throw new Error(`unknown ui '${u}'\n${USAGE}`);
|
|
122
|
+
args.ui = u;
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
case "--public-host":
|
|
126
|
+
args.publicHost = value();
|
|
127
|
+
break;
|
|
67
128
|
default:
|
|
68
129
|
throw new Error(`unknown flag '${flag}'\n${USAGE}`);
|
|
69
130
|
}
|
|
70
131
|
}
|
|
71
|
-
if (args.
|
|
72
|
-
throw new Error("
|
|
132
|
+
if (args.ui !== undefined && args.action !== "ui")
|
|
133
|
+
throw new Error("--ui belongs to ui (or dev-loop ui)");
|
|
134
|
+
if (devLoop && (args.mode !== undefined || args.daemon !== "private" || args.exportOnly || args.json)) {
|
|
135
|
+
throw new Error("dev-loop takes only --ui, --public-host and --spec (no --mode, --daemon, --export-only or --json): it is always the source tier on a private daemon");
|
|
136
|
+
}
|
|
137
|
+
if (args.mode === "standalone" && !args.exportOnly && args.action !== "spec") {
|
|
138
|
+
throw new Error(`the full standalone tier is the dev-loop's: \`dev-loop ${args.action === "ui" ? "ui" : "up"}\` (this mode's headless gate is \`check --mode standalone --export-only\`)`);
|
|
73
139
|
}
|
|
74
140
|
if (args.exportOnly && args.mode !== "standalone") {
|
|
75
141
|
throw new Error("--export-only belongs to --mode standalone");
|
|
76
142
|
}
|
|
143
|
+
if (args.action === "check" && args.daemon === "reuse") {
|
|
144
|
+
throw new Error("check runs on a private daemon (it is what CI runs); to reload your own daemon use `up --daemon reuse`");
|
|
145
|
+
}
|
|
77
146
|
return args;
|
|
78
147
|
}
|
|
79
148
|
/** `demo spec --json`: what will run, resolved as far as it can be without a
|
|
@@ -83,16 +152,17 @@ export function resolvedSpecView(spec, opts) {
|
|
|
83
152
|
const slug = opts.slug ?? demoSlug(spec.product);
|
|
84
153
|
const instanceId = `demo-${slug}`;
|
|
85
154
|
const ports = demoPorts(slug);
|
|
155
|
+
const host = opts.publicHost ?? "localhost";
|
|
86
156
|
const pinned = spec.launch?.params?.STUDIO_HOST_PORT;
|
|
87
157
|
const studioHostPort = pinned !== undefined ? Number(pinned) : ports.studioHostPort;
|
|
88
158
|
const url = (ref) => {
|
|
89
159
|
if ("url" in ref)
|
|
90
160
|
return ref.url;
|
|
91
161
|
if ("control" in ref)
|
|
92
|
-
return `http
|
|
162
|
+
return `http://${host}:${ports.backendPort}${ref.control}`;
|
|
93
163
|
if ("proxy" in ref)
|
|
94
|
-
return `http
|
|
95
|
-
return `http
|
|
164
|
+
return `http://${host}:${ports.proxyPort}${ref.proxy.replaceAll("{id}", instanceId)}`;
|
|
165
|
+
return `http://${host}:${studioHostPort}${ref.studio}`;
|
|
96
166
|
};
|
|
97
167
|
const t = spec.template;
|
|
98
168
|
const template = t && "build" in t
|
|
@@ -109,6 +179,8 @@ export function resolvedSpecView(spec, opts) {
|
|
|
109
179
|
template,
|
|
110
180
|
...(spec.launch !== undefined ? { launch: spec.launch } : {}),
|
|
111
181
|
...(spec.prerequisites !== undefined ? { prerequisites: spec.prerequisites } : {}),
|
|
182
|
+
extras: (spec.extras ?? []).map((e) => e.name),
|
|
183
|
+
beforeLaunch: spec.beforeLaunch !== undefined,
|
|
112
184
|
sources: (spec.sources ?? []).map((s) => ({
|
|
113
185
|
name: s.name,
|
|
114
186
|
asset: s.asset ?? { preset: "camera1" },
|
|
@@ -158,26 +230,60 @@ export async function demoMain(argv, io) {
|
|
|
158
230
|
}
|
|
159
231
|
return 1;
|
|
160
232
|
}
|
|
233
|
+
const mode = args.mode ?? spec.mode ?? "dev";
|
|
234
|
+
const slug = demoSlug(spec.product);
|
|
235
|
+
const publicHost = args.publicHost !== undefined ? { publicHost: args.publicHost } : {};
|
|
161
236
|
try {
|
|
237
|
+
if (args.devLoop) {
|
|
238
|
+
const dirs = standaloneDirs(spec, slug, io.env ?? process.env);
|
|
239
|
+
switch (args.action) {
|
|
240
|
+
case "up":
|
|
241
|
+
case "refresh":
|
|
242
|
+
await io.runDevLoop(spec, {
|
|
243
|
+
action: args.action,
|
|
244
|
+
cwd: io.cwd,
|
|
245
|
+
dirs,
|
|
246
|
+
...publicHost,
|
|
247
|
+
...(io.abort ? { abort: io.abort } : {}),
|
|
248
|
+
});
|
|
249
|
+
return 0;
|
|
250
|
+
case "down":
|
|
251
|
+
await io.devLoopDown(spec.product);
|
|
252
|
+
return 0;
|
|
253
|
+
default: {
|
|
254
|
+
const panes = devLoopPaneList(spec, { cwd: io.cwd, slug, ...dirs, ...publicHost });
|
|
255
|
+
return io.launchUi(args.ui ?? "zellij", { session: `${slug}-dev-loop`, tab: slug, panes });
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
162
259
|
switch (args.action) {
|
|
163
260
|
case "spec": {
|
|
164
|
-
const view = resolvedSpecView(spec, { mode
|
|
261
|
+
const view = resolvedSpecView(spec, { mode, ...publicHost });
|
|
165
262
|
io.stdout(args.json ? JSON.stringify(view, null, 2) : renderView(view));
|
|
166
263
|
return 0;
|
|
167
264
|
}
|
|
168
265
|
case "down":
|
|
169
266
|
await io.demoDown(spec.product);
|
|
170
267
|
return 0;
|
|
171
|
-
case "
|
|
172
|
-
|
|
268
|
+
case "ui": {
|
|
269
|
+
if (mode === "standalone")
|
|
270
|
+
throw new Error("the standalone tier's multiplexer is `dev-loop ui`");
|
|
271
|
+
const panes = demoPaneList(spec, { cwd: io.cwd, slug, mode, daemon: args.daemon, ...publicHost });
|
|
272
|
+
return io.launchUi(args.ui ?? "zellij", { session: `${slug}-demo`, tab: slug, panes });
|
|
273
|
+
}
|
|
274
|
+
default: {
|
|
173
275
|
if (args.exportOnly) {
|
|
174
276
|
await io.runExportCheck(spec, { cwd: io.cwd });
|
|
175
277
|
return 0;
|
|
176
278
|
}
|
|
279
|
+
if (mode === "standalone")
|
|
280
|
+
throw new Error("the full standalone tier is `dev-loop up`");
|
|
177
281
|
await io.runDemo(spec, {
|
|
178
282
|
action: args.action,
|
|
179
|
-
mode
|
|
283
|
+
mode,
|
|
284
|
+
daemon: args.daemon,
|
|
180
285
|
cwd: io.cwd,
|
|
286
|
+
...publicHost,
|
|
181
287
|
...(io.abort ? { abort: io.abort } : {}),
|
|
182
288
|
});
|
|
183
289
|
return 0;
|
|
@@ -185,7 +291,7 @@ export async function demoMain(argv, io) {
|
|
|
185
291
|
}
|
|
186
292
|
}
|
|
187
293
|
catch (e) {
|
|
188
|
-
io.stderr(
|
|
294
|
+
io.stderr(`${args.devLoop ? "dev-loop" : "demo"} ${args.action} failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
189
295
|
return 1;
|
|
190
296
|
}
|
|
191
297
|
}
|
|
@@ -195,6 +301,10 @@ function renderView(v) {
|
|
|
195
301
|
` daemon :${v.ports.daemonPort} dev backend :${v.ports.backendPort} proxy :${v.ports.proxyPort} studio :${v.ports.studioHostPort}`,
|
|
196
302
|
` template ${"name" in v.template ? v.template.name : `build ${v.template.build.name}`}`,
|
|
197
303
|
];
|
|
304
|
+
if (v.image)
|
|
305
|
+
lines.push(` image ${v.image}`);
|
|
306
|
+
for (const e of v.extras)
|
|
307
|
+
lines.push(` extra ${e}`);
|
|
198
308
|
for (const s of v.sources)
|
|
199
309
|
lines.push(` source ${s.name}: ${JSON.stringify(s.asset)} -> ingest ${JSON.stringify(s.ingest)}`);
|
|
200
310
|
for (const g of v.ready)
|
|
@@ -216,12 +326,17 @@ export function defaultDemoCliIo() {
|
|
|
216
326
|
const release = () => controller.abort();
|
|
217
327
|
process.once("SIGINT", release);
|
|
218
328
|
process.once("SIGTERM", release);
|
|
329
|
+
// A multiplexer session ending sends its panes SIGHUP: that is a release too.
|
|
330
|
+
process.once("SIGHUP", release);
|
|
219
331
|
return {
|
|
220
332
|
cwd: process.cwd(),
|
|
221
333
|
loadSpec: loadSpecModule,
|
|
222
334
|
runDemo: (spec, opts) => runDemo(spec, opts),
|
|
223
335
|
runExportCheck: (spec, opts) => runExportCheck(spec, opts),
|
|
224
336
|
demoDown: (product) => demoDown(product),
|
|
337
|
+
runDevLoop: (spec, opts) => runDevLoop(spec, opts),
|
|
338
|
+
devLoopDown: (product) => devLoopDown(product),
|
|
339
|
+
launchUi: (ui, layout) => launchLayout(ui, renderLayout(layout, ui), layout),
|
|
225
340
|
stdout: (line) => console.log(line),
|
|
226
341
|
stderr: (line) => console.error(line),
|
|
227
342
|
abort: controller.signal,
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// `ctl-dev-loop`, the bin behind a product's `bun run dev-loop`: the
|
|
3
|
+
// standalone tier's verbs (`up`, `refresh`, `down`, `ui`) are `ctl-demo
|
|
4
|
+
// dev-loop <verb>` under another name.
|
|
5
|
+
import { defaultDemoCliIo, demoMain } from "./cli.js";
|
|
6
|
+
if (import.meta.main) {
|
|
7
|
+
process.exit(await demoMain(["dev-loop", ...process.argv.slice(2)], defaultDemoCliIo()));
|
|
8
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { SourceAsset } from "../source-pump.js";
|
|
2
|
+
import { type StandaloneDirs } from "./panes.js";
|
|
3
|
+
import { type DemoDeps, type DemoTimeouts } from "./run.js";
|
|
4
|
+
import type { DemoSpec } from "./spec.js";
|
|
5
|
+
export interface DevLoopOptions {
|
|
6
|
+
action: "up" | "refresh";
|
|
7
|
+
cwd: string;
|
|
8
|
+
slug?: string;
|
|
9
|
+
/** Default: `standaloneDirs(spec, slug)` — the environment, the spec, the conventions. */
|
|
10
|
+
dirs?: StandaloneDirs;
|
|
11
|
+
publicHost?: string;
|
|
12
|
+
abort?: AbortSignal;
|
|
13
|
+
timeouts?: DemoTimeouts;
|
|
14
|
+
}
|
|
15
|
+
export interface DevLoopResult {
|
|
16
|
+
workdir: string;
|
|
17
|
+
studioUrl: string;
|
|
18
|
+
}
|
|
19
|
+
export interface StandaloneSource {
|
|
20
|
+
name: string;
|
|
21
|
+
port: number;
|
|
22
|
+
streamId: string;
|
|
23
|
+
asset: SourceAsset;
|
|
24
|
+
}
|
|
25
|
+
/** The pump script the sources pane runs on Enter: every source in
|
|
26
|
+
* parallel with the host's ffmpeg, into the from-source engine. A preset is
|
|
27
|
+
* fetched into the daemon's sample-media cache if it is not there yet; a
|
|
28
|
+
* mediaFile that is absent on this box pumps its fallback. */
|
|
29
|
+
export declare function pumpScript(sources: StandaloneSource[], fileExists: (path: string) => boolean): string;
|
|
30
|
+
export declare function runDevLoop(spec: DemoSpec, opts: DevLoopOptions, deps?: DemoDeps): Promise<DevLoopResult>;
|
|
31
|
+
/** `dev-loop down`: end the recorded run from any shell. The workdir stays —
|
|
32
|
+
* it is the developer's, and Studio may still be looking at it. */
|
|
33
|
+
export declare function devLoopDown(product: string, deps?: DemoDeps): Promise<void>;
|