@norskvideo/ctl-test-harness 0.1.16 → 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/smoke.d.ts +12 -3
- package/smoke.js +3 -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": "*",
|
package/smoke.d.ts
CHANGED
|
@@ -34,7 +34,14 @@ export interface SmokeContext {
|
|
|
34
34
|
}): Promise<CliResult>;
|
|
35
35
|
fetchStudio(path: string): Promise<Response>;
|
|
36
36
|
}
|
|
37
|
-
|
|
37
|
+
/** Set on any observation that holds with or without a source — a resilient
|
|
38
|
+
* switch that keeps a ladder fed from a slate, a control surface that is up
|
|
39
|
+
* regardless — so it runs the satisfied phase only. `components` is
|
|
40
|
+
* structural by nature and needs no flag. */
|
|
41
|
+
interface Structural {
|
|
42
|
+
structural?: boolean;
|
|
43
|
+
}
|
|
44
|
+
export type SmokeObservation = Structural & ({
|
|
38
45
|
kind: "components";
|
|
39
46
|
ids: string[];
|
|
40
47
|
} | {
|
|
@@ -60,7 +67,7 @@ export type SmokeObservation = {
|
|
|
60
67
|
kind: "custom";
|
|
61
68
|
label: string;
|
|
62
69
|
satisfied: (ctx: SmokeContext) => Promise<boolean>;
|
|
63
|
-
};
|
|
70
|
+
});
|
|
64
71
|
export interface SmokeSpec {
|
|
65
72
|
product: {
|
|
66
73
|
/** Manifest name, e.g. "norsk-probe". The registration must report it. */
|
|
@@ -142,8 +149,10 @@ export interface SmokePorts extends BaseHarnessPorts {
|
|
|
142
149
|
export declare function smokePorts(slug: string, overrides?: Partial<SmokePorts>): SmokePorts;
|
|
143
150
|
export declare const defaultSmokeDeps: SmokeDeps;
|
|
144
151
|
/** Whether an observation depends on a source being pumped. Structural ones
|
|
145
|
-
*
|
|
152
|
+
* (`components`, or anything the spec marks `structural`) run the satisfied
|
|
153
|
+
* phase only. */
|
|
146
154
|
export declare function sourceDependent(o: SmokeObservation): boolean;
|
|
147
155
|
/** One sample of an observation — the predicate the three phases poll. */
|
|
148
156
|
export declare function observationSatisfied(o: SmokeObservation, ctx: SmokeContext, readers: SmokeReaders): Promise<boolean>;
|
|
149
157
|
export declare function runProductSmoke(slug: string, spec: SmokeSpec, deps?: SmokeDeps): Promise<void>;
|
|
158
|
+
export {};
|
package/smoke.js
CHANGED
|
@@ -100,9 +100,10 @@ function describeObservation(o) {
|
|
|
100
100
|
}
|
|
101
101
|
}
|
|
102
102
|
/** Whether an observation depends on a source being pumped. Structural ones
|
|
103
|
-
*
|
|
103
|
+
* (`components`, or anything the spec marks `structural`) run the satisfied
|
|
104
|
+
* phase only. */
|
|
104
105
|
export function sourceDependent(o) {
|
|
105
|
-
return o.kind !== "components";
|
|
106
|
+
return o.kind !== "components" && o.structural !== true;
|
|
106
107
|
}
|
|
107
108
|
/** One sample of an observation — the predicate the three phases poll. */
|
|
108
109
|
export async function observationSatisfied(o, ctx, readers) {
|