@norskvideo/ctl-test-harness 0.1.31 → 0.1.33

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.
Files changed (3) hide show
  1. package/package.json +1 -1
  2. package/smoke.d.ts +35 -0
  3. package/smoke.js +71 -6
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@norskvideo/ctl-test-harness",
3
- "version": "0.1.31",
3
+ "version": "0.1.33",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
package/smoke.d.ts CHANGED
@@ -41,6 +41,12 @@ export interface SmokeContext {
41
41
  interface Structural {
42
42
  structural?: boolean;
43
43
  }
44
+ /** The manifest's nav slot, as ctl's product page renders it. */
45
+ export interface SidebarEntry {
46
+ label: string;
47
+ icon?: string;
48
+ route: string;
49
+ }
44
50
  export type SmokeObservation = Structural & ({
45
51
  kind: "components";
46
52
  ids: string[];
@@ -137,6 +143,21 @@ export interface SmokeDeps {
137
143
  ensureNetwork(): ReturnType<typeof ensureRunnerOnNetwork>;
138
144
  supportsNoPublish(): Promise<boolean>;
139
145
  containerUser(): string | undefined;
146
+ /** Diagnostic dump for a satisfied-phase timeout, taken while the instance
147
+ * is still up. Optional so an existing custom-deps consumer keeps working;
148
+ * the default reads every container carrying the instance's label. */
149
+ diagnose?(instanceId: string): string;
150
+ /** GET a manifest-declared route through the daemon's product proxy — the
151
+ * same URL ctl's product page links to. */
152
+ fetchProductRoute(opts: {
153
+ daemonPort: number;
154
+ product: string;
155
+ route: string;
156
+ storeDir: string;
157
+ }): Promise<{
158
+ status: number;
159
+ body: string;
160
+ }>;
140
161
  log(line: string): void;
141
162
  }
142
163
  /** Per-slug port band, above the product integration harnesses' own bands:
@@ -148,6 +169,20 @@ export interface SmokePorts extends BaseHarnessPorts {
148
169
  }
149
170
  export declare function smokePorts(slug: string, overrides?: Partial<SmokePorts>): SmokePorts;
150
171
  export declare const defaultSmokeDeps: SmokeDeps;
172
+ /** A manifest's `ui.sidebarEntries` are what ctl's product page renders as
173
+ * links, under /products/<name>/<route>. Nothing else checks that the product
174
+ * serves them, so a button that 404s ships silently — the shape 04c calls a
175
+ * declaration nothing checks. Image registrations only: the routes are served
176
+ * out of the image (the docs bundle is baked by `build:image`), so a dev-url
177
+ * run would fail on an artefact that is not under test. */
178
+ export declare function assertManifestRoutesServed(opts: {
179
+ entries: readonly SidebarEntry[];
180
+ product: string;
181
+ daemonPort: number;
182
+ storeDir: string;
183
+ fetchRoute: SmokeDeps["fetchProductRoute"];
184
+ log(line: string): void;
185
+ }): Promise<void>;
151
186
  /** Whether an observation depends on a source being pumped. Structural ones
152
187
  * (`components`, or anything the spec marks `structural`) run the satisfied
153
188
  * phase only. */
package/smoke.js CHANGED
@@ -18,8 +18,9 @@
18
18
  // not a dependency of this package (daemon.ts's cleanupDaemon has the same
19
19
  // rule); a product wanting typed commands bridges with toArgv in its own spec.
20
20
  import { spawnSync } from "node:child_process";
21
- import { existsSync, writeFileSync } from "node:fs";
21
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
22
22
  import { basename, dirname, join } from "node:path";
23
+ import { dumpInstanceContainerLogs } from "./container-logs.js";
23
24
  import { ensureRunnerOnNetwork, netReachMode, STUDIO_INTERNAL_PORT, studioBaseFrom, } from "./container-net.js";
24
25
  import { cleanupDaemon, requireLicenseFile, runCli, startDaemon, } from "./daemon.js";
25
26
  import { hashSlot } from "./harness-config.js";
@@ -81,8 +82,49 @@ export const defaultSmokeDeps = {
81
82
  ensureNetwork: () => ensureRunnerOnNetwork(),
82
83
  supportsNoPublish: ctlSupportsNoPublish,
83
84
  containerUser: runnerContainerUser,
85
+ diagnose: (instanceId) => dumpInstanceContainerLogs(instanceId),
86
+ fetchProductRoute: async ({ daemonPort, product, route, storeDir }) => {
87
+ // The daemon fronts the product proxy with the same auth guard as /api/*,
88
+ // so the secret travels when the store has one (source-pump's rule).
89
+ const secretFile = join(storeDir, "proxy-secret");
90
+ const secret = existsSync(secretFile) ? readFileSync(secretFile, "utf8").trim() : "";
91
+ const url = `http://localhost:${daemonPort}/products/${encodeURIComponent(product)}${route.startsWith("/") ? route : `/${route}`}`;
92
+ const res = await fetch(url, {
93
+ headers: secret ? { "x-norsk-proxy": secret } : {},
94
+ redirect: "manual",
95
+ signal: AbortSignal.timeout(10_000),
96
+ });
97
+ return { status: res.status, body: (await res.text()).slice(0, 200) };
98
+ },
84
99
  log: (line) => console.log(`[smoke] ${line}`),
85
100
  };
101
+ /** A manifest's `ui.sidebarEntries` are what ctl's product page renders as
102
+ * links, under /products/<name>/<route>. Nothing else checks that the product
103
+ * serves them, so a button that 404s ships silently — the shape 04c calls a
104
+ * declaration nothing checks. Image registrations only: the routes are served
105
+ * out of the image (the docs bundle is baked by `build:image`), so a dev-url
106
+ * run would fail on an artefact that is not under test. */
107
+ export async function assertManifestRoutesServed(opts) {
108
+ if (opts.entries.length === 0) {
109
+ opts.log("manifest declares no sidebar routes to check");
110
+ return;
111
+ }
112
+ for (const entry of opts.entries) {
113
+ const where = `/products/${opts.product}${entry.route}`;
114
+ const { status, body } = await opts.fetchRoute({
115
+ daemonPort: opts.daemonPort,
116
+ product: opts.product,
117
+ route: entry.route,
118
+ storeDir: opts.storeDir,
119
+ });
120
+ // A redirect is served: /docs 301s to /docs/, which is the link working.
121
+ if (status >= 400) {
122
+ throw new Error(`manifest sidebar entry "${entry.label}" points at ${where}, which the product does not serve ` +
123
+ `(${status}: ${body.replace(/\s+/g, " ").trim().slice(0, 120)})`);
124
+ }
125
+ opts.log(`sidebar entry "${entry.label}" -> ${where} (${status})`);
126
+ }
127
+ }
86
128
  function describeObservation(o) {
87
129
  switch (o.kind) {
88
130
  case "components":
@@ -228,6 +270,19 @@ export async function runProductSmoke(slug, spec, deps = defaultSmokeDeps) {
228
270
  throw new Error(`product add registered '${added.name}', expected '${spec.product.name}'`);
229
271
  }
230
272
  registered = true;
273
+ if ("image" in spec.product.register) {
274
+ await assertManifestRoutesServed({
275
+ entries: added.manifest?.ui?.sidebarEntries ?? [],
276
+ product: spec.product.name,
277
+ daemonPort: ports.daemonPort,
278
+ storeDir,
279
+ fetchRoute: deps.fetchProductRoute,
280
+ log: (line) => deps.log(`${slug}: ${line}`),
281
+ });
282
+ }
283
+ else {
284
+ deps.log(`${slug}: dev-url registration — manifest routes not checked (they are served out of the image)`);
285
+ }
231
286
  // Template.
232
287
  let templateName;
233
288
  if ("name" in spec.template) {
@@ -304,11 +359,21 @@ export async function runProductSmoke(slug, spec, deps = defaultSmokeDeps) {
304
359
  });
305
360
  }
306
361
  for (const o of spec.observe) {
307
- await pollUntil(() => observationSatisfied(o, ctx, deps.readers), {
308
- timeoutMs: timeouts.observeMs,
309
- intervalMs: 1000,
310
- label: `${describeObservation(o)} never satisfied${vacuous ? "" : " while the sources pumped"}`,
311
- });
362
+ try {
363
+ await pollUntil(() => observationSatisfied(o, ctx, deps.readers), {
364
+ timeoutMs: timeouts.observeMs,
365
+ intervalMs: 1000,
366
+ label: `${describeObservation(o)} never satisfied${vacuous ? "" : " while the sources pumped"}`,
367
+ });
368
+ }
369
+ catch (e) {
370
+ // Only this phase: a not-yet or cleared failure is a statement about the
371
+ // observable itself, where a container dump adds noise, not cause. Here
372
+ // the predicate stayed false for a reason that lives in the containers
373
+ // -- and cleanup removes them moments from now.
374
+ const dump = deps.diagnose?.(instanceId) ?? "";
375
+ throw dump ? new Error(`${e instanceof Error ? e.message : String(e)}\n${dump}`, { cause: e }) : e;
376
+ }
312
377
  }
313
378
  deps.log(`${slug}: satisfied phase passed for ${spec.observe.length} observation(s)`);
314
379
  if (!vacuous) {