@norskvideo/ctl-test-harness 0.1.21 → 0.1.23
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 +14 -2
- package/compose-advancing.js +26 -10
- package/demo/cli.d.ts +16 -1
- package/demo/cli.js +105 -16
- 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.js +3 -1
- package/demo/index.d.ts +6 -2
- package/demo/index.js +3 -1
- package/demo/panes.d.ts +81 -0
- package/demo/panes.js +236 -0
- package/demo/run.d.ts +96 -3
- package/demo/run.js +49 -29
- package/demo/spec.d.ts +12 -2
- package/demo/spec.js +20 -11
- package/package.json +3 -2
package/compose-advancing.d.ts
CHANGED
|
@@ -2,9 +2,19 @@ export type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;
|
|
|
2
2
|
export declare const DEFAULT_COMPOSE_NODE = "video_compose";
|
|
3
3
|
/** The highest mediaIn across every node of that name; 0 when none. */
|
|
4
4
|
export declare function maxNodeMediaIn(summary: unknown, node: string): number;
|
|
5
|
+
export interface ComposeSample {
|
|
6
|
+
frames: number;
|
|
7
|
+
/** Why the sample is 0 when it is: the visualiser was unreachable, and how. */
|
|
8
|
+
error?: string;
|
|
9
|
+
}
|
|
5
10
|
/** One sample: the highest mediaIn of `node` across every root workflow under
|
|
6
|
-
* `base` (`.../visualiser`). An unreachable visualiser reads as 0
|
|
7
|
-
* caller's second sample, not a throw, decides. */
|
|
11
|
+
* `base` (`.../visualiser`). An unreachable visualiser reads as 0 with the
|
|
12
|
+
* reason beside it — the caller's second sample, not a throw, decides. */
|
|
13
|
+
export declare function composeSample(opts: {
|
|
14
|
+
fetch: FetchLike;
|
|
15
|
+
base: string;
|
|
16
|
+
node?: string;
|
|
17
|
+
}): Promise<ComposeSample>;
|
|
8
18
|
export declare function composeFrames(opts: {
|
|
9
19
|
fetch: FetchLike;
|
|
10
20
|
base: string;
|
|
@@ -15,6 +25,8 @@ export interface ComposeAdvancingResult {
|
|
|
15
25
|
before: number;
|
|
16
26
|
after: number;
|
|
17
27
|
node: string;
|
|
28
|
+
/** Set when a sample could not read the visualiser at all. */
|
|
29
|
+
error?: string;
|
|
18
30
|
}
|
|
19
31
|
/** Two samples `settleMs` apart: advancing when the second is above the first
|
|
20
32
|
* and above zero — the fallback card and a stalled feed both read flat. */
|
package/compose-advancing.js
CHANGED
|
@@ -28,12 +28,18 @@ async function getJson(fetch, url) {
|
|
|
28
28
|
const r = await fetch(url, { signal: AbortSignal.timeout(5000) });
|
|
29
29
|
if (!r.ok)
|
|
30
30
|
throw new Error(`GET ${url} -> ${r.status}`);
|
|
31
|
-
|
|
31
|
+
const text = await r.text();
|
|
32
|
+
try {
|
|
33
|
+
return JSON.parse(text);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
throw new Error(`GET ${url} -> ${r.status}, not JSON (a sign-in page? the route needs no auth): ${text.slice(0, 80)}`);
|
|
37
|
+
}
|
|
32
38
|
}
|
|
33
39
|
/** One sample: the highest mediaIn of `node` across every root workflow under
|
|
34
|
-
* `base` (`.../visualiser`). An unreachable visualiser reads as 0
|
|
35
|
-
* caller's second sample, not a throw, decides. */
|
|
36
|
-
export async function
|
|
40
|
+
* `base` (`.../visualiser`). An unreachable visualiser reads as 0 with the
|
|
41
|
+
* reason beside it — the caller's second sample, not a throw, decides. */
|
|
42
|
+
export async function composeSample(opts) {
|
|
37
43
|
const node = opts.node ?? DEFAULT_COMPOSE_NODE;
|
|
38
44
|
try {
|
|
39
45
|
const wf = (await getJson(opts.fetch, `${opts.base}/workflow`));
|
|
@@ -44,18 +50,28 @@ export async function composeFrames(opts) {
|
|
|
44
50
|
const summary = await getJson(opts.fetch, `${opts.base}/workflow/${encodeURIComponent(root.wfid)}/summary`);
|
|
45
51
|
max = Math.max(max, maxNodeMediaIn(summary, node));
|
|
46
52
|
}
|
|
47
|
-
return max;
|
|
53
|
+
return { frames: max };
|
|
48
54
|
}
|
|
49
|
-
catch {
|
|
50
|
-
return 0;
|
|
55
|
+
catch (e) {
|
|
56
|
+
return { frames: 0, error: e instanceof Error ? e.message : String(e) };
|
|
51
57
|
}
|
|
52
58
|
}
|
|
59
|
+
export async function composeFrames(opts) {
|
|
60
|
+
return (await composeSample(opts)).frames;
|
|
61
|
+
}
|
|
53
62
|
/** Two samples `settleMs` apart: advancing when the second is above the first
|
|
54
63
|
* and above zero — the fallback card and a stalled feed both read flat. */
|
|
55
64
|
export async function composeAdvancing(opts) {
|
|
56
65
|
const node = opts.node ?? DEFAULT_COMPOSE_NODE;
|
|
57
|
-
const before = await
|
|
66
|
+
const before = await composeSample({ fetch: opts.fetch, base: opts.base, node });
|
|
58
67
|
await new Promise((r) => setTimeout(r, opts.settleMs ?? DEFAULT_SETTLE_MS));
|
|
59
|
-
const after = await
|
|
60
|
-
|
|
68
|
+
const after = await composeSample({ fetch: opts.fetch, base: opts.base, node });
|
|
69
|
+
const error = after.error ?? before.error;
|
|
70
|
+
return {
|
|
71
|
+
advancing: after.frames > before.frames && after.frames > 0,
|
|
72
|
+
before: before.frames,
|
|
73
|
+
after: after.frames,
|
|
74
|
+
node,
|
|
75
|
+
...(error !== undefined ? { error } : {}),
|
|
76
|
+
};
|
|
61
77
|
}
|
package/demo/cli.d.ts
CHANGED
|
@@ -1,14 +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
5
|
import type { DemoDaemonPolicy, DemoMode, DemoSpec } from "./spec.js";
|
|
4
6
|
export interface DemoArgs {
|
|
5
|
-
|
|
7
|
+
/** `dev-loop <action>`: the standalone tier's verbs. */
|
|
8
|
+
devLoop: boolean;
|
|
9
|
+
action: "up" | "check" | "down" | "spec" | "ui" | "refresh";
|
|
6
10
|
/** Absent: the spec's `mode`, else dev. */
|
|
7
11
|
mode?: DemoMode | "standalone";
|
|
8
12
|
daemon: DemoDaemonPolicy;
|
|
9
13
|
exportOnly: boolean;
|
|
10
14
|
json: boolean;
|
|
11
15
|
spec: string;
|
|
16
|
+
/** `ui` only; zellij unless said. */
|
|
17
|
+
ui?: DemoUi;
|
|
18
|
+
/** Default: PUBLIC_HOST in the environment. */
|
|
19
|
+
publicHost?: string;
|
|
12
20
|
}
|
|
13
21
|
export declare function parseDemoArgs(argv: string[]): DemoArgs;
|
|
14
22
|
export interface ResolvedSpecView {
|
|
@@ -58,6 +66,7 @@ export interface ResolvedSpecView {
|
|
|
58
66
|
export declare function resolvedSpecView(spec: DemoSpec, opts: {
|
|
59
67
|
mode: string;
|
|
60
68
|
slug?: string;
|
|
69
|
+
publicHost?: string;
|
|
61
70
|
}): ResolvedSpecView;
|
|
62
71
|
/** Everything `demoMain` touches, injectable so the dispatch is unit-tested. */
|
|
63
72
|
export interface DemoCliIo {
|
|
@@ -70,6 +79,12 @@ export interface DemoCliIo {
|
|
|
70
79
|
exportDir: string;
|
|
71
80
|
}>;
|
|
72
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>;
|
|
73
88
|
stdout(line: string): void;
|
|
74
89
|
stderr(line: string): void;
|
|
75
90
|
abort?: AbortSignal;
|
package/demo/cli.js
CHANGED
|
@@ -15,13 +15,21 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
|
|
|
15
15
|
// demo check --mode standalone --export-only build + export-workdir + every symlink resolves
|
|
16
16
|
// demo down tear down what `up` recorded, from any shell
|
|
17
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`
|
|
18
22
|
//
|
|
19
23
|
// The spec is `tests/demo.spec.ts` in the cwd (`--spec <path>` otherwise), a
|
|
20
24
|
// TypeScript module whose default export is `defineDemo({...})`. This runs
|
|
21
25
|
// under bun only: the spec is TypeScript and so is every product's dev shell.
|
|
22
26
|
import { resolve } from "node:path";
|
|
27
|
+
import { devLoopDown, runDevLoop } from "./dev-loop.js";
|
|
28
|
+
import { demoPaneList, devLoopPaneList, launchLayout, renderLayout, standaloneDirs, } from "./panes.js";
|
|
23
29
|
import { demoDown, demoPorts, demoSlug, runDemo, runExportCheck, } from "./run.js";
|
|
24
|
-
const USAGE = `usage: demo <up|check|down|spec> [--mode dev|image|standalone] [--daemon private|reuse] [--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>]
|
|
25
33
|
|
|
26
34
|
up launch, print the URLs, hold (Ctrl-C tears down)
|
|
27
35
|
check up, then tear down and exit 0/1 — what CI runs, always on a private daemon
|
|
@@ -29,6 +37,14 @@ const USAGE = `usage: demo <up|check|down|spec> [--mode dev|image|standalone] [-
|
|
|
29
37
|
build the template, export the standalone workdir, assert every symlink resolves
|
|
30
38
|
down tear down what \`up\` recorded
|
|
31
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
|
|
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
|
|
32
48
|
|
|
33
49
|
--mode dev run the product from source (bun run dev) and add it by URL
|
|
34
50
|
--mode image add the built product image — the customer path
|
|
@@ -36,14 +52,37 @@ const USAGE = `usage: demo <up|check|down|spec> [--mode dev|image|standalone] [-
|
|
|
36
52
|
--daemon reuse your listening daemon and real store; the driver deletes the instance,
|
|
37
53
|
removes the template and product, then adds again (stored templates
|
|
38
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)
|
|
39
58
|
--spec <path> the demo spec (default tests/demo.spec.ts)`;
|
|
40
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"];
|
|
41
62
|
export function parseDemoArgs(argv) {
|
|
42
|
-
const [
|
|
43
|
-
|
|
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}`);
|
|
44
71
|
throw new Error(action ? `unknown action '${action}'\n${USAGE}` : USAGE);
|
|
45
72
|
}
|
|
46
|
-
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;
|
|
47
86
|
for (let i = 0; i < rest.length; i++) {
|
|
48
87
|
const flag = rest[i];
|
|
49
88
|
const value = () => {
|
|
@@ -76,12 +115,27 @@ export function parseDemoArgs(argv) {
|
|
|
76
115
|
case "--spec":
|
|
77
116
|
args.spec = value();
|
|
78
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;
|
|
79
128
|
default:
|
|
80
129
|
throw new Error(`unknown flag '${flag}'\n${USAGE}`);
|
|
81
130
|
}
|
|
82
131
|
}
|
|
83
|
-
if (args.
|
|
84
|
-
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\`)`);
|
|
85
139
|
}
|
|
86
140
|
if (args.exportOnly && args.mode !== "standalone") {
|
|
87
141
|
throw new Error("--export-only belongs to --mode standalone");
|
|
@@ -98,16 +152,17 @@ export function resolvedSpecView(spec, opts) {
|
|
|
98
152
|
const slug = opts.slug ?? demoSlug(spec.product);
|
|
99
153
|
const instanceId = `demo-${slug}`;
|
|
100
154
|
const ports = demoPorts(slug);
|
|
155
|
+
const host = opts.publicHost ?? "localhost";
|
|
101
156
|
const pinned = spec.launch?.params?.STUDIO_HOST_PORT;
|
|
102
157
|
const studioHostPort = pinned !== undefined ? Number(pinned) : ports.studioHostPort;
|
|
103
158
|
const url = (ref) => {
|
|
104
159
|
if ("url" in ref)
|
|
105
160
|
return ref.url;
|
|
106
161
|
if ("control" in ref)
|
|
107
|
-
return `http
|
|
162
|
+
return `http://${host}:${ports.backendPort}${ref.control}`;
|
|
108
163
|
if ("proxy" in ref)
|
|
109
|
-
return `http
|
|
110
|
-
return `http
|
|
164
|
+
return `http://${host}:${ports.proxyPort}${ref.proxy.replaceAll("{id}", instanceId)}`;
|
|
165
|
+
return `http://${host}:${studioHostPort}${ref.studio}`;
|
|
111
166
|
};
|
|
112
167
|
const t = spec.template;
|
|
113
168
|
const template = t && "build" in t
|
|
@@ -176,30 +231,59 @@ export async function demoMain(argv, io) {
|
|
|
176
231
|
return 1;
|
|
177
232
|
}
|
|
178
233
|
const mode = args.mode ?? spec.mode ?? "dev";
|
|
234
|
+
const slug = demoSlug(spec.product);
|
|
235
|
+
const publicHost = args.publicHost !== undefined ? { publicHost: args.publicHost } : {};
|
|
179
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
|
+
}
|
|
180
259
|
switch (args.action) {
|
|
181
260
|
case "spec": {
|
|
182
|
-
const view = resolvedSpecView(spec, { mode });
|
|
261
|
+
const view = resolvedSpecView(spec, { mode, ...publicHost });
|
|
183
262
|
io.stdout(args.json ? JSON.stringify(view, null, 2) : renderView(view));
|
|
184
263
|
return 0;
|
|
185
264
|
}
|
|
186
265
|
case "down":
|
|
187
266
|
await io.demoDown(spec.product);
|
|
188
267
|
return 0;
|
|
189
|
-
case "
|
|
190
|
-
|
|
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: {
|
|
191
275
|
if (args.exportOnly) {
|
|
192
276
|
await io.runExportCheck(spec, { cwd: io.cwd });
|
|
193
277
|
return 0;
|
|
194
278
|
}
|
|
195
|
-
if (mode === "standalone")
|
|
196
|
-
throw new Error("the full standalone tier is
|
|
197
|
-
}
|
|
279
|
+
if (mode === "standalone")
|
|
280
|
+
throw new Error("the full standalone tier is `dev-loop up`");
|
|
198
281
|
await io.runDemo(spec, {
|
|
199
282
|
action: args.action,
|
|
200
283
|
mode,
|
|
201
284
|
daemon: args.daemon,
|
|
202
285
|
cwd: io.cwd,
|
|
286
|
+
...publicHost,
|
|
203
287
|
...(io.abort ? { abort: io.abort } : {}),
|
|
204
288
|
});
|
|
205
289
|
return 0;
|
|
@@ -207,7 +291,7 @@ export async function demoMain(argv, io) {
|
|
|
207
291
|
}
|
|
208
292
|
}
|
|
209
293
|
catch (e) {
|
|
210
|
-
io.stderr(
|
|
294
|
+
io.stderr(`${args.devLoop ? "dev-loop" : "demo"} ${args.action} failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
211
295
|
return 1;
|
|
212
296
|
}
|
|
213
297
|
}
|
|
@@ -242,12 +326,17 @@ export function defaultDemoCliIo() {
|
|
|
242
326
|
const release = () => controller.abort();
|
|
243
327
|
process.once("SIGINT", release);
|
|
244
328
|
process.once("SIGTERM", release);
|
|
329
|
+
// A multiplexer session ending sends its panes SIGHUP: that is a release too.
|
|
330
|
+
process.once("SIGHUP", release);
|
|
245
331
|
return {
|
|
246
332
|
cwd: process.cwd(),
|
|
247
333
|
loadSpec: loadSpecModule,
|
|
248
334
|
runDemo: (spec, opts) => runDemo(spec, opts),
|
|
249
335
|
runExportCheck: (spec, opts) => runExportCheck(spec, opts),
|
|
250
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),
|
|
251
340
|
stdout: (line) => console.log(line),
|
|
252
341
|
stderr: (line) => console.error(line),
|
|
253
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>;
|
package/demo/dev-loop.js
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// The full standalone tier (05-demo s7 step 3): probe's `refresh-standalone`
|
|
2
|
+
// and the sequencing half of `probe-demo-zellij`, for every product. The
|
|
3
|
+
// engine and Studio run from their checkouts (the `dev-loop ui` panes); this
|
|
4
|
+
// driver owns the rest — a private daemon, the dev backend on the driver's
|
|
5
|
+
// port, the template built from source, the workdir exported with the
|
|
6
|
+
// live-source links, the pump script resolved from the template's ports, and
|
|
7
|
+
// the stamp the Studio pane waits for. `up` holds so `refresh` can re-export
|
|
8
|
+
// against the same daemon and backend; `down` tears them down and leaves the
|
|
9
|
+
// developer's workdir where it is.
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { DEFAULT_STANDALONE_STUDIO_PORT, DEV_LOOP_SOURCES, DEV_LOOP_STAMP, standaloneDirs, } from "./panes.js";
|
|
12
|
+
import { DemoSession, defaultDemoDeps, demoSlug, exportStandaloneWorkdir, resolveIngestPort, } from "./run.js";
|
|
13
|
+
/** The daemon's built-in presets and where it caches them (norsk-ctl
|
|
14
|
+
* backend/src/docker/constants.ts: SOURCE_PRESETS, S3_MEDIA_BASE); the
|
|
15
|
+
* standalone tier pumps from the same files with the host's ffmpeg. */
|
|
16
|
+
const SAMPLE_MEDIA_BASE = "https://s3.eu-west-1.amazonaws.com/norsk.video/media-examples/data";
|
|
17
|
+
const SAMPLE_MEDIA_FILES = { camera1: "action.mp4", camera2: "wildlife.ts" };
|
|
18
|
+
const SAMPLE_MEDIA_DIR = "$HOME/.norsk-ctl/sample-media";
|
|
19
|
+
function generated(pattern) {
|
|
20
|
+
const video = pattern === "bars" ? "smptebars=size=1280x720:rate=25" : "testsrc=size=1280x720:rate=25";
|
|
21
|
+
return `ffmpeg -hide_banner -loglevel warning -re -f lavfi -i "${video}" -f lavfi -i "sine=frequency=440:sample_rate=48000" -c:v libx264 -preset veryfast -tune zerolatency -g 50 -c:a aac`;
|
|
22
|
+
}
|
|
23
|
+
function pumpLines(asset, target) {
|
|
24
|
+
if ("mediaFile" in asset) {
|
|
25
|
+
return [
|
|
26
|
+
`ffmpeg -hide_banner -loglevel warning -re -stream_loop -1 -i "${asset.mediaFile}" -c copy -f mpegts "${target}" &`,
|
|
27
|
+
];
|
|
28
|
+
}
|
|
29
|
+
if ("generate" in asset)
|
|
30
|
+
return [`${generated(asset.generate)} -f mpegts "${target}" &`];
|
|
31
|
+
const file = SAMPLE_MEDIA_FILES[asset.preset];
|
|
32
|
+
if (!file)
|
|
33
|
+
throw new Error(`unknown preset '${asset.preset}'; the daemon's presets are ${Object.keys(SAMPLE_MEDIA_FILES).join(", ")}`);
|
|
34
|
+
return [
|
|
35
|
+
`f="${SAMPLE_MEDIA_DIR}/${file}"; [ -f "$f" ] || curl -fL --create-dirs -o "$f" "${SAMPLE_MEDIA_BASE}/${file}"`,
|
|
36
|
+
`ffmpeg -hide_banner -loglevel warning -re -stream_loop -1 -i "$f" -c copy -f mpegts "${target}" &`,
|
|
37
|
+
];
|
|
38
|
+
}
|
|
39
|
+
/** The pump script the sources pane runs on Enter: every source in
|
|
40
|
+
* parallel with the host's ffmpeg, into the from-source engine. A preset is
|
|
41
|
+
* fetched into the daemon's sample-media cache if it is not there yet; a
|
|
42
|
+
* mediaFile that is absent on this box pumps its fallback. */
|
|
43
|
+
export function pumpScript(sources, fileExists) {
|
|
44
|
+
const lines = [
|
|
45
|
+
"#!/usr/bin/env bash",
|
|
46
|
+
"# written by dev-loop up: the demo's sources, pumped from this box into the from-source engine",
|
|
47
|
+
"set -uo pipefail",
|
|
48
|
+
];
|
|
49
|
+
for (const s of sources) {
|
|
50
|
+
const target = `srt://127.0.0.1:${s.port}?streamid=${s.streamId}`;
|
|
51
|
+
const asset = "mediaFile" in s.asset && !fileExists(s.asset.mediaFile) ? s.asset.fallback : s.asset;
|
|
52
|
+
const via = "mediaFile" in s.asset && !fileExists(s.asset.mediaFile) ? ` (${s.asset.mediaFile} is not on this box)` : "";
|
|
53
|
+
lines.push("", `# ${s.name}: ${JSON.stringify(asset)} -> ${target}${via}`, ...pumpLines(asset, target));
|
|
54
|
+
}
|
|
55
|
+
lines.push("", "wait");
|
|
56
|
+
return `${lines.join("\n")}\n`;
|
|
57
|
+
}
|
|
58
|
+
function standalonePort(ingest, t) {
|
|
59
|
+
if ("label" in ingest) {
|
|
60
|
+
throw new Error(`ingest label '${ingest.label}' — the standalone tier has no instance to resolve a label against; name the template parameter (ingest: { param })`);
|
|
61
|
+
}
|
|
62
|
+
if ("port" in ingest)
|
|
63
|
+
return ingest.port;
|
|
64
|
+
return resolveIngestPort(ingest, [], t);
|
|
65
|
+
}
|
|
66
|
+
/** The standalone tier's template: its own when the spec gives one, else the demo's. */
|
|
67
|
+
function loopTemplate(spec) {
|
|
68
|
+
return spec.standalone?.template ?? spec.template;
|
|
69
|
+
}
|
|
70
|
+
function studioPortOf(envText) {
|
|
71
|
+
const m = /^export PORT='?(\d+)/m.exec(envText ?? "");
|
|
72
|
+
return m ? Number(m[1]) : DEFAULT_STANDALONE_STUDIO_PORT;
|
|
73
|
+
}
|
|
74
|
+
/** Build (or reload) the template, export the workdir, write the pump script
|
|
75
|
+
* and finally the stamp — the order the panes rely on. */
|
|
76
|
+
async function exportLoop(s, templateName, workdir, refresh) {
|
|
77
|
+
const { spec, deps } = s;
|
|
78
|
+
deps.removeFile(join(workdir, DEV_LOOP_STAMP));
|
|
79
|
+
if (refresh) {
|
|
80
|
+
const t = loopTemplate(spec);
|
|
81
|
+
if (t && "build" in t)
|
|
82
|
+
await s.resolveTemplate(t);
|
|
83
|
+
else
|
|
84
|
+
await s.cliOk(["product", "reload", spec.product]);
|
|
85
|
+
}
|
|
86
|
+
await exportStandaloneWorkdir(s, templateName, workdir);
|
|
87
|
+
const declared = await s.declaredParams(templateName);
|
|
88
|
+
const t = { declared, overrides: spec.standalone?.params ?? {} };
|
|
89
|
+
const sources = (spec.sources ?? []).map((src) => {
|
|
90
|
+
const asset = src.asset ?? { preset: "camera1" };
|
|
91
|
+
return { name: src.name, port: standalonePort(src.ingest, t), streamId: src.streamId ?? src.name, asset };
|
|
92
|
+
});
|
|
93
|
+
deps.writeFile(join(workdir, DEV_LOOP_SOURCES), pumpScript(sources, deps.fileExists));
|
|
94
|
+
deps.writeFile(join(workdir, DEV_LOOP_STAMP), `${new Date().toISOString()}\n`);
|
|
95
|
+
return studioPortOf(deps.readFile(join(workdir, "env")));
|
|
96
|
+
}
|
|
97
|
+
function describeLoop(s, dirs, studioPort) {
|
|
98
|
+
const { spec } = s;
|
|
99
|
+
const url = `http://${s.host}:${studioPort}`;
|
|
100
|
+
return [
|
|
101
|
+
`${spec.product} standalone workdir exported to ${dirs.workdir} (template from the dev backend on :${s.ports.backendPort})`,
|
|
102
|
+
` engine cd ${dirs.norskDir} && nix develop --command make media-shell`,
|
|
103
|
+
` studio cd ${dirs.studioDir} && (. ${dirs.workdir}/env && npm run server-dev) -> ${url}`,
|
|
104
|
+
` sources bash ${join(dirs.workdir, DEV_LOOP_SOURCES)} (into the engine, when the workflow is running)`,
|
|
105
|
+
` after a shared/ edit: restart this (the dev backend does not watch shared/); a dashboard edit needs only a browser refresh`,
|
|
106
|
+
];
|
|
107
|
+
}
|
|
108
|
+
async function refuseIfUp(spec, deps) {
|
|
109
|
+
const prev = deps.state.read(spec.product, "dev-loop");
|
|
110
|
+
if (!prev)
|
|
111
|
+
return;
|
|
112
|
+
if (await deps.daemonAnswers(prev.daemonPort)) {
|
|
113
|
+
throw new Error(`dev-loop '${spec.product}' is already up (daemon :${prev.daemonPort}, workdir ${prev.workdir}) — \`dev-loop refresh\` re-exports it, \`dev-loop down\` ends it`);
|
|
114
|
+
}
|
|
115
|
+
deps.log(`forgetting a stale dev-loop record of a run on :${prev.daemonPort} (nothing of it is left)`);
|
|
116
|
+
deps.state.remove(spec.product, "dev-loop");
|
|
117
|
+
}
|
|
118
|
+
export async function runDevLoop(spec, opts, deps = defaultDemoDeps(opts.cwd)) {
|
|
119
|
+
const slug = opts.slug ?? demoSlug(spec.product);
|
|
120
|
+
const dirs = opts.dirs ?? standaloneDirs(spec, slug);
|
|
121
|
+
const common = {
|
|
122
|
+
mode: "dev",
|
|
123
|
+
daemon: "private",
|
|
124
|
+
...(opts.timeouts !== undefined ? { timeouts: opts.timeouts } : {}),
|
|
125
|
+
...(opts.publicHost !== undefined ? { publicHost: opts.publicHost } : {}),
|
|
126
|
+
};
|
|
127
|
+
if (opts.action === "refresh") {
|
|
128
|
+
const state = deps.state.read(spec.product, "dev-loop");
|
|
129
|
+
if (!state || !(await deps.daemonAnswers(state.daemonPort)) || !state.templateName) {
|
|
130
|
+
throw new Error(`dev-loop '${spec.product}' is not up — run \`dev-loop up\` first (it holds; refresh from another pane)`);
|
|
131
|
+
}
|
|
132
|
+
const s = new DemoSession(spec, slug, opts.cwd, deps, { ...common, storeDir: state.storeDir });
|
|
133
|
+
const workdir = state.workdir ?? dirs.workdir;
|
|
134
|
+
const studioPort = await exportLoop(s, state.templateName, workdir, true);
|
|
135
|
+
for (const line of describeLoop(s, { ...dirs, workdir }, studioPort))
|
|
136
|
+
deps.log(line);
|
|
137
|
+
deps.log("refreshed — reload the workflow in Studio");
|
|
138
|
+
return { workdir, studioUrl: `http://${s.host}:${studioPort}` };
|
|
139
|
+
}
|
|
140
|
+
await refuseIfUp(spec, deps);
|
|
141
|
+
const s = new DemoSession(spec, slug, opts.cwd, deps, common);
|
|
142
|
+
let recorded = false;
|
|
143
|
+
let result;
|
|
144
|
+
let journeyError;
|
|
145
|
+
try {
|
|
146
|
+
await s.attachDaemon();
|
|
147
|
+
await s.startDev();
|
|
148
|
+
await s.register({ licence: false });
|
|
149
|
+
const templateName = await s.resolveTemplate(loopTemplate(spec));
|
|
150
|
+
const studioPort = await exportLoop(s, templateName, dirs.workdir, false);
|
|
151
|
+
for (const line of describeLoop(s, dirs, studioPort))
|
|
152
|
+
deps.log(line);
|
|
153
|
+
result = { workdir: dirs.workdir, studioUrl: `http://${s.host}:${studioPort}` };
|
|
154
|
+
const state = {
|
|
155
|
+
product: spec.product,
|
|
156
|
+
kind: "dev-loop",
|
|
157
|
+
storeDir: s.storeDir,
|
|
158
|
+
daemonPort: s.ports.daemonPort,
|
|
159
|
+
workdir: dirs.workdir,
|
|
160
|
+
templateName,
|
|
161
|
+
...(s.dev?.pid !== undefined ? { devPid: s.dev.pid } : {}),
|
|
162
|
+
};
|
|
163
|
+
deps.state.write(state);
|
|
164
|
+
recorded = true;
|
|
165
|
+
deps.log("holding — Ctrl-C (or `dev-loop down` from another shell) ends it; `dev-loop refresh` re-exports");
|
|
166
|
+
await deps.hold(opts.abort);
|
|
167
|
+
}
|
|
168
|
+
catch (e) {
|
|
169
|
+
journeyError = e;
|
|
170
|
+
}
|
|
171
|
+
await s.teardown({ instances: [], handles: [] });
|
|
172
|
+
if (recorded)
|
|
173
|
+
deps.state.remove(spec.product, "dev-loop");
|
|
174
|
+
if (journeyError !== undefined)
|
|
175
|
+
throw journeyError;
|
|
176
|
+
return result;
|
|
177
|
+
}
|
|
178
|
+
/** `dev-loop down`: end the recorded run from any shell. The workdir stays —
|
|
179
|
+
* it is the developer's, and Studio may still be looking at it. */
|
|
180
|
+
export async function devLoopDown(product, deps = defaultDemoDeps(process.cwd())) {
|
|
181
|
+
const state = deps.state.read(product, "dev-loop");
|
|
182
|
+
if (!state) {
|
|
183
|
+
deps.log(`nothing recorded as up for the ${product} dev-loop`);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
const cli = (argv) => deps.cli(state.storeDir, ["--port", String(state.daemonPort), ...argv]);
|
|
187
|
+
try {
|
|
188
|
+
await deps.cleanup({
|
|
189
|
+
deleteInstance: (id) => cli(["instance", "delete", id, "--purge"]),
|
|
190
|
+
stopDaemon: () => cli(["shutdown"]),
|
|
191
|
+
instances: [],
|
|
192
|
+
daemon: null,
|
|
193
|
+
storeDir: state.storeDir,
|
|
194
|
+
containers: [],
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
catch (e) {
|
|
198
|
+
deps.log(`cleanup: ${e instanceof Error ? e.message : String(e)}; nuking the store as root`);
|
|
199
|
+
}
|
|
200
|
+
if (state.devPid !== undefined)
|
|
201
|
+
deps.killPid?.(state.devPid);
|
|
202
|
+
deps.nukeStoreAsRoot(state.storeDir);
|
|
203
|
+
deps.state.remove(product, "dev-loop");
|
|
204
|
+
deps.log(`${product} dev-loop torn down (the workdir ${state.workdir ?? ""} is untouched)`);
|
|
205
|
+
}
|
package/demo/gates.js
CHANGED
|
@@ -48,7 +48,9 @@ export function composeAdvancingGate(opts = {}) {
|
|
|
48
48
|
node,
|
|
49
49
|
...(opts.settleMs !== undefined ? { settleMs: opts.settleMs } : {}),
|
|
50
50
|
});
|
|
51
|
-
if (
|
|
51
|
+
if (r.error)
|
|
52
|
+
ctx.log(`visualiser unreachable at ${base}: ${r.error}`);
|
|
53
|
+
else if (!r.advancing)
|
|
52
54
|
ctx.log(`${node} not advancing yet (${r.before} -> ${r.after})`);
|
|
53
55
|
return r.advancing;
|
|
54
56
|
},
|
package/demo/index.d.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
export type { DemoArgs, DemoCliIo, ResolvedSpecView } from "./cli.js";
|
|
2
2
|
export { defaultDemoCliIo, demoMain, parseDemoArgs, resolvedSpecView } from "./cli.js";
|
|
3
|
+
export type { DevLoopOptions, DevLoopResult, StandaloneSource } from "./dev-loop.js";
|
|
4
|
+
export { devLoopDown, pumpScript, runDevLoop } from "./dev-loop.js";
|
|
3
5
|
export { composeAdvancingGate, DemoGateFailure, logsCleanGate } from "./gates.js";
|
|
4
|
-
export type {
|
|
5
|
-
export {
|
|
6
|
+
export type { DemoPane, DemoPaneOptions, DemoUi, DevLoopPaneOptions, PaneLayout, StandaloneDirs } from "./panes.js";
|
|
7
|
+
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";
|
|
6
10
|
export type { DemoContext, DemoDaemonPolicy, DemoExtra, DemoIngest, DemoLaunchContext, DemoMode, DemoOpen, DemoReady, DemoSource, DemoSpec, DemoTemplate, DemoUrl, } from "./spec.js";
|
|
7
11
|
export { DemoSpecSchema, defineDemo, formatSpecIssues } from "./spec.js";
|
package/demo/index.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export { defaultDemoCliIo, demoMain, parseDemoArgs, resolvedSpecView } from "./cli.js";
|
|
2
|
+
export { devLoopDown, pumpScript, runDevLoop } from "./dev-loop.js";
|
|
2
3
|
export { composeAdvancingGate, DemoGateFailure, logsCleanGate } from "./gates.js";
|
|
3
|
-
export {
|
|
4
|
+
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";
|
|
4
6
|
export { DemoSpecSchema, defineDemo, formatSpecIssues } from "./spec.js";
|