@norskvideo/ctl-dev-kit 0.2.39 → 0.2.41
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.
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Whether a doc-guide engine tier that exited non-zero failed for real, or only
|
|
4
|
+
* a teardown hook ran out of time after every guide's test had passed. Each
|
|
5
|
+
* product's scripts/doc-guide/regen-manual.sh asks this, from the tier's own
|
|
6
|
+
* output, instead of "are the capture files on disk": that rule passed a
|
|
7
|
+
* composition guide that hung after two captures (2026-10-03) and passed any
|
|
8
|
+
* guide whose assertions failed after its captures were written. The engine
|
|
9
|
+
* tier is also half of the publish gate (dev-kit conventions/build-image.yml),
|
|
10
|
+
* so what it forgives, the gate forgives.
|
|
11
|
+
*
|
|
12
|
+
* Forgiven: every result line a test passed, apart from unnamed hook failures
|
|
13
|
+
* in guides that also passed a test. Anything else -- a named test failing, a
|
|
14
|
+
* guide whose only result is a failed hook (its setup never finished), or no
|
|
15
|
+
* results at all -- is a real failure.
|
|
16
|
+
*
|
|
17
|
+
* Reads both runners' list output: bun test (`(pass) guide > test [ms]`,
|
|
18
|
+
* `(fail) guide > (unnamed)` for a hook) and Playwright (`✓ n [project] ›
|
|
19
|
+
* file:line › guide › test (t)`; an afterAll failing after every test passed
|
|
20
|
+
* belongs to no test, so it leaves only ✓ lines).
|
|
21
|
+
*
|
|
22
|
+
* bun engine-verdict.ts <captured engine-tier output>
|
|
23
|
+
* exit 0 = teardown only (continue), 1 = real failure (abort)
|
|
24
|
+
*
|
|
25
|
+
* Imports only node: it runs inside product repos (see publish/product-channel.ts).
|
|
26
|
+
*/
|
|
27
|
+
import { readFileSync } from "node:fs";
|
|
28
|
+
/** Colour escapes (ESC [ ... m), built from a string: lint refuses a control
|
|
29
|
+
* character in a regex literal. */
|
|
30
|
+
const ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g");
|
|
31
|
+
const BUN = /\((pass|fail)\) (.+?) > (.+?) \[[\d.]+m?s\]\s*$/;
|
|
32
|
+
const PLAYWRIGHT = /([✓✘])\s+\d+ \[[^\]]*\] › [^›]+ › (.+?) › (.+?) \([\d.]+m?s\)\s*$/;
|
|
33
|
+
function results(output) {
|
|
34
|
+
const out = [];
|
|
35
|
+
for (const raw of output.split("\n")) {
|
|
36
|
+
const line = raw.replace(ANSI, "");
|
|
37
|
+
const b = line.match(BUN);
|
|
38
|
+
if (b) {
|
|
39
|
+
out.push({ guide: b[2], test: b[3], passed: b[1] === "pass" });
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
const p = line.match(PLAYWRIGHT);
|
|
43
|
+
if (p)
|
|
44
|
+
out.push({ guide: p[2], test: p[3], passed: p[1] === "✓" });
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
export function engineVerdict(output) {
|
|
49
|
+
const all = results(output);
|
|
50
|
+
if (all.length === 0)
|
|
51
|
+
return { ok: false, reason: "no test results in the engine tier's output" };
|
|
52
|
+
const failedTests = all.filter((r) => !r.passed && r.test !== "(unnamed)");
|
|
53
|
+
const passedGuides = new Set(all.filter((r) => r.passed).map((r) => r.guide));
|
|
54
|
+
const hookOnlyGuides = [
|
|
55
|
+
...new Set(all.filter((r) => !r.passed && r.test === "(unnamed)" && !passedGuides.has(r.guide)).map((r) => r.guide)),
|
|
56
|
+
];
|
|
57
|
+
const problems = [
|
|
58
|
+
...failedTests.map((r) => `failed: ${r.guide} > ${r.test}`),
|
|
59
|
+
...hookOnlyGuides.map((g) => `never ran (only a hook result): ${g}`),
|
|
60
|
+
];
|
|
61
|
+
return problems.length ? { ok: false, reason: problems.join("; ") } : { ok: true, reason: "teardown only" };
|
|
62
|
+
}
|
|
63
|
+
if (import.meta.main) {
|
|
64
|
+
const path = process.argv[2];
|
|
65
|
+
if (!path) {
|
|
66
|
+
console.error("usage: engine-verdict.ts <captured engine-tier output>");
|
|
67
|
+
process.exit(2);
|
|
68
|
+
}
|
|
69
|
+
const verdict = engineVerdict(readFileSync(path, "utf8"));
|
|
70
|
+
console.log(verdict.ok ? `engine tier: ${verdict.reason}` : `engine tier failed: ${verdict.reason}`);
|
|
71
|
+
process.exit(verdict.ok ? 0 : 1);
|
|
72
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@norskvideo/ctl-dev-kit",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.41",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./create-product": "./create-product/create-product.ts",
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
"./doc-guide/build-manual": "./doc-guide/build-manual.js",
|
|
9
9
|
"./doc-guide/bundle": "./doc-guide/bundle.js",
|
|
10
10
|
"./doc-guide/docs-release": "./doc-guide/docs-release.js",
|
|
11
|
+
"./doc-guide/engine-verdict": "./doc-guide/engine-verdict.js",
|
|
11
12
|
"./doc-guide/guides-config": "./doc-guide/guides-config.js",
|
|
12
13
|
"./doc-guide/instance-proxy": "./doc-guide/instance-proxy.js",
|
|
13
14
|
"./doc-guide/live-browser": "./doc-guide/live-browser.js",
|
|
@@ -16,16 +16,72 @@
|
|
|
16
16
|
* are not reliable yet: it skips only the guide half of the gate, must give a
|
|
17
17
|
* reason, and the reason travels into the published row, so nobody reads
|
|
18
18
|
* "passed" off an image whose guides never ran.
|
|
19
|
+
*
|
|
20
|
+
* Self-contained on purpose: it imports nothing outside node and this package.
|
|
21
|
+
* Products resolve the dev-kit's siblings from their OWN lockfiles, so a sibling
|
|
22
|
+
* import here bound the writer to whatever schema version a product happened to
|
|
23
|
+
* lock -- the first gated publish in every product died on a missing export.
|
|
24
|
+
* The object's contract is @norskvideo/ctl-product-template-schema's
|
|
25
|
+
* product-channels; product-channel.test.ts parses this module's output with it.
|
|
19
26
|
*/
|
|
20
27
|
import { existsSync, readFileSync } from "node:fs";
|
|
21
|
-
import {
|
|
22
|
-
type ProductChannelRow,
|
|
23
|
-
ProductChannelSchema,
|
|
24
|
-
parseProductChannels,
|
|
25
|
-
withChannelRow,
|
|
26
|
-
} from "@norskvideo/ctl-product-template-schema/product-channels";
|
|
27
28
|
import { builtImageName } from "../licence/check-template.ts";
|
|
28
29
|
|
|
30
|
+
const CHANNELS = ["nightly", "rc", "latest"] as const;
|
|
31
|
+
type Channel = (typeof CHANNELS)[number];
|
|
32
|
+
|
|
33
|
+
export interface ProductChannelRow {
|
|
34
|
+
image: string;
|
|
35
|
+
sha: string;
|
|
36
|
+
publishedAt: string;
|
|
37
|
+
runtimeImages: string[];
|
|
38
|
+
gate: { checks: "passed"; guides: "passed" | { skipped: string }; run?: string };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface ProductChannels {
|
|
42
|
+
schemaVersion: 1;
|
|
43
|
+
product: string;
|
|
44
|
+
imageRef: string;
|
|
45
|
+
updated: string;
|
|
46
|
+
channels: Partial<Record<Channel, unknown>>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The current object, checked only as far as the merge needs: other channels'
|
|
50
|
+
* rows are carried through untouched, so a newer writer's fields survive. */
|
|
51
|
+
function parseCurrent(text: string): ProductChannels {
|
|
52
|
+
const raw = JSON.parse(text) as Partial<ProductChannels>;
|
|
53
|
+
if (raw.schemaVersion !== 1) throw new Error(`channels object has schemaVersion ${raw.schemaVersion}, not 1`);
|
|
54
|
+
if (typeof raw.product !== "string" || typeof raw.imageRef !== "string") {
|
|
55
|
+
throw new Error("channels object has no product/imageRef");
|
|
56
|
+
}
|
|
57
|
+
if (typeof raw.channels !== "object" || raw.channels === null) throw new Error("channels object has no channels");
|
|
58
|
+
return raw as ProductChannels;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** `current` with `channel` set to `row`; every other channel is kept. */
|
|
62
|
+
export function withChannelRow(
|
|
63
|
+
current: ProductChannels | null,
|
|
64
|
+
opts: { product: string; imageRef: string; channel: Channel; row: ProductChannelRow; updated: string },
|
|
65
|
+
): ProductChannels {
|
|
66
|
+
if (current && (current.product !== opts.product || current.imageRef !== opts.imageRef)) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
`channels object belongs to ${current.product} (${current.imageRef}), not ${opts.product} (${opts.imageRef})`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
schemaVersion: 1,
|
|
73
|
+
product: opts.product,
|
|
74
|
+
imageRef: opts.imageRef,
|
|
75
|
+
updated: opts.updated,
|
|
76
|
+
channels: { ...(current?.channels ?? {}), [opts.channel]: opts.row },
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function parseChannel(value: string): Channel {
|
|
81
|
+
if (!(CHANNELS as readonly string[]).includes(value)) throw new Error(`not a channel: ${value}`);
|
|
82
|
+
return value as Channel;
|
|
83
|
+
}
|
|
84
|
+
|
|
29
85
|
export type GuideGate = { run: true } | { run: false; reason: string };
|
|
30
86
|
|
|
31
87
|
/** Whether the guides gate this product's publish, from its opt-out file. */
|
|
@@ -106,20 +162,12 @@ if (import.meta.main) {
|
|
|
106
162
|
console.log(productForImage(readFileSync(need("template"), "utf8"), need("image")));
|
|
107
163
|
} else if (cmd === "row") {
|
|
108
164
|
const currentText = readIfPresent(flag("current"));
|
|
109
|
-
|
|
110
|
-
if (currentText !== undefined) {
|
|
111
|
-
const parsed = parseProductChannels(JSON.parse(currentText));
|
|
112
|
-
if (!parsed.ok) {
|
|
113
|
-
console.error(`product-channel row: the current object does not parse:\n${parsed.error}`);
|
|
114
|
-
process.exit(1);
|
|
115
|
-
}
|
|
116
|
-
current = parsed.value;
|
|
117
|
-
}
|
|
165
|
+
const current = currentText === undefined ? null : parseCurrent(currentText);
|
|
118
166
|
const skipped = flag("skipped");
|
|
119
167
|
const merged = withChannelRow(current, {
|
|
120
168
|
product: need("product"),
|
|
121
169
|
imageRef: need("image-ref"),
|
|
122
|
-
channel:
|
|
170
|
+
channel: parseChannel(need("channel")),
|
|
123
171
|
updated: need("published-at"),
|
|
124
172
|
row: gateRow({
|
|
125
173
|
image: need("image"),
|