mirror of
https://github.com/CopilotKit/CopilotKit.git
synced 2026-09-14 16:26:20 +08:00
a7499569d7
Add showcase/scripts/sync-promote-service-options.ts: generates the promote workflow's service `choice` options from the SSOT (railway-envs.ts), spliced between BEGIN/END markers in showcase_promote.yml. Fail-loud throughout — every emitted token must resolve to exactly one service under the resolve-step predicate (name|dispatchName match AND probe.prod), tokens are YAML-safe, args are strict (a typo'd flag cannot trigger a destructive write), and markers are validated before any rewrite. Wire it into a lefthook pre-commit hook (regenerate + restage; set -e so a failed regen blocks the commit) and an advisory (never-failing) drift check in showcase_validate.yml. Vitest coverage for ordering, exclusion, collision/ambiguity guards, marker errors, exit codes, idempotency, and the import-side-effect guard.
626 lines
26 KiB
TypeScript
626 lines
26 KiB
TypeScript
import { execFileSync, spawnSync } from "node:child_process";
|
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join, resolve } from "node:path";
|
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
import { parse as parseYaml } from "yaml";
|
|
import { SERVICES } from "../railway-envs";
|
|
// Importing the module MUST NOT trigger main() (the robust import.meta.url
|
|
// guard ensures the file only runs main when invoked directly, never on
|
|
// import). If this import wrote the workflow, the suite would have side
|
|
// effects on the real file — see the dedicated guard test below.
|
|
import { computeOptionTokens, SENTINEL } from "../sync-promote-service-options";
|
|
|
|
const SCRIPT = resolve(__dirname, "..", "sync-promote-service-options.ts");
|
|
|
|
// The exact diagnostic fragments the generator emits for each marker-error
|
|
// case. Tests assert against these specific strings (not a generic /marker/i)
|
|
// so a regression that swaps one diagnostic for another is caught.
|
|
const DIAG_NOT_FOUND = "generated marker block not found";
|
|
const DIAG_DUPLICATE = "duplicate generated markers";
|
|
const DIAG_OUT_OF_ORDER = "malformed marker block";
|
|
|
|
// A minimal but representative workflow fixture: the real showcase_promote.yml
|
|
// has many more jobs, but for the generator's purposes all that matters is
|
|
// the `service:` input block carrying the generated marker region. We keep
|
|
// unrelated content around the markers to prove it is preserved verbatim.
|
|
const BEGIN =
|
|
"# >>> BEGIN GENERATED service options (showcase/scripts/sync-promote-service-options.ts) — DO NOT EDIT";
|
|
const END = "# <<< END GENERATED service options";
|
|
|
|
function fixture(generatedBody: string): string {
|
|
return [
|
|
'name: "Showcase: Promote (staging → prod)"',
|
|
"",
|
|
"on:",
|
|
" workflow_dispatch:",
|
|
" inputs:",
|
|
" service:",
|
|
' description: "Service to promote"',
|
|
" required: true",
|
|
" type: choice",
|
|
` ${BEGIN}`,
|
|
generatedBody,
|
|
` ${END}`,
|
|
" digest:",
|
|
' description: "Optional digest override"',
|
|
" required: false",
|
|
" type: string",
|
|
"",
|
|
"jobs:",
|
|
" resolve-targets:",
|
|
" runs-on: ubuntu-latest",
|
|
" steps:",
|
|
" - run: echo hi",
|
|
"",
|
|
].join("\n");
|
|
}
|
|
|
|
// A synthetic services map shaped like SERVICES (Record<string, ServiceEntry
|
|
// & { dispatchName?: string }>). Only the fields computeOptionTokens reads
|
|
// (`probe.prod` and `dispatchName`) carry meaningful values here; the rest are
|
|
// filler to satisfy the type. Keys are deliberately ordered so that sorting by
|
|
// SSOT KEY would produce a DIFFERENT order than sorting by the RENDERED token,
|
|
// proving the sort is by rendered token.
|
|
const SYNTHETIC: typeof SERVICES = {
|
|
// SSOT key "zeta" but renders as "alpha" via dispatchName → must sort FIRST.
|
|
zeta: {
|
|
serviceId: "s-zeta",
|
|
prodInstanceId: "p-zeta",
|
|
stagingInstanceId: "st-zeta",
|
|
ciBuilt: true,
|
|
gateValidated: true,
|
|
dispatchName: "alpha",
|
|
domains: { staging: "zeta.staging", prod: "zeta.prod" },
|
|
probe: { staging: true, prod: true, driver: "harness" },
|
|
},
|
|
// SSOT key "beta", no dispatchName → token falls back to the bare key
|
|
// "beta", which sorts AFTER the rendered token "alpha" (from key "zeta").
|
|
beta: {
|
|
serviceId: "s-beta",
|
|
prodInstanceId: "p-beta",
|
|
stagingInstanceId: "st-beta",
|
|
ciBuilt: true,
|
|
gateValidated: true,
|
|
// no dispatchName → token falls back to the SSOT key "beta".
|
|
domains: { staging: "beta.staging", prod: "beta.prod" },
|
|
probe: { staging: true, prod: true, driver: "harness" },
|
|
},
|
|
// prod:false → MUST be excluded from the dropdown entirely.
|
|
excluded: {
|
|
serviceId: "s-excl",
|
|
prodInstanceId: "p-excl",
|
|
stagingInstanceId: "st-excl",
|
|
ciBuilt: true,
|
|
gateValidated: true,
|
|
dispatchName: "aaa-would-sort-first",
|
|
domains: { staging: "excl.staging", prod: "excl.prod" },
|
|
probe: { staging: true, prod: false, driver: "harness" },
|
|
},
|
|
};
|
|
|
|
describe("computeOptionTokens (unit)", () => {
|
|
it("excludes probe.prod:false, sorts by rendered token, prefers dispatchName", () => {
|
|
const tokens = computeOptionTokens(SYNTHETIC);
|
|
|
|
// (a) The prod:false entry is excluded even though its dispatchName
|
|
// ("aaa-would-sort-first") would otherwise sort to the very front.
|
|
expect(tokens).not.toContain("aaa-would-sort-first");
|
|
expect(tokens).not.toContain("excluded");
|
|
|
|
// (c) dispatchName wins over the SSOT key: "zeta" surfaces as "alpha",
|
|
// and its bare key never appears.
|
|
expect(tokens).toContain("alpha");
|
|
expect(tokens).not.toContain("zeta");
|
|
// "beta" has no dispatchName → its key is used verbatim.
|
|
expect(tokens).toContain("beta");
|
|
|
|
// (b) Ordering: SENTINEL, then "all", then promotable tokens sorted
|
|
// alphabetically by the RENDERED token. Sorting by SSOT key would have
|
|
// put "beta" before "alpha" (because key "beta" < key "zeta"); the
|
|
// rendered-token sort puts "alpha" first.
|
|
expect(tokens).toEqual([SENTINEL, "all", "alpha", "beta"]);
|
|
});
|
|
|
|
it("throws (fail loud) on a YAML-unsafe rendered token, naming token + SSOT key", () => {
|
|
// A future SSOT entry whose dispatchName carries a YAML-special char (here
|
|
// a colon + space) would, if interpolated raw, emit a malformed workflow
|
|
// the pre-commit hook silently `git add`s. The generator must fail loud.
|
|
const badMap: typeof SERVICES = {
|
|
gamma: {
|
|
serviceId: "s-gamma",
|
|
prodInstanceId: "p-gamma",
|
|
stagingInstanceId: "st-gamma",
|
|
ciBuilt: true,
|
|
gateValidated: true,
|
|
dispatchName: "bad: token", // colon + space → NOT YAML-safe
|
|
domains: { staging: "g.staging", prod: "g.prod" },
|
|
probe: { staging: true, prod: true, driver: "harness" },
|
|
},
|
|
};
|
|
expect(() => computeOptionTokens(badMap)).toThrow(/not YAML-safe/);
|
|
// The diagnostic names the offending token and its SSOT key.
|
|
expect(() => computeOptionTokens(badMap)).toThrow(/bad: token/);
|
|
expect(() => computeOptionTokens(badMap)).toThrow(/gamma/);
|
|
});
|
|
|
|
it("throws (fail loud) on a duplicate assembled token, naming the offender", () => {
|
|
// Two distinct SSOT keys whose RENDERED tokens collide: key "dup-a" has
|
|
// dispatchName "dup", and key "dup" has no dispatchName → both render as
|
|
// "dup". assertDispatchNamesUnique would NOT catch this (it only compares
|
|
// dispatchName-vs-dispatchName), so the generator must guard the
|
|
// assembled list itself or it would emit a duplicate `- dup` option that
|
|
// the pre-commit hook silently `git add`s. The exact-match resolve guard
|
|
// catches it because token "dup" matches BOTH services (key "dup" by
|
|
// name, key "dup-a" by dispatchName) → resolves to 2, not 1.
|
|
const dupMap: typeof SERVICES = {
|
|
"dup-a": {
|
|
serviceId: "s-dupa",
|
|
prodInstanceId: "p-dupa",
|
|
stagingInstanceId: "st-dupa",
|
|
ciBuilt: true,
|
|
gateValidated: true,
|
|
dispatchName: "dup",
|
|
domains: { staging: "dupa.staging", prod: "dupa.prod" },
|
|
probe: { staging: true, prod: true, driver: "harness" },
|
|
},
|
|
dup: {
|
|
serviceId: "s-dup",
|
|
prodInstanceId: "p-dup",
|
|
stagingInstanceId: "st-dup",
|
|
ciBuilt: true,
|
|
gateValidated: true,
|
|
// no dispatchName → renders as the bare key "dup", colliding with
|
|
// the dispatchName above.
|
|
domains: { staging: "dup.staging", prod: "dup.prod" },
|
|
probe: { staging: true, prod: true, driver: "harness" },
|
|
},
|
|
};
|
|
expect(() => computeOptionTokens(dupMap)).toThrow(/resolves to 2 services/);
|
|
expect(() => computeOptionTokens(dupMap)).toThrow(/"dup"/);
|
|
});
|
|
|
|
it("throws (fail loud) on a CROSS-collision the dedupe guard misses: distinct rendered tokens that resolve ambiguously", () => {
|
|
// The bug the exact-match resolve guard exists to catch, which the OLD
|
|
// rendered-token dedupe guard MISSED:
|
|
// - Service A ("svc-a") has dispatchName "foo" → renders token "foo".
|
|
// - Service B ("foo") has dispatchName "bar" → renders token "bar".
|
|
// The two RENDERED tokens ("foo" and "bar") are DISTINCT, so a dedupe
|
|
// guard sees no duplicate and passes. But under the workflow resolve
|
|
// predicate (s.name === T || s.dispatchName === T), token "foo" matches
|
|
// BOTH A (by dispatchName "foo") AND B (by name "foo") → ambiguous and
|
|
// un-promotable. The generator must fail loud, naming the ambiguous
|
|
// token and both colliding service keys.
|
|
const crossMap: typeof SERVICES = {
|
|
"svc-a": {
|
|
serviceId: "s-a",
|
|
prodInstanceId: "p-a",
|
|
stagingInstanceId: "st-a",
|
|
ciBuilt: true,
|
|
gateValidated: true,
|
|
dispatchName: "foo", // renders token "foo"
|
|
domains: { staging: "a.staging", prod: "a.prod" },
|
|
probe: { staging: true, prod: true, driver: "harness" },
|
|
},
|
|
foo: {
|
|
serviceId: "s-foo",
|
|
prodInstanceId: "p-foo",
|
|
stagingInstanceId: "st-foo",
|
|
ciBuilt: true,
|
|
gateValidated: true,
|
|
dispatchName: "bar", // renders token "bar" (DISTINCT from "foo")
|
|
domains: { staging: "foo.staging", prod: "foo.prod" },
|
|
probe: { staging: true, prod: true, driver: "harness" },
|
|
},
|
|
};
|
|
// Token "foo" resolves to 2 services (svc-a by dispatchName, foo by name).
|
|
expect(() => computeOptionTokens(crossMap)).toThrow(
|
|
/resolves to 2 services/,
|
|
);
|
|
// Diagnostic names the ambiguous token and BOTH colliding service keys.
|
|
expect(() => computeOptionTokens(crossMap)).toThrow(/"foo"/);
|
|
expect(() => computeOptionTokens(crossMap)).toThrow(/svc-a/);
|
|
});
|
|
|
|
it("does NOT throw when a prod:false service shares a token with a prod-eligible service (resolve step also filters probe.prod)", () => {
|
|
// The generator's uniqueness guard mirrors the workflow resolve step's
|
|
// predicate EXACTLY — including its `probe.prod === true` filter. So a
|
|
// non-prod service sharing a name/dispatchName token with a prod-eligible
|
|
// service is NOT a collision: the resolve step would filter the non-prod
|
|
// candidate out and resolve the token unambiguously to the single
|
|
// prod-eligible service.
|
|
// - Service X ("svc-x", probe.prod:true) has dispatchName "shared" →
|
|
// renders + emits token "shared".
|
|
// - Service Y ("shared", probe.prod:false) has name "shared" → would
|
|
// match token "shared" by name, but is filtered out by the prod guard.
|
|
// Under an all-services match filter, token "shared" would resolve to 2
|
|
// services (X by dispatchName, Y by name) and THROW (false positive). With
|
|
// the probe.prod restriction it resolves to exactly 1 (X) and must pass.
|
|
const prodFilteredMap: typeof SERVICES = {
|
|
"svc-x": {
|
|
serviceId: "s-x",
|
|
prodInstanceId: "p-x",
|
|
stagingInstanceId: "st-x",
|
|
ciBuilt: true,
|
|
gateValidated: true,
|
|
dispatchName: "shared", // renders + emits token "shared"
|
|
domains: { staging: "x.staging", prod: "x.prod" },
|
|
probe: { staging: true, prod: true, driver: "harness" },
|
|
},
|
|
shared: {
|
|
serviceId: "s-shared",
|
|
prodInstanceId: "p-shared",
|
|
stagingInstanceId: "st-shared",
|
|
ciBuilt: true,
|
|
gateValidated: true,
|
|
// name "shared" collides with X's emitted token, but probe.prod:false
|
|
// means the resolve step (and now the guard) filters it out.
|
|
domains: { staging: "shared.staging", prod: "shared.prod" },
|
|
probe: { staging: true, prod: false, driver: "harness" },
|
|
},
|
|
};
|
|
// The guard must NOT throw: X resolves uniquely once Y is excluded.
|
|
expect(() => computeOptionTokens(prodFilteredMap)).not.toThrow();
|
|
// X's token IS emitted; the prod:false service Y never appears.
|
|
const tokens = computeOptionTokens(prodFilteredMap);
|
|
expect(tokens).toContain("shared");
|
|
expect(tokens).toEqual([SENTINEL, "all", "shared"]);
|
|
});
|
|
|
|
it("throws (fail loud) when a real-service token collides with a reserved literal", () => {
|
|
// A service whose rendered token is exactly "all" (the explicit
|
|
// whole-fleet literal). If allowed through it would emit a SECOND `- all`
|
|
// option and masquerade as the reserved value the resolve step
|
|
// special-cases. The generator must fail loud, naming the offender.
|
|
const reservedCollisionMap: typeof SERVICES = {
|
|
delta: {
|
|
serviceId: "s-delta",
|
|
prodInstanceId: "p-delta",
|
|
stagingInstanceId: "st-delta",
|
|
ciBuilt: true,
|
|
gateValidated: true,
|
|
dispatchName: "all", // collides with the reserved "all" literal
|
|
domains: { staging: "d.staging", prod: "d.prod" },
|
|
probe: { staging: true, prod: true, driver: "harness" },
|
|
},
|
|
};
|
|
expect(() => computeOptionTokens(reservedCollisionMap)).toThrow(
|
|
/reserved literal/,
|
|
);
|
|
expect(() => computeOptionTokens(reservedCollisionMap)).toThrow(/"all"/);
|
|
});
|
|
});
|
|
|
|
describe("sync-promote-service-options", () => {
|
|
let workDir: string;
|
|
let wfPath: string;
|
|
|
|
beforeEach(() => {
|
|
workDir = mkdtempSync(join(tmpdir(), "sync-promote-"));
|
|
wfPath = join(workDir, "showcase_promote.yml");
|
|
});
|
|
|
|
afterEach(() => {
|
|
rmSync(workDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it("rewrites a drifted options block to the exact expected ordered list", () => {
|
|
// Seed with deliberately wrong/stale content inside the markers.
|
|
writeFileSync(
|
|
wfPath,
|
|
fixture(
|
|
[
|
|
" default: all",
|
|
" options:",
|
|
" - all",
|
|
" - bogus-stale-entry",
|
|
].join("\n"),
|
|
),
|
|
);
|
|
execFileSync("npx", ["tsx", SCRIPT, `--workflow=${wfPath}`], {
|
|
stdio: "pipe",
|
|
});
|
|
const after = readFileSync(wfPath, "utf8");
|
|
const doc = parseYaml(after);
|
|
const input = doc.on.workflow_dispatch.inputs.service;
|
|
// The rendered dropdown is the FULL, ordered token list: SENTINEL, then
|
|
// "all", then every probe.prod service rendered as (dispatchName ?? name)
|
|
// and sorted ALPHABETICALLY BY RENDERED TOKEN (not by SSOT key). Deep-equal
|
|
// against the generator's own output so the test tracks the SSOT and the
|
|
// token-sort order exactly.
|
|
expect(input.options).toEqual(computeOptionTokens(SERVICES));
|
|
expect(input.options[0]).toBe("__select_a_service__");
|
|
expect(input.options[1]).toBe("all");
|
|
expect(input.default).toBe("__select_a_service__");
|
|
expect(input.options).toContain("ag2");
|
|
expect(input.options).toContain("pocketbase"); // no dispatchName → name fallback
|
|
expect(input.options).not.toContain("bogus-stale-entry");
|
|
// dispatchName tokens, not SSOT keys, for services that define one.
|
|
expect(input.options).not.toContain("showcase-ag2");
|
|
});
|
|
|
|
it("is idempotent: a second run is byte-identical and --check exits 0", () => {
|
|
writeFileSync(wfPath, fixture(" default: all\n options:"));
|
|
execFileSync("npx", ["tsx", SCRIPT, `--workflow=${wfPath}`], {
|
|
stdio: "pipe",
|
|
});
|
|
const first = readFileSync(wfPath, "utf8");
|
|
execFileSync("npx", ["tsx", SCRIPT, `--workflow=${wfPath}`], {
|
|
stdio: "pipe",
|
|
});
|
|
const second = readFileSync(wfPath, "utf8");
|
|
expect(second).toBe(first);
|
|
const check = spawnSync(
|
|
"npx",
|
|
["tsx", SCRIPT, "--check", `--workflow=${wfPath}`],
|
|
{ encoding: "utf8" },
|
|
);
|
|
expect(check.status).toBe(0);
|
|
});
|
|
|
|
it("--check exits 1 on drift with the re-run diagnostic", () => {
|
|
writeFileSync(
|
|
wfPath,
|
|
fixture(
|
|
[" default: all", " options:", " - all"].join(
|
|
"\n",
|
|
),
|
|
),
|
|
);
|
|
const result = spawnSync(
|
|
"npx",
|
|
["tsx", SCRIPT, "--check", `--workflow=${wfPath}`],
|
|
{ encoding: "utf8" },
|
|
);
|
|
expect(result.status).toBe(1);
|
|
expect(result.stderr).toMatch(/sync-promote-service-options/);
|
|
expect(result.stderr).toMatch(/re-run/i);
|
|
});
|
|
|
|
it("places the sentinel as option[0] and sets default to the sentinel", () => {
|
|
writeFileSync(wfPath, fixture(" options:"));
|
|
execFileSync("npx", ["tsx", SCRIPT, `--workflow=${wfPath}`], {
|
|
stdio: "pipe",
|
|
});
|
|
const doc = parseYaml(readFileSync(wfPath, "utf8"));
|
|
const input = doc.on.workflow_dispatch.inputs.service;
|
|
expect(input.options[0]).toBe("__select_a_service__");
|
|
expect(input.default).toBe("__select_a_service__");
|
|
});
|
|
|
|
it("fails loud (non-zero) when markers are missing — no silent rewrite", () => {
|
|
const noMarkers = [
|
|
"on:",
|
|
" workflow_dispatch:",
|
|
" inputs:",
|
|
" service:",
|
|
' description: "Service to promote"',
|
|
" type: choice",
|
|
"",
|
|
].join("\n");
|
|
writeFileSync(wfPath, noMarkers);
|
|
const result = spawnSync("npx", ["tsx", SCRIPT, `--workflow=${wfPath}`], {
|
|
encoding: "utf8",
|
|
});
|
|
// Render error → exit 3, with the SPECIFIC "not found" diagnostic.
|
|
expect(result.status).toBe(3);
|
|
expect(result.stderr).toContain(DIAG_NOT_FOUND);
|
|
expect(result.stderr).not.toContain(DIAG_DUPLICATE);
|
|
expect(result.stderr).not.toContain(DIAG_OUT_OF_ORDER);
|
|
// The file must be untouched (no silent rewrite of the wrong region).
|
|
expect(readFileSync(wfPath, "utf8")).toBe(noMarkers);
|
|
});
|
|
|
|
it("fails loud when markers are duplicated", () => {
|
|
const dup = [
|
|
" service:",
|
|
" type: choice",
|
|
` ${BEGIN}`,
|
|
" options:",
|
|
` ${END}`,
|
|
" other:",
|
|
" type: choice",
|
|
` ${BEGIN}`,
|
|
" options:",
|
|
` ${END}`,
|
|
"",
|
|
].join("\n");
|
|
writeFileSync(wfPath, dup);
|
|
const result = spawnSync("npx", ["tsx", SCRIPT, `--workflow=${wfPath}`], {
|
|
encoding: "utf8",
|
|
});
|
|
// Render error → exit 3, with the SPECIFIC "duplicate" diagnostic.
|
|
expect(result.status).toBe(3);
|
|
expect(result.stderr).toContain(DIAG_DUPLICATE);
|
|
expect(result.stderr).not.toContain(DIAG_NOT_FOUND);
|
|
expect(result.stderr).not.toContain(DIAG_OUT_OF_ORDER);
|
|
expect(readFileSync(wfPath, "utf8")).toBe(dup);
|
|
});
|
|
|
|
it("fails loud when markers are out of order (END before BEGIN)", () => {
|
|
// Exactly one BEGIN and one END, but END appears FIRST. This must be
|
|
// distinguished from "not found" and "duplicate" with its own diagnostic.
|
|
const outOfOrder = [
|
|
" service:",
|
|
" type: choice",
|
|
` ${END}`,
|
|
" options:",
|
|
` ${BEGIN}`,
|
|
"",
|
|
].join("\n");
|
|
writeFileSync(wfPath, outOfOrder);
|
|
const result = spawnSync("npx", ["tsx", SCRIPT, `--workflow=${wfPath}`], {
|
|
encoding: "utf8",
|
|
});
|
|
// Render error → nonzero exit 3, with the SPECIFIC "malformed" diagnostic.
|
|
expect(result.status).toBe(3);
|
|
expect(result.stderr).toContain(DIAG_OUT_OF_ORDER);
|
|
expect(result.stderr).not.toContain(DIAG_NOT_FOUND);
|
|
expect(result.stderr).not.toContain(DIAG_DUPLICATE);
|
|
// The file must be left untouched.
|
|
expect(readFileSync(wfPath, "utf8")).toBe(outOfOrder);
|
|
});
|
|
|
|
it("exits 2 on a read error (nonexistent --workflow path)", () => {
|
|
const missingPath = join(workDir, "does-not-exist.yml");
|
|
const result = spawnSync(
|
|
"npx",
|
|
["tsx", SCRIPT, `--workflow=${missingPath}`],
|
|
{ encoding: "utf8" },
|
|
);
|
|
// ALL read failures, including a missing file, exit 2 (not 3).
|
|
expect(result.status).toBe(2);
|
|
expect(result.stderr).toContain("failed to read");
|
|
});
|
|
|
|
it("rejects an unknown flag (fail loud, exit 2) instead of silently writing", () => {
|
|
// Seed a valid workflow so the ONLY reason to fail is the bad arg — a
|
|
// typo like `--chek` must NOT silently fall through to a destructive
|
|
// write of the real workflow.
|
|
writeFileSync(wfPath, fixture(" options:"));
|
|
const before = readFileSync(wfPath, "utf8");
|
|
const result = spawnSync(
|
|
"npx",
|
|
["tsx", SCRIPT, "--chek", `--workflow=${wfPath}`],
|
|
{ encoding: "utf8" },
|
|
);
|
|
expect(result.status).toBe(2);
|
|
expect(result.stderr).toContain("Unknown argument: --chek");
|
|
// The file is untouched — no silent write happened.
|
|
expect(readFileSync(wfPath, "utf8")).toBe(before);
|
|
});
|
|
|
|
it("rejects an empty --workflow= value (fail loud, exit 2)", () => {
|
|
const result = spawnSync("npx", ["tsx", SCRIPT, "--workflow="], {
|
|
encoding: "utf8",
|
|
});
|
|
expect(result.status).toBe(2);
|
|
expect(result.stderr).toContain("--workflow= requires a path value");
|
|
});
|
|
|
|
it("rejects a bare --workflow with no value (fail loud, exit 2)", () => {
|
|
const result = spawnSync("npx", ["tsx", SCRIPT, "--workflow", wfPath], {
|
|
encoding: "utf8",
|
|
});
|
|
expect(result.status).toBe(2);
|
|
expect(result.stderr).toContain("--workflow requires a value");
|
|
});
|
|
|
|
it("rejects a duplicate --workflow= (fail loud, exit 2)", () => {
|
|
const other = join(workDir, "other.yml");
|
|
const result = spawnSync(
|
|
"npx",
|
|
["tsx", SCRIPT, `--workflow=${wfPath}`, `--workflow=${other}`],
|
|
{ encoding: "utf8" },
|
|
);
|
|
expect(result.status).toBe(2);
|
|
expect(result.stderr).toContain("--workflow may only be supplied once");
|
|
});
|
|
|
|
it("preserves content outside the markers and keeps valid YAML", () => {
|
|
writeFileSync(wfPath, fixture(" options:"));
|
|
execFileSync("npx", ["tsx", SCRIPT, `--workflow=${wfPath}`], {
|
|
stdio: "pipe",
|
|
});
|
|
const after = readFileSync(wfPath, "utf8");
|
|
// Surrounding structure is intact.
|
|
expect(after).toContain('name: "Showcase: Promote (staging → prod)"');
|
|
expect(after).toContain(" digest:");
|
|
expect(after).toContain(" resolve-targets:");
|
|
// Still parses, and the digest input is untouched.
|
|
const doc = parseYaml(after);
|
|
expect(doc.on.workflow_dispatch.inputs.digest.type).toBe("string");
|
|
expect(doc.on.workflow_dispatch.inputs.service.type).toBe("choice");
|
|
});
|
|
|
|
it("importing the module does NOT trigger main() (import.meta.url guard)", () => {
|
|
// Spawn a tsx process whose entrypoint is an ESM eval that dynamically
|
|
// IMPORTS the module — so process.argv[1] is the eval shim, NOT the module
|
|
// path. The `import.meta.url` guard must therefore see argv[1] !== its own
|
|
// URL and skip main(). If the guard were broken, main() would run on
|
|
// import, read the DEFAULT workflow path, and print "wrote ..."/"already
|
|
// up to date." We assert no such generator output appears, and that the
|
|
// import itself completes (the sentinel prints AFTER the await).
|
|
const importer = join(workDir, "importer.mjs");
|
|
// Point at the .ts source via a file:// URL so tsx transpiles it on import.
|
|
const scriptUrl = `file://${SCRIPT}`;
|
|
writeFileSync(
|
|
importer,
|
|
[
|
|
`await import(${JSON.stringify(scriptUrl)});`,
|
|
`process.stdout.write("IMPORT_OK\\n");`,
|
|
].join("\n"),
|
|
);
|
|
const result = spawnSync("npx", ["tsx", importer], {
|
|
encoding: "utf8",
|
|
cwd: workDir,
|
|
});
|
|
// The import completed cleanly...
|
|
expect(result.status).toBe(0);
|
|
expect(result.stdout).toContain("IMPORT_OK");
|
|
// ...and main() never ran: none of its stdout/stderr signatures appear.
|
|
expect(result.stdout).not.toMatch(/^wrote /m);
|
|
expect(result.stdout).not.toContain("already up to date");
|
|
expect(result.stdout).not.toContain("up to date.");
|
|
expect(result.stderr).not.toContain("failed to read");
|
|
});
|
|
|
|
it("every generated token round-trips to exactly ONE SSOT service (resolve-step contract)", () => {
|
|
const tokens = computeOptionTokens(SERVICES);
|
|
// The literals the resolve step special-cases; they must never collide
|
|
// with a real service token.
|
|
const reserved = new Set([SENTINEL, "all"]);
|
|
|
|
for (const token of tokens) {
|
|
if (reserved.has(token)) continue;
|
|
|
|
// The SAME predicate the workflow's resolve step uses to map a chosen
|
|
// dropdown token back to an SSOT service, applied EXACTLY:
|
|
// (.name === token || .dispatchName === token) && .probe.prod === true
|
|
// The resolve step is `select(.name == $s or .dispatchName == $s) |
|
|
// select(.probe.prod == true)`, so the `probe.prod === true` filter is a
|
|
// load-bearing term of the predicate, NOT a redundant one we can omit.
|
|
// Including it here keeps this round-trip check identical to both the
|
|
// resolve step and the generator's own uniqueness guard: a future
|
|
// non-prod service sharing a name/dispatchName token with a prod-eligible
|
|
// service would resolve unambiguously to the single prod-eligible service
|
|
// (the non-prod candidate is filtered out), so it must NOT be counted as
|
|
// an ambiguous match here either.
|
|
const matches = Object.entries(SERVICES).filter(
|
|
([name, entry]) =>
|
|
entry.probe.prod === true &&
|
|
(name === token || entry.dispatchName === token),
|
|
);
|
|
// Exactly ONE service resolves from each generated token — no ambiguity,
|
|
// no orphans. This pins the generator↔resolve contract.
|
|
expect(
|
|
matches.length,
|
|
`token "${token}" should resolve to exactly one service, got ${matches.length}`,
|
|
).toBe(1);
|
|
}
|
|
|
|
// No real-service token may masquerade as a reserved literal. The loop
|
|
// above `continue`s on reserved tokens, so it can never observe this —
|
|
// assert it DIRECTLY against the SSOT instead: iterate every real
|
|
// service, render its token exactly as computeOptionTokens does
|
|
// (dispatchName ?? key), and require none equals "all" or SENTINEL.
|
|
for (const [name, entry] of Object.entries(SERVICES)) {
|
|
const rendered = entry.dispatchName ?? name;
|
|
expect(
|
|
rendered,
|
|
`SSOT service "${name}" renders token "${rendered}", which collides with reserved literal SENTINEL`,
|
|
).not.toBe(SENTINEL);
|
|
expect(
|
|
rendered,
|
|
`SSOT service "${name}" renders token "${rendered}", which collides with reserved literal "all"`,
|
|
).not.toBe("all");
|
|
}
|
|
|
|
// Sanity: the reserved literals appear exactly once each, in order.
|
|
expect(tokens[0]).toBe(SENTINEL);
|
|
expect(tokens[1]).toBe("all");
|
|
expect(tokens.filter((t) => t === SENTINEL)).toHaveLength(1);
|
|
expect(tokens.filter((t) => t === "all")).toHaveLength(1);
|
|
});
|
|
});
|