chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildResultArtifactName,
|
||||
mergeBuildResultFiles,
|
||||
parseBuildOutputs,
|
||||
shouldRedeployStaging,
|
||||
successSet,
|
||||
} from "../build-outputs";
|
||||
import type { BuildOutcome, ServiceBuildResult } from "../build-outputs";
|
||||
|
||||
const sample: ServiceBuildResult[] = [
|
||||
{ service: "showcase-mastra", status: "success" },
|
||||
{ service: "showcase-ag2", status: "failure" },
|
||||
{ service: "shell-docs", status: "skipped" },
|
||||
{ service: "showcase-aimock", status: "success" },
|
||||
];
|
||||
|
||||
describe("build-outputs", () => {
|
||||
it("parses a JSON array of {service,status} entries", () => {
|
||||
const json = JSON.stringify(sample);
|
||||
expect(parseBuildOutputs(json)).toEqual(sample);
|
||||
});
|
||||
|
||||
it("throws on malformed JSON", () => {
|
||||
expect(() => parseBuildOutputs("not json")).toThrow(/parse/i);
|
||||
});
|
||||
|
||||
it("throws on entries missing fields", () => {
|
||||
expect(() => parseBuildOutputs(JSON.stringify([{ service: "x" }]))).toThrow(
|
||||
/status/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("throws on an unknown status value", () => {
|
||||
const bad = JSON.stringify([{ service: "x", status: "weird" }]);
|
||||
expect(() => parseBuildOutputs(bad)).toThrow(/status/i);
|
||||
});
|
||||
|
||||
it("rejects an empty service string", () => {
|
||||
const bad = JSON.stringify([{ service: "", status: "success" }]);
|
||||
expect(() => parseBuildOutputs(bad)).toThrow(/service/i);
|
||||
});
|
||||
|
||||
it("rejects a whitespace-only service string", () => {
|
||||
const bad = JSON.stringify([{ service: " ", status: "success" }]);
|
||||
expect(() => parseBuildOutputs(bad)).toThrow(/service/i);
|
||||
});
|
||||
|
||||
it("error message for parseBuildOutputs includes the entry index", () => {
|
||||
const bad = JSON.stringify([
|
||||
{ service: "ok", status: "success" },
|
||||
{ service: "x", status: "weird" },
|
||||
]);
|
||||
expect(() => parseBuildOutputs(bad)).toThrow(/\[1\]/);
|
||||
});
|
||||
|
||||
it("successSet returns only services with status === 'success'", () => {
|
||||
expect(successSet(sample).sort()).toEqual(
|
||||
["showcase-aimock", "showcase-mastra"].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it("successSet returns empty when no services succeeded", () => {
|
||||
const allFailed: ServiceBuildResult[] = [
|
||||
{ service: "a", status: "failure" },
|
||||
{ service: "b", status: "failure" },
|
||||
];
|
||||
expect(successSet(allFailed)).toEqual([]);
|
||||
});
|
||||
|
||||
it("type BuildOutcome enumerates success|failure|skipped", () => {
|
||||
const outcomes: BuildOutcome[] = ["success", "failure", "skipped"];
|
||||
expect(outcomes).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildResultArtifactName", () => {
|
||||
it("returns the canonical per-slot artifact name", () => {
|
||||
expect(buildResultArtifactName("showcase-aimock")).toBe(
|
||||
"build-result-showcase-aimock",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects empty service names (would collide with aggregate artifact)", () => {
|
||||
expect(() => buildResultArtifactName("")).toThrow(/service/i);
|
||||
});
|
||||
|
||||
it("rejects whitespace-only service names", () => {
|
||||
expect(() => buildResultArtifactName(" ")).toThrow(/service/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeBuildResultFiles", () => {
|
||||
it("merges per-slot {service,status} JSON payloads into a single array", () => {
|
||||
const slotPayloads = [
|
||||
'{"service":"showcase-mastra","status":"success"}',
|
||||
'{"service":"showcase-ag2","status":"failure"}',
|
||||
'{"service":"showcase-aimock","status":"success"}',
|
||||
];
|
||||
expect(mergeBuildResultFiles(slotPayloads)).toEqual([
|
||||
{ service: "showcase-mastra", status: "success" },
|
||||
{ service: "showcase-ag2", status: "failure" },
|
||||
{ service: "showcase-aimock", status: "success" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("throws when a slot payload is missing service or status", () => {
|
||||
expect(() => mergeBuildResultFiles(['{"service":"x"}'])).toThrow(/status/i);
|
||||
});
|
||||
|
||||
it("throws on an invalid status value", () => {
|
||||
expect(() =>
|
||||
mergeBuildResultFiles(['{"service":"x","status":"weird"}']),
|
||||
).toThrow(/status/i);
|
||||
});
|
||||
|
||||
it("returns an empty array when no slot payloads are provided", () => {
|
||||
expect(mergeBuildResultFiles([])).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects an empty service in a slot payload", () => {
|
||||
expect(() =>
|
||||
mergeBuildResultFiles(['{"service":"","status":"success"}']),
|
||||
).toThrow(/service/i);
|
||||
});
|
||||
|
||||
it("rejects a whitespace-only service in a slot payload", () => {
|
||||
expect(() =>
|
||||
mergeBuildResultFiles(['{"service":" ","status":"success"}']),
|
||||
).toThrow(/service/i);
|
||||
});
|
||||
|
||||
it("throws when duplicate service names appear across slot payloads", () => {
|
||||
const slotPayloads = [
|
||||
'{"service":"dup","status":"failure"}',
|
||||
'{"service":"other","status":"success"}',
|
||||
'{"service":"dup","status":"success"}',
|
||||
];
|
||||
expect(() => mergeBuildResultFiles(slotPayloads)).toThrow(/duplicate/i);
|
||||
expect(() => mergeBuildResultFiles(slotPayloads)).toThrow(/dup/);
|
||||
});
|
||||
|
||||
it("normalizes trailing whitespace in service to its trimmed canonical form", () => {
|
||||
const result = mergeBuildResultFiles([
|
||||
'{"service":"foo ","status":"success"}',
|
||||
]);
|
||||
expect(result).toEqual([{ service: "foo", status: "success" }]);
|
||||
});
|
||||
|
||||
it("normalizes leading whitespace in service to its trimmed canonical form", () => {
|
||||
const result = mergeBuildResultFiles([
|
||||
'{"service":" foo","status":"success"}',
|
||||
]);
|
||||
expect(result).toEqual([{ service: "foo", status: "success" }]);
|
||||
});
|
||||
|
||||
it("parseBuildOutputs also returns the trimmed canonical service", () => {
|
||||
const json = JSON.stringify([{ service: " bar ", status: "success" }]);
|
||||
expect(parseBuildOutputs(json)).toEqual([
|
||||
{ service: "bar", status: "success" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("detects duplicates that differ only by surrounding whitespace", () => {
|
||||
const slotPayloads = [
|
||||
'{"service":"foo","status":"failure"}',
|
||||
'{"service":"foo ","status":"success"}',
|
||||
];
|
||||
expect(() => mergeBuildResultFiles(slotPayloads)).toThrow(/duplicate/i);
|
||||
expect(() => mergeBuildResultFiles(slotPayloads)).toThrow(/foo/);
|
||||
});
|
||||
|
||||
it("rejects an array payload with a clear 'expected object' error (not 'missing service')", () => {
|
||||
expect(() => mergeBuildResultFiles(["[]"])).toThrow(/expected object/i);
|
||||
expect(() => mergeBuildResultFiles(["[]"])).not.toThrow(/missing/i);
|
||||
});
|
||||
|
||||
it("parseBuildOutputs rejects an array nested as an entry with 'expected object'", () => {
|
||||
const bad = JSON.stringify([[]]);
|
||||
expect(() => parseBuildOutputs(bad)).toThrow(/expected object/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldRedeployStaging", () => {
|
||||
it("returns true when at least one service succeeded", () => {
|
||||
expect(
|
||||
shouldRedeployStaging([
|
||||
{ service: "a", status: "success" },
|
||||
{ service: "b", status: "failure" },
|
||||
]),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when every service failed or was skipped", () => {
|
||||
expect(
|
||||
shouldRedeployStaging([
|
||||
{ service: "a", status: "failure" },
|
||||
{ service: "b", status: "skipped" },
|
||||
]),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false on empty input", () => {
|
||||
expect(shouldRedeployStaging([])).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,719 @@
|
||||
/**
|
||||
* Tests for showcase/scripts/lib/manifest.ts.
|
||||
*
|
||||
* parseManifest is the single source of truth for reading and shape-validating
|
||||
* manifest.yaml. Tests pin the tagged-union return shape that the consumers
|
||||
* (audit.ts / validate-parity.ts / capture-previews.ts) rely on.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import { parseManifest, createDemoId, type DemoId } from "../manifest.js";
|
||||
|
||||
// chmod-enforcement probe so tests that rely on EACCES can be cleanly
|
||||
// skipped on CI runners that ignore chmod (running as root, certain
|
||||
// network mounts). Mirrors the probe in validate-pins.test.ts.
|
||||
const isRoot = process.getuid?.() === 0;
|
||||
function probeChmodEnforced(): boolean {
|
||||
if (isRoot) return false;
|
||||
let probe: string | undefined;
|
||||
try {
|
||||
probe = fs.mkdtempSync(path.join(os.tmpdir(), "lib-manifest-chmod-probe-"));
|
||||
fs.chmodSync(probe, 0o000);
|
||||
try {
|
||||
fs.readdirSync(probe);
|
||||
return false;
|
||||
} catch (e) {
|
||||
const err = e as NodeJS.ErrnoException;
|
||||
return err.code === "EACCES";
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
if (probe) {
|
||||
try {
|
||||
fs.chmodSync(probe, 0o755);
|
||||
} catch {
|
||||
// best-effort restore
|
||||
}
|
||||
try {
|
||||
fs.rmSync(probe, { recursive: true, force: true });
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const chmodEnforced = probeChmodEnforced();
|
||||
const cannotEnforceEacces = isRoot || !chmodEnforced;
|
||||
|
||||
function tmpdir(prefix = "lib-manifest-"): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
function write(file: string, body: string): void {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, body, "utf-8");
|
||||
}
|
||||
|
||||
describe("parseManifest", () => {
|
||||
let root: string;
|
||||
|
||||
beforeEach(() => {
|
||||
root = tmpdir();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("returns {kind:'missing'} when the file does not exist", () => {
|
||||
const r = parseManifest(path.join(root, "does-not-exist.yaml"));
|
||||
expect(r.kind).toBe("missing");
|
||||
});
|
||||
|
||||
it("returns {kind:'malformed', subkind:'shape'} for an empty file (yaml.parse → null)", () => {
|
||||
// Empty YAML parses to null, which is not a valid manifest mapping. The
|
||||
// guard must reject it before callers try to read .demos / .deployed.
|
||||
// This is a SHAPE error (YAML parsed, result was null), not a syntax
|
||||
// error.
|
||||
const f = path.join(root, "manifest.yaml");
|
||||
write(f, "");
|
||||
const r = parseManifest(f);
|
||||
expect(r.kind).toBe("malformed");
|
||||
if (r.kind === "malformed") {
|
||||
expect(r.subkind).toBe("shape");
|
||||
expect(r.error.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns {kind:'malformed', subkind:'shape'} for a non-object YAML (bare scalar)", () => {
|
||||
const f = path.join(root, "manifest.yaml");
|
||||
write(f, "42\n");
|
||||
const r = parseManifest(f);
|
||||
expect(r.kind).toBe("malformed");
|
||||
if (r.kind === "malformed") {
|
||||
expect(r.subkind).toBe("shape");
|
||||
}
|
||||
});
|
||||
|
||||
it("returns {kind:'malformed', subkind:'shape'} for an array at top level", () => {
|
||||
const f = path.join(root, "manifest.yaml");
|
||||
write(f, "- a\n- b\n");
|
||||
const r = parseManifest(f);
|
||||
expect(r.kind).toBe("malformed");
|
||||
if (r.kind === "malformed") {
|
||||
expect(r.subkind).toBe("shape");
|
||||
}
|
||||
});
|
||||
|
||||
it("returns {kind:'malformed', subkind:'syntax'} for a syntactically broken YAML", () => {
|
||||
// Subkind discriminator separates YAML parser failures (syntax) from
|
||||
// post-parse shape-mismatch failures (shape). CI can route these
|
||||
// differently — a syntax error is almost always a typo; a shape error
|
||||
// points at a schema-drift issue.
|
||||
const f = path.join(root, "manifest.yaml");
|
||||
write(f, "demos: [[[\nunterminated\n");
|
||||
const r = parseManifest(f);
|
||||
expect(r.kind).toBe("malformed");
|
||||
if (r.kind === "malformed") {
|
||||
expect(r.subkind).toBe("syntax");
|
||||
}
|
||||
});
|
||||
|
||||
it("returns {kind:'malformed', subkind:'shape'} when slug is missing", () => {
|
||||
// slug is required — every consumer (audit.ts / validate-parity.ts /
|
||||
// capture-previews.ts) relies on it. Missing slug = unconditional bug.
|
||||
const f = path.join(root, "manifest.yaml");
|
||||
write(f, "name: My Pkg\n");
|
||||
const r = parseManifest(f);
|
||||
expect(r.kind).toBe("malformed");
|
||||
if (r.kind === "malformed") {
|
||||
expect(r.subkind).toBe("shape");
|
||||
expect(r.error).toMatch(/slug/i);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns {kind:'malformed', subkind:'shape'} when slug is not a string", () => {
|
||||
// YAML `slug: 42` parses to a number; the `as Manifest` cast would
|
||||
// previously have let the number propagate. parseManifest must reject.
|
||||
const f = path.join(root, "manifest.yaml");
|
||||
write(f, "slug: 42\n");
|
||||
const r = parseManifest(f);
|
||||
expect(r.kind).toBe("malformed");
|
||||
if (r.kind === "malformed") {
|
||||
expect(r.subkind).toBe("shape");
|
||||
expect(r.error).toMatch(/slug/i);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns {kind:'malformed', subkind:'shape'} when slug is the empty string", () => {
|
||||
const f = path.join(root, "manifest.yaml");
|
||||
write(f, 'slug: ""\n');
|
||||
const r = parseManifest(f);
|
||||
expect(r.kind).toBe("malformed");
|
||||
if (r.kind === "malformed") {
|
||||
expect(r.subkind).toBe("shape");
|
||||
}
|
||||
});
|
||||
|
||||
it("returns {kind:'malformed', subkind:'shape'} when name is present but not a string", () => {
|
||||
// name is optional but, if present, must be a string. Previously the
|
||||
// `as Manifest` cast would have let a number through silently.
|
||||
const f = path.join(root, "manifest.yaml");
|
||||
write(f, "slug: x\nname: 42\n");
|
||||
const r = parseManifest(f);
|
||||
expect(r.kind).toBe("malformed");
|
||||
if (r.kind === "malformed") {
|
||||
expect(r.subkind).toBe("shape");
|
||||
expect(r.error).toMatch(/name/i);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns {kind:'malformed', subkind:'shape'} when demos is not an array", () => {
|
||||
const f = path.join(root, "manifest.yaml");
|
||||
write(f, "slug: x\ndemos: not-array\n");
|
||||
const r = parseManifest(f);
|
||||
expect(r.kind).toBe("malformed");
|
||||
if (r.kind === "malformed") {
|
||||
expect(r.subkind).toBe("shape");
|
||||
expect(r.error).toMatch(/demos/i);
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts {kind:'ok'} when demos is explicitly null (treated as omitted)", () => {
|
||||
// `demos: ~` (YAML explicit null) is semantically equivalent to
|
||||
// "demos omitted" — the current implementation short-circuits on
|
||||
// `obj.demos != null` so both null and undefined are allowed. This
|
||||
// test locks in that behavior. Prior to the simplification, the code
|
||||
// path guarded on `obj.demos !== undefined` which meant YAML's null
|
||||
// hit the non-array branch and reported "expected array, got object"
|
||||
// (because `typeof null === "object"`) — a confusing message.
|
||||
const f = path.join(root, "manifest.yaml");
|
||||
write(f, "slug: x\ndemos: ~\n");
|
||||
const r = parseManifest(f);
|
||||
expect(r.kind).toBe("ok");
|
||||
});
|
||||
|
||||
it("reports non-nullish non-array demos with precise type (number, not 'object')", () => {
|
||||
// describeType special-cases null/array so the error message is
|
||||
// correct for the JS footgun where `typeof null === "object"` and
|
||||
// `typeof [] === "object"`. Here a number should say "got number".
|
||||
const f = path.join(root, "manifest.yaml");
|
||||
write(f, "slug: x\ndemos: 42\n");
|
||||
const r = parseManifest(f);
|
||||
expect(r.kind).toBe("malformed");
|
||||
if (r.kind === "malformed") {
|
||||
expect(r.error).toMatch(/got number/);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns {kind:'malformed', subkind:'shape'} when a demo entry lacks a string id", () => {
|
||||
const f = path.join(root, "manifest.yaml");
|
||||
write(f, "slug: x\ndemos:\n - noid: true\n");
|
||||
const r = parseManifest(f);
|
||||
expect(r.kind).toBe("malformed");
|
||||
if (r.kind === "malformed") {
|
||||
expect(r.subkind).toBe("shape");
|
||||
expect(r.error).toMatch(/id/i);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns {kind:'malformed', subkind:'shape'} when deployed is present but not a boolean", () => {
|
||||
const f = path.join(root, "manifest.yaml");
|
||||
write(f, 'slug: x\ndeployed: "yes"\n');
|
||||
const r = parseManifest(f);
|
||||
expect(r.kind).toBe("malformed");
|
||||
if (r.kind === "malformed") {
|
||||
expect(r.subkind).toBe("shape");
|
||||
expect(r.error).toMatch(/deployed/i);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns {kind:'ok', manifest} for a valid manifest", () => {
|
||||
const f = path.join(root, "manifest.yaml");
|
||||
write(
|
||||
f,
|
||||
"slug: mypkg\nname: My Pkg\ndeployed: true\ndemos:\n - id: foo\n name: Foo\n - id: bar\n",
|
||||
);
|
||||
const r = parseManifest(f);
|
||||
expect(r.kind).toBe("ok");
|
||||
if (r.kind === "ok") {
|
||||
expect(r.manifest.slug).toBe("mypkg");
|
||||
expect(r.manifest.name).toBe("My Pkg");
|
||||
expect(r.manifest.deployed).toBe(true);
|
||||
expect(r.manifest.demos?.length).toBe(2);
|
||||
expect(r.manifest.demos?.[0].id).toBe("foo");
|
||||
}
|
||||
});
|
||||
|
||||
it("returns {kind:'ok', manifest} with deployed:false preserved", () => {
|
||||
// Symmetric to the deployed:true positive test above. `deployed: false`
|
||||
// is a legal boolean and must parse as {kind:"ok"} with the flag
|
||||
// preserved, not collapsed into undefined. The strict boolean guard
|
||||
// rejects stringly-typed "no", "false", etc., but a real boolean false
|
||||
// must round-trip cleanly.
|
||||
const f = path.join(root, "manifest.yaml");
|
||||
write(f, "slug: mypkg\ndeployed: false\n");
|
||||
const r = parseManifest(f);
|
||||
expect(r.kind).toBe("ok");
|
||||
if (r.kind === "ok") {
|
||||
expect(r.manifest.slug).toBe("mypkg");
|
||||
expect(r.manifest.deployed).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns {kind:'ok'} with an empty demos array when demos is omitted", () => {
|
||||
// `demos` is always set by parseManifest: empty readonly
|
||||
// array when the manifest omits the field, so callers can iterate
|
||||
// without `?.` chaining. The old optional-undefined shape is gone.
|
||||
const f = path.join(root, "manifest.yaml");
|
||||
write(f, "slug: mypkg\n");
|
||||
const r = parseManifest(f);
|
||||
expect(r.kind).toBe("ok");
|
||||
if (r.kind === "ok") {
|
||||
expect(r.manifest.demos).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns {kind:'malformed', subkind:'shape'} when demos[i] is null", () => {
|
||||
// YAML `demos: [~]` parses to `[null]`. The per-entry object guard must
|
||||
// reject this as shape-malformed rather than crashing on `d.id` later.
|
||||
const f = path.join(root, "manifest.yaml");
|
||||
write(f, "slug: x\ndemos:\n - ~\n");
|
||||
const r = parseManifest(f);
|
||||
expect(r.kind).toBe("malformed");
|
||||
if (r.kind === "malformed") {
|
||||
expect(r.subkind).toBe("shape");
|
||||
expect(r.error).toMatch(/demos\[0\].*null/i);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns {kind:'malformed', subkind:'shape'} when demos[i] is a scalar", () => {
|
||||
// YAML `demos: [42]` parses to `[42]`. The per-entry object guard must
|
||||
// describe the concrete scalar type in the error (not "object").
|
||||
const f = path.join(root, "manifest.yaml");
|
||||
write(f, "slug: x\ndemos:\n - 42\n");
|
||||
const r = parseManifest(f);
|
||||
expect(r.kind).toBe("malformed");
|
||||
if (r.kind === "malformed") {
|
||||
expect(r.subkind).toBe("shape");
|
||||
expect(r.error).toMatch(/demos\[0\].*number/i);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns {kind:'malformed', subkind:'shape'} when demos[i].id is an empty string", () => {
|
||||
// Empty ids would round-trip as valid strings but make downstream
|
||||
// demo-path construction (`integrations/<slug>/src/app/demos/<id>`) collapse
|
||||
// onto the demos dir itself. Reject at validation time.
|
||||
const f = path.join(root, "manifest.yaml");
|
||||
write(f, 'slug: x\ndemos:\n - id: ""\n');
|
||||
const r = parseManifest(f);
|
||||
expect(r.kind).toBe("malformed");
|
||||
if (r.kind === "malformed") {
|
||||
expect(r.subkind).toBe("shape");
|
||||
expect(r.error).toMatch(/demos\[0\]\.id.*non-empty/i);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns {kind:'malformed', subkind:'shape'} on duplicate demo ids", () => {
|
||||
// Two demos with the same id used to silently propagate — audit.ts
|
||||
// would build two missing-demo-dir anomalies for the same path and
|
||||
// validate-parity.ts would double-count coverage. Reject up-front.
|
||||
const f = path.join(root, "manifest.yaml");
|
||||
write(
|
||||
f,
|
||||
"slug: x\ndemos:\n - id: agentic-chat\n - id: human-in-the-loop\n - id: agentic-chat\n",
|
||||
);
|
||||
const r = parseManifest(f);
|
||||
expect(r.kind).toBe("malformed");
|
||||
if (r.kind === "malformed") {
|
||||
expect(r.subkind).toBe("shape");
|
||||
expect(r.error).toMatch(/duplicate demo id.*agentic-chat/i);
|
||||
}
|
||||
});
|
||||
|
||||
it("verifies manifest.slug matches the expected dir slug", () => {
|
||||
// parseManifest accepts an optional `dirSlug` parameter so callers
|
||||
// that derive filePath from a slug can detect drift between the
|
||||
// manifest's declared slug and the directory that holds it (copy/
|
||||
// paste error, rename-without-updating). Catch at the parser so
|
||||
// downstream tools don't silently apply the wrong slug.
|
||||
const f = path.join(root, "manifest.yaml");
|
||||
write(f, "slug: bar-pkg\n");
|
||||
const r = parseManifest(f, "foo-pkg");
|
||||
expect(r.kind).toBe("malformed");
|
||||
if (r.kind === "malformed") {
|
||||
expect(r.subkind).toBe("shape");
|
||||
expect(r.error).toMatch(/slug.*mismatch|mismatch.*slug/i);
|
||||
expect(r.error).toContain("foo-pkg");
|
||||
expect(r.error).toContain("bar-pkg");
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts a manifest where the declared slug matches dirSlug", () => {
|
||||
// Positive case for the slug-mismatch check: matching slug and
|
||||
// dirSlug should still return {kind:'ok'}.
|
||||
const f = path.join(root, "manifest.yaml");
|
||||
write(f, "slug: mypkg\n");
|
||||
const r = parseManifest(f, "mypkg");
|
||||
expect(r.kind).toBe("ok");
|
||||
});
|
||||
|
||||
it("skips the slug-mismatch check when dirSlug is omitted (backwards-compatible)", () => {
|
||||
// Callers that don't operate against the packages tree (test
|
||||
// fixtures, programmatic invocations with synthetic paths) should
|
||||
// continue to work unchanged when they don't pass dirSlug.
|
||||
const f = path.join(root, "manifest.yaml");
|
||||
write(f, "slug: whatever\n");
|
||||
const r = parseManifest(f);
|
||||
expect(r.kind).toBe("ok");
|
||||
});
|
||||
|
||||
it("returns {kind:'malformed', subkind:'shape'} when a demo's name field is not a string", () => {
|
||||
// Prior permissive behavior silently coerced non-string `name` to
|
||||
// undefined. Match the strictness applied to top-level `name`:
|
||||
// present-but-wrong-type is a shape malformed.
|
||||
const f = path.join(root, "manifest.yaml");
|
||||
write(f, "slug: x\ndemos:\n - id: foo\n name: 42\n");
|
||||
const r = parseManifest(f);
|
||||
expect(r.kind).toBe("malformed");
|
||||
if (r.kind === "malformed") {
|
||||
expect(r.subkind).toBe("shape");
|
||||
expect(r.error).toMatch(/demos\[0\]\.name/i);
|
||||
}
|
||||
});
|
||||
|
||||
it("freezes the returned manifest and its demos array", () => {
|
||||
// parseManifest must return a frozen Manifest: downstream tools
|
||||
// share the value across buckets and a mutation by one would poison
|
||||
// the rest. Both the outer object and the nested demos array must
|
||||
// be frozen.
|
||||
const f = path.join(root, "manifest.yaml");
|
||||
write(f, "slug: x\ndemos:\n - id: foo\n - id: bar\n");
|
||||
const r = parseManifest(f);
|
||||
expect(r.kind).toBe("ok");
|
||||
if (r.kind === "ok") {
|
||||
expect(Object.isFrozen(r.manifest)).toBe(true);
|
||||
expect(Object.isFrozen(r.manifest.demos)).toBe(true);
|
||||
expect(() => {
|
||||
(r.manifest as unknown as Record<string, unknown>)["new"] = "bogus";
|
||||
}).toThrow();
|
||||
expect(() => {
|
||||
(r.manifest.demos as unknown as unknown[])[0] = { id: "x" };
|
||||
}).toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
// --- demo.route validation (added by R2 fix cycle) ---------------------
|
||||
//
|
||||
// `route` is optional on each demo. When present, it must be a non-empty
|
||||
// string that begins with "/demos/". Downstream validators (validate-parity
|
||||
// routeToDirName, bundle-demo-content) rely on the "/demos/" prefix to
|
||||
// strip it uniformly; accepting a bare "/hitl" or a number silently
|
||||
// produces the wrong on-disk directory lookup.
|
||||
|
||||
it("returns {kind:'malformed', subkind:'shape'} when demos[i].route is a number", () => {
|
||||
const f = path.join(root, "manifest.yaml");
|
||||
write(f, "slug: x\ndemos:\n - id: foo\n route: 42\n");
|
||||
const r = parseManifest(f);
|
||||
expect(r.kind).toBe("malformed");
|
||||
if (r.kind === "malformed") {
|
||||
expect(r.subkind).toBe("shape");
|
||||
expect(r.error).toMatch(/route/i);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns {kind:'malformed', subkind:'shape'} when demos[i].route is null", () => {
|
||||
// YAML `route: ~` parses to null. hasOwnProp is true but the value is
|
||||
// not a string, so the non-empty-string guard rejects it.
|
||||
const f = path.join(root, "manifest.yaml");
|
||||
write(f, "slug: x\ndemos:\n - id: foo\n route: ~\n");
|
||||
const r = parseManifest(f);
|
||||
expect(r.kind).toBe("malformed");
|
||||
if (r.kind === "malformed") {
|
||||
expect(r.subkind).toBe("shape");
|
||||
expect(r.error).toMatch(/route/i);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns {kind:'malformed', subkind:'shape'} when demos[i].route is an object", () => {
|
||||
const f = path.join(root, "manifest.yaml");
|
||||
write(f, "slug: x\ndemos:\n - id: foo\n route:\n nested: true\n");
|
||||
const r = parseManifest(f);
|
||||
expect(r.kind).toBe("malformed");
|
||||
if (r.kind === "malformed") {
|
||||
expect(r.subkind).toBe("shape");
|
||||
expect(r.error).toMatch(/route/i);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns {kind:'malformed', subkind:'shape'} when demos[i].route is the empty string", () => {
|
||||
const f = path.join(root, "manifest.yaml");
|
||||
write(f, 'slug: x\ndemos:\n - id: foo\n route: ""\n');
|
||||
const r = parseManifest(f);
|
||||
expect(r.kind).toBe("malformed");
|
||||
if (r.kind === "malformed") {
|
||||
expect(r.subkind).toBe("shape");
|
||||
expect(r.error).toMatch(/route/i);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns {kind:'malformed', subkind:'shape'} when demos[i].route does not start with /demos/", () => {
|
||||
// Catches the exact anti-pattern the prefix guard exists for: a bare
|
||||
// "/hitl" looks route-shaped but routeToDirName's prefix strip would
|
||||
// return the whole string unchanged, then miss a real directory match.
|
||||
const f = path.join(root, "manifest.yaml");
|
||||
write(f, "slug: x\ndemos:\n - id: foo\n route: /hitl\n");
|
||||
const r = parseManifest(f);
|
||||
expect(r.kind).toBe("malformed");
|
||||
if (r.kind === "malformed") {
|
||||
expect(r.subkind).toBe("shape");
|
||||
expect(r.error).toMatch(/\/demos\//);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns {kind:'malformed', subkind:'shape'} when demos[i].route is exactly '/demos/'", () => {
|
||||
// The `/demos/` prefix guard accepts any string that starts with the
|
||||
// prefix, including the bare prefix with an empty tail. Downstream
|
||||
// consumers (routeToDirName, bundle-demo-content) strip the prefix and
|
||||
// expect a non-empty segment to follow; an empty tail would silently
|
||||
// point at the parent demos/ directory rather than a specific demo.
|
||||
// Reject at the parser boundary so validate-parity / bundle-demo-content
|
||||
// can treat a successful parse as a non-empty-segment invariant.
|
||||
const f = path.join(root, "manifest.yaml");
|
||||
write(f, "slug: x\ndemos:\n - id: foo\n route: /demos/\n");
|
||||
const r = parseManifest(f);
|
||||
expect(r.kind).toBe("malformed");
|
||||
if (r.kind === "malformed") {
|
||||
expect(r.subkind).toBe("shape");
|
||||
expect(r.error).toMatch(/non-empty segment/i);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns {kind:'ok'} and persists demo.route on the frozen entry when well-formed", () => {
|
||||
const f = path.join(root, "manifest.yaml");
|
||||
write(f, "slug: x\ndemos:\n - id: foo\n route: /demos/hitl-in-chat\n");
|
||||
const r = parseManifest(f);
|
||||
expect(r.kind).toBe("ok");
|
||||
if (r.kind === "ok") {
|
||||
const first = r.manifest.demos?.[0];
|
||||
expect(first?.id).toBe("foo");
|
||||
expect(first?.route).toBe("/demos/hitl-in-chat");
|
||||
expect(Object.isFrozen(first)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns {kind:'ok'} with demo.route undefined when route is omitted (backward compat)", () => {
|
||||
// Absence of `route` must not be treated as shape-malformed. Existing
|
||||
// manifests predate this field; they must still parse cleanly and the
|
||||
// frozen demo entry's .route must be undefined (not null, not a
|
||||
// placeholder).
|
||||
const f = path.join(root, "manifest.yaml");
|
||||
write(f, "slug: x\ndemos:\n - id: foo\n");
|
||||
const r = parseManifest(f);
|
||||
expect(r.kind).toBe("ok");
|
||||
if (r.kind === "ok") {
|
||||
const first = r.manifest.demos?.[0];
|
||||
expect(first?.id).toBe("foo");
|
||||
expect(first?.route).toBeUndefined();
|
||||
expect(Object.isFrozen(first)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("sets demos to a frozen empty readonly array when demos is omitted", () => {
|
||||
// `demos` is non-optional in the public type: when absent, return an
|
||||
// empty readonly array so consumers can iterate without `?.` chains.
|
||||
const f = path.join(root, "manifest.yaml");
|
||||
write(f, "slug: x\n");
|
||||
const r = parseManifest(f);
|
||||
expect(r.kind).toBe("ok");
|
||||
if (r.kind === "ok") {
|
||||
expect(r.manifest.demos).toEqual([]);
|
||||
expect(Object.isFrozen(r.manifest.demos)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it.skipIf(cannotEnforceEacces)(
|
||||
"returns {kind:'unreadable'} when the parent dir is unreadable (EACCES on stat)",
|
||||
() => {
|
||||
// Regression for FX30-C Fix 2 (R29-2 H3): parseManifest used
|
||||
// fs.existsSync which CONFLATES ENOENT with EACCES — a manifest
|
||||
// whose parent dir is 0700 owned by another user collapses to
|
||||
// "missing" instead of surfacing as "unreadable". That's the
|
||||
// exact anti-pattern probeDir in validate-parity.ts was written
|
||||
// to avoid. parseManifest must use statSync + errno inspection
|
||||
// so EACCES/ENOTDIR/etc route to `{kind:"unreadable"}`.
|
||||
const root = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), "lib-manifest-parent-eacces-"),
|
||||
);
|
||||
const parentDir = path.join(root, "locked");
|
||||
fs.mkdirSync(parentDir);
|
||||
const manifestFile = path.join(parentDir, "manifest.yaml");
|
||||
fs.writeFileSync(manifestFile, "slug: ok\n");
|
||||
fs.chmodSync(parentDir, 0o000);
|
||||
try {
|
||||
const r = parseManifest(manifestFile);
|
||||
expect(r.kind).toBe("unreadable");
|
||||
} finally {
|
||||
try {
|
||||
fs.chmodSync(parentDir, 0o755);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("returns {kind:'unreadable'} when statSync throws a non-ENOENT errno", () => {
|
||||
// Deterministic variant of the chmod-based test above: stub statSync
|
||||
// to throw EACCES so the behavior is verified even on FS layers that
|
||||
// ignore chmod. Pre-ENOENT semantics (via fs.existsSync) would have
|
||||
// collapsed this to {kind:"missing"}.
|
||||
const tmp = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), "lib-manifest-stat-spy-"),
|
||||
);
|
||||
const f = path.join(tmp, "manifest.yaml");
|
||||
fs.writeFileSync(f, "slug: ok\n");
|
||||
const orig = fs.statSync;
|
||||
const spy = vi.spyOn(fs, "statSync").mockImplementation(((
|
||||
p: fs.PathLike,
|
||||
options?: unknown,
|
||||
) => {
|
||||
if (typeof p === "string" && p === f) {
|
||||
const e: NodeJS.ErrnoException = new Error("EACCES: injected");
|
||||
e.code = "EACCES";
|
||||
throw e;
|
||||
}
|
||||
return (orig as unknown as (p: fs.PathLike, o?: unknown) => unknown)(
|
||||
p,
|
||||
options,
|
||||
);
|
||||
}) as typeof fs.statSync);
|
||||
try {
|
||||
const r = parseManifest(f);
|
||||
expect(r.kind).toBe("unreadable");
|
||||
if (r.kind === "unreadable") {
|
||||
expect(r.error).toContain("EACCES");
|
||||
}
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("returns {kind:'missing'} (not 'unreadable') when statSync throws ENOENT", () => {
|
||||
// ENOENT is the legitimate "file absent" signal and must continue to
|
||||
// route to {kind:"missing"}. Only non-ENOENT errno values escalate
|
||||
// to "unreadable".
|
||||
const r = parseManifest(
|
||||
path.join(os.tmpdir(), "definitely-absent-xyz", "manifest.yaml"),
|
||||
);
|
||||
expect(r.kind).toBe("missing");
|
||||
});
|
||||
|
||||
it("returns {kind:'malformed'} when dirSlug is the empty string (caller bug)", () => {
|
||||
// Regression for FX30-C Fix 3 (R29-2 H2): `""` is NOT a valid opt-out
|
||||
// sentinel — `undefined` means "caller opted out of slug-check".
|
||||
// An empty string usually comes from path.basename on a weird path
|
||||
// (trailing slash, etc.) in the caller; silently collapsing it to
|
||||
// undefined hides a caller bug. The parser must surface it.
|
||||
const tmp = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), "lib-manifest-empty-slug-"),
|
||||
);
|
||||
const f = path.join(tmp, "manifest.yaml");
|
||||
fs.writeFileSync(f, "slug: mypkg\n");
|
||||
try {
|
||||
const r = parseManifest(f, "");
|
||||
expect(r.kind).toBe("malformed");
|
||||
if (r.kind === "malformed") {
|
||||
expect(r.subkind).toBe("shape");
|
||||
expect(r.error).toMatch(/empty.*dirSlug|dirSlug.*empty/i);
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("returns {kind:'unreadable'} when readFileSync throws (e.g. EACCES)", () => {
|
||||
// Simulate a permission error via spy. existsSync returns true but
|
||||
// readFileSync throws — the parser must surface this as 'unreadable',
|
||||
// distinct from 'missing' (file absent) and 'malformed' (file present,
|
||||
// contents bad).
|
||||
//
|
||||
// The spy falls through to the original implementation for any path
|
||||
// other than the target manifest. Throwing "unexpected readFileSync
|
||||
// call" from within a spy masked the real failure (e.g. vitest's own
|
||||
// readFileSync for transform cache) with a confusing error — fall-
|
||||
// through is the correct behavior for a mock of this shape.
|
||||
const f = path.join(root, "manifest.yaml");
|
||||
write(f, "slug: ok\n");
|
||||
const orig = fs.readFileSync;
|
||||
const spy = vi.spyOn(fs, "readFileSync").mockImplementation(((
|
||||
p: fs.PathOrFileDescriptor,
|
||||
options?: unknown,
|
||||
) => {
|
||||
if (typeof p === "string" && p === f) {
|
||||
const e: NodeJS.ErrnoException = new Error("EACCES: permission denied");
|
||||
e.code = "EACCES";
|
||||
throw e;
|
||||
}
|
||||
return (
|
||||
orig as unknown as (p: fs.PathOrFileDescriptor, o?: unknown) => unknown
|
||||
)(p, options);
|
||||
}) as typeof fs.readFileSync);
|
||||
try {
|
||||
const r = parseManifest(f);
|
||||
expect(r.kind).toBe("unreadable");
|
||||
if (r.kind === "unreadable") {
|
||||
expect(r.error).toContain("EACCES");
|
||||
}
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("createDemoId", () => {
|
||||
it("accepts a non-empty string and returns a branded DemoId", () => {
|
||||
const id = createDemoId("foo");
|
||||
expect(id).toBe("foo");
|
||||
});
|
||||
|
||||
it("returns null for the empty string", () => {
|
||||
expect(createDemoId("")).toBeNull();
|
||||
});
|
||||
|
||||
it("accepts unknown input and returns null for non-string values", () => {
|
||||
// Signature widened from (s: string) to (s: unknown) so the dead
|
||||
// typeof check becomes a live guard. Non-string inputs at API
|
||||
// boundaries (yaml.parse results, untyped JSON) return null rather
|
||||
// than silently producing a fake branded id.
|
||||
expect(createDemoId(null)).toBeNull();
|
||||
expect(createDemoId(undefined)).toBeNull();
|
||||
expect(createDemoId(42)).toBeNull();
|
||||
expect(createDemoId({})).toBeNull();
|
||||
expect(createDemoId([])).toBeNull();
|
||||
expect(createDemoId(true)).toBeNull();
|
||||
});
|
||||
|
||||
it("narrows its input via a type predicate (compile-time shape)", () => {
|
||||
// The returned value, when non-null, is both a DemoId AND carries a
|
||||
// TypeScript narrowing that lets callers read it as a string without
|
||||
// further casts. This test is mostly structural — the assertion is
|
||||
// that the code compiles; we still run a basic runtime check.
|
||||
const candidate: unknown = "abc";
|
||||
const id = createDemoId(candidate);
|
||||
if (id !== null) {
|
||||
// Compiler should accept DemoId as assignable to a string-slot.
|
||||
const asString: string = id;
|
||||
expect(asString).toBe("abc");
|
||||
// Also exercise the DemoId type import to ensure the export exists.
|
||||
const branded: DemoId = id;
|
||||
expect(branded).toBe("abc");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import path from "node:path";
|
||||
|
||||
const REPO_ROOT = path.resolve(__dirname, "../../../..");
|
||||
|
||||
describe("Railway GraphQL host unification", () => {
|
||||
it("no source file references backboard.railway.com (must be .app)", () => {
|
||||
// grep across showcase/ and .github/workflows/. Use -F (literal) so
|
||||
// the dot in the pattern is not regex-interpreted. The test file
|
||||
// itself contains the forbidden literal; exclude it via git pathspec
|
||||
// `:(exclude)` magic. Also exclude two files that legitimately
|
||||
// reference the deprecated host in documentation/negative-assertion
|
||||
// form (no functional use):
|
||||
// - railway-graphql.ts JSDoc explains the deprecated .com endpoint
|
||||
// - railway-graphql.test.ts asserts the endpoint is NOT .com
|
||||
// Use --untracked so a newly-added (not-yet-committed) file that
|
||||
// reintroduces the forbidden host still trips the guard. Use
|
||||
// execFileSync with an args array so REPO_ROOT is not shell-parsed.
|
||||
let out = "";
|
||||
try {
|
||||
out = execFileSync(
|
||||
"git",
|
||||
[
|
||||
"-C",
|
||||
REPO_ROOT,
|
||||
"grep",
|
||||
"--untracked",
|
||||
"-nF",
|
||||
"backboard.railway.com",
|
||||
"--",
|
||||
"showcase",
|
||||
":(exclude)showcase/scripts/lib/__tests__/railway-graphql.scan.test.ts",
|
||||
":(exclude)showcase/scripts/lib/railway-graphql.ts",
|
||||
":(exclude)showcase/scripts/lib/__tests__/railway-graphql.test.ts",
|
||||
".github/workflows",
|
||||
],
|
||||
{ encoding: "utf-8" },
|
||||
);
|
||||
} catch (err) {
|
||||
// git grep exits 1 when no matches — that's the clean-miss success
|
||||
// case. Any other exit status (git missing, bad cwd, pathspec
|
||||
// syntax error, etc.) must fail loud rather than silently pass.
|
||||
const status = (err as { status?: number }).status;
|
||||
if (status === 1) {
|
||||
out = "";
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
expect(out).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { RAILWAY_GRAPHQL_ENDPOINT } from "../railway-graphql";
|
||||
|
||||
describe("railway-graphql", () => {
|
||||
it("uses the canonical backboard.railway.app host", () => {
|
||||
expect(RAILWAY_GRAPHQL_ENDPOINT).toBe(
|
||||
"https://backboard.railway.app/graphql/v2",
|
||||
);
|
||||
});
|
||||
|
||||
it("never resolves to the .com host (historical drift target)", () => {
|
||||
expect(RAILWAY_GRAPHQL_ENDPOINT).not.toContain("backboard.railway.com");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { resolveRailwayToken, RailwayTokenError } from "../railway-token";
|
||||
|
||||
/**
|
||||
* Envelope-level tests for resolveRailwayToken — the unified resolver
|
||||
* used by redeploy-env.ts and verify-railway-image-refs.ts.
|
||||
*
|
||||
* Each failure mode must throw a DISTINCT, actionable error so the
|
||||
* silent-token-fallthrough diagnostic gap can never re-open.
|
||||
*/
|
||||
describe("resolveRailwayToken (envelope)", () => {
|
||||
let dir: string;
|
||||
const originalEnv = process.env.RAILWAY_TOKEN;
|
||||
const originalHome = process.env.HOME;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), "rwy-token-env-"));
|
||||
delete process.env.RAILWAY_TOKEN;
|
||||
process.env.HOME = dir;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
if (originalEnv === undefined) delete process.env.RAILWAY_TOKEN;
|
||||
else process.env.RAILWAY_TOKEN = originalEnv;
|
||||
if (originalHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = originalHome;
|
||||
});
|
||||
|
||||
it("returns RAILWAY_TOKEN env var when set, source='env'", () => {
|
||||
process.env.RAILWAY_TOKEN = "env-token-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";
|
||||
const result = resolveRailwayToken();
|
||||
expect(result.token).toBe("env-token-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx");
|
||||
expect(result.source).toBe("env");
|
||||
});
|
||||
|
||||
it("returns token from ~/.railway/config.json when env unset, source='config'", () => {
|
||||
mkdirSync(join(dir, ".railway"));
|
||||
writeFileSync(
|
||||
join(dir, ".railway", "config.json"),
|
||||
JSON.stringify({
|
||||
user: { accessToken: "from-config-aaaaaaaaaaaaaaaaaaaaaaaa" },
|
||||
}),
|
||||
);
|
||||
const result = resolveRailwayToken();
|
||||
expect(result.token).toBe("from-config-aaaaaaaaaaaaaaaaaaaaaaaa");
|
||||
expect(result.source).toBe("config");
|
||||
});
|
||||
|
||||
it("throws DISTINCT error when $HOME is unset and env-var also unset", () => {
|
||||
delete process.env.HOME;
|
||||
expect(() => resolveRailwayToken()).toThrow(RailwayTokenError);
|
||||
try {
|
||||
resolveRailwayToken();
|
||||
throw new Error("should have thrown");
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(RailwayTokenError);
|
||||
const err = e as RailwayTokenError;
|
||||
expect(err.code).toBe("NO_HOME");
|
||||
expect(err.message).toMatch(/\$HOME/);
|
||||
}
|
||||
});
|
||||
|
||||
it("trims RAILWAY_TOKEN env var (padded with whitespace/newline)", () => {
|
||||
process.env.RAILWAY_TOKEN = " padded-token-xxxxxxxxxxxxxxxxxxxxxxxx\n";
|
||||
const result = resolveRailwayToken();
|
||||
expect(result.token).toBe("padded-token-xxxxxxxxxxxxxxxxxxxxxxxx");
|
||||
expect(result.source).toBe("env");
|
||||
});
|
||||
|
||||
it("treats whitespace-only RAILWAY_TOKEN as UNSET and falls through to config", () => {
|
||||
process.env.RAILWAY_TOKEN = " ";
|
||||
mkdirSync(join(dir, ".railway"));
|
||||
writeFileSync(
|
||||
join(dir, ".railway", "config.json"),
|
||||
JSON.stringify({
|
||||
user: { accessToken: "from-config-bbbbbbbbbbbbbbbbbbbbbbbb" },
|
||||
}),
|
||||
);
|
||||
const result = resolveRailwayToken();
|
||||
expect(result.token).toBe("from-config-bbbbbbbbbbbbbbbbbbbbbbbb");
|
||||
expect(result.source).toBe("config");
|
||||
});
|
||||
|
||||
it("treats whitespace-only RAILWAY_TOKEN as UNSET and surfaces NO_FILE when no config exists", () => {
|
||||
process.env.RAILWAY_TOKEN = " \n";
|
||||
try {
|
||||
resolveRailwayToken();
|
||||
throw new Error("should have thrown");
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(RailwayTokenError);
|
||||
const err = e as RailwayTokenError;
|
||||
// Must NOT be returned as source="env" with a whitespace token —
|
||||
// that produces invalid Bearer headers and silent 401s.
|
||||
expect(err.code).toBe("NO_FILE");
|
||||
}
|
||||
});
|
||||
|
||||
it("throws DISTINCT error when ~/.railway/config.json does not exist", () => {
|
||||
try {
|
||||
resolveRailwayToken();
|
||||
throw new Error("should have thrown");
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(RailwayTokenError);
|
||||
const err = e as RailwayTokenError;
|
||||
expect(err.code).toBe("NO_FILE");
|
||||
expect(err.message).toMatch(/RAILWAY_TOKEN/);
|
||||
expect(err.message).toMatch(/railway login/);
|
||||
}
|
||||
});
|
||||
|
||||
it("throws DISTINCT error when ~/.railway/config.json is malformed JSON", () => {
|
||||
mkdirSync(join(dir, ".railway"));
|
||||
writeFileSync(join(dir, ".railway", "config.json"), "{ not json");
|
||||
try {
|
||||
resolveRailwayToken();
|
||||
throw new Error("should have thrown");
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(RailwayTokenError);
|
||||
const err = e as RailwayTokenError;
|
||||
expect(err.code).toBe("MALFORMED");
|
||||
expect(err.message).toMatch(/Malformed ~\/\.railway\/config\.json/);
|
||||
}
|
||||
});
|
||||
|
||||
it("throws DISTINCT error when config parses but yields no usable token (the silent-fallthrough gap)", () => {
|
||||
mkdirSync(join(dir, ".railway"));
|
||||
// Parses fine — just no token field at any layer.
|
||||
writeFileSync(
|
||||
join(dir, ".railway", "config.json"),
|
||||
JSON.stringify({ projects: { something: "else" } }),
|
||||
);
|
||||
try {
|
||||
resolveRailwayToken();
|
||||
throw new Error("should have thrown");
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(RailwayTokenError);
|
||||
const err = e as RailwayTokenError;
|
||||
// CRITICAL: must NOT be the generic NO_FILE message — the
|
||||
// user needs to know the file WAS found but had no token.
|
||||
expect(err.code).toBe("NO_TOKEN_IN_CONFIG");
|
||||
expect(err.message).toMatch(
|
||||
/~\/\.railway\/config\.json.*no.*token|found.*no.*token/i,
|
||||
);
|
||||
// Must also hint at the remedy.
|
||||
expect(err.message).toMatch(/railway login|RAILWAY_TOKEN/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { resolveRailwayTokenFromConfig } from "../railway-token";
|
||||
import type { RailwayConfigShape } from "../railway-token";
|
||||
|
||||
describe("resolveRailwayTokenFromConfig", () => {
|
||||
it("prefers user.accessToken when present", () => {
|
||||
const cfg: RailwayConfigShape = {
|
||||
user: {
|
||||
accessToken: "new-access-token-43-chars-or-more-aaaaaaaa",
|
||||
token: "legacy-short-token",
|
||||
},
|
||||
};
|
||||
const warn = vi.fn();
|
||||
const result = resolveRailwayTokenFromConfig(cfg, { warn });
|
||||
expect(result).toBe("new-access-token-43-chars-or-more-aaaaaaaa");
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to top-level accessToken", () => {
|
||||
const cfg: RailwayConfigShape = {
|
||||
accessToken: "top-level-access-token-aaaaaaaaaaaaaaaaaaaa",
|
||||
};
|
||||
const warn = vi.fn();
|
||||
const result = resolveRailwayTokenFromConfig(cfg, { warn });
|
||||
expect(result).toBe("top-level-access-token-aaaaaaaaaaaaaaaaaaaa");
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to legacy user.token AND emits a deprecation warning", () => {
|
||||
const cfg: RailwayConfigShape = {
|
||||
user: { token: "legacy-short-token-aaaaaaaaaa" },
|
||||
};
|
||||
const warn = vi.fn();
|
||||
const result = resolveRailwayTokenFromConfig(cfg, { warn });
|
||||
expect(result).toBe("legacy-short-token-aaaaaaaaaa");
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
expect(warn.mock.calls[0][0]).toMatch(
|
||||
/deprecated.*user\.token.*accessToken/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to top-level token with deprecation warning", () => {
|
||||
const cfg: RailwayConfigShape = { token: "legacy-top-aaaaaaaaa" };
|
||||
const warn = vi.fn();
|
||||
const result = resolveRailwayTokenFromConfig(cfg, { warn });
|
||||
expect(result).toBe("legacy-top-aaaaaaaaa");
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns undefined and does not warn on an empty config", () => {
|
||||
const warn = vi.fn();
|
||||
const result = resolveRailwayTokenFromConfig({}, { warn });
|
||||
expect(result).toBeUndefined();
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores empty-string tokens at every layer", () => {
|
||||
const cfg: RailwayConfigShape = {
|
||||
user: { accessToken: "", token: "" },
|
||||
accessToken: "",
|
||||
token: "",
|
||||
};
|
||||
const warn = vi.fn();
|
||||
const result = resolveRailwayTokenFromConfig(cfg, { warn });
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("treats whitespace-only user.accessToken as empty and falls through", () => {
|
||||
const cfg: RailwayConfigShape = {
|
||||
user: { accessToken: " " },
|
||||
};
|
||||
const warn = vi.fn();
|
||||
const result = resolveRailwayTokenFromConfig(cfg, { warn });
|
||||
expect(result).toBeUndefined();
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls through whitespace-only user.accessToken to legacy user.token with warn", () => {
|
||||
const cfg: RailwayConfigShape = {
|
||||
user: {
|
||||
accessToken: "\n\t ",
|
||||
token: "legacy-short-token-aaaaaaaaaa",
|
||||
},
|
||||
};
|
||||
const warn = vi.fn();
|
||||
const result = resolveRailwayTokenFromConfig(cfg, { warn });
|
||||
expect(result).toBe("legacy-short-token-aaaaaaaaaa");
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("treats whitespace-only tokens at every layer as empty", () => {
|
||||
const cfg: RailwayConfigShape = {
|
||||
user: { accessToken: " ", token: "\t" },
|
||||
accessToken: "\n",
|
||||
token: " ",
|
||||
};
|
||||
const warn = vi.fn();
|
||||
const result = resolveRailwayTokenFromConfig(cfg, { warn });
|
||||
expect(result).toBeUndefined();
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns undefined when config is null", () => {
|
||||
const warn = vi.fn();
|
||||
const result = resolveRailwayTokenFromConfig(null, { warn });
|
||||
expect(result).toBeUndefined();
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns undefined when config is undefined", () => {
|
||||
const warn = vi.fn();
|
||||
const result = resolveRailwayTokenFromConfig(undefined, { warn });
|
||||
expect(result).toBeUndefined();
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns undefined when config is a string (non-object JSON)", () => {
|
||||
const warn = vi.fn();
|
||||
const result = resolveRailwayTokenFromConfig(
|
||||
"not-an-object" as unknown as RailwayConfigShape,
|
||||
{ warn },
|
||||
);
|
||||
expect(result).toBeUndefined();
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns undefined when config is a number", () => {
|
||||
const warn = vi.fn();
|
||||
const result = resolveRailwayTokenFromConfig(
|
||||
42 as unknown as RailwayConfigShape,
|
||||
{ warn },
|
||||
);
|
||||
expect(result).toBeUndefined();
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns the trimmed token when user.accessToken has surrounding whitespace", () => {
|
||||
const cfg: RailwayConfigShape = {
|
||||
user: { accessToken: " padded-token-aaaaaaaaaaaaaaaaaaaa " },
|
||||
};
|
||||
const warn = vi.fn();
|
||||
const result = resolveRailwayTokenFromConfig(cfg, { warn });
|
||||
expect(result).toBe("padded-token-aaaaaaaaaaaaaaaaaaaa");
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns the trimmed token when user.accessToken has a trailing newline", () => {
|
||||
const cfg: RailwayConfigShape = {
|
||||
user: { accessToken: "abc-access-token-aaaaaaaaaaaaaaaaaaaa\n" },
|
||||
};
|
||||
const warn = vi.fn();
|
||||
const result = resolveRailwayTokenFromConfig(cfg, { warn });
|
||||
expect(result).toBe("abc-access-token-aaaaaaaaaaaaaaaaaaaa");
|
||||
});
|
||||
|
||||
it("returns the trimmed token when top-level accessToken has surrounding whitespace", () => {
|
||||
const cfg: RailwayConfigShape = {
|
||||
accessToken: "\ttop-padded-aaaaaaaaaaaaaaaaaaaaaaaa\n",
|
||||
};
|
||||
const warn = vi.fn();
|
||||
const result = resolveRailwayTokenFromConfig(cfg, { warn });
|
||||
expect(result).toBe("top-padded-aaaaaaaaaaaaaaaaaaaaaaaa");
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns the trimmed token for legacy user.token (with warn)", () => {
|
||||
const cfg: RailwayConfigShape = {
|
||||
user: { token: " legacy-user-token-aaaaaaaaaa\n" },
|
||||
};
|
||||
const warn = vi.fn();
|
||||
const result = resolveRailwayTokenFromConfig(cfg, { warn });
|
||||
expect(result).toBe("legacy-user-token-aaaaaaaaaa");
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns the trimmed token for legacy top-level token (with warn)", () => {
|
||||
const cfg: RailwayConfigShape = { token: "\nlegacy-top-aaaaaaaaa " };
|
||||
const warn = vi.fn();
|
||||
const result = resolveRailwayTokenFromConfig(cfg, { warn });
|
||||
expect(result).toBe("legacy-top-aaaaaaaaa");
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns undefined when config is an array", () => {
|
||||
const warn = vi.fn();
|
||||
const result = resolveRailwayTokenFromConfig(
|
||||
[] as unknown as RailwayConfigShape,
|
||||
{ warn },
|
||||
);
|
||||
expect(result).toBeUndefined();
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,431 @@
|
||||
/**
|
||||
* Tests for showcase/scripts/lib/slug-map.ts.
|
||||
*
|
||||
* Pins the shared slug/examples mapping tables and the
|
||||
* born-in-showcase set so all three validators agree.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import {
|
||||
BORN_IN_SHOWCASE,
|
||||
SLUG_MAP,
|
||||
SLUG_TO_EXAMPLES,
|
||||
FALLBACK_MAP,
|
||||
isShowcaseSlug,
|
||||
} from "../slug-map.js";
|
||||
import type { ShowcaseSlug } from "../slug-map.js";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const PACKAGES_DIR = path.resolve(__dirname, "..", "..", "..", "integrations");
|
||||
|
||||
describe("BORN_IN_SHOWCASE", () => {
|
||||
it("contains the 7 known born-in-showcase slugs", () => {
|
||||
expect(BORN_IN_SHOWCASE.has("ag2")).toBe(true);
|
||||
expect(BORN_IN_SHOWCASE.has("claude-sdk-python")).toBe(true);
|
||||
expect(BORN_IN_SHOWCASE.has("claude-sdk-typescript")).toBe(true);
|
||||
expect(BORN_IN_SHOWCASE.has("langroid")).toBe(true);
|
||||
expect(BORN_IN_SHOWCASE.has("ms-agent-harness-dotnet")).toBe(true);
|
||||
expect(BORN_IN_SHOWCASE.has("spring-ai")).toBe(true);
|
||||
expect(BORN_IN_SHOWCASE.has("strands-typescript")).toBe(true);
|
||||
});
|
||||
|
||||
it("has exactly 7 entries — guards against accidental additions", () => {
|
||||
// Size assertion: if someone adds a new born-in-showcase slug without
|
||||
// updating the test above, this pins the cardinality so the addition
|
||||
// is caught rather than silently accepted.
|
||||
expect(BORN_IN_SHOWCASE.size).toBe(7);
|
||||
});
|
||||
|
||||
it("is a frozen / immutable ReadonlySet (add throws)", () => {
|
||||
// Callers must not mutate the shared set at runtime. A ReadonlySet type
|
||||
// is compile-time only; we back it with a frozen Set so a runtime
|
||||
// `.add()` attempt throws in strict mode rather than silently diverging
|
||||
// from the other validator copies.
|
||||
const s = BORN_IN_SHOWCASE as unknown as Set<string>;
|
||||
expect(() => s.add("sneaky-mutation")).toThrow();
|
||||
});
|
||||
|
||||
it("rejects .delete and .clear on the frozen set", () => {
|
||||
const s = BORN_IN_SHOWCASE as unknown as Set<string>;
|
||||
expect(() => s.delete("ag2")).toThrow();
|
||||
expect(() => s.clear()).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SLUG_TO_EXAMPLES (showcase slug → examples dir names)", () => {
|
||||
// This test reads the live showcase/integrations/ tree. In sparse
|
||||
// checkouts (CI shards, partial clones) the directory may be absent;
|
||||
// skip rather than false-fail when that happens.
|
||||
it.skipIf(!fs.existsSync(PACKAGES_DIR))(
|
||||
"has no dead entries — every target dir exists under showcase/integrations/",
|
||||
() => {
|
||||
// Regression guard: the old audit.ts map contained crewai-flows,
|
||||
// agent-spec-langgraph, and mcp-apps which produced phantom "no
|
||||
// examples source" anomalies. Removing them here is the whole point of
|
||||
// the extraction.
|
||||
for (const slug of Object.keys(SLUG_TO_EXAMPLES)) {
|
||||
const pkgPath = path.join(PACKAGES_DIR, slug);
|
||||
expect(
|
||||
fs.existsSync(pkgPath),
|
||||
`SLUG_TO_EXAMPLES slug '${slug}' has no matching showcase/integrations/${slug}/`,
|
||||
).toBe(true);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Companion to the skipIf test above — runs UNCONDITIONALLY against a
|
||||
// fixture tmpdir so the invariant ("every SLUG_TO_EXAMPLES key has a
|
||||
// matching integrations/<slug>/ dir") is still exercised on sparse
|
||||
// checkouts / CI shards / Docker build contexts where the real
|
||||
// showcase/integrations/ tree is absent. A skipIf without a contra-positive
|
||||
// assertion is indistinguishable from "test was deleted."
|
||||
describe("fixture-based invariant (runs regardless of checkout layout)", () => {
|
||||
let fixtureDir: string;
|
||||
let fixturePackagesDir: string;
|
||||
|
||||
beforeAll(() => {
|
||||
fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), "slug-map-fixture-"));
|
||||
fixturePackagesDir = path.join(fixtureDir, "integrations");
|
||||
fs.mkdirSync(fixturePackagesDir, { recursive: true });
|
||||
// Seed a directory for every SLUG_TO_EXAMPLES key. This mirrors the
|
||||
// expected layout of showcase/integrations/ — the invariant check below
|
||||
// is identical in spirit to the skipIf test, just pointed at a
|
||||
// fixture whose contents we fully control.
|
||||
for (const slug of Object.keys(SLUG_TO_EXAMPLES)) {
|
||||
fs.mkdirSync(path.join(fixturePackagesDir, slug), { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
fs.rmSync(fixtureDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("every SLUG_TO_EXAMPLES key resolves to a real dir in the fixture", () => {
|
||||
const seen: string[] = [];
|
||||
for (const slug of Object.keys(SLUG_TO_EXAMPLES)) {
|
||||
const pkgPath = path.join(fixturePackagesDir, slug);
|
||||
expect(
|
||||
fs.existsSync(pkgPath),
|
||||
`fixture missing integrations/${slug}/ — invariant seeding is broken`,
|
||||
).toBe(true);
|
||||
seen.push(slug);
|
||||
}
|
||||
// Sentinel: assert we actually iterated at least one slug. A silent
|
||||
// empty SLUG_TO_EXAMPLES would otherwise pass vacuously.
|
||||
expect(seen.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not include the three known dead entries", () => {
|
||||
expect(
|
||||
(SLUG_TO_EXAMPLES as Record<string, unknown>)["crewai-flows"],
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
(SLUG_TO_EXAMPLES as Record<string, unknown>)["agent-spec-langgraph"],
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
(SLUG_TO_EXAMPLES as Record<string, unknown>)["mcp-apps"],
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects adding a new top-level key at runtime", () => {
|
||||
// Object.isFrozen is the weak form of this assertion: it only checks
|
||||
// a flag. An actual mutation attempt is the real invariant — strict
|
||||
// mode is active in ESM, so assignment on a frozen object throws.
|
||||
expect(() => {
|
||||
(SLUG_TO_EXAMPLES as unknown as Record<string, readonly string[]>)[
|
||||
"bogus-new-slug"
|
||||
] = ["nothing"];
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it("rejects reassigning an existing top-level entry at runtime", () => {
|
||||
expect(() => {
|
||||
(SLUG_TO_EXAMPLES as unknown as Record<string, readonly string[]>)[
|
||||
"mastra"
|
||||
] = ["replaced"];
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it("rejects mutating an inner array (element assignment throws)", () => {
|
||||
// freezeMap2D must freeze BOTH the outer record AND each inner array.
|
||||
// Without the inner freeze, `SLUG_TO_EXAMPLES.mastra[0] = "x"` would
|
||||
// silently succeed even though the outer Object.isFrozen reports true.
|
||||
expect(() => {
|
||||
(SLUG_TO_EXAMPLES.mastra as unknown as string[])[0] = "mutated";
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it("rejects .push on an inner array (all mutation methods fail)", () => {
|
||||
expect(() => {
|
||||
(SLUG_TO_EXAMPLES.mastra as unknown as string[]).push("extra");
|
||||
}).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SLUG_MAP (examples dir → showcase slug)", () => {
|
||||
it("contains the known mapping for langgraph-js → langgraph-typescript", () => {
|
||||
// Sample entry inversely matched with SLUG_TO_EXAMPLES.
|
||||
expect(SLUG_MAP.get("langgraph-js")).toBe("langgraph-typescript");
|
||||
});
|
||||
|
||||
it("inverse of SLUG_MAP covers a sample SLUG_TO_EXAMPLES entry", () => {
|
||||
// For a slug with a unique examples dir (not a fan-out like crewai-*),
|
||||
// the entries should be bidirectionally consistent.
|
||||
const exampleDirs = SLUG_TO_EXAMPLES["langgraph-typescript"];
|
||||
expect(exampleDirs).toBeDefined();
|
||||
for (const dir of exampleDirs!) {
|
||||
expect(SLUG_MAP.get(dir)).toBe("langgraph-typescript");
|
||||
}
|
||||
});
|
||||
|
||||
// Reads the live showcase/integrations/ tree — skip in sparse checkouts
|
||||
// where that directory is not materialized.
|
||||
it.skipIf(!fs.existsSync(PACKAGES_DIR))(
|
||||
"every VALUE in SLUG_MAP names a real showcase/integrations/<slug>/ dir",
|
||||
() => {
|
||||
// Dead-entry guard: the old SLUG_MAP carried values like `crewai`,
|
||||
// `maf-dotnet`, `maf-python`, `aws-strands`, `agent-spec-langgraph`,
|
||||
// `a2a`, `mcp-apps`, `pydanticai` that did NOT exist under
|
||||
// showcase/integrations/. Those broke validate-pins.ts's reverse lookup
|
||||
// and forced FALLBACK_MAP to re-express the corrections.
|
||||
for (const [, slug] of SLUG_MAP) {
|
||||
const pkgPath = path.join(PACKAGES_DIR, slug);
|
||||
expect(
|
||||
fs.existsSync(pkgPath),
|
||||
`SLUG_MAP value '${slug}' has no matching showcase/integrations/${slug}/`,
|
||||
).toBe(true);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Companion to the skipIf test above — runs UNCONDITIONALLY against a
|
||||
// fixture tmpdir so the SLUG_MAP value invariant is exercised even
|
||||
// when showcase/integrations/ is absent. Without this, a sparse CI
|
||||
// checkout would silently skip the invariant and a regression
|
||||
// (reintroducing a dead value like `crewai` / `mcp-apps`) would pass.
|
||||
describe("fixture-based SLUG_MAP invariant (runs regardless of checkout layout)", () => {
|
||||
let fixtureDir: string;
|
||||
let fixturePackagesDir: string;
|
||||
|
||||
beforeAll(() => {
|
||||
fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), "slug-map-fixture-"));
|
||||
fixturePackagesDir = path.join(fixtureDir, "integrations");
|
||||
fs.mkdirSync(fixturePackagesDir, { recursive: true });
|
||||
// Seed the fixture with every unique VALUE in SLUG_MAP plus every
|
||||
// FALLBACK_MAP target — those are the invariants we enforce.
|
||||
const targets = new Set<string>();
|
||||
for (const [, slug] of SLUG_MAP) targets.add(slug);
|
||||
for (const target of Object.values(FALLBACK_MAP)) targets.add(target);
|
||||
for (const slug of targets) {
|
||||
fs.mkdirSync(path.join(fixturePackagesDir, slug), { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
fs.rmSync(fixtureDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("every SLUG_MAP value resolves to a real dir in the fixture", () => {
|
||||
let iter = 0;
|
||||
for (const [, slug] of SLUG_MAP) {
|
||||
const pkgPath = path.join(fixturePackagesDir, slug);
|
||||
expect(
|
||||
fs.existsSync(pkgPath),
|
||||
`fixture missing integrations/${slug}/ — seeding is broken`,
|
||||
).toBe(true);
|
||||
iter++;
|
||||
}
|
||||
expect(iter).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("every FALLBACK_MAP target resolves to a real dir in the fixture", () => {
|
||||
let iter = 0;
|
||||
for (const [slug, target] of Object.entries(FALLBACK_MAP)) {
|
||||
const pkgPath = path.join(fixturePackagesDir, target);
|
||||
expect(
|
||||
fs.existsSync(pkgPath),
|
||||
`FALLBACK_MAP['${slug}'] = '${target}' has no matching dir`,
|
||||
).toBe(true);
|
||||
iter++;
|
||||
}
|
||||
expect(iter).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("is frozen — .set throws", () => {
|
||||
const m = SLUG_MAP as unknown as Map<string, string>;
|
||||
expect(() => m.set("bad", "mutation")).toThrow();
|
||||
});
|
||||
|
||||
it("is frozen — .delete and .clear throw", () => {
|
||||
const m = SLUG_MAP as unknown as Map<string, string>;
|
||||
expect(() => m.delete("langgraph-js")).toThrow();
|
||||
expect(() => m.clear()).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isShowcaseSlug runtime validator", () => {
|
||||
it("accepts a non-empty, kebab-cased-or-plain slug string", () => {
|
||||
expect(isShowcaseSlug("ag2")).toBe(true);
|
||||
expect(isShowcaseSlug("langgraph-typescript")).toBe(true);
|
||||
expect(isShowcaseSlug("ms-agent-framework-python")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects the empty string", () => {
|
||||
expect(isShowcaseSlug("")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects non-string inputs via defensive typeof check", () => {
|
||||
// Signature widened from (s: string) to (s: unknown) so the guard
|
||||
// is a live validator at any API boundary. Pass the values directly
|
||||
// — no `as unknown as string` casts needed now that the parameter
|
||||
// type accepts unknown.
|
||||
expect(isShowcaseSlug(null)).toBe(false);
|
||||
expect(isShowcaseSlug(undefined)).toBe(false);
|
||||
expect(isShowcaseSlug(42)).toBe(false);
|
||||
expect(isShowcaseSlug({})).toBe(false);
|
||||
expect(isShowcaseSlug([])).toBe(false);
|
||||
expect(isShowcaseSlug(true)).toBe(false);
|
||||
});
|
||||
|
||||
it("narrows its argument via a user-defined type predicate", () => {
|
||||
// isShowcaseSlug is declared `(s: unknown): s is ShowcaseSlug`, so
|
||||
// when it returns true the compiler narrows the caller's variable to
|
||||
// ShowcaseSlug. This test is primarily a compile-time assertion; a
|
||||
// runtime check backs it up.
|
||||
const candidate: unknown = "ag2";
|
||||
if (isShowcaseSlug(candidate)) {
|
||||
// Must be assignable to ShowcaseSlug without further casts.
|
||||
const s: ShowcaseSlug = candidate;
|
||||
expect(s).toBe("ag2");
|
||||
} else {
|
||||
throw new Error("expected 'ag2' to satisfy isShowcaseSlug");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects slugs containing whitespace or path separators", () => {
|
||||
// These are the most likely garbage-input patterns at the boundary.
|
||||
expect(isShowcaseSlug("foo bar")).toBe(false);
|
||||
expect(isShowcaseSlug("foo/bar")).toBe(false);
|
||||
expect(isShowcaseSlug("foo\\bar")).toBe(false);
|
||||
});
|
||||
|
||||
it("is applied at construction — every BORN_IN_SHOWCASE and SLUG_MAP slug satisfies it", () => {
|
||||
for (const s of BORN_IN_SHOWCASE) expect(isShowcaseSlug(s)).toBe(true);
|
||||
for (const [, slug] of SLUG_MAP) expect(isShowcaseSlug(slug)).toBe(true);
|
||||
for (const slug of Object.keys(SLUG_TO_EXAMPLES))
|
||||
expect(isShowcaseSlug(slug)).toBe(true);
|
||||
for (const slug of Object.keys(FALLBACK_MAP))
|
||||
expect(isShowcaseSlug(slug)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("freezeSet / freezeMap behavioral invariants", () => {
|
||||
it("BORN_IN_SHOWCASE rejects re-defining its mutation methods", () => {
|
||||
// Behavioral form of the old descriptor-bit check: the concrete
|
||||
// invariant is that a later caller cannot restore a working `.add`
|
||||
// by re-replacing the property. Assert that any re-defineProperty
|
||||
// attempt throws, rather than inspecting descriptor bits directly —
|
||||
// tests should pin observable behavior, not implementation shape.
|
||||
const s = BORN_IN_SHOWCASE as unknown as Set<string>;
|
||||
expect(() => {
|
||||
Object.defineProperty(s, "add", { value: (_v: string) => s });
|
||||
}).toThrow();
|
||||
expect(() => {
|
||||
Object.defineProperty(s, "delete", { value: (_v: string) => true });
|
||||
}).toThrow();
|
||||
expect(() => {
|
||||
Object.defineProperty(s, "clear", { value: () => undefined });
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it("SLUG_MAP rejects re-defining its mutation methods", () => {
|
||||
const m = SLUG_MAP as unknown as Map<string, string>;
|
||||
expect(() => {
|
||||
Object.defineProperty(m, "set", {
|
||||
value: (_k: string, _v: string) => m,
|
||||
});
|
||||
}).toThrow();
|
||||
expect(() => {
|
||||
Object.defineProperty(m, "delete", { value: (_k: string) => true });
|
||||
}).toThrow();
|
||||
expect(() => {
|
||||
Object.defineProperty(m, "clear", { value: () => undefined });
|
||||
}).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SLUG_TO_EXAMPLES / FALLBACK_MAP / BORN_IN_SHOWCASE derive from one entries source", () => {
|
||||
it("every FALLBACK_MAP entry names a slug also present in SLUG_TO_EXAMPLES", () => {
|
||||
// Derivation invariant: both maps come from the same per-slug entry
|
||||
// (slug, examples dirs, optional fallback). A FALLBACK_MAP key with
|
||||
// no SLUG_TO_EXAMPLES counterpart would mean the two maps were edited
|
||||
// independently and fell out of sync.
|
||||
for (const slug of Object.keys(FALLBACK_MAP)) {
|
||||
expect(
|
||||
(SLUG_TO_EXAMPLES as Record<string, unknown>)[slug],
|
||||
`FALLBACK_MAP slug '${slug}' missing from SLUG_TO_EXAMPLES`,
|
||||
).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("FALLBACK_MAP target equals the first SLUG_TO_EXAMPLES candidate for the same slug", () => {
|
||||
// Both maps share the same underlying entry; the fallback is simply
|
||||
// the chosen preferred dir out of SLUG_TO_EXAMPLES[slug]. If someone
|
||||
// edits one side but not the other, the two maps will disagree.
|
||||
for (const [slug, target] of Object.entries(FALLBACK_MAP)) {
|
||||
const dirs = SLUG_TO_EXAMPLES[slug];
|
||||
expect(dirs).toBeDefined();
|
||||
expect(dirs![0]).toBe(target);
|
||||
}
|
||||
});
|
||||
|
||||
it("BORN_IN_SHOWCASE and SLUG_TO_EXAMPLES are disjoint (no slug is both)", () => {
|
||||
// A born-in-showcase slug has no examples counterpart by definition;
|
||||
// putting it in SLUG_TO_EXAMPLES would contradict that. The derivation
|
||||
// pipeline enforces that an entry with `bornInShowcase: true` has NO
|
||||
// examples dirs, so the two outputs can never overlap.
|
||||
for (const slug of BORN_IN_SHOWCASE) {
|
||||
expect(
|
||||
(SLUG_TO_EXAMPLES as Record<string, unknown>)[slug],
|
||||
`'${slug}' is in both BORN_IN_SHOWCASE and SLUG_TO_EXAMPLES`,
|
||||
).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("BORN_IN_SHOWCASE and FALLBACK_MAP are disjoint", () => {
|
||||
// Same pairing: born-in-showcase slugs have no examples dir, so a
|
||||
// FALLBACK_MAP target for one would be nonsensical.
|
||||
for (const slug of BORN_IN_SHOWCASE) {
|
||||
expect(
|
||||
(FALLBACK_MAP as Record<string, unknown>)[slug],
|
||||
`'${slug}' is in both BORN_IN_SHOWCASE and FALLBACK_MAP`,
|
||||
).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("FALLBACK_MAP (documents SLUG_MAP staleness)", () => {
|
||||
it("contains the stale-mapping entries validate-pins.ts relied on", () => {
|
||||
expect(FALLBACK_MAP["crewai-crews"]).toBe("crewai-crews");
|
||||
expect(FALLBACK_MAP["ms-agent-dotnet"]).toBe("ms-agent-framework-dotnet");
|
||||
expect(FALLBACK_MAP["ms-agent-python"]).toBe("ms-agent-framework-python");
|
||||
expect(FALLBACK_MAP["pydantic-ai"]).toBe("pydantic-ai");
|
||||
expect(FALLBACK_MAP["strands"]).toBe("strands-python");
|
||||
});
|
||||
|
||||
it("rejects runtime mutation", () => {
|
||||
expect(() => {
|
||||
(FALLBACK_MAP as unknown as Record<string, string>)["new-key"] = "bogus";
|
||||
}).toThrow();
|
||||
expect(() => {
|
||||
(FALLBACK_MAP as unknown as Record<string, string>)["strands"] = "other";
|
||||
}).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* build-outputs.ts — Parse + merge the structured per-slot build results
|
||||
* emitted by `showcase_build.yml`. Each matrix slot in the build job
|
||||
* uploads a per-slot artifact named `build-result-<dispatch_name>`
|
||||
* containing a single `result.json` payload of the shape
|
||||
* `{service: "<dispatch_name>", status: "success"|"failure"|"skipped"}`.
|
||||
* The aggregate-build-results job downloads every `build-result-*`
|
||||
* artifact and merges the payloads via `mergeBuildResultFiles` below;
|
||||
* the resulting array is uploaded as the canonical `build-results`
|
||||
* artifact for cross-workflow consumption. The deploy workflow (and the
|
||||
* redeploy guard) read this list instead of parsing job names.
|
||||
*
|
||||
* NOTE: the "single result.json per slot" invariant is enforced
|
||||
* workflow-side (each matrix slot writes exactly one file before
|
||||
* uploading its artifact); this module assumes that contract and
|
||||
* validates only the parsed payload shape, not the filesystem layout.
|
||||
*/
|
||||
|
||||
// Single source of truth for the set of valid build outcomes. The
|
||||
// `as const` tuple drives BOTH the runtime `VALID_STATUSES` set AND
|
||||
// the compile-time `BuildOutcome` union (derived via indexed access
|
||||
// below), so the tuple is the only place a status needs to be added.
|
||||
// Since `BuildOutcome` is derived from this tuple there is no separate
|
||||
// union that could drift out of sync — no redundant exhaustiveness
|
||||
// assertion is needed.
|
||||
const BUILD_OUTCOMES = ["success", "failure", "skipped"] as const;
|
||||
|
||||
export type BuildOutcome = (typeof BUILD_OUTCOMES)[number];
|
||||
|
||||
const VALID_STATUSES: ReadonlySet<BuildOutcome> = new Set(BUILD_OUTCOMES);
|
||||
|
||||
export interface ServiceBuildResult {
|
||||
service: string;
|
||||
status: BuildOutcome;
|
||||
}
|
||||
|
||||
function isNonBlankString(value: unknown): value is string {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared validator for a single `{service, status}` payload. Used by
|
||||
* both `parseBuildOutputs` (per array entry) and `mergeBuildResultFiles`
|
||||
* (per slot payload) so validation rules + error wording live in one
|
||||
* place. `contextLabel` is prefixed to every error message — callers
|
||||
* pass something like `"parseBuildOutputs entry[3]"` or
|
||||
* `"mergeBuildResultFiles slot[2]"` so the failure points at the
|
||||
* offending row.
|
||||
*/
|
||||
function validateServiceBuildResult(
|
||||
raw: unknown,
|
||||
contextLabel: string,
|
||||
): ServiceBuildResult {
|
||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
||||
throw new Error(
|
||||
`${contextLabel}: expected object with {service, status}, got ${JSON.stringify(raw)}`,
|
||||
);
|
||||
}
|
||||
const service = (raw as { service?: unknown }).service;
|
||||
if (typeof service !== "string") {
|
||||
throw new Error(
|
||||
`${contextLabel}: missing required string field "service": ${JSON.stringify(raw)}`,
|
||||
);
|
||||
}
|
||||
const trimmedService = service.trim();
|
||||
if (trimmedService.length === 0) {
|
||||
throw new Error(
|
||||
`${contextLabel}: field "service" must be a non-empty, non-whitespace string: ${JSON.stringify(raw)}`,
|
||||
);
|
||||
}
|
||||
const status = (raw as { status?: unknown }).status;
|
||||
if (
|
||||
typeof status !== "string" ||
|
||||
!VALID_STATUSES.has(status as BuildOutcome)
|
||||
) {
|
||||
throw new Error(
|
||||
`${contextLabel}: invalid "status" (must be success|failure|skipped): ${JSON.stringify(raw)}`,
|
||||
);
|
||||
}
|
||||
return { service: trimmedService, status: status as BuildOutcome };
|
||||
}
|
||||
|
||||
export function parseBuildOutputs(raw: string): ServiceBuildResult[] {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`Failed to parse build outputs JSON: ${
|
||||
e instanceof Error ? e.message : String(e)
|
||||
}`,
|
||||
{ cause: e },
|
||||
);
|
||||
}
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new Error("Build outputs must be a JSON array");
|
||||
}
|
||||
return parsed.map((entry, idx) =>
|
||||
validateServiceBuildResult(entry, `parseBuildOutputs entry[${idx}]`),
|
||||
);
|
||||
}
|
||||
|
||||
export function successSet(results: ServiceBuildResult[]): string[] {
|
||||
return results.filter((r) => r.status === "success").map((r) => r.service);
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical artifact-name convention for the per-slot build-result
|
||||
* handoff. Each matrix slot in showcase_build.yml uploads exactly one
|
||||
* artifact named `build-result-<dispatch_name>` containing a single
|
||||
* `result.json` file. The aggregator job downloads every artifact
|
||||
* matching the `build-result-*` pattern and merges them via
|
||||
* mergeBuildResultFiles below. We refuse empty/whitespace service
|
||||
* names so the per-slot artifact cannot collide with the aggregated
|
||||
* `build-results` artifact published downstream.
|
||||
*/
|
||||
export function buildResultArtifactName(service: string): string {
|
||||
if (!isNonBlankString(service)) {
|
||||
throw new Error(
|
||||
"buildResultArtifactName: `service` must be a non-empty, non-whitespace string",
|
||||
);
|
||||
}
|
||||
return `build-result-${service}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge a list of per-slot result.json payloads (raw strings, one per
|
||||
* matrix slot's uploaded artifact) into a single ServiceBuildResult[].
|
||||
* Each payload MUST be a JSON object with `service: string` and
|
||||
* `status: success|failure|skipped`. The merge is order-preserving so
|
||||
* downstream consumers can rely on stable iteration.
|
||||
*
|
||||
* Fails loud on duplicate `service` names across slots: a duplicate
|
||||
* means an upstream dispatch-name collision (two slots claiming the
|
||||
* same service), which would let a `failure` + `success` pair for the
|
||||
* same service spuriously look like a success in `successSet`. We
|
||||
* surface the collision instead of silently deduping.
|
||||
*/
|
||||
export function mergeBuildResultFiles(
|
||||
slotPayloads: readonly string[],
|
||||
): ServiceBuildResult[] {
|
||||
const merged = slotPayloads.map((raw, idx) => {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`mergeBuildResultFiles slot[${idx}]: not valid JSON: ${
|
||||
e instanceof Error ? e.message : String(e)
|
||||
}`,
|
||||
{ cause: e },
|
||||
);
|
||||
}
|
||||
return validateServiceBuildResult(
|
||||
parsed,
|
||||
`mergeBuildResultFiles slot[${idx}]`,
|
||||
);
|
||||
});
|
||||
|
||||
const seen = new Set<string>();
|
||||
const duplicates = new Set<string>();
|
||||
for (const { service } of merged) {
|
||||
if (seen.has(service)) {
|
||||
duplicates.add(service);
|
||||
} else {
|
||||
seen.add(service);
|
||||
}
|
||||
}
|
||||
if (duplicates.size > 0) {
|
||||
const names = Array.from(duplicates).sort().join(", ");
|
||||
throw new Error(
|
||||
`mergeBuildResultFiles: duplicate service name(s) across slots: ${names}`,
|
||||
);
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true iff at least one service in the build set finished as
|
||||
* `success`. Gates redeploy: when no service succeeded, redeploy MUST
|
||||
* be skipped so we do not re-pull the stale `:latest` and silently
|
||||
* look healthy.
|
||||
*/
|
||||
export function shouldRedeployStaging(results: ServiceBuildResult[]): boolean {
|
||||
return results.some((r) => r.status === "success");
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
export interface MultiFileRegionSource {
|
||||
demoKey: string;
|
||||
regionName: string;
|
||||
files: string[];
|
||||
}
|
||||
|
||||
export const ALLOWED_MULTI_FILE_REGION_KEYS = new Set([
|
||||
"ag2::headless-complete::custom-bubbles",
|
||||
"ag2::open-gen-ui-advanced::sandbox-function-registration",
|
||||
"agno::headless-complete::custom-bubbles",
|
||||
"agno::open-gen-ui-advanced::sandbox-function-registration",
|
||||
"built-in-agent::headless-complete::custom-bubbles",
|
||||
"claude-sdk-python::headless-complete::custom-bubbles",
|
||||
"claude-sdk-python::open-gen-ui-advanced::sandbox-function-registration",
|
||||
"claude-sdk-typescript::headless-complete::custom-bubbles",
|
||||
"claude-sdk-typescript::open-gen-ui-advanced::sandbox-function-registration",
|
||||
"crewai-crews::headless-complete::custom-bubbles",
|
||||
"crewai-crews::open-gen-ui-advanced::sandbox-function-registration",
|
||||
"google-adk::headless-complete::custom-bubbles",
|
||||
"google-adk::open-gen-ui-advanced::sandbox-function-registration",
|
||||
"langgraph-fastapi::headless-complete::custom-bubbles",
|
||||
"langgraph-fastapi::open-gen-ui-advanced::sandbox-function-registration",
|
||||
"langgraph-python::headless-complete::custom-bubbles",
|
||||
"langgraph-python::open-gen-ui-advanced::sandbox-function-registration",
|
||||
"langgraph-typescript::headless-complete::custom-bubbles",
|
||||
"langgraph-typescript::open-gen-ui-advanced::sandbox-function-registration",
|
||||
"langroid::headless-complete::custom-bubbles",
|
||||
"langroid::open-gen-ui-advanced::sandbox-function-registration",
|
||||
"llamaindex::headless-complete::custom-bubbles",
|
||||
"llamaindex::open-gen-ui-advanced::sandbox-function-registration",
|
||||
"mastra::headless-complete::custom-bubbles",
|
||||
"mastra::open-gen-ui-advanced::sandbox-function-registration",
|
||||
"ms-agent-dotnet::open-gen-ui-advanced::sandbox-function-registration",
|
||||
"ms-agent-harness-dotnet::open-gen-ui-advanced::sandbox-function-registration",
|
||||
"ms-agent-python::open-gen-ui-advanced::sandbox-function-registration",
|
||||
"pydantic-ai::headless-complete::custom-bubbles",
|
||||
"pydantic-ai::open-gen-ui-advanced::sandbox-function-registration",
|
||||
"spring-ai::headless-complete::custom-bubbles",
|
||||
"spring-ai::open-gen-ui-advanced::sandbox-function-registration",
|
||||
"strands::headless-complete::custom-bubbles",
|
||||
"strands::open-gen-ui-advanced::sandbox-function-registration",
|
||||
"strands-typescript::headless-complete::custom-bubbles",
|
||||
"strands-typescript::open-gen-ui-advanced::sandbox-function-registration",
|
||||
]);
|
||||
|
||||
export function multiFileRegionKey(
|
||||
demoKey: string,
|
||||
regionName: string,
|
||||
): string {
|
||||
return `${demoKey}::${regionName}`;
|
||||
}
|
||||
|
||||
export function findUnexpectedMultiFileRegions(
|
||||
sources: MultiFileRegionSource[],
|
||||
): MultiFileRegionSource[] {
|
||||
return sources.filter(
|
||||
(source) =>
|
||||
source.files.length > 1 &&
|
||||
!ALLOWED_MULTI_FILE_REGION_KEYS.has(
|
||||
multiFileRegionKey(source.demoKey, source.regionName),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { checkEssentialContent } from "./essential-content.js";
|
||||
|
||||
describe("checkEssentialContent", () => {
|
||||
it("flags a quickstart page missing the 'run agent' section", () => {
|
||||
const result = checkEssentialContent({
|
||||
path: "integrations/mastra/quickstart.mdx",
|
||||
body: "# QS\n\nInstall the CLI.\n",
|
||||
});
|
||||
expect(result.status).toBe("fail");
|
||||
expect(result.messages.join(" ").toLowerCase()).toContain("run");
|
||||
});
|
||||
|
||||
it("passes a quickstart page with all required elements", () => {
|
||||
const body = `# QS
|
||||
## Install
|
||||
\`\`\`bash
|
||||
npm install
|
||||
\`\`\`
|
||||
## Run your agent
|
||||
## Wire CopilotKit provider
|
||||
## Try it
|
||||
`;
|
||||
const result = checkEssentialContent({
|
||||
path: "integrations/mastra/quickstart.mdx",
|
||||
body,
|
||||
});
|
||||
expect(result.status).toBe("pass");
|
||||
});
|
||||
|
||||
it("uses the feature-page checklist for non-quickstart pages", () => {
|
||||
const body = "# Frontend Tools\n\nWhat is this? Something.\n";
|
||||
const result = checkEssentialContent({
|
||||
path: "integrations/mastra/frontend-tools.mdx",
|
||||
body,
|
||||
});
|
||||
expect(result.status).toBe("fail");
|
||||
expect(result.messages.join(" ").toLowerCase()).toContain("code sample");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
export interface PageInput {
|
||||
path: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export type Status = "pass" | "fail";
|
||||
|
||||
export interface ContentResult {
|
||||
status: Status;
|
||||
messages: string[];
|
||||
}
|
||||
|
||||
interface Rule {
|
||||
description: string;
|
||||
test: (body: string) => boolean;
|
||||
}
|
||||
|
||||
const QUICKSTART_RULES: Rule[] = [
|
||||
{
|
||||
description: "install step (bash/npm/uv install)",
|
||||
test: (b) => /install|npm i\b|uv add|npx /i.test(b),
|
||||
},
|
||||
{
|
||||
description: "run agent step",
|
||||
test: (b) => /run.*(agent|server|dev)/i.test(b),
|
||||
},
|
||||
{
|
||||
description: "wire CopilotKit provider",
|
||||
test: (b) => /CopilotKit\s*(?:Provider)?|<CopilotKit\b/i.test(b),
|
||||
},
|
||||
{
|
||||
description: "try-it / first interaction",
|
||||
test: (b) => /try it|chat|ask the agent|start chatting/i.test(b),
|
||||
},
|
||||
];
|
||||
|
||||
const FEATURE_RULES: Rule[] = [
|
||||
{
|
||||
description: "what-is intro",
|
||||
test: (b) => /what is this|what is\b|introduction/i.test(b),
|
||||
},
|
||||
{
|
||||
description: "at least one fenced code sample",
|
||||
test: (b) => /```[a-z]/i.test(b),
|
||||
},
|
||||
{
|
||||
description: "next-steps or further-reading link",
|
||||
test: (b) =>
|
||||
/next steps|what's next|further reading|see also/i.test(b) ||
|
||||
/<Card\b/i.test(b),
|
||||
},
|
||||
];
|
||||
|
||||
function pickRules(pathRel: string): Rule[] {
|
||||
if (/quickstart/i.test(pathRel)) return QUICKSTART_RULES;
|
||||
if (/troubleshooting/i.test(pathRel)) return [];
|
||||
return FEATURE_RULES;
|
||||
}
|
||||
|
||||
export function checkEssentialContent(input: PageInput): ContentResult {
|
||||
const rules = pickRules(input.path);
|
||||
const messages: string[] = [];
|
||||
for (const rule of rules) {
|
||||
if (!rule.test(input.body)) {
|
||||
messages.push(`${input.path}: missing ${rule.description}`);
|
||||
}
|
||||
}
|
||||
return {
|
||||
status: messages.length === 0 ? "pass" : "fail",
|
||||
messages,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,528 @@
|
||||
/**
|
||||
* Shared manifest schema + parser.
|
||||
*
|
||||
* Used by audit.ts, validate-parity.ts, and capture-previews.ts so the
|
||||
* three tools agree on:
|
||||
* 1. the Manifest / ManifestDemo TypeScript shape
|
||||
* 2. runtime shape validation of `manifest.yaml`
|
||||
* 3. the tagged-union return type distinguishing missing /
|
||||
* malformed / unreadable / ok
|
||||
*/
|
||||
|
||||
import fs from "fs";
|
||||
import yaml from "yaml";
|
||||
|
||||
/**
|
||||
* Branded, non-empty demo id. Structurally a string (so downstream
|
||||
* callers can still use `demo.id` in template strings, `Set<string>`
|
||||
* membership, equality comparisons, etc.) but the branding prevents
|
||||
* arbitrary strings from flowing into a `DemoId` slot without going
|
||||
* through the `createDemoId` smart constructor. parseManifest is the
|
||||
* sole production caller of that constructor; it validates non-empty
|
||||
* at runtime and re-reports shape-malformed on failure.
|
||||
*
|
||||
* The `__brand` property is phantom-only — it does not exist at
|
||||
* runtime. That keeps the branded type zero-cost while still giving
|
||||
* the compiler a distinct nominal type for id values.
|
||||
*/
|
||||
export type DemoId = string & { readonly __brand: "DemoId" };
|
||||
|
||||
/**
|
||||
* Smart constructor for `DemoId`. Returns the branded value on success
|
||||
* or `null` if validation fails (non-string or empty string). Kept as
|
||||
* `null`-returning rather than throwing so `parseManifest` can turn a
|
||||
* failure into its usual `{kind:"malformed", subkind:"shape"}` result
|
||||
* without crossing an exception boundary.
|
||||
*
|
||||
* The parameter type is `unknown` — this function sits at an API
|
||||
* boundary (yaml.parse results, JSON-roundtripped demos, caller-supplied
|
||||
* strings) where the compile-time type does not hold. A widened param
|
||||
* keeps the runtime typeof guard alive rather than reducing to a dead
|
||||
* check.
|
||||
*/
|
||||
export function createDemoId(s: unknown): DemoId | null {
|
||||
if (typeof s !== "string" || s.length === 0) return null;
|
||||
return s as DemoId;
|
||||
}
|
||||
|
||||
/**
|
||||
* One entry under `manifest.yaml :: demos[]`. Name is optional; id is
|
||||
* required AND non-empty (checked at runtime by parseManifest, typed
|
||||
* via the `DemoId` brand).
|
||||
*
|
||||
* Fields are `readonly`: parseManifest freezes the returned object so
|
||||
* downstream consumers cannot mutate a demo after validation. The type
|
||||
* matches the runtime freeze.
|
||||
*/
|
||||
export interface ManifestDemo {
|
||||
readonly id: DemoId;
|
||||
readonly name?: string;
|
||||
/**
|
||||
* Informational demos (e.g. cli-start): when set, the row surfaces this
|
||||
* copy-pasteable command in the dashboard instead of a live preview.
|
||||
* Such demos have no on-disk folder — parity / bundling skip them.
|
||||
*/
|
||||
readonly command?: string;
|
||||
/**
|
||||
* Relative URL path for the demo. `id` is the CATALOG identifier
|
||||
* (stable; matched against spec/qa filenames and shell-side registry
|
||||
* entries), whereas `route` is the deliberate URL / filesystem path
|
||||
* (`/demos/<dir>` — resolved to `src/app/demos/<dir>/` by consumers
|
||||
* that need the on-disk location, e.g. bundle-demo-content and
|
||||
* validate-parity). The two are intentionally separate so renaming a
|
||||
* catalog id does not mass-rewrite URLs (and vice versa).
|
||||
*
|
||||
* Optional at the type boundary for backward compatibility with test
|
||||
* fixtures and historical manifests. When present, parseManifest
|
||||
* validates it is a non-empty string starting with "/demos/".
|
||||
* Consumers that need an on-disk demo directory should prefer
|
||||
* `route` when present and fall back to `id`.
|
||||
*/
|
||||
readonly route?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Union of the fields used by audit.ts / validate-parity.ts / capture-previews.ts.
|
||||
*
|
||||
* `slug` is REQUIRED: every manifest in showcase/integrations/ carries a
|
||||
* slug, and none of the three consumers ever constructs or accepts a
|
||||
* Manifest without one. Marking it required here lets downstream code
|
||||
* drop `manifest.slug ?? "(unknown)"` fallbacks without TypeScript
|
||||
* complaining. parseManifest enforces `slug` is a non-empty string at
|
||||
* runtime, so the type matches reality.
|
||||
*
|
||||
* `name` and `deployed` remain optional: in practice not every manifest
|
||||
* sets `name`, and `deployed` only appears when meaningful.
|
||||
*
|
||||
* `demos` is NON-optional but always set by parseManifest — an empty
|
||||
* readonly array when the manifest omits the field. Callers iterate
|
||||
* unconditionally instead of `?.` chaining.
|
||||
*
|
||||
* Fields are `readonly`: parseManifest deep-freezes the returned object
|
||||
* (including the nested `demos` array), so the public type matches the
|
||||
* runtime invariant.
|
||||
*/
|
||||
export interface Manifest {
|
||||
readonly slug: string;
|
||||
readonly name?: string;
|
||||
readonly deployed?: boolean;
|
||||
/**
|
||||
* Deployed backend URL for this integration (e.g. the Railway public
|
||||
* domain). Used by capture-previews.ts to navigate to each demo. Not
|
||||
* required by audit.ts / validate-parity.ts, so it remains optional;
|
||||
* callers that need it should check before use.
|
||||
* parseManifest validates that, when present, it is a non-empty string.
|
||||
*/
|
||||
readonly backend_url?: string;
|
||||
readonly demos: readonly ManifestDemo[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Tagged union of manifest parse outcomes. Callers discriminate on
|
||||
* `kind`:
|
||||
*
|
||||
* - "missing" — manifest.yaml does not exist on disk
|
||||
* - "malformed" — file exists but its contents do not round-trip
|
||||
* to a valid Manifest. Further split on `subkind`:
|
||||
* "syntax" = YAML parser rejected the text outright
|
||||
* (unterminated arrays, bad indentation);
|
||||
* "shape" = YAML parsed but the resulting value
|
||||
* does not match the Manifest shape
|
||||
* (null/scalar top-level, non-array demos,
|
||||
* demo missing id, duplicate demo id, etc.)
|
||||
* - "unreadable" — file exists but readFileSync threw
|
||||
* (permissions, I/O race, etc.)
|
||||
* - "ok" — parse succeeded and shape validated
|
||||
*
|
||||
* The `subkind` discriminator on "malformed" lets callers route each
|
||||
* failure mode distinctly: a "syntax" subkind flags a likely typo in
|
||||
* the YAML source, whereas "shape" flags a schema-drift / validation
|
||||
* problem (missing required field, wrong type, duplicate id, etc.).
|
||||
*/
|
||||
export type ParsedManifest =
|
||||
| { kind: "ok"; manifest: Manifest }
|
||||
| { kind: "missing" }
|
||||
| { kind: "malformed"; subkind: "syntax" | "shape"; error: string }
|
||||
| { kind: "unreadable"; error: string };
|
||||
|
||||
function errMsg(e: unknown): string {
|
||||
return e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
|
||||
/**
|
||||
* Describe a value for error messages. Distinguishes null/array from
|
||||
* plain `typeof` because `typeof null === "object"` and
|
||||
* `typeof [] === "object"` both hide the real shape from users.
|
||||
*/
|
||||
function describeType(v: unknown): string {
|
||||
if (v === null) return "null";
|
||||
if (Array.isArray(v)) return "array";
|
||||
return typeof v;
|
||||
}
|
||||
|
||||
/**
|
||||
* `Object.hasOwn`-backed predicate that narrows `obj` to a type where
|
||||
* `key` is known to exist. `Object.hasOwn` avoids inherited-property
|
||||
* pitfalls of the raw `in` operator for any plain object, and pairs
|
||||
* with the TS predicate so callers read `obj[key]` without further
|
||||
* casts.
|
||||
*/
|
||||
function hasOwnProp<K extends string>(
|
||||
obj: object,
|
||||
key: K,
|
||||
): obj is object & Record<K, unknown> {
|
||||
return Object.hasOwn(obj, key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared frozen empty-array sentinel reused across the "no demos
|
||||
* declared" path. Every ok-result's `.demos` is this same frozen
|
||||
* readonly value when the manifest omits demos, so callers can iterate
|
||||
* unconditionally and the public `readonly` type matches the runtime
|
||||
* freeze.
|
||||
*/
|
||||
const EMPTY_DEMOS: readonly ManifestDemo[] = Object.freeze([]);
|
||||
|
||||
/**
|
||||
* Read + parse + validate a manifest.yaml at `filePath`. Returns a
|
||||
* tagged-union `ParsedManifest`; never throws for content errors.
|
||||
*
|
||||
* The shape checks are intentionally strict:
|
||||
* - top-level must be a plain object mapping (not null, scalar, or
|
||||
* array) — otherwise downstream `.demos` / `.deployed` reads would
|
||||
* TypeError at runtime;
|
||||
* - `slug` must be a non-empty string (every consumer relies
|
||||
* on it — missing slug is always a bug);
|
||||
* - `name`, if present, must be a string;
|
||||
* - `demos`, if present, must be an array of objects each with a
|
||||
* string `id`;
|
||||
* - `deployed`, if present, must be a boolean (YAML `"yes"` parses to
|
||||
* a string, which would be silently treated as truthy without this
|
||||
* check — classic footgun).
|
||||
*
|
||||
* Any shape failure produces
|
||||
* `{ kind: "malformed", subkind: "shape", error }` with a
|
||||
* human-readable reason. YAML parser failures produce
|
||||
* `{ kind: "malformed", subkind: "syntax", error }` (distinct so CI can
|
||||
* route syntax errors differently from schema-drift errors). Missing
|
||||
* files produce `{ kind: "missing" }` (distinct from malformed so
|
||||
* callers can emit different anomalies). Read errors (EACCES etc.)
|
||||
* produce `{ kind: "unreadable" }` — we do NOT collapse them into
|
||||
* malformed because the file's contents are not actually known to be
|
||||
* invalid.
|
||||
*/
|
||||
export function parseManifest(
|
||||
filePath: string,
|
||||
dirSlug?: string,
|
||||
): ParsedManifest {
|
||||
// Empty-string dirSlug is a caller bug: `undefined` is the opt-out
|
||||
// sentinel ("caller did not supply a dir slug"). If a caller's
|
||||
// path.basename or similar produced `""` (trailing-slash path,
|
||||
// typo), silently collapsing to undefined would hide the bug and
|
||||
// skip the slug-mismatch guard. Surface as malformed-shape so the
|
||||
// caller sees the error at the parser boundary.
|
||||
if (dirSlug === "") {
|
||||
return {
|
||||
kind: "malformed",
|
||||
subkind: "shape",
|
||||
error: `caller passed empty dirSlug (use undefined to opt out of the slug-check)`,
|
||||
};
|
||||
}
|
||||
|
||||
// Use statSync instead of fs.existsSync so ENOENT and non-ENOENT
|
||||
// errno values (EACCES, ENOTDIR, etc.) are distinguished. existsSync
|
||||
// CONFLATES these: a manifest whose parent dir is 0700 owned by
|
||||
// another user returns false from existsSync, which would collapse
|
||||
// an infrastructure failure into a benign "missing" signal. The
|
||||
// long docstring on probeDir in validate-parity.ts explains the
|
||||
// same anti-pattern — the fix is to stat + inspect errno.
|
||||
try {
|
||||
fs.statSync(filePath);
|
||||
} catch (e) {
|
||||
const code = (e as NodeJS.ErrnoException)?.code;
|
||||
if (code === "ENOENT") return { kind: "missing" };
|
||||
return { kind: "unreadable", error: errMsg(e) };
|
||||
}
|
||||
|
||||
let raw: string;
|
||||
try {
|
||||
raw = fs.readFileSync(filePath, "utf-8");
|
||||
} catch (e) {
|
||||
return { kind: "unreadable", error: errMsg(e) };
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = yaml.parse(raw);
|
||||
} catch (e) {
|
||||
return { kind: "malformed", subkind: "syntax", error: errMsg(e) };
|
||||
}
|
||||
|
||||
// Top-level type guard. yaml.parse("") is null and yaml.parse("42") is
|
||||
// 42 — neither is a manifest. Arrays also aren't valid (manifest.yaml
|
||||
// is a mapping).
|
||||
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return {
|
||||
kind: "malformed",
|
||||
subkind: "shape",
|
||||
error: `expected YAML object at top level, got ${
|
||||
parsed === null ? "null (empty file?)" : describeType(parsed)
|
||||
}`,
|
||||
};
|
||||
}
|
||||
|
||||
// At this point `parsed` is known to be a plain object mapping. We
|
||||
// narrow each field access through `hasOwnProp` so the compiler does
|
||||
// not need a blanket `as Record<string, unknown>` cast — every read
|
||||
// below is narrowed by the predicate it just passed.
|
||||
const obj: object = parsed;
|
||||
|
||||
// slug is required — every consumer (audit.ts / validate-parity.ts /
|
||||
// capture-previews.ts) assumes a slug exists. A manifest without one is
|
||||
// always a bug, not a tolerable edge case.
|
||||
if (!hasOwnProp(obj, "slug")) {
|
||||
return {
|
||||
kind: "malformed",
|
||||
subkind: "shape",
|
||||
error: `missing required "slug" (non-empty string)`,
|
||||
};
|
||||
}
|
||||
const slug = obj.slug;
|
||||
if (typeof slug !== "string" || slug.length === 0) {
|
||||
// `hasOwnProp(obj, "slug")` already passed above, so the key is
|
||||
// present; the value can still be the empty string, null, or a
|
||||
// non-string. `describeType` covers all of those uniformly.
|
||||
return {
|
||||
kind: "malformed",
|
||||
subkind: "shape",
|
||||
error: `expected "slug" to be a non-empty string, got ${describeType(slug)}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Slug-mismatch guard. Every consumer derives the target
|
||||
// package by computing `packagesDir/<slug>/manifest.yaml`, so if the
|
||||
// manifest's declared slug disagrees with the directory that holds
|
||||
// it, downstream tools would silently key a copy-paste or rename
|
||||
// mistake into the wrong package. Catch at the parser so both tools
|
||||
// report the drift the same way.
|
||||
//
|
||||
// Opt-in: callers pass the expected dir slug via the `dirSlug`
|
||||
// parameter. When omitted (undefined), the check is skipped so
|
||||
// existing tests and programmatic callers that don't operate against
|
||||
// the packages tree continue to work. An explicit empty string is
|
||||
// rejected at the top of the function as a caller bug — `undefined`
|
||||
// is the opt-out sentinel; `""` is never a valid dir slug.
|
||||
// The two production callers (audit.ts, validate-parity.ts) pass the
|
||||
// slug they used to build the filePath.
|
||||
const expectedDirSlug = typeof dirSlug === "string" ? dirSlug : undefined;
|
||||
if (expectedDirSlug !== undefined && expectedDirSlug !== slug) {
|
||||
return {
|
||||
kind: "malformed",
|
||||
subkind: "shape",
|
||||
error: `slug mismatch: manifest declares "${slug}" but lives under dir "${expectedDirSlug}"`,
|
||||
};
|
||||
}
|
||||
|
||||
// name (optional) must be a string if present.
|
||||
let name: string | undefined;
|
||||
if (hasOwnProp(obj, "name") && obj.name !== undefined) {
|
||||
if (typeof obj.name !== "string") {
|
||||
return {
|
||||
kind: "malformed",
|
||||
subkind: "shape",
|
||||
error: `expected "name" to be a string, got ${describeType(obj.name)}`,
|
||||
};
|
||||
}
|
||||
name = obj.name;
|
||||
}
|
||||
|
||||
// deployed (optional) must be a real boolean if present.
|
||||
let deployed: boolean | undefined;
|
||||
if (hasOwnProp(obj, "deployed")) {
|
||||
if (typeof obj.deployed !== "boolean") {
|
||||
return {
|
||||
kind: "malformed",
|
||||
subkind: "shape",
|
||||
error: `expected "deployed" to be boolean, got ${describeType(obj.deployed)}`,
|
||||
};
|
||||
}
|
||||
deployed = obj.deployed;
|
||||
}
|
||||
|
||||
// backend_url (optional) must be a non-empty string if present. Not
|
||||
// required by the audit/parity tools, so an absent field is fine.
|
||||
let backendUrl: string | undefined;
|
||||
if (hasOwnProp(obj, "backend_url") && obj.backend_url !== undefined) {
|
||||
if (typeof obj.backend_url !== "string" || obj.backend_url.length === 0) {
|
||||
return {
|
||||
kind: "malformed",
|
||||
subkind: "shape",
|
||||
error: `expected "backend_url" to be a non-empty string, got ${describeType(obj.backend_url)}`,
|
||||
};
|
||||
}
|
||||
backendUrl = obj.backend_url;
|
||||
}
|
||||
|
||||
// demos must be an array of objects with non-empty string id. Both
|
||||
// "key absent" and "explicit null" (YAML `demos:`) are treated as
|
||||
// "no demos declared" — parseManifest normalizes to an empty frozen
|
||||
// array so `Manifest.demos` is always set. If the key is present with
|
||||
// a non-nullish value that is not an array, fall through and report.
|
||||
let demos: readonly ManifestDemo[] = EMPTY_DEMOS;
|
||||
if (hasOwnProp(obj, "demos") && obj.demos != null) {
|
||||
const rawDemos = obj.demos;
|
||||
if (!Array.isArray(rawDemos)) {
|
||||
return {
|
||||
kind: "malformed",
|
||||
subkind: "shape",
|
||||
error: `expected "demos" to be an array, got ${describeType(rawDemos)}`,
|
||||
};
|
||||
}
|
||||
const validated: ManifestDemo[] = [];
|
||||
// Duplicate-id detection: two demos with the same id cascade into
|
||||
// double-counted coverage and double missing-demo-dir anomalies
|
||||
// downstream. Reject at validation time so the error surfaces at
|
||||
// the manifest, not at the consuming tool.
|
||||
const seen = new Set<string>();
|
||||
for (let i = 0; i < rawDemos.length; i++) {
|
||||
const d: unknown = rawDemos[i];
|
||||
if (d === null || typeof d !== "object" || Array.isArray(d)) {
|
||||
return {
|
||||
kind: "malformed",
|
||||
subkind: "shape",
|
||||
error: `expected demos[${i}] to be an object, got ${describeType(d)}`,
|
||||
};
|
||||
}
|
||||
if (!hasOwnProp(d, "id") || typeof d.id !== "string") {
|
||||
// Missing key or non-string value: describe the actual type so
|
||||
// debuggers can distinguish this from the empty-string branch
|
||||
// below. `describeType(undefined)` covers the missing-key case
|
||||
// (the `hasOwnProp` narrowing means `d.id` is typed as unknown
|
||||
// only inside the branch, but at runtime an absent key reads as
|
||||
// undefined).
|
||||
const actual = hasOwnProp(d, "id") ? d.id : undefined;
|
||||
return {
|
||||
kind: "malformed",
|
||||
subkind: "shape",
|
||||
error: `expected demos[${i}].id to be a string, got ${describeType(actual)}`,
|
||||
};
|
||||
}
|
||||
const brandedId = createDemoId(d.id);
|
||||
if (brandedId === null) {
|
||||
// `d.id` is a string here (checked above); createDemoId only
|
||||
// returns null for the empty-string case. Keep the "non-empty
|
||||
// string" wording so this branch is distinct from the
|
||||
// missing/non-string branch above.
|
||||
return {
|
||||
kind: "malformed",
|
||||
subkind: "shape",
|
||||
error: `expected demos[${i}].id to be a non-empty string`,
|
||||
};
|
||||
}
|
||||
if (seen.has(brandedId)) {
|
||||
return {
|
||||
kind: "malformed",
|
||||
subkind: "shape",
|
||||
error: `duplicate demo id "${brandedId}" at demos[${i}]`,
|
||||
};
|
||||
}
|
||||
seen.add(brandedId);
|
||||
// demo-level `name` strictness: match the strictness applied to
|
||||
// other fields so schema drift (present-but-wrong-type name)
|
||||
// surfaces at the parser, not downstream. Absent `name` remains
|
||||
// valid.
|
||||
let demoName: string | undefined;
|
||||
if (hasOwnProp(d, "name") && d.name !== undefined) {
|
||||
if (typeof d.name !== "string") {
|
||||
return {
|
||||
kind: "malformed",
|
||||
subkind: "shape",
|
||||
error: `expected demos[${i}].name to be a string, got ${describeType(d.name)}`,
|
||||
};
|
||||
}
|
||||
demoName = d.name;
|
||||
}
|
||||
// demo-level `command`: optional informational demos (e.g. cli-start).
|
||||
// When set, the row surfaces this copy-pasteable command in the
|
||||
// dashboard instead of a live preview. Such demos have no on-disk
|
||||
// folder — parity / bundling skip them.
|
||||
let demoCommand: string | undefined;
|
||||
if (hasOwnProp(d, "command") && d.command !== undefined) {
|
||||
if (typeof d.command !== "string" || d.command.length === 0) {
|
||||
return {
|
||||
kind: "malformed",
|
||||
subkind: "shape",
|
||||
error: `expected demos[${i}].command to be a non-empty string, got ${describeType(d.command)}`,
|
||||
};
|
||||
}
|
||||
demoCommand = d.command;
|
||||
}
|
||||
// demo-level `route`: optional. When present, must be a non-empty
|
||||
// string beginning with "/demos/" so downstream consumers
|
||||
// (bundle-demo-content, validate-parity) can uniformly strip that
|
||||
// prefix to derive the on-disk demo directory. The `/demos/` guard
|
||||
// catches accidental absolute URLs or bare segments that would
|
||||
// silently point to the wrong directory at runtime.
|
||||
let demoRoute: string | undefined;
|
||||
if (hasOwnProp(d, "route") && d.route !== undefined) {
|
||||
if (typeof d.route !== "string" || d.route.length === 0) {
|
||||
return {
|
||||
kind: "malformed",
|
||||
subkind: "shape",
|
||||
error: `expected demos[${i}].route to be a non-empty string, got ${describeType(d.route)}`,
|
||||
};
|
||||
}
|
||||
if (!d.route.startsWith("/demos/")) {
|
||||
return {
|
||||
kind: "malformed",
|
||||
subkind: "shape",
|
||||
error: `expected demos[${i}].route to start with "/demos/", got "${d.route}"`,
|
||||
};
|
||||
}
|
||||
// Reject exactly "/demos/" (empty tail segment). Downstream
|
||||
// consumers strip the "/demos/" prefix to derive an on-disk
|
||||
// directory name; an empty tail would point at the parent
|
||||
// demos/ directory rather than a specific demo. Guard at the
|
||||
// parser boundary so validate-parity / bundle-demo-content can
|
||||
// treat a successful parse as a non-empty segment invariant.
|
||||
if (d.route.length <= "/demos/".length) {
|
||||
return {
|
||||
kind: "malformed",
|
||||
subkind: "shape",
|
||||
error: `expected demos[${i}].route to have a non-empty segment after "/demos/", got "${d.route}"`,
|
||||
};
|
||||
}
|
||||
demoRoute = d.route;
|
||||
}
|
||||
const demoEntry: {
|
||||
id: DemoId;
|
||||
name?: string;
|
||||
command?: string;
|
||||
route?: string;
|
||||
} = {
|
||||
id: brandedId,
|
||||
};
|
||||
if (demoName !== undefined) demoEntry.name = demoName;
|
||||
if (demoCommand !== undefined) demoEntry.command = demoCommand;
|
||||
if (demoRoute !== undefined) demoEntry.route = demoRoute;
|
||||
validated.push(Object.freeze(demoEntry));
|
||||
}
|
||||
demos = Object.freeze(validated);
|
||||
}
|
||||
|
||||
// Construct the result field-by-field from the narrowed locals so
|
||||
// there is no `as unknown as Manifest` double-cast crossing the
|
||||
// validation boundary. Each field below was checked individually
|
||||
// above; the compiler tracks the narrowed type through the local
|
||||
// bindings, so this object literal typechecks against Manifest
|
||||
// without any casts. The final object is frozen so the `readonly`
|
||||
// fields on Manifest match the runtime behavior.
|
||||
const manifest: Manifest = Object.freeze({
|
||||
slug,
|
||||
...(name !== undefined ? { name } : {}),
|
||||
...(deployed !== undefined ? { deployed } : {}),
|
||||
...(backendUrl !== undefined ? { backend_url: backendUrl } : {}),
|
||||
demos,
|
||||
});
|
||||
return { kind: "ok", manifest };
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* railway-graphql.ts — Single source of truth for the Railway GraphQL
|
||||
* endpoint host for TypeScript importers in this repo. The historic
|
||||
* `.com` host (`backboard.railway.com`) is unauthenticated for the
|
||||
* public GraphQL API and silently returns 401/403; the canonical host
|
||||
* is `backboard.railway.app`.
|
||||
*
|
||||
* Note: the Ruby `bin/railway` script and the inline `curl` commands
|
||||
* embedded in `.github/workflows/*.yml` hold their OWN copies of this
|
||||
* URL because they cannot import a TypeScript module. Those copies are
|
||||
* kept in sync by hand and enforced by the regression guard in
|
||||
* `./__tests__/railway-graphql.scan.test.ts`, which fails the build if
|
||||
* any source file under `showcase/` or `.github/workflows/` reintroduces
|
||||
* the `.com` host.
|
||||
*/
|
||||
export const RAILWAY_GRAPHQL_ENDPOINT =
|
||||
"https://backboard.railway.app/graphql/v2" as const;
|
||||
|
||||
// Fail-fast at module load if a hand-edit ever breaks the URL literal.
|
||||
new URL(RAILWAY_GRAPHQL_ENDPOINT);
|
||||
|
||||
/** Default cap for sanitizeErrorBody. Multi-KB Cloudflare WAF HTML
|
||||
* pages would otherwise spam stderr / $GITHUB_STEP_SUMMARY. */
|
||||
export const RAILWAY_ERROR_BODY_MAX_DEFAULT = 200;
|
||||
|
||||
/**
|
||||
* Sanitize a Railway API error body for inclusion in logs / the
|
||||
* markdown summary. Railway/Cloudflare error responses can be
|
||||
* multi-KB HTML pages:
|
||||
*
|
||||
* - strip `<` and `>` (would break markdown tables)
|
||||
* - strip control chars `\n`, `\r`, `\t` (would break single-line
|
||||
* log records AND newline-bearing markdown rows in redeploy-env)
|
||||
* - cap at `max` chars (default 200) with an ellipsis on overflow
|
||||
*
|
||||
* Shared between redeploy-env.ts and verify-railway-image-refs.ts so
|
||||
* both consumers strip control chars at the source.
|
||||
*/
|
||||
export function sanitizeErrorBody(
|
||||
body: string,
|
||||
max: number = RAILWAY_ERROR_BODY_MAX_DEFAULT,
|
||||
): string {
|
||||
// Strip angle brackets (markdown-breaking) and control chars
|
||||
// (newline/carriage-return/tab — break single-line log records
|
||||
// and the markdown row redeploy-env produces). Original behavior
|
||||
// removed `<>` without substitution; preserve that for `<>` and
|
||||
// additionally remove `\n\r\t`.
|
||||
const stripped = body.replace(/[<>\n\r\t]/g, "");
|
||||
if (stripped.length <= max) return stripped;
|
||||
return stripped.slice(0, max) + "…";
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
/**
|
||||
* railway-token.ts — Shared resolver for the Railway GraphQL bearer.
|
||||
*
|
||||
* The Railway CLI stores the public-GraphQL bearer in `user.accessToken`.
|
||||
* The shorter `user.token` is a legacy CLI session token that does NOT
|
||||
* authenticate to the public GraphQL API. Older configs still on disk
|
||||
* have `user.token` set and `user.accessToken` empty; those callers get
|
||||
* a one-cycle deprecation warning and still work.
|
||||
*
|
||||
* Resolution order matches the first four candidates of
|
||||
* `showcase/bin/railway` `Auth.token`; the per-project
|
||||
* `projects.<id>.token` fallback is intentionally not honored (no
|
||||
* project-scoped tokens here):
|
||||
* 1. user.accessToken
|
||||
* 2. accessToken (top-level)
|
||||
* 3. user.token (legacy → warn)
|
||||
* 4. token (top-level) (legacy → warn)
|
||||
*
|
||||
* Returns undefined when no usable token is present; callers print the
|
||||
* "set RAILWAY_TOKEN or run `railway login`" error.
|
||||
*
|
||||
* This resolver reads ONLY the parsed config object passed in and does
|
||||
* NOT consult `process.env.RAILWAY_TOKEN` — the caller is responsible
|
||||
* for the environment-variable lane. Any returned value is trimmed so
|
||||
* stray whitespace/newlines from `~/.railway/config.json` never reach
|
||||
* an `Authorization: Bearer <token>` header.
|
||||
*/
|
||||
|
||||
export interface RailwayConfigShape {
|
||||
user?: {
|
||||
accessToken?: string;
|
||||
token?: string;
|
||||
};
|
||||
accessToken?: string;
|
||||
token?: string;
|
||||
}
|
||||
|
||||
export interface ResolverDeps {
|
||||
warn?: (message: string) => void;
|
||||
}
|
||||
|
||||
const DEPRECATION_MESSAGE =
|
||||
"[railway-token] WARNING: legacy Railway config field is deprecated: " +
|
||||
"`user.token` / top-level `token` no longer authenticates the public " +
|
||||
"GraphQL API. The Railway CLI now writes `user.accessToken`; re-run " +
|
||||
"`railway login` to refresh ~/.railway/config.json. Support for the " +
|
||||
"legacy field will be removed in a future release.";
|
||||
|
||||
function nonEmpty(v: unknown): v is string {
|
||||
return typeof v === "string" && v.trim().length > 0;
|
||||
}
|
||||
|
||||
export function resolveRailwayTokenFromConfig(
|
||||
config: RailwayConfigShape | null | undefined,
|
||||
deps: ResolverDeps = {},
|
||||
): string | undefined {
|
||||
// Defensive guard: config originates from JSON.parse of
|
||||
// ~/.railway/config.json (untrusted). Reject anything that isn't a
|
||||
// plain object before property access.
|
||||
if (config === null || config === undefined) return undefined;
|
||||
if (typeof config !== "object") return undefined;
|
||||
if (Array.isArray(config)) return undefined;
|
||||
|
||||
const warn = deps.warn ?? ((m: string) => console.warn(m));
|
||||
|
||||
const userAccess = config.user?.accessToken;
|
||||
if (nonEmpty(userAccess)) return userAccess.trim();
|
||||
|
||||
const topAccess = config.accessToken;
|
||||
if (nonEmpty(topAccess)) return topAccess.trim();
|
||||
|
||||
const userLegacy = config.user?.token;
|
||||
if (nonEmpty(userLegacy)) {
|
||||
warn(DEPRECATION_MESSAGE);
|
||||
return userLegacy.trim();
|
||||
}
|
||||
|
||||
const topLegacy = config.token;
|
||||
if (nonEmpty(topLegacy)) {
|
||||
warn(DEPRECATION_MESSAGE);
|
||||
return topLegacy.trim();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Failure-mode codes for resolveRailwayToken. Each is a distinct,
|
||||
* actionable diagnostic so callers (and operators reading CI logs) can
|
||||
* tell exactly WHY token resolution failed:
|
||||
*
|
||||
* NO_HOME : $HOME is unset (so ~/.railway/config.json can't
|
||||
* be located) AND RAILWAY_TOKEN is also unset.
|
||||
* NO_FILE : $HOME is set but ~/.railway/config.json does
|
||||
* not exist (and env-var is unset).
|
||||
* MALFORMED : ~/.railway/config.json exists but JSON.parse
|
||||
* threw.
|
||||
* NO_TOKEN_IN_CONFIG : ~/.railway/config.json exists and parses OK
|
||||
* but contains no usable token at any of the
|
||||
* four known layers. (Closes the silent-token-
|
||||
* fallthrough diagnostic gap where the operator
|
||||
* previously saw the generic "No Railway token
|
||||
* found" with no hint the file was inspected.)
|
||||
*/
|
||||
export type RailwayTokenErrorCode =
|
||||
| "NO_HOME"
|
||||
| "NO_FILE"
|
||||
| "MALFORMED"
|
||||
| "NO_TOKEN_IN_CONFIG";
|
||||
|
||||
export class RailwayTokenError extends Error {
|
||||
readonly code: RailwayTokenErrorCode;
|
||||
constructor(code: RailwayTokenErrorCode, message: string) {
|
||||
super(message);
|
||||
this.name = "RailwayTokenError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ResolveRailwayTokenOptions extends ResolverDeps {
|
||||
/** Override $HOME lookup (testing only). */
|
||||
home?: string;
|
||||
/** Override env-var lookup (testing only). */
|
||||
env?: NodeJS.ProcessEnv;
|
||||
/** Filesystem injection (testing only). */
|
||||
fs?: Pick<typeof fs, "existsSync" | "readFileSync">;
|
||||
}
|
||||
|
||||
export interface RailwayTokenResolution {
|
||||
token: string;
|
||||
source: "env" | "config";
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified entrypoint shared by redeploy-env.ts and
|
||||
* verify-railway-image-refs.ts. Encapsulates the previously-duplicated
|
||||
* getToken() envelope so the four failure modes can have distinct,
|
||||
* actionable diagnostics in one place.
|
||||
*
|
||||
* Resolution order:
|
||||
* 1. process.env.RAILWAY_TOKEN (returned with source="env")
|
||||
* 2. ~/.railway/config.json via resolveRailwayTokenFromConfig
|
||||
* (returned with source="config")
|
||||
*
|
||||
* Throws RailwayTokenError with a discriminator `.code` for each failure
|
||||
* mode (NO_HOME / NO_FILE / MALFORMED / NO_TOKEN_IN_CONFIG). NEVER calls
|
||||
* process.exit — the script entrypoint is responsible for mapping the
|
||||
* error to a non-zero exit code so this function stays unit-testable.
|
||||
*/
|
||||
export function resolveRailwayToken(
|
||||
opts: ResolveRailwayTokenOptions = {},
|
||||
): RailwayTokenResolution {
|
||||
const env = opts.env ?? process.env;
|
||||
const fsImpl = opts.fs ?? fs;
|
||||
|
||||
// Trim the env-var lane to honor the module's no-whitespace-in-header
|
||||
// invariant. A `RAILWAY_TOKEN` secret with a trailing newline (common
|
||||
// from `op read`/heredoc/shell export) would otherwise be returned
|
||||
// verbatim and produce an invalid `Authorization: Bearer <token>\n`
|
||||
// header → silent Railway 401. A whitespace-only value is treated as
|
||||
// UNSET (falls through to the config-file lane).
|
||||
const envToken = env.RAILWAY_TOKEN;
|
||||
if (typeof envToken === "string") {
|
||||
const trimmed = envToken.trim();
|
||||
if (trimmed.length > 0) {
|
||||
return { token: trimmed, source: "env" };
|
||||
}
|
||||
}
|
||||
|
||||
const home = opts.home ?? env.HOME;
|
||||
if (!home) {
|
||||
throw new RailwayTokenError(
|
||||
"NO_HOME",
|
||||
"No Railway token found. RAILWAY_TOKEN is unset (or whitespace-only) and $HOME is unset so ~/.railway/config.json cannot be located.",
|
||||
);
|
||||
}
|
||||
|
||||
const configPath = path.join(home, ".railway", "config.json");
|
||||
if (!fsImpl.existsSync(configPath)) {
|
||||
throw new RailwayTokenError(
|
||||
"NO_FILE",
|
||||
"No Railway token found. Set RAILWAY_TOKEN or run `railway login`.",
|
||||
);
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(fsImpl.readFileSync(configPath, "utf-8"));
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
throw new RailwayTokenError(
|
||||
"MALFORMED",
|
||||
`Malformed ~/.railway/config.json: ${msg}`,
|
||||
);
|
||||
}
|
||||
|
||||
const token = resolveRailwayTokenFromConfig(
|
||||
parsed as RailwayConfigShape | null | undefined,
|
||||
opts,
|
||||
);
|
||||
if (typeof token === "string" && token.length > 0) {
|
||||
return { token, source: "config" };
|
||||
}
|
||||
|
||||
throw new RailwayTokenError(
|
||||
"NO_TOKEN_IN_CONFIG",
|
||||
"No Railway token found: ~/.railway/config.json was found and parsed but contains no usable token (user.accessToken / accessToken / user.token / token). Set RAILWAY_TOKEN or re-run `railway login`.",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
/**
|
||||
* Shared slug / examples-directory mapping tables.
|
||||
*
|
||||
* Three tools consume these:
|
||||
* - audit.ts (showcase slug → examples dir name[s])
|
||||
* - validate-pins.ts (showcase slug → examples dir, via SLUG_MAP
|
||||
* inverse + FALLBACK_MAP override)
|
||||
* - validate-parity.ts (born-in-showcase membership)
|
||||
*
|
||||
* Everything here is immutable. We freeze the plain-object maps and
|
||||
* install throwing replacement methods on the Set/Map via
|
||||
* `Object.defineProperty` with `writable:false, configurable:false`
|
||||
* (a frozen plain Object is not enough for Map/Set — their `.set`/`.add`
|
||||
* methods don't respect Object.freeze). The consequence: any runtime
|
||||
* mutation attempt throws, matching the TypeScript `Readonly*` types.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Semantic alias: a slug that names a `showcase/integrations/<slug>/`
|
||||
* directory. Structurally a plain string (so external callers can
|
||||
* compare against arbitrary strings without ceremony) but named
|
||||
* distinctly from `ExamplesDir` so the map signatures below document
|
||||
* their direction of mapping. The runtime invariant — every
|
||||
* ShowcaseSlug that appears in SLUG_MAP (as a value), SLUG_TO_EXAMPLES
|
||||
* (as a key), or FALLBACK_MAP (as a key) is an actual directory under
|
||||
* `showcase/integrations/` — is enforced by slug-map.test.ts, not by the
|
||||
* type system.
|
||||
*
|
||||
* We deliberately keep this as a type alias (not a branded type) to
|
||||
* avoid forcing `as` casts on every external caller that builds a
|
||||
* slug from a `path.basename` or `fs.readdirSync` result. The safety
|
||||
* this trades away is recovered by `isShowcaseSlug` — a runtime
|
||||
* validator applied at API boundaries (see `ENTRIES` construction
|
||||
* below and the BORN_IN_SHOWCASE / SLUG_MAP assertions).
|
||||
*/
|
||||
export type ShowcaseSlug = string;
|
||||
|
||||
/**
|
||||
* Semantic alias: a directory name under `examples/integrations/` (or
|
||||
* `integrations/` in older trees). Intentionally distinct at the type
|
||||
* level from ShowcaseSlug so the SLUG_MAP / SLUG_TO_EXAMPLES /
|
||||
* FALLBACK_MAP signatures read unambiguously. Structurally `string`
|
||||
* for the same reason as ShowcaseSlug: external callers iterate these
|
||||
* maps with plain strings and we don't want to force `as` casts on
|
||||
* every caller.
|
||||
*/
|
||||
export type ExamplesDir = string;
|
||||
|
||||
/**
|
||||
* Runtime validator for a ShowcaseSlug. Applied at API boundaries
|
||||
* (SLUG entries construction, BORN_IN_SHOWCASE membership, callers
|
||||
* that accept user-supplied slug strings) to catch obvious garbage —
|
||||
* empty strings, whitespace, path separators — before it flows into
|
||||
* a filesystem path or a slug-indexed Map.
|
||||
*
|
||||
* The parameter type is `unknown` — this function sits at an API
|
||||
* boundary where TS guarantees are weakest (yaml.parse results, JSON
|
||||
* roundtrips, user-supplied strings). A widened param keeps the typeof
|
||||
* guard live rather than reducing to a dead check under `(s: string)`.
|
||||
*
|
||||
* The return signature is a user-defined type predicate (`s is
|
||||
* ShowcaseSlug`), so callers can narrow an `unknown` or `string` local
|
||||
* to a `ShowcaseSlug` without a cast after a successful check.
|
||||
*/
|
||||
export function isShowcaseSlug(s: unknown): s is ShowcaseSlug {
|
||||
if (typeof s !== "string") return false;
|
||||
if (s.length === 0) return false;
|
||||
// Reject whitespace and path separators: these are the characters
|
||||
// that would break `path.join(packages, slug)` most surprisingly
|
||||
// (newlines, spaces, `/`, `\`). We intentionally don't enforce a
|
||||
// strict kebab-case pattern — existing slugs include dots and
|
||||
// uppercase in related repos, so a strict regex would over-constrain.
|
||||
if (/[\s/\\]/.test(s)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a Set so mutation methods throw. Object.freeze on a Set does
|
||||
* not prevent .add/.delete — the set itself is frozen but its internal
|
||||
* slots are not. Casting to ReadonlySet is compile-time only.
|
||||
*
|
||||
* The returned type is ReadonlySet<T> (not Set<T>): callers that keep
|
||||
* a Set handle would bypass the runtime guard, so we force the narrow
|
||||
* type out of the helper.
|
||||
*
|
||||
* The replacement methods are installed with `writable: false` and
|
||||
* `configurable: false` so they cannot themselves be re-replaced
|
||||
* (`Object.defineProperty(set, "add", {value: realAdd})`) to restore
|
||||
* mutation. Without those descriptors, a later caller could silently
|
||||
* circumvent the freeze.
|
||||
*/
|
||||
function freezeSet<T>(s: Set<T>): ReadonlySet<T> {
|
||||
// Replace mutation methods FIRST, THEN freeze — Object.freeze locks
|
||||
// the object non-extensible, after which defineProperty throws.
|
||||
// `add` is typed to return the set so we match that signature but
|
||||
// throw before anything can observe the return value.
|
||||
const fail = (method: string) => () => {
|
||||
throw new TypeError(`Cannot ${method} frozen Set`);
|
||||
};
|
||||
const lock = { writable: false, configurable: false, enumerable: false };
|
||||
Object.defineProperty(s, "add", { ...lock, value: fail("add") });
|
||||
Object.defineProperty(s, "delete", { ...lock, value: fail("delete") });
|
||||
Object.defineProperty(s, "clear", { ...lock, value: fail("clear") });
|
||||
return Object.freeze(s);
|
||||
}
|
||||
|
||||
function freezeMap<K, V>(m: Map<K, V>): ReadonlyMap<K, V> {
|
||||
const fail = (method: string) => () => {
|
||||
throw new TypeError(`Cannot ${method} frozen Map`);
|
||||
};
|
||||
const lock = { writable: false, configurable: false, enumerable: false };
|
||||
Object.defineProperty(m, "set", { ...lock, value: fail("set") });
|
||||
Object.defineProperty(m, "delete", { ...lock, value: fail("delete") });
|
||||
Object.defineProperty(m, "clear", { ...lock, value: fail("clear") });
|
||||
return Object.freeze(m);
|
||||
}
|
||||
|
||||
/**
|
||||
* Freeze a 2D record (outer object + inner arrays) in one call. The
|
||||
* outer record is frozen so keys cannot be added/removed/reassigned;
|
||||
* each inner array is also frozen so element assignment (`arr[0] = …`)
|
||||
* throws in strict mode. Prevents the common "I froze the outer but
|
||||
* forgot the inner" bug and ensures the Readonly<...> type matches the
|
||||
* runtime behavior.
|
||||
*/
|
||||
function freezeMap2D<K extends string, V>(
|
||||
obj: Record<K, readonly V[]>,
|
||||
): Readonly<Record<K, readonly V[]>> {
|
||||
for (const k of Object.keys(obj) as K[]) {
|
||||
Object.freeze(obj[k]);
|
||||
}
|
||||
return Object.freeze(obj);
|
||||
}
|
||||
|
||||
/**
|
||||
* Single source of truth for the showcase/examples mapping tables.
|
||||
* `SLUG_TO_EXAMPLES`, `FALLBACK_MAP`, and `BORN_IN_SHOWCASE` below are
|
||||
* derived from this array via reducers. Adding / removing / renaming
|
||||
* a slug happens in ONE place; before, three parallel maps had to be
|
||||
* edited in lockstep and silently fell out of sync.
|
||||
*
|
||||
* Entry shape:
|
||||
* - slug — the `showcase/integrations/<slug>/` directory name
|
||||
* - bornInShowcase — true iff the package has no examples/integrations
|
||||
* counterpart (skip instead of warn downstream).
|
||||
* When true, `examples` MUST be empty.
|
||||
* - examples — candidate dir names under `examples/integrations/`
|
||||
* (or `integrations/` in older trees). The FIRST
|
||||
* entry is treated as the preferred fallback.
|
||||
* - fallback — if true, expose an entry in FALLBACK_MAP that
|
||||
* points at `examples[0]`. FALLBACK_MAP documents
|
||||
* known SLUG_MAP staleness where the slug under
|
||||
* `showcase/integrations/` no longer matches SLUG_MAP's
|
||||
* examples→slug direction.
|
||||
*
|
||||
* `SLUG_MAP` (examples → slug) is NOT derived — it reflects the
|
||||
* historical migrate-integration-examples.ts intent and is kept as a
|
||||
* standalone declaration so the "known stale" documentation at its
|
||||
* call sites in validate-pins.ts continues to hold.
|
||||
*/
|
||||
/**
|
||||
* Discriminated union form of SlugEntry.
|
||||
*
|
||||
* Three mutually-exclusive shapes:
|
||||
* - born-in-showcase: no examples counterpart, no fallback. The
|
||||
* `examples` tuple is statically empty and
|
||||
* `fallback` is never set.
|
||||
* - examples (no fallback): a real examples dir exists but the
|
||||
* SLUG_MAP forward mapping is not stale.
|
||||
* The `examples` tuple is non-empty and
|
||||
* `fallback` is explicitly false.
|
||||
* - examples + fallback: SLUG_MAP is stale for this slug;
|
||||
* FALLBACK_MAP documents the correction.
|
||||
* The `examples` tuple is non-empty and
|
||||
* `fallback` is true; FALLBACK_MAP keys
|
||||
* off this variant.
|
||||
*
|
||||
* Modeled as a discriminated union so consumers can read
|
||||
* `e.examples[0]` on the fallback branch without a runtime length
|
||||
* assertion — the tuple type guarantees at least one element, and the
|
||||
* compiler narrows accordingly. Prior to this split, the flat
|
||||
* `examples: readonly ExamplesDir[]` forced a runtime check at the
|
||||
* FALLBACK_MAP reducer to rule out an empty tuple.
|
||||
*/
|
||||
type SlugEntry =
|
||||
| {
|
||||
readonly slug: ShowcaseSlug;
|
||||
readonly bornInShowcase: true;
|
||||
readonly examples: readonly [];
|
||||
readonly fallback: false;
|
||||
}
|
||||
| {
|
||||
readonly slug: ShowcaseSlug;
|
||||
readonly bornInShowcase: false;
|
||||
readonly examples: readonly [ExamplesDir, ...ExamplesDir[]];
|
||||
readonly fallback: boolean;
|
||||
};
|
||||
|
||||
const ENTRIES: readonly SlugEntry[] = [
|
||||
// Born-in-showcase packages have no examples/integrations counterpart.
|
||||
{ slug: "ag2", bornInShowcase: true, examples: [], fallback: false },
|
||||
{
|
||||
slug: "claude-sdk-python",
|
||||
bornInShowcase: true,
|
||||
examples: [],
|
||||
fallback: false,
|
||||
},
|
||||
{
|
||||
slug: "claude-sdk-typescript",
|
||||
bornInShowcase: true,
|
||||
examples: [],
|
||||
fallback: false,
|
||||
},
|
||||
{ slug: "langroid", bornInShowcase: true, examples: [], fallback: false },
|
||||
{
|
||||
slug: "ms-agent-harness-dotnet",
|
||||
bornInShowcase: true,
|
||||
examples: [],
|
||||
fallback: false,
|
||||
},
|
||||
{ slug: "spring-ai", bornInShowcase: true, examples: [], fallback: false },
|
||||
{
|
||||
slug: "strands-typescript",
|
||||
bornInShowcase: true,
|
||||
examples: [],
|
||||
fallback: false,
|
||||
},
|
||||
|
||||
// Packages with a straightforward examples/integrations counterpart
|
||||
// whose dir name matches SLUG_MAP's examples→slug direction.
|
||||
{
|
||||
slug: "langgraph-python",
|
||||
bornInShowcase: false,
|
||||
examples: ["langgraph-python"],
|
||||
fallback: false,
|
||||
},
|
||||
{
|
||||
slug: "langgraph-typescript",
|
||||
bornInShowcase: false,
|
||||
examples: ["langgraph-js"],
|
||||
fallback: false,
|
||||
},
|
||||
{
|
||||
slug: "langgraph-fastapi",
|
||||
bornInShowcase: false,
|
||||
examples: ["langgraph-fastapi"],
|
||||
fallback: false,
|
||||
},
|
||||
{
|
||||
slug: "mastra",
|
||||
bornInShowcase: false,
|
||||
examples: ["mastra"],
|
||||
fallback: false,
|
||||
},
|
||||
{
|
||||
slug: "agno",
|
||||
bornInShowcase: false,
|
||||
examples: ["agno"],
|
||||
fallback: false,
|
||||
},
|
||||
{
|
||||
slug: "llamaindex",
|
||||
bornInShowcase: false,
|
||||
examples: ["llamaindex"],
|
||||
fallback: false,
|
||||
},
|
||||
{
|
||||
slug: "google-adk",
|
||||
bornInShowcase: false,
|
||||
examples: ["adk"],
|
||||
fallback: false,
|
||||
},
|
||||
|
||||
// Packages whose showcase slug no longer matches SLUG_MAP's
|
||||
// examples→slug direction — these need FALLBACK_MAP entries so
|
||||
// validate-pins.ts can still resolve them.
|
||||
{
|
||||
slug: "crewai-crews",
|
||||
bornInShowcase: false,
|
||||
examples: ["crewai-crews"],
|
||||
fallback: true,
|
||||
},
|
||||
{
|
||||
slug: "pydantic-ai",
|
||||
bornInShowcase: false,
|
||||
examples: ["pydantic-ai"],
|
||||
fallback: true,
|
||||
},
|
||||
{
|
||||
slug: "ms-agent-dotnet",
|
||||
bornInShowcase: false,
|
||||
examples: ["ms-agent-framework-dotnet"],
|
||||
fallback: true,
|
||||
},
|
||||
{
|
||||
slug: "ms-agent-python",
|
||||
bornInShowcase: false,
|
||||
examples: ["ms-agent-framework-python"],
|
||||
fallback: true,
|
||||
},
|
||||
{
|
||||
slug: "strands",
|
||||
bornInShowcase: false,
|
||||
examples: ["strands-python"],
|
||||
fallback: true,
|
||||
},
|
||||
];
|
||||
|
||||
// API-boundary validation: every slug that enters the derived maps
|
||||
// must pass `isShowcaseSlug`. The loop runs at module load so a bad
|
||||
// entry trips construction immediately rather than on first use.
|
||||
// The "bornInShowcase implies empty examples" and "fallback implies
|
||||
// non-empty examples" invariants are enforced statically by the
|
||||
// SlugEntry discriminated union above and no longer need a runtime
|
||||
// check here.
|
||||
for (const e of ENTRIES) {
|
||||
if (!isShowcaseSlug(e.slug)) {
|
||||
throw new Error(
|
||||
`lib/slug-map: invalid ShowcaseSlug in ENTRIES: ${JSON.stringify(e.slug)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Packages intentionally without a Dojo (examples/integrations)
|
||||
* counterpart. They are the single source of truth for:
|
||||
* - audit.ts → skip the "missing examples/integrations counterpart"
|
||||
* anomaly;
|
||||
* - validate-pins.ts → emit [SKIP] instead of [WARN].
|
||||
*
|
||||
* Derived from ENTRIES by filtering `bornInShowcase === true`.
|
||||
*/
|
||||
export const BORN_IN_SHOWCASE: ReadonlySet<ShowcaseSlug> = freezeSet(
|
||||
new Set<ShowcaseSlug>(
|
||||
ENTRIES.filter((e) => e.bornInShowcase).map((e) => e.slug),
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* Forward map: examples/integrations directory name → showcase slug.
|
||||
* Mirrors migrate-integration-examples.ts (which does not export its
|
||||
* SLUG_MAP). Kept as a Map for O(1) lookup and so `.get()` returns
|
||||
* `ShowcaseSlug | undefined` unambiguously.
|
||||
*
|
||||
* This map is NOT derived from ENTRIES: it represents the historical
|
||||
* migration intent at the time showcase was split from
|
||||
* examples/integrations, and validate-pins.ts's comments explicitly
|
||||
* call out that it is "known stale". FALLBACK_MAP (derived from
|
||||
* ENTRIES) documents the corrections where SLUG_MAP no longer matches
|
||||
* the current slug under `showcase/integrations/`.
|
||||
*
|
||||
* Only entries whose VALUES correspond to real `showcase/integrations/<slug>/`
|
||||
* directories are included. Dead entries (crewai-flows → crewai,
|
||||
* pydantic-ai → pydanticai, ms-agent-framework-dotnet → maf-dotnet,
|
||||
* etc.) were removed because they broke validate-pins.ts's reverse
|
||||
* lookup and forced FALLBACK_MAP to re-express the corrections. The
|
||||
* slug-map.test.ts pins this invariant against the real integrations/ tree
|
||||
* so future edits cannot reintroduce the drift.
|
||||
*/
|
||||
export const SLUG_MAP: ReadonlyMap<ExamplesDir, ShowcaseSlug> = freezeMap(
|
||||
new Map<ExamplesDir, ShowcaseSlug>([
|
||||
["langgraph-python", "langgraph-python"],
|
||||
["langgraph-js", "langgraph-typescript"],
|
||||
["langgraph-fastapi", "langgraph-fastapi"],
|
||||
["mastra", "mastra"],
|
||||
["agno", "agno"],
|
||||
["llamaindex", "llamaindex"],
|
||||
["adk", "google-adk"],
|
||||
]),
|
||||
);
|
||||
|
||||
// Construction-time assertion: every SLUG_MAP value is a valid slug.
|
||||
for (const [, slug] of SLUG_MAP) {
|
||||
if (!isShowcaseSlug(slug)) {
|
||||
throw new Error(
|
||||
`lib/slug-map: invalid SLUG_MAP value: ${JSON.stringify(slug)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse / corrected map used by audit.ts: showcase slug → candidate
|
||||
* examples/integrations dir name(s). Dead entries that pointed at
|
||||
* non-existent showcase packages (crewai-flows, agent-spec-langgraph,
|
||||
* mcp-apps) are intentionally excluded so audit.ts no longer emits
|
||||
* phantom "no examples source" anomalies for them.
|
||||
*
|
||||
* Derived from ENTRIES: each entry with non-empty `examples` becomes
|
||||
* `SLUG_TO_EXAMPLES[slug] = examples`. freezeMap2D freezes BOTH the
|
||||
* outer record (no adding/removing keys, no reassigning arrays) AND
|
||||
* each inner array (no element assignment). The compile-time
|
||||
* `Readonly<Record<..., readonly ExamplesDir[]>>` matches.
|
||||
*/
|
||||
export const SLUG_TO_EXAMPLES: Readonly<
|
||||
Record<ShowcaseSlug, readonly ExamplesDir[]>
|
||||
> = freezeMap2D<ShowcaseSlug, ExamplesDir>(
|
||||
ENTRIES.reduce<Record<ShowcaseSlug, readonly ExamplesDir[]>>((acc, e) => {
|
||||
if (e.examples.length > 0) {
|
||||
acc[e.slug] = e.examples;
|
||||
}
|
||||
return acc;
|
||||
}, {}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Fallback map used by validate-pins.ts: showcase slug → examples dir
|
||||
* name. These entries document known SLUG_MAP staleness — the slug
|
||||
* under `showcase/integrations/` no longer matches the value in SLUG_MAP,
|
||||
* so we override here. If SLUG_MAP is refreshed, clean up the
|
||||
* `fallback: true` flag on the corresponding ENTRIES row and this
|
||||
* map rebuilds to match.
|
||||
*
|
||||
* Derived from ENTRIES: each entry with `fallback: true` exposes
|
||||
* `FALLBACK_MAP[slug] = examples[0]`.
|
||||
*/
|
||||
export const FALLBACK_MAP: Readonly<Record<ShowcaseSlug, ExamplesDir>> =
|
||||
Object.freeze(
|
||||
ENTRIES.reduce<Record<ShowcaseSlug, ExamplesDir>>((acc, e) => {
|
||||
if (e.fallback) {
|
||||
acc[e.slug] = e.examples[0];
|
||||
}
|
||||
return acc;
|
||||
}, {}),
|
||||
);
|
||||
Reference in New Issue
Block a user