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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user