@norskvideo/ctl-test-harness 0.1.22 → 0.1.24

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.
@@ -2,9 +2,24 @@ 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
+ /** Root workflows the visualiser listed — 0 while nothing is deployed. */
8
+ roots: number;
9
+ /** Whether any root workflow's summary carried a node of that name — a
10
+ * flat 0 with the node absent is a different failure from a stalled one. */
11
+ nodeSeen: boolean;
12
+ /** Why the sample is 0 when it is: the visualiser was unreachable, and how. */
13
+ error?: string;
14
+ }
5
15
  /** 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. */
16
+ * `base` (`.../visualiser`). An unreachable visualiser reads as 0 with the
17
+ * reason beside it — the caller's second sample, not a throw, decides. */
18
+ export declare function composeSample(opts: {
19
+ fetch: FetchLike;
20
+ base: string;
21
+ node?: string;
22
+ }): Promise<ComposeSample>;
8
23
  export declare function composeFrames(opts: {
9
24
  fetch: FetchLike;
10
25
  base: string;
@@ -15,6 +30,11 @@ export interface ComposeAdvancingResult {
15
30
  before: number;
16
31
  after: number;
17
32
  node: string;
33
+ /** From the second sample: root workflows listed, and whether the node was in any. */
34
+ roots: number;
35
+ nodeSeen: boolean;
36
+ /** Set when a sample could not read the visualiser at all. */
37
+ error?: string;
18
38
  }
19
39
  /** Two samples `settleMs` apart: advancing when the second is above the first
20
40
  * and above zero — the fallback card and a stalled feed both read flat. */
@@ -24,38 +24,68 @@ export function maxNodeMediaIn(summary, node) {
24
24
  }
25
25
  return max;
26
26
  }
27
+ function hasNode(summary, node) {
28
+ const nodes = summary?.nodes;
29
+ return Array.isArray(nodes) && nodes.some((n) => n?.name === node);
30
+ }
27
31
  async function getJson(fetch, url) {
28
32
  const r = await fetch(url, { signal: AbortSignal.timeout(5000) });
29
33
  if (!r.ok)
30
34
  throw new Error(`GET ${url} -> ${r.status}`);
31
- return r.json();
35
+ const text = await r.text();
36
+ try {
37
+ return JSON.parse(text);
38
+ }
39
+ catch {
40
+ throw new Error(`GET ${url} -> ${r.status}, not JSON (a sign-in page? the route needs no auth): ${text.slice(0, 80)}`);
41
+ }
32
42
  }
33
43
  /** 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) {
44
+ * `base` (`.../visualiser`). An unreachable visualiser reads as 0 with the
45
+ * reason beside it — the caller's second sample, not a throw, decides. */
46
+ export async function composeSample(opts) {
37
47
  const node = opts.node ?? DEFAULT_COMPOSE_NODE;
38
48
  try {
39
49
  const wf = (await getJson(opts.fetch, `${opts.base}/workflow`));
40
50
  let max = 0;
51
+ let roots = 0;
52
+ let nodeSeen = false;
41
53
  for (const root of wf?.rootWorkflows ?? []) {
42
- if (typeof root?.wfid !== "string")
54
+ // The engine numbers its workflows (`wfid: 3339`); a string-only check
55
+ // here skipped every root and the gate read a silent 0 -> 0 everywhere.
56
+ const wfid = root?.wfid;
57
+ if (typeof wfid !== "string" && typeof wfid !== "number")
43
58
  continue;
44
- const summary = await getJson(opts.fetch, `${opts.base}/workflow/${encodeURIComponent(root.wfid)}/summary`);
59
+ roots += 1;
60
+ const summary = await getJson(opts.fetch, `${opts.base}/workflow/${encodeURIComponent(String(wfid))}/summary`);
61
+ if (hasNode(summary, node))
62
+ nodeSeen = true;
45
63
  max = Math.max(max, maxNodeMediaIn(summary, node));
46
64
  }
47
- return max;
65
+ return { frames: max, roots, nodeSeen };
48
66
  }
49
- catch {
50
- return 0;
67
+ catch (e) {
68
+ return { frames: 0, roots: 0, nodeSeen: false, error: e instanceof Error ? e.message : String(e) };
51
69
  }
52
70
  }
71
+ export async function composeFrames(opts) {
72
+ return (await composeSample(opts)).frames;
73
+ }
53
74
  /** Two samples `settleMs` apart: advancing when the second is above the first
54
75
  * and above zero — the fallback card and a stalled feed both read flat. */
55
76
  export async function composeAdvancing(opts) {
56
77
  const node = opts.node ?? DEFAULT_COMPOSE_NODE;
57
- const before = await composeFrames({ fetch: opts.fetch, base: opts.base, node });
78
+ const before = await composeSample({ fetch: opts.fetch, base: opts.base, node });
58
79
  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 };
80
+ const after = await composeSample({ fetch: opts.fetch, base: opts.base, node });
81
+ const error = after.error ?? before.error;
82
+ return {
83
+ advancing: after.frames > before.frames && after.frames > 0,
84
+ before: before.frames,
85
+ after: after.frames,
86
+ node,
87
+ roots: after.roots,
88
+ nodeSeen: after.nodeSeen,
89
+ ...(error !== undefined ? { error } : {}),
90
+ };
61
91
  }
package/demo/gates.js CHANGED
@@ -48,8 +48,19 @@ export function composeAdvancingGate(opts = {}) {
48
48
  node,
49
49
  ...(opts.settleMs !== undefined ? { settleMs: opts.settleMs } : {}),
50
50
  });
51
- if (!r.advancing)
52
- ctx.log(`${node} not advancing yet (${r.before} -> ${r.after})`);
51
+ if (r.error)
52
+ ctx.log(`visualiser unreachable at ${base}: ${r.error}`);
53
+ else if (!r.advancing) {
54
+ // A flat 0 has three causes worth telling apart before anyone reads
55
+ // engine logs: nothing deployed yet, a graph without that node, or a
56
+ // stalled feed into a node that is there.
57
+ const why = r.roots === 0
58
+ ? "; no root workflows deployed yet"
59
+ : r.nodeSeen
60
+ ? ""
61
+ : `; no node named ${node} in ${r.roots} root workflow(s)`;
62
+ ctx.log(`${node} not advancing yet (${r.before} -> ${r.after}${why})`);
63
+ }
53
64
  return r.advancing;
54
65
  },
55
66
  };
package/demo/run.js CHANGED
@@ -421,6 +421,12 @@ export class DemoSession {
421
421
  join(this.storeDir, "norsk-runtime"),
422
422
  "--proxy-port",
423
423
  String(this.ports.proxyPort),
424
+ // A throwaway daemon on a banded port has nothing to protect, and its
425
+ // /instance/<id>/... routes (the visualiser a gate reads, the `open`
426
+ // URLs) must answer without a session: with oauth on, the compose gate
427
+ // read the sign-in page as "0 frames" for 180s (playout CI, 2026-08-29).
428
+ "--proxy-auth",
429
+ "none",
424
430
  "--no-http-redirect",
425
431
  "--no-start-server",
426
432
  ...(this.publicHost ? ["--public-host", this.publicHost] : []),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@norskvideo/ctl-test-harness",
3
- "version": "0.1.22",
3
+ "version": "0.1.24",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {