@norskvideo/ctl-test-harness 0.1.17 → 0.1.18
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/demo/cli.d.ts +74 -0
- package/demo/cli.js +232 -0
- package/demo/index.d.ts +6 -0
- package/demo/index.js +3 -0
- package/demo/run.d.ts +157 -0
- package/demo/run.js +650 -0
- package/demo/spec.d.ts +122 -0
- package/demo/spec.js +146 -0
- package/package.json +10 -2
package/demo/spec.d.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { SourceAsset } from "../source-pump.js";
|
|
3
|
+
/** Where a URL is rooted. `studio` is the instance's published Studio port,
|
|
4
|
+
* `proxy` the daemon's oauth2 proxy (`{id}` expands to the instance id),
|
|
5
|
+
* `control` the product's control plane (the dev backend), `url` verbatim. */
|
|
6
|
+
export type DemoUrl = {
|
|
7
|
+
studio: string;
|
|
8
|
+
} | {
|
|
9
|
+
proxy: string;
|
|
10
|
+
} | {
|
|
11
|
+
control: string;
|
|
12
|
+
} | {
|
|
13
|
+
url: string;
|
|
14
|
+
};
|
|
15
|
+
/** How a source finds the port it dials: the manifest parameter or label of
|
|
16
|
+
* the ingest the template allocated, or — only for a product that really uses
|
|
17
|
+
* the conventional SRT port — the number itself (checked at launch). */
|
|
18
|
+
export type DemoIngest = {
|
|
19
|
+
param: string;
|
|
20
|
+
} | {
|
|
21
|
+
label: string;
|
|
22
|
+
} | {
|
|
23
|
+
port: number;
|
|
24
|
+
};
|
|
25
|
+
export interface DemoSource {
|
|
26
|
+
/** Source identity within the instance (container name + management key). */
|
|
27
|
+
name: string;
|
|
28
|
+
/** Default `{ preset: "camera1" }`. */
|
|
29
|
+
asset?: SourceAsset;
|
|
30
|
+
ingest: DemoIngest;
|
|
31
|
+
streamId?: string;
|
|
32
|
+
}
|
|
33
|
+
export interface DemoContext {
|
|
34
|
+
action: "up" | "check";
|
|
35
|
+
product: string;
|
|
36
|
+
instanceId: string;
|
|
37
|
+
daemonPort: number;
|
|
38
|
+
storeDir: string;
|
|
39
|
+
/** Resolve a spec URL against this run's ports. */
|
|
40
|
+
url(ref: DemoUrl): string;
|
|
41
|
+
cli(argv: string[], opts?: {
|
|
42
|
+
output?: "json" | "yaml";
|
|
43
|
+
}): Promise<{
|
|
44
|
+
stdout: string;
|
|
45
|
+
stderr: string;
|
|
46
|
+
exitCode: number;
|
|
47
|
+
}>;
|
|
48
|
+
fetch(url: string, init?: RequestInit): Promise<Response>;
|
|
49
|
+
log(line: string): void;
|
|
50
|
+
}
|
|
51
|
+
export type DemoReady = {
|
|
52
|
+
http: DemoUrl;
|
|
53
|
+
status?: number;
|
|
54
|
+
bodyIncludes?: string;
|
|
55
|
+
timeoutMs?: number;
|
|
56
|
+
} | {
|
|
57
|
+
custom: (ctx: DemoContext) => Promise<boolean>;
|
|
58
|
+
label?: string;
|
|
59
|
+
timeoutMs?: number;
|
|
60
|
+
};
|
|
61
|
+
export interface DemoOpen {
|
|
62
|
+
name: string;
|
|
63
|
+
url: DemoUrl;
|
|
64
|
+
/** A regex applied to the fetched body; the first match is printed instead
|
|
65
|
+
* of the URL (a CMAF state document's m3u8, say). */
|
|
66
|
+
pick?: string;
|
|
67
|
+
}
|
|
68
|
+
export type DemoTemplate = {
|
|
69
|
+
name: string;
|
|
70
|
+
} | {
|
|
71
|
+
build: {
|
|
72
|
+
/** Default `<product>-demo`. */
|
|
73
|
+
name?: string;
|
|
74
|
+
/** Template inputs: a JSON file path relative to the repo root, or the object itself. */
|
|
75
|
+
input: string | Record<string, unknown>;
|
|
76
|
+
};
|
|
77
|
+
};
|
|
78
|
+
export interface DemoSpec {
|
|
79
|
+
/** Manifest name, e.g. "norsk-playout". The registration must report it. */
|
|
80
|
+
product: string;
|
|
81
|
+
/** Started with PORT=<driver-chosen> in the environment; the driver polls
|
|
82
|
+
* `readyPath` (default `/manifest.json`, what `product add` fetches). */
|
|
83
|
+
dev: {
|
|
84
|
+
command: string[];
|
|
85
|
+
readyPath?: string;
|
|
86
|
+
};
|
|
87
|
+
/** The built product image for `--mode image` (05-demo s7 step 2). */
|
|
88
|
+
image?: string;
|
|
89
|
+
/** Default: the manifest's first default template. */
|
|
90
|
+
template?: DemoTemplate;
|
|
91
|
+
launch?: {
|
|
92
|
+
hardware?: "nvidia" | "none";
|
|
93
|
+
params?: Record<string, string | number>;
|
|
94
|
+
/** `up` only; `check` launches without it (05-demo s5 playout). */
|
|
95
|
+
workingDirectory?: string;
|
|
96
|
+
};
|
|
97
|
+
/** Paths `up` needs on the host, each with the command that makes it. */
|
|
98
|
+
prerequisites?: Array<{
|
|
99
|
+
path: string;
|
|
100
|
+
hint: string;
|
|
101
|
+
}>;
|
|
102
|
+
sources?: DemoSource[];
|
|
103
|
+
/** Gates polled in order once the instance runs and the sources pump — so a
|
|
104
|
+
* gate may demand what only a source produces (composing, analysing). */
|
|
105
|
+
ready?: DemoReady[];
|
|
106
|
+
/** Runs once every gate holds. */
|
|
107
|
+
after?: (ctx: DemoContext) => Promise<void>;
|
|
108
|
+
open?: DemoOpen[];
|
|
109
|
+
/** The standalone tier's live-source links, for `--mode standalone`. Paths
|
|
110
|
+
* are relative to the repo root. */
|
|
111
|
+
standalone?: {
|
|
112
|
+
links?: Record<string, string>;
|
|
113
|
+
dashboards?: string;
|
|
114
|
+
params?: Record<string, string | number>;
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
export declare const DemoSpecSchema: z.ZodType<DemoSpec>;
|
|
118
|
+
/** Format zod's issue list as one line per problem, path first. A union's
|
|
119
|
+
* failure would otherwise arrive as every branch's complaint; the rule the
|
|
120
|
+
* spec author needs (a `mediaFile` needs a `fallback`) is named explicitly. */
|
|
121
|
+
export declare function formatSpecIssues(error: z.ZodError): string;
|
|
122
|
+
export declare function defineDemo(spec: DemoSpec): DemoSpec;
|
package/demo/spec.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// The demo spec (fleet review 05-demo s4): one `tests/demo.spec.ts` per product
|
|
2
|
+
// repo, TypeScript because the ready/after hooks are code and component ids
|
|
3
|
+
// live in code. The driver (run.ts) owns everything the spec does not state —
|
|
4
|
+
// the daemon, its store and ports, the dev backend's port, the instance's
|
|
5
|
+
// ingest ports (resolved from `instance describe`, never restated here).
|
|
6
|
+
//
|
|
7
|
+
// The static rules live in zod so a malformed spec fails at `defineDemo`, with
|
|
8
|
+
// a path, before a daemon exists. Runtime rules (a restated port that is not
|
|
9
|
+
// the conventional one, a template the manifest does not publish) live in
|
|
10
|
+
// run.ts because they need the daemon's answer.
|
|
11
|
+
import { isAbsolute } from "node:path";
|
|
12
|
+
import { z } from "zod";
|
|
13
|
+
const fn = () => z.custom((v) => typeof v === "function", { message: "expected a function" });
|
|
14
|
+
const Url = z.union([
|
|
15
|
+
z.strictObject({ studio: z.string().min(1) }),
|
|
16
|
+
z.strictObject({ proxy: z.string().min(1) }),
|
|
17
|
+
z.strictObject({ control: z.string().min(1) }),
|
|
18
|
+
z.strictObject({ url: z.string().min(1) }),
|
|
19
|
+
]);
|
|
20
|
+
const Builtin = z.union([
|
|
21
|
+
z.strictObject({ preset: z.string().min(1) }),
|
|
22
|
+
z.strictObject({ generate: z.enum(["bars", "testsrc"]) }),
|
|
23
|
+
]);
|
|
24
|
+
const Asset = z.union([
|
|
25
|
+
Builtin,
|
|
26
|
+
z.strictObject({
|
|
27
|
+
mediaFile: z.string().min(1),
|
|
28
|
+
fallback: Builtin,
|
|
29
|
+
}),
|
|
30
|
+
]);
|
|
31
|
+
const Ingest = z.union([
|
|
32
|
+
z.strictObject({ param: z.string().min(1) }),
|
|
33
|
+
z.strictObject({ label: z.string().min(1) }),
|
|
34
|
+
z.strictObject({ port: z.int().positive() }),
|
|
35
|
+
]);
|
|
36
|
+
const Params = z.record(z.string(), z.union([z.string(), z.number()]));
|
|
37
|
+
const relativePath = z
|
|
38
|
+
.string()
|
|
39
|
+
.min(1)
|
|
40
|
+
.refine((p) => !isAbsolute(p) && !p.startsWith("~"), { message: "must be a relative path inside the repo" });
|
|
41
|
+
export const DemoSpecSchema = z
|
|
42
|
+
.strictObject({
|
|
43
|
+
product: z.string().min(1),
|
|
44
|
+
dev: z.strictObject({
|
|
45
|
+
command: z.array(z.string().min(1)).min(1),
|
|
46
|
+
readyPath: z.string().startsWith("/").optional(),
|
|
47
|
+
}),
|
|
48
|
+
image: z.string().min(1).optional(),
|
|
49
|
+
template: z
|
|
50
|
+
.union([
|
|
51
|
+
z.strictObject({ name: z.string().min(1) }),
|
|
52
|
+
z.strictObject({
|
|
53
|
+
build: z.strictObject({
|
|
54
|
+
name: z.string().min(1).optional(),
|
|
55
|
+
input: z.union([z.string().min(1), z.record(z.string(), z.unknown())]),
|
|
56
|
+
}),
|
|
57
|
+
}),
|
|
58
|
+
])
|
|
59
|
+
.optional(),
|
|
60
|
+
launch: z
|
|
61
|
+
.strictObject({
|
|
62
|
+
hardware: z.enum(["nvidia", "none"]).optional(),
|
|
63
|
+
params: Params.optional(),
|
|
64
|
+
workingDirectory: z.string().min(1).optional(),
|
|
65
|
+
})
|
|
66
|
+
.optional(),
|
|
67
|
+
prerequisites: z.array(z.strictObject({ path: z.string().min(1), hint: z.string().min(1) })).optional(),
|
|
68
|
+
sources: z
|
|
69
|
+
.array(z.strictObject({
|
|
70
|
+
name: z.string().min(1),
|
|
71
|
+
asset: Asset.optional(),
|
|
72
|
+
ingest: Ingest,
|
|
73
|
+
streamId: z.string().min(1).optional(),
|
|
74
|
+
}))
|
|
75
|
+
.optional(),
|
|
76
|
+
ready: z
|
|
77
|
+
.array(z.union([
|
|
78
|
+
z.strictObject({
|
|
79
|
+
http: Url,
|
|
80
|
+
status: z.int().optional(),
|
|
81
|
+
bodyIncludes: z.string().optional(),
|
|
82
|
+
timeoutMs: z.int().positive().optional(),
|
|
83
|
+
}),
|
|
84
|
+
z.strictObject({
|
|
85
|
+
custom: fn(),
|
|
86
|
+
label: z.string().optional(),
|
|
87
|
+
timeoutMs: z.int().positive().optional(),
|
|
88
|
+
}),
|
|
89
|
+
]))
|
|
90
|
+
.optional(),
|
|
91
|
+
after: fn().optional(),
|
|
92
|
+
open: z.array(z.strictObject({ name: z.string().min(1), url: Url, pick: z.string().optional() })).optional(),
|
|
93
|
+
standalone: z
|
|
94
|
+
.strictObject({
|
|
95
|
+
links: z.record(z.string().min(1), relativePath).optional(),
|
|
96
|
+
dashboards: relativePath.optional(),
|
|
97
|
+
params: Params.optional(),
|
|
98
|
+
})
|
|
99
|
+
.optional(),
|
|
100
|
+
})
|
|
101
|
+
.superRefine((spec, ctx) => {
|
|
102
|
+
const seen = new Set();
|
|
103
|
+
(spec.sources ?? []).forEach((s, i) => {
|
|
104
|
+
if (seen.has(s.name)) {
|
|
105
|
+
ctx.addIssue({ code: "custom", path: ["sources", i, "name"], message: `duplicate source name '${s.name}'` });
|
|
106
|
+
}
|
|
107
|
+
seen.add(s.name);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
/** Format zod's issue list as one line per problem, path first. A union's
|
|
111
|
+
* failure would otherwise arrive as every branch's complaint; the rule the
|
|
112
|
+
* spec author needs (a `mediaFile` needs a `fallback`) is named explicitly. */
|
|
113
|
+
export function formatSpecIssues(error) {
|
|
114
|
+
const lines = [];
|
|
115
|
+
for (const issue of error.issues) {
|
|
116
|
+
const path = issue.path
|
|
117
|
+
.map((p) => (typeof p === "number" ? `[${p}]` : `.${String(p)}`))
|
|
118
|
+
.join("")
|
|
119
|
+
.replace(/^\./, "");
|
|
120
|
+
const message = issue.code === "invalid_union" ? unionHint(issue.path.map(String)) : issue.message;
|
|
121
|
+
lines.push(`${path || "<root>"}: ${message}`);
|
|
122
|
+
}
|
|
123
|
+
return lines.join("\n");
|
|
124
|
+
}
|
|
125
|
+
function unionHint(path) {
|
|
126
|
+
const leaf = path.at(-1);
|
|
127
|
+
if (leaf === "asset") {
|
|
128
|
+
return "expected { preset }, { generate: 'bars' | 'testsrc' } or { mediaFile, fallback: <preset|generate> } — a mediaFile always needs a fallback";
|
|
129
|
+
}
|
|
130
|
+
if (leaf === "ingest")
|
|
131
|
+
return "expected { param }, { label } or { port } naming the ingest this source dials";
|
|
132
|
+
if (leaf === "template")
|
|
133
|
+
return "expected { name } or { build: { input } }";
|
|
134
|
+
if (leaf === "url" || leaf === "http")
|
|
135
|
+
return "expected { studio }, { proxy }, { control } or { url }";
|
|
136
|
+
return "no branch matched";
|
|
137
|
+
}
|
|
138
|
+
export function defineDemo(spec) {
|
|
139
|
+
const parsed = DemoSpecSchema.safeParse(spec);
|
|
140
|
+
if (!parsed.success) {
|
|
141
|
+
throw new Error(`invalid demo spec:\n${formatSpecIssues(parsed.error)}`);
|
|
142
|
+
}
|
|
143
|
+
// Return the caller's object: the hooks are identities the tests compare by
|
|
144
|
+
// reference, and zod's copy would drop nothing but is a copy all the same.
|
|
145
|
+
return spec;
|
|
146
|
+
}
|
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.18",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": {
|
|
@@ -42,12 +42,20 @@
|
|
|
42
42
|
"./smoke": {
|
|
43
43
|
"types": "./smoke.d.ts",
|
|
44
44
|
"default": "./smoke.js"
|
|
45
|
+
},
|
|
46
|
+
"./demo": {
|
|
47
|
+
"types": "./demo/index.d.ts",
|
|
48
|
+
"default": "./demo/index.js"
|
|
45
49
|
}
|
|
46
50
|
},
|
|
47
51
|
"main": "./index.js",
|
|
48
52
|
"types": "./index.d.ts",
|
|
53
|
+
"bin": {
|
|
54
|
+
"ctl-demo": "./demo/cli.js"
|
|
55
|
+
},
|
|
49
56
|
"dependencies": {
|
|
50
|
-
"@norskvideo/ctl-sdk": "^0.1.0"
|
|
57
|
+
"@norskvideo/ctl-sdk": "^0.1.0",
|
|
58
|
+
"zod": "^4.3.6"
|
|
51
59
|
},
|
|
52
60
|
"peerDependencies": {
|
|
53
61
|
"@norskvideo/norsk-studio": "*",
|