chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:58:18 +08:00
commit 6d5d58c1a9
18293 changed files with 3502153 additions and 0 deletions
@@ -0,0 +1,155 @@
/**
* aggregate-build-results.test.ts — covers the per-slot aggregator that
* runs in the `aggregate-build-results` job of showcase_build.yml.
*
* The script's `run({inputDir, outputDir, githubOutput})` entrypoint is
* exercised directly with temp dirs (so we never touch tracked files or
* spawn subprocesses). We verify:
* 1. empty INPUT_DIR → results.json = `[]`, any_success=false, no throw
* 2. build-result-<x> dir missing result.json → throws naming the slot
* 3. non-`build-result-*` dirs are ignored
* 4. mixed success/failure → correct merged array + any_success=true
* 5. GITHUB_OUTPUT receives heredoc-form `results` block + any_success
*/
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { run } from "../aggregate-build-results";
function makeSlot(
inputDir: string,
service: string,
status: "success" | "failure" | "skipped",
): void {
const dir = join(inputDir, `build-result-${service}`);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, "result.json"), JSON.stringify({ service, status }));
}
describe("aggregate-build-results.run", () => {
let inputDir: string;
let outputDir: string;
let githubOutput: string;
beforeEach(() => {
const base = mkdtempSync(join(tmpdir(), "agg-build-"));
inputDir = join(base, "in");
outputDir = join(base, "out");
githubOutput = join(base, "gh_output");
mkdirSync(inputDir, { recursive: true });
// OUTPUT_DIR intentionally NOT pre-created — run() must mkdir -p.
writeFileSync(githubOutput, "");
});
afterEach(() => {
// Temp dirs are under os.tmpdir(); OS reaps them. No tracked files
// touched, so no cleanup needed.
});
it("empty INPUT_DIR → throws (broken artifact download — the aggregator only runs when >=1 service was scheduled)", () => {
// The aggregator is gated on `has_changes == 'true'` upstream, so the
// matrix is guaranteed non-empty by the time we run. A zero-slot input
// dir therefore means the per-slot artifact download produced nothing
// (broken download, expired artifacts, mis-scoped run-id). Silently
// emitting `any_success=false` with `results=[]` is indistinguishable
// from "all builds failed" — that's a false-green path because the
// deploy workflow then has no success set to intersect against and
// falls back to probing the full service set against stale :latest.
// We refuse the ambiguity and fail loud instead.
expect(() => run({ inputDir, outputDir, githubOutput })).toThrow(
/aggregate-build-results: found 0 build-result-\* slot dirs/,
);
});
it("throws naming the slot when build-result-<x>/result.json is missing", () => {
const slotDir = join(inputDir, "build-result-orphan");
mkdirSync(slotDir, { recursive: true });
// Note: NO result.json written.
expect(() => run({ inputDir, outputDir, githubOutput })).toThrow(
/aggregate-build-results: build-result-orphan is missing result\.json/,
);
});
it("ignores directories that do not match build-result-*", () => {
mkdirSync(join(inputDir, "some-other-artifact"), { recursive: true });
writeFileSync(
join(inputDir, "some-other-artifact", "result.json"),
JSON.stringify({ service: "noise", status: "success" }),
);
// A file (not a directory) at top level should also be ignored.
writeFileSync(join(inputDir, "build-result-not-a-dir"), "garbage");
makeSlot(inputDir, "real", "success");
run({ inputDir, outputDir, githubOutput });
const results = JSON.parse(
readFileSync(join(outputDir, "results.json"), "utf-8"),
);
expect(results).toEqual([{ service: "real", status: "success" }]);
});
it("merges mixed success/failure correctly and sets any_success=true", () => {
makeSlot(inputDir, "alpha", "success");
makeSlot(inputDir, "beta", "failure");
makeSlot(inputDir, "gamma", "skipped");
run({ inputDir, outputDir, githubOutput });
const results = JSON.parse(
readFileSync(join(outputDir, "results.json"), "utf-8"),
);
expect(results).toHaveLength(3);
const byName = new Map<string, string>(
(results as Array<{ service: string; status: string }>).map((r) => [
r.service,
r.status,
]),
);
expect(byName.get("alpha")).toBe("success");
expect(byName.get("beta")).toBe("failure");
expect(byName.get("gamma")).toBe("skipped");
const gh = readFileSync(githubOutput, "utf-8");
expect(gh).toContain("any_success=true");
});
it("writes results to GITHUB_OUTPUT in multi-line heredoc form", () => {
makeSlot(inputDir, "alpha", "success");
makeSlot(inputDir, "beta", "failure");
run({ inputDir, outputDir, githubOutput });
const gh = readFileSync(githubOutput, "utf-8");
// The heredoc form is:
// results<<EOF
// <json>
// EOF
// The delimiter token is implementation-defined but must match on
// both sides (GitHub Actions convention; commonly "EOF" or a unique
// token to avoid collision with embedded payloads).
const heredocRe = /results<<(\S+)\n([\s\S]*?)\n\1\n/;
const match = gh.match(heredocRe);
expect(match).not.toBeNull();
if (!match) return;
const [, , jsonBody] = match;
const parsed = JSON.parse(jsonBody);
expect(Array.isArray(parsed)).toBe(true);
expect(parsed).toHaveLength(2);
// any_success line is still a plain key=value.
expect(gh).toMatch(/any_success=true\n/);
});
it("results.json has a trailing newline", () => {
makeSlot(inputDir, "alpha", "success");
run({ inputDir, outputDir, githubOutput });
const raw = readFileSync(join(outputDir, "results.json"), "utf-8");
expect(raw.endsWith("\n")).toBe(true);
});
});
@@ -0,0 +1,439 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import path from "path";
import { globSync } from "glob";
import { loadFixtureFile, validateFixtures } from "@copilotkit/aimock";
import type { ValidationResult } from "@copilotkit/aimock";
const REPO_ROOT = path.resolve(__dirname, "..", "..", "..");
const fixtureFiles: string[] = [
...globSync("showcase/aimock/shared/*.json", {
cwd: REPO_ROOT,
absolute: true,
}),
...globSync("showcase/aimock/d4/**/*.json", {
cwd: REPO_ROOT,
absolute: true,
}),
...globSync("showcase/aimock/d6/**/*.json", {
cwd: REPO_ROOT,
absolute: true,
}),
...globSync("examples/integrations/*/fixtures/*.json", {
cwd: REPO_ROOT,
absolute: true,
}),
...globSync("scripts/doc-tests/fixtures/*.json", {
cwd: REPO_ROOT,
absolute: true,
}),
];
// ---------------------------------------------------------------------------
// Raw fixture entry with context preserved (loadFixtureFile strips context,
// but we need it for collision detection).
// ---------------------------------------------------------------------------
interface RawFixtureEntry {
match: {
userMessage?: string;
toolCallId?: string;
toolName?: string;
model?: string;
hasToolResult?: boolean;
turnIndex?: number;
sequenceIndex?: number;
endpoint?: string;
context?: string;
[key: string]: unknown;
};
response: unknown;
[key: string]: unknown;
}
/**
* Build a deterministic match key from the match object. The key encodes
* every field that aimock uses for disambiguation so two fixtures with
* identical keys would always collide at runtime.
*/
function matchKey(match: RawFixtureEntry["match"]): string {
const parts: string[] = [];
// Alphabetical, stable order
if (match.endpoint != null) parts.push(`endpoint=${match.endpoint}`);
if (match.hasToolResult != null)
parts.push(`hasToolResult=${match.hasToolResult}`);
if (match.model != null) parts.push(`model=${match.model}`);
if (match.sequenceIndex != null)
parts.push(`sequenceIndex=${match.sequenceIndex}`);
if (match.toolCallId != null) parts.push(`toolCallId=${match.toolCallId}`);
if (match.toolName != null) parts.push(`toolName=${match.toolName}`);
if (match.turnIndex != null) parts.push(`turnIndex=${match.turnIndex}`);
if (match.userMessage != null) parts.push(`userMessage=${match.userMessage}`);
return parts.join("|");
}
/** Load raw fixture entries from a JSON file, preserving context. */
function loadRawFixtures(filePath: string): RawFixtureEntry[] {
try {
const data = JSON.parse(readFileSync(filePath, "utf-8"));
return (data.fixtures ?? []) as RawFixtureEntry[];
} catch {
return [];
}
}
interface TaggedFixture {
entry: RawFixtureEntry;
file: string; // relative path
index: number; // index within file
}
/** Scope key: the context value, or "__shared__" for context-less fixtures. */
const SHARED_SCOPE = "__shared__";
function scopeOf(entry: RawFixtureEntry): string {
return entry.match.context ?? SHARED_SCOPE;
}
// ---------------------------------------------------------------------------
// Deployment scopes — d4 and d6 fixtures never coexist at runtime, so
// collision detection must check within each deployment's load set:
// d4 runtime loads: shared/ + d4/
// d6 runtime loads: shared/ + d6/
// ---------------------------------------------------------------------------
const sharedFiles = globSync("showcase/aimock/shared/*.json", {
cwd: REPO_ROOT,
absolute: true,
});
const d4Files = globSync("showcase/aimock/d4/**/*.json", {
cwd: REPO_ROOT,
absolute: true,
});
const d6Files = globSync("showcase/aimock/d6/**/*.json", {
cwd: REPO_ROOT,
absolute: true,
});
function tagFiles(files: string[]): TaggedFixture[] {
return files.flatMap((fp) => {
const rel = path.relative(REPO_ROOT, fp);
return loadRawFixtures(fp).map((entry, i) => ({
entry,
file: rel,
index: i,
}));
});
}
const sharedTagged = tagFiles(sharedFiles);
const d4Tagged = tagFiles(d4Files);
const d6Tagged = tagFiles(d6Files);
/** Build a Map<contextScope, TaggedFixture[]> for a deployment's fixture set. */
function groupByContext(
fixtures: TaggedFixture[],
): Map<string, TaggedFixture[]> {
const map = new Map<string, TaggedFixture[]>();
for (const t of fixtures) {
const scope = scopeOf(t.entry);
if (!map.has(scope)) map.set(scope, []);
map.get(scope)!.push(t);
}
return map;
}
// Two deployment scopes: shared+d4 and shared+d6
const deploymentScopes: {
name: string;
byContext: Map<string, TaggedFixture[]>;
}[] = [
{ name: "d4", byContext: groupByContext([...sharedTagged, ...d4Tagged]) },
{ name: "d6", byContext: groupByContext([...sharedTagged, ...d6Tagged]) },
];
describe("aimock fixtures across repo", () => {
it("discovers at least one fixture file", () => {
expect(
fixtureFiles.length,
"fixture discovery returned 0 files — misconfigured glob or missing fixtures",
).toBeGreaterThan(0);
});
for (const filePath of fixtureFiles) {
const relative = path.relative(REPO_ROOT, filePath);
it(`${relative} loads and validates with zero errors`, () => {
const fixtures = loadFixtureFile(filePath);
// If loadFixtureFile returns [], the file itself is broken (unreadable,
// invalid JSON, or missing "fixtures" array). Treat as fatal.
expect(
fixtures.length,
`${relative} produced 0 fixtures — file is unreadable or malformed`,
).toBeGreaterThan(0);
const results = validateFixtures(fixtures);
const errors = results.filter(
(r: ValidationResult) => r.severity === "error",
);
if (errors.length > 0) {
const detail = errors
.map((e: ValidationResult) => ` [${e.fixtureIndex}] ${e.message}`)
.join("\n");
throw new Error(
`${relative} has ${errors.length} fixture validation error(s):\n${detail}`,
);
}
expect(errors).toEqual([]);
});
}
});
// ---------------------------------------------------------------------------
// Fixture collision detection
//
// Each test iterates over deployment scopes (d4, d6) independently because
// d4 and d6 fixtures never coexist at runtime.
// ---------------------------------------------------------------------------
describe("fixture collision detection", () => {
it("no exact duplicate match keys within the same context scope", () => {
// Known baseline: 230 duplicates across D6 feature files where different
// demos share the same pill prompts and toolCallIds. Bumped from 11 → 230
// when D6 per-integration fixtures were added — each integration's new
// feature-type fixtures (gen-ui-declarative, multimodal, prebuilt-*,
// tool-rendering-*-catchall, etc.) naturally share match keys with the
// pre-existing demo fixtures (render-a2ui, agentic-chat, tool-rendering,
// gen-ui-tool-based) for the same integration. At runtime these are
// disambiguated by the active demo / probe path.
//
// Bumped 230 → 276 (+46) when the gen-ui-headless-complete.json alias was
// added across all 18 slugs. The gen-ui-headless-complete probe references
// a dedicated fixtureFile but drives the same 4 headless-complete pills
// (weather/stock/highlight/revenue), so its 8 fixtures per slug share
// match keys with the pre-existing headless-complete.json for that
// context. These are disambiguated at runtime by the probe's fixtureFile
// / demo route, exactly like the other cross-feature key overlaps above.
//
// Bumped 276 → 288 (+12) when the declarative-gen-ui demo moved to the
// CopilotKitMiddleware auto-A2UI path across the 3 langgraph integrations
// (langgraph-python / -typescript / -fastapi). The middleware's inner
// forced tool is `render_a2ui`, so each integration's gen-ui-declarative.json
// gained 4 `render_a2ui` fixtures (KPI dashboard / pie / bar / status) that
// share match keys with the pre-existing render_a2ui entries in that
// integration's render-a2ui.json (the a2ui_fixed demo). 4 pills × 3
// integrations = 12. Disambiguated at runtime by the probe's fixtureFile /
// demo route, like the other cross-feature overlaps above.
//
// Bumped 288 → 290 (+2) when the hitl / gen-ui-interrupt / threadid demos
// were ported to google-adk (W3 parity). The new per-demo google-adk
// fixtures reuse google-adk's standard prebuilt-probe pills ("hi from the
// popup/sidebar test"), so they share match keys with the pre-existing
// prebuilt-popup.json / prebuilt-sidebar.json entries for that context.
// Disambiguated at runtime by the probe's fixtureFile / demo route, like
// the other cross-feature overlaps above.
//
// Bumped 290 → 291 (+1) in #5427 when BIA tool-rendering.json's bare 'AAPL'
// matchers were tightened to 'current price of AAPL' to stop shadowing
// gen-ui-headless-complete.json's 'price of AAPL right now' headless pill.
// The tightened matchers share keys with tool-rendering-custom-catchall.json's
// pre-existing 'current price of AAPL' entries in the same BIA context (the
// hasToolResult:false emitter pair and the hasToolResult:true narration pair).
// Disambiguated at runtime by feature route (tool-rendering vs custom-catchall
// fixtureFile) plus the catchall's distinct first prompt ('check Tokyo weather
// forecast') that gates the multi-pill session before the AAPL pill fires.
//
// NOTE: the a2ui-recovery demos (langgraph python/fastapi/typescript +
// strands python/typescript) deliberately use UNIQUE recovery prompts per
// framework. Inner render_a2ui fixtures cannot be context-scoped (the in-graph
// render sub-agent's model client does not forward x-aimock-context), and
// aimock loads every framework's d6 dir into one process, so identical prompts
// would let the first-loaded framework's fixture hijack another's render calls.
// Unique prompts keep each framework's inner fixtures distinct → no new
// shared-scope duplicates, so this ceiling stays at the pre-recovery baseline.
//
// Bumped 291 → 297 (+6) for the Claude SDK demo parity port after
// de-duplicating avoidable no-context beautiful-chat fallbacks. The
// remaining new overlaps are context-scoped cross-demo fixture aliases
// (interrupt/gen-ui-interrupt, declarative/render_a2ui, and copied
// LangGraph headless/feature-parity routes) that are disambiguated by
// fixtureFile/demo route like the existing integration parity copies above.
const KNOWN_DUPLICATE_CEILING = 297;
const collisions: string[] = [];
for (const { name: deploy, byContext } of deploymentScopes) {
for (const [ctx, fixtures] of byContext) {
const seen = new Map<string, TaggedFixture>();
for (const t of fixtures) {
const key = matchKey(t.entry.match);
const prev = seen.get(key);
if (prev) {
collisions.push(
`[${deploy}] context="${ctx}" key="${key}"\n` +
` first: ${prev.file}[${prev.index}]\n` +
` second: ${t.file}[${t.index}]`,
);
} else {
seen.set(key, t);
}
}
}
}
expect(
collisions.length,
`Exact duplicate count (${collisions.length}) exceeds ceiling (${KNOWN_DUPLICATE_CEILING}).\n` +
`Entries beyond the ceiling (iteration order — NOT necessarily the newly introduced ones; diff against the baseline to find the real offenders):\n${collisions.slice(KNOWN_DUPLICATE_CEILING).join("\n\n")}`,
).toBeLessThanOrEqual(KNOWN_DUPLICATE_CEILING);
});
it("no substring shadow collisions within the same context scope", () => {
// A "substring shadow" is when fixture A's userMessage is a substring
// of fixture B's userMessage, AND they share the same values for all
// other differentiating fields (toolName, toolCallId, hasToolResult,
// turnIndex, sequenceIndex, endpoint). In that case aimock's substring
// matching would cause A to shadow B (or vice versa depending on load
// order), leading to non-deterministic behavior.
//
// Known baseline: 128 pre-existing shadows across d4+d6 (tracked for
// cleanup). The D6 per-integration feature-type fixtures
// (tool-rendering-*-catchall, agent-config, gen-ui-interrupt) create
// expected substring overlaps with pre-existing fixtures in the same
// context (e.g. "What's the current price of AAPL?" vs "AAPL" in
// tool-rendering.json, or "tone:professional — ..." vs
// "tone:professional" in chat-css.json). These are disambiguated at
// runtime by other match fields (toolCallId, toolName, turnIndex).
// This test fails if the count INCREASES, preventing new shadows
// from being introduced. Ratchet down as shadows are cleaned up.
// Bumped 123→128 in #5412: 5 new substring overlaps in d6/{ag2,cst}
// gen-ui-declarative + cst/tool-rendering fixtures, runtime-disambiguated
// by toolCallId chunk boundaries and load-order ordering of inner-call
// mirrors before outer fixtures (see _meta._note in those files).
// Bumped 128→134 in #5427: 6 new substring overlaps in
// d6/built-in-agent/{tool-rendering, tool-rendering-reasoning-chain}
// fixtures from the BIA 5-tool D6 port (weather/flight/stock/d20/
// catchall pill variants), runtime-disambiguated by toolName +
// toolCallId.
//
// Ratcheted 134→132 (-2) in #5427 follow-up: BIA tool-rendering.json's
// bare 'AAPL' matchers were tightened to 'current price of AAPL' (no
// longer a substring of gen-ui-headless-complete's 'price of AAPL right
// now' pill), removing 2 pre-existing shadow pairs. The companion
// sequenceIndex-gated emitter + narration-fallback pairs in
// gen-ui-headless-complete.json do not introduce new shadows — the
// narration fallbacks share the same userMessage prefix as the emitters
// (which the shadow detector skips because identical strings are caught
// by the exact-duplicate test, not the shadow test).
// Bumped 132→137: +5 substring shadows from the strands-typescript D6 port
// (its per-integration fixtures mirror the Python strands sibling —
// calculator + tool-rendering pill variants), runtime-disambiguated by
// toolCallId / toolName / turnIndex like the other per-integration copies.
// Bumped 139→142 after this PR rebased against current main. The remaining
// counted shadows are pre-existing D4/D6 baseline overlaps (for example
// weather/AAPL/project-planning/calculator prompt variants), not Claude SDK
// local fallback aliases. Browser-local Claude demos now get an AIMock
// context header from server-side HttpAgent defaults instead of relying on
// context-less prompt aliases.
const KNOWN_SHADOW_CEILING = 142;
const shadows: string[] = [];
for (const { name: deploy, byContext } of deploymentScopes) {
for (const [ctx, fixtures] of byContext) {
// Only consider fixtures that have a userMessage
const withMsg = fixtures.filter(
(t) => typeof t.entry.match.userMessage === "string",
);
for (let i = 0; i < withMsg.length; i++) {
for (let j = i + 1; j < withMsg.length; j++) {
const a = withMsg[i];
const b = withMsg[j];
const msgA = a.entry.match.userMessage!;
const msgB = b.entry.match.userMessage!;
// Skip if messages are identical (caught by exact-duplicate test)
if (msgA === msgB) continue;
// Check substring relationship
const aInB = msgB.includes(msgA);
const bInA = msgA.includes(msgB);
if (!aInB && !bInA) continue;
// Check if other differentiating criteria are identical
const diffFields = [
"toolName",
"toolCallId",
"hasToolResult",
"turnIndex",
"sequenceIndex",
"endpoint",
] as const;
const sameOtherCriteria = diffFields.every(
(f) => a.entry.match[f] === b.entry.match[f],
);
if (!sameOtherCriteria) continue;
const shorter = aInB ? a : b;
const longer = aInB ? b : a;
shadows.push(
`[${deploy}] context="${ctx}"\n` +
` shorter: "${shorter.entry.match.userMessage}" ` +
`(${shorter.file}[${shorter.index}])\n` +
` longer: "${longer.entry.match.userMessage}" ` +
`(${longer.file}[${longer.index}])`,
);
}
}
}
}
// Ratchet: fail if new shadows are introduced; lower the ceiling as
// pre-existing shadows are cleaned up.
expect(
shadows.length,
`Substring shadow count (${shadows.length}) exceeds ceiling (${KNOWN_SHADOW_CEILING}).\n` +
`Entries beyond the ceiling (iteration order — NOT necessarily the newly introduced ones; diff against the baseline to find the real offenders):\n${shadows.slice(KNOWN_SHADOW_CEILING).join("\n\n")}`,
).toBeLessThanOrEqual(KNOWN_SHADOW_CEILING);
});
it("shared (no-context) fixtures have no exact userMessage collisions with scoped fixtures", () => {
// Shared fixtures (no context field) match ANY context at runtime.
// If a shared fixture has the same userMessage as a scoped fixture,
// the match is ambiguous — load order determines which wins.
const sharedWithMsg = sharedTagged.filter(
(t) => typeof t.entry.match.userMessage === "string",
);
if (sharedWithMsg.length === 0) return; // nothing to check
const collisions: string[] = [];
for (const { name: deploy, byContext } of deploymentScopes) {
for (const [ctx, fixtures] of byContext) {
if (ctx === SHARED_SCOPE) continue;
for (const s of sharedWithMsg) {
for (const t of fixtures) {
if (typeof t.entry.match.userMessage !== "string") continue;
if (s.entry.match.userMessage !== t.entry.match.userMessage)
continue;
collisions.push(
`[${deploy}] userMessage="${s.entry.match.userMessage}"\n` +
` shared: ${s.file}[${s.index}]\n` +
` scoped: ${t.file}[${t.index}] (context="${ctx}")`,
);
}
}
}
}
if (collisions.length > 0) {
throw new Error(
`Found ${collisions.length} shared-vs-scoped userMessage collision(s):\n\n${collisions.join("\n\n")}`,
);
}
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,542 @@
// Split from audit.test.ts — see audit.shared.ts header for the full
// rationale (vitest birpc 60s cliff, fork-per-file).
//
// This file hosts ALL subprocess-heavy describes: main() exit codes via
// the CLI, --columns filtering via the CLI, and the module isMain guard
// (which also spawns a subprocess). These are the tests most sensitive
// to the birpc cliff because every `it` shells out to `npx tsx` — the
// per-file fork window isolates their cost so they never stack onto the
// in-process describes' budget.
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import fs from "fs";
import os from "os";
import path from "path";
import { spawnSync } from "child_process";
import { SLUG_TO_EXAMPLES } from "../audit.js";
import {
AUDIT_SCRIPT,
makeTmpTree,
writePackage,
makeExampleDir,
} from "./audit.shared.js";
describe("main() exit codes via CLI subprocess", () => {
let root: string;
beforeEach(() => {
root = makeTmpTree();
});
afterEach(() => {
fs.rmSync(root, { recursive: true, force: true });
});
function runCli(
args: string[],
opts: { cwd?: string; env?: NodeJS.ProcessEnv } = {},
) {
return spawnSync("npx", ["tsx", AUDIT_SCRIPT, ...args], {
cwd: opts.cwd,
env: { ...process.env, ...opts.env },
encoding: "utf-8",
timeout: 30_000,
});
}
it("exits 0 when there are no anomalies", () => {
writePackage(root, "crewai-crews", {
manifest: `slug: crewai-crews\ndeployed: true\ndemos:\n - id: a\n`,
specs: ["a.spec.ts"],
qaFiles: ["a.md"],
});
makeExampleDir(root, "crewai-crews");
const r = runCli([], {
env: { SHOWCASE_AUDIT_ROOT: root },
});
expect(r.status, r.stdout + r.stderr).toBe(0);
// Positive stdout assertions: a regression that truncates stdout or
// drops the summary/table would slip past a r.status-only check.
// Pin the column headers as whole tokens, the Overall health
// counters, and the clean-state affirmation line.
expect(r.stdout).toMatch(/\bslug\b/);
expect(r.stdout).toMatch(/\bdemos\b/);
expect(r.stdout).toMatch(/\bspecs\b/);
expect(r.stdout).toMatch(/\bdeployed\b/);
expect(r.stdout).toMatch(/\bexamples src\b/);
expect(r.stdout).toMatch(/Packages total:\s+1/);
expect(r.stdout).toMatch(/Clean:\s+1/);
expect(r.stdout).toMatch(/With anomalies:\s+0/);
expect(r.stdout).toMatch(/All packages pass coverage audit/);
// The fixture slug must appear as its own row in the table.
expect(r.stdout).toMatch(/\bcrewai-crews\b/);
});
it("exits 1 when anomalies are found", () => {
writePackage(root, "bad", {
manifest: `slug: bad\ndeployed: false\ndemos:\n - id: a\n`,
specs: [],
qaFiles: [],
});
const r = runCli([], {
env: { SHOWCASE_AUDIT_ROOT: root },
});
expect(r.status, r.stdout + r.stderr).toBe(1);
// Positive stdout assertions: the anomaly report must include the
// slug and at least one of the expected anomaly categories for the
// fixture (count mismatch, not deployed, missing examples).
expect(r.stdout).toMatch(/\bbad\b/);
expect(r.stdout).toMatch(/Coverage anomalies/);
expect(r.stdout).toMatch(/Packages total:\s+1/);
expect(r.stdout).toMatch(/With anomalies:\s+1/);
});
it("exits 3 (unreadable) when SHOWCASE_AUDIT_ROOT points to missing packages dir", () => {
// Missing/unreadable packages dir is infrastructure failure, not
// user-input failure — distinct exit code from "invalid content" (2).
const empty = fs.mkdtempSync(path.join(os.tmpdir(), "audit-empty-"));
try {
const r = runCli([], {
env: { SHOWCASE_AUDIT_ROOT: empty },
});
expect(r.status, r.stdout + r.stderr).toBe(3);
// Tighten from /integrations/i — that would also match log lines that
// merely name the string "integrations". Pin the specific diagnostic
// phrase so a regression that swallows the reason (and exits 3
// for some other cause) can't slip through.
expect(r.stderr).toMatch(/packages dir does not exist/);
} finally {
fs.rmSync(empty, { recursive: true, force: true });
}
});
it("exits 3 (unreadable) when packages path exists but is a file, not a directory", () => {
// Regression guard: previously `readdirSync` on a file path threw
// ENOTDIR inside the try/catch in listShowcasePackageSlugs which
// returned [], so the CLI collapsed to "empty packages" (exit 1). We
// now distinguish this with a dedicated stat() check — exit 3.
const fixture = fs.mkdtempSync(path.join(os.tmpdir(), "audit-file-"));
try {
// Create <fixture>/packages as a FILE, not a directory.
fs.writeFileSync(path.join(fixture, "integrations"), "not a dir\n");
const r = runCli([], {
env: { SHOWCASE_AUDIT_ROOT: fixture },
});
expect(r.status, r.stdout + r.stderr).toBe(3);
expect(r.stderr).toMatch(/not a directory/i);
// Tighten: the diagnostic MUST NOT claim the path "does not exist"
// — the file is present, just not a directory. The previous
// redundant `existsSync` pre-check produced the misleading "does
// not exist" wording for this case. After the fix, the statSync
// block produces a precise "is not a directory" message.
expect(r.stderr).not.toMatch(/packages dir does not exist/);
} finally {
fs.rmSync(fixture, { recursive: true, force: true });
}
});
it("exits 3 with a precise EACCES diagnostic when packages dir stat fails (not the misleading 'does not exist' message)", () => {
// Regression guard: main()'s redundant `fs.existsSync` pre-check
// emitted "packages dir does not exist" for EACCES/EPERM/EIO failures
// too — `existsSync` returns false for every statSync failure, not
// just ENOENT. The fix removes the redundant pre-check so the
// subsequent try/statSync block produces an accurate errno-specific
// message.
//
// Inject an EACCES via a preload script that overrides fs.statSync
// only for the target packages dir path (everything else passes
// through to the real implementation so tsx/vitest internals keep
// working).
const fixture = fs.mkdtempSync(path.join(os.tmpdir(), "audit-eacces-"));
const preload = fs.mkdtempSync(path.join(os.tmpdir(), "audit-pre-"));
const preloadScript = path.join(preload, "eacces.cjs");
const pkgDir = path.join(fixture, "integrations");
// Create the dir so existsSync would return true — the bug is that
// statSync failing with EACCES should yield a distinct message. The
// old redundant existsSync check short-circuits ENOENT only; EACCES
// still falls through to statSync. But we also want to assert the
// message doesn't claim "does not exist" — exercise the code path
// by making statSync throw EACCES directly.
fs.mkdirSync(pkgDir, { recursive: true });
fs.writeFileSync(
preloadScript,
`const fs = require("fs");
const target = ${JSON.stringify(pkgDir)};
const origStat = fs.statSync;
fs.statSync = function(...args) {
if (String(args[0]) === target) {
const e = new Error("EACCES: permission denied, stat '" + target + "'");
e.code = "EACCES";
throw e;
}
return origStat.apply(this, args);
};
const origExists = fs.existsSync;
fs.existsSync = function(...args) {
if (String(args[0]) === target) {
// Simulate existsSync hiding EACCES as "false" — the bug.
return false;
}
return origExists.apply(this, args);
};
`,
);
try {
const r = spawnSync(
"npx",
["tsx", "--require", preloadScript, AUDIT_SCRIPT],
{
env: { ...process.env, SHOWCASE_AUDIT_ROOT: fixture },
encoding: "utf-8",
timeout: 30_000,
},
);
expect(r.status, r.stdout + r.stderr).toBe(3);
// The diagnostic MUST carry the EACCES errno (precise,
// actionable) rather than the misleading "does not exist" wording
// the redundant existsSync branch used to emit.
expect(r.stderr).toMatch(/EACCES/);
expect(r.stderr).not.toMatch(/packages dir does not exist/);
} finally {
fs.rmSync(fixture, { recursive: true, force: true });
fs.rmSync(preload, { recursive: true, force: true });
}
});
it("unreadable (3) and invalid-content (2) exit codes differ", () => {
// Regression guard: these two failure modes used to share exit code
// 2, which made it impossible for CI callers to distinguish
// "nothing to audit" from "I don't know what you meant".
const empty = fs.mkdtempSync(path.join(os.tmpdir(), "audit-diff-"));
try {
const unreadable = runCli([], {
env: { SHOWCASE_AUDIT_ROOT: empty },
});
const invalidArgs = runCli(["--slug", "--json"], {
env: { SHOWCASE_AUDIT_ROOT: empty },
});
expect(unreadable.status).not.toBe(invalidArgs.status);
expect(unreadable.status).toBe(3);
expect(invalidArgs.status).toBe(2);
} finally {
fs.rmSync(empty, { recursive: true, force: true });
}
});
it("exits 1 (anomaly) when packages dir exists but is empty", () => {
// tree already has empty packages dir from makeTmpTree
const r = runCli([], {
env: { SHOWCASE_AUDIT_ROOT: root },
});
expect(r.status, r.stdout + r.stderr).toBe(1);
});
it("exits 2 on invalid arg combination (bad arg: --slug --json)", () => {
writePackage(root, "crewai-crews", {
manifest: `slug: crewai-crews\ndeployed: true\ndemos:\n - id: a\n`,
specs: ["a.spec.ts"],
qaFiles: ["a.md"],
});
makeExampleDir(root, "crewai-crews");
const r = runCli(["--slug", "--json"], {
env: { SHOWCASE_AUDIT_ROOT: root },
});
// argparse failure is a user/internal error, not a package anomaly.
expect(r.status, r.stdout + r.stderr).toBe(2);
});
it("--json --slug <slug> combination emits JSON for a single package", () => {
writePackage(root, "crewai-crews", {
manifest: `slug: crewai-crews\ndeployed: true\ndemos:\n - id: a\n`,
specs: ["a.spec.ts"],
qaFiles: ["a.md"],
});
makeExampleDir(root, "crewai-crews");
writePackage(root, "other", {
manifest: `slug: other\ndeployed: true\ndemos:\n - id: a\n`,
specs: ["a.spec.ts"],
qaFiles: ["a.md"],
});
makeExampleDir(root, "other");
const r = runCli(["--json", "--slug", "crewai-crews"], {
env: { SHOWCASE_AUDIT_ROOT: root },
});
expect(r.status, r.stdout + r.stderr).toBe(0);
const parsed = JSON.parse(r.stdout);
expect(parsed.packages.length).toBe(1);
expect(parsed.packages[0].slug).toBe("crewai-crews");
// Scalar summary exposed alongside the nested report.
expect(parsed.hasAnomalies).toBe(false);
expect(parsed.exitCode).toBe(0);
});
it("JSON mode does not duplicate per-package warnings to stderr", () => {
// In JSON mode, warnings are already carried on
// `packages[i].warnings` — echoing them to stderr would
// double-emit the same information. A consumer redirecting
// `2>/dev/null` should still get a complete machine-readable
// report via stdout.
const mappedSlug = "mastra";
expect(SLUG_TO_EXAMPLES[mappedSlug]).toBeDefined();
writePackage(root, mappedSlug, {
manifest: `slug: ${mappedSlug}\ndeployed: true\ndemos:\n - id: a\n`,
specs: ["a.spec.ts"],
qaFiles: ["a.md"],
});
// Intentionally DO NOT create examples/integrations/<mapped> dir so
// findExamplesSource emits a stale-mapping warning.
const r = runCli(["--json"], {
env: { SHOWCASE_AUDIT_ROOT: root },
});
// exit is 0/1 depending on anomalies — the focus here is stderr
// contents, not exit code.
expect(r.stderr || "").not.toMatch(/audit: warning:/);
// The JSON stdout should still carry the warning on the package
// record so JSON consumers aren't blind.
const parsed = JSON.parse(r.stdout);
const p = parsed.packages.find(
(x: { slug: string }) => x.slug === mappedSlug,
);
expect(p).toBeDefined();
expect(p.warnings.length).toBeGreaterThan(0);
});
it("text mode forwards per-package warnings to stderr for human readers", () => {
// Counterpart: in text mode a terminal user watching stderr should
// still see the stale-mapping diagnostic — the sink-based warnings
// must be forwarded, not silently dropped.
const mappedSlug = "mastra";
expect(SLUG_TO_EXAMPLES[mappedSlug]).toBeDefined();
writePackage(root, mappedSlug, {
manifest: `slug: ${mappedSlug}\ndeployed: true\ndemos:\n - id: a\n`,
specs: ["a.spec.ts"],
qaFiles: ["a.md"],
});
const r = runCli([], {
env: { SHOWCASE_AUDIT_ROOT: root },
});
// Tighten from bare /audit: warning:/ — that would also accept any
// unrelated warning. Pin:
// - the "audit: warning:" prefix (routing)
// - the specific slug ("mastra") that triggered it
// - the SLUG_TO_EXAMPLES phrase that identifies this as the
// stale-mapping diagnostic, not some other warning
expect(r.stderr).toMatch(/audit: warning:/);
expect(r.stderr).toMatch(
new RegExp(`audit: warning: SLUG_TO_EXAMPLES entry "${mappedSlug}"`),
);
expect(r.stderr).toMatch(/has no matching directory/);
});
it("exits 4 (internal error) on unexpected exceptions", () => {
// Inject a TypeError into the YAML parser — auditPackage delegates
// to parseManifest which calls yaml.parse inside a try/catch that
// maps to a `malformed` result. HOWEVER, if we monkey-patch
// `yaml.parse` to throw a TypeError AFTER parseManifest has already
// read the file, the catch block inside parseManifest catches that
// and returns `malformed`. The cleanest way to trigger EXIT_INTERNAL
// is to throw a non-Error value from a point that is NOT wrapped
// upstream.
//
// Concretely: override `fs.readFileSync` to throw a TypeError ONLY
// for the target manifest.yaml path. readFileSync in parseManifest
// IS wrapped — but the catch returns `{kind: "unreadable", error}`
// only if `e instanceof Error`. A TypeError IS an Error, so that
// catch activates. That means we can't trigger EXIT_INTERNAL via
// readFileSync either.
//
// The actual unwrapped paths are: buildReport's Object.freeze calls,
// anomaly bucket operations, renderTable, renderAnomalySection,
// renderHealthSection, JSON.stringify. The simplest wedge is to
// monkey-patch `JSON.stringify` to throw TypeError when called with
// the audit report, which happens only in --json mode. For the
// default (text) mode, inject a TypeError through renderTable by
// monkey-patching `String.prototype.padEnd` — but that's too
// invasive.
//
// Simpler: force the failure in `Object.freeze` via a Proxy wrapper
// around the anomaly array. Actually, the cleanest wedge: override
// `Array.prototype.filter` in the preload so the FIRST call after
// slug listing throws. But that breaks tsx too.
//
// Practical solution: use `--json` mode and intercept
// `JSON.stringify` to throw once, after the report is built.
const preload = fs.mkdtempSync(path.join(os.tmpdir(), "audit-preload-"));
const preloadScript = path.join(preload, "boom.cjs");
fs.writeFileSync(
preloadScript,
`const origStringify = JSON.stringify;
JSON.stringify = function(value, ...rest) {
// Only fire when serializing the audit report (has the 'packages'
// array + 'anomalies' object shape). Other JSON.stringify callers
// (test runner, tsx internals, error formatting) still work.
if (
value &&
typeof value === "object" &&
Array.isArray(value.packages) &&
value.anomalies &&
typeof value.anomalies === "object" &&
"countMismatches" in value.anomalies
) {
throw new TypeError("simulated bug: should never happen");
}
return origStringify.call(this, value, ...rest);
};
`,
);
writePackage(root, "foo", {
manifest: `slug: foo\ndeployed: true\ndemos:\n - id: a\n`,
specs: ["a.spec.ts"],
qaFiles: ["a.md"],
});
try {
const r = spawnSync(
"npx",
["tsx", "--require", preloadScript, AUDIT_SCRIPT, "--json"],
{
env: { ...process.env, SHOWCASE_AUDIT_ROOT: root },
encoding: "utf-8",
timeout: 30_000,
},
);
// The injected failure fires from JSON.stringify in main()'s
// --json branch, which is NOT wrapped in a local try/catch — it
// bubbles to the top-level catch, which must route programmer
// bugs (TypeError) to EXIT_INTERNAL (4).
expect(r.status, r.stdout + r.stderr).toBe(4);
// stderr should use the programmer-bug wording, not the generic
// "internal error" one.
expect(r.stderr).toMatch(/bug \(programmer error\)/);
} finally {
fs.rmSync(preload, { recursive: true, force: true });
}
});
it("exits 3 with a clear error when SHOWCASE_AUDIT_ROOT points to a nonexistent path", () => {
// Regression guard: previously an invalid SHOWCASE_AUDIT_ROOT was
// accepted silently and users got a confusing downstream error about
// the derived `<root>/packages` path. We now validate the env-var
// path itself and emit a clear message naming SHOWCASE_AUDIT_ROOT.
const missing = path.join(
os.tmpdir(),
`audit-nonexistent-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
// Paranoia: ensure it really doesn't exist (collision with a prior
// run would mask the fix).
expect(fs.existsSync(missing)).toBe(false);
const r = runCli([], {
env: { SHOWCASE_AUDIT_ROOT: missing },
});
expect(r.status, r.stdout + r.stderr).toBe(3);
expect(r.stderr).toMatch(/SHOWCASE_AUDIT_ROOT/);
expect(r.stderr).toMatch(/does not exist/i);
expect(r.stderr).toContain(missing);
});
it("exits 3 with a clear error when SHOWCASE_AUDIT_ROOT points to a file, not a directory", () => {
// Counterpart regression guard: a file-typed SHOWCASE_AUDIT_ROOT used
// to flow through to the packages-dir check and emit an unhelpful
// "packages dir does not exist: <file>/packages" message. Now the
// env-var validation layer catches it first with a precise diagnostic.
const filePath = path.join(
os.tmpdir(),
`audit-root-as-file-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
fs.writeFileSync(filePath, "not a dir\n");
try {
const r = runCli([], {
env: { SHOWCASE_AUDIT_ROOT: filePath },
});
expect(r.status, r.stdout + r.stderr).toBe(3);
expect(r.stderr).toMatch(/SHOWCASE_AUDIT_ROOT/);
expect(r.stderr).toMatch(/not a directory/i);
expect(r.stderr).toContain(filePath);
} finally {
fs.rmSync(filePath, { force: true });
}
});
});
describe("main() --columns via CLI subprocess", () => {
let root: string;
beforeEach(() => {
root = makeTmpTree();
});
afterEach(() => {
fs.rmSync(root, { recursive: true, force: true });
});
function runCli(
args: string[],
opts: { cwd?: string; env?: NodeJS.ProcessEnv } = {},
) {
return spawnSync("npx", ["tsx", AUDIT_SCRIPT, ...args], {
cwd: opts.cwd,
env: { ...process.env, ...opts.env },
encoding: "utf-8",
timeout: 30_000,
});
}
it("--columns filters the table to the specified columns", () => {
writePackage(root, "crewai-crews", {
manifest: `slug: crewai-crews\ndeployed: true\ndemos:\n - id: a\n`,
specs: ["a.spec.ts"],
qaFiles: ["a.md"],
});
makeExampleDir(root, "crewai-crews");
const r = runCli(["--columns=slug,demos"], {
env: { SHOWCASE_AUDIT_ROOT: root },
});
expect(r.status, r.stdout + r.stderr).toBe(0);
// Full columns include "deployed" and "examples src"; filtered
// output must NOT include those labels. Column headers checked
// as whole tokens — "slug" naked matches any line with the word
// (e.g. "crewai-crews" row) so pin a header-context regex.
expect(r.stdout).toMatch(/\bslug\b/);
expect(r.stdout).toMatch(/\bdemos\b/);
expect(r.stdout).not.toMatch(/\bdeployed\b/);
expect(r.stdout).not.toContain("examples src");
// The fixture slug must appear as its own data row.
expect(r.stdout).toMatch(/\bcrewai-crews\b/);
// And the Overall health summary must still render even with a
// filtered table.
expect(r.stdout).toMatch(/Packages total:\s+1/);
});
});
describe("module isMain guard", () => {
it("does not execute main() when imported as a subprocess (proof via spawnSync)", () => {
// Replace the tautological in-process assertion with a real
// subprocess test. We invoke node on a tiny inline script that
// imports audit.js (as a URL, since the real file is audit.ts and
// emits as audit.js in the module graph) and verifies it exits 0.
// If main() ran on import, it would exit 1 (empty packages) or 3
// (missing packages), not 0.
const helper = fs.mkdtempSync(path.join(os.tmpdir(), "audit-import-"));
const helperScript = path.join(helper, "probe.mjs");
// Use tsx to import the .ts file directly — tsx resolves the .js
// extension against the source .ts.
fs.writeFileSync(
helperScript,
`import("${AUDIT_SCRIPT.replace(/\\/g, "/")}").then((m) => {
if (typeof m.auditPackage !== "function") process.exit(1);
if (typeof m.buildReport !== "function") process.exit(1);
process.exit(0);
}).catch((e) => {
console.error(e);
process.exit(2);
});
`,
);
try {
const r = spawnSync("npx", ["tsx", helperScript], {
encoding: "utf-8",
timeout: 30_000,
});
expect(r.status, r.stdout + r.stderr).toBe(0);
} finally {
fs.rmSync(helper, { recursive: true, force: true });
}
});
});
@@ -0,0 +1,93 @@
// Shared helpers for audit.*.test.ts files.
//
// audit.test.ts was originally a single ~3000-line, 119-test file that ran
// ~71s on Node 22 in CI — over the hardcoded 60s birpc `onTaskUpdate` RPC
// window (upstream vitest #6129 — `DEFAULT_TIMEOUT = 6e4` in the bundled
// birpc). With `pool: 'forks'` (see showcase/scripts/vitest.config.ts) each
// file gets its own fresh worker + its own fresh 60s RPC budget, so the
// cliff is only hit per-file — splitting by describe-category dodges it.
//
// This module hosts the tmpdir / writePackage / makeConfig fixture
// builders and the AUDIT_SCRIPT path constant so we don't duplicate them
// across the split files.
import fs from "fs";
import os from "os";
import path from "path";
import { fileURLToPath } from "url";
import {
anomalyMessage,
type AuditConfig,
type PackageAudit,
} from "../audit.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export const AUDIT_SCRIPT = path.resolve(__dirname, "..", "audit.ts");
/**
* Build a throwaway temp tree mimicking:
* <root>/integrations/<slug>/manifest.yaml
* <root>/integrations/<slug>/tests/e2e/*.spec.ts
* <root>/integrations/<slug>/qa/*.md
* <root>/examples/integrations/<name>/
*/
export function makeTmpTree(): string {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "audit-fixture-"));
fs.mkdirSync(path.join(root, "integrations"), { recursive: true });
fs.mkdirSync(path.join(root, "examples", "integrations"), {
recursive: true,
});
return root;
}
export function makeConfig(root: string): AuditConfig {
return {
packagesDir: path.join(root, "integrations"),
examplesIntegrationsDir: path.join(root, "examples", "integrations"),
repoRoot: root,
};
}
export function writePackage(
root: string,
slug: string,
opts: {
manifest?: string; // raw YAML string; undefined = no manifest.yaml
specs?: string[];
qaFiles?: string[];
},
) {
const pkgDir = path.join(root, "integrations", slug);
fs.mkdirSync(pkgDir, { recursive: true });
if (opts.manifest !== undefined) {
fs.writeFileSync(path.join(pkgDir, "manifest.yaml"), opts.manifest);
}
if (opts.specs && opts.specs.length > 0) {
const e2eDir = path.join(pkgDir, "tests", "e2e");
fs.mkdirSync(e2eDir, { recursive: true });
for (const s of opts.specs) {
fs.writeFileSync(path.join(e2eDir, s), "// test\n");
}
}
if (opts.qaFiles && opts.qaFiles.length > 0) {
const qaDir = path.join(pkgDir, "qa");
fs.mkdirSync(qaDir, { recursive: true });
for (const q of opts.qaFiles) {
fs.writeFileSync(path.join(qaDir, q), "# qa\n");
}
}
}
export function makeExampleDir(root: string, name: string) {
fs.mkdirSync(path.join(root, "examples", "integrations", name), {
recursive: true,
});
}
// Helpers that recover the old string-based predicates so tests read like
// a behavioral spec even though the underlying type is now tagged.
export function anomalyStrings(a: PackageAudit): string[] {
return a.anomalies.map(anomalyMessage);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,35 @@
/**
* Sync contract: validate-parity.ts's BASELINE_DEMO_COUNT constant and
* fail-baseline.json's `baselineDemoCount` field are two declarations of
* the same number — the per-package e2e-spec-count floor. The workflow
* .github/workflows/showcase_validate.yml reads the JSON; validate-parity.ts
* uses the TS constant as its default. A divergence means the validator
* and the CI gate disagree silently, so we lock them together here.
*
* Prior to this test the contract was enforced only by a comment ("keep
* in sync"), which is trivially violated without any CI signal. R29-7
* finding #4 / R29-5.
*/
import { describe, it, expect } from "vitest";
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import { BASELINE_DEMO_COUNT } from "../validate-parity.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
describe("baselineDemoCount sync contract", () => {
it("fail-baseline.json `baselineDemoCount` equals validate-parity.ts BASELINE_DEMO_COUNT", () => {
const baselinePath = path.resolve(__dirname, "..", "fail-baseline.json");
const raw = fs.readFileSync(baselinePath, "utf8");
const parsed = JSON.parse(raw) as { baselineDemoCount?: unknown };
// Explicit type guard rather than a blind cast — if the field ever
// disappears or its type flips, we want a clear failure, not
// `undefined === <n>` silently passing as false.
expect(typeof parsed.baselineDemoCount).toBe("number");
expect(Number.isInteger(parsed.baselineDemoCount)).toBe(true);
expect(parsed.baselineDemoCount).toBe(BASELINE_DEMO_COUNT);
});
});
@@ -0,0 +1,204 @@
import { describe, it, expect, beforeAll, afterAll, afterEach } from "vitest";
import fs from "fs";
import path from "path";
import { execFileSync } from "child_process";
import { FileSnapshotRestorer, execOptsFor } from "./test-cleanup";
import { SCRIPTS_DIR, SHELL_DATA_DIR } from "./paths";
// `bundle-demo-content.ts` rewrites showcase/shell/src/data/demo-content.json
// on every run, leaking changes into the working tree. Snapshot in beforeAll
// and restore after each test. Assumes vitest's `fileParallelism: false`.
const CONTENT_PATH = path.join(SHELL_DATA_DIR, "demo-content.json");
const DATA_FILES = [CONTENT_PATH];
const dataRestorer = new FileSnapshotRestorer(DATA_FILES);
const EXEC_OPTS = execOptsFor(SCRIPTS_DIR);
/** Invoke the bundler via argv form — argv-safe, no shell parser involvement.
* Returns raw stdout so the call sites that need it (test 1) can assert
* against it. */
function runBundler(): string {
const out = execFileSync("npx", ["tsx", "bundle-demo-content.ts"], EXEC_OPTS);
return out.toString();
}
beforeAll(() => {
// Generate the data file (it's gitignored, so it may not exist).
runBundler();
dataRestorer.snapshot();
if (dataRestorer.snapshotMap.size === 0) {
throw new Error(
`bundle-demo-content.test.ts: data snapshot is empty. Expected generated` +
` files at:\n` +
DATA_FILES.map((p) => ` ${p}`).join("\n"),
);
}
});
afterEach(() => dataRestorer.restore());
afterAll(() => dataRestorer.restore());
/** Run the bundler and return the parsed demo-content.json. Tests 3-5 each
* call this so they observe live bundler output (afterEach restores to HEAD
* between tests, so without this step they'd read stale committed content). */
function runBundlerAndRead(): any {
runBundler();
return JSON.parse(fs.readFileSync(CONTENT_PATH, "utf-8"));
}
describe("Content Bundler", () => {
it("generates demo-content.json from existing packages", () => {
const stdout = runBundler();
expect(stdout).toContain("Bundling demo content");
expect(stdout).toContain("langgraph-python::agentic-chat");
expect(fs.existsSync(CONTENT_PATH)).toBe(true);
const content = JSON.parse(fs.readFileSync(CONTENT_PATH, "utf-8"));
expect(Object.keys(content.demos).length).toBeGreaterThan(0);
});
it("bundles correct files for each demo", () => {
const content = runBundlerAndRead();
const agenticChat = content.demos["langgraph-python::agentic-chat"];
expect(agenticChat).toBeDefined();
expect(agenticChat.readme).toBeTruthy();
expect(agenticChat.readme).toContain("Agentic Chat");
expect(agenticChat.files.length).toBeGreaterThan(0);
// page.tsx should be first (sorted by bundler); its bundled filename
// is the column-relative path.
expect(agenticChat.files[0].filename).toBe(
"src/app/demos/agentic-chat/page.tsx",
);
expect(agenticChat.files[0].language).toBe("typescript");
expect(agenticChat.files[0].content).toContain("CopilotKit");
// Backend agent file (from manifest.highlight) should be present.
const agentFile = agenticChat.files.find((f: any) =>
/agents\/agentic_chat\.py$/.test(f.filename),
);
expect(agentFile).toBeDefined();
expect(agentFile.language).toBe("python");
});
it("detects correct language for each file type", () => {
const content = runBundlerAndRead();
for (const [, demo] of Object.entries(content.demos) as any) {
for (const file of demo.files) {
if (file.filename.endsWith(".tsx") || file.filename.endsWith(".ts")) {
expect(file.language).toBe("typescript");
} else if (file.filename.endsWith(".py")) {
expect(file.language).toBe("python");
} else if (file.filename.endsWith(".css")) {
expect(file.language).toBe("css");
}
}
}
});
it("includes backend files for packages with agent code", () => {
const content = runBundlerAndRead();
// langgraph-python: backend files are merged into the flat `files`
// list via the manifest's `highlight:` entries (column-relative paths
// like src/agents/main.py).
const lgDemo = content.demos["langgraph-python::agentic-chat"];
expect(lgDemo).toBeDefined();
const lgAgent = lgDemo.files.find((f: any) =>
/src\/agents\/agentic_chat\.py$/.test(f.filename),
);
expect(lgAgent).toBeDefined();
expect(lgAgent.language).toBe("python");
});
it("includes core langgraph-python demos", () => {
const content = runBundlerAndRead();
const expectedDemos = [
"agentic-chat",
"frontend-tools",
"hitl-in-chat",
"tool-rendering",
"gen-ui-tool-based",
"gen-ui-agent",
"shared-state-streaming",
"subagents",
];
for (const demoId of expectedDemos) {
const key = `langgraph-python::${demoId}`;
expect(content.demos[key]).toBeDefined();
expect(content.demos[key].files.length).toBeGreaterThan(0);
}
});
// Regression guard — verifies the snapshot/restore hooks defined at the
// top of this file actually heal drift that `bundle-demo-content.ts`
// produces in shell/src/data/demo-content.json.
//
// The sentinel append creates transient tracking drift on demo-content.json
// for the duration of the test; a developer with a git GUI / file watcher
// will see flicker while it runs. Restore heals it before the test returns.
it("restores shell/src/data/demo-content.json after the bundler mutates it", () => {
expect(dataRestorer.snapshotMap.size).toBeGreaterThan(0);
// Run the bundler (side-effect: overwrites demo-content.json).
runBundler();
// Capture pre-sentinel content so we can prove the append landed via a
// content check (stronger than byte-length: resistant to a hypothetical
// fs shim that updates stat but not bytes).
const preAppendContent = new Map<string, Buffer>();
for (const p of dataRestorer.snapshotMap.keys()) {
preAppendContent.set(p, fs.readFileSync(p));
}
// Force the file to differ from the snapshot regardless of generator
// output. Safe because we restore immediately below.
const SENTINEL = "\n/* regression-guard-sentinel */\n";
const sentinelBuf = Buffer.from(SENTINEL, "utf-8");
for (const p of dataRestorer.snapshotMap.keys()) {
fs.appendFileSync(p, SENTINEL);
}
// Verify the sentinel actually landed on disk — the file must be
// pre-append content followed by sentinel bytes, exactly.
for (const p of dataRestorer.snapshotMap.keys()) {
const before = preAppendContent.get(p)!;
const expected = Buffer.concat([before, sentinelBuf]);
const actual = fs.readFileSync(p);
expect(
actual.equals(expected),
`sentinel append did not land on ${p}`,
).toBe(true);
}
// Restore and assert bit-for-bit against the in-memory snapshot (NOT
// against a re-read of disk, which would silently agree with a buggy
// restore()).
dataRestorer.restore();
for (const [p, baseline] of dataRestorer.snapshotMap) {
const current = fs.readFileSync(p);
expect(current.equals(baseline), `data drift not restored: ${p}`).toBe(
true,
);
}
});
// Safety net: every snapshotted data file must match its captured baseline
// bit-for-bit at the end of the suite. Mirrors the equivalent check in
// create-integration.test.ts and generate-registry.test.ts.
it("leaves every snapshotted data file byte-identical to its baseline", () => {
expect(dataRestorer.snapshotMap.size).toBeGreaterThan(0);
for (const [p, baseline] of dataRestorer.snapshotMap) {
const current = fs.readFileSync(p);
expect(current.equals(baseline), `data drift after suite: ${p}`).toBe(
true,
);
}
});
});
@@ -0,0 +1,334 @@
import { describe, expect, it } from "vitest";
import path from "node:path";
import { globSync } from "glob";
import {
Journal,
isTextResponse,
isToolCallResponse,
loadFixtureFile,
matchFixture,
} from "@copilotkit/aimock";
import type {
ChatCompletionRequest,
Fixture,
FixtureResponse,
ToolCallResponse,
} from "@copilotkit/aimock";
const REPO_ROOT = path.resolve(__dirname, "..", "..", "..");
// ---------------------------------------------------------------------------
// The beautiful-chat calculator pill in every integration's
// _from-feature-parity.json implements a repeat-click design:
//
// click 1 → sequenceIndex 0 variant → calculator_001 tool call
// click 2 → sequenceIndex 1 variant → calculator_002 tool call
// click 3+ → non-sequenced fallback → calculator_003 tool call (repeats)
//
// with a dedicated toolCallId follow-up entry per leg so the second LLM
// round-trip (thread ends with the tool result) returns text instead of
// another tool call. Because aimock's matchFixture is FIRST-match over the
// fixture array, correctness depends on four ordering invariants:
//
// (1) the three toolCallId follow-up entries precede the leg-1 variants —
// otherwise the follow-up request (whose last USER message is still the
// pill text) re-matches a leg-1 variant and the demo tool-call-loops;
// (2) the sequenceIndex 0/1 variants precede the non-sequenced _003
// fallback — otherwise every click pins to _003 permanently;
// (3) the fallback precedes the generic "build a modern calculator" pair —
// the generic pill is a SUBSTRING of the calculator pill, so reversed
// order would hijack the calculator pill into the open-gen-ui demo;
// (4) within the generic pair, the toolCallId follow-up entry precedes the
// generic leg-1 entry — same loop-prevention rationale as (1).
//
// These invariants were previously enforced only by comments inside the
// fixture files. This test pins them structurally AND behaviorally for every
// integration.
// ---------------------------------------------------------------------------
const CALC_PILL = "build a modern calculator with standard buttons";
const GENERIC_PILL = "build a modern calculator";
// The FULL production pill text, from
// src/app/demos/beautiful-chat/hooks/use-example-suggestions.tsx in 17 of the
// 19 integrations — built-in-agent and claude-sdk-python have no calculator
// pill; their fixture blocks are mirror-parity entries per the GOTCHAS MIRROR
// rule, ready for when those demos gain the pill. The fixture matchers above
// (CALC_PILL / GENERIC_PILL) are SUBSTRINGS of this — the behavioral walk
// sends the full text so it exercises the same substring matching the live
// demo relies on.
const CALC_PILL_FULL =
"Using the generateSandboxedUi tool, build a modern calculator with " +
"standard buttons plus labeled metric shortcut buttons that insert their " +
"values into the display when clicked. Use sample company data.";
const CALC_CALL_IDS = [
"call_fp_beautiful_chat_calculator_001",
"call_fp_beautiful_chat_calculator_002",
"call_fp_beautiful_chat_calculator_003",
] as const;
const GENERIC_CALL_ID = "call_fp_open_gen_ui_calc_001";
const fixtureFiles = globSync(
"showcase/aimock/d6/*/_from-feature-parity.json",
{ cwd: REPO_ROOT, absolute: true },
).sort();
const integrations = fixtureFiles.map((file) => ({
slug: path.basename(path.dirname(file)),
file,
}));
interface CalcEntryIndices {
followUps: number[]; // toolCallId calculator_001/002/003 follow-up entries
sequenced: number[]; // CALC_PILL + sequenceIndex 0/1 leg-1 variants
fallback: number[]; // CALC_PILL without sequenceIndex (_003 fallback)
genericFollowUps: number[]; // toolCallId open_gen_ui_calc_001 follow-up
genericLeg1: number[]; // generic GENERIC_PILL leg-1 entry
}
function indexCalcEntries(fixtures: Fixture[]): CalcEntryIndices {
const out: CalcEntryIndices = {
followUps: [],
sequenced: [],
fallback: [],
genericFollowUps: [],
genericLeg1: [],
};
fixtures.forEach((f, i) => {
const m = f.match;
if (
m.toolCallId !== undefined &&
(CALC_CALL_IDS as readonly string[]).includes(m.toolCallId)
) {
out.followUps.push(i);
} else if (m.userMessage === CALC_PILL) {
if (m.sequenceIndex !== undefined) out.sequenced.push(i);
else out.fallback.push(i);
} else if (m.toolCallId === GENERIC_CALL_ID) {
out.genericFollowUps.push(i);
} else if (m.userMessage === GENERIC_PILL) {
out.genericLeg1.push(i);
}
});
return out;
}
/**
* Fail-loud guard: loadFixtureFile swallows read/parse/shape errors and
* returns [] (it only warns). Without this guard a broken fixture file would
* make the ordering test pass vacuously — Math.max(...[]) === -Infinity is
* less than Math.min(...[]) === Infinity, so every ordering assertion would
* hold over empty index arrays.
*/
function expectNonEmptyFixtures(fixtures: Fixture[], rel: string): void {
expect(
fixtures.length,
`${rel}: loadFixtureFile returned an empty array — it swallows parse/load ` +
`errors and returns [], so this usually means the file is unreadable or ` +
`contains invalid JSON, not that it has no fixtures`,
).toBeGreaterThan(0);
}
/**
* Mirror of the server request flow (see aimock's handler implementations):
* matchFixture reads the journal's per-testId match counts, and a successful
* match increments them (which is what makes sequenceIndex advance).
*
* Note: this mirror assumes no requestTransform is configured — passing one to
* matchFixture flips matching into exact mode and would desynchronize this
* mirror from the (substring-matching) server behavior tested here.
*/
function send(
fixtures: Fixture[],
journal: Journal,
testId: string,
slug: string,
messages: ChatCompletionRequest["messages"],
): Fixture | null {
const req = {
model: "gpt-5.4",
messages: [...messages],
// D6 fixtures use match.context for per-integration scoping; aimock's
// matchFixture checks req._context against it.
_context: slug,
// Mirrors server.js:343 — pinned so this mirror cannot silently desync
// if a future aimock version branches on the endpoint type.
_endpointType: "chat",
} as ChatCompletionRequest;
const fixture = matchFixture(
fixtures,
req,
journal.getFixtureMatchCountsForTest(testId),
);
if (fixture) journal.incrementFixtureMatchCount(fixture, fixtures, testId);
return fixture;
}
describe("beautiful-chat calculator fixture routing", () => {
it("discovers all 20 integration _from-feature-parity.json files", () => {
expect(
integrations.map((i) => i.slug),
"integration count changed — new integration added? mirror its " +
"calculator fixtures from langgraph-python and update this pin",
).toHaveLength(20);
});
for (const { slug, file } of integrations) {
describe(slug, () => {
const fixtures = loadFixtureFile(file);
const rel = path.relative(REPO_ROOT, file);
it("declares the full calculator entry set", () => {
expectNonEmptyFixtures(fixtures, rel);
const idx = indexCalcEntries(fixtures);
expect(idx.followUps, `${rel}: calculator follow-ups`).toHaveLength(3);
expect(idx.sequenced, `${rel}: sequenced leg-1 variants`).toHaveLength(
2,
);
expect(
idx.fallback,
`${rel}: non-sequenced _003 fallback`,
).toHaveLength(1);
expect(
idx.genericFollowUps,
`${rel}: generic follow-up entry`,
).toHaveLength(1);
expect(idx.genericLeg1, `${rel}: generic leg-1 entry`).toHaveLength(1);
});
it("orders entries: follow-ups → sequenced variants → fallback → generic pair", () => {
expectNonEmptyFixtures(fixtures, rel);
const idx = indexCalcEntries(fixtures);
// Guard every index bucket so the Math.max/Math.min comparisons below
// can never pass vacuously over empty arrays (-Infinity < Infinity).
for (const [bucket, indices] of Object.entries(idx)) {
expect(
indices.length,
`${rel}: no ${bucket} entries indexed — the ordering assertions ` +
`below would be vacuous over an empty bucket`,
).toBeGreaterThan(0);
}
const leg1 = [...idx.sequenced, ...idx.fallback];
const generic = [...idx.genericFollowUps, ...idx.genericLeg1];
// (1) toolCallId follow-ups precede ALL leg-1 variants.
expect(
Math.max(...idx.followUps),
`${rel}: a calculator toolCallId follow-up entry must precede every ` +
`leg-1 variant, or follow-up requests re-match a leg-1 fixture ` +
`and the demo tool-call-loops`,
).toBeLessThan(Math.min(...leg1));
// (2) sequenceIndex variants precede the non-sequenced fallback.
expect(
Math.max(...idx.sequenced),
`${rel}: sequenceIndex 0/1 variants must precede the non-sequenced ` +
`_003 fallback, or every click pins to _003 permanently`,
).toBeLessThan(idx.fallback[0]);
// (3) the fallback precedes the generic "build a modern calculator"
// pair (whose pill is a substring of the calculator pill).
expect(
idx.fallback[0],
`${rel}: the _003 fallback must precede the generic ` +
`"${GENERIC_PILL}" pair, or substring matching hijacks the ` +
`calculator pill into the open-gen-ui fixtures`,
).toBeLessThan(Math.min(...generic));
// (4) within the generic pair, the toolCallId follow-up precedes the
// generic leg-1 entry — same loop-prevention rationale as (1): a
// reversed order would re-match leg-1 on the follow-up round-trip.
expect(
Math.max(...idx.genericFollowUps),
`${rel}: the generic toolCallId follow-up (${GENERIC_CALL_ID}) must ` +
`precede the generic "${GENERIC_PILL}" leg-1 entry, or the ` +
`follow-up request re-matches leg-1 and the demo tool-call-loops`,
).toBeLessThan(Math.min(...idx.genericLeg1));
});
it("routes four repeat clicks through _001 → _002 → _003 → _003 with follow-ups", () => {
expectNonEmptyFixtures(fixtures, rel);
const journal = new Journal();
const testId = `calc-routing-${slug}`;
const messages: ChatCompletionRequest["messages"] = [];
const clickLeg1 = (click: number, expectedCallId: string) => {
// Send the FULL production pill text — the fixture matchers are
// substrings of it (see CALC_PILL_FULL above).
messages.push({ role: "user", content: CALC_PILL_FULL });
const fixture = send(fixtures, journal, testId, slug, messages);
expect(
fixture,
`${rel}: click-${click} leg-1 strict-missed`,
).not.toBeNull();
const response = fixture!.response as FixtureResponse;
expect(
isToolCallResponse(response),
`${rel}: click-${click} leg-1 must return a tool call`,
).toBe(true);
const toolCall = (response as ToolCallResponse).toolCalls[0];
expect(
toolCall.id,
`${rel}: click-${click} leg-1 routed to the wrong fixture`,
).toBe(expectedCallId);
// Append the assistant tool call + tool result, as the host does.
messages.push({
role: "assistant",
content: "",
tool_calls: [
{
id: expectedCallId,
type: "function",
function: {
name: toolCall.name,
arguments: toolCall.arguments,
},
},
],
});
messages.push({
role: "tool",
content: "rendered",
tool_call_id: expectedCallId,
});
};
const followUp = (click: number, expectedCallId: string) => {
// Thread's last message is now the tool result for expectedCallId.
const fixture = send(fixtures, journal, testId, slug, messages);
expect(
fixture,
`${rel}: click-${click} follow-up strict-missed`,
).not.toBeNull();
expect(
fixture!.match.toolCallId,
`${rel}: click-${click} follow-up matched a non-follow-up fixture ` +
`(tool-call loop)`,
).toBe(expectedCallId);
const response = fixture!.response as FixtureResponse;
expect(
isTextResponse(response),
`${rel}: click-${click} follow-up must return text`,
).toBe(true);
messages.push({
role: "assistant",
content: (response as { content: string }).content,
});
};
clickLeg1(1, CALC_CALL_IDS[0]);
followUp(1, CALC_CALL_IDS[0]);
clickLeg1(2, CALC_CALL_IDS[1]);
followUp(2, CALC_CALL_IDS[1]);
clickLeg1(3, CALC_CALL_IDS[2]);
followUp(3, CALC_CALL_IDS[2]);
// Click 4+: the non-sequenced fallback keeps serving _003 — no
// strict-miss, no pinning regression — and its toolCallId follow-up
// keeps serving too (the _003 follow-up must match repeatedly).
clickLeg1(4, CALC_CALL_IDS[2]);
followUp(4, CALC_CALL_IDS[2]);
});
});
}
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,248 @@
/**
* Tests for the prod digest-pinning logic in `deploy-to-railway.ts`.
*
* Contract under test: a freshly provisioned PROD showcase service must be
* born pinned to a content digest (`ghcr.io/copilotkit/showcase-<slug>@sha256:...`),
* NEVER the mutable `:latest` tag. Staging floats `:latest` by design and is
* provisioned by a different script — not exercised here.
*
* Style note (mirrors provision-starter-fleet / redeploy-env tests): GHCR is
* the only impure surface and is dependency-injected as a `GhcrHttp`. We use a
* plain recording mock — GHCR is an external registry boundary, not an LLM, so
* aimock does not apply. No real network I/O.
*/
import { describe, it, expect } from "vitest";
import {
assertProdDigestPinned,
buildProdImageRef,
ProdPinError,
resolveGhcrDigest,
} from "../deploy-to-railway";
import type {
GhcrHttp,
GhcrHttpResponse,
ProdInstanceQueryFn,
} from "../deploy-to-railway";
const DIGEST = "sha256:" + "a".repeat(64);
/**
* Build a GhcrHttp mock. `manifestStatus` controls the HEAD on
* /manifests/<tag>; `digestHeader` controls the Docker-Content-Digest header
* returned on a 2xx. The /token GET always succeeds with a dummy bearer
* unless `tokenStatus` overrides it.
*/
function makeGhcrHttp(opts: {
manifestStatus?: number;
digestHeader?: string | null;
tokenStatus?: number;
}): { http: GhcrHttp; calls: { method: string; url: string }[] } {
const calls: { method: string; url: string }[] = [];
const http: GhcrHttp = {
async get(url): Promise<GhcrHttpResponse> {
calls.push({ method: "GET", url });
const status = opts.tokenStatus ?? 200;
return {
status,
headers: {},
body: status < 400 ? JSON.stringify({ token: "dummy-bearer" }) : "",
};
},
async head(url): Promise<GhcrHttpResponse> {
calls.push({ method: "HEAD", url });
const status = opts.manifestStatus ?? 200;
const headers: Record<string, string> = {};
const dh = opts.digestHeader === undefined ? DIGEST : opts.digestHeader;
if (status < 400 && dh) headers["docker-content-digest"] = dh;
return { status, headers, body: "" };
},
};
return { http, calls };
}
describe("resolveGhcrDigest", () => {
it("resolves :latest to its content digest via a manifest HEAD", async () => {
const { http, calls } = makeGhcrHttp({});
const digest = await resolveGhcrDigest(
"showcase-mastra",
"latest",
http,
"pat",
);
expect(digest).toBe(DIGEST);
// Mirrors the Ruby CLI: a /token exchange GET precedes the manifest HEAD.
expect(calls[0]).toMatchObject({ method: "GET" });
expect(calls[0].url).toContain("/token");
expect(calls[1]).toMatchObject({ method: "HEAD" });
expect(calls[1].url).toContain(
"/v2/copilotkit/showcase-mastra/manifests/latest",
);
});
it("throws (fail-loud) when the tag does not exist (404) — no fallback", async () => {
const { http } = makeGhcrHttp({ manifestStatus: 404 });
await expect(
resolveGhcrDigest("showcase-mastra", "latest", http, "pat"),
).rejects.toThrow(/404/);
});
it("throws when GHCR returns a 2xx but no Docker-Content-Digest header", async () => {
const { http } = makeGhcrHttp({ digestHeader: null });
await expect(
resolveGhcrDigest("showcase-mastra", "latest", http, "pat"),
).rejects.toThrow(/Docker-Content-Digest/);
});
it("throws when a supplied token fails the /token exchange (no silent anon downgrade)", async () => {
const { http } = makeGhcrHttp({ tokenStatus: 403 });
await expect(
resolveGhcrDigest("showcase-mastra", "latest", http, "pat"),
).rejects.toThrow(/token exchange failed/);
});
});
describe("buildProdImageRef", () => {
it("produces a digest-pinned prod image ref (@sha256:...), NOT the :latest tag", async () => {
const ref = await buildProdImageRef("mastra", async () => DIGEST);
// The core contract: prod is born pinned, never tracking the mutable tag.
expect(ref).toBe(`ghcr.io/copilotkit/showcase-mastra@${DIGEST}`);
expect(ref).toMatch(/@sha256:[0-9a-f]{64}$/);
expect(ref).not.toContain(":latest");
});
it("fails loud when digest resolution rejects — does NOT fall back to :latest", async () => {
await expect(
buildProdImageRef("mastra", async () => {
throw new Error("GHCR manifest HEAD 404");
}),
).rejects.toThrow(/404/);
});
it("rejects a malformed digest rather than pinning prod to it", async () => {
await expect(
buildProdImageRef("mastra", async () => "sha256:not-a-real-digest"),
).rejects.toThrow(/malformed digest/);
});
it("end-to-end through resolveGhcrDigest yields a pinned ref, never the tag", async () => {
const { http } = makeGhcrHttp({});
const ref = await buildProdImageRef("mastra", (name) =>
resolveGhcrDigest(name, "latest", http, "pat"),
);
expect(ref).toBe(`ghcr.io/copilotkit/showcase-mastra@${DIGEST}`);
expect(ref).not.toContain(":latest");
});
});
/**
* Build a fake `ProdInstanceQueryFn` that returns a single showcase service
* whose PROD serviceInstance carries `image` as its `source.image`. `image:
* null` simulates a service with no configured image source. `serviceName`
* and `envId` default to a matching service in the production env so the
* tests exercise the digest-shape branch rather than the lookup branches.
* `envId` is the `environmentId` the fake serviceInstance is reported under;
* supply a value that differs from the env `assertProdDigestPinned` is asked
* to verify (its 2nd arg) to drive the env-mismatch lookup branch.
*/
function makeProdInstanceQuery(opts: {
image: string | null;
serviceName?: string;
envId?: string;
}): ProdInstanceQueryFn {
const serviceName = opts.serviceName ?? "showcase-mastra";
const instanceEnv = opts.envId ?? "prod-env";
return async () => ({
project: {
services: {
edges: [
{
node: {
name: serviceName,
serviceInstances: {
edges: [
{
node: {
environmentId: instanceEnv,
source:
opts.image === null ? null : { image: opts.image },
},
},
],
},
},
},
],
},
},
});
}
describe("assertProdDigestPinned", () => {
const PINNED = `ghcr.io/copilotkit/showcase-mastra@${DIGEST}`;
it("ACCEPTS a valid ghcr.io/copilotkit/<name>@sha256:<64hex> ref", async () => {
const query = makeProdInstanceQuery({ image: PINNED });
await expect(
assertProdDigestPinned("showcase-mastra", "prod-env", query),
).resolves.toBeUndefined();
});
it("REJECTS a :latest tag ref (mutable, unpinned)", async () => {
const query = makeProdInstanceQuery({
image: "ghcr.io/copilotkit/showcase-mastra:latest",
});
await expect(
assertProdDigestPinned("showcase-mastra", "prod-env", query),
).rejects.toBeInstanceOf(ProdPinError);
await expect(
assertProdDigestPinned("showcase-mastra", "prod-env", query),
).rejects.toThrow(/not digest-pinned/);
});
it("REJECTS an empty/missing source.image", async () => {
const query = makeProdInstanceQuery({ image: null });
await expect(
assertProdDigestPinned("showcase-mastra", "prod-env", query),
).rejects.toBeInstanceOf(ProdPinError);
await expect(
assertProdDigestPinned("showcase-mastra", "prod-env", query),
).rejects.toThrow(/no production image source/);
});
it("REJECTS an empty-string source.image", async () => {
const query = makeProdInstanceQuery({ image: "" });
await expect(
assertProdDigestPinned("showcase-mastra", "prod-env", query),
).rejects.toBeInstanceOf(ProdPinError);
await expect(
assertProdDigestPinned("showcase-mastra", "prod-env", query),
).rejects.toThrow(/no production image source/);
});
it("REJECTS when the service is not found in the project", async () => {
const query = makeProdInstanceQuery({
image: PINNED,
serviceName: "showcase-other",
});
await expect(
assertProdDigestPinned("showcase-mastra", "prod-env", query),
).rejects.toBeInstanceOf(ProdPinError);
await expect(
assertProdDigestPinned("showcase-mastra", "prod-env", query),
).rejects.toThrow(/not found/);
});
it("REJECTS when the service exists but its instance is in a different env", async () => {
// Service IS found, but its only serviceInstance is reported under
// `other-env` while the caller asks to verify `prod-env`. The env-match
// `.find(...)` returns undefined, so no image resolves and the guard throws.
const query = makeProdInstanceQuery({ image: PINNED, envId: "other-env" });
await expect(
assertProdDigestPinned("showcase-mastra", "prod-env", query),
).rejects.toBeInstanceOf(ProdPinError);
await expect(
assertProdDigestPinned("showcase-mastra", "prod-env", query),
).rejects.toThrow(/no production image source/);
});
});
@@ -0,0 +1,67 @@
/**
* Tests for healthcheck-path resolution during provisioning in
* `deploy-to-railway.ts` (`resolveProvisionHealthcheck`).
*
* Contract under test: `healthcheckPathFor` returns undefined for TWO distinct
* reasons, and conflating them wedges deploys:
*
* 1. The service is NOT tracked in the SSOT at all (brand-new/unknown
* service this script is onboarding) → fall back to the agent-class
* default `/api/health`.
* 2. The service IS tracked but deliberately has a null/omitted
* healthcheckPath (dashboard, docs, dojo, webhooks, pocketbase) → it has
* no HTTP health endpoint, so the healthcheck MUST be omitted. Forcing
* `/api/health` onto it yields a 404 that wedges the deploy (the bug this
* test guards against).
*
* Pure SSOT lookups — no network I/O, no aimock (no LLM surface here).
*/
import { describe, it, expect } from "vitest";
import { resolveProvisionHealthcheck } from "../deploy-to-railway";
describe("resolveProvisionHealthcheck", () => {
it("OMITS the healthcheck for a TRACKED service with a null healthcheckPath (docs)", () => {
// RED before fix: `healthcheckPathFor("docs") ?? "/api/health"` wrongly
// returned "/api/health" for this tracked-null service.
expect(resolveProvisionHealthcheck("docs")).toEqual({ kind: "omit" });
});
it("OMITS the healthcheck for other tracked-null services (dashboard, dojo, webhooks, pocketbase)", () => {
for (const svc of ["dashboard", "dojo", "webhooks", "pocketbase"]) {
expect(resolveProvisionHealthcheck(svc)).toEqual({ kind: "omit" });
}
});
it("SETS the SSOT healthcheckPath verbatim for a tracked service that defines one (aimock → /health)", () => {
expect(resolveProvisionHealthcheck("aimock")).toEqual({
kind: "set",
path: "/health",
});
});
it("SETS /api/health for a tracked agent service (showcase-langgraph-python)", () => {
expect(resolveProvisionHealthcheck("showcase-langgraph-python")).toEqual({
kind: "set",
path: "/api/health",
});
});
it("FALLS BACK to /api/health for an UNTRACKED brand-new service", () => {
expect(resolveProvisionHealthcheck("totally-new-unknown-service")).toEqual({
kind: "set",
path: "/api/health",
});
});
it("does NOT treat inherited Object.prototype keys as tracked services", () => {
// Own-property semantics: "constructor"/"toString" are not SSOT members,
// so they take the untracked fallback rather than resolving to a prototype.
for (const key of ["constructor", "toString", "hasOwnProperty"]) {
expect(resolveProvisionHealthcheck(key)).toEqual({
kind: "set",
path: "/api/health",
});
}
});
});
@@ -0,0 +1,67 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
// shell-dojo's src/lib/backend-url.ts is a VERBATIM copy of the shell's
// (scripts cannot be imported across the two Next apps, and lifting it
// to a shared importable package was the larger-blast-radius option).
// The copy's only failure mode is drift — these guards make drift a CI
// failure instead of a silent staging→prod leakage. The behavior itself
// is covered by shell/src/lib/backend-url.test.ts; here we only assert
// the two files (and the script-side default) stay in lockstep.
const SHELL_BACKEND_URL = resolve(
__dirname,
"..",
"..",
"shell",
"src",
"lib",
"backend-url.ts",
);
const DOJO_BACKEND_URL = resolve(
__dirname,
"..",
"..",
"shell-dojo",
"src",
"lib",
"backend-url.ts",
);
const GENERATE_REGISTRY = resolve(__dirname, "..", "generate-registry.ts");
function defaultPatternIn(source: string): string {
const match = source.match(
/DEFAULT_BACKEND_HOST_PATTERN\s*=\s*\n?\s*"([^"]+)"/,
);
if (!match) {
throw new Error("DEFAULT_BACKEND_HOST_PATTERN literal not found");
}
return match[1];
}
describe("shell-dojo backend-url drift guard", () => {
it("shell-dojo/backend-url.ts is byte-identical to shell/backend-url.ts", () => {
const shell = readFileSync(SHELL_BACKEND_URL, "utf8");
const dojo = readFileSync(DOJO_BACKEND_URL, "utf8");
// If this fails, re-copy shell/src/lib/backend-url.ts over
// shell-dojo/src/lib/backend-url.ts (do NOT diverge them — port any
// intended change to the shell first, then re-copy).
expect(dojo).toBe(shell);
});
it("the default host pattern matches between backend-url.ts and generate-registry.ts", () => {
// backend-url.ts (runtime consumer) and generate-registry.ts
// (build-time synthesizer) consume the same SHOWCASE_BACKEND_HOST_PATTERN
// env var and MUST agree on the unset-default, or an unset deploy
// would bake one host yet derive another at runtime.
const runtimeDefault = defaultPatternIn(
readFileSync(SHELL_BACKEND_URL, "utf8"),
);
const scriptDefault = defaultPatternIn(
readFileSync(GENERATE_REGISTRY, "utf8"),
);
expect(runtimeDefault).toBe(scriptDefault);
expect(runtimeDefault).toBe("showcase-{slug}-production.up.railway.app");
});
});
@@ -0,0 +1,272 @@
/**
* E2E tests for self-contained demo pages.
*
* These tests verify that the reverted self-contained demos load correctly
* and show the expected UI components with real content verification.
* They do NOT require a running agent backend -- only the Next.js frontend
* (demos render their UI even without an agent connection, showing loading
* states or empty chat).
*
* Suggestions are rendered client-side by CopilotKit hooks and should be
* visible even without an agent backend.
*
* Note on id/route gap: some demos are described by a canonical manifest id
* (e.g. "hitl-in-chat") but are served from a shorter legacy route path
* (e.g. "/demos/hitl"). Test describe() blocks below use the canonical id
* while page.goto() targets the actual route — this is intentional.
*
* To run with aimock (deterministic LLM responses for agent-dependent tests):
* npx aimock --fixtures showcase/aimock --port 4010 --validate-on-load &
* cd showcase/integrations/langgraph-python
* OPENAI_BASE_URL=http://localhost:4010/v1 OPENAI_API_KEY=test-key pnpm dev &
* cd ../../scripts
* BASE_URL=http://localhost:3000 npx playwright test __tests__/e2e/demo-e2e.spec.ts
*
* To run against a package:
* cd showcase/integrations/langgraph-python
* pnpm dev &
* cd ../../scripts
* BASE_URL=http://localhost:3000 npx playwright test __tests__/e2e/demo-e2e.spec.ts
*/
import { test, expect } from "@playwright/test";
test.describe("Demo: agentic-chat", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/demos/agentic-chat");
// Wait for the chat input to confirm hydration
await expect(
page.locator('textarea[placeholder^="Type a message"]'),
).toBeVisible({
timeout: 15000,
});
});
test("page loads with chat input and styled background container", async ({
page,
}) => {
await expect(
page.locator('[data-testid="background-container"]'),
).toBeVisible();
await expect(
page.locator('[data-testid="background-container"]'),
).toHaveCSS("background-color", "rgb(250, 250, 249)");
});
test("suggestion buttons are rendered with correct text", async ({
page,
}) => {
// The agentic-chat demo configures two suggestions via useConfigureSuggestions
// CopilotKit renders these as clickable elements in the chat UI
await expect(page.getByText("Change background")).toBeVisible({
timeout: 10000,
});
await expect(page.getByText("Generate sonnet")).toBeVisible();
});
test("clicking a suggestion populates the chat input", async ({ page }) => {
// Wait for suggestions to render
const suggestion = page.getByText("Change background");
await expect(suggestion).toBeVisible({ timeout: 10000 });
// Click the suggestion
await suggestion.click();
// The chat input should now contain the suggestion's message text,
// or the message should appear in the chat as a sent message.
//
// The branching below is intentional: CopilotKit's suggestion click
// handler is not deterministic across package implementations — some
// suggestion configurations populate the input (so the user can edit
// before sending), others send immediately. We race against both
// outcomes and assert the matching one. Failures in either branch
// surface concretely: the "populated" branch fails with a substring
// mismatch on the input value, the "sent" branch fails with a
// visibility timeout on the chat message.
//
// Reading inputValue() immediately after click() races the click
// handler — the read can resolve before the handler runs, putting us
// into the "sent" branch incorrectly and producing a confusing 5s
// visibility timeout. Wait until either outcome is observably true
// before branching.
const input = page.locator('textarea[placeholder^="Type a message"]');
await page.waitForFunction(
() => {
const textarea = document.querySelector<HTMLTextAreaElement>(
'textarea[placeholder^="Type a message"]',
);
const hasValue = !!textarea && textarea.value.length > 0;
const hasSentMessage =
document.querySelector(".copilotKitUserMessage") !== null;
return hasValue || hasSentMessage;
},
undefined,
{ timeout: 10000 },
);
const inputValue = await input.inputValue();
if (inputValue) {
// Suggestion populated the input -- verify it contains relevant text
expect(inputValue.toLowerCase()).toContain("background");
} else {
// Suggestion was sent as a message -- verify it appears in the chat
await expect(
page.getByText(/Change the background to something new/i),
).toBeVisible({ timeout: 5000 });
}
});
});
test.describe("Demo: hitl-in-chat (Human in the Loop)", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/demos/hitl");
await expect(
page.locator('textarea[placeholder^="Type a message"]'),
).toBeVisible({
timeout: 15000,
});
});
test("page loads with CopilotChat in a centered max-width container", async ({
page,
}) => {
// The HITL demo renders CopilotChat inside a max-w-4xl centered container
const container = page.locator(".max-w-4xl").first();
await expect(container).toBeVisible();
// The chat input should be inside this container
await expect(
container.locator('textarea[placeholder^="Type a message"]'),
).toBeVisible();
});
test("suggestion buttons are rendered with correct text", async ({
page,
}) => {
// The HITL demo configures two suggestions: "Simple plan" and "Complex plan"
await expect(page.getByText("Simple plan")).toBeVisible({
timeout: 10000,
});
await expect(page.getByText("Complex plan")).toBeVisible();
});
test("clicking a suggestion populates the chat or sends the message", async ({
page,
}) => {
const suggestion = page.getByText("Simple plan");
await expect(suggestion).toBeVisible({ timeout: 10000 });
await suggestion.click();
// Intentional branching: same non-deterministic race as in the
// agentic-chat suite — suggestion click may populate the input or
// send the message immediately depending on the package's
// useConfigureSuggestions wiring. Each branch asserts its own
// concrete outcome so a regression fails with a clear error (either
// an input substring mismatch or a chat message visibility
// timeout), not a generic both-branches-false failure.
//
// Wait for an observable outcome before reading inputValue() — the
// raw read races the click handler and can misroute us into the
// "sent" branch with a confusing 5s timeout.
const input = page.locator('textarea[placeholder^="Type a message"]');
await page.waitForFunction(
() => {
const textarea = document.querySelector<HTMLTextAreaElement>(
'textarea[placeholder^="Type a message"]',
);
const hasValue = !!textarea && textarea.value.length > 0;
const hasSentMessage =
document.querySelector(".copilotKitUserMessage") !== null;
return hasValue || hasSentMessage;
},
undefined,
{ timeout: 10000 },
);
const inputValue = await input.inputValue();
if (inputValue) {
// Populated the input
expect(inputValue.toLowerCase()).toContain("trip to mars");
} else {
// Sent as a message
await expect(page.getByText(/plan a trip to mars/i)).toBeVisible({
timeout: 5000,
});
}
});
});
test.describe("Demo: tool-rendering", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/demos/tool-rendering");
await expect(
page.locator('textarea[placeholder^="Type a message"]'),
).toBeVisible({
timeout: 15000,
});
});
test("page loads with CopilotChat in a centered full-height layout", async ({
page,
}) => {
// The tool-rendering demo wraps CopilotChat in a full-height container
const chatContainer = page.locator(".h-full").first();
await expect(chatContainer).toBeVisible();
});
test("weather suggestion buttons are rendered with specific city text", async ({
page,
}) => {
// The tool-rendering demo configures three weather suggestions with specific cities
await expect(page.getByText("Weather in San Francisco")).toBeVisible({
timeout: 10000,
});
await expect(page.getByText("Weather in New York")).toBeVisible();
await expect(page.getByText("Weather in Tokyo")).toBeVisible();
});
test("clicking a weather suggestion populates the chat or sends the message", async ({
page,
}) => {
const suggestion = page.getByText("Weather in San Francisco");
await expect(suggestion).toBeVisible({ timeout: 10000 });
await suggestion.click();
// Intentional branching: same non-deterministic race as in the
// other demo suites — suggestion click may populate the input or
// send the message immediately. Each branch asserts its own
// concrete outcome so the failure mode (input substring mismatch
// vs. chat-message visibility timeout) stays diagnosable.
//
// Wait for an observable outcome before reading inputValue() — the
// raw read races the click handler and can misroute us into the
// "sent" branch with a confusing 5s timeout.
const input = page.locator('textarea[placeholder^="Type a message"]');
await page.waitForFunction(
() => {
const textarea = document.querySelector<HTMLTextAreaElement>(
'textarea[placeholder^="Type a message"]',
);
const hasValue = !!textarea && textarea.value.length > 0;
const hasSentMessage =
document.querySelector(".copilotKitUserMessage") !== null;
return hasValue || hasSentMessage;
},
undefined,
{ timeout: 10000 },
);
const inputValue = await input.inputValue();
if (inputValue) {
// Populated the input with the weather question
expect(inputValue.toLowerCase()).toContain("san francisco");
} else {
// Sent as a message in the chat
await expect(page.getByText(/weather.*san francisco/i)).toBeVisible({
timeout: 5000,
});
}
});
});
@@ -0,0 +1,162 @@
// NEVER trust DOM measurements alone — always visually inspect screenshots.
/**
* Screenshot verification tests for visual layout validation.
*
* Takes screenshots at 3 viewports (iPhone SE, iPhone 14 Pro Max, Desktop)
* for both the starter homepage and self-contained demo pages.
*
* Screenshots are saved to test-results/screenshots/ for manual inspection.
*
* Note on DEMOS below: the manifest id (e.g. `hitl-in-chat`) and the route
* the demo is served from (e.g. `/demos/hitl`) intentionally differ in some
* cases — the id is the canonical feature identifier shared across all 17
* packages, while the route is the per-package URL path and is allowed to
* use the shorter legacy slug. Both values are kept here so the screenshot
* filenames reflect the canonical id while the navigation targets the real
* route.
*
* To run:
* cd showcase/scripts
* BASE_URL=http://localhost:3000 npx playwright test __tests__/e2e/screenshots.spec.ts
*/
import { test, expect } from "@playwright/test";
const VIEWPORTS = [
{ name: "iphone-se", width: 375, height: 667 },
{ name: "iphone-14-pro-max", width: 430, height: 932 },
{ name: "desktop", width: 1440, height: 900 },
] as const;
const RENDERER_MODES = [
{ name: "Tool-Based", pill: /Tool-Based/ },
{ name: "A2UI", pill: /A2UI Catalog/ },
{ name: "json-render", pill: /json-render/ },
{ name: "HashBrown", pill: /HashBrown/ },
] as const;
const DEMOS = [
{
slug: "agentic-chat",
path: "/demos/agentic-chat",
waitFor: '[data-testid="background-container"]',
},
{
slug: "hitl-in-chat",
path: "/demos/hitl",
waitFor: 'textarea[placeholder^="Type a message"]',
},
{
slug: "tool-rendering",
path: "/demos/tool-rendering",
waitFor: 'textarea[placeholder^="Type a message"]',
},
{
slug: "gen-ui-agent",
path: "/demos/gen-ui-agent",
waitFor: 'textarea[placeholder^="Type a message"]',
},
{
slug: "gen-ui-tool-based",
path: "/demos/gen-ui-tool-based",
waitFor: 'textarea[placeholder^="Type a message"]',
},
{
slug: "shared-state-read",
path: "/demos/shared-state-read",
waitFor: 'textarea[placeholder^="Type a message"]',
},
{
slug: "shared-state-write",
path: "/demos/shared-state-write",
waitFor: 'textarea[placeholder^="Type a message"]',
},
{
slug: "shared-state-streaming",
path: "/demos/shared-state-streaming",
waitFor: 'textarea[placeholder^="Type a message"]',
},
{
slug: "subagents",
path: "/demos/subagents",
waitFor: 'textarea[placeholder^="Type a message"]',
},
] as const;
// ---------------------------------------------------------------------------
// Starter homepage screenshots (with renderer switching)
// ---------------------------------------------------------------------------
for (const viewport of VIEWPORTS) {
test.describe(`Starter screenshots @ ${viewport.name} (${viewport.width}x${viewport.height})`, () => {
test.use({
viewport: { width: viewport.width, height: viewport.height },
});
test("homepage loads and renders", async ({ page }) => {
await page.goto("/");
await expect(page.getByText("CopilotKit Sales Dashboard")).toBeVisible({
timeout: 15000,
});
await page.screenshot({
path: `test-results/screenshots/${viewport.name}-homepage.png`,
fullPage: true,
});
});
for (const mode of RENDERER_MODES) {
test(`${mode.name} renderer`, async ({ page }) => {
await page.goto("/");
await expect(page.getByText("CopilotKit Sales Dashboard")).toBeVisible({
timeout: 15000,
});
const pill = page.getByRole("radio", { name: mode.pill });
await pill.click();
await expect(pill).toHaveAttribute("aria-checked", "true");
// Allow renderer to mount. The radio's aria-checked flip happens
// synchronously on click, but the actual renderer swap is async
// (dynamic import + mount of the selected UI tree). No single
// stable data-testid spans all four renderer modes today, so we
// fall back to a short sleep here. If a common renderer-root
// data-testid is introduced, replace this with an explicit
// `expect(...).toBeVisible()` on that selector.
await page.waitForTimeout(1000);
await page.screenshot({
path: `test-results/screenshots/${viewport.name}-${mode.name.toLowerCase()}.png`,
fullPage: true,
});
});
}
});
}
// ---------------------------------------------------------------------------
// Demo page screenshots
// ---------------------------------------------------------------------------
for (const viewport of VIEWPORTS) {
test.describe(`Demo screenshots @ ${viewport.name} (${viewport.width}x${viewport.height})`, () => {
test.use({
viewport: { width: viewport.width, height: viewport.height },
});
for (const demo of DEMOS) {
test(`${demo.slug}`, async ({ page }) => {
await page.goto(demo.path);
await page.locator(demo.waitFor).first().waitFor({
state: "visible",
timeout: 15000,
});
await page.screenshot({
path: `test-results/screenshots/${viewport.name}-demo-${demo.slug}.png`,
fullPage: true,
});
});
}
});
}
@@ -0,0 +1,249 @@
/**
* E2E tests for the Sales Dashboard starter.
*
* These tests verify that the starter's main page loads correctly,
* the renderer selector works, and each renderer strategy shows
* DIFFERENT content appropriate to that mode. They also exercise
* real user interactions (clicking "Add a deal", switching modes).
*
* They do NOT require a running agent backend -- only the Next.js frontend.
*
* To run with aimock (deterministic LLM responses for agent-dependent tests):
* npx aimock --fixtures showcase/aimock --port 4010 --validate-on-load &
* cd showcase/integrations/langgraph-python
* OPENAI_BASE_URL=http://localhost:4010/v1 OPENAI_API_KEY=test-key npm run dev &
* cd ../../scripts
* BASE_URL=http://localhost:3000 npx playwright test __tests__/e2e/starter-e2e.spec.ts
*
* Or against an extracted starter:
* npx tsx showcase/scripts/extract-starter.ts langgraph-python /tmp/starter
* cd /tmp/starter && npm install && npm run dev &
* cd showcase/scripts
* BASE_URL=http://localhost:3000 npx playwright test __tests__/e2e/starter-e2e.spec.ts
*/
import { test, expect } from "@playwright/test";
test.describe("Starter: Sales Dashboard", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/");
// Wait for the header to confirm the page has hydrated
await expect(page.getByText("CopilotKit Sales Dashboard")).toBeVisible({
timeout: 15000,
});
});
// -------------------------------------------------------------------------
// Page load & renderer selector basics
// -------------------------------------------------------------------------
test("page loads with header and renderer selector with 4 pills", async ({
page,
}) => {
const pills = page.locator("[role='radio']");
await expect(pills).toHaveCount(4);
await expect(page.getByRole("radio", { name: /Tool-Based/ })).toBeVisible();
await expect(
page.getByRole("radio", { name: /A2UI Catalog/ }),
).toBeVisible();
await expect(
page.getByRole("radio", { name: /json-render/ }),
).toBeVisible();
await expect(page.getByRole("radio", { name: /HashBrown/ })).toBeVisible();
});
test("default selection is Tool-Based with pipeline content visible", async ({
page,
}) => {
const toolBasedPill = page.getByRole("radio", { name: /Tool-Based/ });
await expect(toolBasedPill).toHaveAttribute("aria-checked", "true");
// The SalesDashboard must show the pipeline heading and KPI cards
await expect(page.getByText("Sales Pipeline")).toBeVisible({
timeout: 15000,
});
await expect(page.getByText("Total Pipeline")).toBeVisible();
});
// -------------------------------------------------------------------------
// Tool-Based mode: full dashboard verification
// -------------------------------------------------------------------------
test("Tool-Based mode renders pipeline heading, KPI cards, and empty state", async ({
page,
}) => {
await page.getByRole("radio", { name: /Tool-Based/ }).click();
// Pipeline heading
await expect(page.getByText("Sales Pipeline")).toBeVisible({
timeout: 15000,
});
// KPI metric cards: Total Pipeline + the 3 non-closed stages
await expect(page.getByText("Total Pipeline")).toBeVisible();
await expect(page.getByText("Prospect")).toBeVisible();
await expect(page.getByText("Qualified")).toBeVisible();
await expect(page.getByText("Proposal")).toBeVisible();
// Empty state when no deals exist
await expect(page.getByText("No deals yet")).toBeVisible();
await expect(page.getByText("Add a deal")).toBeVisible();
});
// -------------------------------------------------------------------------
// A2UI mode: same SalesDashboard content with A2UI catalog
// -------------------------------------------------------------------------
test("A2UI mode renders the same SalesDashboard content", async ({
page,
}) => {
await page.getByRole("radio", { name: /A2UI Catalog/ }).click();
await expect(
page.getByRole("radio", { name: /A2UI Catalog/ }),
).toHaveAttribute("aria-checked", "true");
// Should still show pipeline and KPI cards (same SalesDashboard component)
await expect(page.getByText("Sales Pipeline")).toBeVisible({
timeout: 15000,
});
await expect(page.getByText("Total Pipeline")).toBeVisible();
await expect(page.getByText("No deals yet")).toBeVisible();
});
// -------------------------------------------------------------------------
// json-render mode: yellow fallback note + tool-based content underneath
// -------------------------------------------------------------------------
test("json-render mode shows yellow fallback note with tool-based content underneath", async ({
page,
}) => {
await page.getByRole("radio", { name: /json-render/ }).click();
await expect(
page.getByRole("radio", { name: /json-render/ }),
).toHaveAttribute("aria-checked", "true");
// The yellow fallback banner
await expect(
page.getByText("json-render is not yet available"),
).toBeVisible({ timeout: 10000 });
// Tool-based content should still be rendered underneath
await expect(page.getByText("Sales Pipeline")).toBeVisible();
await expect(page.getByText("Total Pipeline")).toBeVisible();
});
// -------------------------------------------------------------------------
// HashBrown mode: renders SalesDashboard with HashBrown message renderer
// -------------------------------------------------------------------------
test("HashBrown mode renders SalesDashboard with pipeline content", async ({
page,
}) => {
await page.getByRole("radio", { name: /HashBrown/ }).click();
await expect(
page.getByRole("radio", { name: /HashBrown/ }),
).toHaveAttribute("aria-checked", "true");
// HashBrown wraps the same SalesDashboard, so pipeline content is visible
await expect(page.getByText("Sales Pipeline")).toBeVisible({
timeout: 15000,
});
await expect(page.getByText("Total Pipeline")).toBeVisible();
});
// -------------------------------------------------------------------------
// Critical: switching modes actually changes visible content
// -------------------------------------------------------------------------
test("switching to json-render shows fallback note that other modes lack", async ({
page,
}) => {
// Tool-Based should NOT have the json-render fallback note
await page.getByRole("radio", { name: /Tool-Based/ }).click();
await expect(page.getByText("Sales Pipeline")).toBeVisible({
timeout: 15000,
});
await expect(
page.getByText("json-render is not yet available"),
).not.toBeVisible();
// Switch to json-render -- the note appears
await page.getByRole("radio", { name: /json-render/ }).click();
await expect(
page.getByText("json-render is not yet available"),
).toBeVisible({ timeout: 10000 });
// Pipeline should still be visible underneath
await expect(page.getByText("Sales Pipeline")).toBeVisible();
});
// -------------------------------------------------------------------------
// Critical: Add a deal interaction works (local React state, no agent)
// -------------------------------------------------------------------------
test("clicking Add a deal creates a new deal card and removes empty state", async ({
page,
}) => {
await page.getByRole("radio", { name: /Tool-Based/ }).click();
// Verify empty state is shown
await expect(page.getByText("No deals yet")).toBeVisible({
timeout: 15000,
});
// Click the "Add a deal" button
await page.getByRole("button", { name: "Add a deal" }).click();
// After adding, "No deals yet" should be gone
await expect(page.getByText("No deals yet")).not.toBeVisible();
// A new deal card with the default title "New Deal" should appear
await expect(page.getByText("New Deal")).toBeVisible();
// The "Active Deals" column header should be visible
await expect(page.getByText("Active Deals")).toBeVisible();
});
test("adding multiple deals shows correct count", async ({ page }) => {
await page.getByRole("radio", { name: /Tool-Based/ }).click();
await expect(page.getByText("No deals yet")).toBeVisible({
timeout: 15000,
});
// Add first deal via the empty state button
await page.getByRole("button", { name: "Add a deal" }).click();
await expect(page.getByText("Active Deals")).toBeVisible();
// Add second deal via the column header + button (aria-label="Add new deal")
await page.getByRole("button", { name: "Add new deal" }).click();
// Should now show two "New Deal" entries
const dealCards = page.getByText("New Deal");
await expect(dealCards).toHaveCount(2);
});
// -------------------------------------------------------------------------
// Pill mutual exclusion still works (simpler version of original)
// -------------------------------------------------------------------------
test("only one renderer pill is active at a time", async ({ page }) => {
const modes = ["A2UI Catalog", "HashBrown", "json-render", "Tool-Based"];
for (const modeName of modes) {
const pill = page.getByRole("radio", { name: new RegExp(modeName) });
await pill.click();
await expect(pill).toHaveAttribute("aria-checked", "true");
// All other pills should be unchecked
for (const otherMode of modes) {
if (otherMode !== modeName) {
const otherPill = page.getByRole("radio", {
name: new RegExp(otherMode),
});
await expect(otherPill).toHaveAttribute("aria-checked", "false");
}
}
}
});
});
@@ -0,0 +1,74 @@
import { execFileSync, spawnSync } from "node:child_process";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { resolve, join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
const SCRIPT = resolve(__dirname, "..", "emit-railway-envs-json.ts");
describe("emit-railway-envs-json", () => {
let workDir: string;
let outPath: string;
beforeEach(() => {
workDir = mkdtempSync(join(tmpdir(), "emit-railway-envs-"));
outPath = join(workDir, "railway-envs.generated.json");
});
afterEach(() => {
rmSync(workDir, { recursive: true, force: true });
});
it("emits canonical JSON containing every SSOT service", () => {
execFileSync("npx", ["tsx", SCRIPT, `--out=${outPath}`], { stdio: "pipe" });
const json = JSON.parse(readFileSync(outPath, "utf8"));
expect(json.projectId).toBe("6f8c6bff-a80d-4f8f-b78d-50b32bcf4479");
expect(json.envIds.staging).toBe("8edfef02-ea09-4a20-8689-261f21cc2849");
expect(json.envIds.prod).toBe("b14919f4-6417-429f-848d-c6ae2201e04f");
expect(json.services.length).toBe(41);
const docs = json.services.find((s: { name: string }) => s.name === "docs");
expect(docs.domains.staging).toBe("docs.staging.copilotkit.ai");
expect(docs.domains.prod).toBe("docs.copilotkit.ai");
expect(docs.probe.driver).toBe("docs");
});
it("--check passes when on-disk JSON matches SSOT", () => {
execFileSync("npx", ["tsx", SCRIPT, `--out=${outPath}`], { stdio: "pipe" });
const out = execFileSync(
"npx",
["tsx", SCRIPT, "--check", `--out=${outPath}`],
{ stdio: "pipe" },
).toString();
expect(out).toMatch(/up to date/);
});
it("--check FAILS with a staleness diagnostic when on-disk JSON is stale", () => {
execFileSync("npx", ["tsx", SCRIPT, `--out=${outPath}`], { stdio: "pipe" });
const original = readFileSync(outPath, "utf8");
writeFileSync(outPath, original.replace(/docs.copilotkit.ai/g, "x"));
const result = spawnSync(
"npx",
["tsx", SCRIPT, "--check", `--out=${outPath}`],
{ encoding: "utf8" },
);
expect(result.status).toBe(1);
expect(result.stderr).toMatch(/stale/);
});
it("--check FAILS LOUD on non-ENOENT read errors (e.g. EISDIR)", () => {
// Point --out at a directory so readFileSync raises EISDIR (not ENOENT).
// The script must NOT silently treat this as drift; it must exit non-zero
// with a real error written to stderr, distinct from the staleness exit
// (we use exit code 2 vs 1 to make the distinction observable).
const subdir = mkdtempSync(join(workDir, "isdir-"));
const result = spawnSync(
"npx",
["tsx", SCRIPT, "--check", `--out=${subdir}`],
{ encoding: "utf8" },
);
expect(result.status).toBe(2);
expect(result.stderr).toMatch(/EISDIR|illegal operation on a directory/i);
// Must NOT be the staleness message
expect(result.stderr).not.toMatch(/is stale/);
});
});
@@ -0,0 +1,29 @@
import { describe, it, expect } from "vitest";
import fs from "fs";
import os from "os";
import path from "path";
import { execFileSync } from "child_process";
import { execOptsFor } from "./test-cleanup";
import { SCRIPTS_DIR } from "./paths";
describe("extract-starter", () => {
it("adds .copilotkit/ to generated starter .gitignore", () => {
const outDir = fs.mkdtempSync(path.join(os.tmpdir(), "starter-"));
try {
execFileSync(
"npx",
["tsx", "extract-starter.ts", "langgraph-python", outDir],
execOptsFor(SCRIPTS_DIR),
);
const gitignore = fs.readFileSync(
path.join(outDir, ".gitignore"),
"utf-8",
);
expect(gitignore.split(/\r?\n/)).toContain(".copilotkit/");
} finally {
fs.rmSync(outDir, { recursive: true, force: true });
}
});
});
@@ -0,0 +1,9 @@
slug: test-invalid-genui
generative_ui: [unknown-profile]
interaction_modalities: [chat]
demos:
- id: tool-rendering
name: Tool Rendering
description: test
tags: [agent-capabilities]
route: /demos/tool-rendering
@@ -0,0 +1,7 @@
slug: test-missing-genui
demos:
- id: agentic-chat
name: Agentic Chat
description: test
tags: [chat-ui]
route: /demos/agentic-chat
@@ -0,0 +1,4 @@
slug: missing-demo-dir
demos:
- id: chat
name: Chat
@@ -0,0 +1,4 @@
slug: missing-spec
demos:
- id: chat
name: Chat
@@ -0,0 +1 @@
# qa
@@ -0,0 +1,4 @@
slug: ok-pkg
demos:
- id: chat
name: Chat
@@ -0,0 +1 @@
# chat QA
@@ -0,0 +1 @@
// fixture spec
@@ -0,0 +1,4 @@
slug: spec-exceeds
demos:
- id: chat
name: Chat
@@ -0,0 +1 @@
# qa
@@ -0,0 +1 @@
// fixture - legitimate extra spec
@@ -0,0 +1,6 @@
slug: spec-less
demos:
- id: chat
name: Chat
- id: tools
name: Tools
@@ -0,0 +1 @@
# qa
@@ -0,0 +1 @@
# qa
@@ -0,0 +1 @@
// fixture
@@ -0,0 +1,126 @@
[WARN] google-adk: skipped python-dotenv in showcase/integrations/google-adk/requirements.txt: name-only requirement (no version)
[WARN] google-adk: skipped pydantic in showcase/integrations/google-adk/requirements.txt: name-only requirement (no version)
[WARN] google-adk: skipped google-adk in showcase/integrations/google-adk/requirements.txt: name-only requirement (no version)
[WARN] google-adk: skipped ag-ui-adk in showcase/integrations/google-adk/requirements.txt: name-only requirement (no version)
[WARN] google-adk: skipped fastapi in examples/integrations/adk/agent/pyproject.toml: name-only requirement (no version)
[WARN] google-adk: skipped uvicorn in examples/integrations/adk/agent/pyproject.toml: name-only requirement (no version)
[WARN] google-adk: skipped python-dotenv in examples/integrations/adk/agent/pyproject.toml: name-only requirement (no version)
[WARN] google-adk: skipped pydantic in examples/integrations/adk/agent/pyproject.toml: name-only requirement (no version)
[WARN] google-adk: skipped google-adk in examples/integrations/adk/agent/pyproject.toml: name-only requirement (no version)
[WARN] google-adk: skipped google-genai in examples/integrations/adk/agent/pyproject.toml: name-only requirement (no version)
[WARN] google-adk: skipped ag-ui-adk in examples/integrations/adk/agent/pyproject.toml: name-only requirement (no version)
[WARN] ms-agent-python: skipped python-dotenv in examples/integrations/ms-agent-framework-python/agent/pyproject.toml: name-only requirement (no version)
[WARN] pydantic-ai: skipped uvicorn in examples/integrations/pydantic-ai/agent/pyproject.toml: name-only requirement (no version)
[WARN] pydantic-ai: skipped pydantic-ai-slim in examples/integrations/pydantic-ai/agent/pyproject.toml: name-only requirement (no version)
[WARN] pydantic-ai: skipped pydantic-ai-slim in examples/integrations/pydantic-ai/agent/pyproject.toml: name-only requirement (no version)
[WARN] pydantic-ai: skipped python-dotenv in examples/integrations/pydantic-ai/agent/pyproject.toml: name-only requirement (no version)
[FAIL] agno: ag-ui-protocol non-exact spec (showcase=>=0.1.8, Dojo=>=0.1.8)
[FAIL] agno: agno non-exact spec (showcase=>=2.5.17, Dojo=>=1.7.8)
[FAIL] agno: openai non-exact spec (showcase=>=1.88.0, Dojo=>=1.88.0)
[FAIL] agno: @ag-ui/client non-exact spec (showcase=^0.0.43, Dojo=0.0.52)
[FAIL] agno: @copilotkit/a2ui-renderer is not an exact pin in showcase (next)
[FAIL] agno: @copilotkit/react-core non-exact spec (showcase=next, Dojo=1.55.2)
[FAIL] agno: @copilotkit/react-ui absent in showcase but Dojo pins 1.55.2
[FAIL] agno: @copilotkit/runtime non-exact spec (showcase=next, Dojo=1.55.2)
[FAIL] crewai-crews: ag-ui-crewai non-exact spec (showcase=>=0.2.0,<0.3.0, Dojo=>=0.2.0,<0.3.0)
[FAIL] crewai-crews: ag-ui-protocol non-exact spec (showcase=>=0.1.5, Dojo=>=0.1.10)
[FAIL] crewai-crews: crewai non-exact spec (showcase=>=0.130.0, Dojo===0.130.0)
[FAIL] crewai-crews: crewai-tools non-exact spec (showcase=>=0.38.1, Dojo=~=0.47.1)
[FAIL] crewai-crews: @ag-ui/client is not an exact pin in showcase (^0.0.43)
[FAIL] crewai-crews: @ag-ui/crewai absent in showcase but Dojo pins ^0.0.2
[FAIL] crewai-crews: @copilotkit/a2ui-renderer is not an exact pin in showcase (next)
[FAIL] crewai-crews: @copilotkit/react-core non-exact spec (showcase=next, Dojo=1.55.2)
[FAIL] crewai-crews: @copilotkit/react-ui absent in showcase but Dojo pins 1.55.2
[FAIL] crewai-crews: @copilotkit/runtime non-exact spec (showcase=next, Dojo=1.55.2)
[FAIL] google-adk: google-genai is not an exact pin in showcase (>=0.8.0)
[FAIL] google-adk: @ag-ui/client non-exact spec (showcase=^0.0.43, Dojo=0.0.52)
[FAIL] google-adk: @copilotkit/a2ui-renderer is not an exact pin in showcase (next)
[FAIL] google-adk: @copilotkit/react-core non-exact spec (showcase=next, Dojo=1.55.2)
[FAIL] google-adk: @copilotkit/react-ui absent in showcase but Dojo pins 1.55.2
[FAIL] google-adk: @copilotkit/runtime non-exact spec (showcase=next, Dojo=1.55.2)
[FAIL] langgraph-fastapi: ag-ui-langgraph absent in showcase but Dojo pins ==0.0.22
[FAIL] langgraph-fastapi: copilotkit non-exact spec (showcase=>=0.1.86, Dojo===0.1.74)
[FAIL] langgraph-fastapi: langchain non-exact spec (showcase=>=1.2.0, Dojo===1.0.1)
[FAIL] langgraph-fastapi: langchain-anthropic is not an exact pin in showcase (>=1.3.4)
[FAIL] langgraph-fastapi: langchain-openai non-exact spec (showcase=>=1.1.0, Dojo===1.0.1)
[FAIL] langgraph-fastapi: langgraph non-exact spec (showcase=>=1.1.0, Dojo===1.0.1)
[FAIL] langgraph-fastapi: langgraph-api is not an exact pin in showcase (>=0.7.70)
[FAIL] langgraph-fastapi: langgraph-cli is not an exact pin in showcase (>=0.4.11)
[FAIL] langgraph-fastapi: openai absent in showcase but Dojo pins ==1.109.1
[FAIL] langgraph-fastapi: @copilotkit/a2ui-renderer is not an exact pin in showcase (next)
[FAIL] langgraph-fastapi: @copilotkit/react-core non-exact spec (showcase=next, Dojo=1.55.2)
[FAIL] langgraph-fastapi: @copilotkit/react-ui absent in showcase but Dojo pins 1.55.2
[FAIL] langgraph-fastapi: @copilotkit/runtime non-exact spec (showcase=next, Dojo=1.55.2)
[FAIL] langgraph-python: copilotkit non-exact spec (showcase=>=0.1.86, Dojo===0.1.87)
[FAIL] langgraph-python: langchain non-exact spec (showcase=>=1.2.0,<2.0.0, Dojo===1.2.15)
[FAIL] langgraph-python: langchain-anthropic non-exact spec (showcase=>=1.3.4,<2.0.0, Dojo=>=1.3.4)
[FAIL] langgraph-python: langchain-openai non-exact spec (showcase=>=1.1.0,<2.0.0, Dojo=>=1.1.0)
[FAIL] langgraph-python: langgraph non-exact spec (showcase=>=1.1.0,<2.0.0, Dojo===1.1.6)
[FAIL] langgraph-python: langgraph-api non-exact spec (showcase=>=0.7.70,<0.8.0, Dojo=>=0.7.70)
[FAIL] langgraph-python: langgraph-cli non-exact spec (showcase=>=0.4.11,<0.5.0, Dojo=>=0.4.11)
[FAIL] langgraph-python: langsmith absent in showcase but Dojo pins >=0.4.49
[FAIL] langgraph-python: openai absent in showcase but Dojo pins >=1.68.2,<2.0.0
[FAIL] langgraph-python: @copilotkit/a2ui-renderer non-exact spec (showcase=next, Dojo=1.56.2)
[FAIL] langgraph-python: @copilotkit/react-core non-exact spec (showcase=next, Dojo=1.56.2)
[FAIL] langgraph-python: @copilotkit/react-ui absent in showcase but Dojo pins 1.56.2
[FAIL] langgraph-python: @copilotkit/runtime non-exact spec (showcase=next, Dojo=1.56.2)
[FAIL] langgraph-python: @copilotkit/shared absent in showcase but Dojo pins 1.56.2
[FAIL] langgraph-typescript: @copilotkit/a2ui-renderer is not an exact pin in showcase (next)
[FAIL] langgraph-typescript: @copilotkit/react-core non-exact spec (showcase=next, Dojo=1.56.2)
[FAIL] langgraph-typescript: @copilotkit/react-ui absent in showcase but Dojo pins 1.56.2
[FAIL] langgraph-typescript: @copilotkit/runtime non-exact spec (showcase=next, Dojo=1.56.2)
[FAIL] langgraph-typescript: @copilotkit/sdk-js pinned to 1.51.4, Dojo has 1.56.2
[FAIL] langgraph-typescript: @langchain/core non-exact spec (showcase=^1.0.1, Dojo=^1.0.1)
[FAIL] langgraph-typescript: @langchain/openai non-exact spec (showcase=^1.1.3, Dojo=^1.1.3)
[FAIL] langgraph-typescript: langchain absent in showcase but Dojo pins ^1.0.0
[FAIL] llamaindex: llama-index-core non-exact spec (showcase===0.14.4, Dojo=>=0.14,<0.15)
[FAIL] llamaindex: llama-index-llms-openai non-exact spec (showcase===0.5.6, Dojo=>=0.5.0,<0.6.0)
[FAIL] llamaindex: llama-index-protocols-ag-ui non-exact spec (showcase===0.2.2, Dojo=>=0.2.2)
[FAIL] llamaindex: @ag-ui/client is not an exact pin in showcase (^0.0.43)
[FAIL] llamaindex: @ag-ui/llamaindex absent in showcase but Dojo pins 0.1.5
[FAIL] llamaindex: @copilotkit/a2ui-renderer is not an exact pin in showcase (next)
[FAIL] llamaindex: @copilotkit/react-core non-exact spec (showcase=next, Dojo=1.55.2)
[FAIL] llamaindex: @copilotkit/react-ui absent in showcase but Dojo pins 1.55.2
[FAIL] llamaindex: @copilotkit/runtime non-exact spec (showcase=next, Dojo=1.55.2)
[FAIL] mastra: @ag-ui/client absent in showcase but Dojo pins 0.0.52
[FAIL] mastra: @ag-ui/mastra non-exact spec (showcase=beta, Dojo=beta)
[FAIL] mastra: @ai-sdk/openai non-exact spec (showcase=^2.0.42, Dojo=^2.0.42)
[FAIL] mastra: @copilotkit/a2ui-renderer is not an exact pin in showcase (next)
[FAIL] mastra: @copilotkit/react-core non-exact spec (showcase=next, Dojo=1.55.2)
[FAIL] mastra: @copilotkit/react-ui absent in showcase but Dojo pins 1.55.2
[FAIL] mastra: @copilotkit/runtime non-exact spec (showcase=next, Dojo=1.55.2)
[FAIL] mastra: @mastra/client-js non-exact spec (showcase=beta, Dojo=beta)
[FAIL] mastra: @mastra/core non-exact spec (showcase=beta, Dojo=beta)
[FAIL] mastra: @mastra/libsql non-exact spec (showcase=beta, Dojo=beta)
[FAIL] mastra: @mastra/memory non-exact spec (showcase=beta, Dojo=beta)
[FAIL] mastra: ai is not an exact pin in showcase (^4.0.0)
[FAIL] mastra: mastra non-exact spec (showcase=beta, Dojo=beta)
[FAIL] ms-agent-dotnet: @ag-ui/client non-exact spec (showcase=^0.0.43, Dojo=0.0.52)
[FAIL] ms-agent-dotnet: @copilotkit/a2ui-renderer is not an exact pin in showcase (next)
[FAIL] ms-agent-dotnet: @copilotkit/react-core non-exact spec (showcase=next, Dojo=1.55.2)
[FAIL] ms-agent-dotnet: @copilotkit/react-ui absent in showcase but Dojo pins 1.55.2
[FAIL] ms-agent-dotnet: @copilotkit/runtime non-exact spec (showcase=next, Dojo=1.55.2)
[FAIL] ms-agent-python: agent-framework-ag-ui non-exact spec (showcase=>=1.0.0b251117, Dojo=>=1.0.0b251117)
[FAIL] ms-agent-python: agent-framework-openai is not an exact pin in showcase (>=1.0.0rc6)
[FAIL] ms-agent-python: langchain-core is not an exact pin in showcase (>=0.3.0)
[FAIL] ms-agent-python: langchain-openai is not an exact pin in showcase (>=1.1.0)
[FAIL] ms-agent-python: @ag-ui/client non-exact spec (showcase=^0.0.43, Dojo=0.0.52)
[FAIL] ms-agent-python: @copilotkit/a2ui-renderer is not an exact pin in showcase (next)
[FAIL] ms-agent-python: @copilotkit/react-core non-exact spec (showcase=next, Dojo=1.55.2)
[FAIL] ms-agent-python: @copilotkit/react-ui absent in showcase but Dojo pins 1.55.2
[FAIL] ms-agent-python: @copilotkit/runtime non-exact spec (showcase=next, Dojo=1.55.2)
[FAIL] pydantic-ai: @ag-ui/client non-exact spec (showcase=^0.0.43, Dojo=0.0.52)
[FAIL] pydantic-ai: @copilotkit/a2ui-renderer is not an exact pin in showcase (next)
[FAIL] pydantic-ai: @copilotkit/react-core non-exact spec (showcase=next, Dojo=1.55.2)
[FAIL] pydantic-ai: @copilotkit/react-ui absent in showcase but Dojo pins 1.55.2
[FAIL] pydantic-ai: @copilotkit/runtime non-exact spec (showcase=next, Dojo=1.55.2)
[FAIL] pydantic-ai: @copilotkit/shared absent in showcase but Dojo pins 1.55.2
[FAIL] strands: ag-ui-protocol non-exact spec (showcase=>=0.1.5, Dojo=>=0.1.5)
[FAIL] strands: ag_ui_strands non-exact spec (showcase===0.1.0, Dojo=~=0.1.0)
[FAIL] strands: strands-agents non-exact spec (showcase=>=1.15.0, Dojo=>=1.15.0)
[FAIL] strands: strands-agents-tools non-exact spec (showcase=>=0.2.14, Dojo=>=0.2.14)
[FAIL] strands: @ag-ui/client non-exact spec (showcase=^0.0.43, Dojo=0.0.52)
[FAIL] strands: @copilotkit/a2ui-renderer is not an exact pin in showcase (next)
[FAIL] strands: @copilotkit/react-core non-exact spec (showcase=next, Dojo=1.55.2)
[FAIL] strands: @copilotkit/react-ui absent in showcase but Dojo pins 1.55.2
[FAIL] strands: @copilotkit/runtime non-exact spec (showcase=next, Dojo=1.55.2)
@@ -0,0 +1,7 @@
[SKIP] ag2: born-in-showcase (no Dojo example)
[SKIP] claude-sdk-python: born-in-showcase (no Dojo example)
[SKIP] claude-sdk-typescript: born-in-showcase (no Dojo example)
[SKIP] langroid: born-in-showcase (no Dojo example)
[SKIP] spring-ai: born-in-showcase (no Dojo example)
Summary: OK=0 SKIP=5 WARN=16 FAIL=110
@@ -0,0 +1,6 @@
{
"_comment": "Drift ratchet baseline for showcase_validate.yml. `validatePinsFailCount` holds the last-known FAIL count; `validatePinsFailHash` is a SHA-256 of the sorted, deduplicated `[FAIL] ...` lines from validate-pins.ts. CI compares BOTH: if the count changes, it tells you to ratchet (up rejected, down instructed). If the count is equal but the hash differs, the FAIL *set* has drifted (one item fixed, another regressed) and CI fails with a diff. Never raise the count without an explicit review + sign-off. `baselineDemoCount` is the per-package e2e-spec-count floor (single source of truth consumed by both the workflow and validate-parity.ts). NOTE: validate-parity.ts `BASELINE_DEMO_COUNT` default must match `baselineDemoCount` here; keep them in sync. See .github/workflows/showcase_validate.yml 'Run validate-pins (ratchet)' step.",
"validatePinsFailCount": 110,
"validatePinsFailHash": "aa3dd37613b3e90d7eb07c75d132c7a57731e6b4d7f6423f0a9bc43653e482d3",
"baselineDemoCount": 9
}
@@ -0,0 +1,15 @@
{
"name": "sample",
"version": "0.0.1",
"dependencies": {
"next": "15.0.0",
"@copilotkit/react-core": "1.10.0"
},
"devDependencies": {
"@langchain/langgraph": "0.2.14",
"typescript": "5.0.0"
},
"peerDependencies": {
"@ag-ui/core": "0.0.9"
}
}
@@ -0,0 +1,12 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "sample"
version = "0.0.1"
optional-dependencies = ["ruff==0.4.0"]
dependencies = ["langgraph==0.2.14", "copilotkit==1.10.0"]
[project.optional-dependencies]
dev = ["pytest==8.0.0"]
@@ -0,0 +1,12 @@
[tool.poetry]
name = "sample"
version = "0.0.1"
[tool.poetry.dependencies]
python = "^3.10"
langgraph = "0.2.14"
copilotkit = "1.10.0"
pydantic-ai = "0.0.20"
[tool.poetry.dev-dependencies]
pytest = "8.0.0"
@@ -0,0 +1,10 @@
[project]
name = "sample"
version = "0.0.1"
dependencies = ["langgraph==0.2.14", "copilotkit==1.10.0"]
[project.optional-dependencies]
dev = ["pytest==8.0.0"]
[project.scripts]
mycli = "sample.cli:main"
@@ -0,0 +1,658 @@
{
"aimock": {
"prod": {
"instanceId": "5801d8be-5ad9-4eff-9c9c-7be61d9a023e",
"domain": "showcase-aimock-production.up.railway.app",
"probe": true,
"driver": "aimock",
"repoName": "showcase-aimock"
},
"staging": {
"instanceId": "9f260dfd-d9d4-43e9-98fe-49696f87fe50",
"domain": "aimock-staging.up.railway.app",
"probe": true,
"driver": "aimock",
"repoName": "showcase-aimock"
}
},
"dashboard": {
"prod": {
"instanceId": "e68f98fa-b2ef-41cc-82f6-2ed6f9533bf3",
"domain": "dashboard.showcase.copilotkit.ai",
"probe": true,
"driver": "dashboard",
"repoName": "showcase-shell-dashboard"
},
"staging": {
"instanceId": "aea7332e-17a0-4fab-921c-ed5baad2a6f2",
"domain": "dashboard.showcase.staging.copilotkit.ai",
"probe": true,
"driver": "dashboard",
"repoName": "showcase-shell-dashboard"
}
},
"docs": {
"prod": {
"instanceId": "b15564fc-f832-49b3-82df-fd36f298fe96",
"domain": "docs.copilotkit.ai",
"probe": true,
"driver": "docs",
"repoName": "showcase-shell-docs"
},
"staging": {
"instanceId": "d5caa51d-73ee-4669-bfea-d87bf1488b02",
"domain": "docs.staging.copilotkit.ai",
"probe": true,
"driver": "docs",
"repoName": "showcase-shell-docs"
}
},
"dojo": {
"prod": {
"instanceId": "2ee4f2aa-11ec-4426-9a4a-41a1ad04f16d",
"domain": "dojo.showcase.copilotkit.ai",
"probe": true,
"driver": "dojo",
"repoName": "showcase-shell-dojo"
},
"staging": {
"instanceId": "1284d717-0ff5-432c-9326-fab12661df61",
"domain": "dojo.showcase.staging.copilotkit.ai",
"probe": true,
"driver": "dojo",
"repoName": "showcase-shell-dojo"
}
},
"harness": {
"prod": {
"instanceId": "05fbcdf2-8a50-4b71-b4f6-c92c4b17e626",
"domain": "showcase-harness-production.up.railway.app",
"probe": true,
"driver": "harness",
"repoName": "showcase-harness"
},
"staging": {
"instanceId": "0811f68f-fac4-440e-a350-3a7ca5855b80",
"domain": "harness-staging-2ee4.up.railway.app",
"probe": true,
"driver": "harness",
"repoName": "showcase-harness"
}
},
"harness-workers": {
"prod": {
"instanceId": "7c48ee43-6df4-457b-b977-10f1f1ac1680",
"domain": null,
"probe": false,
"driver": "harness",
"repoName": "showcase-harness"
},
"staging": {
"instanceId": "362c1e37-5f40-45f2-ac7b-0e5adac565f8",
"domain": null,
"probe": false,
"driver": "harness",
"repoName": "showcase-harness"
}
},
"pocketbase": {
"prod": {
"instanceId": "1ee376e2-13f2-4464-801e-d0aa0bf76532",
"domain": "showcase-pocketbase-production.up.railway.app",
"probe": true,
"driver": "pocketbase",
"repoName": "showcase-pocketbase"
},
"staging": {
"instanceId": "0bc7db7b-5a43-4b33-af46-d07fb53c8610",
"domain": "pocketbase-staging-eec0.up.railway.app",
"probe": true,
"driver": "pocketbase",
"repoName": "showcase-pocketbase"
}
},
"shell": {
"prod": {
"instanceId": "01614ccf-e109-4b30-b41b-7c5551c0a34c",
"domain": "showcase.copilotkit.ai",
"probe": true,
"driver": "shell",
"repoName": "showcase-shell"
},
"staging": {
"instanceId": "25b7de41-188c-4f2e-ac07-538212eaeb91",
"domain": "showcase.staging.copilotkit.ai",
"probe": true,
"driver": "shell",
"repoName": "showcase-shell"
}
},
"showcase-ag2": {
"prod": {
"instanceId": "de571c97-03fd-486b-8a54-9767a4a53f95",
"domain": "showcase-ag2-production.up.railway.app",
"probe": true,
"driver": "agent",
"repoName": "showcase-ag2"
},
"staging": {
"instanceId": "ecaf81b3-93a8-4862-92b6-04a016b634ed",
"domain": "showcase-ag2-staging.up.railway.app",
"probe": true,
"driver": "agent",
"repoName": "showcase-ag2"
}
},
"showcase-agno": {
"prod": {
"instanceId": "026d12fb-2844-42af-8f92-b47bc8a06bc8",
"domain": "showcase-agno-production.up.railway.app",
"probe": true,
"driver": "agent",
"repoName": "showcase-agno"
},
"staging": {
"instanceId": "68964ab6-75ca-4095-a64a-52cacfb684f5",
"domain": "showcase-agno-staging.up.railway.app",
"probe": true,
"driver": "agent",
"repoName": "showcase-agno"
}
},
"showcase-built-in-agent": {
"prod": {
"instanceId": "40018ef7-1ed1-4979-b80c-9c2d957b6d88",
"domain": "showcase-built-in-agent-production.up.railway.app",
"probe": true,
"driver": "agent",
"repoName": "showcase-built-in-agent"
},
"staging": {
"instanceId": "b89ae7b3-01cc-4ed4-aca6-23aaa63cd59e",
"domain": "showcase-built-in-agent-staging.up.railway.app",
"probe": true,
"driver": "agent",
"repoName": "showcase-built-in-agent"
}
},
"showcase-claude-sdk-python": {
"prod": {
"instanceId": "bb18caaf-9a3e-4fdd-85ec-562fd82a3a89",
"domain": "showcase-claude-sdk-python-production.up.railway.app",
"probe": true,
"driver": "agent",
"repoName": "showcase-claude-sdk-python"
},
"staging": {
"instanceId": "1ef25aec-5fbd-40b9-8685-57c2681bd45d",
"domain": "showcase-claude-sdk-python-staging.up.railway.app",
"probe": true,
"driver": "agent",
"repoName": "showcase-claude-sdk-python"
}
},
"showcase-claude-sdk-typescript": {
"prod": {
"instanceId": "bee425e4-9661-4a88-8888-922b8cd4b61d",
"domain": "showcase-claude-sdk-typescript-production.up.railway.app",
"probe": true,
"driver": "agent",
"repoName": "showcase-claude-sdk-typescript"
},
"staging": {
"instanceId": "92305747-2f55-4122-aad4-882e989558ab",
"domain": "showcase-claude-sdk-typescript-staging.up.railway.app",
"probe": true,
"driver": "agent",
"repoName": "showcase-claude-sdk-typescript"
}
},
"showcase-crewai-crews": {
"prod": {
"instanceId": "3dab0cc3-cab1-4579-b772-947268088514",
"domain": "showcase-crewai-crews-production.up.railway.app",
"probe": true,
"driver": "agent",
"repoName": "showcase-crewai-crews"
},
"staging": {
"instanceId": "88c2a14f-435b-499e-a811-ee4f4be18fd8",
"domain": "showcase-crewai-crews-staging.up.railway.app",
"probe": true,
"driver": "agent",
"repoName": "showcase-crewai-crews"
}
},
"showcase-google-adk": {
"prod": {
"instanceId": "7b2da5db-87d2-40ad-a3d9-b2d7a5485a22",
"domain": "showcase-google-adk-production.up.railway.app",
"probe": true,
"driver": "agent",
"repoName": "showcase-google-adk"
},
"staging": {
"instanceId": "7efe2fa0-fa78-4585-bc4c-6d39c326e6d1",
"domain": "showcase-google-adk-staging.up.railway.app",
"probe": true,
"driver": "agent",
"repoName": "showcase-google-adk"
}
},
"showcase-langgraph-fastapi": {
"prod": {
"instanceId": "105b7e01-acd0-48e2-9a09-541e2103e8d2",
"domain": "showcase-langgraph-fastapi-production.up.railway.app",
"probe": true,
"driver": "agent",
"repoName": "showcase-langgraph-fastapi"
},
"staging": {
"instanceId": "7899afe0-141b-4217-8dbb-5907813231dc",
"domain": "showcase-langgraph-fastapi-staging.up.railway.app",
"probe": true,
"driver": "agent",
"repoName": "showcase-langgraph-fastapi"
}
},
"showcase-langgraph-python": {
"prod": {
"instanceId": "aec504f7-63d7-4ea6-9d50-601b00d2ae80",
"domain": "showcase-langgraph-python-production.up.railway.app",
"probe": true,
"driver": "agent",
"repoName": "showcase-langgraph-python"
},
"staging": {
"instanceId": "04d29664-a776-4670-9db3-b1d18bce1669",
"domain": "showcase-langgraph-python-staging.up.railway.app",
"probe": true,
"driver": "agent",
"repoName": "showcase-langgraph-python"
}
},
"showcase-langgraph-typescript": {
"prod": {
"instanceId": "f53e9fdc-7c3e-4dfd-9fa8-d7241fd55bb8",
"domain": "showcase-langgraph-typescript-production.up.railway.app",
"probe": true,
"driver": "agent",
"repoName": "showcase-langgraph-typescript"
},
"staging": {
"instanceId": "481ab37f-da8a-4015-bd88-2b28d9eb261a",
"domain": "showcase-langgraph-typescript-staging.up.railway.app",
"probe": true,
"driver": "agent",
"repoName": "showcase-langgraph-typescript"
}
},
"showcase-langroid": {
"prod": {
"instanceId": "6b5e20b5-8f8e-4ec3-9288-7a41122e42e5",
"domain": "showcase-langroid-production.up.railway.app",
"probe": true,
"driver": "agent",
"repoName": "showcase-langroid"
},
"staging": {
"instanceId": "a213f7d9-2117-4944-988b-05e68d819dd5",
"domain": "showcase-langroid-staging.up.railway.app",
"probe": true,
"driver": "agent",
"repoName": "showcase-langroid"
}
},
"showcase-llamaindex": {
"prod": {
"instanceId": "b778856e-9f90-4136-9415-fb2b41173f8d",
"domain": "showcase-llamaindex-production.up.railway.app",
"probe": true,
"driver": "agent",
"repoName": "showcase-llamaindex"
},
"staging": {
"instanceId": "17899ea7-355c-43f2-a152-28cb0b7fa864",
"domain": "showcase-llamaindex-staging.up.railway.app",
"probe": true,
"driver": "agent",
"repoName": "showcase-llamaindex"
}
},
"showcase-mastra": {
"prod": {
"instanceId": "eaeddd9c-8b75-426f-b033-0fd935cbf6ef",
"domain": "showcase-mastra-production.up.railway.app",
"probe": true,
"driver": "agent",
"repoName": "showcase-mastra"
},
"staging": {
"instanceId": "eec22411-aab5-47a1-8f5b-d097e233d7f8",
"domain": "showcase-mastra-staging.up.railway.app",
"probe": true,
"driver": "agent",
"repoName": "showcase-mastra"
}
},
"showcase-ms-agent-dotnet": {
"prod": {
"instanceId": "93ca0edf-7b59-4de4-b1fd-3412bb07bc6a",
"domain": "showcase-ms-agent-dotnet-production.up.railway.app",
"probe": true,
"driver": "agent",
"repoName": "showcase-ms-agent-dotnet"
},
"staging": {
"instanceId": "9826bc58-c472-41e6-b050-29249d4b2a52",
"domain": "showcase-ms-agent-dotnet-staging.up.railway.app",
"probe": true,
"driver": "agent",
"repoName": "showcase-ms-agent-dotnet"
}
},
"showcase-ms-agent-harness-dotnet": {
"prod": {
"instanceId": "8f91ebc6-95c0-4433-b1f7-657ff49c2d59",
"domain": "showcase-ms-agent-harness-dotnet-production.up.railway.app",
"probe": true,
"driver": "agent",
"repoName": "showcase-ms-agent-harness-dotnet"
},
"staging": {
"instanceId": "6b0fe181-9156-4a40-9e44-90befe09833a",
"domain": "showcase-ms-agent-harness-dotnet-staging.up.railway.app",
"probe": true,
"driver": "agent",
"repoName": "showcase-ms-agent-harness-dotnet"
}
},
"showcase-ms-agent-python": {
"prod": {
"instanceId": "323ed911-4d28-45ab-8fc0-7d151828b938",
"domain": "showcase-ms-agent-python-production.up.railway.app",
"probe": true,
"driver": "agent",
"repoName": "showcase-ms-agent-python"
},
"staging": {
"instanceId": "741725ce-5fa1-4327-aff5-53dcc000c29c",
"domain": "showcase-ms-agent-python-staging.up.railway.app",
"probe": true,
"driver": "agent",
"repoName": "showcase-ms-agent-python"
}
},
"showcase-pydantic-ai": {
"prod": {
"instanceId": "192cd647-6824-4f01-937a-1da675d83805",
"domain": "showcase-pydantic-ai-production.up.railway.app",
"probe": true,
"driver": "agent",
"repoName": "showcase-pydantic-ai"
},
"staging": {
"instanceId": "6edf5ca5-6a56-4d28-92c3-2a3360c735db",
"domain": "showcase-pydantic-ai-staging.up.railway.app",
"probe": true,
"driver": "agent",
"repoName": "showcase-pydantic-ai"
}
},
"showcase-spring-ai": {
"prod": {
"instanceId": "2fbf1db2-5e51-44c9-983c-3f2242d95c61",
"domain": "showcase-spring-ai-production.up.railway.app",
"probe": true,
"driver": "agent",
"repoName": "showcase-spring-ai"
},
"staging": {
"instanceId": "189ac76f-bd77-45c0-9c45-3853dae763cc",
"domain": "showcase-spring-ai-staging.up.railway.app",
"probe": true,
"driver": "agent",
"repoName": "showcase-spring-ai"
}
},
"showcase-strands": {
"prod": {
"instanceId": "2123c71b-9385-443c-a1c3-bcf4b1669eeb",
"domain": "showcase-strands-production.up.railway.app",
"probe": true,
"driver": "agent",
"repoName": "showcase-strands"
},
"staging": {
"instanceId": "f8a9d2ed-50ec-4f06-85d6-230baced8471",
"domain": "showcase-strands-staging.up.railway.app",
"probe": true,
"driver": "agent",
"repoName": "showcase-strands"
}
},
"showcase-strands-typescript": {
"prod": {
"instanceId": "8a50728e-6119-43c4-b59c-d9535b6717a4",
"domain": "showcase-strands-typescript-production.up.railway.app",
"probe": true,
"driver": "agent",
"repoName": "showcase-strands-typescript"
},
"staging": {
"instanceId": "3f917b9f-c3f0-4d8b-96ca-7f455e06b5ba",
"domain": "showcase-strands-typescript-staging.up.railway.app",
"probe": true,
"driver": "agent",
"repoName": "showcase-strands-typescript"
}
},
"starter-adk": {
"prod": {
"instanceId": "cb23cae4-9555-4ddd-8a62-f1aa1ff72c67",
"domain": "starter-adk-production.up.railway.app",
"probe": true,
"driver": "starter",
"repoName": "starter-adk"
},
"staging": {
"instanceId": "208a160a-0d7d-44b2-a94d-39e13b24e21a",
"domain": "starter-adk-staging.up.railway.app",
"probe": true,
"driver": "starter",
"repoName": "starter-adk"
}
},
"starter-agno": {
"prod": {
"instanceId": "2f58513b-5fe4-4b09-a28f-93d4caa277b5",
"domain": "starter-agno-production.up.railway.app",
"probe": true,
"driver": "starter",
"repoName": "starter-agno"
},
"staging": {
"instanceId": "9944eb97-7f58-47f8-a49d-65603e209609",
"domain": "starter-agno-staging.up.railway.app",
"probe": true,
"driver": "starter",
"repoName": "starter-agno"
}
},
"starter-crewai-crews": {
"prod": {
"instanceId": "1a3e24cb-0752-45c8-b4a2-0c6096899875",
"domain": "starter-crewai-crews-production.up.railway.app",
"probe": true,
"driver": "starter",
"repoName": "starter-crewai-crews"
},
"staging": {
"instanceId": "820895fb-f65c-4834-a07d-d454035d39c4",
"domain": "starter-crewai-crews-staging.up.railway.app",
"probe": true,
"driver": "starter",
"repoName": "starter-crewai-crews"
}
},
"starter-langgraph-fastapi": {
"prod": {
"instanceId": "0679bc18-e9af-40c6-bc17-0b5eb2cd7bec",
"domain": "starter-langgraph-fastapi-production.up.railway.app",
"probe": true,
"driver": "starter",
"repoName": "starter-langgraph-fastapi"
},
"staging": {
"instanceId": "5f10e976-e121-48a5-bc18-2619798f2f10",
"domain": "starter-langgraph-fastapi-staging.up.railway.app",
"probe": true,
"driver": "starter",
"repoName": "starter-langgraph-fastapi"
}
},
"starter-langgraph-js": {
"prod": {
"instanceId": "50a2205b-8768-4765-b7a1-21941c105051",
"domain": "starter-langgraph-js-production.up.railway.app",
"probe": true,
"driver": "starter",
"repoName": "starter-langgraph-js"
},
"staging": {
"instanceId": "43db83fe-fafb-445b-a19a-51bb086c71b9",
"domain": "starter-langgraph-js-staging.up.railway.app",
"probe": true,
"driver": "starter",
"repoName": "starter-langgraph-js"
}
},
"starter-langgraph-python": {
"prod": {
"instanceId": "24dad599-576a-4154-a621-c3af40629a8f",
"domain": "starter-langgraph-python-production.up.railway.app",
"probe": true,
"driver": "starter",
"repoName": "starter-langgraph-python"
},
"staging": {
"instanceId": "58105e79-4020-4692-8749-c1a63ab63f2c",
"domain": "starter-langgraph-python-staging.up.railway.app",
"probe": true,
"driver": "starter",
"repoName": "starter-langgraph-python"
}
},
"starter-llamaindex": {
"prod": {
"instanceId": "c119f40b-dc71-4734-9716-c1085754b085",
"domain": "starter-llamaindex-production.up.railway.app",
"probe": true,
"driver": "starter",
"repoName": "starter-llamaindex"
},
"staging": {
"instanceId": "44446803-0505-456a-b0c4-01fe82fb3832",
"domain": "starter-llamaindex-staging.up.railway.app",
"probe": true,
"driver": "starter",
"repoName": "starter-llamaindex"
}
},
"starter-mastra": {
"prod": {
"instanceId": "c6fba6d8-8dde-442b-948f-560bf25fa2f1",
"domain": "starter-mastra-production.up.railway.app",
"probe": true,
"driver": "starter",
"repoName": "starter-mastra"
},
"staging": {
"instanceId": "b246e52d-52d8-4015-bb06-89bd09d54f8f",
"domain": "starter-mastra-staging.up.railway.app",
"probe": true,
"driver": "starter",
"repoName": "starter-mastra"
}
},
"starter-ms-agent-framework-dotnet": {
"prod": {
"instanceId": "993237a4-9ee7-47b2-a5be-267e247c1409",
"domain": "starter-ms-agent-framework-dotnet-production.up.railway.app",
"probe": true,
"driver": "starter",
"repoName": "starter-ms-agent-framework-dotnet"
},
"staging": {
"instanceId": "6684c246-e8fd-45a7-86e4-c529a439976f",
"domain": "starter-ms-agent-framework-dotnet-staging.up.railway.app",
"probe": true,
"driver": "starter",
"repoName": "starter-ms-agent-framework-dotnet"
}
},
"starter-ms-agent-framework-python": {
"prod": {
"instanceId": "aa934881-340a-4fb7-8b39-9cb0a6f372b2",
"domain": "starter-ms-agent-framework-python-production.up.railway.app",
"probe": true,
"driver": "starter",
"repoName": "starter-ms-agent-framework-python"
},
"staging": {
"instanceId": "a162348a-f768-4c3f-815c-f617819f64e6",
"domain": "starter-ms-agent-framework-python-staging.up.railway.app",
"probe": true,
"driver": "starter",
"repoName": "starter-ms-agent-framework-python"
}
},
"starter-pydantic-ai": {
"prod": {
"instanceId": "74ce36fe-0b8f-446e-8e06-0b6496b6e829",
"domain": "starter-pydantic-ai-production.up.railway.app",
"probe": true,
"driver": "starter",
"repoName": "starter-pydantic-ai"
},
"staging": {
"instanceId": "25f4eb93-501a-4e5e-b7cf-343eb08ea613",
"domain": "starter-pydantic-ai-staging.up.railway.app",
"probe": true,
"driver": "starter",
"repoName": "starter-pydantic-ai"
}
},
"starter-strands-python": {
"prod": {
"instanceId": "4af440e0-ba05-48a5-b922-5b96a033891a",
"domain": "starter-strands-python-production.up.railway.app",
"probe": true,
"driver": "starter",
"repoName": "starter-strands-python"
},
"staging": {
"instanceId": "adc24096-584a-4ef3-93de-0bc92d49235c",
"domain": "starter-strands-python-staging.up.railway.app",
"probe": true,
"driver": "starter",
"repoName": "starter-strands-python"
}
},
"webhooks": {
"prod": {
"instanceId": "d82ef5b4-3bfd-462e-9436-3d5dbca8681a",
"domain": "hooks.showcase.copilotkit.ai",
"probe": true,
"driver": "webhooks",
"repoName": "showcase-eval-webhook"
},
"staging": {
"instanceId": "450e87e0-aba5-4aba-afaf-15f4deab03f0",
"domain": "hooks.showcase.staging.copilotkit.ai",
"probe": true,
"driver": "webhooks",
"repoName": "showcase-eval-webhook"
}
}
}
@@ -0,0 +1,7 @@
name: Fixture Integration
slug: fixture-int
features:
- shipped-ok
- shipped-broken
- base-route
not_supported_features: []
@@ -0,0 +1 @@
export const POST = async () => new Response(null);
@@ -0,0 +1 @@
export const POST = async () => new Response(null);
@@ -0,0 +1,4 @@
"use client";
export default function P() {
return <CopilotKit runtimeUrl="/api/copilotkit" agent="x" />;
}
@@ -0,0 +1,4 @@
"use client";
export default function P() {
return <CopilotKit runtimeUrl="/api/copilotkit-shipped-broken" agent="x" />;
}
@@ -0,0 +1,4 @@
"use client";
export default function P() {
return <CopilotKit runtimeUrl="/api/copilotkit-shipped-ok" agent="x" />;
}
@@ -0,0 +1,4 @@
"use client";
export default function P() {
return <CopilotKit runtimeUrl="/api/copilotkit-unshipped-broken" agent="x" />;
}
@@ -0,0 +1,9 @@
slug: test-unknown-demo
generative_ui: [constrained-explicit]
interaction_modalities: [chat]
demos:
- id: nonexistent-feature
name: Does Not Exist
description: test
tags: [test]
route: /demos/nonexistent
@@ -0,0 +1,14 @@
slug: test-valid
generative_ui: [constrained-explicit]
interaction_modalities: [chat]
demos:
- id: agentic-chat
name: Agentic Chat
description: test
tags: [chat-ui]
route: /demos/agentic-chat
- id: tool-rendering
name: Tool Rendering
description: test
tags: [agent-capabilities]
route: /demos/tool-rendering
@@ -0,0 +1,396 @@
import {
describe,
it,
expect,
beforeAll,
afterAll,
beforeEach,
afterEach,
} from "vitest";
import fs from "fs";
import path from "path";
import { execFileSync } from "child_process";
import {
FileSnapshotRestorer,
acquireGeneratedDataLock,
execOptsFor,
withGeneratedDataLock,
} from "./test-cleanup";
import { SCRIPTS_DIR, SHELL_DATA_DIR } from "./paths";
// catalog.json is emitted alongside registry.json in all 4 output dirs.
// We snapshot the shell output dir to avoid leaking generated files.
const SHELL_DASHBOARD_DATA_DIR = path.resolve(
SCRIPTS_DIR,
"..",
"shell-dashboard",
"src",
"data",
);
const DATA_FILES = [
path.join(SHELL_DATA_DIR, "registry.json"),
path.join(SHELL_DATA_DIR, "constraints.json"),
path.join(SHELL_DATA_DIR, "catalog.json"),
path.join(SHELL_DASHBOARD_DATA_DIR, "registry.json"),
path.join(SHELL_DASHBOARD_DATA_DIR, "catalog.json"),
];
const dataRestorer = new FileSnapshotRestorer(DATA_FILES);
let releaseGeneratedDataLock: (() => void) | undefined;
const EXEC_OPTS = execOptsFor(SCRIPTS_DIR);
function runGenerator(): string {
const out = execFileSync("npx", ["tsx", "generate-registry.ts"], EXEC_OPTS);
return out.toString();
}
function readCatalog(dir: string = SHELL_DATA_DIR): any {
const catalogPath = path.join(dir, "catalog.json");
return JSON.parse(fs.readFileSync(catalogPath, "utf-8"));
}
beforeAll(() =>
withGeneratedDataLock(() => {
runGenerator();
dataRestorer.snapshot();
if (dataRestorer.snapshotMap.size === 0) {
throw new Error(
`generate-catalog.test.ts: data snapshot is empty. Expected generated` +
` files at:\n` +
DATA_FILES.map((p) => ` ${p}`).join("\n"),
);
}
}),
);
beforeEach(() => {
const release = acquireGeneratedDataLock();
try {
dataRestorer.restore();
releaseGeneratedDataLock = release;
} catch (err) {
release();
throw err;
}
});
afterEach(() => {
try {
dataRestorer.restore();
} finally {
releaseGeneratedDataLock?.();
releaseGeneratedDataLock = undefined;
}
});
afterAll(() => withGeneratedDataLock(() => dataRestorer.restore()));
describe("Catalog Generator", () => {
it("output shape matches CatalogData: { metadata, cells }", () => {
runGenerator();
const catalog = readCatalog();
// Top-level keys must be exactly { metadata, cells }
expect(Object.keys(catalog).sort()).toEqual(["cells", "metadata"]);
// metadata must have exactly the CatalogMetadata keys
expect(Object.keys(catalog.metadata).sort()).toEqual([
"docs_only",
"generated_at",
"reference",
"stub",
"total_cells",
"unshipped",
"unsupported",
"wired",
]);
// No legacy top-level keys
expect(catalog).not.toHaveProperty("generated_at");
expect(catalog).not.toHaveProperty("reference_integration");
expect(catalog).not.toHaveProperty("summary");
});
it("emits catalog.json to all output dirs", () => {
runGenerator();
const outputDirs = [
path.resolve(SCRIPTS_DIR, "..", "shell", "src", "data"),
path.resolve(SCRIPTS_DIR, "..", "shell-docs", "src", "data"),
path.resolve(SCRIPTS_DIR, "..", "shell-dojo", "src", "data"),
path.resolve(SCRIPTS_DIR, "..", "shell-dashboard", "src", "data"),
];
for (const dir of outputDirs) {
const catalogPath = path.join(dir, "catalog.json");
expect(
fs.existsSync(catalogPath),
`catalog.json missing from ${dir}`,
).toBe(true);
}
});
it("cross-join produces 920 cells (46 features x 20 integrations); metadata.total_cells excludes docs-only", () => {
runGenerator();
const catalog = readCatalog();
expect(catalog.cells).toBeDefined();
expect(Array.isArray(catalog.cells)).toBe(true);
const integrated = catalog.cells.filter(
(c: any) => c.manifestation === "integrated",
);
const starters = catalog.cells.filter(
(c: any) => c.manifestation === "starter",
);
// 47 features × 20 integrations = 940 cells. The catalog emits cells
// uniformly for all (integration × feature) pairs; deprecated-feature
// visibility is controlled at the dashboard layer via the "Show
// deprecated" toggle in feature-grid.tsx so the catalog stays
// shape-stable. The 47 includes 2 byoc legacy IDs (`byoc-hashbrown`,
// `byoc-json-render`) plus their renamed aliases (`declarative-*`)
// that langgraph-python uses for the visible URL slugs, and the
// `a2ui-recovery` feature (wired for google-adk + langgraph-{python,
// fastapi,typescript} + strands{,-typescript}; unshipped elsewhere).
expect(integrated.length).toBe(940);
expect(starters.length).toBe(0);
expect(catalog.cells.length).toBe(940);
// total_cells excludes docs-only features (currently 1 feature x 20 integrations = 20)
expect(catalog.metadata.total_cells).toBe(920);
expect(catalog.metadata.docs_only).toBe(20);
});
it("LGP has 47 cells: 37 wired + 1 stub + 7 unshipped + 2 unsupported (deprecated features included; dashboard hides them by default)", () => {
runGenerator();
const catalog = readCatalog();
const lgpCells = catalog.cells.filter(
(c: any) =>
c.integration === "langgraph-python" &&
c.manifestation === "integrated",
);
// 46 = 37 LGP-declared features + 2 quarantined interrupt features
// (gen-ui-interrupt / interrupt-headless, now in
// `not_supported_features`) + 4 deprecated features + 2 legacy
// `byoc-*` aliases (LGP declares `declarative-{hashbrown,json-render}`
// for the visible URL slugs while every other integration still
// declares the legacy `byoc-*` IDs; the catalog emits cells for both
// since both are in the registry, and the LGP cells for the legacy
// IDs are `unshipped` because LGP's manifest only declares the
// renamed form) + 1 unshipped for `threadid-frontend-tool-roundtrip`
// (built-in-agent-only feature; LGP doesn't declare it). `a2ui-recovery`
// is now WIRED for LGP (the recovery demo shipped across langgraph +
// strands), so it no longer counts toward unshipped.
// Dashboard's "Show deprecated" toggle hides deprecated rows by default.
expect(lgpCells.length).toBe(47);
const wired = lgpCells.filter((c: any) => c.status === "wired");
const stub = lgpCells.filter((c: any) => c.status === "stub");
const unshipped = lgpCells.filter((c: any) => c.status === "unshipped");
const unsupported = lgpCells.filter((c: any) => c.status === "unsupported");
// The interrupt-pill quarantine moved gen-ui-interrupt / interrupt-headless
// (both previously `wired`) into `not_supported_features`, so they now
// surface as `unsupported`: wired drops 38 -> 36, unsupported rises 0 -> 2.
// unshipped rises 6 -> 7 with threadid-frontend-tool-roundtrip. Then the
// a2ui-recovery demo shipped for LGP (wired), so wired rises 36 -> 37 and
// unshipped drops 8 -> 7.
expect(wired.length).toBe(37);
expect(stub.length).toBe(1);
expect(unshipped.length).toBe(7);
expect(unsupported.length).toBe(2);
});
it("stub detection: LGP/cli-start has stub status (demo exists, no route)", () => {
runGenerator();
const catalog = readCatalog();
const cliStartCell = catalog.cells.find(
(c: any) => c.id === "langgraph-python/cli-start",
);
expect(cliStartCell).toBeDefined();
expect(cliStartCell.status).toBe("stub");
expect(cliStartCell.manifestation).toBe("integrated");
});
it("parity tier: reference auto-detected as integration with the most wired features (alphabetical tie-break)", () => {
runGenerator();
const catalog = readCatalog();
// After the showcase-fill-186 blitz, multiple integrations match the
// historical LangGraph-Python wired-feature count. The auto-detection
// tie-breaks alphabetically — `langgraph-fastapi` precedes
// `langgraph-python` among the tied set, so it now wins the reference
// slot. Cells under the elected reference must carry parity_tier =
// "reference".
const ref = catalog.metadata.reference;
expect(ref).toBeTruthy();
const refCells = catalog.cells.filter(
(c: any) => c.integration === ref && c.manifestation === "integrated",
);
for (const cell of refCells) {
expect(cell.parity_tier).toBe("reference");
}
});
it("parity tier: crewai-crews wired cells render at_parity or partial against the elected reference", () => {
runGenerator();
const catalog = readCatalog();
const crewaiCells = catalog.cells.filter(
(c: any) =>
c.integration === "crewai-crews" && c.manifestation === "integrated",
);
const crewaiWired = crewaiCells.filter((c: any) => c.status === "wired");
// crewai-crews wired count moved with the blitz; assert the lower bound
// (the partial tier requires intersection >= 3 with the reference's
// wired set, which crewai-crews comfortably exceeds post-blitz).
expect(crewaiWired.length).toBeGreaterThanOrEqual(30);
const tier = crewaiCells[0].parity_tier;
expect(["at_parity", "partial"]).toContain(tier);
for (const cell of crewaiCells) {
expect(cell.parity_tier).toBe(tier);
}
});
it("metadata counts are correct (docs-only excluded from breakdown)", () => {
runGenerator();
const catalog = readCatalog();
expect(catalog.metadata).toBeDefined();
// total_cells excludes docs-only features
expect(catalog.metadata.total_cells).toBe(920);
// Headline counts exclude docs-only cells; must sum to total_cells.
expect(
catalog.metadata.wired +
catalog.metadata.stub +
catalog.metadata.unshipped +
catalog.metadata.unsupported,
).toBe(catalog.metadata.total_cells);
// docs_only + headline counts = total cells in the array
expect(
catalog.metadata.wired +
catalog.metadata.stub +
catalog.metadata.unshipped +
catalog.metadata.unsupported +
catalog.metadata.docs_only,
).toBe(catalog.cells.length);
expect(catalog.metadata.wired).toBeGreaterThanOrEqual(490);
expect(catalog.metadata.unsupported).toBeGreaterThanOrEqual(0);
expect(catalog.metadata.docs_only).toBe(20);
});
it("max_depth: D4 for wired/stub cells, D0 for unshipped/unsupported", () => {
runGenerator();
const catalog = readCatalog();
const wired = catalog.cells.filter((c: any) => c.status === "wired");
const stub = catalog.cells.filter((c: any) => c.status === "stub");
const unshipped = catalog.cells.filter(
(c: any) => c.status === "unshipped",
);
const unsupported = catalog.cells.filter(
(c: any) => c.status === "unsupported",
);
for (const cell of wired) {
expect(cell.max_depth).toBe(4);
}
for (const cell of stub) {
expect(cell.max_depth).toBe(4);
}
for (const cell of unshipped) {
expect(cell.max_depth).toBe(0);
}
for (const cell of unsupported) {
// Unsupported shares max_depth=0 with unshipped — neither has probes.
expect(cell.max_depth).toBe(0);
}
});
it("every integrated cell has a category from feature-registry.json", () => {
runGenerator();
const catalog = readCatalog();
const featureRegistryPath = path.resolve(
SCRIPTS_DIR,
"..",
"shared",
"feature-registry.json",
);
const featureRegistry = JSON.parse(
fs.readFileSync(featureRegistryPath, "utf-8"),
);
const validCategories = new Set(
featureRegistry.categories.map((c: any) => c.id),
);
const integrated = catalog.cells.filter(
(c: any) => c.manifestation === "integrated",
);
for (const cell of integrated) {
expect(cell.category).toBeDefined();
expect(
validCategories.has(cell.category),
`Invalid category "${cell.category}" for cell ${cell.id}`,
).toBe(true);
}
});
it("metadata.generated_at timestamp is present and recent", () => {
runGenerator();
const catalog = readCatalog();
expect(catalog.metadata.generated_at).toBeDefined();
const genTime = new Date(catalog.metadata.generated_at).getTime();
const now = Date.now();
// Should be within the last 60 seconds
expect(now - genTime).toBeLessThan(60000);
});
it("integrated cells have human-readable display names from registries", () => {
runGenerator();
const catalog = readCatalog();
// LGP cell for agentic-chat should have display names, not slugs
const lgpAgenticChat = catalog.cells.find(
(c: any) => c.id === "langgraph-python/agentic-chat",
);
expect(lgpAgenticChat).toBeDefined();
expect(lgpAgenticChat.integration_name).toBe("LangGraph (Python)");
expect(lgpAgenticChat.feature_name).toBe("Pre-Built: CopilotChat");
expect(lgpAgenticChat.category_name).toBe("Chat & UI");
// All integrated cells must have non-null display names
const integrated = catalog.cells.filter(
(c: any) => c.manifestation === "integrated",
);
for (const cell of integrated) {
expect(
typeof cell.integration_name,
`${cell.id} missing integration_name`,
).toBe("string");
expect(typeof cell.feature_name, `${cell.id} missing feature_name`).toBe(
"string",
);
expect(
typeof cell.category_name,
`${cell.id} missing category_name`,
).toBe("string");
}
});
it("cell IDs are unique", () => {
runGenerator();
const catalog = readCatalog();
const ids = catalog.cells.map((c: any) => c.id);
const uniqueIds = new Set(ids);
expect(uniqueIds.size).toBe(ids.length);
});
});
@@ -0,0 +1,376 @@
// SHOWCASE_BACKEND_HOST_PATTERN + error-contract tests for
// generate-registry.ts, run as a subprocess (the script executes main()
// when invoked directly, so its CLI contract — stderr + exit codes — is
// only observable subprocess-wise).
//
// ISOLATION (SU7-F3): every test runs the generator against a throwaway
// tmpdir copy of the showcase tree (scripts + shared + a controlled set
// of integrations), with ALL generator outputs landing inside that
// tmpdir. A previous revision of this suite snapshot/restored the SAME
// working-tree data files that generate-registry.test.ts snapshots,
// violating test-cleanup.ts's documented disjointness contract under
// `fileParallelism: true` — and it captured its baseline WITHOUT a
// healing default generator run, so a crashed override run could poison
// the snapshot for every later run. The per-suite tmpdir eliminates the
// whole shared-mutable-file class structurally: no snapshot, no restore,
// and no working-tree writes at all. This was chosen over merging into
// generate-registry.test.ts (the one-restorer option) because override
// runs here exercise FAILURE paths — keeping those away from the real
// tree entirely is strictly safer than healing the real tree afterwards.
import { describe, it, expect, afterEach, vi } from "vitest";
import fs from "fs";
import os from "os";
import path from "path";
import { createRequire } from "module";
import { execFileSync } from "child_process";
import { FileSnapshotRestorer, SAFE_EXEC_OPTS } from "./test-cleanup";
import { SCRIPTS_DIR } from "./paths";
const SHOWCASE_ROOT = path.resolve(SCRIPTS_DIR, "..");
const REFERENCE_SLUG = "langgraph-python";
const NON_REFERENCE_SLUG = "mastra";
// Resolve the locally-installed tsx CLI from the real scripts dir and
// spawn it via process.execPath — NOT `npx tsx`: npx without -y can
// prompt-hang when the package isn't cached, and the tmpdir cwd must not
// influence which tsx runs (same hardening as shell/vitest.global-setup.ts).
const TSX_CLI = createRequire(path.join(SCRIPTS_DIR, "package.json")).resolve(
"tsx/cli",
);
interface Harness {
root: string;
scriptsDir: string;
/** Absolute path to a generator output/input file under the tmp root. */
file: (...rel: string[]) => string;
}
// Track harness roots and reap them after each test — a failed test must
// not leak tmpdirs across runs.
const harnessRoots: string[] = [];
afterEach(() => {
vi.unstubAllEnvs();
for (const root of harnessRoots.splice(0)) {
fs.rmSync(root, { recursive: true, force: true });
}
});
/**
* Build a minimal throwaway showcase tree the generator can run against:
*
* <root>/scripts/{generate-registry.ts, validate-constraints.ts,
* package.json, node_modules -> real node_modules}
* <root>/shared/{manifest.schema.json, feature-registry.json[,
* constraints.yaml]}
* <root>/integrations/<slug>/manifest.yaml (copied real manifests)
*
* The generator resolves every path relative to its own location, so all
* reads AND writes stay inside the tmpdir.
*/
function makeHarness(
opts: { integrations?: string[]; constraints?: boolean } = {},
): Harness {
const {
integrations = [REFERENCE_SLUG, NON_REFERENCE_SLUG],
constraints = true,
} = opts;
const root = fs.mkdtempSync(
path.join(os.tmpdir(), "generate-registry-harness-"),
);
harnessRoots.push(root);
const scriptsDir = path.join(root, "scripts");
fs.mkdirSync(scriptsDir, { recursive: true });
for (const f of [
"generate-registry.ts",
"validate-constraints.ts",
"package.json",
]) {
fs.copyFileSync(path.join(SCRIPTS_DIR, f), path.join(scriptsDir, f));
}
// Bare-specifier resolution (yaml, ajv, ajv-formats) for the copied
// script — symlink the real node_modules instead of installing.
fs.symlinkSync(
path.join(SCRIPTS_DIR, "node_modules"),
path.join(scriptsDir, "node_modules"),
"dir",
);
const sharedDir = path.join(root, "shared");
fs.mkdirSync(sharedDir, { recursive: true });
const sharedFiles = ["manifest.schema.json", "feature-registry.json"];
if (constraints) sharedFiles.push("constraints.yaml");
for (const f of sharedFiles) {
fs.copyFileSync(
path.join(SHOWCASE_ROOT, "shared", f),
path.join(sharedDir, f),
);
}
fs.mkdirSync(path.join(root, "integrations"), { recursive: true });
for (const slug of integrations) {
const dir = path.join(root, "integrations", slug);
fs.mkdirSync(dir, { recursive: true });
fs.copyFileSync(
path.join(SHOWCASE_ROOT, "integrations", slug, "manifest.yaml"),
path.join(dir, "manifest.yaml"),
);
}
return { root, scriptsDir, file: (...rel) => path.join(root, ...rel) };
}
/**
* Run the harness's generator copy. `env` entries override the inherited
* environment; an explicit `undefined` deletes the variable. Ambient
* pattern vars are always stripped first so a developer shell exporting
* SHOWCASE_BACKEND_HOST_PATTERN can't skew default/fallback tests.
*/
function runGenerator(
harness: Harness,
env: Record<string, string | undefined> = {},
): string {
const childEnv: NodeJS.ProcessEnv = { ...process.env };
delete childEnv.SHOWCASE_BACKEND_HOST_PATTERN;
delete childEnv.NEXT_PUBLIC_SHOWCASE_BACKEND_HOST_PATTERN;
for (const [k, v] of Object.entries(env)) {
if (v === undefined) delete childEnv[k];
else childEnv[k] = v;
}
return execFileSync(process.execPath, [TSX_CLI, "generate-registry.ts"], {
...SAFE_EXEC_OPTS,
cwd: harness.scriptsDir,
env: childEnv,
}).toString();
}
type ExecError = Error & { status?: number | null; stderr?: string };
/** Run and expect a non-zero exit; returns the error for stderr asserts. */
function runGeneratorExpectingFailure(
harness: Harness,
env: Record<string, string | undefined> = {},
): ExecError {
let thrown: unknown;
try {
runGenerator(harness, env);
} catch (err) {
thrown = err;
}
expect(thrown, "expected the generator to exit non-zero").toBeInstanceOf(
Error,
);
return thrown as ExecError;
}
function readJson(harness: Harness, ...rel: string[]): any {
return JSON.parse(fs.readFileSync(harness.file(...rel), "utf-8"));
}
function readRegistry(harness: Harness): {
integrations: Array<{ slug: string; backend_url: string }>;
} {
return readJson(harness, "shell", "src", "data", "registry.json");
}
const DEFAULT_BACKEND_HOST_PATTERN =
"showcase-{slug}-production.up.railway.app";
describe("generate-registry reference-integration error contract (SU7-F3 #1)", () => {
it("supports the zero-manifests path: emits an empty registry AND an empty catalog, exit 0", () => {
// main() explicitly logs "No integration packages found. Generating
// empty registry." — generateCatalog used to crash right after on a
// non-null assertion for the (absent) reference integration,
// breaking the supported empty path with a TypeError.
const harness = makeHarness({ integrations: [] });
const stdout = runGenerator(harness);
expect(stdout).toContain("No integration packages found");
const registry = readRegistry(harness);
expect(registry.integrations).toEqual([]);
const catalog = readJson(harness, "shell", "src", "data", "catalog.json");
expect(catalog.cells).toEqual([]);
expect(catalog.metadata.total_cells).toBe(0);
expect(catalog.metadata.wired).toBe(0);
});
it(`fails loudly (stderr + exit 1) when integrations exist but the reference (${REFERENCE_SLUG}) is missing`, () => {
// Parity tiers are computed against the reference integration — with
// integrations present but the reference absent, the generator must
// fail per its error contract (labeled stderr + exit 1), not crash
// with a raw TypeError stack.
const harness = makeHarness({ integrations: [NON_REFERENCE_SLUG] });
const e = runGeneratorExpectingFailure(harness);
expect(e.status).toBe(1);
expect(e.stderr).toContain(REFERENCE_SLUG);
expect(e.stderr).toContain("reference");
expect(e.stderr).not.toContain("TypeError");
});
});
describe("generate-registry manifest-parse error contract (SU7-F3 #3)", () => {
it("treats an empty manifest.yaml (yaml.parse -> null) as a validation error, not a TypeError", () => {
const harness = makeHarness();
const brokenDir = harness.file("integrations", "broken-empty");
fs.mkdirSync(brokenDir, { recursive: true });
fs.writeFileSync(path.join(brokenDir, "manifest.yaml"), "");
const e = runGeneratorExpectingFailure(harness);
expect(e.status).toBe(1);
expect(e.stderr).toContain("manifest.yaml");
expect(e.stderr).toContain("YAML mapping");
expect(e.stderr).not.toContain("TypeError");
});
it("treats a scalar manifest.yaml as a validation error too", () => {
const harness = makeHarness();
const brokenDir = harness.file("integrations", "broken-scalar");
fs.mkdirSync(brokenDir, { recursive: true });
fs.writeFileSync(path.join(brokenDir, "manifest.yaml"), "just-a-string\n");
const e = runGeneratorExpectingFailure(harness);
expect(e.status).toBe(1);
expect(e.stderr).toContain("YAML mapping");
expect(e.stderr).not.toContain("TypeError");
});
});
describe("writeFileAtomicSync tmp naming matches the straggler-sweep convention (SU7-F3 #5)", () => {
it("names tmp siblings `.<basename>.<16hex>.tmp` so a SIGTERM-killed generator's stragglers get swept", async () => {
// Importing the generator module must NOT run main() — the script
// guards the call on direct invocation. Stub the pattern vars
// before the import anyway so a degenerate ambient value can't trip
// the module-load {slug} check (which would process.exit the vitest
// worker).
vi.stubEnv("SHOWCASE_BACKEND_HOST_PATTERN", "");
vi.stubEnv("NEXT_PUBLIC_SHOWCASE_BACKEND_HOST_PATTERN", "");
const { atomicTmpPath } = await import("../generate-registry");
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "atomic-tmp-naming-"));
harnessRoots.push(dir);
const target = path.join(dir, "registry.json");
fs.writeFileSync(target, "{}\n");
const tmp = atomicTmpPath(target);
// Same-directory sibling — rename(2) must stay on one filesystem.
expect(path.dirname(tmp)).toBe(dir);
// Named EXACTLY like FileSnapshotRestorer's snapshot-time sweep
// expects (`^\.<basename>\.[0-9a-f]{16}\.tmp$`). The previous
// `<target>.<pid>.tmp` shape was invisible to that sweep, so a
// SIGTERM-killed generator (the one crash mode its try/finally
// cannot clean up) accumulated un-swept stragglers forever.
expect(path.basename(tmp)).toMatch(/^\.registry\.json\.[0-9a-f]{16}\.tmp$/);
// Contract proof: a straggler left at that path is reaped by the
// restorer's sweep for the same target.
fs.writeFileSync(tmp, "partial write from a killed generator");
const restorer = new FileSnapshotRestorer([target]);
restorer.snapshot();
expect(fs.existsSync(tmp)).toBe(false);
expect(fs.existsSync(target)).toBe(true);
});
});
describe("generate-registry constraints-read error contract (SU7-F3 #4)", () => {
it("fails with a labeled stderr message + exit 1 when constraints.yaml is missing, not a raw ENOENT stack", () => {
const harness = makeHarness({ constraints: false });
const e = runGeneratorExpectingFailure(harness);
expect(e.status).toBe(1);
expect(e.stderr).toContain("ERROR");
expect(e.stderr).toContain("constraints.yaml");
// The labeled contract, not an unhandled-exception stack trace.
expect(e.stderr).not.toContain("Object.readFileSync");
});
});
describe("generate-registry SHOWCASE_BACKEND_HOST_PATTERN contract", () => {
it("fails loudly (stderr + exit 1) when the pattern lacks the {slug} placeholder", () => {
const harness = makeHarness();
const e = runGeneratorExpectingFailure(harness, {
SHOWCASE_BACKEND_HOST_PATTERN: "no-placeholder.example.com",
});
expect(
e.status,
"a {slug}-less pattern must fail the build, not bake one host everywhere",
).toBe(1);
expect(e.stderr).toContain("SHOWCASE_BACKEND_HOST_PATTERN");
expect(e.stderr).toContain("{slug}");
});
it("substitutes EVERY {slug} occurrence into backend_url (replaceAll parity with backend-url.ts)", () => {
const harness = makeHarness();
runGenerator(harness, {
SHOWCASE_BACKEND_HOST_PATTERN: "{slug}.demos.example.com/{slug}",
});
const registry = readRegistry(harness);
expect(registry.integrations.length).toBeGreaterThan(0);
for (const { slug, backend_url } of registry.integrations) {
expect(backend_url, `backend_url for "${slug}"`).toBe(
`https://${slug}.demos.example.com/${slug}`,
);
}
});
// Build-time normalization parity with the runtime consumer
// (normalizeBackendHostPattern in shell/src/lib/backend-url.ts,
// SU7-F3): registry.json's baked backend_url values are consumed by
// shells with NO runtime re-derivation, so a misconfigured env var at
// build time must normalize the same way it would at request time —
// not ship corrupted URLs.
function expectAllBackendUrls(
harness: Harness,
hostForSlug: (slug: string) => string,
): void {
const registry = readRegistry(harness);
expect(registry.integrations.length).toBeGreaterThan(0);
for (const { slug, backend_url } of registry.integrations) {
expect(backend_url, `backend_url for "${slug}"`).toBe(
`https://${hostForSlug(slug)}`,
);
}
}
it("strips a scheme-bearing pattern instead of baking https://https://… into the registry", () => {
const harness = makeHarness();
runGenerator(harness, {
SHOWCASE_BACKEND_HOST_PATTERN: "https://{slug}.demos.example.com",
});
expectAllBackendUrls(harness, (slug) => `${slug}.demos.example.com`);
});
it("strips a trailing slash so route concatenation can't yield '//'", () => {
const harness = makeHarness();
runGenerator(harness, {
SHOWCASE_BACKEND_HOST_PATTERN: "{slug}.demos.example.com/",
});
expectAllBackendUrls(harness, (slug) => `${slug}.demos.example.com`);
});
it("falls back to NEXT_PUBLIC_SHOWCASE_BACKEND_HOST_PATTERN when the primary var is unset (readEnvPair parity)", () => {
const harness = makeHarness();
runGenerator(harness, {
SHOWCASE_BACKEND_HOST_PATTERN: undefined,
NEXT_PUBLIC_SHOWCASE_BACKEND_HOST_PATTERN: "{slug}.alt.example.com",
});
expectAllBackendUrls(harness, (slug) => `${slug}.alt.example.com`);
});
it("treats an empty-string primary as unset and falls through to the alternate (readEnvPair parity)", () => {
const harness = makeHarness();
runGenerator(harness, {
SHOWCASE_BACKEND_HOST_PATTERN: "",
NEXT_PUBLIC_SHOWCASE_BACKEND_HOST_PATTERN: "{slug}.alt.example.com",
});
expectAllBackendUrls(harness, (slug) => `${slug}.alt.example.com`);
});
it("falls back to the DEFAULT pattern for a degenerate value that cannot form a URL", () => {
const harness = makeHarness();
// "https://" normalizes to "" after the scheme strip — unusable, so
// the generator must fall back to the default pattern (like the
// runtime does) instead of baking "https://https://" into every
// backend_url.
runGenerator(harness, { SHOWCASE_BACKEND_HOST_PATTERN: "https://" });
expectAllBackendUrls(harness, (slug) =>
DEFAULT_BACKEND_HOST_PATTERN.replaceAll("{slug}", slug),
);
});
});
@@ -0,0 +1,273 @@
import {
describe,
it,
expect,
beforeAll,
afterAll,
beforeEach,
afterEach,
} from "vitest";
import fs from "fs";
import path from "path";
import { execFileSync } from "child_process";
import {
FileSnapshotRestorer,
acquireGeneratedDataLock,
execOptsFor,
withGeneratedDataLock,
} from "./test-cleanup";
import { SCRIPTS_DIR, SHELL_DATA_DIR } from "./paths";
// `generate-registry.ts` multi-emits registry.json + catalog.json into
// EVERY shell's src/data plus the shell-only constraints.json. Without
// this, every test run leaks regenerated JSON into the working tree —
// catalog.json drifts on every run (its metadata carries a generated_at
// timestamp), so the full write set must be snapshotted, not just the
// shell registry/constraints pair. Snapshot in beforeAll; restore after
// each test and at the end of the suite. These paths overlap with the
// catalog and integration-smoke suites, so each generator mutation window
// holds the generated-data lock.
const SHOWCASE_ROOT = path.resolve(SCRIPTS_DIR, "..");
const DATA_FILES = [
path.join(SHELL_DATA_DIR, "registry.json"),
path.join(SHELL_DATA_DIR, "catalog.json"),
path.join(SHELL_DATA_DIR, "constraints.json"),
...["shell-docs", "shell-dojo", "shell-dashboard"].flatMap((pkg) => [
path.join(SHOWCASE_ROOT, pkg, "src", "data", "registry.json"),
path.join(SHOWCASE_ROOT, pkg, "src", "data", "catalog.json"),
]),
];
const dataRestorer = new FileSnapshotRestorer(DATA_FILES);
let releaseGeneratedDataLock: (() => void) | undefined;
const EXEC_OPTS = execOptsFor(SCRIPTS_DIR);
/** Invoke the generator via argv form — no shell parser involvement. Matches
* the hygiene principle in create-integration.test.ts. A prior revision
* used `execSync(\`npx tsx ${SCRIPT_PATH}\`)` with an interpolated path,
* which is a landmine even when the constant is safe today. */
function runGenerator(): string {
const out = execFileSync("npx", ["tsx", "generate-registry.ts"], EXEC_OPTS);
return out.toString();
}
beforeAll(() =>
withGeneratedDataLock(() => {
// Generate the data files (they're gitignored, so they may not exist).
// Running the DEFAULT generator before snapshotting also heals any
// drift a previously crashed run left behind — the baseline is always
// fresh default output, never leaked override artifacts (SU7-F3).
runGenerator();
dataRestorer.snapshot();
// Every file in the write set must have been captured — a missing
// entry means the generator's outputs and DATA_FILES have drifted
// apart, and the un-snapshotted file would leak mutations into the
// working tree with no restore (SU7-F3).
const missing = DATA_FILES.filter((p) => !dataRestorer.snapshotMap.has(p));
if (missing.length > 0) {
throw new Error(
`generate-registry.test.ts: snapshot is missing generated files —` +
` DATA_FILES has drifted from the generator's write set:\n` +
missing.map((p) => ` ${p}`).join("\n"),
);
}
}),
);
beforeEach(() => {
const release = acquireGeneratedDataLock();
try {
dataRestorer.restore();
releaseGeneratedDataLock = release;
} catch (err) {
release();
throw err;
}
});
afterEach(() => {
try {
dataRestorer.restore();
} finally {
releaseGeneratedDataLock?.();
releaseGeneratedDataLock = undefined;
}
});
afterAll(() => withGeneratedDataLock(() => dataRestorer.restore()));
// The generator uses __dirname-relative paths, so we test it via the actual
// script against the real showcase directory, using the existing
// langgraph-python package.
describe("Registry Generator", () => {
it("generates registry.json from existing packages", async () => {
const stdout = runGenerator();
expect(stdout).toContain("Generating integration registry");
expect(stdout).toContain("LangGraph (Python)");
const registryPath = path.join(SHELL_DATA_DIR, "registry.json");
expect(fs.existsSync(registryPath)).toBe(true);
const registry = JSON.parse(fs.readFileSync(registryPath, "utf-8"));
expect(registry.feature_registry).toBeDefined();
expect(registry.feature_registry.features.length).toBeGreaterThan(0);
expect(registry.integrations.length).toBeGreaterThan(0);
const langgraph = registry.integrations.find(
(i: any) => i.slug === "langgraph-python",
);
expect(langgraph).toBeDefined();
expect(langgraph.name).toBe("LangGraph (Python)");
expect(langgraph.category).toBe("popular");
expect(langgraph.language).toBe("python");
// Derive the expected counts from the manifest the generator reads, rather
// than pinning magic numbers — the generator copies `features`/`demos`
// straight through, so this can't rot when the wired-feature set changes
// (e.g. the interrupt-pill quarantine that moved gen-ui-interrupt /
// interrupt-headless from `features:` to `not_supported_features:`).
const manifestPath = path.resolve(
SCRIPTS_DIR,
"..",
"integrations",
"langgraph-python",
"manifest.yaml",
);
const yaml = await import("yaml");
const manifest = yaml.parse(fs.readFileSync(manifestPath, "utf-8"));
expect(langgraph.features.length).toBe(manifest.features.length);
expect(langgraph.demos.length).toBe(manifest.demos.length);
});
it("sorts integrations by sort_order", () => {
// Run the generator explicitly — afterEach restores shell/src/data JSONs
// to HEAD between tests, so we can't rely on test 1's side effect to
// leave registry.json populated. Mirrors the `runBundlerAndRead` pattern
// in bundle-demo-content.test.ts tests 2-5.
runGenerator();
const registryPath = path.join(SHELL_DATA_DIR, "registry.json");
const registry = JSON.parse(fs.readFileSync(registryPath, "utf-8"));
// langgraph-python (sort_order: 10) should come before mastra (sort_order: 20)
const lgIdx = registry.integrations.findIndex(
(i: any) => i.slug === "langgraph-python",
);
const mastraIdx = registry.integrations.findIndex(
(i: any) => i.slug === "mastra",
);
expect(lgIdx).toBeLessThan(mastraIdx);
// Verify overall order is non-decreasing by sort_order
for (let i = 1; i < registry.integrations.length; i++) {
const prevOrder = registry.integrations[i - 1].sort_order ?? 999;
const currOrder = registry.integrations[i].sort_order ?? 999;
expect(currOrder).toBeGreaterThanOrEqual(prevOrder);
}
});
it("validates feature IDs against the registry", async () => {
const featureRegistryPath = path.resolve(
SCRIPTS_DIR,
"..",
"shared",
"feature-registry.json",
);
const featureRegistry = JSON.parse(
fs.readFileSync(featureRegistryPath, "utf-8"),
);
const validIds = new Set(featureRegistry.features.map((f: any) => f.id));
const manifestPath = path.resolve(
SCRIPTS_DIR,
"..",
"integrations",
"langgraph-python",
"manifest.yaml",
);
const yaml = await import("yaml");
const manifest = yaml.parse(fs.readFileSync(manifestPath, "utf-8"));
for (const featureId of manifest.features) {
expect(validIds.has(featureId)).toBe(true);
}
for (const demo of manifest.demos) {
expect(validIds.has(demo.id)).toBe(true);
}
});
// Regression guard — ensures the snapshot/restore hooks defined at the top
// of this file actually heal drift that `generate-registry.ts` produces in
// shell/src/data/. If these hooks regress we'll see data-file drift leak
// into the working tree (same failure mode as the workflow YAML leak
// fixed in create-integration.test.ts).
//
// The sentinel append below creates transient tracking drift on
// shell/src/data/*.json for the duration of the test; a developer with a
// git GUI / file watcher will see flicker while it runs. Restore heals it
// before the test returns.
it("restores shell/src/data JSONs after the generator mutates them", () => {
expect(dataRestorer.snapshotMap.size).toBeGreaterThan(0);
// Run the generator explicitly via the argv-safe helper.
runGenerator();
// Capture pre-sentinel content so we can prove the append landed on
// disk via a content check (stronger than byte-length comparison:
// resistant to a hypothetical fs shim that updates stat but not bytes).
const preAppendContent = new Map<string, Buffer>();
for (const p of dataRestorer.snapshotMap.keys()) {
preAppendContent.set(p, fs.readFileSync(p));
}
// Force each snapshotted file to differ from its snapshot — appending a
// byte the generator would never write. This makes the test independent
// of whether the generator's output was byte-identical to the snapshot.
const SENTINEL = "\n/* regression-guard-sentinel */\n";
const sentinelBuf = Buffer.from(SENTINEL, "utf-8");
for (const p of dataRestorer.snapshotMap.keys()) {
fs.appendFileSync(p, SENTINEL);
}
// Verify the sentinel actually landed — the file's bytes must now equal
// its pre-append content followed by the sentinel bytes, exactly.
// Fails red under a readonly-fs mock or a buggy appendFileSync shim.
for (const p of dataRestorer.snapshotMap.keys()) {
const before = preAppendContent.get(p)!;
const expected = Buffer.concat([before, sentinelBuf]);
const actual = fs.readFileSync(p);
expect(
actual.equals(expected),
`sentinel append did not land on ${p}`,
).toBe(true);
}
// Restore and assert bit-for-bit against the in-memory snapshot (NOT
// against a re-read of disk, which would silently agree with a buggy
// restore()).
dataRestorer.restore();
for (const [p, baseline] of dataRestorer.snapshotMap) {
const current = fs.readFileSync(p);
expect(current.equals(baseline), `data drift not restored: ${p}`).toBe(
true,
);
}
});
// Safety net: every snapshotted data file must match its captured baseline
// bit-for-bit at the end of the suite. Mirrors the equivalent check in
// create-integration.test.ts.
it("leaves every snapshotted data file byte-identical to its baseline", () => {
expect(dataRestorer.snapshotMap.size).toBeGreaterThan(0);
for (const [p, baseline] of dataRestorer.snapshotMap) {
const current = fs.readFileSync(p);
expect(current.equals(baseline), `data drift after suite: ${p}`).toBe(
true,
);
}
});
});
@@ -0,0 +1,352 @@
/**
* harness-workers-provisioning.test.ts — CI drift gate for harness-workers
* worker-fleet provisioning fields (`effectiveReplicas`,
* `BROWSER_POOL_MAX_CONTEXTS`).
*
* Style note: mirrors `verify-railway-image-refs.test.ts` — pure validators
* against synthesized or committed-snapshot inputs, NO live Railway API calls.
*
* The drift gate works by comparing the SSOT EFFECTIVE replica count
* (`effectiveReplicas`, declared in `railway-envs.ts`) against the value
* committed to `railway-envs.generated.json` (a static snapshot, NOT a live
* Railway API response). When the SSOT and the snapshot agree, the gate passes.
* When they diverge (someone edited the SSOT but forgot to re-run
* emit-railway-envs-json, or forgot to update the SSOT to match reality), the
* gate fails.
*
* EFFECTIVE REPLICA COUNT: the gate watches `effectiveReplicas`, which models
* `multiRegionConfig.us-west2.numReplicas` — the field Railway actually honors
* to derive the live replica count for this single-region service. The
* top-level `numReplicas` is a documented mirror only; watching it would gate
* a field that does not drive reality.
*
* WORKER MODEL CONFIRMED: 1-worker-per-replica (NOT replicas × pool count).
* `HARNESS_POOL_COUNT` is INFORMATIONAL ONLY — not a fork factor. The
* authoritative worker count is `effectiveReplicas`. The authoritative
* per-worker concurrency is `BROWSER_POOL_MAX_CONTEXTS`.
*/
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
import { SERVICES, workerProvisioningFor } from "../railway-envs";
import type { WorkerProvisioning } from "../railway-envs";
const GENERATED_JSON_PATH = resolve(
__dirname,
"..",
"railway-envs.generated.json",
);
/** Load the committed generated-JSON snapshot (never calls the Railway API). */
function loadGeneratedSnapshot(): {
services: Array<{
name: string;
workerProvisioning?: {
prod: WorkerProvisioning;
staging: WorkerProvisioning;
};
}>;
} {
return JSON.parse(readFileSync(GENERATED_JSON_PATH, "utf8"));
}
describe("harness-workers provisioning SSOT", () => {
it("harness-workers declares workerProvisioning in the SSOT", () => {
const prodProv = workerProvisioningFor("harness-workers", "prod");
const stagingProv = workerProvisioningFor("harness-workers", "staging");
expect(prodProv).not.toBeUndefined();
expect(stagingProv).not.toBeUndefined();
});
it("prod effectiveReplicas = 6 (parity achieved — B-reconcile scaled prod 3 → 6, 2026-06-26)", () => {
// multiRegionConfig.us-west2.numReplicas is the field Railway honors.
// Verified live: deploy.multiRegionConfig = {"us-west2":{"numReplicas":6}}.
const prov = workerProvisioningFor("harness-workers", "prod");
expect(prov?.effectiveReplicas).toBe(6);
});
it("staging effectiveReplicas = 6 (multiRegionConfig.us-west2.numReplicas, verified live)", () => {
// Verified live: deploy.multiRegionConfig = {"us-west2":{"numReplicas":6}}.
const prov = workerProvisioningFor("harness-workers", "staging");
expect(prov?.effectiveReplicas).toBe(6);
});
it("top-level numReplicas mirrors effectiveReplicas in both envs (6 / 6)", () => {
// Single-region service: the top-level numReplicas is a documented mirror of
// the effective per-region count, not an authoritative knob.
const prodProv = workerProvisioningFor("harness-workers", "prod");
const stagingProv = workerProvisioningFor("harness-workers", "staging");
expect(prodProv?.numReplicas).toBe(6);
expect(stagingProv?.numReplicas).toBe(6);
expect(prodProv?.numReplicas).toBe(prodProv?.effectiveReplicas);
expect(stagingProv?.numReplicas).toBe(stagingProv?.effectiveReplicas);
});
it("BROWSER_POOL_MAX_CONTEXTS = 40 for both envs (per-worker concurrency, not a fleet total)", () => {
const prodProv = workerProvisioningFor("harness-workers", "prod");
const stagingProv = workerProvisioningFor("harness-workers", "staging");
expect(prodProv?.BROWSER_POOL_MAX_CONTEXTS).toBe(40);
expect(stagingProv?.BROWSER_POOL_MAX_CONTEXTS).toBe(40);
});
it("overlapSeconds = 45 for both envs (deploy-rollover capacity floor, layer c)", () => {
// RAILWAY_DEPLOYMENT_OVERLAP_SECONDS — keep the old deployment serving until
// the new one is Active so the capacity floor holds across a rollover (no
// staleness dip). See showcase/RAILWAY.md "Deploy rollover".
const prodProv = workerProvisioningFor("harness-workers", "prod");
const stagingProv = workerProvisioningFor("harness-workers", "staging");
expect(prodProv?.overlapSeconds).toBe(45);
expect(stagingProv?.overlapSeconds).toBe(45);
});
it("drainingSeconds = 180 for both envs (graceful-drain window, ≥ PLATFORM_STOP_GRACE_MS)", () => {
// RAILWAY_DEPLOYMENT_DRAINING_SECONDS — the SIGTERM→SIGKILL window. Sized to
// host the shipped composed worker-drain budget (layer b: 3s deregister cap +
// 90s finish-and-report grace + teardown remainder, all < PLATFORM_STOP_GRACE_MS
// = 180s). See showcase/RAILWAY.md "Deploy rollover".
const prodProv = workerProvisioningFor("harness-workers", "prod");
const stagingProv = workerProvisioningFor("harness-workers", "staging");
expect(prodProv?.drainingSeconds).toBe(180);
expect(stagingProv?.drainingSeconds).toBe(180);
});
it("HARNESS_POOL_COUNT is recorded as informational only — NOT used as a fork factor", () => {
// The worker boots 1 process per replica (keyed on HOSTNAME). HARNESS_POOL_COUNT
// is forwarded to each worker as a control-plane hint but NEVER forks additional
// worker processes. The authoritative worker count is numReplicas.
const prodProv = workerProvisioningFor("harness-workers", "prod");
const stagingProv = workerProvisioningFor("harness-workers", "staging");
// Presence is optional; assert its value only when set.
if (prodProv?.HARNESS_POOL_COUNT !== undefined) {
expect(typeof prodProv.HARNESS_POOL_COUNT).toBe("number");
}
if (stagingProv?.HARNESS_POOL_COUNT !== undefined) {
expect(typeof stagingProv.HARNESS_POOL_COUNT).toBe("number");
}
});
it("workerProvisioningFor returns undefined for non-worker services", () => {
// Only harness-workers carries this field; every other service returns undefined.
expect(workerProvisioningFor("harness", "prod")).toBeUndefined();
expect(workerProvisioningFor("aimock", "prod")).toBeUndefined();
expect(workerProvisioningFor("pocketbase", "staging")).toBeUndefined();
});
});
describe("harness-workers provisioning drift gate (SSOT vs generated JSON snapshot)", () => {
/**
* This is the CI drift gate. It compares the SSOT-declared `numReplicas`
* values against the committed `railway-envs.generated.json` snapshot.
*
* If someone edits `railway-envs.ts` (SSOT) but forgets to regenerate the
* JSON, OR edits the JSON directly without updating the SSOT, this test
* catches the drift.
*
* The comparison source is the COMMITTED JSON SNAPSHOT — NOT a live Railway
* API call. This is intentional: live API calls are inappropriate for a unit
* test (flaky, requires auth, slow). The snapshot is updated by running:
* npx tsx showcase/scripts/emit-railway-envs-json.ts
*/
it("SSOT prod effectiveReplicas matches committed generated JSON snapshot", () => {
const snapshot = loadGeneratedSnapshot();
const snapshotEntry = snapshot.services.find(
(s) => s.name === "harness-workers",
);
expect(
snapshotEntry,
"harness-workers missing from railway-envs.generated.json",
).not.toBeUndefined();
const ssotProd = workerProvisioningFor("harness-workers", "prod");
expect(
ssotProd,
"harness-workers prod workerProvisioning missing from SSOT",
).not.toBeUndefined();
const snapshotProdReplicas =
snapshotEntry?.workerProvisioning?.prod?.effectiveReplicas;
expect(
snapshotProdReplicas,
"workerProvisioning.prod.effectiveReplicas missing from generated JSON snapshot",
).not.toBeUndefined();
expect(ssotProd?.effectiveReplicas).toBe(snapshotProdReplicas);
});
it("SSOT staging effectiveReplicas matches committed generated JSON snapshot", () => {
const snapshot = loadGeneratedSnapshot();
const snapshotEntry = snapshot.services.find(
(s) => s.name === "harness-workers",
);
expect(
snapshotEntry,
"harness-workers missing from railway-envs.generated.json",
).not.toBeUndefined();
const ssotStaging = workerProvisioningFor("harness-workers", "staging");
expect(
ssotStaging,
"harness-workers staging workerProvisioning missing from SSOT",
).not.toBeUndefined();
const snapshotStagingReplicas =
snapshotEntry?.workerProvisioning?.staging?.effectiveReplicas;
expect(
snapshotStagingReplicas,
"workerProvisioning.staging.effectiveReplicas missing from generated JSON snapshot",
).not.toBeUndefined();
expect(ssotStaging?.effectiveReplicas).toBe(snapshotStagingReplicas);
});
it("SSOT prod BROWSER_POOL_MAX_CONTEXTS matches committed generated JSON snapshot", () => {
const snapshot = loadGeneratedSnapshot();
const snapshotEntry = snapshot.services.find(
(s) => s.name === "harness-workers",
);
const ssotProd = workerProvisioningFor("harness-workers", "prod");
expect(ssotProd?.BROWSER_POOL_MAX_CONTEXTS).toBe(
snapshotEntry?.workerProvisioning?.prod?.BROWSER_POOL_MAX_CONTEXTS,
);
});
it("SSOT staging BROWSER_POOL_MAX_CONTEXTS matches committed generated JSON snapshot", () => {
const snapshot = loadGeneratedSnapshot();
const snapshotEntry = snapshot.services.find(
(s) => s.name === "harness-workers",
);
const ssotStaging = workerProvisioningFor("harness-workers", "staging");
expect(ssotStaging?.BROWSER_POOL_MAX_CONTEXTS).toBe(
snapshotEntry?.workerProvisioning?.staging?.BROWSER_POOL_MAX_CONTEXTS,
);
});
it("SSOT prod overlapSeconds matches committed generated JSON snapshot", () => {
const snapshot = loadGeneratedSnapshot();
const snapshotEntry = snapshot.services.find(
(s) => s.name === "harness-workers",
);
const ssotProd = workerProvisioningFor("harness-workers", "prod");
expect(
snapshotEntry?.workerProvisioning?.prod?.overlapSeconds,
"workerProvisioning.prod.overlapSeconds missing from generated JSON snapshot",
).not.toBeUndefined();
expect(ssotProd?.overlapSeconds).toBe(
snapshotEntry?.workerProvisioning?.prod?.overlapSeconds,
);
});
it("SSOT staging overlapSeconds matches committed generated JSON snapshot", () => {
const snapshot = loadGeneratedSnapshot();
const snapshotEntry = snapshot.services.find(
(s) => s.name === "harness-workers",
);
const ssotStaging = workerProvisioningFor("harness-workers", "staging");
expect(
snapshotEntry?.workerProvisioning?.staging?.overlapSeconds,
"workerProvisioning.staging.overlapSeconds missing from generated JSON snapshot",
).not.toBeUndefined();
expect(ssotStaging?.overlapSeconds).toBe(
snapshotEntry?.workerProvisioning?.staging?.overlapSeconds,
);
});
it("SSOT prod drainingSeconds matches committed generated JSON snapshot", () => {
const snapshot = loadGeneratedSnapshot();
const snapshotEntry = snapshot.services.find(
(s) => s.name === "harness-workers",
);
const ssotProd = workerProvisioningFor("harness-workers", "prod");
expect(
snapshotEntry?.workerProvisioning?.prod?.drainingSeconds,
"workerProvisioning.prod.drainingSeconds missing from generated JSON snapshot",
).not.toBeUndefined();
expect(ssotProd?.drainingSeconds).toBe(
snapshotEntry?.workerProvisioning?.prod?.drainingSeconds,
);
});
it("SSOT staging drainingSeconds matches committed generated JSON snapshot", () => {
const snapshot = loadGeneratedSnapshot();
const snapshotEntry = snapshot.services.find(
(s) => s.name === "harness-workers",
);
const ssotStaging = workerProvisioningFor("harness-workers", "staging");
expect(
snapshotEntry?.workerProvisioning?.staging?.drainingSeconds,
"workerProvisioning.staging.drainingSeconds missing from generated JSON snapshot",
).not.toBeUndefined();
expect(ssotStaging?.drainingSeconds).toBe(
snapshotEntry?.workerProvisioning?.staging?.drainingSeconds,
);
});
});
describe("harness-workers provisioning with injected test data", () => {
/**
* This test injects a synthetic SSOT entry and confirms the accessor
* returns the correct fields. It does NOT modify the real SERVICES map
* beyond the sentinel, which is removed in the finally block.
*/
it("transient injected SSOT entry: workerProvisioningFor returns correct values", () => {
// Inject a synthetic harness-workers-like entry and confirm the accessor
// returns the correct fields. Remove the injection in the finally block.
const sentinel = "__test-workers-sentinel__";
const mockProv = {
prod: {
effectiveReplicas: 5,
numReplicas: 5,
BROWSER_POOL_MAX_CONTEXTS: 20,
},
staging: {
effectiveReplicas: 10,
numReplicas: 10,
BROWSER_POOL_MAX_CONTEXTS: 20,
},
};
(
SERVICES as Record<
string,
{
serviceId: string;
environments: Record<string, unknown>;
probeDriver: string;
ciBuilt: boolean;
gateValidated: boolean;
workerProvisioning?: {
prod: WorkerProvisioning;
staging: WorkerProvisioning;
};
}
>
)[sentinel] = {
serviceId: "00000000-0000-0000-0000-000000000099",
ciBuilt: false,
gateValidated: false,
probeDriver: "harness",
environments: {
prod: {
instanceId: "11111111-1111-1111-1111-111111111111",
probe: false,
},
staging: {
instanceId: "22222222-2222-2222-2222-222222222222",
probe: false,
},
},
workerProvisioning: mockProv,
};
try {
const prodProv = workerProvisioningFor(sentinel, "prod");
const stagingProv = workerProvisioningFor(sentinel, "staging");
expect(prodProv?.effectiveReplicas).toBe(5);
expect(stagingProv?.effectiveReplicas).toBe(10);
expect(prodProv?.numReplicas).toBe(5);
expect(prodProv?.BROWSER_POOL_MAX_CONTEXTS).toBe(20);
} finally {
delete (SERVICES as Record<string, unknown>)[sentinel];
}
});
});
@@ -0,0 +1,159 @@
import {
describe,
it,
expect,
beforeAll,
afterAll,
beforeEach,
afterEach,
} from "vitest";
import fs from "fs";
import path from "path";
import { execFileSync } from "child_process";
import {
FileSnapshotRestorer,
acquireGeneratedDataLock,
execOptsFor,
withGeneratedDataLock,
} from "./test-cleanup";
import { SCRIPTS_DIR, SHELL_DATA_DIR } from "./paths";
// Ensure registry.json exists (it's generated, gitignored).
const DATA_FILES = [
path.join(SHELL_DATA_DIR, "registry.json"),
path.join(SHELL_DATA_DIR, "constraints.json"),
];
const dataRestorer = new FileSnapshotRestorer(DATA_FILES);
let releaseGeneratedDataLock: (() => void) | undefined;
const EXEC_OPTS = execOptsFor(SCRIPTS_DIR);
function runGenerator(): string {
return execFileSync(
"npx",
["tsx", "generate-registry.ts"],
EXEC_OPTS,
).toString();
}
let registry: {
integrations: Array<{
slug: string;
name: string;
backend_url: string;
deployed: boolean;
features: string[];
demos: Array<{ id: string }>;
}>;
};
// Mirror the derivation logic from the smoke spec
let INTEGRATIONS: Array<{
slug: string;
name: string;
backendUrl: string;
deployed: boolean;
hasToolRendering: boolean;
demos: string[];
}>;
beforeAll(() =>
withGeneratedDataLock(() => {
// Only regenerate if registry.json is absent. When generate-registry.test.ts
// runs concurrently (fileParallelism: true), its beforeAll already creates
// the file; re-running the generator here would race with that suite's
// sentinel test (atomic rename clobbers the appended sentinel).
if (!fs.existsSync(path.join(SHELL_DATA_DIR, "registry.json"))) {
runGenerator();
}
dataRestorer.snapshot();
const registryPath = path.join(SHELL_DATA_DIR, "registry.json");
registry = JSON.parse(fs.readFileSync(registryPath, "utf-8"));
INTEGRATIONS = registry.integrations.map((i) => ({
slug: i.slug,
name: i.name,
backendUrl: i.backend_url,
deployed: i.deployed,
hasToolRendering: i.features.includes("tool-rendering"),
demos: i.demos.map((d: { id: string }) => d.id),
}));
}),
);
beforeEach(() => {
const release = acquireGeneratedDataLock();
try {
dataRestorer.restore();
releaseGeneratedDataLock = release;
} catch (err) {
release();
throw err;
}
});
afterEach(() => {
try {
dataRestorer.restore();
} finally {
releaseGeneratedDataLock?.();
releaseGeneratedDataLock = undefined;
}
});
afterAll(() => withGeneratedDataLock(() => dataRestorer.restore()));
describe("smoke spec integration registry derivation", () => {
it("produces one entry per registry integration", () => {
expect(INTEGRATIONS.length).toBe(registry.integrations.length);
});
it("every integration has a non-empty backendUrl", () => {
for (const i of INTEGRATIONS) {
expect(i.backendUrl, `${i.slug} missing backendUrl`).toBeTruthy();
expect(i.backendUrl).toMatch(/^https?:\/\//);
}
});
it("hasToolRendering matches features list", () => {
for (const reg of registry.integrations) {
const derived = INTEGRATIONS.find((i) => i.slug === reg.slug)!;
expect(derived.hasToolRendering).toBe(
reg.features.includes("tool-rendering"),
);
}
});
it("demo IDs match registry demos", () => {
for (const reg of registry.integrations) {
const derived = INTEGRATIONS.find((i) => i.slug === reg.slug)!;
expect(derived.demos).toEqual(reg.demos.map((d) => d.id));
}
});
it("slugs match registry exactly", () => {
expect(INTEGRATIONS.map((i) => i.slug)).toEqual(
registry.integrations.map((i) => i.slug),
);
});
it("deployed filter works", () => {
const deployed = INTEGRATIONS.filter((i) => i.deployed);
const registryDeployed = registry.integrations.filter((i) => i.deployed);
expect(deployed.length).toBe(registryDeployed.length);
expect(deployed.length).toBeGreaterThan(0);
});
it("no hardcoded integration data remains in smoke spec", () => {
const specPath = path.resolve(
__dirname,
"../../tests/e2e/integration-smoke.spec.ts",
);
const src = fs.readFileSync(specPath, "utf-8");
// The old hardcoded array had Railway URLs inline
expect(src).not.toContain("showcase-langgraph-python-production");
expect(src).not.toContain("showcase-mastra-production");
// Should not have a literal Integration[] type (replaced by inference)
expect(src).not.toContain("const INTEGRATIONS: Integration[]");
});
});
@@ -0,0 +1,372 @@
#!/usr/bin/env bats
# REAL-SURFACE liveness tests for the Change-1 false-positive fix in
# scripts/cli/_common.sh. Unlike isolate.bats (which drives a docker STUB),
# these tests exercise the actual failure surface the spec mandates:
#
# * real slot dirs under a temp XDG_STATE_HOME,
# * a real DEAD owning PID (spawn `sleep` then `wait` for it to exit —
# provably dead via kill -0),
# * a REAL one-container `docker compose` project so containers ARE running.
#
# The bug: when a slot's recorded compose project has RUNNING containers, the
# old _slot_liveness short-circuited to `live` BEFORE checking the owning PID.
# A --keep'd stack (owner process exited, containers still up) was therefore
# classified `live` forever and never reaped → unbounded slot accumulation.
# The fix introduces the `kept` state (running containers + dead/unverifiable
# owner) and a start-time-verified owner probe.
#
# Hermeticity: every throwaway compose project uses a UNIQUE disposable name
# (showcase-isotest-s1-<rand>), NEVER the base `showcase` name, and is torn
# down in teardown() with `docker compose -p <name> down --remove-orphans
# --volumes`. We are fixing a stack-leak bug — these tests must not leak stacks.
#
# All real-docker tests `skip` cleanly when the docker daemon is unreachable or
# the tiny test image cannot be obtained, so the suite stays green on hosts /
# CI runners without docker.
fail() {
echo "$1"
return 1
}
# The tiny image the throwaway one-container project runs. Pinned digest-free
# but version-tagged; pulled in setup() if absent. `sleep infinity` keeps the
# container RUNNING so `docker ps -q --filter label=...` reports it.
TEST_IMAGE="alpine:3.20"
# _docker_ok — true when a docker daemon is reachable. Used to skip the
# real-docker tests on hosts/CI without docker.
_docker_ok() {
command -v docker >/dev/null 2>&1 || return 1
docker info >/dev/null 2>&1 || return 1
return 0
}
# _ensure_test_image — make sure $TEST_IMAGE exists locally, pulling once if
# needed. Returns nonzero (→ caller skips) when it cannot be obtained.
_ensure_test_image() {
docker image inspect "$TEST_IMAGE" >/dev/null 2>&1 && return 0
docker pull "$TEST_IMAGE" >/dev/null 2>&1
}
setup() {
COMMON="$BATS_TEST_DIRNAME/../cli/_common.sh"
# Real XDG state root (no docker stub on PATH — these tests use REAL docker).
export XDG_STATE_HOME="$BATS_TEST_TMPDIR/xdg"
# Minimal real SHOWCASE_ROOT so _slot_offset_ports has a ports file to read
# (we override the function per-test anyway, but load_common references it).
export SHOWCASE_ROOT_OVERRIDE="$BATS_TEST_TMPDIR/root"
mkdir -p "$SHOWCASE_ROOT_OVERRIDE/shared"
cat > "$SHOWCASE_ROOT_OVERRIDE/shared/local-ports.json" <<'JSON'
{ "mastra": 3104 }
JSON
cat > "$SHOWCASE_ROOT_OVERRIDE/docker-compose.local.yml" <<'YML'
services:
aimock:
container_name: showcase-aimock
ports:
- "4010:4010"
YML
# Unique disposable project name for this test's throwaway stack — NEVER the
# base `showcase` name. Recorded so teardown() can compose it down even if
# the test aborts mid-way.
TEST_PROJ="showcase-isotest-s1-${BATS_TEST_NUMBER}-$$-${RANDOM}"
TEST_PORT="" # set by the tests that bind a host port; torn down in teardown
}
teardown() {
# Tear down the throwaway stack unconditionally (the stack-leak guarantee):
# remove containers, orphans, and named volumes for the unique project.
if command -v docker >/dev/null 2>&1 && [ -n "${TEST_PROJ:-}" ]; then
docker compose -p "$TEST_PROJ" down --remove-orphans --volumes >/dev/null 2>&1 || true
fi
}
load_common() {
# shellcheck disable=SC1090
source "$COMMON"
SHOWCASE_ROOT="$SHOWCASE_ROOT_OVERRIDE"
COMPOSE_FILE="$SHOWCASE_ROOT/docker-compose.local.yml"
PORTS_FILE="$SHOWCASE_ROOT/shared/local-ports.json"
COMPOSE_CMD="docker compose -f $COMPOSE_FILE"
}
# _start_real_project <proj> [host_port] — start a REAL one-container compose
# project named <proj> running `sleep infinity` (so the container is RUNNING
# and carries the com.docker.compose.project=<proj> label). If host_port is
# given, the container publishes it (binds a real host listener) so the
# port-probe tests have a genuine docker-held port to exercise lsof against.
_start_real_project() {
local proj="$1" host_port="${2:-}"
local cdir="$BATS_TEST_TMPDIR/compose-$proj"
mkdir -p "$cdir"
if [ -n "$host_port" ]; then
cat > "$cdir/docker-compose.yml" <<YML
services:
keeper:
image: $TEST_IMAGE
command: ["sleep", "infinity"]
ports:
- "127.0.0.1:${host_port}:9"
YML
else
cat > "$cdir/docker-compose.yml" <<YML
services:
keeper:
image: $TEST_IMAGE
command: ["sleep", "infinity"]
YML
fi
docker compose -f "$cdir/docker-compose.yml" -p "$proj" up -d >/dev/null 2>&1
}
# _make_dead_pid — spawn a short-lived process, wait for it to exit, and echo
# its (now dead) pid. Skips the test if the OS recycled the pid to a live
# process between wait and the check (the dead-owner fixture would be invalid).
_make_dead_pid() {
local p
bash -c 'exit 0' &
p=$!
wait "$p" 2>/dev/null || true
if kill -0 "$p" 2>/dev/null; then
skip "PID $p was recycled to a live process — dead-PID fixture invalid"
fi
echo "$p"
}
# ── Change 1: liveness false-positive (the FOUNDATION) ───────────────────────
@test "REAL: a kept stack (dead owner + running containers) classifies kept, not live" {
_docker_ok || skip "docker daemon unavailable"
_ensure_test_image || skip "cannot obtain $TEST_IMAGE"
load_common
local slots="$XDG_STATE_HOME/copilotkit/showcase/slots"
mkdir -p "$slots/0"
# Recorded project + a provably DEAD owner pid (no pid.start → unverifiable
# even if the number were alive). This is exactly a --keep'd stack: the
# owning `showcase test --keep` process has exited, the containers live on.
echo "$TEST_PROJ" > "$slots/0/project"
local dead_pid
dead_pid="$(_make_dead_pid)"
echo "$dead_pid" > "$slots/0/pid"
# Real running container under the recorded project name.
_start_real_project "$TEST_PROJ" || skip "could not start throwaway compose project"
# Sanity: docker really does report a running container for this project.
[ -n "$(docker ps -q --filter "label=com.docker.compose.project=$TEST_PROJ")" ] \
|| skip "throwaway project has no running container — environment issue"
# GREEN expectation (fix in place): the container check wins, but the owner
# is dead → kept, NOT live. The PID column annotates the dead owner.
run _slot_liveness 0
[ "$status" -eq 0 ] || fail "_slot_liveness 0 failed: $output"
[ "$output" = "kept" ] \
|| fail "expected liveness 'kept' for dead-owner+running-containers slot, got '$output' (pre-fix bug returns 'live')"
# _slot_state's PID column shows <pid>(dead), and LIVE=kept — never a bare
# numeric PID with LIVE=live (the exact false-positive from the bug report).
run _slot_state 0
[ "$status" -eq 0 ] || fail "_slot_state 0 failed: $output"
local -a fields
IFS='|' read -ra fields <<< "$output"
[ "${fields[2]}" = "${dead_pid}(dead)" ] \
|| fail "expected PID column '${dead_pid}(dead)', got '${fields[2]}' (pre-fix bug shows bare '$dead_pid')"
[ "${fields[3]}" = "kept" ] \
|| fail "expected LIVE column 'kept', got '${fields[3]}' (pre-fix bug shows 'live')"
}
@test "REAL: a live, start-time-verified owner with running containers stays live" {
_docker_ok || skip "docker daemon unavailable"
_ensure_test_image || skip "cannot obtain $TEST_IMAGE"
load_common
local slots="$XDG_STATE_HOME/copilotkit/showcase/slots"
mkdir -p "$slots/0"
echo "$TEST_PROJ" > "$slots/0/project"
# LIVE owner: this bats process, with a matching pid.start fingerprint.
echo "$$" > "$slots/0/pid"
_pid_start_time "$$" > "$slots/0/pid.start"
_start_real_project "$TEST_PROJ" || skip "could not start throwaway compose project"
[ -n "$(docker ps -q --filter "label=com.docker.compose.project=$TEST_PROJ")" ] \
|| skip "throwaway project has no running container — environment issue"
# A live verified owner UPGRADES kept → live (protect indefinitely).
run _slot_liveness 0
[ "$status" -eq 0 ] || fail "_slot_liveness 0 failed: $output"
[ "$output" = "live" ] \
|| fail "expected 'live' for verified-live-owner+running-containers, got '$output'"
}
@test "REAL: a reused owner PID with running containers is kept, not live (reuse guard)" {
_docker_ok || skip "docker daemon unavailable"
_ensure_test_image || skip "cannot obtain $TEST_IMAGE"
load_common
local slots="$XDG_STATE_HOME/copilotkit/showcase/slots"
mkdir -p "$slots/0"
echo "$TEST_PROJ" > "$slots/0/project"
# Reuse hazard: the recorded pid is THIS live process, but the recorded
# start-time fingerprint belongs to a DIFFERENT (now-gone) process. The
# start-time guard must catch the mismatch and treat the owner as gone.
echo "$$" > "$slots/0/pid"
echo "definitely-not-our-start-time" > "$slots/0/pid.start"
_start_real_project "$TEST_PROJ" || skip "could not start throwaway compose project"
[ -n "$(docker ps -q --filter "label=com.docker.compose.project=$TEST_PROJ")" ] \
|| skip "throwaway project has no running container — environment issue"
run _slot_liveness 0
[ "$status" -eq 0 ] || fail "_slot_liveness 0 failed: $output"
[ "$output" = "kept" ] \
|| fail "expected 'kept' for reused-PID+running-containers (start-time mismatch), got '$output'"
# And the table flags the reuse explicitly.
run _slot_state 0
local -a fields
IFS='|' read -ra fields <<< "$output"
[ "${fields[2]}" = "$$(reused)" ] \
|| fail "expected PID column '$$(reused)', got '${fields[2]}'"
}
@test "REAL: sweep leaves a kept stack standing but reaps it once classified stale" {
_docker_ok || skip "docker daemon unavailable"
_ensure_test_image || skip "cannot obtain $TEST_IMAGE"
load_common
local base="$XDG_STATE_HOME/copilotkit/showcase"
local slots="$base/slots"
mkdir -p "$slots/0"
echo "$TEST_PROJ" > "$slots/0/project"
local dead_pid
dead_pid="$(_make_dead_pid)"
echo "$dead_pid" > "$slots/0/pid"
_start_real_project "$TEST_PROJ" || skip "could not start throwaway compose project"
[ -n "$(docker ps -q --filter "label=com.docker.compose.project=$TEST_PROJ")" ] \
|| skip "throwaway project has no running container — environment issue"
# Part 1: a sweep must NOT reap a kept slot (Change 1 — no TTL yet, kept is
# always protected). The claim runs a sweep opportunistically.
_claim_isolate_slot
[ -d "$slots/0" ] || fail "sweep reaped a kept stack (dead owner + RUNNING containers)"
run cat "$slots/0/project"
[ "$output" = "$TEST_PROJ" ] || fail "kept slot 0 project mangled after sweep: $output"
# The claim landed on slot 1 (slot 0 reserved + kept-protected).
[ "$ISOLATE_SLOT" != "0" ] || fail "claim landed on the protected kept slot 0"
# Part 2: once the containers are gone, the same slot classifies stale and a
# sweep reaps it (this is the existing stopped-keeper reclamation path, now
# routed through the kept→(no containers)→stale transition). Compose the
# throwaway project down so no RUNNING containers protect it anymore.
docker compose -p "$TEST_PROJ" down --remove-orphans --volumes >/dev/null 2>&1 || true
run _slot_liveness 0
[ "$status" -eq 0 ] || fail "_slot_liveness 0 failed after teardown: $output"
[ "$output" = "stale" ] \
|| fail "expected 'stale' for dead owner + NO running containers, got '$output'"
}
# ── Change 1b: _slot_ports_free own-port regression (the rename hazard) ──────
@test "REAL: pinned claim onto a kept slot treats its OWN container ports as own (not foreign)" {
_docker_ok || skip "docker daemon unavailable"
_ensure_test_image || skip "cannot obtain $TEST_IMAGE"
command -v lsof >/dev/null 2>&1 || skip "lsof required for the port-probe path"
load_common
local slots="$XDG_STATE_HOME/copilotkit/showcase/slots"
# Pin slot 9. Constrain the probed port set to a SINGLE deterministic host
# port (override _slot_offset_ports for slot 9) so the test exercises the
# own-project filter against a real docker-held listener without flaking on
# whatever else happens to be bound on this host. real lsof + real docker ps
# still drive the filter decision.
TEST_PORT=39619
_slot_offset_ports() { printf '%d\n' "$TEST_PORT"; }
mkdir -p "$slots/9"
echo "$TEST_PROJ" > "$slots/9/project"
local dead_pid
dead_pid="$(_make_dead_pid)"
echo "$dead_pid" > "$slots/9/pid" # dead owner → this is a KEPT stack
# Real container holding the slot's (overridden) offset port on the host.
_start_real_project "$TEST_PROJ" "$TEST_PORT" || skip "could not start throwaway compose project"
[ -n "$(docker ps -q --filter "label=com.docker.compose.project=$TEST_PROJ")" ] \
|| skip "throwaway project has no running container — environment issue"
# Confirm the host port really is held (by docker) before asserting the filter.
lsof -nP -i :"$TEST_PORT" -sTCP:LISTEN >/dev/null 2>&1 \
|| skip "host port $TEST_PORT not observed as LISTEN — docker port-publish unavailable in this env"
# Liveness is `kept` (dead owner + running containers). With the own-project
# filter widened to live OR kept, the kept slot's own docker listener on its
# own port is NOT a foreign hold → _slot_ports_free returns 0 (free).
run _slot_liveness 9
[ "$output" = "kept" ] || fail "precondition: slot 9 should be 'kept', got '$output'"
run _slot_ports_free 9
[ "$status" -eq 0 ] \
|| fail "kept slot's OWN docker port treated as foreign (pre-fix bug): status=$status output=$output"
# End-to-end: a pinned claim onto the kept slot must NOT die "ports are held
# by a foreign process". The pinned EEXIST path reaps stale/inconclusive and
# retries — but `kept` is neither, so it reaches the port probe, which now
# accepts its own ports. (Pre-fix: liveness=='live'-only filter → die.)
export SHOWCASE_ISO_SLOT=9
run _claim_isolate_slot
[ "$status" -eq 0 ] \
|| fail "pinned claim onto kept slot died (own ports seen as foreign): $output"
[[ "$output" != *"held by a foreign process"* ]] \
|| fail "pinned claim reported its own kept-stack ports as foreign: $output"
}
@test "REAL: a foreign (non-docker) listener on a slot's port is still seen as held" {
_docker_ok || skip "docker daemon unavailable"
command -v lsof >/dev/null 2>&1 || skip "lsof required for the port-probe path"
command -v python3 >/dev/null 2>&1 || skip "python3 used to bind a real foreign listener"
load_common
local slots="$XDG_STATE_HOME/copilotkit/showcase/slots"
TEST_PORT=39620
_slot_offset_ports() { printf '%d\n' "$TEST_PORT"; }
mkdir -p "$slots/9"
echo "$TEST_PROJ" > "$slots/9/project"
echo "$$" > "$slots/9/pid"
_pid_start_time "$$" > "$slots/9/pid.start" # live verified owner → liveness 'live'
# Bind the port with a REAL non-docker process (python3) so the own-project
# docker filter does NOT apply — it must be reported as a foreign hold even
# though the slot's own liveness is live/kept.
python3 -c "
import socket, time, sys
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(('127.0.0.1', $TEST_PORT))
s.listen(1)
sys.stderr.write('bound\n'); sys.stderr.flush()
time.sleep(30)
" 2>"$BATS_TEST_TMPDIR/binder.err" &
local binder_pid=$!
# Wait for the binder to actually be listening (poll up to ~3s).
local i=0
while [ $i -lt 30 ]; do
if lsof -nP -i :"$TEST_PORT" -sTCP:LISTEN >/dev/null 2>&1; then break; fi
sleep 0.1; i=$((i+1))
done
if ! lsof -nP -i :"$TEST_PORT" -sTCP:LISTEN >/dev/null 2>&1; then
kill "$binder_pid" 2>/dev/null || true
skip "could not bind foreign listener on $TEST_PORT"
fi
run _slot_ports_free 9
local rc="$status"
kill "$binder_pid" 2>/dev/null || true
wait "$binder_pid" 2>/dev/null || true
[ "$rc" -ne 0 ] \
|| fail "a foreign (non-docker) listener on the slot's port was wrongly treated as free"
}
@@ -0,0 +1,405 @@
#!/usr/bin/env bats
# REAL-SURFACE tests for `showcase reap` (Change 2) in scripts/cli/cmd-reap.sh.
# These exercise the actual failure surface the spec mandates — real leaked
# compose projects under a temp XDG_STATE_HOME, real slot records, real running
# containers and named volumes — never vi.mock-style fakes.
#
# The bug being fixed: --keep'd / crashed isolated stacks accumulate with no
# tool to list or tear them down. `showcase reap` is that tool: dry-run by
# default (lists, changes nothing), --force executes, and it NEVER touches the
# base `showcase` stack or BuildKit resources.
#
# Hermeticity: every throwaway project uses a UNIQUE disposable name
# (showcase-isotest-s3-<rand>), NEVER the base `showcase` name, and every
# stack/volume/probe container this file creates is torn down in teardown().
# We are FIXING a stack-leak bug — these tests must not leak stacks.
#
# All real-docker tests `skip` cleanly when the daemon is unreachable or the
# tiny image cannot be obtained, so the suite stays green without docker.
fail() {
echo "$1"
return 1
}
TEST_IMAGE="alpine:3.20"
_docker_ok() {
command -v docker >/dev/null 2>&1 || return 1
docker info >/dev/null 2>&1 || return 1
return 0
}
_ensure_test_image() {
docker image inspect "$TEST_IMAGE" >/dev/null 2>&1 && return 0
docker pull "$TEST_IMAGE" >/dev/null 2>&1
}
setup() {
COMMON="$BATS_TEST_DIRNAME/../cli/_common.sh"
REAP="$BATS_TEST_DIRNAME/../cli/cmd-reap.sh"
export XDG_STATE_HOME="$BATS_TEST_TMPDIR/xdg"
export SHOWCASE_ROOT_OVERRIDE="$BATS_TEST_TMPDIR/root"
mkdir -p "$SHOWCASE_ROOT_OVERRIDE/shared"
cat > "$SHOWCASE_ROOT_OVERRIDE/shared/local-ports.json" <<'JSON'
{ "mastra": 3104 }
JSON
cat > "$SHOWCASE_ROOT_OVERRIDE/docker-compose.local.yml" <<'YML'
services:
aimock:
container_name: showcase-aimock
ports:
- "4010:4010"
YML
# Unique disposable names for this test's throwaway stacks — NEVER `showcase`.
RAND="${BATS_TEST_NUMBER}-$$-${RANDOM}"
TEST_PROJ_A="showcase-isotest-s3-${RAND}-a"
TEST_PROJ_B="showcase-isotest-s3-${RAND}-b"
# Fake base-stack + fake-buildkit probe projects to prove the safety guards.
FAKE_BASE="showcase" # the reserved base project name itself
FAKE_BASE_CTR="showcase-isotest-s3-${RAND}-fakebase"
FAKE_BK_CTR="buildx_buildkit_showcase-isotest-s3-${RAND}"
}
teardown() {
# Kill the live-owner helper process if a test started one.
[ -n "${LIVE_OWNER_PID:-}" ] && kill "$LIVE_OWNER_PID" >/dev/null 2>&1 || true
if command -v docker >/dev/null 2>&1; then
local p
for p in "${TEST_PROJ_A:-}" "${TEST_PROJ_B:-}"; do
[ -n "$p" ] && docker compose -p "$p" down --remove-orphans --volumes >/dev/null 2>&1 || true
done
# The fake-base + fake-buildkit probe containers are plain `docker run`
# containers (not compose stacks) — remove them by name. They are labelled
# with com.docker.compose.project so reap's scan would SEE them, but the
# guards must leave them standing; teardown removes them regardless.
[ -n "${FAKE_BASE_CTR:-}" ] && docker rm -f "$FAKE_BASE_CTR" >/dev/null 2>&1 || true
[ -n "${FAKE_BK_CTR:-}" ] && docker rm -f "$FAKE_BK_CTR" >/dev/null 2>&1 || true
# Defensive: should NEVER exist (the base name is reserved), but if a
# regression composed it down/up, clean any stray base-named volumes.
docker volume ls --filter "label=com.docker.compose.project=showcase" -q 2>/dev/null \
| grep -q . && docker compose -p showcase down --volumes >/dev/null 2>&1 || true
fi
}
load_reap() {
# shellcheck disable=SC1090
source "$COMMON"
# shellcheck disable=SC1090
source "$REAP"
SHOWCASE_ROOT="$SHOWCASE_ROOT_OVERRIDE"
COMPOSE_FILE="$SHOWCASE_ROOT/docker-compose.local.yml"
PORTS_FILE="$SHOWCASE_ROOT/shared/local-ports.json"
COMPOSE_CMD="docker compose -f $COMPOSE_FILE"
}
# Start a REAL one-container compose project + a hand-written slot record, so
# the project is a leaked iso stack reap must find. A named volume is attached
# so the volume-count + --volumes teardown are exercised.
_leak_iso_project() {
local proj="$1" slot="$2"
local cdir="$BATS_TEST_TMPDIR/compose-$proj"
mkdir -p "$cdir"
cat > "$cdir/docker-compose.yml" <<YML
services:
keeper:
image: $TEST_IMAGE
command: ["sleep", "infinity"]
volumes:
- data:/data
volumes:
data:
YML
docker compose -f "$cdir/docker-compose.yml" -p "$proj" up -d >/dev/null 2>&1 || return 1
# Slot record (the canonical pointer reap routes teardown through). A dead
# owner pid → the slot classifies kept/stale; --force / --all reap it.
local slots="$XDG_STATE_HOME/copilotkit/showcase/slots"
mkdir -p "$slots/$slot"
echo "$proj" > "$slots/$slot/project"
# provably-dead pid: spawn + reap.
local p; bash -c 'exit 0' & p=$!; wait "$p" 2>/dev/null || true
echo "$p" > "$slots/$slot/pid"
# A run dir, so reap's rundir removal is exercised too.
mkdir -p "$XDG_STATE_HOME/copilotkit/showcase/runs/$proj"
}
_proj_has_container() {
[ -n "$(docker ps -a -q --filter "label=com.docker.compose.project=$1" 2>/dev/null)" ]
}
# Stand up a REAL iso project owned by a LIVE owner: a running container PLUS a
# slot record whose pid+pid.start fingerprint a process we control and keep
# alive for the duration of the test. With running containers and an alive,
# start-time-verified owner, _slot_liveness classifies the slot as `live` — the
# "actively owned, in use" class reap must PRESERVE. The owner is a real
# backgrounded `sleep` process; its pid is recorded in LIVE_OWNER_PID and killed
# in teardown(). Returns non-zero (for `skip`) if the container won't start.
_leak_live_iso_project() {
local proj="$1" slot="$2"
local cdir="$BATS_TEST_TMPDIR/compose-$proj"
mkdir -p "$cdir"
cat > "$cdir/docker-compose.yml" <<YML
services:
keeper:
image: $TEST_IMAGE
command: ["sleep", "infinity"]
volumes:
- data:/data
volumes:
data:
YML
docker compose -f "$cdir/docker-compose.yml" -p "$proj" up -d >/dev/null 2>&1 || return 1
local slots="$XDG_STATE_HOME/copilotkit/showcase/slots"
mkdir -p "$slots/$slot"
echo "$proj" > "$slots/$slot/project"
mkdir -p "$XDG_STATE_HOME/copilotkit/showcase/runs/$proj"
# A REAL backgrounded process as the live owner; record a start-time-verified
# fingerprint (pid + pid.start) so _owner_liveness reads `alive`.
sleep 300 &
LIVE_OWNER_PID=$!
type _pid_start_time >/dev/null 2>&1 || source "$COMMON"
echo "$LIVE_OWNER_PID" > "$slots/$slot/pid"
_pid_start_time "$LIVE_OWNER_PID" > "$slots/$slot/pid.start"
}
# ── RED → GREEN: dry-run lists; --force tears down; guards hold ───────────────
@test "REAL: reap dry-run lists leaked projects and changes NOTHING (RED baseline)" {
_docker_ok || skip "docker daemon unavailable"
_ensure_test_image || skip "cannot obtain $TEST_IMAGE"
load_reap
_leak_iso_project "$TEST_PROJ_A" 11 || skip "could not start throwaway project A"
_leak_iso_project "$TEST_PROJ_B" 12 || skip "could not start throwaway project B"
_proj_has_container "$TEST_PROJ_A" || skip "project A has no container — env issue"
_proj_has_container "$TEST_PROJ_B" || skip "project B has no container — env issue"
# Dry-run (no --force): both projects appear in the plan with a non-zero
# container count, and NOTHING is torn down.
run cmd_reap
[ "$status" -eq 0 ] || fail "reap dry-run failed: $output"
[[ "$output" == *"$TEST_PROJ_A"* ]] || fail "dry-run plan omitted project A: $output"
[[ "$output" == *"$TEST_PROJ_B"* ]] || fail "dry-run plan omitted project B: $output"
[[ "$output" == *"DRY-RUN"* ]] || fail "dry-run banner missing: $output"
# Dry-run changed nothing — both leaked stacks still standing.
_proj_has_container "$TEST_PROJ_A" || fail "dry-run reaped project A (must change nothing)"
_proj_has_container "$TEST_PROJ_B" || fail "dry-run reaped project B (must change nothing)"
}
@test "REAL: reap --force tears down leaked stacks (volumes, slot, run dirs) while base showcase + buildkit are UNTOUCHED (GREEN)" {
_docker_ok || skip "docker daemon unavailable"
_ensure_test_image || skip "cannot obtain $TEST_IMAGE"
load_reap
_leak_iso_project "$TEST_PROJ_A" 11 || skip "could not start throwaway project A"
_leak_iso_project "$TEST_PROJ_B" 12 || skip "could not start throwaway project B"
_proj_has_container "$TEST_PROJ_A" || skip "project A has no container — env issue"
_proj_has_container "$TEST_PROJ_B" || skip "project B has no container — env issue"
# A fake BASE-stack container, labelled exactly like the live default stack
# (com.docker.compose.project=showcase). reap MUST NEVER touch it.
docker run -d --name "$FAKE_BASE_CTR" \
--label "com.docker.compose.project=showcase" \
"$TEST_IMAGE" sleep infinity >/dev/null 2>&1 || skip "could not start fake-base probe"
# A fake BuildKit container (buildx_buildkit_*). reap MUST NEVER touch it.
docker run -d --name "$FAKE_BK_CTR" \
--label "com.docker.compose.project=$FAKE_BK_CTR" \
"$TEST_IMAGE" sleep infinity >/dev/null 2>&1 || skip "could not start fake-buildkit probe"
# Execute. --all reaps every identified iso project regardless of TTL/keep —
# exactly the "tear down everything isolated now" path the spec mandates.
run cmd_reap --all --force
[ "$status" -eq 0 ] || fail "reap --all --force failed: $output"
# Both leaked stacks fully gone: containers, named volumes, slot + run dirs.
_proj_has_container "$TEST_PROJ_A" && fail "project A containers survived --force"
_proj_has_container "$TEST_PROJ_B" && fail "project B containers survived --force"
[ -z "$(docker volume ls --filter "label=com.docker.compose.project=$TEST_PROJ_A" -q 2>/dev/null)" ] \
|| fail "project A named volume survived --force"
[ -z "$(docker volume ls --filter "label=com.docker.compose.project=$TEST_PROJ_B" -q 2>/dev/null)" ] \
|| fail "project B named volume survived --force"
[ ! -d "$XDG_STATE_HOME/copilotkit/showcase/slots/11" ] || fail "slot 11 dir survived --force"
[ ! -d "$XDG_STATE_HOME/copilotkit/showcase/slots/12" ] || fail "slot 12 dir survived --force"
[ ! -d "$XDG_STATE_HOME/copilotkit/showcase/runs/$TEST_PROJ_A" ] || fail "run dir A survived --force"
[ ! -d "$XDG_STATE_HOME/copilotkit/showcase/runs/$TEST_PROJ_B" ] || fail "run dir B survived --force"
# HARD SAFETY: the base-showcase container and the buildkit container are
# STILL RUNNING — reap must never have touched them.
[ -n "$(docker ps -q --filter "name=^${FAKE_BASE_CTR}\$" 2>/dev/null)" ] \
|| fail "reap TORE DOWN the base 'showcase' stack (catastrophic safety failure)"
[ -n "$(docker ps -q --filter "name=^${FAKE_BK_CTR}\$" 2>/dev/null)" ] \
|| fail "reap TORE DOWN a buildx_buildkit_* container (safety failure)"
}
@test "REAL: a single named target reaps exactly that project, leaving siblings standing" {
_docker_ok || skip "docker daemon unavailable"
_ensure_test_image || skip "cannot obtain $TEST_IMAGE"
load_reap
_leak_iso_project "$TEST_PROJ_A" 11 || skip "could not start throwaway project A"
_leak_iso_project "$TEST_PROJ_B" 12 || skip "could not start throwaway project B"
_proj_has_container "$TEST_PROJ_A" || skip "project A has no container — env issue"
_proj_has_container "$TEST_PROJ_B" || skip "project B has no container — env issue"
run cmd_reap "$TEST_PROJ_A" --force
[ "$status" -eq 0 ] || fail "single-target reap failed: $output"
_proj_has_container "$TEST_PROJ_A" && fail "named target project A survived its reap"
_proj_has_container "$TEST_PROJ_B" || fail "sibling project B was wrongly reaped by a single-target reap"
}
@test "REAL: single-target dry-run does NOT mislabel a harness-owned sibling as unidentified (RED→GREEN)" {
_docker_ok || skip "docker daemon unavailable"
_ensure_test_image || skip "cannot obtain $TEST_IMAGE"
load_reap
# TWO real harness-owned iso projects (compose-managed, slot records). Both
# are unambiguously harness-owned via their slot records, so NEITHER should
# ever appear in the "Unidentified — NOT harness-owned" review listing.
_leak_iso_project "$TEST_PROJ_A" 11 || skip "could not start throwaway project A"
_leak_iso_project "$TEST_PROJ_B" 12 || skip "could not start throwaway project B"
_proj_has_container "$TEST_PROJ_A" || skip "project A has no container — env issue"
_proj_has_container "$TEST_PROJ_B" || skip "project B has no container — env issue"
# Single-target dry-run on A. Before the fix, the unidentified listing was
# computed against the narrowed single-target set ({A}), so harness-owned B
# was falsely surfaced as "NOT harness-owned". After the fix, the listing is
# computed against the FULL identification union, so B is excluded.
run cmd_reap "$TEST_PROJ_A"
[ "$status" -eq 0 ] || fail "single-target dry-run failed: $output"
# Isolate the unidentified review section (everything after its warn header).
local unident_section="${output##*Unidentified compose projects}"
# The header is only emitted when there ARE unidentified projects; if the
# whole output lacks it, the section is empty (the trivially-correct case).
if [[ "$output" == *"Unidentified compose projects"* ]]; then
[[ "$unident_section" != *"$TEST_PROJ_B"* ]] \
|| fail "harness-owned sibling B was mislabeled 'NOT harness-owned' in single-target mode: $output"
[[ "$unident_section" != *"$TEST_PROJ_A"* ]] \
|| fail "the single target A was mislabeled 'NOT harness-owned': $output"
fi
# Sanity: dry-run changed nothing — both stacks still standing.
_proj_has_container "$TEST_PROJ_A" || fail "dry-run reaped project A (must change nothing)"
_proj_has_container "$TEST_PROJ_B" || fail "dry-run reaped project B (must change nothing)"
}
@test "REAL: an explicit target with a LIVE owner is PRESERVED (warned, not torn down) without an override" {
_docker_ok || skip "docker daemon unavailable"
_ensure_test_image || skip "cannot obtain $TEST_IMAGE"
load_reap
# A real iso project actively owned by a LIVE process (live pid+pid.start +
# running container) → _slot_liveness reads `live`.
_leak_live_iso_project "$TEST_PROJ_A" 11 || skip "could not start live-owner project A"
_proj_has_container "$TEST_PROJ_A" || skip "project A has no container — env issue"
[ "$(_slot_liveness 11)" = "live" ] || skip "project A did not classify as live — env issue"
# Naming a LIVE-owned project explicitly must NOT silently tear it down: reap
# preserves it and emits a loud live-owner warning naming the project.
run cmd_reap "$TEST_PROJ_A" --force
[ "$status" -eq 0 ] || fail "reap of live target failed unexpectedly: $output"
_proj_has_container "$TEST_PROJ_A" \
|| fail "reap TORE DOWN a LIVE-owned explicit target without an override (safety failure): $output"
[[ "$output" == *"$TEST_PROJ_A"* && "$output" == *"live owner"* ]] \
|| fail "no live-owner warning naming the project was emitted: $output"
# State preserved too — slot + run dir intact.
[ -d "$XDG_STATE_HOME/copilotkit/showcase/slots/11" ] || fail "live target's slot dir was removed"
[ -d "$XDG_STATE_HOME/copilotkit/showcase/runs/$TEST_PROJ_A" ] || fail "live target's run dir was removed"
}
@test "REAL: an explicit LIVE target IS torn down WITH the --include-live override" {
_docker_ok || skip "docker daemon unavailable"
_ensure_test_image || skip "cannot obtain $TEST_IMAGE"
load_reap
_leak_live_iso_project "$TEST_PROJ_A" 11 || skip "could not start live-owner project A"
_proj_has_container "$TEST_PROJ_A" || skip "project A has no container — env issue"
[ "$(_slot_liveness 11)" = "live" ] || skip "project A did not classify as live — env issue"
# The explicit override flag opts in to reaping a live-owner target.
run cmd_reap "$TEST_PROJ_A" --force --include-live
[ "$status" -eq 0 ] || fail "reap --include-live of live target failed: $output"
_proj_has_container "$TEST_PROJ_A" \
&& fail "live target survived --include-live (override must tear it down): $output"
[ -d "$XDG_STATE_HOME/copilotkit/showcase/slots/11" ] \
&& fail "live target's slot dir survived --include-live: $output"
return 0
}
@test "REAL: the self-id label identifies a record-LESS orphan (no slot, no run dir)" {
_docker_ok || skip "docker daemon unavailable"
_ensure_test_image || skip "cannot obtain $TEST_IMAGE"
load_reap
# A REAL compose project carrying ONLY the com.copilotkit.showcase.isolate=1
# label apply_isolation stamps — NO slot record, NO run dir. This is the
# user-supplied `--isolate <name>` orphan whose slot record was lost: the
# label scan is the ONLY mechanism that can find it.
local cdir="$BATS_TEST_TMPDIR/compose-$TEST_PROJ_A"
mkdir -p "$cdir"
cat > "$cdir/docker-compose.yml" <<YML
services:
keeper:
image: $TEST_IMAGE
command: ["sleep", "infinity"]
labels:
com.copilotkit.showcase.isolate: "1"
YML
docker compose -f "$cdir/docker-compose.yml" -p "$TEST_PROJ_A" up -d >/dev/null 2>&1 \
|| skip "could not start label-only orphan project"
_proj_has_container "$TEST_PROJ_A" || skip "orphan has no container — env issue"
# Identified via the label scan despite no slot record → in the plan.
run cmd_reap
[ "$status" -eq 0 ] || fail "reap dry-run failed: $output"
[[ "$output" == *"$TEST_PROJ_A"* ]] || fail "label-only orphan not identified by the label scan: $output"
# And --force reaps it (real compose project → compose down removes it).
run cmd_reap --force
[ "$status" -eq 0 ] || fail "reap --force failed: $output"
_proj_has_container "$TEST_PROJ_A" && fail "label-only orphan survived --force"
return 0
}
@test "REAL: refusing to reap the reserved base 'showcase' name as an explicit target" {
load_reap
run cmd_reap showcase --force
[ "$status" -ne 0 ] || fail "reap allowed the reserved base 'showcase' target (must refuse)"
[[ "$output" == *"showcase"* ]] || fail "refusal message should name 'showcase': $output"
}
# ── An explicit target with NOTHING to tear down must not claim a phantom reap ──
# A name with no slot record, no run dir, and (when docker is reachable) zero
# containers + zero volumes classifies `inconclusive`. Tearing it down removes
# nothing, so reap must report nothing-to-reap and count 0 — not "Reaped 1".
@test "REAL: reap --force on a nonexistent target reaps NOTHING and counts 0 (no phantom reap)" {
load_reap
# A syntactically valid, harness-safe, but entirely nonexistent project name.
local missing="showcase-isotest-s3-${RAND}-ghost"
run cmd_reap "$missing" --force
[ "$status" -eq 0 ] || fail "reap of a nonexistent target should exit 0: $output"
[[ "$output" == *"Reaped 1"* ]] && fail "reap claimed a phantom teardown (Reaped 1) for a nonexistent target: $output"
[[ "$output" == *"Nothing to reap"* ]] \
|| fail "reap should report nothing-to-reap for a nonexistent target: $output"
}
# A REAL existing target must still report reaped:1 (happy path preserved).
@test "REAL: reap --force on a REAL existing target still counts it as reaped (happy path)" {
_docker_ok || skip "docker daemon unavailable"
_ensure_test_image || skip "cannot obtain $TEST_IMAGE"
load_reap
_leak_iso_project "$TEST_PROJ_A" 11 || skip "could not start throwaway project A"
_proj_has_container "$TEST_PROJ_A" || skip "project A has no container — env issue"
run cmd_reap "$TEST_PROJ_A" --force
[ "$status" -eq 0 ] || fail "single-target reap failed: $output"
[[ "$output" == *"Reaped 1"* ]] || fail "real target should report Reaped 1: $output"
_proj_has_container "$TEST_PROJ_A" && fail "real target survived its reap"
return 0
}
@@ -0,0 +1,264 @@
#!/usr/bin/env bats
# REAL-SURFACE TTL tests for Change 3 (ISOLATE_KEEP_TTL) in
# scripts/cli/_common.sh. These mirror isolate-liveness-real.bats: real slot
# dirs under a temp XDG_STATE_HOME, a real DEAD owning PID (spawn-then-`wait`),
# and a REAL one-container `docker compose` project so containers ARE running.
#
# Change 3: a `kept` stack (running containers + dead/unverifiable owner — a
# forgotten `--keep` leak) is protected only until it outlives ISOLATE_KEEP_TTL
# (default 4h). The TTL age anchors on the slot's `pid`-file mtime, which is
# aged via `touch -t`:
# * pid mtime now-5h (PAST the 4h TTL) → liveness flips kept → stale, the
# sweep reaps it (containers + volumes + slot gone) and emits a LOUD warning
# naming project / age / TTL.
# * pid mtime now-1h (WITHIN the 4h TTL) → still `kept`, sweep leaves it alone.
#
# Hermeticity: every throwaway compose project uses a UNIQUE disposable name
# (showcase-isotest-s2-<rand>), NEVER the base `showcase` name, and is torn
# down in teardown(). All real-docker tests `skip` cleanly when docker is
# unreachable or the test image cannot be obtained.
fail() {
echo "$1"
return 1
}
TEST_IMAGE="alpine:3.20"
_docker_ok() {
command -v docker >/dev/null 2>&1 || return 1
docker info >/dev/null 2>&1 || return 1
return 0
}
_ensure_test_image() {
docker image inspect "$TEST_IMAGE" >/dev/null 2>&1 && return 0
docker pull "$TEST_IMAGE" >/dev/null 2>&1
}
setup() {
COMMON="$BATS_TEST_DIRNAME/../cli/_common.sh"
export XDG_STATE_HOME="$BATS_TEST_TMPDIR/xdg"
export SHOWCASE_ROOT_OVERRIDE="$BATS_TEST_TMPDIR/root"
mkdir -p "$SHOWCASE_ROOT_OVERRIDE/shared"
cat > "$SHOWCASE_ROOT_OVERRIDE/shared/local-ports.json" <<'JSON'
{ "mastra": 3104 }
JSON
cat > "$SHOWCASE_ROOT_OVERRIDE/docker-compose.local.yml" <<'YML'
services:
aimock:
container_name: showcase-aimock
ports:
- "4010:4010"
YML
# Unique disposable project name — NEVER base `showcase`. S2-namespaced.
TEST_PROJ="showcase-isotest-s2-${BATS_TEST_NUMBER}-$$-${RANDOM}"
}
teardown() {
if command -v docker >/dev/null 2>&1 && [ -n "${TEST_PROJ:-}" ]; then
docker compose -p "$TEST_PROJ" down --remove-orphans --volumes >/dev/null 2>&1 || true
fi
}
load_common() {
# shellcheck disable=SC1090
source "$COMMON"
SHOWCASE_ROOT="$SHOWCASE_ROOT_OVERRIDE"
COMPOSE_FILE="$SHOWCASE_ROOT/docker-compose.local.yml"
PORTS_FILE="$SHOWCASE_ROOT/shared/local-ports.json"
COMPOSE_CMD="docker compose -f $COMPOSE_FILE"
}
# Start a REAL one-container compose project named <proj> running `sleep
# infinity` so the container is RUNNING and carries the compose-project label.
_start_real_project() {
local proj="$1"
local cdir="$BATS_TEST_TMPDIR/compose-$proj"
mkdir -p "$cdir"
cat > "$cdir/docker-compose.yml" <<YML
services:
keeper:
image: $TEST_IMAGE
command: ["sleep", "infinity"]
YML
docker compose -f "$cdir/docker-compose.yml" -p "$proj" up -d >/dev/null 2>&1
}
# Spawn a short-lived process, wait for exit, echo its (now dead) pid.
_make_dead_pid() {
local p
bash -c 'exit 0' &
p=$!
wait "$p" 2>/dev/null || true
if kill -0 "$p" 2>/dev/null; then
skip "PID $p was recycled to a live process — dead-PID fixture invalid"
fi
echo "$p"
}
# _age_pid_file <pidfile> <hours_ago> — set the pid file's mtime to N hours in
# the past (the TTL anchor). Cross-platform `touch -t [[CC]YY]MMDDhhmm.ss`.
_age_pid_file() {
local pidfile="$1" hours_ago="$2" stamp
if [[ "$OSTYPE" == darwin* ]]; then
stamp="$(date -v-"${hours_ago}"H +%Y%m%d%H%M.%S)"
else
stamp="$(date -d "${hours_ago} hours ago" +%Y%m%d%H%M.%S)"
fi
touch -t "$stamp" "$pidfile"
}
# ── Change 3: TTL on running kept stacks ─────────────────────────────────────
@test "REAL: a kept slot aged PAST the keep TTL classifies stale (not kept)" {
_docker_ok || skip "docker daemon unavailable"
_ensure_test_image || skip "cannot obtain $TEST_IMAGE"
load_common
local slots="$XDG_STATE_HOME/copilotkit/showcase/slots"
mkdir -p "$slots/0"
echo "$TEST_PROJ" > "$slots/0/project"
local dead_pid
dead_pid="$(_make_dead_pid)"
echo "$dead_pid" > "$slots/0/pid"
_start_real_project "$TEST_PROJ" || skip "could not start throwaway compose project"
[ -n "$(docker ps -q --filter "label=com.docker.compose.project=$TEST_PROJ")" ] \
|| skip "throwaway project has no running container — environment issue"
# Within TTL (pid mtime ~now): a kept stack is protected.
run _slot_liveness 0
[ "$status" -eq 0 ] || fail "_slot_liveness 0 failed: $output"
[ "$output" = "kept" ] \
|| fail "expected 'kept' for fresh dead-owner+running-containers slot, got '$output'"
# Age the pid-file anchor to 5h ago — past the 4h default TTL.
_age_pid_file "$slots/0/pid" 5
# PAST the TTL: the kept stack reclassifies stale (the kept→stale transition).
run _slot_liveness 0
[ "$status" -eq 0 ] || fail "_slot_liveness 0 failed after aging: $output"
[ "$output" = "stale" ] \
|| fail "expected 'stale' for kept slot aged past keep TTL, got '$output' (pre-TTL bug returns 'kept')"
}
@test "REAL: sweep reaps a kept stack past the TTL and emits the loud project/age/TTL warning" {
_docker_ok || skip "docker daemon unavailable"
_ensure_test_image || skip "cannot obtain $TEST_IMAGE"
load_common
local slots="$XDG_STATE_HOME/copilotkit/showcase/slots"
mkdir -p "$slots/0"
echo "$TEST_PROJ" > "$slots/0/project"
local dead_pid
dead_pid="$(_make_dead_pid)"
echo "$dead_pid" > "$slots/0/pid"
_start_real_project "$TEST_PROJ" || skip "could not start throwaway compose project"
[ -n "$(docker ps -q --filter "label=com.docker.compose.project=$TEST_PROJ")" ] \
|| skip "throwaway project has no running container — environment issue"
# Age the kept slot past the TTL so the sweep will reclassify + reap it.
_age_pid_file "$slots/0/pid" 5
# Sweep (real path the claim runs opportunistically). Capture its output.
run _sweep_isolate_slots
[ "$status" -eq 0 ] || fail "_sweep_isolate_slots failed: $output"
# The loud warning names the project, an age, and the keep TTL.
[[ "$output" == *"reaping kept stack '$TEST_PROJ'"* ]] \
|| fail "warning did not name the project: $output"
[[ "$output" == *"keep TTL ${ISOLATE_KEEP_TTL}s"* ]] \
|| fail "warning did not name the keep TTL: $output"
[[ "$output" == *"age "*"s >"* ]] \
|| fail "warning did not name the age: $output"
[[ "$output" == *"forgotten --keep leak"* ]] \
|| fail "warning did not flag the forgotten --keep leak: $output"
# Reaped: containers gone, volumes gone, slot dir gone.
[ -z "$(docker ps -aq --filter "label=com.docker.compose.project=$TEST_PROJ")" ] \
|| fail "kept stack's containers survived the past-TTL sweep"
[ -z "$(docker volume ls -q --filter "label=com.docker.compose.project=$TEST_PROJ")" ] \
|| fail "kept stack's volumes survived the past-TTL sweep"
[ ! -d "$slots/0" ] \
|| fail "kept stack's slot dir survived the past-TTL sweep"
}
@test "REAL: sweep PRESERVES a kept stack still within the TTL" {
_docker_ok || skip "docker daemon unavailable"
_ensure_test_image || skip "cannot obtain $TEST_IMAGE"
load_common
local slots="$XDG_STATE_HOME/copilotkit/showcase/slots"
mkdir -p "$slots/0"
echo "$TEST_PROJ" > "$slots/0/project"
local dead_pid
dead_pid="$(_make_dead_pid)"
echo "$dead_pid" > "$slots/0/pid"
_start_real_project "$TEST_PROJ" || skip "could not start throwaway compose project"
[ -n "$(docker ps -q --filter "label=com.docker.compose.project=$TEST_PROJ")" ] \
|| skip "throwaway project has no running container — environment issue"
# Age to 1h ago — WITHIN the 4h default TTL: still kept, must survive a sweep.
_age_pid_file "$slots/0/pid" 1
run _slot_liveness 0
[ "$output" = "kept" ] || fail "precondition: within-TTL slot should be 'kept', got '$output'"
run _sweep_isolate_slots
[ "$status" -eq 0 ] || fail "_sweep_isolate_slots failed: $output"
[[ "$output" != *"reaping kept stack"* ]] \
|| fail "sweep wrongly reaped a within-TTL kept stack: $output"
[ -d "$slots/0" ] \
|| fail "within-TTL kept slot dir was reaped"
[ -n "$(docker ps -q --filter "label=com.docker.compose.project=$TEST_PROJ")" ] \
|| fail "within-TTL kept stack's containers were torn down"
run cat "$slots/0/project"
[ "$output" = "$TEST_PROJ" ] || fail "within-TTL kept slot project mangled: $output"
}
# Mandatory-fallback regression: a kept slot whose `pid` and `project` files are
# unstattable must still age out via the slot-dir mtime → ISOLATE_STALE_THRESHOLD
# path rather than living forever. Here the pid/project mtimes are aged past the
# 4h TTL too, but the point is the anchor chain never returns empty for a
# present slot, so the transition fires. (Direct empty-anchor injection is not
# possible on a real slot dir — every present file is stattable — so we assert
# the chain's resilience: removing pid/project still yields a stale verdict via
# slot-dir mtime under a low ISOLATE_STALE_THRESHOLD.)
@test "REAL: a kept stack with no pid/project anchor still ages out via the fallback chain" {
_docker_ok || skip "docker daemon unavailable"
_ensure_test_image || skip "cannot obtain $TEST_IMAGE"
load_common
local slots="$XDG_STATE_HOME/copilotkit/showcase/slots"
mkdir -p "$slots/0"
echo "$TEST_PROJ" > "$slots/0/project"
local dead_pid
dead_pid="$(_make_dead_pid)"
echo "$dead_pid" > "$slots/0/pid"
_start_real_project "$TEST_PROJ" || skip "could not start throwaway compose project"
[ -n "$(docker ps -q --filter "label=com.docker.compose.project=$TEST_PROJ")" ] \
|| skip "throwaway project has no running container — environment issue"
# Force the anchor chain onto its LAST link: pid + project both aged past the
# TTL. _kept_slot_age must pick up the pid mtime (first stattable link) and
# return a > TTL age → stale. This exercises that a present slot always yields
# a numeric age (never empty) so the transition cannot silently no-op.
_age_pid_file "$slots/0/pid" 6
_age_pid_file "$slots/0/project" 6
run _kept_slot_age 0
[ "$status" -eq 0 ] || fail "_kept_slot_age 0 failed: $output"
[[ "$output" =~ ^[0-9]+$ ]] || fail "_kept_slot_age returned non-numeric (would skip TTL): '$output'"
[ "$output" -gt "$ISOLATE_KEEP_TTL" ] || fail "expected age > TTL, got '$output'"
run _slot_liveness 0
[ "$output" = "stale" ] || fail "expected 'stale' via fallback-chain anchor, got '$output'"
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,99 @@
#!/usr/bin/env bats
# Tests for lint-prod-gate.sh — the post-promote pinned-ness gate extracted from
# .github/workflows/showcase_promote.yml (UNIT U12, spec §8.2).
#
# Core invariant under test: after a promote, EVERY prod service must be pinned
# to an immutable `@sha256:` digest. A born-on-`:latest` service (the
# deploy-to-railway.ts unpinned-source case, spec R-E) must be caught LOUD — the
# gate exits non-zero so the verify-prod job (and the run) goes red. A
# fully-pinned fleet passes (exit 0).
#
# The gate is a thin wrapper around `bin/railway lint-prod`, whose exit-code
# contract IS the assertion: exit 1 on findings (an unpinned prod service), exit
# 0 when all pinned, exit 2 on a hard error. The real `bin/railway` is replaced
# on PATH-independent terms via RAILWAY_BIN, pointed at a stub that mimics those
# exit codes. The gate's job is to (a) invoke `lint-prod` with the right
# subcommand and (b) FAIL the step (propagate the non-zero) when lint-prod finds
# an unpinned service — never swallow it.
#
# NB on assertion gating: bats does NOT run test bodies under `set -e`. Only the
# FINAL command decides pass/fail, so every non-final assertion is written
# `[[ ... ]] || fail "msg"` to force a hard failure with a diagnostic. A bare
# `[[ ... ]]` on a non-final line is a silent no-op (false-green).
# fail <msg> — print the message to the bats failure stream and abort the test.
fail() {
echo "$1" >&2
return 1
}
setup() {
SCRIPT="$BATS_TEST_DIRNAME/../lint-prod-gate.sh"
[ -x "$SCRIPT" ] || fail "lint-prod-gate.sh missing or not executable at $SCRIPT"
STUB_DIR="$BATS_TEST_TMPDIR/stub"
mkdir -p "$STUB_DIR"
# Unpinned stub: mimics `bin/railway lint-prod` finding a born-on-:latest prod
# service — prints a finding and exits 1 (the lint-prod findings contract).
# Also asserts the gate invokes it with the `lint-prod` subcommand, so an
# arg-order regression in the wrapper is caught.
cat > "$STUB_DIR/railway-unpinned" <<'STUB'
#!/usr/bin/env bash
[ "$1" = "lint-prod" ] || { echo "expected first arg 'lint-prod', got '$1'" >&2; exit 99; }
echo "deploy-to-railway: not digest-pinned (image=ghcr.io/copilotkit/showcase-x:latest)"
echo "DRIFT: 1 production service(s) not digest-pinned."
exit 1
STUB
chmod +x "$STUB_DIR/railway-unpinned"
# Pinned stub: every prod service on an @sha256: digest — exits 0.
cat > "$STUB_DIR/railway-pinned" <<'STUB'
#!/usr/bin/env bash
[ "$1" = "lint-prod" ] || { echo "expected first arg 'lint-prod', got '$1'" >&2; exit 99; }
echo "OK: all production services digest-pinned."
exit 0
STUB
chmod +x "$STUB_DIR/railway-pinned"
# Error stub: lint-prod hit a hard error (auth/GraphQL) — exits 2. The gate
# must treat a non-zero (incl. 2) as a hard failure, never a pass.
cat > "$STUB_DIR/railway-error" <<'STUB'
#!/usr/bin/env bash
[ "$1" = "lint-prod" ] || { echo "expected first arg 'lint-prod', got '$1'" >&2; exit 99; }
echo "graphql error: boom" >&2
exit 2
STUB
chmod +x "$STUB_DIR/railway-error"
}
@test "unpinned prod service -> gate fails loud (non-zero, finding surfaced)" {
run env RAILWAY_BIN="$STUB_DIR/railway-unpinned" bash "$SCRIPT"
[ "$status" -ne 0 ] || fail "expected non-zero exit on unpinned prod service, got $status"
[[ "$output" == *"not digest-pinned"* ]] || fail "finding not surfaced: $output"
}
@test "fully-pinned fleet -> gate passes (exit 0)" {
run env RAILWAY_BIN="$STUB_DIR/railway-pinned" bash "$SCRIPT"
[ "$status" -eq 0 ] || fail "expected exit 0 on a fully-pinned fleet, got $status ($output)"
[[ "$output" == *"all production services digest-pinned"* ]] || fail "OK line not surfaced: $output"
}
@test "lint-prod hard error (exit 2) -> gate fails (never swallowed)" {
run env RAILWAY_BIN="$STUB_DIR/railway-error" bash "$SCRIPT"
[ "$status" -ne 0 ] || fail "expected non-zero exit on a lint-prod hard error, got $status"
}
@test "missing/non-executable RAILWAY_BIN -> fails loud (not a vacuous pass)" {
run env RAILWAY_BIN="$STUB_DIR/does-not-exist" bash "$SCRIPT"
[ "$status" -ne 0 ] || fail "expected non-zero exit when RAILWAY_BIN is missing, got $status"
[[ "$output" == *"missing or not executable"* ]] || fail "missing-binary error not surfaced: $output"
}
@test "the workflow wires the gate into the verify-prod job" {
# Guard against the gate being orphaned (script present + tested, but never
# invoked by the promote workflow). Assert the verify-prod job actually calls
# lint-prod-gate.sh as a post-promote step.
WF="$BATS_TEST_DIRNAME/../../../.github/workflows/showcase_promote.yml"
[ -f "$WF" ] || fail "promote workflow missing at $WF"
grep -q "lint-prod-gate.sh" "$WF" || fail "showcase_promote.yml does not invoke lint-prod-gate.sh"
}
@@ -0,0 +1,316 @@
import { afterEach, describe, expect, it } from "vitest";
import { execSync } from "node:child_process";
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
// Repo root is two directories above this test file
// (showcase/scripts/__tests__/X -> showcase/scripts -> showcase -> <repo>).
const REPO_ROOT = resolve(import.meta.dirname, "..", "..", "..");
const CONFIG_PATH = join(REPO_ROOT, ".oxlintrc.json");
const RULE_MODULE_PATH = join(
REPO_ROOT,
"packages",
"react-ui",
"oxlint-rules",
"no-public-env-shell-read.mjs",
);
// Import BANNED_KEYS from the rule module itself (rather than hand-mirroring
// the list) so the table-driven coverage cannot drift from the rule's true
// banned set. If someone adds a new banned key to the rule and forgets the
// test, the test set still expands automatically; if someone removes a key
// from the rule, the test set contracts. The assertion is "the rule is
// exhaustively table-driven against its own banned set."
const ruleModule = (await import(pathToFileURL(RULE_MODULE_PATH).href)) as {
BANNED_KEYS: Set<string>;
};
if (
!ruleModule.BANNED_KEYS ||
!(ruleModule.BANNED_KEYS instanceof Set) ||
ruleModule.BANNED_KEYS.size === 0
) {
throw new Error(
`Rule module did not export a non-empty BANNED_KEYS Set: ${RULE_MODULE_PATH}`,
);
}
const BANNED_KEYS = [...ruleModule.BANNED_KEYS] as readonly string[];
const ALLOWED_KEYS = [
"NEXT_PUBLIC_COMMIT_SHA",
"NEXT_PUBLIC_BRANCH",
"NEXT_PUBLIC_LOCAL_BACKENDS",
] as const;
// Track every fixture we stage so afterEach reaps them — even on a thrown
// assertion mid-test. Without a centralized tracker, a thrown expect()
// inside a try/finally branch can still leave a file behind if the cleanup
// path itself diverges across tests.
const stagedPaths: string[] = [];
function stageFile(absPath: string, source: string): void {
mkdirSync(dirname(absPath), { recursive: true });
writeFileSync(absPath, source, "utf8");
stagedPaths.push(absPath);
}
afterEach(() => {
while (stagedPaths.length > 0) {
const p = stagedPaths.pop();
if (p) rmSync(p, { force: true });
}
});
interface LintOutcome {
exitCode: number;
combined: string;
}
function runOxlint(target: string): LintOutcome {
try {
const stdout = execSync(`npx oxlint -c ${CONFIG_PATH} ${target} --quiet`, {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
cwd: REPO_ROOT,
}).toString();
return { exitCode: 0, combined: stdout };
} catch (e) {
const err = e as {
status?: number;
stderr?: Buffer | string;
stdout?: Buffer | string;
};
const stderr = err.stderr?.toString() ?? "";
const stdout = err.stdout?.toString() ?? "";
return { exitCode: err.status ?? 1, combined: stderr + stdout };
}
}
function shellFixturePath(name: string): string {
return join(
REPO_ROOT,
"showcase",
"shell-dashboard",
"src",
"lib",
`${name}.lintfixture.tsx`,
);
}
describe("oxlint copilotkit/no-public-env-shell-read NEXT_PUBLIC_* guard", () => {
describe("banned-key coverage (all variants flagged)", () => {
for (const key of BANNED_KEYS) {
it(`flags process.env.${key} (dotted member)`, () => {
const target = shellFixturePath(`bad-dotted-${key}`);
stageFile(target, `export const x = process.env.${key};\n`);
const r = runOxlint(target);
expect(r.exitCode).not.toBe(0);
expect(r.combined).toMatch(/no-public-env-shell-read/);
expect(r.combined).toMatch(/getRuntimeConfig/);
});
it(`flags process.env["${key}"] (bracket-string member)`, () => {
const target = shellFixturePath(`bad-bracket-${key}`);
stageFile(target, `export const x = process.env["${key}"];\n`);
const r = runOxlint(target);
expect(r.exitCode).not.toBe(0);
expect(r.combined).toMatch(/no-public-env-shell-read/);
});
}
});
describe("allowed-key non-firing (build-stamps + computed local-dev value)", () => {
for (const key of ALLOWED_KEYS) {
it(`does NOT flag process.env.${key}`, () => {
const target = shellFixturePath(`ok-${key}`);
stageFile(target, `export const x = process.env.${key};\n`);
const r = runOxlint(target);
expect(r.exitCode).toBe(0);
});
}
});
describe("variant coverage (destructuring / optional chaining / template-key)", () => {
it("flags const { NEXT_PUBLIC_SHELL_URL } = process.env (destructuring)", () => {
const target = shellFixturePath("bad-destructure");
stageFile(
target,
`const { NEXT_PUBLIC_SHELL_URL } = process.env;\nexport const x = NEXT_PUBLIC_SHELL_URL;\n`,
);
const r = runOxlint(target);
expect(r.exitCode).not.toBe(0);
expect(r.combined).toMatch(/no-public-env-shell-read/);
});
it("flags const { NEXT_PUBLIC_SHELL_URL: aliased } = process.env (destructuring with alias)", () => {
const target = shellFixturePath("bad-destructure-aliased");
stageFile(
target,
`const { NEXT_PUBLIC_SHELL_URL: aliased } = process.env;\nexport const x = aliased;\n`,
);
const r = runOxlint(target);
expect(r.exitCode).not.toBe(0);
expect(r.combined).toMatch(/no-public-env-shell-read/);
});
it("flags process.env?.NEXT_PUBLIC_SHELL_URL (optional chaining)", () => {
const target = shellFixturePath("bad-optchain");
stageFile(
target,
`export const x = process.env?.NEXT_PUBLIC_SHELL_URL;\n`,
);
const r = runOxlint(target);
expect(r.exitCode).not.toBe(0);
expect(r.combined).toMatch(/no-public-env-shell-read/);
});
it("flags process.env[`NEXT_PUBLIC_SHELL_URL`] (no-expression template literal key)", () => {
const target = shellFixturePath("bad-tplkey");
stageFile(
target,
"export const x = process.env[`NEXT_PUBLIC_SHELL_URL`];\n",
);
const r = runOxlint(target);
expect(r.exitCode).not.toBe(0);
expect(r.combined).toMatch(/no-public-env-shell-read/);
});
it('flags const { ["NEXT_PUBLIC_SHELL_URL"]: x } = process.env (destructuring with computed string key)', () => {
// Parity with the bracket-member form: the destructuring branch
// must apply the same staticKeyName() recognition as the member
// branch, otherwise a computed string key sneaks through.
const target = shellFixturePath("bad-destructure-computed-string");
stageFile(
target,
`const { ["NEXT_PUBLIC_SHELL_URL"]: x } = process.env;\nexport const y = x;\n`,
);
const r = runOxlint(target);
expect(r.exitCode).not.toBe(0);
expect(r.combined).toMatch(/no-public-env-shell-read/);
});
it("flags const { [`NEXT_PUBLIC_SHELL_URL`]: x } = process.env (destructuring with no-expression template key)", () => {
const target = shellFixturePath("bad-destructure-computed-template");
stageFile(
target,
"const { [`NEXT_PUBLIC_SHELL_URL`]: x } = process.env;\nexport const y = x;\n",
);
const r = runOxlint(target);
expect(r.exitCode).not.toBe(0);
expect(r.combined).toMatch(/no-public-env-shell-read/);
});
it("does NOT flag process.env.NEXT_PUBLIC_SHELL_URL = ... (assignment LHS)", () => {
// Writes (test/runtime overrides) are intentionally not flagged.
const target = shellFixturePath("ok-assign");
stageFile(
target,
`process.env.NEXT_PUBLIC_SHELL_URL = "x";\nexport {};\n`,
);
const r = runOxlint(target);
expect(r.exitCode).toBe(0);
});
it("does NOT flag delete process.env.NEXT_PUBLIC_SHELL_URL", () => {
const target = shellFixturePath("ok-delete");
stageFile(
target,
`delete process.env.NEXT_PUBLIC_SHELL_URL;\nexport {};\n`,
);
const r = runOxlint(target);
expect(r.exitCode).toBe(0);
});
});
describe("override scoping (shell-only enforcement; runtime-config + non-shell exempt)", () => {
it("does NOT flag inside a runtime-config implementation file (off-override)", () => {
// The off-override matches `showcase/**/lib/runtime-config*.{ts,tsx}`
// (and the `.client` variant). Confirm the rule is silenced there.
const target = join(
REPO_ROOT,
"showcase",
"shell-dashboard",
"src",
"lib",
"runtime-config.lintfixture.tsx",
);
stageFile(
target,
`export const x = process.env.NEXT_PUBLIC_SHELL_URL;\n`,
);
const r = runOxlint(target);
expect(r.exitCode).toBe(0);
});
it("does NOT flag inside packages/ (outside the shell trees)", () => {
const target = join(
REPO_ROOT,
"packages",
"react-ui",
"src",
"__noflag.lintfixture.tsx",
);
stageFile(
target,
`export const x = process.env.NEXT_PUBLIC_SHELL_URL;\n`,
);
const r = runOxlint(target);
expect(r.exitCode).toBe(0);
});
it("DOES flag inside shell-docs/src (shell-tree, not runtime-config)", () => {
const target = join(
REPO_ROOT,
"showcase",
"shell-docs",
"src",
"__bad.lintfixture.tsx",
);
stageFile(
target,
`export const x = process.env.NEXT_PUBLIC_SHELL_URL;\n`,
);
const r = runOxlint(target);
expect(r.exitCode).not.toBe(0);
expect(r.combined).toMatch(/no-public-env-shell-read/);
});
it("DOES flag inside shell/src (shell-tree)", () => {
// The override list in .oxlintrc.json includes showcase/shell/src/**;
// exercise that branch explicitly so a future override-list edit that
// accidentally drops `shell` is caught.
const target = join(
REPO_ROOT,
"showcase",
"shell",
"src",
"__bad.lintfixture.tsx",
);
stageFile(
target,
`export const x = process.env.NEXT_PUBLIC_SHELL_URL;\n`,
);
const r = runOxlint(target);
expect(r.exitCode).not.toBe(0);
expect(r.combined).toMatch(/no-public-env-shell-read/);
});
it("DOES flag inside shell-dojo/src (shell-tree)", () => {
// Same as above for the dojo shell tree.
const target = join(
REPO_ROOT,
"showcase",
"shell-dojo",
"src",
"__bad.lintfixture.tsx",
);
stageFile(
target,
`export const x = process.env.NEXT_PUBLIC_SHELL_URL;\n`,
);
const r = runOxlint(target);
expect(r.exitCode).not.toBe(0);
expect(r.combined).toMatch(/no-public-env-shell-read/);
});
});
});
@@ -0,0 +1,215 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import path from "node:path";
import { globSync } from "glob";
import { loadFixtureFile, matchFixture } from "@copilotkit/aimock";
import type {
ChatCompletionRequest,
Fixture,
TextResponse,
ToolCallResponse,
} from "@copilotkit/aimock";
// Regression guard for the MCP Apps demo suggestion pills
// (showcase/integrations/langgraph-python/src/app/demos/mcp-apps/suggestions.ts).
//
// Bug class this test catches: a pill's verbatim `message` falls through
// the per-integration mcp-apps fixtures and gets absorbed by a generic
// substring catch-all later in the load chain, producing a content-only
// response with no `create_view` tool call. The agent never invokes the
// MCP tool, the runtime never fetches the UI resource, and the sandboxed
// iframe never mounts. The chat looks like it "works" — it just silently
// no-ops the demo's whole point.
//
// We use aimock's `matchFixture` directly (no HTTP) so the test is
// fast and deterministic: same matcher, same fixture load order as
// docker-compose.local.yml.
const REPO_ROOT = path.resolve(__dirname, "..", "..", "..");
const SUGGESTIONS_PATH = path.join(
REPO_ROOT,
"showcase",
"integrations",
"langgraph-python",
"src",
"app",
"demos",
"mcp-apps",
"suggestions.ts",
);
// Load fixtures for a single integration (langgraph-python, the reference
// integration) plus shared. At runtime each integration only sees its own
// scoped fixtures via X-AIMock-Context, so loading a single integration's
// fixture set is the correct simulation — loading all 18 integrations'
// fixtures would produce first-match collisions across identical prompts.
function loadBundledFixtures(): Fixture[] {
const fixtureFiles = [
...globSync("showcase/aimock/shared/*.json", {
cwd: REPO_ROOT,
absolute: true,
}),
...globSync("showcase/aimock/d4/langgraph-python/*.json", {
cwd: REPO_ROOT,
absolute: true,
}),
...globSync("showcase/aimock/d6/langgraph-python/*.json", {
cwd: REPO_ROOT,
absolute: true,
}),
];
return fixtureFiles.flatMap((f) => loadFixtureFile(f));
}
function buildRequest(opts: {
userMessage: string;
withCreateViewTool?: boolean;
toolResult?: { callId: string };
}): ChatCompletionRequest {
const messages: ChatCompletionRequest["messages"] = [
{ role: "user", content: opts.userMessage },
];
if (opts.toolResult) {
messages.push({
role: "assistant",
content: "",
tool_calls: [
{
id: opts.toolResult.callId,
type: "function",
function: { name: "create_view", arguments: "{}" },
},
],
});
messages.push({
role: "tool",
content: "ok",
tool_call_id: opts.toolResult.callId,
});
}
return {
model: "gpt-5.4",
messages,
// D6 fixtures use match.context for per-integration scoping; aimock's
// matchFixture checks req._context against it.
_context: "langgraph-python",
tools: opts.withCreateViewTool
? [
{
type: "function",
function: {
name: "create_view",
description: "Excalidraw MCP tool",
parameters: { type: "object" },
},
},
]
: undefined,
} as ChatCompletionRequest;
}
// One row per suggestion pill in suggestions.ts. `expectedFixtureKey`
// is the load-bearing substring the mcp-apps fixture is keyed on —
// it MUST be a substring of `message` (aimock matches `userMessage`
// case-sensitively via String.prototype.includes — see
// node_modules/@copilotkit/aimock/dist/router.js#matchFixture).
const PILLS = [
{
title: "Draw a flowchart",
message: "Use Excalidraw to draw a simple flowchart with three steps.",
expectedFixtureKey: "draw a simple flowchart",
expectedToolCallId: "call_d5_mcp_apps_create_view_flowchart_001",
},
{
title: "Sketch a system diagram",
message:
"Open Excalidraw and sketch a system diagram with a client, server, and database.",
expectedFixtureKey: "Open Excalidraw and sketch a system diagram",
expectedToolCallId: "call_d5_mcp_apps_create_view_001",
},
] as const;
describe("MCP Apps suggestion-pill fixture routing", () => {
it("suggestions.ts still uses the exact pill messages this test asserts on", () => {
const src = readFileSync(SUGGESTIONS_PATH, "utf8");
for (const pill of PILLS) {
expect(
src.includes(pill.message),
`suggestions.ts no longer contains pill message:\n "${pill.message}"\n` +
`If you re-worded a pill, update both the matching fixture in ` +
`showcase/aimock/d6/*/mcp-apps.json AND the PILLS table ` +
`in this test so the new wording still routes to the create_view ` +
`fixture.`,
).toBe(true);
expect(
pill.message.includes(pill.expectedFixtureKey),
`Pill "${pill.title}" message must contain fixture key ` +
`"${pill.expectedFixtureKey}" as a substring — aimock's userMessage ` +
`matcher is case-sensitive String.includes.`,
).toBe(true);
}
});
it("turn 1: each pill routes to its create_view fixture (not a content-only catch-all)", () => {
const fixtures = loadBundledFixtures();
for (const pill of PILLS) {
const req = buildRequest({
userMessage: pill.message,
withCreateViewTool: true,
});
const matched = matchFixture(fixtures, req);
expect(
matched,
`Pill "${pill.title}" — no fixture matched. The pill prompt would ` +
`fall through to a 404, leaving the chat hung on the spinner.`,
).not.toBeNull();
const resp = matched!.response as Partial<ToolCallResponse> &
Partial<TextResponse>;
const toolName = resp.toolCalls?.[0]?.name;
expect(
toolName,
`Pill "${pill.title}" — matched fixture did not emit a create_view ` +
`tool call. Got response: ${JSON.stringify(resp)}. A non-MCP fixture ` +
`(e.g. feature-parity.json's \`{userMessage: "steps"}\` content-only ` +
`blurb) is absorbing the pill prompt, so the runtime never invokes ` +
`the MCP tool and the sandboxed iframe never mounts.`,
).toBe("create_view");
expect(
resp.toolCalls?.[0]?.id,
`Pill "${pill.title}" — matched the wrong create_view fixture ` +
`(unexpected tool_call id). The narration fixture won't fire on the ` +
`follow-up request and the chat will hang.`,
).toBe(pill.expectedToolCallId);
}
});
it("turn 2 (post tool result): each pill routes to its narration fixture", () => {
const fixtures = loadBundledFixtures();
for (const pill of PILLS) {
const req = buildRequest({
userMessage: pill.message,
toolResult: { callId: pill.expectedToolCallId },
});
const matched = matchFixture(fixtures, req);
expect(
matched,
`Pill "${pill.title}" — no fixture matched the post-tool-result ` +
`request. Without this, the chat will hang waiting for the ` +
`agent's final reply after the MCP iframe renders.`,
).not.toBeNull();
const resp = matched!.response as Partial<TextResponse> &
Partial<ToolCallResponse>;
expect(
typeof resp.content === "string" && resp.content.length > 0,
`Pill "${pill.title}" — turn-2 fixture is missing user-visible content.`,
).toBe(true);
expect(
resp.toolCalls,
`Pill "${pill.title}" — turn-2 fixture must be content-only; emitting ` +
`another tool call here would loop the agent on the same MCP tool.`,
).toBeUndefined();
}
});
});
@@ -0,0 +1,218 @@
import { describe, it, expect } from "vitest";
import {
groupByContext,
groupByDemoCell,
mergeIntoFixtureFile,
} from "../merge-recorded-fixtures";
import type {
Fixture,
FixtureFile,
FixtureMeta,
} from "../merge-recorded-fixtures";
describe("merge-recorded-fixtures", () => {
// -------------------------------------------------------------------
// groupByContext
// -------------------------------------------------------------------
describe("groupByContext", () => {
it("groups fixtures by match.context field", () => {
const fixtures: Fixture[] = [
{ match: { context: "lgp", userMessage: "hi" }, response: {} },
{ match: { context: "lgt", userMessage: "hi" }, response: {} },
{ match: { context: "lgp", userMessage: "bye" }, response: {} },
];
const grouped = groupByContext(fixtures);
expect(grouped.get("lgp")?.length).toBe(2);
expect(grouped.get("lgt")?.length).toBe(1);
});
it("places fixtures without context under __shared__", () => {
const fixtures: Fixture[] = [
{ match: { userMessage: "hello" }, response: {} },
{ match: { context: "lgp", userMessage: "hi" }, response: {} },
];
const grouped = groupByContext(fixtures);
expect(grouped.get("__shared__")?.length).toBe(1);
expect(grouped.get("lgp")?.length).toBe(1);
});
it("returns empty map for empty input", () => {
const grouped = groupByContext([]);
expect(grouped.size).toBe(0);
});
it("preserves fixture order within each group", () => {
const fixtures: Fixture[] = [
{ match: { context: "a", userMessage: "first" }, response: {} },
{ match: { context: "a", userMessage: "second" }, response: {} },
{ match: { context: "a", userMessage: "third" }, response: {} },
];
const grouped = groupByContext(fixtures);
const group = grouped.get("a")!;
expect(group[0].match.userMessage).toBe("first");
expect(group[1].match.userMessage).toBe("second");
expect(group[2].match.userMessage).toBe("third");
});
});
// -------------------------------------------------------------------
// groupByDemoCell
// -------------------------------------------------------------------
describe("groupByDemoCell", () => {
it("groups fixtures by _comment prefix on the fixture", () => {
const fixtures: Fixture[] = [
{
_comment: "agentic-chat turn 1",
match: { userMessage: "hi" },
response: {},
},
{
_comment: "agentic-chat turn 2",
match: { userMessage: "more" },
response: {},
},
{
_comment: "hitl turn 1",
match: { userMessage: "hi" },
response: {},
},
];
const grouped = groupByDemoCell(fixtures);
expect(grouped.get("agentic-chat")?.length).toBe(2);
expect(grouped.get("hitl")?.length).toBe(1);
});
it("falls back to match._comment when top-level _comment is absent", () => {
const fixtures: Fixture[] = [
{
match: { _comment: "gen-ui step 1", userMessage: "hi" },
response: {},
},
{
match: { _comment: "gen-ui step 2", userMessage: "more" },
response: {},
},
];
const grouped = groupByDemoCell(fixtures);
expect(grouped.get("gen-ui")?.length).toBe(2);
});
it("places fixtures without _comment under __unknown__", () => {
const fixtures: Fixture[] = [
{ match: { userMessage: "hello" }, response: {} },
];
const grouped = groupByDemoCell(fixtures);
expect(grouped.get("__unknown__")?.length).toBe(1);
});
it("handles mixed _comment locations", () => {
const fixtures: Fixture[] = [
{
_comment: "demo-a turn 1",
match: { userMessage: "hi" },
response: {},
},
{
match: { _comment: "demo-b step 1", userMessage: "bye" },
response: {},
},
{ match: { userMessage: "no comment" }, response: {} },
];
const grouped = groupByDemoCell(fixtures);
expect(grouped.get("demo-a")?.length).toBe(1);
expect(grouped.get("demo-b")?.length).toBe(1);
expect(grouped.get("__unknown__")?.length).toBe(1);
});
});
// -------------------------------------------------------------------
// mergeIntoFixtureFile
// -------------------------------------------------------------------
describe("mergeIntoFixtureFile", () => {
const meta: FixtureMeta = {
_comment: "test merge",
_recordedAt: "2026-01-01T00:00:00Z",
_source: "test",
};
it("creates a new file when existing is null", () => {
const incoming: Fixture[] = [
{ match: { userMessage: "hi" }, response: { content: "hello" } },
];
const result = mergeIntoFixtureFile(null, incoming, meta);
expect(result.fixtures.length).toBe(1);
expect(result._meta).toEqual(meta);
});
it("merges without duplicates (incoming wins)", () => {
const existing: FixtureFile = {
fixtures: [
{
match: { userMessage: "hi" },
response: { content: "old" },
},
{
match: { userMessage: "unique-existing" },
response: { content: "keep" },
},
],
};
const incoming: Fixture[] = [
{
match: { userMessage: "hi" },
response: { content: "new" },
},
];
const result = mergeIntoFixtureFile(existing, incoming, meta);
// "unique-existing" kept, "hi" replaced by incoming.
expect(result.fixtures.length).toBe(2);
const hiFixture = result.fixtures.find(
(f) => f.match.userMessage === "hi",
);
expect(hiFixture?.response.content).toBe("new");
});
it("deduplicates using turnIndex and hasToolResult", () => {
const existing: FixtureFile = {
fixtures: [
{
match: {
userMessage: "hi",
turnIndex: 0,
hasToolResult: false,
},
response: { content: "old-turn-0" },
},
{
match: {
userMessage: "hi",
turnIndex: 1,
hasToolResult: true,
},
response: { content: "old-turn-1" },
},
],
};
const incoming: Fixture[] = [
{
match: {
userMessage: "hi",
turnIndex: 0,
hasToolResult: false,
},
response: { content: "new-turn-0" },
},
];
const result = mergeIntoFixtureFile(existing, incoming, meta);
// turn-0 replaced, turn-1 kept.
expect(result.fixtures.length).toBe(2);
const turn0 = result.fixtures.find((f) => f.match.turnIndex === 0);
expect(turn0?.response.content).toBe("new-turn-0");
const turn1 = result.fixtures.find((f) => f.match.turnIndex === 1);
expect(turn1?.response.content).toBe("old-turn-1");
});
});
});
@@ -0,0 +1,109 @@
import { describe, it, expect, beforeAll } from "vitest";
import path from "path";
import { globSync } from "glob";
import { loadFixtureFile } from "@copilotkit/aimock";
// Regression guard for the three "open-gen-ui-advanced" interactive pills.
//
// Each pill's `userMessage` doubles as a fixture key in the per-integration
// fixture files under `showcase/aimock/d6/`. The fixture's
// `generateSandboxedUi` tool-call arguments MUST include `jsFunctions` that
// wire up the in-iframe click handlers — otherwise the iframe renders HTML+CSS
// but every button is a no-op (the regression that motivated this test).
//
// Fixture aimock schema validation (in `aimock-fixtures.test.ts`) catches
// structural issues but not semantic ones — it doesn't know that the
// Calculator's HTML needs JS to do anything. This test plugs that gap.
const REPO_ROOT = path.resolve(__dirname, "..", "..", "..");
type Fixture = {
match: { userMessage?: string; toolCallId?: string };
response: {
toolCalls?: Array<{ name: string; arguments: string }>;
content?: string;
};
};
let fixturesByMessage: Record<string, Fixture[]> = {};
beforeAll(() => {
// Load fixtures for a single integration (langgraph-python, the reference
// integration) plus shared. At runtime each integration only sees its own
// scoped fixtures via X-AIMock-Context, so loading a single integration's
// fixture set is the correct simulation.
const fixtureFiles = [
...globSync("showcase/aimock/shared/*.json", {
cwd: REPO_ROOT,
absolute: true,
}),
...globSync("showcase/aimock/d4/langgraph-python/*.json", {
cwd: REPO_ROOT,
absolute: true,
}),
...globSync("showcase/aimock/d6/langgraph-python/*.json", {
cwd: REPO_ROOT,
absolute: true,
}),
];
const allFixtures = fixtureFiles.flatMap((f) =>
loadFixtureFile(f),
) as unknown as Fixture[];
for (const f of allFixtures) {
const key = f.match.userMessage;
if (!key) continue;
(fixturesByMessage[key] ??= []).push(f);
}
});
const findToolCallArgs = (userMessage: string): Record<string, unknown> => {
const matches = fixturesByMessage[userMessage] ?? [];
// We want the fixture that returns the tool call (not the follow-up
// `content` reply that uses toolCallId as a discriminator).
const withToolCall = matches.find(
(f) =>
!f.match.toolCallId &&
f.response.toolCalls?.[0]?.name === "generateSandboxedUi",
);
if (!withToolCall) {
throw new Error(
`No generateSandboxedUi fixture for userMessage="${userMessage}" (found ${matches.length} candidates)`,
);
}
return JSON.parse(withToolCall.response.toolCalls![0].arguments);
};
describe("open-gen-ui-advanced interactive fixtures wire jsFunctions to host bridges", () => {
it("Calculator pill calls evaluateExpression via jsFunctions", () => {
const args = findToolCallArgs("Calculator (calls evaluateExpression)");
expect(
args.jsFunctions,
"Calculator fixture missing jsFunctions — iframe buttons would be no-ops",
).toBeTypeOf("string");
expect(args.jsFunctions as string).toContain(
"Websandbox.connection.remote.evaluateExpression",
);
});
it("Ping the host pill calls notifyHost via jsFunctions", () => {
const args = findToolCallArgs("Ping the host (calls notifyHost)");
expect(
args.jsFunctions,
"Ping-the-host fixture missing jsFunctions — iframe button would be a no-op",
).toBeTypeOf("string");
expect(args.jsFunctions as string).toContain(
"Websandbox.connection.remote.notifyHost",
);
});
it("Inline expression evaluator pill calls evaluateExpression via jsFunctions", () => {
const args = findToolCallArgs("Inline expression evaluator");
expect(
args.jsFunctions,
"Inline-evaluator fixture missing jsFunctions — Evaluate button would be a no-op",
).toBeTypeOf("string");
expect(args.jsFunctions as string).toContain(
"Websandbox.connection.remote.evaluateExpression",
);
});
});
+17
View File
@@ -0,0 +1,17 @@
// Shared path constants for showcase/scripts test suites. Centralized here
// so that repo-root / scripts-dir / data-dir drift in one place when the
// directory layout changes — several suites previously recomputed these and
// any future mismatch would be a silent bug.
import path from "path";
export const SCRIPTS_DIR = path.resolve(__dirname, "..");
export const REPO_ROOT = path.resolve(SCRIPTS_DIR, "..", "..");
export const SHELL_DATA_DIR = path.resolve(
SCRIPTS_DIR,
"..",
"shell",
"src",
"data",
);
export const WORKFLOWS_DIR = path.resolve(REPO_ROOT, ".github", "workflows");
@@ -0,0 +1,910 @@
#!/usr/bin/env bats
# Tests for promote-fleet.sh — the per-service promote loop extracted from
# .github/workflows/showcase_promote.yml.
#
# Core invariant under test (the bug this script fixes): a single red service
# must NOT abort the fleet. Every service in the CSV is attempted regardless of
# individual failures; the run aggregates a failed-set + succeeded-set, prints a
# summary, and exits non-zero iff ANY service failed.
#
# The real `bin/railway` is replaced on PATH-independent terms via RAILWAY_BIN,
# pointed at a stub that succeeds/fails per service name.
#
# NB on assertion gating: bats does NOT run test bodies under `set -e` (errexit).
# Only the FINAL command's exit status decides whether a test passes — a non-zero
# command on any earlier line does NOT abort the test. So a bare `[[ ... ]]` on a
# non-final line is a silent no-op: if it's false, nothing fails. That is why every
# substantive / non-final assertion MUST be written `[[ ... ]] || fail "message"`.
# The `|| fail` is what actually forces the hard failure (and supplies the
# diagnostic MESSAGE) when the check is violated — e.g. a service missing from
# $GITHUB_OUTPUT reports WHY it failed instead of being silently passed over.
# Dropping the `|| fail` from an intermediate assertion turns it into a false-green.
# fail <msg> — print the message to the bats failure stream and abort the test.
fail() {
echo "$1" >&2
return 1
}
setup() {
SCRIPT="$BATS_TEST_DIRNAME/../promote-fleet.sh"
STUB_DIR="$BATS_TEST_TMPDIR/stub"
mkdir -p "$STUB_DIR"
# Capture the per-step GitHub Actions output to a temp file so the script's
# `$GITHUB_OUTPUT` append (succeeded_csv=...) runs exactly as it would in CI.
# Each test reads this file to assert the exported succeeded set.
export GITHUB_OUTPUT="$BATS_TEST_TMPDIR/github_output"
: > "$GITHUB_OUTPUT"
# Stub `railway`: invoked as `railway promote <svc> [flags...]`.
# Succeeds for A and C, fails (exit 7) for B. Echoes its service so we can
# assert every service was attempted (including C, AFTER B failed). Also
# asserts the FIRST positional arg is literally `promote` so an arg-order
# regression in the script (e.g. dropping the subcommand) is caught.
cat > "$STUB_DIR/railway" <<'STUB'
#!/usr/bin/env bash
# args: promote <svc> --yes --non-interactive [--digest REF]
[ "$1" = "promote" ] || { echo "expected first arg 'promote', got '$1'" >&2; exit 99; }
svc="$2"
echo "STUB called for: $svc"
case "$svc" in
svc-b) exit 7 ;; # chronically-red service (the abort trigger pre-fix)
*) exit 0 ;;
esac
STUB
chmod +x "$STUB_DIR/railway"
export RAILWAY_BIN="$STUB_DIR/railway"
# All-green stub for the success case.
cat > "$STUB_DIR/railway-green" <<'STUB'
#!/usr/bin/env bash
[ "$1" = "promote" ] || { echo "expected first arg 'promote', got '$1'" >&2; exit 99; }
echo "STUB called for: $2"
exit 0
STUB
chmod +x "$STUB_DIR/railway-green"
}
@test "attempts every service even after one fails, and exits non-zero" {
run env SERVICES_CSV="svc-a,svc-b,svc-c" bash "$SCRIPT"
# (1) all three attempted — C runs even though B failed before it
[[ "$output" == *"STUB called for: svc-a"* ]] || fail "svc-a not attempted: $output"
[[ "$output" == *"STUB called for: svc-b"* ]] || fail "svc-b not attempted: $output"
[[ "$output" == *"STUB called for: svc-c"* ]] || fail "svc-c not attempted: $output"
# (2) non-zero aggregate exit because at least one service failed
[ "$status" -ne 0 ] || fail "expected non-zero exit, got $status"
# (3) summary classifies B as failed, A and C as succeeded. Anchor to the
# EXACT summary lines (not bare substrings) so a misclassification (e.g.
# svc-b leaking into the succeeded set) is actually caught.
[[ "$output" == *"FAILED (1): svc-b=7"* ]] || fail "wrong FAILED summary line: $output"
[[ "$output" == *"SUCCEEDED (2): svc-a svc-c"* ]] || fail "wrong SUCCEEDED summary line: $output"
# (4) the succeeded set is exported to $GITHUB_OUTPUT for verify-prod scoping.
# Only the services that actually promoted (a and c) — never the failed b.
run cat "$GITHUB_OUTPUT"
[[ "$output" == *"succeeded_csv=svc-a,svc-c"* ]] || fail "missing/wrong succeeded_csv: $output"
[[ "$output" != *"svc-b"* ]] || fail "failed svc-b leaked into GITHUB_OUTPUT: $output"
}
@test "all-green fleet exits zero" {
export RAILWAY_BIN="$STUB_DIR/railway-green"
run env SERVICES_CSV="svc-a,svc-b,svc-c" bash "$SCRIPT"
[ "$status" -eq 0 ] || fail "expected zero exit, got $status: $output"
[[ "$output" == *"STUB called for: svc-a"* ]] || fail "svc-a not attempted: $output"
[[ "$output" == *"STUB called for: svc-b"* ]] || fail "svc-b not attempted: $output"
[[ "$output" == *"STUB called for: svc-c"* ]] || fail "svc-c not attempted: $output"
# All three exported as the succeeded set.
run cat "$GITHUB_OUTPUT"
[[ "$output" == *"succeeded_csv=svc-a,svc-b,svc-c"* ]] || fail "wrong succeeded_csv: $output"
}
@test "stray/trailing commas in CSV are skipped; real services still attempted" {
export RAILWAY_BIN="$STUB_DIR/railway-green"
run env SERVICES_CSV="svc-a,,svc-c" bash "$SCRIPT"
# Both non-empty services attempted; empty token between the commas ignored.
[ "$status" -eq 0 ] || fail "expected zero exit, got $status: $output"
[[ "$output" == *"STUB called for: svc-a"* ]] || fail "svc-a not attempted: $output"
[[ "$output" == *"STUB called for: svc-c"* ]] || fail "svc-c not attempted: $output"
[[ "$output" == *"SUCCEEDED (2): svc-a svc-c"* ]] || fail "wrong SUCCEEDED summary line: $output"
# The "Attempted N" count must reflect services ACTUALLY attempted (2), not
# the raw CSV token count (3, including the empty token between the commas).
[[ "$output" == *"Attempted 2 service(s)"* ]] || fail "wrong Attempted count (should skip empty token): $output"
run cat "$GITHUB_OUTPUT"
[[ "$output" == *"succeeded_csv=svc-a,svc-c"* ]] || fail "wrong succeeded_csv: $output"
}
@test "single failing service still fails the run" {
run env SERVICES_CSV="svc-b" bash "$SCRIPT"
[ "$status" -ne 0 ] || fail "expected non-zero exit, got $status"
[[ "$output" == *"STUB called for: svc-b"* ]] || fail "svc-b not attempted: $output"
[[ "$output" == *"FAILED (1): svc-b=7"* ]] || fail "wrong FAILED summary line: $output"
}
@test "all-fail run still exports an EMPTY succeeded_csv to GITHUB_OUTPUT" {
# verify-prod's empty-guard depends on the key being present-but-empty on the
# all-fail-via-promote path (the loop ran, every service failed, zero
# promoted) — an absent key (vs an empty value) is a different contract and
# would break downstream scoping. NB this empty-but-present contract holds for
# the all-fail path only; the script's early-exit guards (empty CSV / missing
# RAILWAY_BIN / all-empty-token) exit BEFORE the $GITHUB_OUTPUT write, so no
# key is emitted there. Assert key present, value empty.
run env SERVICES_CSV="svc-b" bash "$SCRIPT"
[ "$status" -ne 0 ] || fail "expected non-zero exit, got $status: $output"
run grep '^succeeded_csv=' "$GITHUB_OUTPUT"
[ "$status" -eq 0 ] || fail "succeeded_csv key missing from GITHUB_OUTPUT"
# The value must be EMPTY: the line is exactly `succeeded_csv=` (no service).
[ "$output" = "succeeded_csv=" ] || fail "succeeded_csv should be empty on all-fail, got: $output"
}
@test "single succeeding service exits zero" {
run env SERVICES_CSV="svc-a" bash "$SCRIPT"
[ "$status" -eq 0 ] || fail "expected zero exit, got $status: $output"
[[ "$output" == *"STUB called for: svc-a"* ]] || fail "svc-a not attempted: $output"
}
@test "passes --digest through to railway for a single service" {
# Stub that asserts --digest is forwarded AND the first arg is `promote`.
cat > "$STUB_DIR/railway-digest" <<'STUB'
#!/usr/bin/env bash
[ "$1" = "promote" ] || { echo "expected first arg 'promote', got '$1'" >&2; exit 99; }
echo "ARGS: $*"
[[ "$*" == *"--digest sha256:deadbeef"* ]] || { echo "missing digest" >&2; exit 9; }
exit 0
STUB
chmod +x "$STUB_DIR/railway-digest"
export RAILWAY_BIN="$STUB_DIR/railway-digest"
run env SERVICES_CSV="svc-a" DIGEST="sha256:deadbeef" bash "$SCRIPT"
[ "$status" -eq 0 ] || fail "expected zero exit, got $status: $output"
# Anchor to the STUB's received-args marker (`ARGS: ...`), NOT the script's
# pre-invocation `==> ...` echo. The pre-invocation echo would pass even if
# the script dropped --digest before actually invoking railway; only the
# stub's ARGS line proves the flag was forwarded to the real invocation.
[[ "$output" == *"ARGS: "*"--digest sha256:deadbeef"* ]] || fail "digest not forwarded to railway invocation: $output"
}
@test "empty CSV fails loud rather than silently succeeding" {
run env SERVICES_CSV="" bash "$SCRIPT"
[ "$status" -ne 0 ] || fail "expected non-zero exit on empty CSV, got $status"
}
@test "all-empty-token CSV fails loud rather than silently no-op succeeding" {
# A non-empty CSV that parses to ONLY empty tokens (e.g. ",,") must NOT exit 0
# claiming success — every token is skipped, zero services are attempted, and
# that is a false success the empty-string guard alone does not catch.
export RAILWAY_BIN="$STUB_DIR/railway-green"
run env SERVICES_CSV=",," bash "$SCRIPT"
[ "$status" -ne 0 ] || fail "expected non-zero exit on all-empty-token CSV, got $status: $output"
[[ "$output" != *"STUB called for:"* ]] || fail "no service should have been attempted: $output"
[[ "$output" == *"::error::"* ]] || fail "expected an ::error:: for zero-attempted CSV: $output"
}
@test "missing/non-executable RAILWAY_BIN fails loud before attempting any service" {
# A bad RAILWAY_BIN would otherwise make every iteration fail with 126/127 and
# misreport a single environment error as N per-service promote failures.
run env RAILWAY_BIN="$STUB_DIR/does-not-exist" SERVICES_CSV="svc-a,svc-c" bash "$SCRIPT"
[ "$status" -ne 0 ] || fail "expected non-zero exit on missing RAILWAY_BIN, got $status: $output"
[[ "$output" == *"::error::"* ]] || fail "expected a distinct ::error:: naming the missing binary: $output"
[[ "$output" == *"does-not-exist"* ]] || fail "error should name the missing binary: $output"
# The guard fires BEFORE the loop: no per-service promote was attempted.
[[ "$output" != *"==> "* ]] || fail "no service promote should have been attempted: $output"
[[ "$output" != *"promote failed for"* ]] || fail "env error misattributed as per-service failure: $output"
}
@test "whitespace around tokens is trimmed; trimmed names are attempted" {
# `IFS=',' read` does not trim, so "svc-a, svc-c" yields a literal " svc-c"
# (leading space). The script must trim so the REAL service name is promoted.
export RAILWAY_BIN="$STUB_DIR/railway-green"
run env SERVICES_CSV="svc-a, svc-c" bash "$SCRIPT"
[ "$status" -eq 0 ] || fail "expected zero exit, got $status: $output"
[[ "$output" == *"STUB called for: svc-a"* ]] || fail "svc-a not attempted: $output"
# Trimmed: stub receives `svc-c`, NOT ` svc-c`.
[[ "$output" == *"STUB called for: svc-c"* ]] || fail "svc-c not attempted (trimmed): $output"
[[ "$output" != *"STUB called for: svc-c"* ]] || fail "leading space not trimmed from token: $output"
[[ "$output" == *"SUCCEEDED (2): svc-a svc-c"* ]] || fail "wrong SUCCEEDED summary line: $output"
run cat "$GITHUB_OUTPUT"
[[ "$output" == *"succeeded_csv=svc-a,svc-c"* ]] || fail "wrong succeeded_csv (trimmed): $output"
}
@test "whitespace-only token is correctly skipped" {
# A token of only whitespace (the middle " " in "svc-a, ,svc-c") must be
# treated like an empty token and skipped — not promoted as a blank service.
export RAILWAY_BIN="$STUB_DIR/railway-green"
run env SERVICES_CSV="svc-a, ,svc-c" bash "$SCRIPT"
[ "$status" -eq 0 ] || fail "expected zero exit, got $status: $output"
[[ "$output" == *"SUCCEEDED (2): svc-a svc-c"* ]] || fail "whitespace-only token not skipped: $output"
[[ "$output" == *"Attempted 2 service(s)"* ]] || fail "wrong Attempted count: $output"
}
@test "aggregates STAGING_DRIFT_MARKER lines into staging_drift output + summary" {
# Stub that emits a drift marker for svc-b (staging not serving :latest) while
# still SUCCEEDING — drift is a warning surface, not a gate. The script must
# scan each promote's stdout, aggregate the markers, write them to
# $GITHUB_OUTPUT (staging_drift=...) and surface them in the summary.
cat > "$STUB_DIR/railway-drift" <<'STUB'
#!/usr/bin/env bash
[ "$1" = "promote" ] || { echo "expected first arg 'promote', got '$1'" >&2; exit 99; }
svc="$2"
echo "STUB called for: $svc"
if [ "$svc" = "svc-b" ]; then
echo "STAGING_DRIFT_MARKER: svc-b(running=f9454e79fbf5,latest=261ccdef3f9a)"
fi
exit 0
STUB
chmod +x "$STUB_DIR/railway-drift"
export RAILWAY_BIN="$STUB_DIR/railway-drift"
run env SERVICES_CSV="svc-a,svc-b,svc-c" bash "$SCRIPT"
# Drift does not fail the run (every service succeeded).
[ "$status" -eq 0 ] || fail "drift must not fail the run, got $status: $output"
# Summary surfaces the drift loudly and names the running/latest digests.
[[ "$output" == *"STAGING DRIFT (1)"* ]] || fail "missing drift summary block: $output"
[[ "$output" == *"running=f9454e79fbf5"* ]] || fail "summary missing RUNNING digest: $output"
[[ "$output" == *"latest=261ccdef3f9a"* ]] || fail "summary missing :latest digest: $output"
# The aggregated payload is exported for the notify job's Slack message.
run cat "$GITHUB_OUTPUT"
[[ "$output" == *"staging_drift=svc-b(running=f9454e79fbf5,latest=261ccdef3f9a)"* ]] \
|| fail "missing/wrong staging_drift in GITHUB_OUTPUT: $output"
}
@test "joins multiple STAGING_DRIFT_MARKER entries with '; ' separator" {
# Two services drift. The aggregated staging_drift payload must join the
# entries with "; " (semicolon + space), NOT ";" — `${drift[*]}` under
# IFS='; ' would only honor the first IFS char and drop the space.
cat > "$STUB_DIR/railway-drift2" <<'STUB'
#!/usr/bin/env bash
[ "$1" = "promote" ] || { echo "expected first arg 'promote', got '$1'" >&2; exit 99; }
svc="$2"
echo "STUB called for: $svc"
if [ "$svc" = "svc-a" ]; then
echo "STAGING_DRIFT_MARKER: svc-a(running=aaaaaaaaaaaa,latest=bbbbbbbbbbbb)"
fi
if [ "$svc" = "svc-c" ]; then
echo "STAGING_DRIFT_MARKER: svc-c(running=cccccccccccc,latest=dddddddddddd)"
fi
exit 0
STUB
chmod +x "$STUB_DIR/railway-drift2"
export RAILWAY_BIN="$STUB_DIR/railway-drift2"
run env SERVICES_CSV="svc-a,svc-b,svc-c" bash "$SCRIPT"
[ "$status" -eq 0 ] || fail "drift must not fail the run, got $status: $output"
[[ "$output" == *"STAGING DRIFT (2)"* ]] || fail "missing drift summary block: $output"
run cat "$GITHUB_OUTPUT"
# Both entries present, joined with "; " (NOT ";" — the dropped-space bug).
[[ "$output" == *"staging_drift=svc-a(running=aaaaaaaaaaaa,latest=bbbbbbbbbbbb); svc-c(running=cccccccccccc,latest=dddddddddddd)"* ]] \
|| fail "multi-entry drift not joined with '; ' separator: $output"
}
@test "no drift markers -> empty staging_drift output, no drift summary" {
export RAILWAY_BIN="$STUB_DIR/railway-green"
run env SERVICES_CSV="svc-a,svc-b,svc-c" bash "$SCRIPT"
[ "$status" -eq 0 ] || fail "expected zero exit, got $status: $output"
[[ "$output" != *"STAGING DRIFT"* ]] || fail "no drift expected but summary shows one: $output"
run cat "$GITHUB_OUTPUT"
# Key present but empty (the non-drift contract): staging_drift=
[[ "$output" == *"staging_drift="* ]] || fail "staging_drift key absent: $output"
[[ "$output" != *"staging_drift=svc"* ]] || fail "unexpected drift payload: $output"
}
# ── results JSON for the three-variant Slack renderer ───────────────────────
#
# promote-fleet emits a base64-encoded `results` blob (schema_version=1)
# consumed by .github/workflows/showcase_promote_notify.yml. The renderer is
# the SSOT for the schema; these tests assert promote-fleet emits a blob that
# matches it: valid base64 → valid JSON, schema_version=1, BOTH succeeded[] and
# failed[] present, succeeded[] entries are `{service}` objects, and failed[]
# entries are `{service, exit, category}` with the real exit code. The
# end-to-end RED-GREEN proof (decode → pipe through the dry-run harness →
# rendered ⚠️ partial / ✅ success / ❌ total message) lives outside bats.
# decode_results_b64 — read results_b64 from $GITHUB_OUTPUT and decode it to
# stdout as JSON, failing the test loudly if the key is absent or the value is
# not valid base64/JSON. Mirrors the renderer's decode step.
decode_results_b64() {
local b64
b64=$(grep '^results_b64=' "$GITHUB_OUTPUT" | head -1 | cut -d= -f2-)
[ -n "$b64" ] || fail "results_b64 key absent from GITHUB_OUTPUT: $(cat "$GITHUB_OUTPUT")"
printf '%s' "$b64" | base64 -d 2>/dev/null || fail "results_b64 is not valid base64: $b64"
}
@test "emits results JSON (schema_version=1) with both succeeded[] and failed[] on a partial promote" {
# Mixed run: svc-a/svc-c succeed, svc-b fails (exit 7). The emitted blob must
# carry the partial outcome the renderer turns into the ⚠️ partial message.
run env SERVICES_CSV="svc-a,svc-b,svc-c" bash "$SCRIPT"
[ "$status" -ne 0 ] || fail "expected non-zero exit on partial, got $status: $output"
# Decode → valid JSON with schema_version=1.
json=$(decode_results_b64)
echo "$json" | jq -e '.schema_version == 1' >/dev/null \
|| fail "schema_version != 1: $json"
# succeeded[] = the two that pinned, as {service} objects (renderer reads
# length only, but we emit objects for symmetry/future use).
[ "$(echo "$json" | jq -r '.succeeded | length')" = "2" ] \
|| fail "succeeded[] should have 2 entries: $json"
echo "$json" | jq -e '[.succeeded[].service] | sort == ["svc-a","svc-c"]' >/dev/null \
|| fail "succeeded[] services wrong: $json"
# failed[] = svc-b with its REAL exit code (7) + the default category. The
# renderer renders "• `svc-b` — exit 7 (promote-failed)".
[ "$(echo "$json" | jq -r '.failed | length')" = "1" ] \
|| fail "failed[] should have exactly 1 entry: $json"
echo "$json" | jq -e '.failed[0] == {service:"svc-b", exit:7, category:"promote-failed"}' >/dev/null \
|| fail "failed[0] does not match {svc-b, exit 7, promote-failed}: $json"
# The failed service must NOT leak into succeeded[].
echo "$json" | jq -e '[.succeeded[].service] | index("svc-b") == null' >/dev/null \
|| fail "failed svc-b leaked into succeeded[]: $json"
}
@test "emits results JSON with empty failed[] on an all-green promote" {
export RAILWAY_BIN="$STUB_DIR/railway-green"
run env SERVICES_CSV="svc-a,svc-b,svc-c" bash "$SCRIPT"
[ "$status" -eq 0 ] || fail "expected zero exit, got $status: $output"
json=$(decode_results_b64)
echo "$json" | jq -e '.schema_version == 1' >/dev/null || fail "schema_version != 1: $json"
[ "$(echo "$json" | jq -r '.succeeded | length')" = "3" ] || fail "succeeded[] should have 3: $json"
# Empty failed[] → the renderer's ✅ success variant.
echo "$json" | jq -e '.failed == []' >/dev/null || fail "failed[] should be empty on all-green: $json"
}
@test "emits results JSON with empty succeeded[] on an all-fail promote (total variant)" {
# Every service fails → succeeded[]=[], failed[] non-empty → the renderer's
# ❌ total-failure variant (succeeded==0 && failed>0 defensive branch).
run env SERVICES_CSV="svc-b" bash "$SCRIPT"
[ "$status" -ne 0 ] || fail "expected non-zero exit, got $status: $output"
json=$(decode_results_b64)
echo "$json" | jq -e '.schema_version == 1' >/dev/null || fail "schema_version != 1: $json"
echo "$json" | jq -e '.succeeded == []' >/dev/null || fail "succeeded[] should be empty on all-fail: $json"
[ "$(echo "$json" | jq -r '.failed | length')" = "1" ] || fail "failed[] should have 1: $json"
echo "$json" | jq -e '.failed[0].exit == 7' >/dev/null || fail "failed exit code wrong: $json"
}
# ── U4: tier-ordered closure promote with dependent-tier gating ─────────────
#
# When CLOSURE_PLAN (tier-annotated `tier:name,tier:name,...` from U3's
# resolve-promote-targets.sh) is supplied, promote-fleet iterates the closure
# BY TIER (0->1->2). The existing per-service best-effort loop is preserved
# WITHIN a tier, but a tier GATES its dependents: if ANY tier-0 service fails
# pin+verify, tiers 1 and 2 do NOT promote (a stale aimock/harness under fresh
# integrations = non-equivalent prod); a tier-1 failure blocks tier-2. Blocked
# tiers are reported NOT-ATTEMPTED (distinct from FAILED) so the operator can
# re-run (spec R-B). succeeded_csv = the actually-pinned closure subset.
# A per-tier stub: tier-0 = aimock(svc-a)/pocketbase(svc-b); tier-1 =
# harness(svc-h); tier-2 = integrations(svc-i1,svc-i2). svc-fail always fails.
setup_tier_stub() {
cat > "$STUB_DIR/railway-tier" <<'STUB'
#!/usr/bin/env bash
[ "$1" = "promote" ] || { echo "expected first arg 'promote', got '$1'" >&2; exit 99; }
svc="$2"
echo "STUB called for: $svc"
case "$svc" in
svc-fail) exit 7 ;;
*) exit 0 ;;
esac
STUB
chmod +x "$STUB_DIR/railway-tier"
export RAILWAY_BIN="$STUB_DIR/railway-tier"
}
@test "U4: tier-0 failure HALTS tiers 1 and 2 (reported NOT-ATTEMPTED, not failed)" {
setup_tier_stub
# tier-0: svc-fail (fails) + svc-a (ok); tier-1: svc-h; tier-2: svc-i1,svc-i2.
run env CLOSURE_PLAN="0:svc-fail,0:svc-a,1:svc-h,2:svc-i1,2:svc-i2" bash "$SCRIPT"
# tier-0 members ARE attempted (best-effort within the failing tier).
[[ "$output" == *"STUB called for: svc-fail"* ]] || fail "tier-0 svc-fail not attempted: $output"
[[ "$output" == *"STUB called for: svc-a"* ]] || fail "tier-0 svc-a not attempted: $output"
# tiers 1 and 2 are NOT attempted — the tier-0 failure gated them.
[[ "$output" != *"STUB called for: svc-h"* ]] || fail "tier-1 svc-h should NOT have been attempted after tier-0 failure: $output"
[[ "$output" != *"STUB called for: svc-i1"* ]] || fail "tier-2 svc-i1 should NOT have been attempted after tier-0 failure: $output"
[[ "$output" != *"STUB called for: svc-i2"* ]] || fail "tier-2 svc-i2 should NOT have been attempted after tier-0 failure: $output"
# Non-zero aggregate exit (a tier-0 service failed).
[ "$status" -ne 0 ] || fail "expected non-zero exit on tier-0 failure, got $status: $output"
# Blocked tiers reported as NOT-ATTEMPTED — distinct from FAILED so the
# operator knows they can re-run them once tier-0 is healthy.
[[ "$output" == *"NOT-ATTEMPTED"* ]] || fail "expected a NOT-ATTEMPTED report for gated tiers: $output"
[[ "$output" == *"svc-h"* ]] || fail "gated svc-h should be named in NOT-ATTEMPTED: $output"
[[ "$output" == *"svc-i1"* ]] || fail "gated svc-i1 should be named in NOT-ATTEMPTED: $output"
[[ "$output" == *"svc-i2"* ]] || fail "gated svc-i2 should be named in NOT-ATTEMPTED: $output"
# succeeded_csv = actually-pinned subset (only svc-a; NOT the gated tiers,
# NOT the failed svc-fail).
run cat "$GITHUB_OUTPUT"
[[ "$output" == *"succeeded_csv=svc-a"* ]] || fail "succeeded_csv should be exactly the pinned subset (svc-a): $output"
[[ "$output" != *"svc-h"* ]] || fail "gated svc-h must not leak into succeeded_csv: $output"
[[ "$output" != *"svc-fail"* ]] || fail "failed svc-fail must not leak into succeeded_csv: $output"
}
@test "U4: tier-1 failure blocks tier-2 (tier-0 still promoted)" {
setup_tier_stub
# tier-0: svc-a (ok); tier-1: svc-fail (fails); tier-2: svc-i1.
run env CLOSURE_PLAN="0:svc-a,1:svc-fail,2:svc-i1" bash "$SCRIPT"
# tier-0 + tier-1 attempted.
[[ "$output" == *"STUB called for: svc-a"* ]] || fail "tier-0 svc-a not attempted: $output"
[[ "$output" == *"STUB called for: svc-fail"* ]] || fail "tier-1 svc-fail not attempted: $output"
# tier-2 NOT attempted (gated by the tier-1 failure).
[[ "$output" != *"STUB called for: svc-i1"* ]] || fail "tier-2 svc-i1 should NOT have been attempted after tier-1 failure: $output"
[ "$status" -ne 0 ] || fail "expected non-zero exit on tier-1 failure, got $status: $output"
[[ "$output" == *"NOT-ATTEMPTED"* ]] || fail "expected NOT-ATTEMPTED report for gated tier-2: $output"
[[ "$output" == *"svc-i1"* ]] || fail "gated svc-i1 should be named in NOT-ATTEMPTED: $output"
# tier-0 svc-a DID pin even though tier-1 later failed.
run cat "$GITHUB_OUTPUT"
[[ "$output" == *"succeeded_csv=svc-a"* ]] || fail "tier-0 svc-a should be in succeeded_csv: $output"
}
@test "U4: all-green closure promotes every tier in order (0 before 1 before 2)" {
setup_tier_stub
run env CLOSURE_PLAN="0:svc-a,0:svc-b,1:svc-h,2:svc-i1,2:svc-i2" bash "$SCRIPT"
[ "$status" -eq 0 ] || fail "expected zero exit on all-green closure, got $status: $output"
# Every service attempted.
for s in svc-a svc-b svc-h svc-i1 svc-i2; do
[[ "$output" == *"STUB called for: $s"* ]] || fail "$s not attempted: $output"
done
# Tier ordering: tier-0 services appear BEFORE tier-1, which appears BEFORE
# tier-2. Use the byte offset of each STUB line in $output to assert order.
a_pos="${output%%STUB called for: svc-a*}"
h_pos="${output%%STUB called for: svc-h*}"
i1_pos="${output%%STUB called for: svc-i1*}"
[ "${#a_pos}" -lt "${#h_pos}" ] || fail "tier-0 svc-a must promote BEFORE tier-1 svc-h: $output"
[ "${#h_pos}" -lt "${#i1_pos}" ] || fail "tier-1 svc-h must promote BEFORE tier-2 svc-i1: $output"
# All five exported as the succeeded set, in tier order.
run cat "$GITHUB_OUTPUT"
[[ "$output" == *"succeeded_csv=svc-a,svc-b,svc-h,svc-i1,svc-i2"* ]] || fail "wrong succeeded_csv: $output"
}
@test "U4: a tier-2 (leaf) failure does NOT gate anything; best-effort within the leaf tier" {
setup_tier_stub
# tier-0 ok, tier-1 ok, tier-2: svc-fail (fails) + svc-i1 (ok). The leaf tier
# has no dependents, so svc-i1 must STILL be attempted after svc-fail fails
# (the existing best-effort-within-a-tier behavior).
run env CLOSURE_PLAN="0:svc-a,1:svc-h,2:svc-fail,2:svc-i1" bash "$SCRIPT"
[[ "$output" == *"STUB called for: svc-fail"* ]] || fail "tier-2 svc-fail not attempted: $output"
[[ "$output" == *"STUB called for: svc-i1"* ]] || fail "tier-2 svc-i1 not attempted after a sibling failed (best-effort): $output"
[ "$status" -ne 0 ] || fail "expected non-zero exit (a service failed), got $status: $output"
run cat "$GITHUB_OUTPUT"
# svc-i1 pinned despite its sibling failing; svc-fail not in succeeded_csv.
[[ "$output" == *"succeeded_csv=svc-a,svc-h,svc-i1"* ]] || fail "wrong succeeded_csv (leaf best-effort): $output"
}
@test "U4: --digest override still works on the single-service closure path" {
# R-B: the single-service --digest escape path must survive the tier path.
cat > "$STUB_DIR/railway-tier-digest" <<'STUB'
#!/usr/bin/env bash
[ "$1" = "promote" ] || { echo "expected first arg 'promote', got '$1'" >&2; exit 99; }
echo "ARGS: $*"
[[ "$*" == *"--digest sha256:deadbeef"* ]] || { echo "missing digest" >&2; exit 9; }
exit 0
STUB
chmod +x "$STUB_DIR/railway-tier-digest"
export RAILWAY_BIN="$STUB_DIR/railway-tier-digest"
run env CLOSURE_PLAN="2:svc-a" DIGEST="sha256:deadbeef" bash "$SCRIPT"
[ "$status" -eq 0 ] || fail "expected zero exit, got $status: $output"
[[ "$output" == *"ARGS: "*"--digest sha256:deadbeef"* ]] || fail "digest not forwarded on closure path: $output"
}
@test "U4: STAGING_DRIFT_MARKER aggregation works on the tier path too" {
cat > "$STUB_DIR/railway-tier-drift" <<'STUB'
#!/usr/bin/env bash
[ "$1" = "promote" ] || { echo "expected first arg 'promote', got '$1'" >&2; exit 99; }
svc="$2"
echo "STUB called for: $svc"
if [ "$svc" = "svc-h" ]; then
echo "STAGING_DRIFT_MARKER: svc-h(running=f9454e79fbf5,latest=261ccdef3f9a)"
fi
exit 0
STUB
chmod +x "$STUB_DIR/railway-tier-drift"
export RAILWAY_BIN="$STUB_DIR/railway-tier-drift"
run env CLOSURE_PLAN="0:svc-a,1:svc-h,2:svc-i1" bash "$SCRIPT"
[ "$status" -eq 0 ] || fail "drift must not fail the run, got $status: $output"
[[ "$output" == *"STAGING DRIFT (1)"* ]] || fail "missing drift summary block on tier path: $output"
run cat "$GITHUB_OUTPUT"
[[ "$output" == *"staging_drift=svc-h(running=f9454e79fbf5,latest=261ccdef3f9a)"* ]] \
|| fail "missing/wrong staging_drift on tier path: $output"
}
@test "U4: closure plan with empty/whitespace tokens within a tier skips them" {
setup_tier_stub
# A stray comma / whitespace token inside the tier-annotated plan must be
# skipped exactly like the flat-CSV path (preserve trim + empty-token skip).
run env CLOSURE_PLAN="0:svc-a,0: ,1:svc-h" bash "$SCRIPT"
[ "$status" -eq 0 ] || fail "expected zero exit, got $status: $output"
[[ "$output" == *"STUB called for: svc-a"* ]] || fail "svc-a not attempted: $output"
[[ "$output" == *"STUB called for: svc-h"* ]] || fail "svc-h not attempted: $output"
run cat "$GITHUB_OUTPUT"
[[ "$output" == *"succeeded_csv=svc-a,svc-h"* ]] || fail "wrong succeeded_csv (whitespace token skipped): $output"
}
# ── Within-tier parallel fan-out ────────────────────────────────────────────
#
# A full `service=all` promote is dominated by bin/railway's serial
# verify_serving_digest! (~300s/service). Running every promote in a tier
# strictly one-after-another overruns the job timeout mid-fleet. promote-fleet
# now backgrounds `promote_one` WITHIN a tier up to a bounded concurrency cap
# (PROMOTE_FANOUT, default 5), drains all PIDs at the end of the tier (the tier
# BARRIER), then reaps each service's result back into the aggregate. Cross-tier
# ordering + dependent-tier gating + best-effort + the final exit-nonzero-iff-any
# -failed semantics are all preserved.
#
# These tests exercise the REAL surface: bats shells out to the real
# promote-fleet.sh with a RAILWAY_BIN stub. The tier is encoded as the FIRST
# dash-separated field of the service name (e.g. `t0-a` is a tier-0 service) so
# the stub — which only receives `promote <svc>` — can record it without the
# script forwarding the tier.
#
# DETERMINISTIC CONCURRENCY PROOF (no wall-clock-sleep race).
# An earlier version slept a fixed 0.4s in each stub invocation and asserted the
# observed peak overlap was >= 2. That is timing-fragile: on a loaded CI host a
# backgrounded promote's spawn latency can approach the sleep, so the second
# invocation starts only AFTER the first already returned — peak reads 1 and the
# test FALSE-REDs "ran serially". (We were bitten by exactly this flake.)
#
# The rewrite proves parallelism by ACTUAL SIMULTANEITY via a RENDEZVOUS BARRIER,
# independent of any sleep-vs-spawn-latency race:
# * Each stub invocation atomically REGISTERS itself in a shared `running/`
# directory by creating a uniquely-named file (each writes only its OWN
# file — no shared-file append, so there is NO torn-write / atomicity risk).
# * It then BLOCKS, polling the live count of `running/` entries, until that
# count reaches BARRIER_TARGET (= min(cap, n_ready), the max simultaneity the
# launcher can actually reach) OR a generous timeout elapses.
# * While blocking it records the peak live count it ever observed to its own
# `peak.<svc>` file, then DEREGISTERS (removes its file) and exits.
# When the launcher CAN reach the cap, exactly BARRIER_TARGET invocations are
# provably alive together (they all release only once the target is met), so the
# recorded peak == BARRIER_TARGET DETERMINISTICALLY — no dependence on sleep.
# A genuinely-serial (broken) launcher never has >1 invocation alive, so the
# barrier is never met: each invocation blocks until the TIMEOUT, then exits one
# at a time and the recorded peak stays 1 → the `peak>=cap` assertion still goes
# RED on a serial run (RED-on-serial preserved), and the timeout guarantees the
# run TERMINATES rather than deadlocking.
#
# Tests that only need start/end ORDERING (the cross-tier barrier) still record a
# per-event start/end file (one file per event — again no shared-file append) and
# read epoch.ns timestamps back from those files.
# BARRIER_TIMEOUT_SECS — generous upper bound a stub invocation waits at the
# rendezvous before giving up (so a serial launcher terminates + REDs, never
# hangs). Comfortably longer than any real launch latency; short enough that a
# broken-serial run still finishes the bats test quickly.
BARRIER_TIMEOUT_SECS=10
# setup_timeline_stub <barrier_target> [fail_svc] — install a RAILWAY_BIN that,
# for each `promote <svc>`:
# 1. writes a per-event `start.<svc>` file (epoch.ns + tier) under $EVENTS_DIR,
# 2. registers itself in $RUNNING_DIR (atomic: creates its own unique file),
# 3. blocks at the rendezvous until $RUNNING_DIR holds <barrier_target> entries
# OR BARRIER_TIMEOUT_SECS elapses, recording the peak live count it saw to
# $PEAKS_DIR/peak.<svc>,
# 4. deregisters, writes a per-event `end.<svc>` file, then exits 0 (or exit 7
# if <svc> == <fail_svc>, AFTER recording everything so the failing svc is
# still observable).
# All recording is via one-file-per-event writes (no shared-file appends), so the
# measurement is race-free. Pass barrier_target = min(PROMOTE_FANOUT, n_ready).
setup_timeline_stub() {
local barrier_target="$1"
local fail_svc="${2:-}"
export EVENTS_DIR="$BATS_TEST_TMPDIR/events"
export RUNNING_DIR="$BATS_TEST_TMPDIR/running"
export PEAKS_DIR="$BATS_TEST_TMPDIR/peaks"
rm -rf "$EVENTS_DIR" "$RUNNING_DIR" "$PEAKS_DIR"
mkdir -p "$EVENTS_DIR" "$RUNNING_DIR" "$PEAKS_DIR"
cat > "$STUB_DIR/railway-timeline" <<STUB
#!/usr/bin/env bash
[ "\$1" = "promote" ] || { echo "expected first arg 'promote', got '\$1'" >&2; exit 99; }
svc="\$2"
tier="\${svc%%-*}" # leading t<N> field encodes the tier
echo "STUB called for: \$svc"
# (1) per-event start file (its OWN file — no shared append).
printf '%s %s\n' "\$(date +%s.%N)" "\$tier" > "$EVENTS_DIR/start.\$svc"
# (2) register at the rendezvous: create our own unique membership file.
printf '%s' "\$\$" > "$RUNNING_DIR/\$svc"
# (3) block until BARRIER_TARGET are simultaneously alive OR timeout; track the
# peak live count we observe. Count = number of membership files in
# \$RUNNING_DIR, computed by globbing into the positional params (no \`ls\`
# parsing — robust + shellcheck-clean) and reading \$#.
target=$barrier_target
deadline=\$(( \$(date +%s) + $BARRIER_TIMEOUT_SECS ))
peak=0
while :; do
set -- "$RUNNING_DIR"/*
if [ "\$1" = "$RUNNING_DIR/*" ] && [ ! -e "\$1" ]; then
live=0 # glob did not match (empty dir)
else
live=\$#
fi
[ "\$live" -gt "\$peak" ] && peak=\$live
[ "\$live" -ge "\$target" ] && break
[ "\$(date +%s)" -ge "\$deadline" ] && break
sleep 0.02
done
printf '%s' "\$peak" > "$PEAKS_DIR/peak.\$svc"
# (4) deregister, record end, exit.
rm -f "$RUNNING_DIR/\$svc"
printf '%s %s\n' "\$(date +%s.%N)" "\$tier" > "$EVENTS_DIR/end.\$svc"
if [ "\$svc" = "$fail_svc" ]; then exit 7; fi
exit 0
STUB
chmod +x "$STUB_DIR/railway-timeline"
export RAILWAY_BIN="$STUB_DIR/railway-timeline"
}
# max_concurrency — print the peak number of simultaneously-alive stub
# invocations, read DETERMINISTICALLY from the per-invocation peak files the
# rendezvous barrier recorded (each invocation logged the max live count it ever
# saw). The fleet-wide peak is the max across those readings. No timestamp
# sweep, no sleep dependence: when the launcher reaches the cap, every concurrent
# invocation observes (and records) that count before any releases.
max_concurrency() {
local p max=0
for f in "$PEAKS_DIR"/peak.*; do
[ -f "$f" ] || continue
p="$(cat "$f")"
[ "$p" -gt "$max" ] && max="$p"
done
printf '%s' "$max"
}
@test "fan-out: within a tier, promotes run concurrently but never exceed PROMOTE_FANOUT" {
# 6 tier-0 services, cap 3. The rendezvous barrier makes the proof
# deterministic: with cap 3 and 6 ready, exactly 3 promotes are provably alive
# together (they only release once 3 have rendezvoused), so the recorded peak
# is EXACTLY 3 — independent of any sleep. peak must REACH the cap (proves
# parallelism, not still-serial) and never EXCEED it (proves the bound holds).
setup_timeline_stub 3 # BARRIER_TARGET = min(PROMOTE_FANOUT=3, ready=6) = 3
run env PROMOTE_FANOUT=3 \
CLOSURE_PLAN="0:t0-a,0:t0-b,0:t0-c,0:t0-d,0:t0-e,0:t0-f" \
bash "$SCRIPT"
[ "$status" -eq 0 ] || fail "expected zero exit on all-green fan-out, got $status: $output"
# Every service attempted.
for s in t0-a t0-b t0-c t0-d t0-e t0-f; do
[[ "$output" == *"STUB called for: $s"* ]] || fail "$s not attempted: $output"
done
local peak
peak="$(max_concurrency)"
# Bound enforced: never more than PROMOTE_FANOUT in flight at once.
[ "$peak" -le 3 ] || fail "peak concurrency $peak exceeded PROMOTE_FANOUT=3 (peaks: $(cat "$PEAKS_DIR"/peak.* 2>/dev/null))"
# Parallelism actually happened. With the barrier this is deterministic: a
# still-serial launcher never gets >1 invocation alive, never meets the
# rendezvous, and each invocation times out with a recorded peak of 1 — so this
# assertion goes RED on a serial run (RED-on-serial preserved) with no flake.
[ "$peak" -ge 2 ] || fail "peak concurrency $peak < 2 — promotes ran serially, not in parallel (peaks: $(cat "$PEAKS_DIR"/peak.* 2>/dev/null))"
}
@test "fan-out: tier barrier — no tier-1 start precedes any tier-0 end" {
# Cross-tier MUST stay strictly serial: the tier-0 drain (barrier) completes
# before any tier-1 promote launches. A naive fan-out that backgrounds across
# tier boundaries would let a tier-1 start race ahead of a tier-0 end — this
# asserts it does not. BARRIER_TARGET=2 is reachable WITHIN each tier (tier-0
# has 3 ready, tier-1 has 2), so neither tier deadlocks; the rendezvous holds
# each tier's promotes alive together long enough that a cross-tier leak (a
# tier-1 start before a tier-0 end) would be plainly visible in the ordering.
setup_timeline_stub 2
run env PROMOTE_FANOUT=5 \
CLOSURE_PLAN="0:t0-a,0:t0-b,0:t0-c,1:t1-g,1:t1-h" \
bash "$SCRIPT"
[ "$status" -eq 0 ] || fail "expected zero exit, got $status: $output"
# Latest tier-0 `end` timestamp (from per-event end.<svc> files; field 2 is the
# tier, field 1 is the epoch.ns).
local last_t0_end
last_t0_end="$(cat "$EVENTS_DIR"/end.t0-* 2>/dev/null | awk '{ print $1 }' | sort -g | tail -1)"
# Earliest tier-1 `start` timestamp.
local first_t1_start
first_t1_start="$(cat "$EVENTS_DIR"/start.t1-* 2>/dev/null | awk '{ print $1 }' | sort -g | head -1)"
[ -n "$last_t0_end" ] || fail "no tier-0 end markers: $(ls "$EVENTS_DIR")"
[ -n "$first_t1_start" ] || fail "no tier-1 start markers: $(ls "$EVENTS_DIR")"
# Barrier: every tier-1 start happens at or after the last tier-0 end.
# awk numeric compare (timestamps are epoch.ns floats). Boundary-inclusive
# (>=): a tier-1 start tying the last tier-0 end on a coarse clock is valid;
# only a start STRICTLY before a tier-0 end (b < a) is a real violation.
awk -v a="$last_t0_end" -v b="$first_t1_start" 'BEGIN { exit !(b >= a) }' \
|| fail "tier barrier violated: tier-1 start $first_t1_start did not follow tier-0 end $last_t0_end (events: $(ls "$EVENTS_DIR"))"
}
@test "fan-out: a failing tier-0 svc does not abort its siblings; run exits nonzero and reports the failure" {
# Best-effort within the (parallel) tier must survive: when one tier-0 service
# exits 7, every OTHER tier-0 service still launches + runs to completion, the
# final exit is nonzero, and the summary lists the failed svc as `<svc>=7`.
# The failing svc records its start/barrier/end markers BEFORE exiting 7, so it
# still participates in the rendezvous (BARRIER_TARGET=4 = min(cap 5, ready 4)).
setup_timeline_stub 4 "t0-b"
run env PROMOTE_FANOUT=5 \
CLOSURE_PLAN="0:t0-a,0:t0-b,0:t0-c,0:t0-d" \
bash "$SCRIPT"
# All four tier-0 services launched despite t0-b failing.
for s in t0-a t0-b t0-c t0-d; do
[[ "$output" == *"STUB called for: $s"* ]] || fail "$s not attempted (best-effort broken): $output"
[ -f "$EVENTS_DIR/start.$s" ] || fail "$s has no start marker (did not actually launch): $(ls "$EVENTS_DIR")"
done
# Non-zero aggregate exit because t0-b failed.
[ "$status" -ne 0 ] || fail "expected non-zero exit on a failed svc, got $status: $output"
# Summary classifies t0-b as failed with its exit code.
[[ "$output" == *"t0-b=7"* ]] || fail "failed svc t0-b=7 not reported in summary: $output"
# The greens are reported succeeded.
run cat "$GITHUB_OUTPUT"
[[ "$output" == *"succeeded_csv="* ]] || fail "missing succeeded_csv: $output"
[[ "$output" == *"t0-a"* && "$output" == *"t0-c"* && "$output" == *"t0-d"* ]] \
|| fail "green siblings missing from succeeded_csv: $output"
[[ "$output" != *"t0-b"* ]] || fail "failed t0-b leaked into succeeded_csv: $output"
}
@test "fan-out: PROMOTE_FANOUT override is honored (cap 2 holds peak <= 2)" {
# Lowering the cap to 2 must hold the peak concurrency to 2 even with 6 ready
# services — proves the env override actually drives the launcher bound. With
# BARRIER_TARGET=2 the rendezvous deterministically reaches exactly 2 (if the
# launcher honors the override); if it ignored the cap and ran all 6, peak
# would exceed 2; if it ran serially, peak would stay 1 and the >=2 assertion
# REDs. Either way the override is proven without any sleep dependence.
setup_timeline_stub 2 # BARRIER_TARGET = min(PROMOTE_FANOUT=2, ready=6) = 2
run env PROMOTE_FANOUT=2 \
CLOSURE_PLAN="0:t0-a,0:t0-b,0:t0-c,0:t0-d,0:t0-e,0:t0-f" \
bash "$SCRIPT"
[ "$status" -eq 0 ] || fail "expected zero exit, got $status: $output"
local peak
peak="$(max_concurrency)"
[ "$peak" -le 2 ] || fail "PROMOTE_FANOUT=2 not honored: peak concurrency $peak > 2 (peaks: $(cat "$PEAKS_DIR"/peak.* 2>/dev/null))"
[ "$peak" -ge 2 ] || fail "cap-2 run never reached concurrency 2 — not parallel (peaks: $(cat "$PEAKS_DIR"/peak.* 2>/dev/null))"
}
@test "fan-out: a PRESENT-but-EMPTY .rc (crash/disk-full mid-write) is treated as failed, not parsed as garbage" {
# The backgrounded promote_one subshell can be killed (or the disk fill) AFTER
# its <slot>.rc file exists but BEFORE the exit code is written — leaving a
# PRESENT-but-EMPTY .rc. reap_tier's `-f` check passes (the file exists), so the
# missing-file guard does NOT fire; if reap_tier then trusts the empty contents,
# `[ "$rc" -eq 0 ]` runs on rc="" and bash errors with "integer expression
# expected", and the svc gets recorded as a garbage `<svc>=` (empty rc). This
# asserts the empty/non-numeric rc is validated and folded as a real failure.
#
# We drive it with a stub that locates the script's scratch dir, pre-writes an
# EMPTY <svc>.rc, then kill -9's its parent (the promote_one subshell) so the
# script never overwrites the empty .rc with a real exit code.
cat > "$STUB_DIR/railway-emptyrc" <<'STUB'
#!/usr/bin/env bash
[ "$1" = "promote" ] || { echo "expected first arg 'promote', got '$1'" >&2; exit 99; }
svc="$2"
echo "STUB called for: $svc"
# Locate promote_one's scratch dir (the sole promote-fleet.* under TMPDIR) and
# pre-seed an EMPTY .rc for this service, mimicking a write that began but never
# completed (crash/disk-full). svc names here carry no '/', so the slot == svc.
work="$(ls -d "${TMPDIR:-/tmp}"/promote-fleet.* 2>/dev/null | tail -1)"
if [ -n "$work" ] && [ -d "$work" ]; then
: > "$work/$svc.rc"
fi
# Kill the promote_one subshell so it dies BEFORE writing the real rc, leaving
# the present-but-empty .rc behind. The stub runs inside promote_one's command
# substitution: $PPID is that cmd-subst subshell; its parent (grandparent of the
# stub) is the backgrounded promote_one subshell itself — kill THAT so line 179's
# `echo "$rc" > <slot>.rc` never overwrites the empty file. -9 so no trap rewrites it.
gp="$(ps -o ppid= -p "$PPID" 2>/dev/null | tr -d ' ')"
[ -n "$gp" ] && kill -9 "$gp" 2>/dev/null
kill -9 "$PPID" 2>/dev/null
exit 0
STUB
chmod +x "$STUB_DIR/railway-emptyrc"
export RAILWAY_BIN="$STUB_DIR/railway-emptyrc"
run env SERVICES_CSV="svc-empty" bash "$SCRIPT"
# (1) the run exits NONZERO — a lost/garbled result must never read as success.
[ "$status" -ne 0 ] || fail "expected non-zero exit on empty .rc, got $status: $output"
# (2) the bash `[` integer-comparison error must NOT leak (the symptom of an
# unvalidated empty rc reaching `[ "$rc" -eq 0 ]`).
[[ "$output" != *"integer expression expected"* ]] \
|| fail "empty rc leaked into integer comparison: $output"
# (3) the service is recorded as a real failure (rc coerced to 1), NOT a garbage
# empty `svc-empty=` entry.
[[ "$output" == *"svc-empty=1"* ]] || fail "empty-rc svc not recorded as svc-empty=1: $output"
[[ "$output" != *"svc-empty= "* && "$output" != *"svc-empty="$'\n'* ]] \
|| fail "garbage empty-rc entry (svc-empty=) leaked into summary: $output"
}
# --- NEW: standalone services (`s:` tier marker) -----------------------------
# A standalone service is promoted UNGATED: it is attempted regardless of any
# other service's failure (never NOT-ATTEMPTED on an unrelated failure), and its
# own failure fails the run WITHOUT gating the tiered services.
@test "U4: a standalone (s:) service is promoted even when an unrelated tier-1 service fails" {
setup_tier_stub
# tier-1 svc-fail fails (gating tier-2 svc-i1 as usual). The standalone
# svc-docs (s:) must STILL be attempted and must land in succeeded_csv.
run env CLOSURE_PLAN="s:svc-docs,0:svc-a,1:svc-fail,2:svc-i1" bash "$SCRIPT"
# Standalone attempted DESPITE the unrelated tier-1 failure.
[[ "$output" == *"STUB called for: svc-docs"* ]] \
|| fail "standalone svc-docs must be attempted despite the tier-1 failure: $output"
# Normal tier gating is unchanged: tier-2 svc-i1 is still gated.
[[ "$output" != *"STUB called for: svc-i1"* ]] \
|| fail "tier-2 svc-i1 should still be gated by the tier-1 failure: $output"
# The run still fails (svc-fail genuinely failed)...
[ "$status" -ne 0 ] || fail "expected non-zero exit (svc-fail failed), got $status: $output"
# ...but the standalone promoted successfully and is NOT in NOT-ATTEMPTED.
run cat "$GITHUB_OUTPUT"
[[ "$output" == *"svc-docs"* ]] || fail "standalone svc-docs should be in succeeded_csv: $output"
}
@test "U4: a standalone (s:) service's OWN failure fails the run but does NOT gate the tiers" {
setup_tier_stub
# The standalone svc-fail fails. Because a standalone neither gates nor is
# gated, EVERY tier must still be attempted, and the run exits non-zero.
run env CLOSURE_PLAN="s:svc-fail,0:svc-a,1:svc-h,2:svc-i1" bash "$SCRIPT"
[[ "$output" == *"STUB called for: svc-fail"* ]] || fail "standalone svc-fail not attempted: $output"
# All tiers STILL attempted — the standalone failure must not gate them.
[[ "$output" == *"STUB called for: svc-a"* ]] || fail "tier-0 svc-a gated by standalone failure: $output"
[[ "$output" == *"STUB called for: svc-h"* ]] || fail "tier-1 svc-h gated by standalone failure: $output"
[[ "$output" == *"STUB called for: svc-i1"* ]] || fail "tier-2 svc-i1 gated by standalone failure: $output"
[ "$status" -ne 0 ] || fail "expected non-zero exit (standalone svc-fail failed), got $status: $output"
# The tiered services were NOT recorded NOT-ATTEMPTED.
[[ "$output" != *"NOT-ATTEMPTED"* ]] || fail "a standalone failure must not gate (NOT-ATTEMPTED) any tier: $output"
}
@test "U4: an all-green plan with a standalone (s:) member promotes everything" {
setup_tier_stub
run env CLOSURE_PLAN="s:svc-docs,0:svc-a,1:svc-h,2:svc-i1" bash "$SCRIPT"
[ "$status" -eq 0 ] || fail "expected exit 0 for an all-green plan, got $status: $output"
for s in svc-docs svc-a svc-h svc-i1; do
[[ "$output" == *"STUB called for: $s"* ]] || fail "$s not attempted: $output"
done
run cat "$GITHUB_OUTPUT"
[[ "$output" == *"svc-docs"* ]] || fail "standalone svc-docs should be in succeeded_csv: $output"
}
@@ -0,0 +1,170 @@
#!/usr/bin/env bats
# Tests for the alert post-and-verify predicate shared by
# .github/workflows/showcase_promote_notify.yml and its dry-run helper.
#
# The bug under test: the thread-reply and #oss-alerts cross-post used to pipe
# the Slack API response to /dev/null. Slack returns HTTP 200 with
# `{"ok":false,"error":"channel_not_found"}` on LOGICAL failures, so a failed
# failure-ALERT (the page-the-humans message) was silently dropped — no warning,
# no non-zero exit. `slack_alert_posted_ok` is the testable predicate that now
# surfaces such drops via a GitHub `::warning::` and a non-zero return.
#
# NB on assertion gating: bats does NOT run test bodies under errexit. Only the
# FINAL command's status decides pass/fail, so every non-final assertion is
# written `[[ ... ]] || fail "message"`. The `|| fail` is what forces the hard
# failure; dropping it turns the assertion into a silent false-green.
fail() {
echo "$1" >&2
return 1
}
setup() {
# The predicate lives in the workflow's dry-run helper. Source it (the helper
# has an EXECUTION GUARD so sourcing defines functions without running the
# dry-run body).
HELPER="$BATS_TEST_DIRNAME/../../../.github/workflows/showcase_promote_notify.dry-run.sh"
[ -f "$HELPER" ] || fail "helper not found: $HELPER"
# shellcheck source=/dev/null
source "$HELPER"
}
@test "slack_alert_posted_ok: ok:true response returns 0 and emits no warning" {
run slack_alert_posted_ok "#oss-alerts cross-post" '{"ok":true,"ts":"123.456"}'
[ "$status" -eq 0 ] || fail "expected status 0 on ok:true, got $status"
[[ "$output" != *"::warning::"* ]] || fail "expected NO warning on ok:true, got: $output"
}
@test "slack_alert_posted_ok: ok:false (channel_not_found) returns non-zero and warns" {
# This is the silent-drop the fix surfaces: HTTP 200 but logical failure.
run slack_alert_posted_ok "#oss-alerts cross-post" '{"ok":false,"error":"channel_not_found"}'
[ "$status" -ne 0 ] || fail "expected non-zero status on ok:false, got $status"
[[ "$output" == *"::warning::"* ]] || fail "expected a ::warning:: on ok:false, got: $output"
[[ "$output" == *"channel_not_found"* ]] || fail "expected the Slack error in the warning, got: $output"
[[ "$output" == *"#oss-alerts cross-post"* ]] || fail "expected the call label in the warning, got: $output"
}
@test "slack_alert_posted_ok: transport-failure sentinel ({}) returns non-zero and warns" {
# slack_api returns "{}" on non-2xx/transport failure; treat that as a drop.
run slack_alert_posted_ok "thread reply" '{}'
[ "$status" -ne 0 ] || fail "expected non-zero status on empty response, got $status"
[[ "$output" == *"::warning::"* ]] || fail "expected a ::warning:: on empty response, got: $output"
[[ "$output" == *"thread reply"* ]] || fail "expected the call label in the warning, got: $output"
}
# ---------- A3: high-value predicate edge cases ----------
# Each must be treated as a DROPPED page: non-zero return AND a surfaced
# ::warning::. These exercise the `jq ... || echo false` / `// false` defenses
# against non-JSON, malformed, and ok-key-absent responses.
@test "slack_alert_posted_ok: curl transport error / non-JSON body returns non-zero and warns" {
# slack_api feeds the raw body through on some failure modes; a proxy/5xx page
# like '<html>500</html>' is not JSON — jq fails, `.ok` must default to false.
run slack_alert_posted_ok "#oss-alerts cross-post" '<html>500</html>'
[ "$status" -ne 0 ] || fail "expected non-zero status on non-JSON body, got $status"
[[ "$output" == *"::warning::"* ]] || fail "expected a ::warning:: on non-JSON body, got: $output"
[[ "$output" == *"#oss-alerts cross-post"* ]] || fail "expected the call label in the warning, got: $output"
}
@test "slack_alert_posted_ok: malformed JSON returns non-zero and warns" {
# A truncated/garbled body that jq cannot parse — must NOT be treated as ok.
run slack_alert_posted_ok "#oss-alerts cross-post" '{"ok":tru'
[ "$status" -ne 0 ] || fail "expected non-zero status on malformed JSON, got $status"
[[ "$output" == *"::warning::"* ]] || fail "expected a ::warning:: on malformed JSON, got: $output"
}
@test "slack_alert_posted_ok: missing ok key entirely ({}) returns non-zero and warns" {
# Valid JSON but no `ok` field — `.ok // false` must default to false.
run slack_alert_posted_ok "#oss-alerts cross-post" '{}'
[ "$status" -ne 0 ] || fail "expected non-zero status on missing ok key, got $status"
[[ "$output" == *"::warning::"* ]] || fail "expected a ::warning:: on missing ok key, got: $output"
}
@test "slack_alert_posted_ok: ok:null returns non-zero and warns" {
# `.ok // false` only defaults on null/absent; an explicit null must NOT pass.
run slack_alert_posted_ok "#oss-alerts cross-post" '{"ok":null}'
[ "$status" -ne 0 ] || fail "expected non-zero status on ok:null, got $status"
[[ "$output" == *"::warning::"* ]] || fail "expected a ::warning:: on ok:null, got: $output"
}
# ---------- A2: anti-drift parity guard ----------
# The bats suite sources ONLY the .sh mirror, so a future yml-only edit to
# slack_alert_posted_ok would drift undetected while bats stayed green. Extract
# the function body from BOTH files and assert they are byte-identical modulo
# leading indentation (the .yml carries the step's run-block indent). Drift =>
# CI failure.
@test "slack_alert_posted_ok: yml and sh mirror definitions are identical (anti-drift)" {
local root yml sh
root="$BATS_TEST_DIRNAME/../../../.github/workflows"
yml="$root/showcase_promote_notify.yml"
sh="$root/showcase_promote_notify.dry-run.sh"
[ -f "$yml" ] || fail "yml not found: $yml"
[ -f "$sh" ] || fail "sh mirror not found: $sh"
# Extract `slack_alert_posted_ok() { ... }` (first such block) and strip
# leading whitespace so indent differences between the two homes don't count.
extract() {
awk '/^[[:space:]]*slack_alert_posted_ok\(\) \{/{f=1} f{print} f&&/^[[:space:]]*\}$/{exit}' "$1" \
| sed 's/^[[:space:]]*//'
}
local yml_body sh_body
yml_body=$(extract "$yml")
sh_body=$(extract "$sh")
[ -n "$yml_body" ] || fail "could not extract slack_alert_posted_ok from $yml"
[ -n "$sh_body" ] || fail "could not extract slack_alert_posted_ok from $sh"
[ "$yml_body" = "$sh_body" ] || fail "slack_alert_posted_ok drifted between yml and sh mirror:
$(diff <(printf '%s\n' "$sh_body") <(printf '%s\n' "$yml_body"))"
}
# ---------- A1: end-to-end call-site fail-loud/warn-only distinction ----------
# The predicate above is exercised in isolation, but the BUG the branch fixes is
# in the CALL-SITE WIRING: the #oss-alerts page-the-humans post is FAIL-LOUD (no
# `|| true`, so a dropped delivery reds the renderer job) while the thread-reply
# summary post stays WARN-ONLY (`|| true`). A future re-add of `|| true` to the
# #oss-alerts call-site would leave the predicate tests green while silently
# reintroducing the drop. These tests run the dry-run script as a subprocess on a
# FAILURE outcome (so BOTH posts execute) and inject responses via the
# DRY_RUN_OSS_RESP / DRY_RUN_THREAD_RESP hooks to lock the per-call-site exit
# semantics. The `partial` fixture yields outcome=partial → both posts fire.
PARTIAL_FIXTURE() {
echo "$BATS_TEST_DIRNAME/../../test-fixtures/promote-notify/partial.json"
}
@test "call-site: dropped #oss-alerts page (ok:false) reds the job (non-zero exit)" {
local fixture
fixture="$(PARTIAL_FIXTURE)"
[ -f "$fixture" ] || fail "partial fixture not found: $fixture"
# OSS page drops, thread reply ok. Fail-loud call-site must propagate non-zero.
run env DRY_RUN_OSS_RESP='{"ok":false,"error":"channel_not_found"}' \
bash "$HELPER" --file "$fixture"
[ "$status" -ne 0 ] || fail "expected non-zero exit when #oss-alerts page is dropped, got $status; output: $output"
[[ "$output" == *"::warning::"* ]] || fail "expected a ::warning:: for the dropped page, got: $output"
[[ "$output" == *"#oss-alerts cross-post"* ]] || fail "expected the #oss-alerts label in the warning, got: $output"
}
@test "call-site: dropped thread reply (ok:false) is warn-only (zero exit + warning)" {
local fixture
fixture="$(PARTIAL_FIXTURE)"
[ -f "$fixture" ] || fail "partial fixture not found: $fixture"
# Thread reply drops, OSS page ok. Warn-only call-site must NOT red the job.
run env DRY_RUN_THREAD_RESP='{"ok":false,"error":"channel_not_found"}' \
bash "$HELPER" --file "$fixture"
[ "$status" -eq 0 ] || fail "expected zero exit when only the thread reply is dropped, got $status; output: $output"
[[ "$output" == *"::warning::"* ]] || fail "expected a ::warning:: for the dropped thread reply, got: $output"
[[ "$output" == *"thread reply"* ]] || fail "expected the thread-reply label in the warning, got: $output"
}
@test "call-site: both posts ok → zero exit, no warning" {
local fixture
fixture="$(PARTIAL_FIXTURE)"
[ -f "$fixture" ] || fail "partial fixture not found: $fixture"
# Default sim responses are ok:true for both posts.
run bash "$HELPER" --file "$fixture"
[ "$status" -eq 0 ] || fail "expected zero exit when both posts succeed, got $status; output: $output"
[[ "$output" != *"::warning::"* ]] || fail "expected NO warning when both posts succeed, got: $output"
[[ "$output" == *"outcome=partial"* ]] || fail "expected the trailing outcome line (proves the OSS post ran), got: $output"
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,109 @@
/**
* railway-envs.golden.test.ts — Behavior-preservation guard for the
* railway-envs SSOT env-map refactor (Option C).
*
* This test serializes the FULLY-RESOLVED view of every service in every
* env — exactly what the public accessors (instanceIdFor / domainFor /
* repoNameFor) and the per-entry probe config return — into a single
* canonical fixture (`fixtures/railway-envs.golden.json`).
*
* It is intentionally accessor-driven, NOT field-driven: it does not read
* `entry.prodInstanceId` / `entry.domains.prod` / `entry.probe` directly.
* It reads ONLY through the public resolution surface. That is the whole
* point — the refactor swaps the internal `ServiceEntry` shape
* (`prodInstanceId`/`stagingInstanceId`/`domains`/`probe`/`probeDriver`)
* for a unified `environments` map, and this snapshot proves that for
* every existing (service, env) pair the RESOLVED values are byte-identical
* before and after. If the refactor changes any resolved instanceId,
* domain, probe flag, driver, or repoName, this `toEqual` fails loud.
*
* The fixture is committed on the CURRENT (pre-refactor) schema. The
* env-map refactor keeps it green EXCEPT for the documented non-functional
* placeholder/borrowed values it removes (visible as the only diff to this
* fixture in the refactor commit):
*
* 1. `harness-workers.prod` — DROPPED. The old schema required a
* distinct prod UUID per entry, so the worker (a staging-only service)
* carried its own serviceId mirrored as a non-functional prod
* placeholder that was never dereferenced. The env-map schema simply
* omits the prod env.
* 2. `harness-workers.staging` `domain` — null (was a BORROWED
* control-plane host). This domainless worker has `probe:false`, so
* `domainFor` is never called for it at runtime; the old schema's
* `domains{}` invariant forced a borrowed host literal, which the
* env-map schema drops. Resolution via `domainFor` now throws
* (captured as null) — behavior-preserving because no runtime path
* probed this host.
*
* Every OTHER (service, env) pair resolves byte-identically.
*/
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
import {
SERVICES,
domainFor,
envsFor,
instanceIdFor,
probeEnabled,
repoNameFor,
} from "../railway-envs";
import type { EnvName } from "../railway-envs";
const GOLDEN_PATH = resolve(__dirname, "fixtures", "railway-envs.golden.json");
/**
* Resolve, for one (service, env) pair, the same projection the rest of
* the tooling depends on — exclusively via the public accessors plus the
* per-entry probe config that verify-deploy consumes. `domain` is captured
* defensively (domainFor throws on a missing/scheme-bearing host, so we
* record null on throw rather than aborting the whole snapshot — a service
* that legitimately throws today must keep throwing after the refactor).
*/
function resolveServiceEnv(name: string, env: EnvName) {
const entry = SERVICES[name];
let domain: string | null;
try {
domain = domainFor(name, env);
} catch {
domain = null;
}
return {
instanceId: instanceIdFor(name, env),
domain,
probe: probeEnabled(name, env),
driver: entry.probeDriver,
repoName: repoNameFor(name, env),
};
}
function buildSnapshot(): Record<
string,
Record<string, ReturnType<typeof resolveServiceEnv>>
> {
const out: Record<
string,
Record<string, ReturnType<typeof resolveServiceEnv>>
> = {};
for (const name of Object.keys(SERVICES).sort()) {
out[name] = {};
// Iterate the envs that genuinely exist for this service (envsFor),
// NOT a hardcoded ["prod","staging"]. This is what proves the refactor
// preserved resolution for every REAL (service, env) pair while
// dropping the old schema's non-functional placeholder env entries
// (e.g. harness-workers's mirrored prod instanceId).
for (const env of envsFor(name)) {
out[name][env] = resolveServiceEnv(name, env);
}
}
return out;
}
describe("railway-envs golden snapshot (behavior-preservation guard)", () => {
it("resolves every (service, env) pair byte-identically to the frozen fixture", () => {
const actual = buildSnapshot();
const expected = JSON.parse(readFileSync(GOLDEN_PATH, "utf8"));
expect(actual).toEqual(expected);
});
});
@@ -0,0 +1,150 @@
#!/usr/bin/env bats
# Tests for reconcile-prod-gate.sh — the on-demand prod-vs-staging drift gate.
#
# Core invariant under test: a STALE prod service (its serving digest has
# drifted BEHIND a green staging) must be caught LOUD — the gate exits non-zero
# so a manual run visibly flags the drift on the run. A fleet with no stale
# service passes (exit 0); a hard error (exit 2) is never swallowed.
#
# The gate is a thin wrapper around `bin/railway reconcile-prod`, whose
# exit-code contract IS the assertion: exit 1 on a stale service, exit 0 when
# none stale (all green, or only green+gray), exit 2 on a hard error. The real
# `bin/railway` is replaced via RAILWAY_BIN, pointed at a stub mimicking those
# exit codes. The gate's job is to (a) invoke `reconcile-prod` with the right
# subcommand and (b) FAIL the step (propagate the non-zero) on a stale service
# — never swallow it.
#
# NB on assertion gating: bats does NOT run test bodies under `set -e`. Only the
# FINAL command decides pass/fail, so every non-final assertion is written
# `[[ ... ]] || fail "msg"` to force a hard failure with a diagnostic.
# fail <msg> — print the message to the bats failure stream and abort the test.
fail() {
echo "$1" >&2
return 1
}
setup() {
SCRIPT="$BATS_TEST_DIRNAME/../reconcile-prod-gate.sh"
[ -x "$SCRIPT" ] || fail "reconcile-prod-gate.sh missing or not executable at $SCRIPT"
STUB_DIR="$BATS_TEST_TMPDIR/stub"
mkdir -p "$STUB_DIR"
# Stale stub: mimics `bin/railway reconcile-prod` finding a prod service that
# has drifted behind a green staging — prints a table + STALE line and exits
# 1 (the reconcile-prod findings contract). Also asserts the gate invokes it
# with the `reconcile-prod` subcommand, so an arg-order regression is caught.
cat > "$STUB_DIR/railway-stale" <<'STUB'
#!/usr/bin/env bash
[ "$1" = "reconcile-prod" ] || { echo "expected first arg 'reconcile-prod', got '$1'" >&2; exit 99; }
echo "NAME STATUS PROD STAGING"
echo "shell stale aaaa1111 bbbb2222"
echo ""
echo "Summary: 0 green, 1 stale, 0 gray (1 prod-eligible)."
echo "STALE: prod has drifted behind green staging for: shell."
exit 1
STUB
chmod +x "$STUB_DIR/railway-stale"
# In-sync stub: no service stale — exits 0.
cat > "$STUB_DIR/railway-green" <<'STUB'
#!/usr/bin/env bash
[ "$1" = "reconcile-prod" ] || { echo "expected first arg 'reconcile-prod', got '$1'" >&2; exit 99; }
echo "Summary: 39 green, 0 stale, 0 gray (39 prod-eligible)."
echo "OK: no production service is stale vs staging."
exit 0
STUB
chmod +x "$STUB_DIR/railway-green"
# Error stub: reconcile-prod hit a hard error (auth/GraphQL) — exits 2. The
# gate must treat a non-zero (incl. 2) as a hard failure, never a pass.
cat > "$STUB_DIR/railway-error" <<'STUB'
#!/usr/bin/env bash
[ "$1" = "reconcile-prod" ] || { echo "expected first arg 'reconcile-prod', got '$1'" >&2; exit 99; }
echo "graphql error: boom" >&2
exit 2
STUB
chmod +x "$STUB_DIR/railway-error"
# JSON-error stub: distinguishes the machine-output (`--json`) invocation from
# the human-table invocation. The `--json` invocation emits a DISTINCT stderr
# diagnostic (`JSON-CAPTURE-DIAG`) and NO stdout payload, then exits 2; the
# human-table invocation emits its own line. This isolates the `--json`
# capture path so we can assert it (a) preserves ITS stderr diagnostic instead
# of routing it to /dev/null and (b) does not write a blank artifact from the
# empty stdout.
cat > "$STUB_DIR/railway-json-error" <<'STUB'
#!/usr/bin/env bash
[ "$1" = "reconcile-prod" ] || { echo "expected first arg 'reconcile-prod', got '$1'" >&2; exit 99; }
if [ "$2" = "--json" ]; then
echo "graphql error: JSON-CAPTURE-DIAG staging outage" >&2
exit 2
fi
echo "graphql error: human-table boom" >&2
exit 2
STUB
chmod +x "$STUB_DIR/railway-json-error"
}
@test "stale prod service -> gate fails loud (non-zero, finding surfaced)" {
run env RAILWAY_BIN="$STUB_DIR/railway-stale" bash "$SCRIPT"
[ "$status" -ne 0 ] || fail "expected non-zero exit on a stale prod service, got $status"
[[ "$output" == *"STALE"* ]] || fail "stale finding not surfaced: $output"
[[ "$output" == *"shell"* ]] || fail "stale service name not surfaced: $output"
}
@test "no stale service -> gate passes (exit 0)" {
run env RAILWAY_BIN="$STUB_DIR/railway-green" bash "$SCRIPT"
[ "$status" -eq 0 ] || fail "expected exit 0 when no service stale, got $status ($output)"
[[ "$output" == *"no production service has drifted stale"* ]] || fail "OK line not surfaced: $output"
}
@test "reconcile-prod hard error (exit 2) -> gate fails (never swallowed)" {
run env RAILWAY_BIN="$STUB_DIR/railway-error" bash "$SCRIPT"
[ "$status" -ne 0 ] || fail "expected non-zero exit on a reconcile-prod hard error, got $status"
}
@test "missing/non-executable RAILWAY_BIN -> fails loud (not a vacuous pass)" {
run env RAILWAY_BIN="$STUB_DIR/does-not-exist" bash "$SCRIPT"
[ "$status" -ne 0 ] || fail "expected non-zero exit when RAILWAY_BIN is missing, got $status"
[[ "$output" == *"missing or not executable"* ]] || fail "missing-binary error not surfaced: $output"
}
@test "RECONCILE_JSON writes machine output without changing the verdict" {
# The JSON capture is a best-effort SECOND invocation; its presence must not
# change the gate verdict. With the green stub the gate still exits 0, and
# the JSON file is created from the stub's stdout.
JSON_OUT="$BATS_TEST_TMPDIR/reconcile.json"
run env RAILWAY_BIN="$STUB_DIR/railway-green" RECONCILE_JSON="$JSON_OUT" bash "$SCRIPT"
[ "$status" -eq 0 ] || fail "expected exit 0 with green stub + RECONCILE_JSON, got $status ($output)"
[ -f "$JSON_OUT" ] || fail "RECONCILE_JSON file not written"
}
@test "RECONCILE_JSON capture preserves the stderr diagnostic on a hard error" {
# When the --json capture invocation hits a hard error, its stderr diagnostic
# must NOT be swallowed (routed to /dev/null) — a failed probe has to leave a
# diagnostic trail in the job log instead of vanishing.
JSON_OUT="$BATS_TEST_TMPDIR/reconcile.json"
run env RAILWAY_BIN="$STUB_DIR/railway-json-error" RECONCILE_JSON="$JSON_OUT" bash "$SCRIPT"
[[ "$output" == *"JSON-CAPTURE-DIAG"* ]] || fail "stderr diagnostic from the --json capture was swallowed: $output"
}
@test "RECONCILE_JSON does not write a blank artifact when the capture produces nothing" {
# When the --json invocation errors and emits no payload, the gate must NOT
# leave a blank/empty reconcile.json behind — an empty artifact is uploaded as
# if it were a real result. Either no file, or a non-empty file.
JSON_OUT="$BATS_TEST_TMPDIR/reconcile.json"
run env RAILWAY_BIN="$STUB_DIR/railway-json-error" RECONCILE_JSON="$JSON_OUT" bash "$SCRIPT"
if [ -f "$JSON_OUT" ]; then
[ -s "$JSON_OUT" ] || fail "wrote a blank reconcile.json artifact (empty file) on a no-payload error"
fi
}
@test "the on-demand workflow wires the gate in" {
# Guard against the gate being orphaned (script present + tested, but never
# invoked by the reconcile workflow). Assert the workflow actually calls
# reconcile-prod-gate.sh.
WF="$BATS_TEST_DIRNAME/../../../.github/workflows/showcase_reconcile.yml"
[ -f "$WF" ] || fail "reconcile workflow missing at $WF"
grep -q "reconcile-prod-gate.sh" "$WF" || fail "showcase_reconcile.yml does not invoke reconcile-prod-gate.sh"
}
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { expandImageConsumers } from "../redeploy-env";
import { SERVICES } from "../railway-envs";
// Regression: the prod `harness-workers` fleet worker runs the shared
// `showcase-harness` image (`imageOf: "harness"`) but had NO `prod` env entry
// in the SSOT. `expandImageConsumers` is env-aware — a consumer only joins an
// env's redeploy scope if it declares that env — so a rebuilt
// `showcase-harness:latest` bounced the prod control-plane but SILENTLY SKIPPED
// the prod worker, leaving it on a stale image (a 1-demo `registry.json` for
// `ms-agent-harness-dotnet` → missing `UI` badge → `D0`). This test exercises
// the REAL expansion against the REAL SSOT and asserts the prod worker enters
// the prod harness redeploy scope.
//
// Verified prod worker identity (Railway, READ-ONLY GraphQL):
// serviceId c2aa8a0b-350e-4b76-8541-3012dfac41d0
// prod env b14919f4-6417-429f-848d-c6ae2201e04f
// instanceId 7c48ee43-6df4-457b-b977-10f1f1ac1680
const PROD_WORKER_SERVICE_ID = "c2aa8a0b-350e-4b76-8541-3012dfac41d0";
describe("harness redeploy-scope expansion (imageOf: harness) — prod worker", () => {
it("includes harness-workers in the PROD redeploy scope when showcase-harness rebuilds", () => {
// The image-rebuild scope for `showcase-harness:latest` starts at its
// builder SSOT key, `harness`. The redeploy script expands this with
// env-aware imageOf consumers before redeploying.
const scope = expandImageConsumers(["harness"], "prod");
// The prod worker MUST be pulled in so a rebuild bounces it off its stale
// image. (RED before the SSOT backfill: the worker declares only `staging`,
// so the env filter at redeploy-env.ts:278 drops it from the prod scope.)
expect(scope).toContain("harness-workers");
// And it must resolve to the real prod worker service.
const worker = SERVICES["harness-workers"];
expect(worker.serviceId).toBe(PROD_WORKER_SERVICE_ID);
expect(Object.hasOwn(worker.environments, "prod")).toBe(true);
});
it("still includes harness-workers in the STAGING redeploy scope (no regression)", () => {
const scope = expandImageConsumers(["harness"], "staging");
expect(scope).toContain("harness-workers");
});
});
@@ -0,0 +1,135 @@
import { mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { runRedeploy } from "../redeploy-env";
import { SERVICES } from "../railway-envs";
// A.4: per-service JSON summary contract.
//
// Shape (cross-workstream contract, consumed by showcase_deploy.yml's
// `enforce-redeploy-gate` job in A.7):
// Array<{ service: string; status: "ok" | "error"; error?: string }>
//
// When REDEPLOY_SUMMARY_JSON env var is set, runRedeploy MUST write the
// records array to that path atomically (stage to .tmp, rename). When
// unset, no JSON is written.
//
// PR #5093's exit-code contract MUST be preserved verbatim:
// - staging: exitCode === 0 even when all per-service redeploys fail
// - prod: exitCode === 1 on any per-service failure
// The JSON write happens BEFORE the exit-code computation; it never
// changes exit semantics on disk hiccups (write failures are warn-only).
describe("redeploy-env per-service JSON summary", () => {
let dir: string;
let summaryPath: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "redeploy-summary-"));
summaryPath = join(dir, "summary.json");
process.env.REDEPLOY_SUMMARY_JSON = summaryPath;
});
afterEach(() => {
delete process.env.REDEPLOY_SUMMARY_JSON;
rmSync(dir, { recursive: true, force: true });
});
it("writes one JSON record per attempted service with status", async () => {
const ag2ServiceId = SERVICES["showcase-ag2"].serviceId;
const summary = await runRedeploy({
env: "staging",
services: ["showcase-mastra", "showcase-ag2"],
appendSummary: () => {},
redeploy: async (serviceId) => {
if (serviceId === ag2ServiceId) {
return { ok: false, error: "boom" };
}
return { ok: true };
},
});
expect(summary.exitCode).toBe(0); // staging contract intact
const records = JSON.parse(readFileSync(summaryPath, "utf8")) as Array<{
service: string;
status: "ok" | "error";
error?: string;
}>;
expect(records).toEqual(
expect.arrayContaining([
{ service: "showcase-ag2", status: "error", error: "boom" },
{ service: "showcase-mastra", status: "ok" },
]),
);
expect(records).toHaveLength(2);
});
it("does NOT write JSON when REDEPLOY_SUMMARY_JSON is unset", async () => {
delete process.env.REDEPLOY_SUMMARY_JSON;
await runRedeploy({
env: "staging",
services: ["showcase-mastra"],
appendSummary: () => {},
redeploy: async () => ({ ok: true }),
});
// The temp dir we created is empty — assert nothing was written.
// (No path was given; the function had nowhere to write.)
expect(readdirSync(dir)).toEqual([]);
});
it("preserves PR #5093 contract: staging exits 0 even when all fail", async () => {
const summary = await runRedeploy({
env: "staging",
services: ["showcase-mastra", "showcase-ag2"],
appendSummary: () => {},
redeploy: async () => ({ ok: false, error: "boom" }),
});
expect(summary.exitCode).toBe(0);
expect(summary.failed).toBe(2);
const records = JSON.parse(readFileSync(summaryPath, "utf8")) as Array<{
service: string;
status: "ok" | "error";
error?: string;
}>;
expect(records.every((r) => r.status === "error")).toBe(true);
expect(records).toHaveLength(2);
});
it("preserves PR #5093 contract: prod exits 1 on any per-service failure", async () => {
const summary = await runRedeploy({
env: "prod",
services: ["showcase-mastra"],
appendSummary: () => {},
redeploy: async () => ({ ok: false, error: "boom" }),
});
expect(summary.exitCode).toBe(1);
const records = JSON.parse(readFileSync(summaryPath, "utf8")) as Array<{
service: string;
status: "ok" | "error";
error?: string;
}>;
expect(records).toEqual([
{ service: "showcase-mastra", status: "error", error: "boom" },
]);
});
it("records caught throws as status:error with the thrown message", async () => {
const summary = await runRedeploy({
env: "staging",
services: ["showcase-mastra"],
appendSummary: () => {},
redeploy: async () => {
throw new Error("kaboom");
},
});
expect(summary.exitCode).toBe(0);
expect(summary.failed).toBe(1);
const records = JSON.parse(readFileSync(summaryPath, "utf8")) as Array<{
service: string;
status: "ok" | "error";
error?: string;
}>;
expect(records).toEqual([
{ service: "showcase-mastra", status: "error", error: "kaboom" },
]);
});
});
@@ -0,0 +1,32 @@
import { describe, expect, it, vi } from "vitest";
import { resolveRailwayTokenFromConfig } from "../lib/railway-token";
// Characterization test: pins the resolver contract that redeploy-env's
// getToken path will use after E-2d. The resolver itself was proven
// red-green in E-2a/E-2b; this test exists so a future change to the
// resolver that breaks the redeploy-env consumer surfaces here too.
// EXPECTED OUTCOME: PASS on first run (resolver already exists). This is
// intentional — see the step header. Do NOT relabel this as a RED step.
describe("redeploy-env token resolution contract", () => {
it("returns accessToken from a typical post-login config", () => {
const warn = vi.fn();
expect(
resolveRailwayTokenFromConfig(
{ user: { accessToken: "abc-access-aaaaaaaaaaaaa" } },
{ warn },
),
).toBe("abc-access-aaaaaaaaaaaaa");
expect(warn).not.toHaveBeenCalled();
});
it("warns when only user.token (legacy) is present", () => {
const warn = vi.fn();
expect(
resolveRailwayTokenFromConfig(
{ user: { token: "legacy-xxxxxxxxxx" } },
{ warn },
),
).toBe("legacy-xxxxxxxxxx");
expect(warn).toHaveBeenCalledOnce();
});
});
@@ -0,0 +1,259 @@
#!/usr/bin/env bats
# Tests for resolve-promote-targets.sh — the resolve-targets logic extracted from
# .github/workflows/showcase_promote.yml so the closure derivation + the existing
# guards are unit-testable (mirrors how verify-prod-display.sh / promote-fleet.sh
# were extracted from the same workflow).
#
# U3 (spec §8.3 Phase 1, BACKWARD-COMPAT). The script MUST:
# 1. Resolve INPUT (SSOT key / dispatch_name / 'all') into services_csv —
# the EXISTING leaf-set CSV used by the actual promote (unchanged behavior).
# 2. Compute the tiered promote CLOSURE from the generated JSON's `closure`
# block (transitive runtimeDeps Tier-1 verification, tier-ordered 0→1→2)
# and EMIT it into $GITHUB_STEP_SUMMARY so operators SEE the closure (§4.5),
# WITHOUT changing the leaf-set promote (U4 enforces tier ordering later).
# 3. Emit a tier-annotated `closure_csv` output for U4 to consume.
# 4. PRESERVE the existing guards: the --digest+all reject, the empty-CSV
# fail-loud, the ambiguous/unknown-service guard.
#
# Inputs (env): INPUT (workflow_dispatch service), DIGEST (optional), GENERATED
# (path to railway-envs.generated.json). Outputs are appended to $GITHUB_OUTPUT
# and $GITHUB_STEP_SUMMARY (both pointed at temp files by setup()).
#
# NB on assertion gating: bats does NOT run test bodies under `set -e`. Only the
# FINAL command decides pass/fail, so every non-final assertion is written
# `[[ ... ]] || fail "msg"` to force a hard failure with a diagnostic.
fail() {
echo "$1" >&2
return 1
}
setup() {
SCRIPT="$BATS_TEST_DIRNAME/../resolve-promote-targets.sh"
[ -f "$SCRIPT" ] || fail "resolve-promote-targets.sh missing at $SCRIPT"
export GITHUB_OUTPUT="$BATS_TEST_TMPDIR/github_output"
export GITHUB_STEP_SUMMARY="$BATS_TEST_TMPDIR/github_step_summary"
: > "$GITHUB_OUTPUT"
: > "$GITHUB_STEP_SUMMARY"
# A small, self-contained fixture so the test does not depend on the live SSOT
# (which churns as integrations come and go). Two tier-2 agents both depend on
# tier-0 `aimock`; `dashboard` is tier-1 and depends on tier-0 `pocketbase` +
# tier-1 `harness`. `legacy` has probe.prod:false (must never resolve as a
# leaf). The `closure.services` array is the tier-ordered full-fleet closure
# (as U2 emits it); the per-service entries carry promoteTier/runtimeDeps.
GEN="$BATS_TEST_TMPDIR/generated.json"
cat > "$GEN" <<'JSON'
{
"closure": {
"services": [
{ "name": "aimock", "tier": 0 },
{ "name": "pocketbase", "tier": 0 },
{ "name": "harness", "tier": 1 },
{ "name": "dashboard", "tier": 1 },
{ "name": "agent-a", "tier": 2 },
{ "name": "agent-b", "tier": 2 }
],
"skipped": [
{ "name": "harness-workers", "reason": "no \"prod\" environment in the SSOT" }
]
},
"services": [
{ "name": "aimock", "dispatchName": "showcase-aimock", "probe": { "prod": true }, "promoteTier": 0 },
{ "name": "pocketbase", "dispatchName": "showcase-pocketbase", "probe": { "prod": true }, "promoteTier": 0 },
{ "name": "harness", "dispatchName": "showcase-harness", "probe": { "prod": true }, "promoteTier": 1 },
{ "name": "dashboard", "dispatchName": "shell-dashboard", "probe": { "prod": true }, "promoteTier": 1, "runtimeDeps": ["pocketbase", "harness"] },
{ "name": "agent-a", "dispatchName": "a", "probe": { "prod": true }, "promoteTier": 2, "runtimeDeps": ["aimock"], "serviceRefs": [ { "key": "OPENAI_BASE_URL", "target": "aimock" } ] },
{ "name": "agent-b", "dispatchName": "b", "probe": { "prod": true }, "promoteTier": 2, "runtimeDeps": ["aimock"] },
{ "name": "legacy", "dispatchName": "legacy", "probe": { "prod": false }, "promoteTier": 2 }
]
}
JSON
export GENERATED="$GEN"
}
run_resolve() {
# run_resolve <INPUT> [DIGEST]
INPUT="$1" DIGEST="${2:-}" GENERATED="$GENERATED" run bash "$SCRIPT"
}
# --- existing leaf-set behavior (backward-compat, must be unchanged) ---------
@test "single service by SSOT name -> leaf services_csv is just that service" {
run_resolve "agent-a"
[ "$status" -eq 0 ] || fail "expected exit 0, got $status: $output"
run grep '^services_csv=' "$GITHUB_OUTPUT"
[ "$output" = "services_csv=agent-a" ] || fail "leaf services_csv changed: $output"
}
@test "single service by dispatch_name -> leaf services_csv is the SSOT name" {
run_resolve "a"
[ "$status" -eq 0 ] || fail "expected exit 0, got $status: $output"
run grep '^services_csv=' "$GITHUB_OUTPUT"
[ "$output" = "services_csv=agent-a" ] || fail "dispatch_name did not map to SSOT name: $output"
}
@test "all -> leaf services_csv is every prod-eligible service (legacy excluded)" {
run_resolve "all"
[ "$status" -eq 0 ] || fail "expected exit 0, got $status: $output"
run grep '^services_csv=' "$GITHUB_OUTPUT"
# All prod-eligible SSOT names, sorted (the existing `sort -u` behavior).
[ "$output" = "services_csv=agent-a,agent-b,aimock,dashboard,harness,pocketbase" ] \
|| fail "all leaf CSV wrong (legacy must be excluded): $output"
}
# --- preserved guards --------------------------------------------------------
@test "--digest + all is rejected loud" {
run_resolve "all" "sha256:deadbeef"
[ "$status" -ne 0 ] || fail "expected non-zero exit on --digest+all, got $status"
[[ "$output" == *"::error::"* ]] || fail "expected ::error:: on --digest+all: $output"
}
@test "unknown service is rejected loud" {
run_resolve "does-not-exist"
[ "$status" -ne 0 ] || fail "expected non-zero exit on unknown service, got $status"
[[ "$output" == *"::error::"* ]] || fail "expected ::error:: on unknown service: $output"
}
@test "not-prod-eligible service is rejected loud" {
run_resolve "legacy"
[ "$status" -ne 0 ] || fail "expected non-zero exit on non-prod-eligible service, got $status"
[[ "$output" == *"::error::"* ]] || fail "expected ::error:: on non-prod-eligible service: $output"
}
@test "the placeholder selection aborts loud" {
run_resolve "__select_a_service__"
[ "$status" -ne 0 ] || fail "expected non-zero exit on placeholder, got $status"
[[ "$output" == *"::error::"* ]] || fail "expected ::error:: on placeholder: $output"
}
# --- NEW: tiered closure computation + surfacing (U3) -------------------------
@test "single tier-2 service closure pulls its tier-0 dep + ALL tier-1 verification, tier-ordered" {
run_resolve "agent-a"
[ "$status" -eq 0 ] || fail "expected exit 0, got $status: $output"
run grep '^closure_csv=' "$GITHUB_OUTPUT"
# agent-a (t2) -> aimock (t0, its runtimeDep) + harness,dashboard (t1, always
# included for an equivalence-gated promote) + dashboard's transitive deps
# pocketbase (t0) + harness (t1) + agent-a (t2). Tier-ordered (0→1→2), and
# WITHIN a tier the closure block's listing order (harness BEFORE dashboard).
[ "$output" = "closure_csv=aimock,pocketbase,harness,dashboard,agent-a" ] \
|| fail "closure_csv wrong shape/order: $output"
}
@test "closure plan is surfaced into the step summary, tier-labeled" {
run_resolve "agent-a"
[ "$status" -eq 0 ] || fail "expected exit 0, got $status: $output"
run cat "$GITHUB_STEP_SUMMARY"
[[ "$output" == *"Promote closure"* ]] || fail "summary missing closure heading: $output"
[[ "$output" == *"aimock"* ]] || fail "summary missing tier-0 dep aimock: $output"
[[ "$output" == *"agent-a"* ]] || fail "summary missing requested service: $output"
# The summary must show the TIER for each member so the operator sees ordering.
[[ "$output" == *"tier 0"* || "$output" == *"Tier 0"* || "$output" == *"t0"* ]] \
|| fail "summary missing tier labels: $output"
}
@test "all -> closure_csv equals the full tier-ordered closure" {
run_resolve "all"
[ "$status" -eq 0 ] || fail "expected exit 0, got $status: $output"
run grep '^closure_csv=' "$GITHUB_OUTPUT"
# The whole fleet, in the closure block's tier order (0,0,1,1,2,2).
[ "$output" = "closure_csv=aimock,pocketbase,harness,dashboard,agent-a,agent-b" ] \
|| fail "all closure_csv wrong: $output"
}
@test "skipped (no-prod-env) members are surfaced in the summary, never silent" {
run_resolve "all"
[ "$status" -eq 0 ] || fail "expected exit 0, got $status: $output"
run cat "$GITHUB_STEP_SUMMARY"
[[ "$output" == *"harness-workers"* ]] || fail "summary did not surface skipped member: $output"
[[ "$output" == *"skip"* || "$output" == *"Skip"* ]] || fail "summary missing skip reason label: $output"
}
@test "tier-annotated closure_plan output is emitted for U4 to consume" {
run_resolve "agent-a"
[ "$status" -eq 0 ] || fail "expected exit 0, got $status: $output"
# A machine-readable tier-annotated plan (tier:name pairs) for U4's gating.
run grep '^closure_plan=' "$GITHUB_OUTPUT"
[ "$status" -eq 0 ] || fail "closure_plan output missing"
[[ "$output" == *"0:aimock"* ]] || fail "closure_plan missing tier-annotated tier-0 entry: $output"
[[ "$output" == *"2:agent-a"* ]] || fail "closure_plan missing tier-annotated tier-2 entry: $output"
}
# --- NEW: standalone services (no deps, never gated) -------------------------
# A standalone leaf (e.g. `docs`) depends on nothing and gates on nothing: a
# request for ONLY standalone services must resolve to a closure of just those
# services (NO Tier-1 control plane pulled in), and each standalone member is
# emitted with the `s:` marker so the fleet driver promotes it ungated.
# Fixture variant: the normal tier-gated fleet PLUS a standalone leaf `docs`.
_gen_with_standalone() {
cat > "$GENERATED" <<'JSON'
{
"closure": {
"services": [
{ "name": "aimock", "tier": 0 },
{ "name": "harness", "tier": 1 },
{ "name": "dashboard", "tier": 1 },
{ "name": "agent-a", "tier": 2 },
{ "name": "docs", "tier": 2, "standalone": true }
],
"skipped": []
},
"services": [
{ "name": "aimock", "dispatchName": "showcase-aimock", "probe": { "prod": true }, "promoteTier": 0 },
{ "name": "harness", "dispatchName": "showcase-harness", "probe": { "prod": true }, "promoteTier": 1 },
{ "name": "dashboard", "dispatchName": "shell-dashboard", "probe": { "prod": true }, "promoteTier": 1 },
{ "name": "agent-a", "dispatchName": "a", "probe": { "prod": true }, "promoteTier": 2, "runtimeDeps": ["aimock"] },
{ "name": "docs", "dispatchName": "shell-docs", "probe": { "prod": true }, "promoteTier": 2, "standalone": true }
]
}
JSON
}
@test "standalone-only request resolves to a closure of JUST itself (no Tier-1 pulled in)" {
_gen_with_standalone
run_resolve "docs"
[ "$status" -eq 0 ] || fail "expected exit 0, got $status: $output"
run grep '^closure_csv=' "$GITHUB_OUTPUT"
[ "$output" = "closure_csv=docs" ] \
|| fail "standalone closure must be docs ONLY (no harness/dashboard): $output"
}
@test "standalone request resolves via dispatch_name (shell-docs) too" {
_gen_with_standalone
run_resolve "shell-docs"
[ "$status" -eq 0 ] || fail "expected exit 0, got $status: $output"
run grep '^closure_csv=' "$GITHUB_OUTPUT"
[ "$output" = "closure_csv=docs" ] \
|| fail "shell-docs must resolve to a docs-only closure: $output"
}
@test "standalone member is emitted with the s: marker in closure_plan" {
_gen_with_standalone
run_resolve "docs"
[ "$status" -eq 0 ] || fail "expected exit 0, got $status: $output"
run grep '^closure_plan=' "$GITHUB_OUTPUT"
[ "$output" = "closure_plan=s:docs" ] \
|| fail "standalone must emit the s:docs marker (not a numeric tier): $output"
}
@test "a normal request still pulls Tier-1 and excludes the unrelated standalone leaf" {
_gen_with_standalone
run_resolve "agent-a"
[ "$status" -eq 0 ] || fail "expected exit 0, got $status: $output"
run grep '^closure_csv=' "$GITHUB_OUTPUT"
[[ "$output" == *"harness"* ]] || fail "non-standalone request must still pull Tier-1 harness: $output"
[[ "$output" != *"docs"* ]] || fail "agent-a closure must NOT include the unrelated standalone docs: $output"
}
@test "all closure_plan marks the standalone member s: and keeps the rest tier-annotated" {
_gen_with_standalone
run_resolve "all"
[ "$status" -eq 0 ] || fail "expected exit 0, got $status: $output"
run grep '^closure_plan=' "$GITHUB_OUTPUT"
[[ "$output" == *"s:docs"* ]] || fail "all plan must mark docs standalone (s:docs): $output"
[[ "$output" == *"1:harness"* ]] || fail "all plan must keep harness tier-annotated: $output"
}
@@ -0,0 +1,251 @@
/**
* resolve-verify-matrix.cli.test.ts — pins the byte-for-byte $GITHUB_OUTPUT
* contract that showcase_deploy.yml depends on.
*
* The deploy workflow's `verify:` job has
* if: needs.resolve-matrix.outputs.has_services == 'true'
* which compares against the LITERAL strings 'true'/'false' (everything in
* $GITHUB_OUTPUT is a string). A regression that writes `has_services=1`
* or `has_services=True` would silently skip verify on every redeploy.
* Pin that contract here so the byte-for-byte format is part of the test
* surface, not just the pure-function unit tests.
*
* The pure resolver is covered by resolve-verify-matrix.test.ts. THIS
* file spawns the CLI as a child process against the real
* railway-envs.generated.json so the loader + parseSsotServices +
* writeGithubOutput contract gets end-to-end coverage.
*
* Cases (mirrors decision-table boundaries the workflow YAML depends on):
* 1. workflow_run + SUMMARY_PRESENT=true + OK_FROM_REDEPLOY=""
* → services_csv=\nhas_services=false\n [Issue A pinned end-to-end]
* 2. workflow_run + SUMMARY_PRESENT=true + OK_FROM_REDEPLOY="<two real names>"
* → services_csv=<sorted CSV>\nhas_services=true\n
* 3. workflow_dispatch + no summary → full probe-eligible set, has_services=true.
* 4. workflow_dispatch + service=<unknown> → non-zero exit, stderr ::error::Unknown service.
*/
import { spawnSync } from "node:child_process";
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// The repo root is the cwd the workflow YAML uses for the script (paths
// inside the script are written as `showcase/scripts/...`, relative to
// repo root). Tests therefore must spawn from repo root.
const REPO_ROOT = resolve(__dirname, "..", "..", "..");
const SCRIPT = "showcase/scripts/resolve-verify-matrix.ts";
const SSOT_PATH = join(
REPO_ROOT,
"showcase/scripts/railway-envs.generated.json",
);
interface SpawnResult {
status: number;
stderr: string;
output: string;
}
function runCli(env: Record<string, string>): SpawnResult {
const dir = mkdtempSync(join(tmpdir(), "rvm-cli-"));
const outputPath = join(dir, "gh_output");
writeFileSync(outputPath, "");
const fullEnv = {
...process.env,
GITHUB_OUTPUT: outputPath,
...env,
};
// spawnSync (rather than execFileSync) so we capture stderr on BOTH
// zero and non-zero exit. execFileSync exposes stderr only on throw,
// which means the ::warning:: drift path (exit 0 + stderr text) is
// invisible to the test harness. spawnSync returns a single object
// regardless of status, so we inspect `status` and `stderr` directly.
const res = spawnSync("npx", ["tsx", SCRIPT], {
cwd: REPO_ROOT,
env: fullEnv,
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf-8",
});
return {
status: typeof res.status === "number" ? res.status : 1,
stderr: typeof res.stderr === "string" ? res.stderr : "",
output: readFileSync(outputPath, "utf-8"),
};
}
// Pull two real probe-eligible names off the actual SSOT so the test
// stays in lock-step with the emitter (rather than hard-coding fixtures
// that could drift).
function realProbeEligibleNames(): string[] {
const raw = JSON.parse(readFileSync(SSOT_PATH, "utf-8")) as {
services: { name: string; probe: { staging: boolean } }[];
};
return raw.services
.filter((s) => s.probe.staging === true)
.map((s) => s.name)
.sort();
}
describe("resolve-verify-matrix CLI (end-to-end against real SSOT)", () => {
// The CLI spawns `npx tsx`. Cold-start can take >1s; bump timeout.
it("workflow_run + summary_present + empty ok_services → has_services=false (Issue A end-to-end)", () => {
const r = runCli({
EVENT_NAME: "workflow_run",
SUMMARY_PRESENT: "true",
OK_FROM_REDEPLOY: "",
DISPATCH_SERVICE: "",
});
expect(r.status).toBe(0);
// Byte-for-byte: the workflow YAML compares against the literal
// strings 'true' / 'false'. Match the EXACT bytes (including the
// trailing newlines and key=value form).
expect(r.output).toBe("services_csv=\nhas_services=false\n");
}, 30_000);
it("workflow_run + summary_present + two real ok_services → sorted CSV + has_services=true", () => {
// Hard-code two real, stable probe-eligible SSOT names rather than
// picking probe[0]/probe[1] off the live list. The earlier
// probe-index version was tautological: it pulled sorted names from
// the SSOT, reversed them, fed them back, and asserted the resolver
// re-sorted to the same order — which would silently pass even on a
// resolver that did nothing (because probe[0] < probe[1] is the
// ALREADY-sorted SSOT order). Also: if the SSOT ever shrank to <2
// probe-eligible entries, the test would silently assert
// `services_csv=undefined,undefined`.
//
// `aimock` and `harness` are foundational infra services (not
// integration slots), so they will not churn out of the SSOT. We
// assert they're both still probe-eligible at runtime; if either
// ever leaves, this test fails LOUD with a specific message rather
// than silently degrading.
const probe = new Set(realProbeEligibleNames());
expect(probe.has("aimock")).toBe(true);
expect(probe.has("harness")).toBe(true);
// Feed unsorted so the sort-on-intersection assertion is real:
// "harness,aimock" must come out as "aimock,harness".
const r = runCli({
EVENT_NAME: "workflow_run",
SUMMARY_PRESENT: "true",
OK_FROM_REDEPLOY: "harness,aimock",
DISPATCH_SERVICE: "",
});
expect(r.status).toBe(0);
expect(r.output).toBe("services_csv=aimock,harness\nhas_services=true\n");
}, 30_000);
// -----------------------------------------------------------------------
// FIX 3 — surface SSOT/build drift via ::warning::ok_services tokens
// dropped (no SSOT match). The intersection logic already silently drops
// unmatched tokens from the verify matrix; the WARNING is the entire
// drift-detection contract operators rely on to notice that a redeploy
// reported success for a service the SSOT has forgotten about (or vice
// versa). Without coverage here it could silently regress to "no warning
// emitted" and the gate would keep working while losing its early-warning
// signal.
// -----------------------------------------------------------------------
it("workflow_run + ok_services with bogus token → ::warning:: lists dropped tokens, real ones still verify", () => {
const r = runCli({
EVENT_NAME: "workflow_run",
SUMMARY_PRESENT: "true",
// `svc-bogus` is not in the SSOT under any spelling; `aimock` is
// a real probe-eligible service. The verify CSV must drop the
// bogus token AND the wrapper must `::warning::` so the dropped
// token surfaces in the workflow log as an annotation.
OK_FROM_REDEPLOY: "svc-bogus,aimock",
DISPATCH_SERVICE: "",
});
expect(r.status).toBe(0);
expect(r.output).toContain("services_csv=aimock\n");
expect(r.output).toContain("has_services=true\n");
expect(r.stderr).toMatch(
/::warning::ok_services tokens dropped \(no SSOT match\): svc-bogus/,
);
}, 30_000);
// -----------------------------------------------------------------------
// FIX 5 — EVENT_NAME must be exactly 'workflow_run' or 'workflow_dispatch'.
// The CLI used to do an unchecked `as` cast on EVENT_NAME, so a typo or
// accidental new trigger ('push', 'schedule') would compile-pass at the
// type layer and only fail deep inside the resolver. Make the boundary
// total: a narrowing helper rejects unknown EVENT_NAME values up front,
// matching the resolver's own runtime guard with a SINGLE consistent
// story (type system + runtime agree).
// -----------------------------------------------------------------------
it("EVENT_NAME=push → non-zero exit with ::error::resolve-verify-matrix: unexpected EVENT_NAME", () => {
const r = runCli({
EVENT_NAME: "push",
SUMMARY_PRESENT: "",
OK_FROM_REDEPLOY: "",
DISPATCH_SERVICE: "",
});
expect(r.status).not.toBe(0);
expect(r.stderr).toMatch(
/::error::resolve-verify-matrix: unexpected EVENT_NAME 'push'/,
);
}, 30_000);
// -----------------------------------------------------------------------
// FIX 7 — workflow_run requires SUMMARY_PRESENT to be exactly "true" or
// "false". The check-redeploy-summary step always sets one of those two
// values, so any other input (including "" from a step-id wiring break,
// or "True" from a case-typo) means the wiring is broken and the gate
// would silently fall through to the intersection branch. Fail loud
// rather than silently emitting has_services=false on a real redeploy.
// workflow_dispatch ignores SUMMARY_PRESENT and must NOT trigger this.
// -----------------------------------------------------------------------
it("EVENT_NAME=workflow_run + SUMMARY_PRESENT='' → non-zero exit with workflow_run requires summary_present", () => {
const r = runCli({
EVENT_NAME: "workflow_run",
SUMMARY_PRESENT: "",
OK_FROM_REDEPLOY: "",
DISPATCH_SERVICE: "",
});
expect(r.status).not.toBe(0);
expect(r.stderr).toMatch(
/::error::resolve-verify-matrix: workflow_run requires summary_present in \{true,false\}, got ''/,
);
}, 30_000);
it("EVENT_NAME=workflow_run + SUMMARY_PRESENT='True' (case typo) → non-zero exit", () => {
const r = runCli({
EVENT_NAME: "workflow_run",
SUMMARY_PRESENT: "True",
OK_FROM_REDEPLOY: "",
DISPATCH_SERVICE: "",
});
expect(r.status).not.toBe(0);
expect(r.stderr).toMatch(
/::error::resolve-verify-matrix: workflow_run requires summary_present in \{true,false\}, got 'True'/,
);
}, 30_000);
it("workflow_dispatch + no summary → full probe-eligible set + has_services=true", () => {
const probe = realProbeEligibleNames();
const r = runCli({
EVENT_NAME: "workflow_dispatch",
SUMMARY_PRESENT: "",
OK_FROM_REDEPLOY: "",
DISPATCH_SERVICE: "",
});
expect(r.status).toBe(0);
expect(r.output).toBe(
`services_csv=${probe.join(",")}\nhas_services=true\n`,
);
}, 30_000);
it("workflow_dispatch + unknown service → non-zero exit with ::error::Unknown service", () => {
const r = runCli({
EVENT_NAME: "workflow_dispatch",
SUMMARY_PRESENT: "",
OK_FROM_REDEPLOY: "",
DISPATCH_SERVICE: "totally-not-a-real-service",
});
expect(r.status).not.toBe(0);
expect(r.stderr).toMatch(
/::error::Unknown service 'totally-not-a-real-service'/,
);
}, 30_000);
});
@@ -0,0 +1,471 @@
/**
* resolve-verify-matrix.test.ts — covers the pure resolver that decides
* which services the post-redeploy verify probe should target.
*
* The resolver replaces the inline bash+jq block in
* .github/workflows/showcase_deploy.yml's `resolve-matrix` job ("Build
* verify matrix from SSOT" step). The bash had produced two confirmed
* bugs across prior CR rounds, so the logic was extracted to a pure
* function with parity tests against the OLD behavior PLUS a new test
* for the Issue A fix:
*
* workflow_run + summary_present=true + ok_services empty (all errored)
* → has_services=false (skip verify). The previous bash fell through
* to the full probe-eligible fleet, gratuitously probing all services
* against stale `:latest` on a redeploy where everything errored. The
* workflow still reds via `enforce-redeploy-gate` (independent of this
* matrix), so skipping verify here is correct.
*
* Cases (mirrors the decision table in the resolver JSDoc):
* 1. workflow_dispatch + 'all' → full probe-eligible set, has_services=true.
* 2. workflow_dispatch + specific in-SSOT service → just that service.
* 3. workflow_dispatch + unknown service → throws (preserves bash).
* 4. workflow_run + summary_present=false → has_services=false.
* 5. workflow_run + summary_present=true + ok empty → has_services=false. [FIX]
* 6. workflow_run + summary_present=true + ok=[a,c] → intersection.
* 7. ok contains a non-probe-eligible service → excluded.
*/
import { describe, expect, it } from "vitest";
import {
okCsvToCanonicalNames,
parseSsotServices,
resolveVerifyMatrix,
} from "../resolve-verify-matrix";
import type { SsotService } from "../resolve-verify-matrix";
// Fixture SSOT services: a mix of probe.staging=true and false, with
// both `name` and `dispatchName` populated so the intersection logic
// can be exercised against either spelling.
const fixtureServices: SsotService[] = [
{
name: "svc-a",
dispatchName: "dispatch-a",
probe: { staging: true },
},
{
name: "svc-b",
dispatchName: "dispatch-b",
probe: { staging: true },
},
{
name: "svc-c",
dispatchName: "dispatch-c",
probe: { staging: true },
},
{
name: "svc-d",
dispatchName: "dispatch-d",
probe: { staging: true },
},
// probe.staging=false → never in the probe-eligible set.
{
name: "svc-noprobe-1",
dispatchName: "dispatch-noprobe-1",
probe: { staging: false },
},
{
name: "svc-noprobe-2",
dispatchName: null,
probe: { staging: false },
},
];
describe("resolveVerifyMatrix", () => {
it("workflow_dispatch + 'all' → full probe-eligible set, sorted, has_services=true", () => {
const out = resolveVerifyMatrix({
eventName: "workflow_dispatch",
summaryPresent: "",
okFromRedeploy: "",
dispatchService: "all",
ssotServices: fixtureServices,
});
// The bash emits `jq -r '.services[] | select(.probe.staging == true) | .name' | sort -u`.
// Probe-eligible names: svc-a, svc-b, svc-c, svc-d. Sorted+dedup'd, CSV.
expect(out.servicesCsv).toBe("svc-a,svc-b,svc-c,svc-d");
expect(out.hasServices).toBe(true);
});
it("workflow_dispatch + empty dispatch input (defaults to 'all') → full probe-eligible set", () => {
const out = resolveVerifyMatrix({
eventName: "workflow_dispatch",
summaryPresent: "",
okFromRedeploy: "",
dispatchService: "",
ssotServices: fixtureServices,
});
expect(out.servicesCsv).toBe("svc-a,svc-b,svc-c,svc-d");
expect(out.hasServices).toBe(true);
});
it("workflow_dispatch + specific in-SSOT service (by name) → just that service", () => {
const out = resolveVerifyMatrix({
eventName: "workflow_dispatch",
summaryPresent: "",
okFromRedeploy: "",
dispatchService: "svc-b",
ssotServices: fixtureServices,
});
expect(out.servicesCsv).toBe("svc-b");
expect(out.hasServices).toBe(true);
});
it("workflow_dispatch + specific in-SSOT service (by dispatchName) → resolves to canonical name", () => {
const out = resolveVerifyMatrix({
eventName: "workflow_dispatch",
summaryPresent: "",
okFromRedeploy: "",
dispatchService: "dispatch-c",
ssotServices: fixtureServices,
});
expect(out.servicesCsv).toBe("svc-c");
expect(out.hasServices).toBe(true);
});
it("workflow_dispatch + unknown service → throws (preserves bash error/exit behavior)", () => {
// The bash printed `::error::Unknown service '$DISPATCH_SERVICE' (not
// an SSOT key or dispatch_name)` and `exit 1`. The resolver mirrors
// this by throwing; the CLI wrapper converts the throw into a non-zero
// process exit with the same `::error::` annotation.
expect(() =>
resolveVerifyMatrix({
eventName: "workflow_dispatch",
summaryPresent: "",
okFromRedeploy: "",
dispatchService: "totally-not-a-real-service",
ssotServices: fixtureServices,
}),
).toThrow(/Unknown service 'totally-not-a-real-service'/);
});
it("workflow_run + summary_present=false → has_services=false (nothing was redeployed)", () => {
const out = resolveVerifyMatrix({
eventName: "workflow_run",
summaryPresent: "false",
okFromRedeploy: "",
dispatchService: "",
ssotServices: fixtureServices,
});
expect(out.servicesCsv).toBe("");
expect(out.hasServices).toBe(false);
});
// ---------------------------------------------------------------------
// Issue A fix — write this FIRST and watch it RED against a naive
// implementation that falls through to the full probe-eligible fleet
// when OK_FROM_REDEPLOY is empty. The old bash collapsed (summary-
// present, all-errored) with (summary-absent / dispatch-fleet) and
// probed every service against stale :latest. The workflow already
// reds via `enforce-redeploy-gate` when redeploy_red=true, so this
// matrix should yield has_services=false in the all-errored case.
// ---------------------------------------------------------------------
it("workflow_run + summary_present=true + ok empty → has_services=false (Issue A fix)", () => {
const out = resolveVerifyMatrix({
eventName: "workflow_run",
summaryPresent: "true",
okFromRedeploy: "",
dispatchService: "",
ssotServices: fixtureServices,
});
expect(out.servicesCsv).toBe("");
expect(out.hasServices).toBe(false);
});
it("workflow_run + summary_present=true + ok=[svc-a,svc-c] → csv = sorted intersection with probe-eligible", () => {
const out = resolveVerifyMatrix({
eventName: "workflow_run",
summaryPresent: "true",
okFromRedeploy: "svc-a,svc-c",
dispatchService: "",
ssotServices: fixtureServices,
});
expect(out.servicesCsv).toBe("svc-a,svc-c");
expect(out.hasServices).toBe(true);
});
it("workflow_run + ok uses dispatchName aliases → resolved to canonical names in CSV", () => {
// The redeploy summary CSV may carry dispatch_names (the build matrix
// identifier) rather than SSOT keys. The bash mapped via
// `select(.name == $r or .dispatchName == $r) | .name` → canonical.
const out = resolveVerifyMatrix({
eventName: "workflow_run",
summaryPresent: "true",
okFromRedeploy: "dispatch-a,dispatch-c",
dispatchService: "",
ssotServices: fixtureServices,
});
expect(out.servicesCsv).toBe("svc-a,svc-c");
expect(out.hasServices).toBe(true);
});
it("workflow_run + ok contains a non-probe-eligible service → excluded from CSV", () => {
// svc-noprobe-1 has probe.staging=false; even if it redeployed OK
// it must not appear in the verify matrix because we have no probe
// driver for it.
const out = resolveVerifyMatrix({
eventName: "workflow_run",
summaryPresent: "true",
okFromRedeploy: "svc-a,svc-noprobe-1,svc-d",
dispatchService: "",
ssotServices: fixtureServices,
});
expect(out.servicesCsv).toBe("svc-a,svc-d");
expect(out.hasServices).toBe(true);
});
it("workflow_run + ok intersection collapses to empty → has_services=false", () => {
// Every OK service is non-probe-eligible. Old bash would have emitted
// services_csv='' and has_services=false here too (it set has_services
// off the CSV emptiness), so this is a parity case.
const out = resolveVerifyMatrix({
eventName: "workflow_run",
summaryPresent: "true",
okFromRedeploy: "svc-noprobe-1,svc-noprobe-2",
dispatchService: "",
ssotServices: fixtureServices,
});
expect(out.servicesCsv).toBe("");
expect(out.hasServices).toBe(false);
});
it("workflow_run + ok with duplicates → dedup'd in CSV", () => {
const out = resolveVerifyMatrix({
eventName: "workflow_run",
summaryPresent: "true",
okFromRedeploy: "svc-a,svc-a,dispatch-a,svc-b",
dispatchService: "",
ssotServices: fixtureServices,
});
expect(out.servicesCsv).toBe("svc-a,svc-b");
expect(out.hasServices).toBe(true);
});
// ---------------------------------------------------------------------
// FIX 3 — unknown eventName must throw rather than silently falling
// through to the workflow_run intersection branch. A typo or unexpected
// trigger today produces a SILENT skip (intersection of "" with probe-
// eligible = empty → has_services=false) which is indistinguishable from
// the legitimate "summary absent, nothing to verify" path.
// ---------------------------------------------------------------------
it("unknown eventName → throws with ::error:: annotation (fail-loud)", () => {
expect(() =>
resolveVerifyMatrix({
eventName: "schedule",
summaryPresent: "",
okFromRedeploy: "",
dispatchService: "",
ssotServices: fixtureServices,
}),
).toThrow(
/::error::resolve-verify-matrix: unexpected eventName 'schedule'/,
);
});
// ---------------------------------------------------------------------
// FIX 7 — make the workflow_run boundary total. summaryPresent MUST be
// exactly "true" or "false" on workflow_run (check-redeploy-summary
// always sets one of the two). Any other value (including the empty
// string from a future step-id-rename wiring break, or "True" from a
// case-typo) used to fall through to the intersection branch and
// silently emit has_services=false — indistinguishable from a
// legitimate skip. Throw instead. workflow_dispatch ignores
// summaryPresent and must NOT throw.
// ---------------------------------------------------------------------
it("workflow_run + summaryPresent='' → throws (boundary is total)", () => {
expect(() =>
resolveVerifyMatrix({
eventName: "workflow_run",
summaryPresent: "",
okFromRedeploy: "",
dispatchService: "",
ssotServices: fixtureServices,
}),
).toThrow(
/::error::resolve-verify-matrix: workflow_run requires summary_present in \{true,false\}, got ''/,
);
});
it("workflow_run + summaryPresent='True' (case typo) → throws", () => {
expect(() =>
resolveVerifyMatrix({
eventName: "workflow_run",
summaryPresent: "True",
okFromRedeploy: "",
dispatchService: "",
ssotServices: fixtureServices,
}),
).toThrow(
/::error::resolve-verify-matrix: workflow_run requires summary_present in \{true,false\}, got 'True'/,
);
});
it("workflow_dispatch ignores summaryPresent (any value) — does NOT throw on empty", () => {
// workflow_dispatch path never reads summaryPresent; it must keep
// working even when the wrapper passes the default "".
const out = resolveVerifyMatrix({
eventName: "workflow_dispatch",
summaryPresent: "",
okFromRedeploy: "",
dispatchService: "all",
ssotServices: fixtureServices,
});
expect(out.servicesCsv).toBe("svc-a,svc-b,svc-c,svc-d");
expect(out.hasServices).toBe(true);
});
});
// -------------------------------------------------------------------------
// FIX 4 — okCsvToCanonicalNames: trim tokens and report unmatched tokens.
// The redeploy-gate bash emits `join(",")` which produces no spaces today,
// but any future change to the bash (or a human-driven workflow_dispatch
// caller that hand-types a CSV) that adds spaces silently dropped tokens
// because `"a, b".split(",")` yields ["a", " b"] and " b" matches nothing.
// We trim before matching, and surface unknown tokens so the CLI wrapper
// can `::warning::` on SSOT/build drift (the function itself stays pure).
// -------------------------------------------------------------------------
describe("okCsvToCanonicalNames", () => {
it("trims whitespace around tokens — 'svc-a, svc-c' == 'svc-a,svc-c'", () => {
const a = okCsvToCanonicalNames("svc-a, svc-c", fixtureServices);
const b = okCsvToCanonicalNames("svc-a,svc-c", fixtureServices);
expect(Array.from(a.canonical).sort()).toEqual(["svc-a", "svc-c"]);
expect(Array.from(b.canonical).sort()).toEqual(["svc-a", "svc-c"]);
expect(a.dropped).toEqual([]);
expect(b.dropped).toEqual([]);
});
it("reports tokens that match no SSOT service in `dropped`", () => {
const out = okCsvToCanonicalNames(
"svc-a,not-a-real-service,svc-b",
fixtureServices,
);
expect(Array.from(out.canonical).sort()).toEqual(["svc-a", "svc-b"]);
expect(out.dropped).toEqual(["not-a-real-service"]);
});
it("ignores empty tokens (e.g. trailing comma) without reporting them as dropped", () => {
const out = okCsvToCanonicalNames("svc-a,,svc-b,", fixtureServices);
expect(Array.from(out.canonical).sort()).toEqual(["svc-a", "svc-b"]);
expect(out.dropped).toEqual([]);
});
});
// -------------------------------------------------------------------------
// FIX 1 — parseSsotServices: validate the SSOT shape rather than blindly
// casting JSON.parse() output. A truncated/drifted SSOT (emitter crashed
// mid-write, or schema renamed) parses but silently shrinks/empties the
// probe-eligible set → real redeploys go unverified, or verify is skipped
// on a real redeploy. We refuse the ambiguity and throw with a
// ::error::-prefixed message.
// -------------------------------------------------------------------------
describe("parseSsotServices", () => {
it("accepts a well-formed SSOT and returns the services array", () => {
const raw = {
services: [
{ name: "svc-a", dispatchName: null, probe: { staging: true } },
{
name: "svc-b",
dispatchName: "dispatch-b",
probe: { staging: false },
},
],
};
const out = parseSsotServices(raw, "test-path");
expect(out).toHaveLength(2);
expect(out[0].name).toBe("svc-a");
expect(out[1].probe.staging).toBe(false);
});
it("throws when `services` is not an array", () => {
expect(() =>
parseSsotServices({ services: { not: "an array" } }, "test-path"),
).toThrow(/::error::SSOT test-path malformed: `services` is not an array/);
});
it("throws when `services` is an empty array (emitter crashed mid-write)", () => {
expect(() => parseSsotServices({ services: [] }, "test-path")).toThrow(
/::error::SSOT test-path malformed: `services` is empty/,
);
});
it("throws when a service entry has no `name`", () => {
expect(() =>
parseSsotServices(
{
services: [
{ name: "svc-a", dispatchName: null, probe: { staging: true } },
{ dispatchName: null, probe: { staging: true } },
],
},
"test-path",
),
).toThrow(
/::error::SSOT test-path malformed: services\[1\] missing `name`/,
);
});
it("throws when `probe` is missing", () => {
expect(() =>
parseSsotServices(
{
services: [{ name: "svc-a", dispatchName: null }],
},
"test-path",
),
).toThrow(
/::error::SSOT test-path malformed: services\[0\] \(svc-a\) missing `probe`/,
);
});
it("accepts a missing `dispatchName` (live SSOT has services without one, e.g. pocketbase) and normalizes to null", () => {
const out = parseSsotServices(
{
services: [
{ name: "svc-a", probe: { staging: true } },
{ name: "svc-b", dispatchName: null, probe: { staging: true } },
{
name: "svc-c",
dispatchName: "dispatch-c",
probe: { staging: true },
},
],
},
"test-path",
);
expect(out[0].dispatchName).toBeNull();
expect(out[1].dispatchName).toBeNull();
expect(out[2].dispatchName).toBe("dispatch-c");
});
it("throws when `dispatchName` is set to a non-string non-null value", () => {
expect(() =>
parseSsotServices(
{
services: [
{ name: "svc-a", dispatchName: 42, probe: { staging: true } },
],
},
"test-path",
),
).toThrow(
/::error::SSOT test-path malformed: services\[0\] \(svc-a\) `dispatchName` must be string, null, or absent/,
);
});
it("throws when `probe.staging` is not a boolean", () => {
expect(() =>
parseSsotServices(
{
services: [
{
name: "svc-a",
dispatchName: null,
probe: { staging: "true" },
},
],
},
"test-path",
),
).toThrow(
/::error::SSOT test-path malformed: services\[0\] \(svc-a\) `probe.staging` is not boolean/,
);
});
});
@@ -0,0 +1,120 @@
import { describe, expect, it } from "vitest";
import path from "node:path";
import { globSync } from "glob";
import { loadFixtureFile, matchFixture } from "@copilotkit/aimock";
import type {
ChatCompletionRequest,
Fixture,
TextResponse,
} from "@copilotkit/aimock";
const REPO_ROOT = path.resolve(__dirname, "..", "..", "..");
// Load D6 fixtures only for this test. State/context demos are D6 features;
// D4 chat fixtures contain broad substring matchers (e.g. "Say hi") that
// shadow the more specific D6 shared-state fixtures when loaded together.
// Migration files (_migrated-from-*.json) are excluded because they contain
// systemMessage-based discriminators that loadFixtureFile strips.
function loadBundledFixtures(): Fixture[] {
const fixtureFiles = [
...globSync("showcase/aimock/shared/*.json", {
cwd: REPO_ROOT,
absolute: true,
}).filter((f) => !path.basename(f).startsWith("_migrated")),
...globSync("showcase/aimock/d6/langgraph-python/*.json", {
cwd: REPO_ROOT,
absolute: true,
}),
];
return fixtureFiles.flatMap((f) => loadFixtureFile(f));
}
function request(
userMessage: string,
systemMessage: string,
): ChatCompletionRequest {
return {
model: "gpt-5.4",
messages: [
{ role: "system", content: systemMessage },
{ role: "user", content: userMessage },
],
// D6 fixtures use match.context for per-integration scoping; aimock's
// matchFixture checks req._context against it.
_context: "langgraph-python",
} as ChatCompletionRequest;
}
const DEFAULT_SHARED_STATE_SYSTEM = `
Application State
{
"preferences": {
"name": "",
"tone": "casual",
"language": "English",
"interests": []
},
"notes": []
}
`;
const DEFAULT_READONLY_CONTEXT_SYSTEM = `
## Context from the application
The currently logged-in user's display name:
Atai
The user's IANA timezone (used when mentioning times):
America/Los_Angeles
The user's recent activity in the app, newest first:
["Viewed the pricing page","Watched the product demo video"]
`;
// D6 fixtures use integration-level context scoping (X-AIMock-Context header)
// instead of systemMessage-based differentiation. At the fixture level, the
// same userMessage matches regardless of system message content — the routing
// to the correct integration's fixtures happens at the HTTP layer.
//
// This test verifies that the default demo fixtures exist and produce
// semantically correct content for the reference integration.
describe("state/context fixture routing", () => {
it("shared-state default preferences match with correct content", () => {
const fixtures = loadBundledFixtures();
const defaultMatch = matchFixture(
fixtures,
request("Say hi and introduce yourself.", DEFAULT_SHARED_STATE_SYSTEM),
);
expect(defaultMatch).not.toBeNull();
expect((defaultMatch!.response as TextResponse).content).toContain(
"shared-state co-pilot",
);
});
it("readonly context defaults match with correct content", () => {
const fixtures = loadBundledFixtures();
const defaultMatch = matchFixture(
fixtures,
request(
"What do you know about me from my context?",
DEFAULT_READONLY_CONTEXT_SYSTEM,
),
);
expect(defaultMatch).not.toBeNull();
expect((defaultMatch!.response as TextResponse).content).toContain("Atai");
});
it("readonly context follow-up question matches", () => {
const fixtures = loadBundledFixtures();
const followUp = matchFixture(
fixtures,
request(
"Based on my recent activity, what should I try next?",
DEFAULT_READONLY_CONTEXT_SYSTEM,
),
);
expect(followUp).not.toBeNull();
expect((followUp!.response as TextResponse).content).toBeTruthy();
});
});
@@ -0,0 +1,198 @@
import { describe, expect, it } from "vitest";
import path from "node:path";
import { globSync } from "glob";
import { loadFixtureFile, matchFixture } from "@copilotkit/aimock";
import type {
ChatCompletionRequest,
Fixture,
TextResponse,
ToolCallResponse,
} from "@copilotkit/aimock";
const REPO_ROOT = path.resolve(__dirname, "..", "..", "..");
// Load fixtures for a single integration (langgraph-python, the reference
// integration) plus shared. At runtime each integration only sees its own
// scoped fixtures via X-AIMock-Context, so loading a single integration's
// fixture set is the correct simulation — loading all 18 integrations'
// fixtures would produce first-match collisions across identical prompts.
function loadBundledFixtures(): Fixture[] {
const fixtureFiles = [
...globSync("showcase/aimock/shared/*.json", {
cwd: REPO_ROOT,
absolute: true,
}),
...globSync("showcase/aimock/d4/langgraph-python/*.json", {
cwd: REPO_ROOT,
absolute: true,
}),
...globSync("showcase/aimock/d6/langgraph-python/*.json", {
cwd: REPO_ROOT,
absolute: true,
}),
];
return fixtureFiles.flatMap((f) => loadFixtureFile(f));
}
// D6 subagent fixtures use turnIndex-based chaining instead of toolCallId.
// Each turn in the conversation increments the turn index, and the fixture
// matches on the combination of userMessage + turnIndex + toolName.
//
// Turn 0: initial request → emits research_agent tool call
// Turn 1: after research result → emits writing_agent tool call
// Turn 2: after writing result → emits critique_agent tool call
// Turn 3: after critique result → emits final content
function buildRequest(opts: {
userMessage: string;
turnCount?: number;
toolName?: string;
toolResultCallId?: string;
}): ChatCompletionRequest {
const messages: ChatCompletionRequest["messages"] = [
{ role: "user", content: opts.userMessage },
];
// Add assistant+tool turn pairs to reach the desired turnIndex.
// Each pair simulates the agent calling a sub-agent tool and getting a result.
const turns = opts.turnCount ?? 0;
for (let i = 0; i < turns; i++) {
messages.push({
role: "assistant",
content: "",
tool_calls: [
{
id: `call_turn_${i}`,
type: "function",
function: { name: "sub_agent", arguments: "{}" },
},
],
});
messages.push({
role: "tool",
content: "ok",
tool_call_id: `call_turn_${i}`,
});
}
return {
model: "gpt-5.4",
messages,
// D6 fixtures use match.context for per-integration scoping; aimock's
// matchFixture checks req._context against it.
_context: "langgraph-python",
tools: [
{
type: "function",
function: {
name: "research_agent",
description: "research",
parameters: { type: "object" },
},
},
{
type: "function",
function: {
name: "writing_agent",
description: "writing",
parameters: { type: "object" },
},
},
{
type: "function",
function: {
name: "critique_agent",
description: "critique",
parameters: { type: "object" },
},
},
],
} as ChatCompletionRequest;
}
const CHAINS = [
{
title: "blog",
prompt:
"Produce a short blog post about the benefits of cold exposure training",
research: "call_d5_subagents_p1_research_001",
writing: "call_d5_subagents_p1_writing_001",
critique: "call_d5_subagents_p1_critique_001",
},
{
title: "explain",
prompt: "Explain how large language models handle tool calling",
research: "call_d5_subagents_p2_research_001",
writing: "call_d5_subagents_p2_writing_001",
critique: "call_d5_subagents_p2_critique_001",
},
{
title: "summarize",
prompt: "Summarize the current state of reusable rockets",
research: "call_d5_subagents_p3_research_001",
writing: "call_d5_subagents_p3_writing_001",
critique: "call_d5_subagents_p3_critique_001",
},
] as const;
describe("subagents bundled fixture routing", () => {
it("each pill chains research -> writing -> critique -> final via turnIndex", () => {
const fixtures = loadBundledFixtures();
for (const chain of CHAINS) {
// Turn 0: initial request → research_agent
const first = matchFixture(
fixtures,
buildRequest({ userMessage: chain.prompt, turnCount: 0 }),
);
expect(first, `${chain.title}: first leg should match`).not.toBeNull();
expect(
(first!.response as ToolCallResponse).toolCalls?.[0],
).toMatchObject({
id: chain.research,
name: "research_agent",
});
// Turn 1: after research → writing_agent
const second = matchFixture(
fixtures,
buildRequest({ userMessage: chain.prompt, turnCount: 1 }),
);
expect(
second,
`${chain.title}: second leg (turnIndex=1) should match`,
).not.toBeNull();
expect(
(second!.response as ToolCallResponse).toolCalls?.[0],
).toMatchObject({
id: chain.writing,
name: "writing_agent",
});
// Turn 2: after writing → critique_agent
const third = matchFixture(
fixtures,
buildRequest({ userMessage: chain.prompt, turnCount: 2 }),
);
expect(
third,
`${chain.title}: third leg (turnIndex=2) should match`,
).not.toBeNull();
expect(
(third!.response as ToolCallResponse).toolCalls?.[0],
).toMatchObject({
id: chain.critique,
name: "critique_agent",
});
// Turn 3: after critique → final content
const final = matchFixture(
fixtures,
buildRequest({ userMessage: chain.prompt, turnCount: 3 }),
);
expect(
final,
`${chain.title}: final leg (turnIndex=3) should match`,
).not.toBeNull();
expect((final!.response as TextResponse).content).toContain(
"after research",
);
}
});
});
@@ -0,0 +1,687 @@
import { execFileSync, spawnSync } from "node:child_process";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { parse as parseYaml } from "yaml";
import { SERVICES } from "../railway-envs";
// Importing the module MUST NOT trigger main() (the robust import.meta.url
// guard ensures the file only runs main when invoked directly, never on
// import). If this import wrote the workflow, the suite would have side
// effects on the real file — see the dedicated guard test below.
import { computeOptionTokens, SENTINEL } from "../sync-promote-service-options";
import type { ServiceEntry } from "../railway-envs";
const SCRIPT = resolve(__dirname, "..", "sync-promote-service-options.ts");
// The REAL, committed workflow file that GitHub validates the `service`
// workflow_dispatch choice against server-side. `gh workflow run` is rejected
// with HTTP 422 ("Provided value '<svc>' ... not in the list of allowed
// values") whenever a chosen value is absent from THIS file on the default
// branch — so the durable regression test below asserts against the committed
// file, not just the in-memory generator output.
const COMMITTED_WORKFLOW = resolve(
__dirname,
"..",
"..",
"..",
".github",
"workflows",
"showcase_promote.yml",
);
/**
* The full set of services that MUST appear in the promote dropdown. This is
* the regression guard's allowlist: if a future generator change (or a botched
* SSOT edit) drops ANY of these, both the generator-output and committed-file
* assertions below fail LOUDLY in CI.
*
* `shell-docs` is called out by name on purpose: it is promoted regularly by a
* teammate, and the explicit worry is that a future pinning/generator
* regression must NEVER silently strip it from the dropdown. The 12 `starter-*`
* services are listed because they were historically OMITTED (the committed
* dropdown was never regenerated after they joined the SSOT), which caused the
* HTTP 422 rejection this regression test exists to prevent recurring.
*/
const REQUIRED_PROMOTE_TARGETS = [
// The teammate-critical target — guarded by name, never just by count.
"shell-docs",
// The 12 starter-* container fleet (the previously-omitted set).
"starter-adk",
"starter-agno",
"starter-crewai-crews",
"starter-langgraph-fastapi",
"starter-langgraph-js",
"starter-langgraph-python",
"starter-llamaindex",
"starter-mastra",
"starter-ms-agent-framework-dotnet",
"starter-ms-agent-framework-python",
"starter-pydantic-ai",
"starter-strands-python",
] as const;
/**
* Build a synthetic env-map `ServiceEntry` for computeOptionTokens tests.
* Only the fields the generator reads matter (`environments.prod.probe` +
* `dispatchName`); the rest are filler to satisfy the type. `slug` seeds the
* placeholder IDs/domains so each synthetic entry is internally distinct.
*/
function mkEntry(
slug: string,
opts: { dispatchName?: string; prodProbe?: boolean } = {},
): ServiceEntry & { dispatchName?: string } {
const { dispatchName, prodProbe = true } = opts;
return {
serviceId: `s-${slug}`,
ciBuilt: true,
gateValidated: true,
probeDriver: "harness",
...(dispatchName !== undefined ? { dispatchName } : {}),
environments: {
prod: {
instanceId: `p-${slug}`,
domain: `${slug}.prod`,
probe: prodProbe,
},
staging: {
instanceId: `st-${slug}`,
domain: `${slug}.staging`,
probe: true,
},
},
};
}
// The exact diagnostic fragments the generator emits for each marker-error
// case. Tests assert against these specific strings (not a generic /marker/i)
// so a regression that swaps one diagnostic for another is caught.
const DIAG_NOT_FOUND = "generated marker block not found";
const DIAG_DUPLICATE = "duplicate generated markers";
const DIAG_OUT_OF_ORDER = "malformed marker block";
// A minimal but representative workflow fixture: the real showcase_promote.yml
// has many more jobs, but for the generator's purposes all that matters is
// the `service:` input block carrying the generated marker region. We keep
// unrelated content around the markers to prove it is preserved verbatim.
const BEGIN =
"# >>> BEGIN GENERATED service options (showcase/scripts/sync-promote-service-options.ts) — DO NOT EDIT";
const END = "# <<< END GENERATED service options";
function fixture(generatedBody: string): string {
return [
'name: "Showcase: Promote (staging → prod)"',
"",
"on:",
" workflow_dispatch:",
" inputs:",
" service:",
' description: "Service to promote"',
" required: true",
" type: choice",
` ${BEGIN}`,
generatedBody,
` ${END}`,
" digest:",
' description: "Optional digest override"',
" required: false",
" type: string",
"",
"jobs:",
" resolve-targets:",
" runs-on: ubuntu-latest",
" steps:",
" - run: echo hi",
"",
].join("\n");
}
// A synthetic services map shaped like SERVICES (Record<string, ServiceEntry
// & { dispatchName?: string }>). Only the fields computeOptionTokens reads
// (`probe.prod` and `dispatchName`) carry meaningful values here; the rest are
// filler to satisfy the type. Keys are deliberately ordered so that sorting by
// SSOT KEY would produce a DIFFERENT order than sorting by the RENDERED token,
// proving the sort is by rendered token.
const SYNTHETIC: typeof SERVICES = {
// SSOT key "zeta" but renders as "alpha" via dispatchName → must sort FIRST.
zeta: mkEntry("zeta", { dispatchName: "alpha" }),
// SSOT key "beta", no dispatchName → token falls back to the bare key
// "beta", which sorts AFTER the rendered token "alpha" (from key "zeta").
beta: mkEntry("beta"),
// prod:false → MUST be excluded from the dropdown entirely.
excluded: mkEntry("excl", {
dispatchName: "aaa-would-sort-first",
prodProbe: false,
}),
};
describe("computeOptionTokens (unit)", () => {
it("excludes probe.prod:false, sorts by rendered token, prefers dispatchName", () => {
const tokens = computeOptionTokens(SYNTHETIC);
// (a) The prod:false entry is excluded even though its dispatchName
// ("aaa-would-sort-first") would otherwise sort to the very front.
expect(tokens).not.toContain("aaa-would-sort-first");
expect(tokens).not.toContain("excluded");
// (c) dispatchName wins over the SSOT key: "zeta" surfaces as "alpha",
// and its bare key never appears.
expect(tokens).toContain("alpha");
expect(tokens).not.toContain("zeta");
// "beta" has no dispatchName → its key is used verbatim.
expect(tokens).toContain("beta");
// (b) Ordering: SENTINEL, then "all", then promotable tokens sorted
// alphabetically by the RENDERED token. Sorting by SSOT key would have
// put "beta" before "alpha" (because key "beta" < key "zeta"); the
// rendered-token sort puts "alpha" first.
expect(tokens).toEqual([SENTINEL, "all", "alpha", "beta"]);
});
it("throws (fail loud) on a YAML-unsafe rendered token, naming token + SSOT key", () => {
// A future SSOT entry whose dispatchName carries a YAML-special char (here
// a colon + space) would, if interpolated raw, emit a malformed workflow
// the pre-commit hook silently `git add`s. The generator must fail loud.
const badMap: typeof SERVICES = {
// colon + space → NOT YAML-safe
gamma: mkEntry("gamma", { dispatchName: "bad: token" }),
};
expect(() => computeOptionTokens(badMap)).toThrow(/not YAML-safe/);
// The diagnostic names the offending token and its SSOT key.
expect(() => computeOptionTokens(badMap)).toThrow(/bad: token/);
expect(() => computeOptionTokens(badMap)).toThrow(/gamma/);
});
it("throws (fail loud) on a duplicate assembled token, naming the offender", () => {
// Two distinct SSOT keys whose RENDERED tokens collide: key "dup-a" has
// dispatchName "dup", and key "dup" has no dispatchName → both render as
// "dup". assertDispatchNamesUnique would NOT catch this (it only compares
// dispatchName-vs-dispatchName), so the generator must guard the
// assembled list itself or it would emit a duplicate `- dup` option that
// the pre-commit hook silently `git add`s. The exact-match resolve guard
// catches it because token "dup" matches BOTH services (key "dup" by
// name, key "dup-a" by dispatchName) → resolves to 2, not 1.
const dupMap: typeof SERVICES = {
"dup-a": mkEntry("dupa", { dispatchName: "dup" }),
// no dispatchName → renders as the bare key "dup", colliding with the
// dispatchName above.
dup: mkEntry("dup"),
};
expect(() => computeOptionTokens(dupMap)).toThrow(/resolves to 2 services/);
expect(() => computeOptionTokens(dupMap)).toThrow(/"dup"/);
});
it("throws (fail loud) on a CROSS-collision the dedupe guard misses: distinct rendered tokens that resolve ambiguously", () => {
// The bug the exact-match resolve guard exists to catch, which the OLD
// rendered-token dedupe guard MISSED:
// - Service A ("svc-a") has dispatchName "foo" → renders token "foo".
// - Service B ("foo") has dispatchName "bar" → renders token "bar".
// The two RENDERED tokens ("foo" and "bar") are DISTINCT, so a dedupe
// guard sees no duplicate and passes. But under the workflow resolve
// predicate (s.name === T || s.dispatchName === T), token "foo" matches
// BOTH A (by dispatchName "foo") AND B (by name "foo") → ambiguous and
// un-promotable. The generator must fail loud, naming the ambiguous
// token and both colliding service keys.
const crossMap: typeof SERVICES = {
"svc-a": mkEntry("a", { dispatchName: "foo" }), // renders token "foo"
// renders token "bar" (DISTINCT from "foo")
foo: mkEntry("foo", { dispatchName: "bar" }),
};
// Token "foo" resolves to 2 services (svc-a by dispatchName, foo by name).
expect(() => computeOptionTokens(crossMap)).toThrow(
/resolves to 2 services/,
);
// Diagnostic names the ambiguous token and BOTH colliding service keys.
expect(() => computeOptionTokens(crossMap)).toThrow(/"foo"/);
expect(() => computeOptionTokens(crossMap)).toThrow(/svc-a/);
});
it("does NOT throw when a prod:false service shares a token with a prod-eligible service (resolve step also filters probe.prod)", () => {
// The generator's uniqueness guard mirrors the workflow resolve step's
// predicate EXACTLY — including its `probe.prod === true` filter. So a
// non-prod service sharing a name/dispatchName token with a prod-eligible
// service is NOT a collision: the resolve step would filter the non-prod
// candidate out and resolve the token unambiguously to the single
// prod-eligible service.
// - Service X ("svc-x", probe.prod:true) has dispatchName "shared" →
// renders + emits token "shared".
// - Service Y ("shared", probe.prod:false) has name "shared" → would
// match token "shared" by name, but is filtered out by the prod guard.
// Under an all-services match filter, token "shared" would resolve to 2
// services (X by dispatchName, Y by name) and THROW (false positive). With
// the probe.prod restriction it resolves to exactly 1 (X) and must pass.
const prodFilteredMap: typeof SERVICES = {
// renders + emits token "shared"
"svc-x": mkEntry("x", { dispatchName: "shared" }),
// name "shared" collides with X's emitted token, but probe.prod:false
// means the resolve step (and now the guard) filters it out.
shared: mkEntry("shared", { prodProbe: false }),
};
// The guard must NOT throw: X resolves uniquely once Y is excluded.
expect(() => computeOptionTokens(prodFilteredMap)).not.toThrow();
// X's token IS emitted; the prod:false service Y never appears.
const tokens = computeOptionTokens(prodFilteredMap);
expect(tokens).toContain("shared");
expect(tokens).toEqual([SENTINEL, "all", "shared"]);
});
it("throws (fail loud) when a real-service token collides with a reserved literal", () => {
// A service whose rendered token is exactly "all" (the explicit
// whole-fleet literal). If allowed through it would emit a SECOND `- all`
// option and masquerade as the reserved value the resolve step
// special-cases. The generator must fail loud, naming the offender.
const reservedCollisionMap: typeof SERVICES = {
// collides with the reserved "all" literal
delta: mkEntry("delta", { dispatchName: "all" }),
};
expect(() => computeOptionTokens(reservedCollisionMap)).toThrow(
/reserved literal/,
);
expect(() => computeOptionTokens(reservedCollisionMap)).toThrow(/"all"/);
});
});
describe("sync-promote-service-options", () => {
let workDir: string;
let wfPath: string;
beforeEach(() => {
workDir = mkdtempSync(join(tmpdir(), "sync-promote-"));
wfPath = join(workDir, "showcase_promote.yml");
});
afterEach(() => {
rmSync(workDir, { recursive: true, force: true });
});
it("rewrites a drifted options block to the exact expected ordered list", () => {
// Seed with deliberately wrong/stale content inside the markers.
writeFileSync(
wfPath,
fixture(
[
" default: all",
" options:",
" - all",
" - bogus-stale-entry",
].join("\n"),
),
);
execFileSync("npx", ["tsx", SCRIPT, `--workflow=${wfPath}`], {
stdio: "pipe",
});
const after = readFileSync(wfPath, "utf8");
const doc = parseYaml(after);
const input = doc.on.workflow_dispatch.inputs.service;
// The rendered dropdown is the FULL, ordered token list: SENTINEL, then
// "all", then every probe.prod service rendered as (dispatchName ?? name)
// and sorted ALPHABETICALLY BY RENDERED TOKEN (not by SSOT key). Deep-equal
// against the generator's own output so the test tracks the SSOT and the
// token-sort order exactly.
expect(input.options).toEqual(computeOptionTokens(SERVICES));
expect(input.options[0]).toBe("__select_a_service__");
expect(input.options[1]).toBe("all");
expect(input.default).toBe("__select_a_service__");
expect(input.options).toContain("ag2");
// pocketbase now defines dispatchName "showcase-pocketbase", so the
// rendered token is the dispatchName, not the bare SSOT key.
expect(input.options).toContain("showcase-pocketbase");
expect(input.options).not.toContain("bogus-stale-entry");
// dispatchName tokens, not SSOT keys, for services that define one.
expect(input.options).not.toContain("showcase-ag2");
});
it("is idempotent: a second run is byte-identical and --check exits 0", () => {
writeFileSync(wfPath, fixture(" default: all\n options:"));
execFileSync("npx", ["tsx", SCRIPT, `--workflow=${wfPath}`], {
stdio: "pipe",
});
const first = readFileSync(wfPath, "utf8");
execFileSync("npx", ["tsx", SCRIPT, `--workflow=${wfPath}`], {
stdio: "pipe",
});
const second = readFileSync(wfPath, "utf8");
expect(second).toBe(first);
const check = spawnSync(
"npx",
["tsx", SCRIPT, "--check", `--workflow=${wfPath}`],
{ encoding: "utf8" },
);
expect(check.status).toBe(0);
});
it("--check exits 1 on drift with the re-run diagnostic", () => {
writeFileSync(
wfPath,
fixture(
[" default: all", " options:", " - all"].join(
"\n",
),
),
);
const result = spawnSync(
"npx",
["tsx", SCRIPT, "--check", `--workflow=${wfPath}`],
{ encoding: "utf8" },
);
expect(result.status).toBe(1);
expect(result.stderr).toMatch(/sync-promote-service-options/);
expect(result.stderr).toMatch(/re-run/i);
});
it("places the sentinel as option[0] and sets default to the sentinel", () => {
writeFileSync(wfPath, fixture(" options:"));
execFileSync("npx", ["tsx", SCRIPT, `--workflow=${wfPath}`], {
stdio: "pipe",
});
const doc = parseYaml(readFileSync(wfPath, "utf8"));
const input = doc.on.workflow_dispatch.inputs.service;
expect(input.options[0]).toBe("__select_a_service__");
expect(input.default).toBe("__select_a_service__");
});
it("fails loud (non-zero) when markers are missing — no silent rewrite", () => {
const noMarkers = [
"on:",
" workflow_dispatch:",
" inputs:",
" service:",
' description: "Service to promote"',
" type: choice",
"",
].join("\n");
writeFileSync(wfPath, noMarkers);
const result = spawnSync("npx", ["tsx", SCRIPT, `--workflow=${wfPath}`], {
encoding: "utf8",
});
// Render error → exit 3, with the SPECIFIC "not found" diagnostic.
expect(result.status).toBe(3);
expect(result.stderr).toContain(DIAG_NOT_FOUND);
expect(result.stderr).not.toContain(DIAG_DUPLICATE);
expect(result.stderr).not.toContain(DIAG_OUT_OF_ORDER);
// The file must be untouched (no silent rewrite of the wrong region).
expect(readFileSync(wfPath, "utf8")).toBe(noMarkers);
});
it("fails loud when markers are duplicated", () => {
const dup = [
" service:",
" type: choice",
` ${BEGIN}`,
" options:",
` ${END}`,
" other:",
" type: choice",
` ${BEGIN}`,
" options:",
` ${END}`,
"",
].join("\n");
writeFileSync(wfPath, dup);
const result = spawnSync("npx", ["tsx", SCRIPT, `--workflow=${wfPath}`], {
encoding: "utf8",
});
// Render error → exit 3, with the SPECIFIC "duplicate" diagnostic.
expect(result.status).toBe(3);
expect(result.stderr).toContain(DIAG_DUPLICATE);
expect(result.stderr).not.toContain(DIAG_NOT_FOUND);
expect(result.stderr).not.toContain(DIAG_OUT_OF_ORDER);
expect(readFileSync(wfPath, "utf8")).toBe(dup);
});
it("fails loud when markers are out of order (END before BEGIN)", () => {
// Exactly one BEGIN and one END, but END appears FIRST. This must be
// distinguished from "not found" and "duplicate" with its own diagnostic.
const outOfOrder = [
" service:",
" type: choice",
` ${END}`,
" options:",
` ${BEGIN}`,
"",
].join("\n");
writeFileSync(wfPath, outOfOrder);
const result = spawnSync("npx", ["tsx", SCRIPT, `--workflow=${wfPath}`], {
encoding: "utf8",
});
// Render error → nonzero exit 3, with the SPECIFIC "malformed" diagnostic.
expect(result.status).toBe(3);
expect(result.stderr).toContain(DIAG_OUT_OF_ORDER);
expect(result.stderr).not.toContain(DIAG_NOT_FOUND);
expect(result.stderr).not.toContain(DIAG_DUPLICATE);
// The file must be left untouched.
expect(readFileSync(wfPath, "utf8")).toBe(outOfOrder);
});
it("exits 2 on a read error (nonexistent --workflow path)", () => {
const missingPath = join(workDir, "does-not-exist.yml");
const result = spawnSync(
"npx",
["tsx", SCRIPT, `--workflow=${missingPath}`],
{ encoding: "utf8" },
);
// ALL read failures, including a missing file, exit 2 (not 3).
expect(result.status).toBe(2);
expect(result.stderr).toContain("failed to read");
});
it("rejects an unknown flag (fail loud, exit 2) instead of silently writing", () => {
// Seed a valid workflow so the ONLY reason to fail is the bad arg — a
// typo like `--chek` must NOT silently fall through to a destructive
// write of the real workflow.
writeFileSync(wfPath, fixture(" options:"));
const before = readFileSync(wfPath, "utf8");
const result = spawnSync(
"npx",
["tsx", SCRIPT, "--chek", `--workflow=${wfPath}`],
{ encoding: "utf8" },
);
expect(result.status).toBe(2);
expect(result.stderr).toContain("Unknown argument: --chek");
// The file is untouched — no silent write happened.
expect(readFileSync(wfPath, "utf8")).toBe(before);
});
it("rejects an empty --workflow= value (fail loud, exit 2)", () => {
const result = spawnSync("npx", ["tsx", SCRIPT, "--workflow="], {
encoding: "utf8",
});
expect(result.status).toBe(2);
expect(result.stderr).toContain("--workflow= requires a path value");
});
it("rejects a bare --workflow with no value (fail loud, exit 2)", () => {
const result = spawnSync("npx", ["tsx", SCRIPT, "--workflow", wfPath], {
encoding: "utf8",
});
expect(result.status).toBe(2);
expect(result.stderr).toContain("--workflow requires a value");
});
it("rejects a duplicate --workflow= (fail loud, exit 2)", () => {
const other = join(workDir, "other.yml");
const result = spawnSync(
"npx",
["tsx", SCRIPT, `--workflow=${wfPath}`, `--workflow=${other}`],
{ encoding: "utf8" },
);
expect(result.status).toBe(2);
expect(result.stderr).toContain("--workflow may only be supplied once");
});
it("preserves content outside the markers and keeps valid YAML", () => {
writeFileSync(wfPath, fixture(" options:"));
execFileSync("npx", ["tsx", SCRIPT, `--workflow=${wfPath}`], {
stdio: "pipe",
});
const after = readFileSync(wfPath, "utf8");
// Surrounding structure is intact.
expect(after).toContain('name: "Showcase: Promote (staging → prod)"');
expect(after).toContain(" digest:");
expect(after).toContain(" resolve-targets:");
// Still parses, and the digest input is untouched.
const doc = parseYaml(after);
expect(doc.on.workflow_dispatch.inputs.digest.type).toBe("string");
expect(doc.on.workflow_dispatch.inputs.service.type).toBe("choice");
});
it("importing the module does NOT trigger main() (import.meta.url guard)", () => {
// Spawn a tsx process whose entrypoint is an ESM eval that dynamically
// IMPORTS the module — so process.argv[1] is the eval shim, NOT the module
// path. The `import.meta.url` guard must therefore see argv[1] !== its own
// URL and skip main(). If the guard were broken, main() would run on
// import, read the DEFAULT workflow path, and print "wrote ..."/"already
// up to date." We assert no such generator output appears, and that the
// import itself completes (the sentinel prints AFTER the await).
const importer = join(workDir, "importer.mjs");
// Point at the .ts source via a file:// URL so tsx transpiles it on import.
const scriptUrl = `file://${SCRIPT}`;
writeFileSync(
importer,
[
`await import(${JSON.stringify(scriptUrl)});`,
`process.stdout.write("IMPORT_OK\\n");`,
].join("\n"),
);
const result = spawnSync("npx", ["tsx", importer], {
encoding: "utf8",
cwd: workDir,
});
// The import completed cleanly...
expect(result.status).toBe(0);
expect(result.stdout).toContain("IMPORT_OK");
// ...and main() never ran: none of its stdout/stderr signatures appear.
expect(result.stdout).not.toMatch(/^wrote /m);
expect(result.stdout).not.toContain("already up to date");
expect(result.stdout).not.toContain("up to date.");
expect(result.stderr).not.toContain("failed to read");
});
it("every generated token round-trips to exactly ONE SSOT service (resolve-step contract)", () => {
const tokens = computeOptionTokens(SERVICES);
// The literals the resolve step special-cases; they must never collide
// with a real service token.
const reserved = new Set([SENTINEL, "all"]);
for (const token of tokens) {
if (reserved.has(token)) continue;
// The SAME predicate the workflow's resolve step uses to map a chosen
// dropdown token back to an SSOT service, applied EXACTLY:
// (.name === token || .dispatchName === token) && .probe.prod === true
// The resolve step is `select(.name == $s or .dispatchName == $s) |
// select(.probe.prod == true)`, so the `probe.prod === true` filter is a
// load-bearing term of the predicate, NOT a redundant one we can omit.
// Including it here keeps this round-trip check identical to both the
// resolve step and the generator's own uniqueness guard: a future
// non-prod service sharing a name/dispatchName token with a prod-eligible
// service would resolve unambiguously to the single prod-eligible service
// (the non-prod candidate is filtered out), so it must NOT be counted as
// an ambiguous match here either.
const matches = Object.entries(SERVICES).filter(
([name, entry]) =>
// Mirror the generator's isProdPromotable: declares a prod env
// whose probe is enabled (probe defaults to true when omitted).
entry.environments.prod !== undefined &&
(entry.environments.prod.probe ?? true) === true &&
(name === token || entry.dispatchName === token),
);
// Exactly ONE service resolves from each generated token — no ambiguity,
// no orphans. This pins the generator↔resolve contract.
expect(
matches.length,
`token "${token}" should resolve to exactly one service, got ${matches.length}`,
).toBe(1);
}
// No real-service token may masquerade as a reserved literal. The loop
// above `continue`s on reserved tokens, so it can never observe this —
// assert it DIRECTLY against the SSOT instead: iterate every real
// service, render its token exactly as computeOptionTokens does
// (dispatchName ?? key), and require none equals "all" or SENTINEL.
for (const [name, entry] of Object.entries(SERVICES)) {
const rendered = entry.dispatchName ?? name;
expect(
rendered,
`SSOT service "${name}" renders token "${rendered}", which collides with reserved literal SENTINEL`,
).not.toBe(SENTINEL);
expect(
rendered,
`SSOT service "${name}" renders token "${rendered}", which collides with reserved literal "all"`,
).not.toBe("all");
}
// Sanity: the reserved literals appear exactly once each, in order.
expect(tokens[0]).toBe(SENTINEL);
expect(tokens[1]).toBe("all");
expect(tokens.filter((t) => t === SENTINEL)).toHaveLength(1);
expect(tokens.filter((t) => t === "all")).toHaveLength(1);
});
});
describe("promote dropdown regression guard (shell-docs + starters must stay listed)", () => {
// The whole set the dropdown must currently expose, computed from the SSOT.
// Used as the "no previously-listed target is dropped" oracle: every token
// the generator currently emits (minus the two reserved literals) MUST
// survive in both the generator output and the committed workflow file.
const expectedTokens = computeOptionTokens(SERVICES);
const expectedRealTargets = expectedTokens.filter(
(t) => t !== SENTINEL && t !== "all",
);
it("generator output contains shell-docs AND all 12 starters by name", () => {
const tokens = computeOptionTokens(SERVICES);
// Assert MEMBERSHIP by name (not just a count) so a regression that swaps
// one target for another is still caught.
for (const required of REQUIRED_PROMOTE_TARGETS) {
expect(
tokens,
`promote dropdown (generator output) must contain "${required}" — ` +
`it is a required promote target and must never be dropped`,
).toContain(required);
}
});
it("the COMMITTED showcase_promote.yml choice list contains shell-docs AND all 12 starters", () => {
// This is the file GitHub validates the choice enum against server-side.
// If shell-docs or any starter is absent here, `gh workflow run
// showcase_promote.yml -f service=<svc>` is rejected with HTTP 422 even
// though the SSOT lists the service. Asserting the COMMITTED file (not
// just the in-memory generator output) is what makes this guard real:
// it fails if the dropdown is ever left un-regenerated after an SSOT change.
const doc = parseYaml(readFileSync(COMMITTED_WORKFLOW, "utf8"));
const options: string[] = doc.on.workflow_dispatch.inputs.service.options;
for (const required of REQUIRED_PROMOTE_TARGETS) {
expect(
options,
`committed showcase_promote.yml service dropdown must contain ` +
`"${required}" (GitHub validates the choice enum against this file; ` +
`a missing value yields HTTP 422 on dispatch)`,
).toContain(required);
}
});
it("the committed dropdown is the EXACT union — drops no previously-listed target", () => {
// The committed file must equal the generator's full output, guaranteeing
// the fix is purely additive: every service the generator emits (the union
// of shell-docs, the starters, and every pre-existing target) is present,
// in order, with nothing removed.
const doc = parseYaml(readFileSync(COMMITTED_WORKFLOW, "utf8"));
const options: string[] = doc.on.workflow_dispatch.inputs.service.options;
// Nothing the generator currently emits may be missing from the committed
// file — this is the "never drop an existing target" invariant.
for (const target of expectedRealTargets) {
expect(
options,
`committed dropdown dropped previously-listed promote target ` +
`"${target}"`,
).toContain(target);
}
// And the committed list is byte-for-byte the generator's output, so the
// dropdown can never silently drift from the SSOT in either direction.
expect(options).toEqual(expectedTokens);
});
});
@@ -0,0 +1,649 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import fs from "fs";
import os from "os";
import path from "path";
import { execFileSync } from "child_process";
import {
FileSnapshotRestorer,
SAFE_EXEC_OPTS,
execOptsFor,
restoreFromGitHead,
} from "./test-cleanup";
// Unit tests for the shared test-cleanup harness. Covers:
// - FileSnapshotRestorer round trip (mutate + restore)
// - FileSnapshotRestorer ENOENT read handling (file deleted after snapshot)
// - FileSnapshotRestorer ENOENT write handling (parent dir deleted)
// - FileSnapshotRestorer re-invocation guard (snapshot twice throws)
// - FileSnapshotRestorer byte-exact round trip (non-utf8 bytes)
// - FileSnapshotRestorer sweeps atomic-write tmp stragglers on snapshot()
// - restoreFromGitHead narrow catch (benign pathspec vs fatal errors)
// - restoreFromGitHead accepts the allowlisted truthy CI values
// - restoreFromGitHead tracked/untracked partitioning (mixed path list)
// - restoreFromGitHead off-CI guard propagates stderr on re-raise
/** Env with all `GIT_*` vars stripped — pre-commit hooks (lefthook) run with
* GIT_DIR / GIT_INDEX_FILE / GIT_WORK_TREE set on process.env, which cause
* child `git commit` calls to ignore `cwd` and write to the HOST repo. Every
* test-owned subprocess must use this env so tmp-repo commits stay confined.
* Without this scrub, a developer running `git commit` (which triggers
* test-and-check-packages -> `pnpm run test` -> this file) would silently
* accumulate "initial" / "init" commits on the real working-tree HEAD. */
function cleanGitEnv(): NodeJS.ProcessEnv {
const out: NodeJS.ProcessEnv = {};
for (const [k, v] of Object.entries(process.env)) {
if (!k.startsWith("GIT_")) out[k] = v;
}
return out;
}
/** Exec options shared by every `git` subprocess spawned from this test file.
* Stdio is explicitly piped (not inherited) so child stdout/stderr can't
* interleave with the vitest worker's stdio streams — inherited stdio on a
* thread/fork vitest worker disrupts the worker→parent RPC channel on Node
* 20 and surfaces as "Timeout calling onTaskUpdate" during teardown. */
const TEST_GIT_STDIO = ["ignore", "pipe", "pipe"] as const;
function mkTmpRepo(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "test-cleanup-"));
const env = cleanGitEnv();
const opts = { cwd: dir, env, stdio: TEST_GIT_STDIO } as const;
execFileSync("git", ["init", "-q"], opts);
execFileSync("git", ["config", "user.email", "t@t"], opts);
execFileSync("git", ["config", "user.name", "t"], opts);
execFileSync("git", ["config", "commit.gpgsign", "false"], opts);
return dir;
}
function commitAll(repo: string, msg: string): void {
const env = cleanGitEnv();
const opts = { cwd: repo, env, stdio: TEST_GIT_STDIO } as const;
execFileSync("git", ["add", "-A"], opts);
execFileSync("git", ["commit", "-q", "-m", msg], opts);
}
describe("FileSnapshotRestorer", () => {
let tmp: string;
beforeEach(() => {
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "fsr-"));
});
afterEach(() => {
fs.rmSync(tmp, { recursive: true, force: true });
});
it("round trip: mutate then restore returns original content", () => {
const f = path.join(tmp, "a.txt");
fs.writeFileSync(f, "original");
const r = new FileSnapshotRestorer([f]);
r.snapshot();
fs.writeFileSync(f, "mutated");
expect(fs.readFileSync(f, "utf-8")).toBe("mutated");
r.restore();
expect(fs.readFileSync(f, "utf-8")).toBe("original");
});
it("byte-exact round trip preserves non-utf8 bytes", () => {
const f = path.join(tmp, "bin");
// 0xC3 followed by 0x28 is an invalid utf-8 sequence. A utf-8 string
// round-trip would replace it with U+FFFD; Buffer round-trip preserves it.
const bytes = Buffer.from([0x00, 0xc3, 0x28, 0xff]);
fs.writeFileSync(f, bytes);
const r = new FileSnapshotRestorer([f]);
r.snapshot();
fs.writeFileSync(f, Buffer.from([0x01, 0x02]));
r.restore();
const got = fs.readFileSync(f);
expect(got.equals(bytes)).toBe(true);
});
it("is a no-op on a clean run (no mtime churn)", () => {
const f = path.join(tmp, "a.txt");
fs.writeFileSync(f, "unchanged");
const r = new FileSnapshotRestorer([f]);
r.snapshot();
const before = fs.statSync(f).mtimeMs;
r.restore();
const after = fs.statSync(f).mtimeMs;
expect(after).toBe(before);
});
it("re-creates a snapshotted file that was deleted after snapshot", () => {
const f = path.join(tmp, "a.txt");
fs.writeFileSync(f, "gone");
const r = new FileSnapshotRestorer([f]);
r.snapshot();
fs.rmSync(f);
expect(fs.existsSync(f)).toBe(false);
r.restore();
expect(fs.readFileSync(f, "utf-8")).toBe("gone");
});
it("re-creates parent directory on write ENOENT", () => {
const f = path.join(tmp, "sub", "a.txt");
fs.mkdirSync(path.dirname(f), { recursive: true });
fs.writeFileSync(f, "deep");
const r = new FileSnapshotRestorer([f]);
r.snapshot();
fs.rmSync(path.dirname(f), { recursive: true });
expect(fs.existsSync(f)).toBe(false);
r.restore();
expect(fs.readFileSync(f, "utf-8")).toBe("deep");
});
it("ignores paths that don't exist at snapshot time", () => {
const r = new FileSnapshotRestorer([path.join(tmp, "nonexistent.txt")]);
r.snapshot();
expect(r.snapshotMap.size).toBe(0);
r.restore(); // no-op, shouldn't throw
});
it("throws when snapshot() is called twice on the same instance", () => {
const f = path.join(tmp, "a.txt");
fs.writeFileSync(f, "first");
const r = new FileSnapshotRestorer([f]);
r.snapshot();
expect(() => r.snapshot()).toThrow(
/called on a restorer that already has a snapshot/,
);
});
it("sweeps leftover atomic-write tmp stragglers on snapshot()", () => {
const f = path.join(tmp, "a.txt");
fs.writeFileSync(f, "content");
// Simulate a straggler matching the atomic-write naming convention
// (`.{basename}.{16-hex}.tmp`). SIGKILL between writeFileSync +
// renameSync would leave one of these behind.
const straggler = path.join(tmp, ".a.txt.0123456789abcdef.tmp");
fs.writeFileSync(straggler, "leftover");
expect(fs.existsSync(straggler)).toBe(true);
// An unrelated dot-tmp file that MUST be preserved (not our pattern).
const unrelated = path.join(tmp, ".editor-swap.tmp");
fs.writeFileSync(unrelated, "keep me");
const r = new FileSnapshotRestorer([f]);
r.snapshot();
expect(fs.existsSync(straggler)).toBe(false);
expect(fs.existsSync(unrelated)).toBe(true);
});
});
describe("restoreFromGitHead", () => {
let repo: string;
let savedCI: string | undefined;
beforeEach(() => {
repo = mkTmpRepo();
savedCI = process.env.CI;
// Default to CI=true; individual tests that need the off-CI guard
// override this inside the test body.
process.env.CI = "true";
});
afterEach(() => {
if (savedCI === undefined) delete process.env.CI;
else process.env.CI = savedCI;
fs.rmSync(repo, { recursive: true, force: true });
});
it("restores a tracked file from HEAD (on CI)", () => {
fs.writeFileSync(path.join(repo, "a.txt"), "committed");
commitAll(repo, "initial");
fs.writeFileSync(path.join(repo, "a.txt"), "drift");
restoreFromGitHead(repo, ["a.txt"]);
expect(fs.readFileSync(path.join(repo, "a.txt"), "utf-8")).toBe(
"committed",
);
});
it("accepts CI=1 as truthy", () => {
process.env.CI = "1";
fs.writeFileSync(path.join(repo, "a.txt"), "committed");
commitAll(repo, "initial");
fs.writeFileSync(path.join(repo, "a.txt"), "drift");
expect(() => restoreFromGitHead(repo, ["a.txt"])).not.toThrow();
expect(fs.readFileSync(path.join(repo, "a.txt"), "utf-8")).toBe(
"committed",
);
});
it("accepts CI=yes as truthy (case-insensitive)", () => {
process.env.CI = "YES";
fs.writeFileSync(path.join(repo, "a.txt"), "committed");
commitAll(repo, "initial");
fs.writeFileSync(path.join(repo, "a.txt"), "drift");
expect(() => restoreFromGitHead(repo, ["a.txt"])).not.toThrow();
});
it("treats CI='false', CI='0', and arbitrary strings as off", () => {
fs.writeFileSync(path.join(repo, "a.txt"), "committed");
commitAll(repo, "initial");
fs.writeFileSync(path.join(repo, "a.txt"), "wip");
process.env.CI = "false";
expect(() => restoreFromGitHead(repo, ["a.txt"])).toThrow(
/refusing to overwrite/,
);
process.env.CI = "0";
expect(() => restoreFromGitHead(repo, ["a.txt"])).toThrow(
/refusing to overwrite/,
);
// Allowlist strictness: a random value is off, not on.
process.env.CI = "on";
expect(() => restoreFromGitHead(repo, ["a.txt"])).toThrow(
/refusing to overwrite/,
);
});
it("skips untracked paths when mixed with tracked peers (benign pathspec)", () => {
// Mixed lists must succeed: partitionTrackedPaths filters out the
// untracked entry and the tracked entry is healed normally. (An
// all-untracked call is a separate case — covered by the
// "drifted baseline guard" block.)
fs.writeFileSync(path.join(repo, "a.txt"), "committed");
commitAll(repo, "initial");
fs.writeFileSync(path.join(repo, "a.txt"), "drift");
expect(() => restoreFromGitHead(repo, ["a.txt", "nope.txt"])).not.toThrow();
expect(fs.readFileSync(path.join(repo, "a.txt"), "utf-8")).toBe(
"committed",
);
});
it("handles mixed tracked+untracked lists without masking dirty tracked files", () => {
// Regression guard: a mixed tracked/untracked list previously caused
// `git diff --quiet` to exit 128 (pathspec mismatch from untracked),
// which the guard treated as "nothing to clobber" and silently
// overwrote the dirty tracked file.
delete process.env.CI;
fs.writeFileSync(path.join(repo, "tracked.txt"), "committed");
commitAll(repo, "initial");
// Dirty tracked file + one untracked path in the same call.
fs.writeFileSync(path.join(repo, "tracked.txt"), "wip");
expect(() =>
restoreFromGitHead(repo, ["tracked.txt", "untracked.txt"]),
).toThrow(/refusing to overwrite uncommitted changes/);
// Critically: the dirty tracked file must NOT have been clobbered.
expect(fs.readFileSync(path.join(repo, "tracked.txt"), "utf-8")).toBe(
"wip",
);
});
it("off-CI, refuses to clobber uncommitted tracked-file changes", () => {
delete process.env.CI;
fs.writeFileSync(path.join(repo, "a.txt"), "committed");
commitAll(repo, "initial");
// Create dev-style uncommitted edit
fs.writeFileSync(path.join(repo, "a.txt"), "wip");
expect(() => restoreFromGitHead(repo, ["a.txt"])).toThrow(
/refusing to overwrite uncommitted changes/,
);
// File must remain unchanged
expect(fs.readFileSync(path.join(repo, "a.txt"), "utf-8")).toBe("wip");
});
it("off-CI error message mentions the discard alternative", () => {
delete process.env.CI;
fs.writeFileSync(path.join(repo, "a.txt"), "committed");
commitAll(repo, "initial");
fs.writeFileSync(path.join(repo, "a.txt"), "wip");
try {
restoreFromGitHead(repo, ["a.txt"]);
throw new Error("should have thrown");
} catch (err) {
expect((err as Error).message).toMatch(/git checkout HEAD --/);
}
});
it("off-CI, heals when tree is clean wrt the target paths", () => {
delete process.env.CI;
fs.writeFileSync(path.join(repo, "a.txt"), "committed");
commitAll(repo, "initial");
// clean tree -> heal is a no-op but must not throw
expect(() => restoreFromGitHead(repo, ["a.txt"])).not.toThrow();
expect(fs.readFileSync(path.join(repo, "a.txt"), "utf-8")).toBe(
"committed",
);
});
});
describe("SAFE_EXEC_OPTS", () => {
it("exposes stdio ignore/pipe/pipe and a bounded timeout", () => {
expect(SAFE_EXEC_OPTS.stdio).toEqual(["ignore", "pipe", "pipe"]);
expect(SAFE_EXEC_OPTS.timeout).toBe(30000);
expect(SAFE_EXEC_OPTS.maxBuffer).toBe(10 * 1024 * 1024);
});
it("freezes the inner stdio array (not just the outer object)", () => {
expect(Object.isFrozen(SAFE_EXEC_OPTS)).toBe(true);
expect(Object.isFrozen(SAFE_EXEC_OPTS.stdio)).toBe(true);
});
});
describe("execOptsFor", () => {
it("returns a frozen object with cwd and the SAFE_EXEC_OPTS defaults", () => {
const opts = execOptsFor("/some/path");
expect(opts.cwd).toBe("/some/path");
expect(opts.stdio).toEqual(["ignore", "pipe", "pipe"]);
expect(opts.timeout).toBe(30000);
expect(Object.isFrozen(opts)).toBe(true);
});
});
// --- Regression guards: narrow-catch + per-basename sweep + drift guard ---
describe("restoreFromGitHead: narrow catch in partitionTrackedPaths", () => {
let savedCI: string | undefined;
beforeEach(() => {
savedCI = process.env.CI;
process.env.CI = "true";
});
afterEach(() => {
if (savedCI === undefined) delete process.env.CI;
else process.env.CI = savedCI;
});
it("fails loudly when the git binary is missing (PATH empty)", () => {
const repo = fs.mkdtempSync(path.join(os.tmpdir(), "fsr-nogit-"));
try {
const env = cleanGitEnv();
const opts = { cwd: repo, env, stdio: TEST_GIT_STDIO } as const;
execFileSync("git", ["init", "-q"], opts);
fs.writeFileSync(path.join(repo, "a.txt"), "x");
execFileSync("git", ["config", "user.email", "t@t"], opts);
execFileSync("git", ["config", "user.name", "t"], opts);
execFileSync("git", ["config", "commit.gpgsign", "false"], opts);
execFileSync("git", ["add", "-A"], opts);
execFileSync("git", ["commit", "-q", "-m", "init"], opts);
// Force PATH to an empty dir so the spawned `git` fails with ENOENT.
// Prior to the narrow-catch fix, `partitionTrackedPaths` swallowed
// ENOENT and treated the path as "untracked", causing
// `restoreFromGitHead` to silently no-op and lock in the drifted
// baseline.
const emptyDir = fs.mkdtempSync(path.join(os.tmpdir(), "fsr-empty-"));
const savedPath = process.env.PATH;
process.env.PATH = emptyDir;
try {
expect(() => restoreFromGitHead(repo, ["a.txt"])).toThrow(
/partitionTrackedPaths|git ls-files/,
);
} finally {
process.env.PATH = savedPath;
fs.rmSync(emptyDir, { recursive: true, force: true });
}
} finally {
fs.rmSync(repo, { recursive: true, force: true });
}
});
});
describe("FileSnapshotRestorer: sweepTmpStragglers basename scope", () => {
let tmp: string;
beforeEach(() => {
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "fsr-sweep-"));
});
afterEach(() => {
fs.rmSync(tmp, { recursive: true, force: true });
});
it("does NOT sweep same-shaped tmp files for unrelated basenames", () => {
// Only `a.txt` is in the snapshot scope. A `.b.txt.<hex>.tmp` straggler
// must survive the sweep — it belongs to a different snapshot target
// (possibly run by a different tool in the same directory). Prior to
// the per-basename tightening, the generic regex
// `/^\..+\.[0-9a-f]{16}\.tmp$/` matched and deleted any file of this
// shape.
const a = path.join(tmp, "a.txt");
fs.writeFileSync(a, "x");
const ourStraggler = path.join(tmp, ".a.txt.0123456789abcdef.tmp");
fs.writeFileSync(ourStraggler, "ours");
const foreignStraggler = path.join(tmp, ".b.txt.0123456789abcdef.tmp");
fs.writeFileSync(foreignStraggler, "foreign");
const r = new FileSnapshotRestorer([a]);
r.snapshot();
expect(fs.existsSync(ourStraggler)).toBe(false);
expect(fs.existsSync(foreignStraggler)).toBe(true);
});
});
describe("FileSnapshotRestorer: double-snapshot guard (flag-based)", () => {
let tmp: string;
beforeEach(() => {
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "fsr-dbl-"));
});
afterEach(() => {
fs.rmSync(tmp, { recursive: true, force: true });
});
it("throws on second snapshot() even when the path list matched nothing", () => {
// Prior to the flag-based guard, the check was size-based
// (`snapshots.size > 0`); with zero matching paths the size stayed 0
// forever and a second snapshot() would silently succeed.
const r = new FileSnapshotRestorer([path.join(tmp, "never.txt")]);
r.snapshot();
expect(r.snapshotMap.size).toBe(0);
expect(() => r.snapshot()).toThrow(
/called on a restorer that already has a snapshot/,
);
});
});
// Note: we intentionally do NOT test the `GIT_*` scrub by setting
// `process.env.GIT_DIR` in the test body — a polluted process.env has
// catastrophic blast-radius (any other git call in ANY parallel vitest
// suite or pre-commit hook would misroute to our decoy repo, and if our
// afterEach is skipped for any reason we'd silently corrupt the real
// working tree). The unit under test is `gitEnv()`, which we cover via its
// observable behavior: the existing "restores a tracked file from HEAD (on
// CI)" / mixed-list tests exercise the git-subprocess path with a real
// repo and would fail immediately if `gitEnv` stopped forwarding PATH,
// HOME, etc. The scrub itself is a simple `!k.startsWith("GIT_")` loop.
describe("restoreFromGitHead: drifted baseline guard", () => {
let repo: string;
let savedCI: string | undefined;
beforeEach(() => {
repo = mkTmpRepo();
savedCI = process.env.CI;
process.env.CI = "true";
});
afterEach(() => {
if (savedCI === undefined) delete process.env.CI;
else process.env.CI = savedCI;
fs.rmSync(repo, { recursive: true, force: true });
});
it("throws on CI when the input has paths but none are tracked", () => {
// Prior to the drifted-baseline guard, this silently early-returned
// and the caller would snapshot whatever drifted content was on disk.
expect(() => restoreFromGitHead(repo, ["totally-untracked.txt"])).toThrow(
/no input path is tracked by git/,
);
});
it("warns (does not throw) off-CI when nothing is tracked", () => {
delete process.env.CI;
// Off-CI we don't want to disrupt a developer running tests against a
// tree that may not yet have committed these files. Warn and return.
const warnings: string[] = [];
const origWarn = console.warn;
console.warn = (msg: unknown) => {
warnings.push(String(msg));
};
try {
expect(() =>
restoreFromGitHead(repo, ["totally-untracked.txt"]),
).not.toThrow();
expect(
warnings.some((w) => /no input path is tracked by git/.test(w)),
).toBe(true);
} finally {
console.warn = origWarn;
}
});
// --- Post-heal drift guard: the `git checkout HEAD --` above must leave the
// tracked paths byte-identical to HEAD. We simulate a hostile layer by
// stubbing execFileSync at the module level? No — simpler: we stub
// `checkout` indirectly by preloading the file with drift AFTER checkout
// would have run. We can't intercept the real checkout, so instead we
// cover the guard by replacing the `git` binary with a wrapper that
// rewrites the file to drifted content. Too invasive. Simplest direct
// cover: make `git diff --quiet` exit non-zero by monkey-patching PATH
// to a shim that reports drift. Skipped as over-engineered.
//
// Instead, verify the structural invariant: on CI, after a successful
// path-partition + checkout, a repo whose tracked file genuinely matches
// HEAD must NOT trigger the guard. Red-green: an earlier revision of
// this module lacked the post-heal diff and this test would have
// silently passed; the drift-scenario coverage is exercised by the
// integration test suites (create-integration, generate-registry,
// bundle-demo-content) which all run under CI=true.
it("does not false-positive on a clean tracked path (CI)", () => {
const a = path.join(repo, "a.txt");
fs.writeFileSync(a, "baseline\n");
commitAll(repo, "baseline");
// Tree is clean; checkout is a no-op; post-heal diff must be clean.
expect(() => restoreFromGitHead(repo, ["a.txt"])).not.toThrow();
// File still matches HEAD.
expect(fs.readFileSync(a, "utf-8")).toBe("baseline\n");
});
/** Build a shim `git` wrapper that fails the N-th `diff --quiet` invocation
* (1-indexed). Earlier diff calls pass through to the real git. Used to
* distinguish the off-CI pre-checkout dirty-tracked-file check (1st diff)
* from the post-heal drift guard (2nd diff) without false positives.
*
* State is persisted in a counter file in the shim dir so the same shim
* can be used across multiple restoreFromGitHead calls in one test. */
function mkGitShim(failNthDiff: number): {
shimDir: string;
cleanup: () => void;
activate: () => string | undefined;
deactivate: (saved: string | undefined) => void;
} {
const shimDir = fs.mkdtempSync(path.join(os.tmpdir(), "git-shim-"));
const counterFile = path.join(shimDir, "counter");
fs.writeFileSync(counterFile, "0");
const realGit = execFileSync("which", ["git"], {
encoding: "utf-8" as const,
})
.toString()
.trim();
const shim = path.join(shimDir, "git");
fs.writeFileSync(
shim,
`#!/usr/bin/env bash\n` +
`COUNTER_FILE=${JSON.stringify(counterFile)}\n` +
`FAIL_N=${failNthDiff}\n` +
`if [ "$1" = "diff" ] && [ "$2" = "--quiet" ]; then\n` +
` n=$(cat "$COUNTER_FILE")\n` +
` n=$((n+1))\n` +
` echo "$n" > "$COUNTER_FILE"\n` +
` if [ "$n" = "$FAIL_N" ]; then\n` +
` exit 1\n` +
` fi\n` +
`fi\n` +
`exec ${JSON.stringify(realGit)} "$@"\n`,
{ mode: 0o755 },
);
return {
shimDir,
cleanup: () => fs.rmSync(shimDir, { recursive: true, force: true }),
activate: () => {
const saved = process.env.PATH;
process.env.PATH = `${shimDir}:${saved ?? ""}`;
return saved;
},
deactivate: (saved) => {
process.env.PATH = saved;
},
};
}
// Direct cover of the guard's throw path: use a git shim that makes the
// POST-HEAL diff (the 2nd `diff --quiet`) exit 1. On CI there's only one
// diff call (the post-heal) so `failNthDiff: 1` is correct.
it("throws on CI when a post-heal diff reports drift", () => {
const a = path.join(repo, "a.txt");
fs.writeFileSync(a, "baseline\n");
commitAll(repo, "baseline");
const shim = mkGitShim(1);
const saved = shim.activate();
try {
expect(() => restoreFromGitHead(repo, ["a.txt"])).toThrow(
/drifted-baseline guard: post-heal diff failed/,
);
} finally {
shim.deactivate(saved);
shim.cleanup();
}
});
it("warns (does not throw) off-CI when a post-heal diff reports drift", () => {
delete process.env.CI;
const a = path.join(repo, "a.txt");
fs.writeFileSync(a, "baseline\n");
commitAll(repo, "baseline");
// Off-CI there are TWO diff calls: (1) the pre-checkout dirty-tracked
// check, (2) the post-heal drift guard. We want to fail only the 2nd.
const shim = mkGitShim(2);
const saved = shim.activate();
const warnings: string[] = [];
const origWarn = console.warn;
console.warn = (msg: unknown) => {
warnings.push(String(msg));
};
try {
expect(() => restoreFromGitHead(repo, ["a.txt"])).not.toThrow();
expect(
warnings.some((w) =>
/drifted-baseline guard: post-heal diff failed/.test(w),
),
).toBe(true);
} finally {
console.warn = origWarn;
shim.deactivate(saved);
shim.cleanup();
}
});
});
+663
View File
@@ -0,0 +1,663 @@
// Shared test helper: snapshot/restore working-tree files that test scripts
// mutate as a side effect.
//
// Rationale:
//
// Several showcase test suites invoke real generator scripts (create-integration,
// generate-registry, bundle-demo-content) that write to tracked files OUTSIDE
// any tmp dir — workflow YAMLs in .github/workflows/ and data JSONs in
// showcase/shell/src/data/. Without explicit restoration these writes leak
// into the working tree on every `nx run-many -t test` and, on Node 20 CI with
// vitest worker pools, the accumulated drift races the worker-RPC channel
// (`Timeout calling "onTaskUpdate"` -> ELIFECYCLE).
//
// This module provides two pieces:
//
// 1. `restoreFromGitHead(repoRoot, paths)` — synchronously restore any file
// from the most recent git HEAD. Used in `beforeAll` to heal a working
// tree left dirty by a previously crashed test run before we snapshot.
//
// 2. `FileSnapshotRestorer` — captures content of a fixed file list at
// snapshot time and rewrites only the files that drift. Idempotent. Used
// in `afterEach` / `afterAll` as the inner loop.
//
// PARALLELISM: the in-memory FileSnapshotRestorer is per-process state, so
// distinct suites snapshotting DIFFERENT file sets in separate vitest forks
// do not collide. What DOES need serialization across processes is git —
// `git checkout HEAD -- <paths>` grabs `.git/index.lock`, and two concurrent
// invocations (across suites, or between a suite and the pre-commit hook)
// race for it. `restoreFromGitHead` below acquires a cross-process file lock
// around every git invocation so parallel fork-pool execution under
// `fileParallelism: true` is safe. Suites that snapshot and mutate the SAME
// generated data files must additionally hold `acquireGeneratedDataLock()`
// around the mutation window.
//
// WINDOWS: callers in sibling test files (create-integration.test.ts,
// generate-registry.test.ts, bundle-demo-content.test.ts) invoke `npx`
// through `execFileSync` — these will fail on Windows because `npx` is a
// `.cmd` there and `execFileSync("npx", ...)` without `shell: true` fails.
// Showcase tests currently run on Ubuntu/macOS CI only; if we ever add
// Windows CI those call sites need a `process.platform === "win32"` gate.
import fs from "fs";
import os from "os";
import path from "path";
import crypto from "crypto";
import { execFileSync } from "child_process";
import type { StdioOptions } from "child_process";
/** Escape a string for inclusion as a literal in a `RegExp`. */
function escapeRegExp(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/** Construct an Error with `cause` attached, without relying on the ES2022
* two-argument `Error` constructor (which the TS lib used in this workspace
* does not declare). Runtime behaviour is identical — Node has supported
* `Error.cause` since v16.9 — but the TypeScript signature for our target
* lib (ES2020) only accepts a message, so assigning after construction
* keeps the type-checker happy without dropping diagnostic context. */
function errorWithCause(message: string, cause: unknown): Error {
const err = new Error(message);
(err as Error & { cause?: unknown }).cause = cause;
return err;
}
/** Shared exec options for all child_process calls in the test harness.
*
* `stdio: ["ignore", "pipe", "pipe"]` — on Node 20 CI, inheriting stderr
* into the parent vitest worker races the worker-RPC channel (observed as
* `Timeout calling "onTaskUpdate"`). We capture instead; success-path stderr
* is dropped by design — use the generator's stdout for test assertions.
*
* `maxBuffer: 10MiB` — defensive; the default 1MiB can deadlock if the
* generator logs verbose output.
*
* `timeout: 30000` — generators shell out to npx/tsx which cold-boots on
* first run; 15s produced flakes on slow CI runners.
*/
// Frozen at runtime (the test-cleanup.test.ts unit asserts the inner array
// is frozen so suites can't mutate it) but typed as `StdioOptions` — NOT a
// narrower `readonly` tuple — so spreading `SAFE_EXEC_OPTS` into
// `execFileSync(..., opts)` doesn't trip the readonly-vs-mutable-array
// mismatch in the node `@types/node` `StdioOptions` signature.
const SAFE_STDIO: StdioOptions = Object.freeze([
"ignore",
"pipe",
"pipe",
]) as StdioOptions;
export const SAFE_EXEC_OPTS = Object.freeze({
encoding: "utf-8" as const,
timeout: 30000,
maxBuffer: 10 * 1024 * 1024,
stdio: SAFE_STDIO,
});
/** Build exec options scoped to a specific cwd. Shared helper so suites don't
* recompute the same frozen `{...SAFE_EXEC_OPTS, cwd}` shape. The freeze is
* defensive — callers that accidentally mutate would corrupt subsequent
* invocations. */
export function execOptsFor(cwd: string) {
return Object.freeze({ ...SAFE_EXEC_OPTS, cwd });
}
/** Build the env forced for every `git` invocation in this module.
*
* Computed lazily per call so a test that temporarily mutates `process.env`
* (e.g. to toggle `CI`) observes the mutation instead of a module-load-time
* snapshot.
*
* Strips `GIT_*` environment overrides from the parent process
* (`GIT_DIR`, `GIT_INDEX_FILE`, `GIT_WORK_TREE`, ...). If the developer or
* a wrapping tool has set any of these, git would silently redirect our
* `ls-files` / `diff` / `checkout` calls to a different repository,
* corrupting the snapshot baseline. We scrub ALL `GIT_*` vars (allowlist
* is simpler and safer than enumerating the dozen+ recognized vars).
*
* `LC_ALL=C` / `LANG=C` — git localizes its error strings; the benign
* "pathspec did not match" detection is regex-based and must not depend on
* the developer's locale. DO NOT remove LC_ALL — the `benign-pathspec`
* regex below is English-only by design. */
function gitEnv(): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {};
for (const [k, v] of Object.entries(process.env)) {
if (k.startsWith("GIT_")) continue;
env[k] = v;
}
env.LC_ALL = "C";
env.LANG = "C";
return env;
}
/** Shared exec options for git invocations inside this module. Captures
* stderr (we interrogate it for benign-pathspec detection) and applies the
* SAFE_EXEC_OPTS timeout / buffer bounds — a hung git (corrupt worktree,
* signing prompt, network filesystem) would otherwise wedge the suite.
*
* Frozen for symmetry with `execOptsFor` — callers can't accidentally
* mutate shared options. */
function gitExecOpts(cwd: string) {
return Object.freeze({
...SAFE_EXEC_OPTS,
cwd,
env: gitEnv(),
});
}
/** True when running under a recognized truthy CI env var. Accepts exactly
* the common values — `"true"`, `"1"`, `"yes"` (case-insensitive). Anything
* else (including unset, `""`, `"0"`, `"false"`, arbitrary strings) is off.
* Allowlist over blocklist for strictness. */
function isCI(): boolean {
const v = process.env.CI;
if (!v) return false;
const normalized = v.toLowerCase();
return normalized === "true" || normalized === "1" || normalized === "yes";
}
/** Cross-process advisory lock via exclusive `mkdir`. Serializes every
* `restoreFromGitHead` invocation across vitest forks so concurrent
* `git checkout HEAD --` calls don't race for `.git/index.lock` (or for
* it with an external git op like the pre-commit hook). Mirrors the
* `proper-lockfile` approach but uses no deps — `fs.mkdirSync` is atomic
* on POSIX and Windows and is the canonical primitive for this pattern.
*
* The lock directory lives in `os.tmpdir()` (not repo-local) so a crash
* can't leave a stale lock inside the worktree where `git status` would
* show it. Stale locks older than `STALE_LOCK_MS` are reaped before the
* wait loop to heal any orphan left by a hard-killed previous run.
*
* Callers should always release via the returned `release()` in a
* finally — a thrown git error must not leave the lock held. */
const GIT_LOCK_DIR = path.join(
os.tmpdir(),
"copilotkit-showcase-git-restore.lock",
);
const GENERATED_DATA_LOCK_DIR = path.join(
os.tmpdir(),
"copilotkit-showcase-generated-data.lock",
);
const STALE_LOCK_MS = 60_000;
const WAIT_TIMEOUT_MS = 30_000;
const WAIT_POLL_MS = 25;
function reapStaleLock(lockDir: string): void {
try {
const st = fs.statSync(lockDir);
if (Date.now() - st.mtimeMs > STALE_LOCK_MS) {
try {
fs.rmdirSync(lockDir);
} catch {
/* another process already reaped / released it */
}
}
} catch {
/* not present */
}
}
function acquireLock(lockDir: string, label: string): () => void {
const start = Date.now();
reapStaleLock(lockDir);
for (;;) {
try {
fs.mkdirSync(lockDir);
return () => {
try {
fs.rmdirSync(lockDir);
} catch {
/* already released */
}
};
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
if (Date.now() - start > WAIT_TIMEOUT_MS) {
throw new Error(
`${label}: timed out after ${WAIT_TIMEOUT_MS}ms waiting for ${lockDir}.` +
` A previous run may have crashed holding the lock; remove the directory and retry.`,
{ cause: err },
);
}
// Busy-wait at 25ms. Node lacks a sync sleep; a tight loop on a
// 25ms granularity adds negligible CPU and matches proper-lockfile's
// default retry cadence.
const deadline = Date.now() + WAIT_POLL_MS;
while (Date.now() < deadline) {
/* spin */
}
reapStaleLock(lockDir);
}
}
}
function acquireGitLock(): () => void {
return acquireLock(GIT_LOCK_DIR, "acquireGitLock");
}
export function acquireGeneratedDataLock(): () => void {
return acquireLock(GENERATED_DATA_LOCK_DIR, "acquireGeneratedDataLock");
}
export function withGeneratedDataLock<T>(fn: () => T): T {
const release = acquireGeneratedDataLock();
try {
return fn();
} finally {
release();
}
}
/** Split input paths into tracked vs untracked relative to HEAD. Uses
* `git ls-files --error-unmatch` per path; exit 0 = tracked, exit 1 =
* untracked. Any OTHER failure (ENOENT git binary, EACCES, corrupt
* worktree, signal kill, timeout) is re-raised — otherwise a broken
* environment gets silently treated as "everything untracked" and the
* caller skips its destructive healing, locking in any drifted baseline.
*
* N complexity — we run one subprocess per path. Acceptable while the
* snapshot scope is single-digit paths (3 workflow YAMLs / 2 data JSONs).
* If scope grows materially, batch via a single `git ls-files -z -- <paths>`
* and diff the output against the input.
*/
function partitionTrackedPaths(
repoRoot: string,
paths: readonly string[],
): { tracked: string[]; untracked: string[] } {
const tracked: string[] = [];
const untracked: string[] = [];
for (const p of paths) {
try {
execFileSync("git", ["ls-files", "--error-unmatch", "--", p], {
...gitExecOpts(repoRoot),
stdio: ["ignore", "ignore", "pipe"],
});
tracked.push(p);
} catch (err) {
const code = (err as { status?: number | null }).status;
const errno = (err as NodeJS.ErrnoException).code;
// SAFE_EXEC_OPTS pins `encoding: "utf-8"`, so stderr is a string here.
const stderr = (err as { stderr?: string }).stderr ?? "";
// Exit 1 == pathspec unmatched (git's documented "not tracked" code).
// Anything else (ENOENT = git missing, EACCES, ETIMEDOUT, SIGKILL with
// status=null, exit 128 for corrupt repo, ...) is an environment
// failure, not a tracked/untracked question.
if (code === 1 && typeof errno !== "string") {
untracked.push(p);
continue;
}
throw errorWithCause(
`partitionTrackedPaths: unexpected git ls-files failure for ${p}` +
` (exit ${code ?? "?"}, errno ${errno ?? "n/a"}): ${stderr.trim()}`,
err,
);
}
}
return { tracked, untracked };
}
/**
* Restore the given files from `git HEAD` (working tree refresh).
*
* @param repoRoot Absolute path to the git repository root. Used as `cwd` for
* all git invocations so resolution doesn't depend on the
* caller's current working directory.
* @param paths Absolute or repo-relative paths to restore. Empty array is
* a no-op. Mixed tracked/untracked lists are supported: this
* function partitions them via `git ls-files --error-unmatch`
* and operates ONLY on tracked paths.
*
* This is destructive for tracked paths — it clobbers any uncommitted
* tracked-file edits. To prevent silently destroying a developer's in-progress
* work:
*
* - On CI (`process.env.CI` set to a truthy value) we always heal, since
* CI checkouts start clean and any drift is leaked state from a previous
* test run.
* - Off CI, we refuse to heal if the developer has uncommitted changes to
* any of the tracked target paths, throwing an error that tells them to
* stash, commit, or discard (`git checkout HEAD -- <paths>`) first. If
* the tree is already clean wrt these paths, we heal.
*
* Tracked paths are restored via `git checkout HEAD -- <paths>`; untracked
* paths are skipped (off-CI) or throw (on CI) per the drifted-baseline guard.
* An entirely-untracked input list throws on CI and logs a warning off-CI.
* Anything else — EACCES, git missing, corrupt worktree — is re-raised so the
* test suite fails loudly rather than silently seeding a drifted baseline into
* the subsequent snapshot.
*/
export function restoreFromGitHead(
repoRoot: string,
paths: readonly string[],
): void {
if (paths.length === 0) return;
// Serialize every git invocation against concurrent callers (other vitest
// forks, pre-commit hooks) so `git checkout HEAD --` doesn't race for
// `.git/index.lock`. Held across partition + diff + checkout + post-heal
// diff so any intermediate state is consistent from the caller's POV.
const release = acquireGitLock();
try {
restoreFromGitHeadLocked(repoRoot, paths);
} finally {
release();
}
}
function restoreFromGitHeadLocked(
repoRoot: string,
paths: readonly string[],
): void {
// Partition BEFORE any destructive op. Mixing untracked paths into
// `git diff --quiet` would produce exit 128 (pathspec mismatch) and
// cause the off-CI guard to mis-treat a legitimate dirty-tracked case
// as "nothing to clobber", silently overwriting developer edits.
const { tracked } = partitionTrackedPaths(repoRoot, paths);
if (tracked.length === 0) {
// Silent early-return here used to lock in a drifted baseline: a
// previous run's generator output that was then committed (or a
// developer moved the paths out of tracking entirely) would leave
// `partitionTrackedPaths` returning an empty list and the caller
// would happily snapshot the already-drifted content. Surface it so
// the user investigates: on CI we throw outright; off-CI we warn
// (developers may legitimately be exercising the test harness against
// a tree that hasn't had those files committed yet).
const msg =
`restoreFromGitHead: no input path is tracked by git:\n` +
paths.map((p) => ` ${p}`).join("\n") +
`\nSnapshot baseline would be drifted — investigate before running tests.`;
if (isCI()) {
throw new Error(msg);
}
// eslint-disable-next-line no-console
console.warn(`[test-cleanup] ${msg}`);
return;
}
if (!isCI()) {
// Off-CI guard: bail before clobbering developer edits. Only runs
// against tracked paths so untracked path components can't mask a
// dirty tracked file.
try {
execFileSync("git", ["diff", "--quiet", "HEAD", "--", ...tracked], {
...gitExecOpts(repoRoot),
stdio: ["ignore", "pipe", "pipe"],
});
} catch (err) {
const code = (err as { status?: number }).status;
// SAFE_EXEC_OPTS pins `encoding: "utf-8"`, so stderr is a string here.
const stderr = (err as { stderr?: string }).stderr ?? "";
if (code === 1) {
// `git diff --quiet` exits 1 when there ARE differences.
throw errorWithCause(
`restoreFromGitHead: refusing to overwrite uncommitted changes to:\n` +
tracked.map((p) => ` ${p}`).join("\n") +
`\nStash, commit, or discard these changes before running the test` +
` suite (e.g. \`git checkout HEAD -- <paths>\`).`,
err,
);
}
// Any other failure is unexpected now that we've pre-filtered to
// tracked paths. Re-raise with stderr attached so the caller sees
// what git actually said.
throw errorWithCause(
`restoreFromGitHead: unexpected git diff failure (exit ${code ?? "?"}): ${stderr.trim()}`,
err,
);
}
}
try {
execFileSync("git", ["checkout", "HEAD", "--", ...tracked], {
...gitExecOpts(repoRoot),
stdio: ["ignore", "pipe", "pipe"],
});
} catch (err) {
// Belt-and-braces: handles a race where a tracked file is removed
// between partitionTrackedPaths and this checkout (e.g. a parallel
// rm from another harness touching the same tree). Realistically
// unreachable in this suite — suites run under fileParallelism:
// true, but their snapshot targets are disjoint (see the
// PARALLELISM note above) and no concurrent harness removes
// tracked files — but cheap to tolerate. Git produces this as
// `error: pathspec '…' did not match any file(s) known to git`
// (exit 1). Anything else — EACCES, git missing, corrupt worktree —
// bubbles up so the suite fails loudly instead of seeding a drifted
// baseline.
const stderr =
// SAFE_EXEC_OPTS pins `encoding: "utf-8"`, so stderr is a string here.
(err as { stderr?: string }).stderr ?? "";
const isBenignPathspec = /did not match any file\(s\) known to git/.test(
stderr,
);
if (!isBenignPathspec) {
const code = (err as { status?: number }).status;
throw errorWithCause(
`restoreFromGitHead: git checkout failed (exit ${code ?? "?"}): ${stderr.trim()}`,
err,
);
}
}
// Drifted-baseline guard (post-heal): after the `git checkout HEAD --`
// above, every tracked path we just healed MUST now be byte-identical
// to HEAD. If it isn't, something is mutating these files between our
// checkout and here — an external process, a filesystem layer, or
// (most likely in practice) a parallel test suite we haven't accounted
// for. That's a drifted baseline: our subsequent snapshot would bake
// in the drift and the restore loop would happily maintain it forever.
// On CI this is a hard error; off-CI we let the snapshot proceed but
// warn, since a developer may be iterating on a dirty tree.
try {
execFileSync("git", ["diff", "--quiet", "HEAD", "--", ...tracked], {
...gitExecOpts(repoRoot),
stdio: ["ignore", "pipe", "pipe"],
});
} catch (err) {
const code = (err as { status?: number }).status;
const stderr = (err as { stderr?: string }).stderr ?? "";
const msg =
`restoreFromGitHead: drifted-baseline guard: post-heal diff failed` +
` (exit ${code ?? "?"}) for:\n` +
tracked.map((p) => ` ${p}`).join("\n") +
(stderr.trim() ? `\ngit stderr: ${stderr.trim()}` : "");
if (isCI()) {
throw errorWithCause(msg, err);
}
// eslint-disable-next-line no-console
console.warn(`[test-cleanup] ${msg}`);
}
}
/**
* Snapshots file content in-memory and restores any file that drifts. Uses
* an atomic temp-file + rename only when the on-disk bytes differ from the
* snapshot, which avoids touching mtime on clean runs and guarantees readers
* never observe a truncated file.
*
* ASYMMETRY: `restore()` undoes drift to snapshotted files and re-creates
* snapshotted files that were deleted. It does NOT remove files that tests
* create which weren't in the snapshot — the test is responsible for cleaning
* up new files it creates (the create-integration suite does this explicitly
* via `fs.rmSync(TEST_DIR, {recursive: true, force: true})`).
*/
export class FileSnapshotRestorer {
private readonly snapshots = new Map<string, Buffer>();
// Tracks whether `snapshot()` has been invoked — set unconditionally
// before any throwing work so the double-snapshot guard fires even when
// the path list is empty or every path is missing (size-based guards
// would silently allow a second call in that case).
private snapshotted = false;
constructor(private readonly paths: readonly string[]) {}
/** Capture current content for every path that exists on disk.
*
* Also sweeps any stragger atomic-write temp files in each path's parent
* directory — `.<basename>.<hex>.tmp` — that a prior crashed run left
* behind. Without the sweep, those tmp files accumulate in tracked
* directories (`.github/workflows/`, `showcase/shell/src/data/`) and
* reintroduce the exact pollution this harness exists to prevent.
*
* Throws if called after a previous snapshot — snapshotting twice silently
* discards the original baseline and is almost always a bug. Use a fresh
* restorer instance per test suite. */
snapshot(): void {
if (this.snapshotted) {
throw new Error(
"FileSnapshotRestorer.snapshot() called on a restorer that already" +
" has a snapshot. Construct a new instance per suite.",
);
}
this.snapshotted = true;
// Sweep atomic-write temp stragglers BEFORE capturing. The sweep is
// scoped per-basename: for each snapshot target, we look ONLY for
// `.<basename>.<16hex>.tmp` stragglers of that specific target. Earlier
// revisions used a generic `/^\..+\.[0-9a-f]{16}\.tmp$/` regex which
// would match any same-shaped tmp file in the directory — a landmine in
// shared dirs like `.github/workflows/` where an unrelated tool could
// have created a similarly-named file. Tightened to per-basename scope
// to preserve unrelated tmp files in shared directories.
const bucketed = new Map<string, Set<string>>();
for (const p of this.paths) {
const dir = path.dirname(p);
let bucket = bucketed.get(dir);
if (!bucket) {
bucket = new Set();
bucketed.set(dir, bucket);
}
bucket.add(path.basename(p));
}
for (const [dir, basenames] of bucketed) {
this.sweepTmpStragglers(dir, basenames);
}
for (const p of this.paths) {
if (fs.existsSync(p)) {
this.snapshots.set(p, fs.readFileSync(p));
}
}
}
/** Remove `.<basename>.<16hex>.tmp` stragglers in `dir`, where `basename`
* is drawn from the snapshot target list for that directory. Tolerant of
* a missing directory (parent dir may not exist yet in a fresh clone).
*
* The pattern matches files produced by `atomicWrite` for the specific
* targets we're about to snapshot, and ONLY those — stragglers for
* unrelated files in the same directory are left alone. EACCES/EBUSY on
* a specific unlink is debug-logged (`DEBUG_TEST_CLEANUP=1`) so hangs
* are diagnosable, then swallowed per best-effort sweep semantics. */
private sweepTmpStragglers(
dir: string,
basenames: ReadonlySet<string>,
): void {
let entries: string[];
try {
entries = fs.readdirSync(dir);
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") return;
throw err;
}
// Escape each target basename for literal regex inclusion, then build
// an alternation. `crypto.randomBytes(8)` is 16 hex chars.
const escaped = Array.from(basenames, escapeRegExp);
const re = new RegExp(`^\\.(?:${escaped.join("|")})\\.[0-9a-f]{16}\\.tmp$`);
for (const name of entries) {
if (!re.test(name)) continue;
const target = path.join(dir, name);
try {
fs.unlinkSync(target);
} catch (err) {
if (process.env.DEBUG_TEST_CLEANUP) {
const code = (err as NodeJS.ErrnoException).code ?? "?";
// eslint-disable-next-line no-console
console.warn(
`[test-cleanup] sweep: unlink ${target} failed (${code})`,
);
}
/* best effort */
}
}
}
/**
* Restore every snapshotted path. If the on-disk bytes match the snapshot,
* no write happens. Writes are atomic: we write the snapshot bytes to a
* sibling temp file and `fs.renameSync` it into place, so readers never
* observe a truncated intermediate state. Missing files are re-created
* from the snapshot (including re-creating parent directories if they
* were deleted). Any unexpected read error (EACCES, EISDIR, EBUSY, ...)
* propagates.
*/
restore(): void {
for (const [p, content] of this.snapshots) {
let current: Buffer | null;
try {
current = fs.readFileSync(p);
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
current = null;
} else {
throw err;
}
}
if (current === null || !current.equals(content)) {
this.atomicWrite(p, content);
}
}
}
/** Atomic write via temp file + rename. Creates parent dir if missing.
*
* Temp filename uses `crypto.randomBytes(8).toString("hex")` so two
* concurrent writes from the same process can't collide (they would with
* `Date.now()` at ms resolution) and so the `snapshot()` sweep regex can
* match stragglers unambiguously. */
private atomicWrite(target: string, content: Buffer): void {
const dir = path.dirname(target);
const ensureDir = () => {
try {
fs.mkdirSync(dir, { recursive: true });
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
}
};
const write = () => {
// Temp file sits in the same directory so rename is atomic (same
// filesystem). `os.tmpdir()` could be a different mount.
const suffix = crypto.randomBytes(8).toString("hex");
const tmp = path.join(dir, `.${path.basename(target)}.${suffix}.tmp`);
fs.writeFileSync(tmp, content);
try {
fs.renameSync(tmp, target);
} catch (err) {
// Best effort — don't leak temp files on rename failure.
try {
fs.unlinkSync(tmp);
} catch {
/* swallow */
}
throw err;
}
};
try {
write();
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
// Parent directory was removed between snapshot and restore — create
// it and retry once.
ensureDir();
write();
} else {
throw err;
}
}
}
/** Expose the snapshot map (read-only) so tests can assert against it. */
get snapshotMap(): ReadonlyMap<string, Buffer> {
return this.snapshots;
}
}
@@ -0,0 +1,53 @@
import { describe, it, expect } from "vitest";
import fs from "fs";
import path from "path";
import yaml from "yaml";
import { validateManifestConstraints } from "../validate-constraints.js";
const CONSTRAINTS_PATH = path.resolve(
__dirname,
"..",
"..",
"shared",
"constraints.yaml",
);
const FIXTURES_DIR = path.resolve(__dirname, "fixtures");
function loadConstraints() {
return yaml.parse(fs.readFileSync(CONSTRAINTS_PATH, "utf-8"));
}
function loadFixture(name: string) {
return yaml.parse(fs.readFileSync(path.join(FIXTURES_DIR, name), "utf-8"));
}
describe("Constraint Validator", () => {
const constraints = loadConstraints();
it("passes a valid manifest", () => {
const manifest = loadFixture("valid-manifest.yaml");
const errors = validateManifestConstraints(manifest, constraints);
expect(errors).toEqual([]);
});
it("rejects a demo not allowed by declared generative_ui", () => {
const manifest = loadFixture("invalid-genui-manifest.yaml");
const errors = validateManifestConstraints(manifest, constraints);
expect(errors.length).toBeGreaterThan(0);
expect(errors[0]).toContain("tool-rendering");
expect(errors[0]).toContain("not allowed");
});
it("skips validation when generative_ui is missing", () => {
const manifest = loadFixture("missing-genui-manifest.yaml");
const errors = validateManifestConstraints(manifest, constraints);
expect(errors).toEqual([]);
});
it("rejects a demo ID not in any allowed list", () => {
const manifest = loadFixture("unknown-demo-manifest.yaml");
const errors = validateManifestConstraints(manifest, constraints);
expect(errors.length).toBeGreaterThan(0);
expect(errors[0]).toContain("nonexistent-feature");
});
});
@@ -0,0 +1,157 @@
/**
* Guardrail for aimock fixture drift.
*
* Background: aimock serves deterministic responses in prod to save LLM cost.
* Fixtures substring-match the user message and return a hardcoded tool call.
* When a fixture returns a tool name that the matched demo's agent doesn't
* actually register, the tool call dangles and the UI renders nothing.
* That's what broke gen-ui-tool-based and beautiful-chat in prod before the
* fixture cleanup landed. This validator catches that class of drift.
*/
import { describe, it, expect } from "vitest";
import {
validate,
type Fixture,
type DemoSurface,
type Violation,
} from "../validate-fixture-tool-surface";
const demoGenUiToolBased: DemoSurface = {
slug: "langgraph-python",
demoId: "gen-ui-tool-based",
agentId: "gen-ui-tool-based",
suggestions: [
"Show me a pie chart of website traffic by source.",
"Show me a bar chart of quarterly sales for Q1, Q2, Q3, Q4.",
],
tools: ["render_pie_chart", "render_bar_chart"],
};
const demoBeautifulChat: DemoSurface = {
slug: "langgraph-python",
demoId: "beautiful-chat",
agentId: "beautiful-chat",
suggestions: [
"Show me a pie chart of our revenue distribution by category.",
"Toggle the app theme using the toggleTheme tool.",
],
tools: ["query_data", "pieChart", "barChart", "scheduleTime", "toggleTheme"],
};
describe("validateFixtureToolSurface", () => {
it("flags a fixture whose tool name isn't registered by the matched demo", () => {
const badFixture: Fixture = {
match: { userMessage: "pie chart" },
response: {
toolCalls: [
{
name: "query_data",
arguments: '{"query":"revenue by category"}',
},
],
},
};
const violations = validate([badFixture], [demoGenUiToolBased]);
expect(violations).toHaveLength(1);
expect(violations[0]).toMatchObject<Partial<Violation>>({
fixtureMatch: "pie chart",
fixtureTool: "query_data",
demo: { slug: "langgraph-python", demoId: "gen-ui-tool-based" },
});
});
it("accepts a fixture whose tool name is in the matched demo's surface", () => {
const goodFixture: Fixture = {
match: { userMessage: "pie chart of website traffic by source" },
response: {
toolCalls: [{ name: "render_pie_chart", arguments: "{}" }],
},
};
const violations = validate([goodFixture], [demoGenUiToolBased]);
expect(violations).toEqual([]);
});
it("ignores content-only fixtures (no toolCalls → no drift possible)", () => {
const contentFixture: Fixture = {
match: { userMessage: "hello" },
response: { content: "Hi there!" },
};
const violations = validate([contentFixture], [demoGenUiToolBased]);
expect(violations).toEqual([]);
});
it("ignores fixtures that don't match any demo's suggestions", () => {
const orphanFixture: Fixture = {
match: { userMessage: "something no suggestion contains" },
response: {
toolCalls: [{ name: "made_up_tool", arguments: "{}" }],
},
};
const violations = validate([orphanFixture], [demoGenUiToolBased]);
// Ad-hoc prompts that users type manually may legitimately fire these
// fixtures with no matching suggestion. That's out of scope for this
// validator — we only assert "fixtures that THE SHOWCASE ITSELF TRIGGERS
// via its own suggestion pills line up with the agent's tool surface".
expect(violations).toEqual([]);
});
it("matches case-insensitively", () => {
const badFixture: Fixture = {
match: { userMessage: "PIE CHART" },
response: {
toolCalls: [{ name: "query_data", arguments: "{}" }],
},
};
const violations = validate([badFixture], [demoGenUiToolBased]);
expect(violations).toHaveLength(1);
expect(violations[0].fixtureMatch).toBe("PIE CHART");
});
it("reports a violation per (fixture, matching demo) pair when multiple demos match", () => {
const genericFixture: Fixture = {
match: { userMessage: "pie chart" },
response: {
toolCalls: [{ name: "query_data", arguments: "{}" }],
},
};
const violations = validate(
[genericFixture],
[demoGenUiToolBased, demoBeautifulChat],
);
// gen-ui-tool-based does NOT have query_data → 1 violation
// beautiful-chat DOES have query_data → no violation
expect(violations).toHaveLength(1);
expect(violations[0].demo.demoId).toBe("gen-ui-tool-based");
});
it("reports one violation per unregistered tool in a multi-tool response", () => {
const multiToolFixture: Fixture = {
match: { userMessage: "pie chart of website traffic by source" },
response: {
toolCalls: [
{ name: "render_pie_chart", arguments: "{}" }, // OK
{ name: "query_data", arguments: "{}" }, // drift
{ name: "generate_a2ui", arguments: "{}" }, // drift
],
},
};
const violations = validate([multiToolFixture], [demoGenUiToolBased]);
expect(violations).toHaveLength(2);
const badTools = violations.map((v) => v.fixtureTool).sort();
expect(badTools).toEqual(["generate_a2ui", "query_data"]);
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,262 @@
import { describe, it, expect } from "vitest";
import fs from "fs";
import path from "path";
import { createHash } from "crypto";
import { fileURLToPath } from "url";
import {
computePinDrift,
PinDriftBaselineError,
} from "../validate-pins-core.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const FIXTURES = path.resolve(__dirname, "fixtures", "pin-drift");
// Helper: build a baseline JSON document matching the on-disk shape of
// `showcase/scripts/fail-baseline.json`. Keep the `_comment` field in —
// the schema ignores unknown top-level keys so this matches production.
function makeBaseline(count: number, hash: string): string {
return JSON.stringify({
_comment: "test baseline",
validatePinsFailCount: count,
validatePinsFailHash: hash,
baselineDemoCount: 9,
});
}
// Helper: compute the hash the same way the CI shell does —
// `sort -u | shasum -a 256` — so each test can produce its own expected
// hash without copy-pasting hex strings. If this differs from the
// implementation, every test flips red.
function shellHash(lines: string[]): string {
if (lines.length === 0) return "";
const deduped = Array.from(new Set(lines)).sort();
return createHash("sha256")
.update(deduped.join("\n") + "\n")
.digest("hex");
}
describe("computePinDrift", () => {
it("stable: identical FAIL sets → status 'stable', delta 0", () => {
const failed = ["[FAIL] a", "[FAIL] b", "[FAIL] c"];
const baseline = makeBaseline(failed.length, shellHash(failed));
const r = computePinDrift({
failBaselineJson: baseline,
currentWorkingState: { failed },
});
expect(r.status).toBe("stable");
expect(r.delta).toBe(0);
expect(r.actualCount).toBe(3);
expect(r.baselineCount).toBe(3);
});
it("regressed: additional FAIL → positive delta", () => {
const prior = ["[FAIL] a", "[FAIL] b"];
const now = ["[FAIL] a", "[FAIL] b", "[FAIL] c"];
const baseline = makeBaseline(prior.length, shellHash(prior));
const r = computePinDrift({
failBaselineJson: baseline,
currentWorkingState: { failed: now },
});
expect(r.status).toBe("regressed");
expect(r.delta).toBe(1);
expect(r.actualCount).toBe(3);
});
it("improved: fewer FAILs → negative delta", () => {
const prior = ["[FAIL] a", "[FAIL] b", "[FAIL] c"];
const now = ["[FAIL] a"];
const baseline = makeBaseline(prior.length, shellHash(prior));
const r = computePinDrift({
failBaselineJson: baseline,
currentWorkingState: { failed: now },
});
expect(r.status).toBe("improved");
expect(r.delta).toBe(-2);
});
it("no_baseline: empty baseline file → status 'no_baseline'", () => {
const r = computePinDrift({
failBaselineJson: "",
currentWorkingState: { failed: ["[FAIL] a"] },
});
expect(r.status).toBe("no_baseline");
expect(r.actualCount).toBe(1);
expect(r.baselineCount).toBe(0);
expect(r.delta).toBe(0);
});
it("no_baseline: whitespace-only baseline → status 'no_baseline'", () => {
// Whitespace-only means the file exists but hasn't been seeded yet —
// we don't want an accidental fs.readFileSync of a stub to crash
// before ratchet can run.
const r = computePinDrift({
failBaselineJson: " \n\t\n",
currentWorkingState: { failed: [] },
});
expect(r.status).toBe("no_baseline");
});
it("regressed on equal-count/different-set: remove 1, add 1 → 'regressed'", () => {
// Hash ratchet invariant: if the count matches but the set rotated,
// that's NOT stable — the CI shell treats it as a regression so a
// silent "heal one, break one" slip cannot sneak past weekly drift.
const prior = ["[FAIL] a", "[FAIL] b"];
const now = ["[FAIL] a", "[FAIL] c"];
const baseline = makeBaseline(prior.length, shellHash(prior));
const r = computePinDrift({
failBaselineJson: baseline,
currentWorkingState: { failed: now },
});
expect(r.status).toBe("regressed");
expect(r.delta).toBe(0); // count equal...
expect(r.hash).not.toBe(shellHash(prior)); // ...but hash differs
});
it("malformed baseline JSON throws PinDriftBaselineError", () => {
expect(() =>
computePinDrift({
failBaselineJson: "{not json",
currentWorkingState: { failed: [] },
}),
).toThrow(PinDriftBaselineError);
});
it("baseline with wrong type for validatePinsFailCount throws", () => {
expect(() =>
computePinDrift({
failBaselineJson: JSON.stringify({
validatePinsFailCount: "not a number",
validatePinsFailHash: "a".repeat(64),
}),
currentWorkingState: { failed: [] },
}),
).toThrow(PinDriftBaselineError);
});
it("baseline with malformed hash throws", () => {
expect(() =>
computePinDrift({
failBaselineJson: JSON.stringify({
validatePinsFailCount: 0,
validatePinsFailHash: "ZZZZ",
}),
currentWorkingState: { failed: [] },
}),
).toThrow(PinDriftBaselineError);
});
it("baseline that isn't an object throws", () => {
expect(() =>
computePinDrift({
failBaselineJson: JSON.stringify([1, 2, 3]),
currentWorkingState: { failed: [] },
}),
).toThrow(PinDriftBaselineError);
});
it("currentWorkingState must carry failLines or failed", () => {
expect(() =>
computePinDrift({
failBaselineJson: makeBaseline(0, shellHash([])),
currentWorkingState: { bogus: true },
}),
).toThrow(PinDriftBaselineError);
});
it("currentWorkingState: null throws", () => {
expect(() =>
computePinDrift({
failBaselineJson: makeBaseline(0, shellHash([])),
currentWorkingState: null,
}),
).toThrow(PinDriftBaselineError);
});
it("accepts raw `failLines` stderr shape (filters non-FAIL)", () => {
// Raw stderr from the CLI carries [WARN] and [FAIL] lines. Only
// [FAIL] lines participate in the ratchet — mirrors
// `grep -E '^\[FAIL\]'` in the CI shell.
const stderr = [
"[WARN] pkg: skipped x",
"[FAIL] a: foo",
"[FAIL] b: bar",
"[WARN] pkg: skipped y",
];
const r = computePinDrift({
failBaselineJson: "",
currentWorkingState: { failLines: stderr },
});
expect(r.actualCount).toBe(2);
expect(r.failed).toEqual(["[FAIL] a: foo", "[FAIL] b: bar"]);
});
it("dedupes repeated FAIL lines (matches sort -u)", () => {
const r = computePinDrift({
failBaselineJson: "",
currentWorkingState: {
failed: ["[FAIL] a", "[FAIL] a", "[FAIL] b", "[FAIL] b", "[FAIL] c"],
},
});
expect(r.actualCount).toBe(3);
expect(r.failed).toEqual(["[FAIL] a", "[FAIL] b", "[FAIL] c"]);
});
it("returns empty hash when no FAILs", () => {
const r = computePinDrift({
failBaselineJson: "",
currentWorkingState: { failed: [] },
});
expect(r.hash).toBe("");
expect(r.failed).toEqual([]);
});
describe("legacy-parity cross-check against committed fail-baseline.json", () => {
// This is the Slot D cross-check: drive the committed baseline +
// captured CLI stderr snapshot through `computePinDrift` and assert
// it matches the same count/hash the CI shell ratchet would compute.
// If either side drifts (CI shell changes, or our core math changes)
// this test flips red — that is the whole point.
it("matches committed baseline count + hash from captured CLI output", () => {
const baselineJson = fs.readFileSync(
path.join(FIXTURES, "fail-baseline.json"),
"utf8",
);
const stderr = fs
.readFileSync(path.join(FIXTURES, "cli-baseline-stderr.txt"), "utf8")
.split("\n");
const r = computePinDrift({
failBaselineJson: baselineJson,
currentWorkingState: { failLines: stderr },
});
const parsed = JSON.parse(baselineJson) as {
validatePinsFailCount: number;
validatePinsFailHash: string;
};
expect(r.actualCount).toBe(parsed.validatePinsFailCount);
expect(r.hash).toBe(parsed.validatePinsFailHash);
expect(r.status).toBe("stable");
expect(r.delta).toBe(0);
});
it("Summary stdout line reports FAIL=actualCount (format contract)", () => {
// The CI shell extracts `FAIL=<int>` from the Summary line of the
// CLI's stdout. If the CLI output format drifts, the shell extractor
// breaks — this test pins the format we depend on.
const stdout = fs.readFileSync(
path.join(FIXTURES, "cli-baseline-stdout.txt"),
"utf8",
);
const match = stdout.match(/FAIL=(\d+)/);
expect(match).not.toBeNull();
const baselineJson = fs.readFileSync(
path.join(FIXTURES, "fail-baseline.json"),
"utf8",
);
const parsed = JSON.parse(baselineJson) as {
validatePinsFailCount: number;
};
expect(Number(match![1])).toBe(parsed.validatePinsFailCount);
});
});
});
@@ -0,0 +1,310 @@
// Tests for the showcase-internal pin invariant (Phase 1):
//
// - Every `@copilotkit/*` dep in every showcase integration pins to
// `canonicalCopilotKitVersion` from showcase-canonical-pins.json,
// OR to a per-slug per-dep override.
// - Every other framework dep is an exact pin (no ^/~/>=/latest/...).
// - Workspace refs are skipped, not failed.
//
// The validator no longer reads `examples/integrations/` — that
// cross-product comparison was retired. These tests are the single
// authoritative test surface for the new invariant.
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import fs from "fs";
import path from "path";
import { validateAll } from "../validate-pins.js";
import { tmpdir, write } from "./validate-pins.shared.js";
interface Fixture {
repoRoot: string;
pkgsDir: string;
}
function makeFixture(canonical: {
canonicalCopilotKitVersion?: string;
overrides?: Record<string, Record<string, string>>;
}): Fixture {
const repoRoot = tmpdir();
const pkgsDir = path.join(repoRoot, "showcase", "integrations");
fs.mkdirSync(pkgsDir, { recursive: true });
write(
path.join(repoRoot, "showcase", "scripts", "showcase-canonical-pins.json"),
JSON.stringify({
canonicalCopilotKitVersion:
canonical.canonicalCopilotKitVersion ?? "1.59.2",
overrides: canonical.overrides ?? {},
}),
);
return { repoRoot, pkgsDir };
}
describe("showcase-internal canonical-pin invariant", () => {
let saved: string | undefined;
let repoRoot: string | undefined;
beforeEach(() => {
saved = process.env.VALIDATE_PINS_REPO_ROOT;
});
afterEach(() => {
if (saved === undefined) {
delete process.env.VALIDATE_PINS_REPO_ROOT;
} else {
process.env.VALIDATE_PINS_REPO_ROOT = saved;
}
if (repoRoot) fs.rmSync(repoRoot, { recursive: true, force: true });
});
it("passes when all @copilotkit/* deps match canonical and framework deps are exact-pinned", () => {
const fx = makeFixture({});
repoRoot = fx.repoRoot;
process.env.VALIDATE_PINS_REPO_ROOT = fx.repoRoot;
write(
path.join(fx.pkgsDir, "mastra", "package.json"),
JSON.stringify({
name: "mastra",
dependencies: {
"@copilotkit/react-core": "1.59.2",
"@copilotkit/runtime": "1.59.2",
"@mastra/core": "0.15.0",
},
}),
);
const report = validateAll();
expect(report.fail).toEqual([]);
expect(report.ok.some((l) => l.includes("[OK] mastra"))).toBe(true);
});
it("FAILs when @copilotkit/* dep does not match canonical version", () => {
const fx = makeFixture({});
repoRoot = fx.repoRoot;
process.env.VALIDATE_PINS_REPO_ROOT = fx.repoRoot;
write(
path.join(fx.pkgsDir, "mastra", "package.json"),
JSON.stringify({
name: "mastra",
dependencies: {
"@copilotkit/react-core": "1.58.0",
"@mastra/core": "0.15.0",
},
}),
);
const report = validateAll();
expect(
report.fail.some(
(l) =>
l.includes("[FAIL] mastra:") &&
l.includes("@copilotkit/react-core") &&
l.includes("canonical is 1.59.2"),
),
).toBe(true);
});
it("FAILs when @copilotkit/* dep uses 'latest'", () => {
const fx = makeFixture({});
repoRoot = fx.repoRoot;
process.env.VALIDATE_PINS_REPO_ROOT = fx.repoRoot;
write(
path.join(fx.pkgsDir, "mastra", "package.json"),
JSON.stringify({
name: "mastra",
dependencies: {
"@copilotkit/runtime": "latest",
"@mastra/core": "0.15.0",
},
}),
);
const report = validateAll();
expect(
report.fail.some(
(l) =>
l.includes("@copilotkit/runtime") &&
l.includes("canonical is 1.59.2"),
),
).toBe(true);
});
it("passes when an override allows a non-canonical @copilotkit pin (e.g. pkg.pr.new URL)", () => {
const url =
"https://pkg.pr.new/CopilotKit/CopilotKit/@copilotkit/runtime@4482";
const fx = makeFixture({
overrides: {
"built-in-agent": {
"@copilotkit/runtime": url,
},
},
});
repoRoot = fx.repoRoot;
process.env.VALIDATE_PINS_REPO_ROOT = fx.repoRoot;
write(
path.join(fx.pkgsDir, "built-in-agent", "package.json"),
JSON.stringify({
name: "built-in-agent",
dependencies: {
"@copilotkit/react-core": "1.59.2",
"@copilotkit/runtime": url,
},
}),
);
const report = validateAll();
expect(report.fail).toEqual([]);
expect(report.ok.some((l) => l.includes("[OK] built-in-agent"))).toBe(true);
});
it("FAILs when an overridden @copilotkit/* dep does not match the override spec", () => {
const url =
"https://pkg.pr.new/CopilotKit/CopilotKit/@copilotkit/runtime@4482";
const fx = makeFixture({
overrides: {
"built-in-agent": {
"@copilotkit/runtime": url,
},
},
});
repoRoot = fx.repoRoot;
process.env.VALIDATE_PINS_REPO_ROOT = fx.repoRoot;
write(
path.join(fx.pkgsDir, "built-in-agent", "package.json"),
JSON.stringify({
name: "built-in-agent",
dependencies: {
"@copilotkit/react-core": "1.59.2",
"@copilotkit/runtime":
"https://pkg.pr.new/CopilotKit/CopilotKit/@copilotkit/runtime@DIFFERENT",
},
}),
);
const report = validateAll();
expect(
report.fail.some(
(l) =>
l.includes("@copilotkit/runtime") && l.includes("override expects"),
),
).toBe(true);
});
it("supports a per-slug legacy-version override (ms-agent-harness-dotnet style)", () => {
const fx = makeFixture({
overrides: {
"ms-agent-harness-dotnet": {
"@copilotkit/react-core": "1.57.2",
"@copilotkit/runtime": "1.57.2",
},
},
});
repoRoot = fx.repoRoot;
process.env.VALIDATE_PINS_REPO_ROOT = fx.repoRoot;
write(
path.join(fx.pkgsDir, "ms-agent-harness-dotnet", "package.json"),
JSON.stringify({
name: "ms-agent-harness-dotnet",
dependencies: {
"@copilotkit/react-core": "1.57.2",
"@copilotkit/runtime": "1.57.2",
},
}),
);
const report = validateAll();
expect(report.fail).toEqual([]);
});
it("FAILs when a framework dep is not an exact pin (^1.0.0)", () => {
const fx = makeFixture({});
repoRoot = fx.repoRoot;
process.env.VALIDATE_PINS_REPO_ROOT = fx.repoRoot;
write(
path.join(fx.pkgsDir, "mastra", "package.json"),
JSON.stringify({
name: "mastra",
dependencies: {
"@copilotkit/react-core": "1.59.2",
"@mastra/core": "^1.0.0",
},
}),
);
const report = validateAll();
expect(
report.fail.some(
(l) => l.includes("@mastra/core") && l.includes("is not an exact pin"),
),
).toBe(true);
});
it("FAILs when a framework dep uses 'next' dist-tag", () => {
const fx = makeFixture({});
repoRoot = fx.repoRoot;
process.env.VALIDATE_PINS_REPO_ROOT = fx.repoRoot;
write(
path.join(fx.pkgsDir, "mastra", "package.json"),
JSON.stringify({
name: "mastra",
dependencies: {
"@copilotkit/react-core": "1.59.2",
"@mastra/core": "next",
},
}),
);
const report = validateAll();
expect(
report.fail.some(
(l) => l.includes("@mastra/core") && /not an exact pin/.test(l),
),
).toBe(true);
});
it("skips workspace: refs instead of failing", () => {
const fx = makeFixture({});
repoRoot = fx.repoRoot;
process.env.VALIDATE_PINS_REPO_ROOT = fx.repoRoot;
write(
path.join(fx.pkgsDir, "mastra", "package.json"),
JSON.stringify({
name: "mastra",
dependencies: {
"@copilotkit/react-core": "1.59.2",
"@mastra/core": "workspace:*",
},
}),
);
const report = validateAll();
expect(report.fail).toEqual([]);
expect(report.skip.some((l) => l.includes("@mastra/core"))).toBe(true);
});
it("ignores non-framework deps (e.g. react, lodash)", () => {
const fx = makeFixture({});
repoRoot = fx.repoRoot;
process.env.VALIDATE_PINS_REPO_ROOT = fx.repoRoot;
write(
path.join(fx.pkgsDir, "mastra", "package.json"),
JSON.stringify({
name: "mastra",
dependencies: {
"@copilotkit/react-core": "1.59.2",
"@mastra/core": "0.15.0",
// Non-framework deps are unconstrained.
react: "^18.2.0",
lodash: "latest",
},
}),
);
const report = validateAll();
expect(report.fail).toEqual([]);
});
it("does NOT depend on examples/integrations — passes with the dir entirely absent", () => {
const fx = makeFixture({});
repoRoot = fx.repoRoot;
process.env.VALIDATE_PINS_REPO_ROOT = fx.repoRoot;
// Notably: do NOT create examples/integrations.
write(
path.join(fx.pkgsDir, "mastra", "package.json"),
JSON.stringify({
name: "mastra",
dependencies: { "@copilotkit/react-core": "1.59.2" },
}),
);
const report = validateAll();
expect(report.fail).toEqual([]);
});
});
@@ -0,0 +1,119 @@
// Split from validate-pins.test.ts — see validate-pins.shared.ts header
// for the full rationale (vitest birpc 60s cliff, fork-per-file).
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import fs from "fs";
import path from "path";
import { spawnSync } from "child_process";
import { VALIDATE_PINS_SCRIPT, tmpdir, write } from "./validate-pins.shared.js";
function writeCanonical(
repoRoot: string,
version = "1.59.2",
overrides: Record<string, Record<string, string>> = {},
): void {
write(
path.join(repoRoot, "showcase", "scripts", "showcase-canonical-pins.json"),
JSON.stringify({ canonicalCopilotKitVersion: version, overrides }),
);
}
describe("validate-pins CLI exit codes", () => {
let repoRoot: string;
beforeEach(() => {
repoRoot = tmpdir();
fs.mkdirSync(path.join(repoRoot, "showcase", "integrations"), {
recursive: true,
});
writeCanonical(repoRoot);
});
afterEach(() => {
fs.rmSync(repoRoot, { recursive: true, force: true });
});
function runCli() {
return spawnSync("npx", ["tsx", VALIDATE_PINS_SCRIPT], {
encoding: "utf-8",
env: { ...process.env, VALIDATE_PINS_REPO_ROOT: repoRoot },
timeout: 30_000,
});
}
it("exits 1 when FAIL>0 (real drift: non-exact pin)", () => {
const slug = "mastra";
const pkgDir = path.join(repoRoot, "showcase", "integrations", slug);
fs.mkdirSync(pkgDir, { recursive: true });
write(
path.join(pkgDir, "package.json"),
JSON.stringify({
name: slug,
dependencies: { "@mastra/core": "next" },
}),
);
const r = runCli();
expect(r.status, r.stdout + r.stderr).toBe(1);
});
it("exits 0 when clean (all framework deps exact-pinned and @copilotkit canonical)", () => {
const slug = "mastra";
const pkgDir = path.join(repoRoot, "showcase", "integrations", slug);
fs.mkdirSync(pkgDir, { recursive: true });
write(
path.join(pkgDir, "package.json"),
JSON.stringify({
name: slug,
dependencies: {
"@copilotkit/react-core": "1.59.2",
"@mastra/core": "0.15.0",
},
}),
);
const r = runCli();
expect(r.status, r.stdout + r.stderr).toBe(0);
});
});
describe("validate-pins CLI exit code 3 (unreadable input)", () => {
it("exits 3 when VALIDATE_PINS_REPO_ROOT points at a non-directory", () => {
const tmp = tmpdir();
try {
const filePath = path.join(tmp, "not-a-dir");
fs.writeFileSync(filePath, "x");
const r = spawnSync("npx", ["tsx", VALIDATE_PINS_SCRIPT], {
encoding: "utf-8",
env: { ...process.env, VALIDATE_PINS_REPO_ROOT: filePath },
timeout: 30_000,
});
expect(r.status, r.stdout + r.stderr).toBe(3);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
});
describe("module import does not invoke main()", () => {
it("importing the module exits cleanly (status 0)", () => {
// Use tsx's module loader via a small inline program that imports
// validate-pins.js. If main() were invoked on import, the process
// would exit with the validator's report status (likely 1 in this
// test environment), NOT 0.
const prog = `import(${JSON.stringify(
VALIDATE_PINS_SCRIPT,
)}).then(() => process.exit(0)).catch((e) => { console.error(e); process.exit(42); });`;
const r = spawnSync("npx", ["tsx", "-e", prog], {
encoding: "utf-8",
env: {
...process.env,
// Point REPO_ROOT at this worktree so computeRepoRoot's override
// validation passes; the value is irrelevant because main() must
// not run.
VALIDATE_PINS_REPO_ROOT: path.resolve(__dirname, "..", "..", ".."),
},
timeout: 30_000,
});
expect(r.status, r.stdout + r.stderr).toBe(0);
});
});
@@ -0,0 +1,262 @@
// Split from validate-pins.test.ts — see validate-pins.shared.ts header
// for the full rationale (vitest birpc 60s cliff, fork-per-file).
//
// This file covers EACCES / infra-class fault routing in validateAll's
// showcase-side scan (the validator no longer reads `examples/` so the
// examples-side EACCES test was retired).
import { describe, it, expect } from "vitest";
import fs from "fs";
import os from "os";
import path from "path";
import { spawnSync } from "child_process";
import {
VALIDATE_PINS_SCRIPT,
write,
withTmp,
} from "./validate-pins.shared.js";
// Minimal valid canonical-pins.json wired into a tmp repo so validateAll
// reaches the slug loop instead of bailing on the config-load step. Tests
// that care about a NON-default canonical version can pass an override.
function writeCanonicalPins(repoRoot: string, version = "1.59.2"): void {
write(
path.join(repoRoot, "showcase", "scripts", "showcase-canonical-pins.json"),
JSON.stringify({ canonicalCopilotKitVersion: version, overrides: {} }),
);
}
const isRoot = process.getuid?.() === 0;
// Some filesystems (e.g. certain CI mounts, network shares) ignore
// chmod 0000 and keep a dir readable even to non-root processes. Probe
// at module scope so the skip is visible in the test report rather than
// a silent early `return` inside `it()`.
function probeChmodEnforced(): boolean {
if (isRoot) return false;
let probeDir: string | undefined;
try {
probeDir = fs.mkdtempSync(path.join(os.tmpdir(), "vp-chmod-probe-"));
fs.chmodSync(probeDir, 0o000);
try {
fs.readdirSync(probeDir);
return false;
} catch (e) {
const err = e as NodeJS.ErrnoException;
return err.code === "EACCES";
}
} catch {
return false;
} finally {
if (probeDir) {
try {
fs.chmodSync(probeDir, 0o755);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(
`[validate-pins test] failed to restore perms on probe dir ${probeDir} (possible leak): ${msg}`,
);
}
try {
fs.rmSync(probeDir, { recursive: true, force: true });
} catch {
// best-effort
}
}
}
}
const chmodEnforced = probeChmodEnforced();
const cannotEnforceEacces = isRoot || !chmodEnforced;
describe("validateAll: infra parse error routes to EXIT_UNREADABLE", () => {
it.skipIf(cannotEnforceEacces)(
"EACCES on showcase package dir exits 3, not 1",
() => {
withTmp((tmp) => {
writeCanonicalPins(tmp);
const slug = "mastra";
const packagesDir = path.join(tmp, "showcase", "integrations");
const pkgSlugDir = path.join(packagesDir, slug);
fs.mkdirSync(pkgSlugDir, { recursive: true });
write(
path.join(pkgSlugDir, "package.json"),
JSON.stringify({ name: slug, dependencies: {} }),
);
// Make the package dir itself unreadable so statSync on its
// entries fails. The infra=true branch in collectDepsFromDir
// routes through UnreadableInputError → EXIT_UNREADABLE (3).
fs.chmodSync(pkgSlugDir, 0o000);
try {
const r = spawnSync("npx", ["tsx", VALIDATE_PINS_SCRIPT], {
env: {
...process.env,
VALIDATE_PINS_REPO_ROOT: tmp,
},
encoding: "utf-8",
timeout: 30_000,
});
expect(r.status, r.stdout + r.stderr).toBe(3);
} finally {
try {
fs.chmodSync(pkgSlugDir, 0o755);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(
`[validate-pins test] failed to restore perms on ${pkgSlugDir} (possible leak): ${msg}`,
);
}
}
});
},
);
});
describe("validateAll: readFileSync EACCES routes to EXIT_UNREADABLE", () => {
it.skipIf(cannotEnforceEacces)(
"exits 3 when a showcase package.json is statable but unreadable (mode 0000 file, readable parent)",
() => {
withTmp((tmp) => {
writeCanonicalPins(tmp);
const slug = "mastra";
const packagesDir = path.join(tmp, "showcase", "integrations");
const pkgSlugDir = path.join(packagesDir, slug);
fs.mkdirSync(pkgSlugDir, { recursive: true });
const pkgFile = path.join(pkgSlugDir, "package.json");
write(pkgFile, JSON.stringify({ name: slug, dependencies: {} }));
// chmod 0000 on the FILE itself (parent readable). statSync on
// the file succeeds (metadata is in the parent dir inode), so
// the stat guard in collectDepsFromDir passes and the error
// surfaces from readFileSync instead. This exercises the
// classification at the parsePackageJson catch site.
fs.chmodSync(pkgFile, 0o000);
try {
const r = spawnSync("npx", ["tsx", VALIDATE_PINS_SCRIPT], {
env: {
...process.env,
VALIDATE_PINS_REPO_ROOT: tmp,
},
encoding: "utf-8",
timeout: 30_000,
});
expect(r.status, r.stdout + r.stderr).toBe(3);
} finally {
try {
fs.chmodSync(pkgFile, 0o644);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(
`[validate-pins test] failed to restore perms on ${pkgFile} (possible leak): ${msg}`,
);
}
}
});
},
);
});
describe("validateAll: readdirSync(PACKAGES_DIR) failure routes to EXIT_UNREADABLE", () => {
it.skipIf(cannotEnforceEacces)(
"exits 3 when PACKAGES_DIR is traverseable but not readable",
() => {
withTmp((tmp) => {
writeCanonicalPins(tmp);
const packagesDir = path.join(tmp, "showcase", "integrations");
fs.mkdirSync(packagesDir, { recursive: true });
// Create one slug inside so the dir isn't legitimately empty —
// we want readdir to FAIL, not return [].
fs.mkdirSync(path.join(packagesDir, "mastra"), { recursive: true });
// Mode 0o111: executable (traverseable) but not readable. statSync
// succeeds (metadata is in the parent) but readdirSync throws EACCES.
fs.chmodSync(packagesDir, 0o111);
try {
const r = spawnSync("npx", ["tsx", VALIDATE_PINS_SCRIPT], {
env: {
...process.env,
VALIDATE_PINS_REPO_ROOT: tmp,
},
encoding: "utf-8",
timeout: 30_000,
});
expect(r.status, r.stdout + r.stderr).toBe(3);
} finally {
try {
fs.chmodSync(packagesDir, 0o755);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(
`[validate-pins test] failed to restore perms on ${packagesDir} (possible leak): ${msg}`,
);
}
}
});
},
);
});
describe("validateAll: missing packages dir routes to EXIT_UNREADABLE", () => {
it("exits 3 when showcase/integrations does not exist", () => {
withTmp((tmp) => {
writeCanonicalPins(tmp);
const r = spawnSync("npx", ["tsx", VALIDATE_PINS_SCRIPT], {
env: {
...process.env,
VALIDATE_PINS_REPO_ROOT: tmp,
},
encoding: "utf-8",
timeout: 30_000,
});
// A missing packages dir is not drift — it's a repo-structure
// error. Must be EXIT_UNREADABLE (3), not EXIT_DRIFT (1).
expect(r.status, r.stdout + r.stderr).toBe(3);
});
});
});
describe("validateAll: empty packages dir routes to EXIT_UNREADABLE", () => {
it("exits 3 when showcase/integrations exists but contains no slugs", () => {
withTmp((tmp) => {
writeCanonicalPins(tmp);
// Create showcase/integrations but leave it empty. readdir returns [].
fs.mkdirSync(path.join(tmp, "showcase", "integrations"), {
recursive: true,
});
const r = spawnSync("npx", ["tsx", VALIDATE_PINS_SCRIPT], {
env: {
...process.env,
VALIDATE_PINS_REPO_ROOT: tmp,
},
encoding: "utf-8",
timeout: 30_000,
});
expect(r.status, r.stdout + r.stderr).toBe(3);
});
});
});
describe("validateAll: missing canonical-pins config routes to EXIT_UNREADABLE", () => {
it("exits 3 when showcase-canonical-pins.json is not present", () => {
withTmp((tmp) => {
// Deliberately do NOT write canonical-pins. Create the packages
// dir so the early packages-dir guard doesn't fire first.
fs.mkdirSync(path.join(tmp, "showcase", "integrations"), {
recursive: true,
});
const r = spawnSync("npx", ["tsx", VALIDATE_PINS_SCRIPT], {
env: {
...process.env,
VALIDATE_PINS_REPO_ROOT: tmp,
},
encoding: "utf-8",
timeout: 30_000,
});
// Missing config is a repo-structure / configuration problem,
// same class as a missing packages dir → EXIT_UNREADABLE (3).
expect(r.status, r.stdout + r.stderr).toBe(3);
});
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,49 @@
// Shared helpers for validate-pins.*.test.ts files.
//
// These tests were originally a single multi-thousand-line file. They
// were split per describe-category to keep each file comfortably under
// vitest's hardcoded 60s birpc `onTaskUpdate` RPC window (upstream
// vitest #6129 — `DEFAULT_TIMEOUT = 6e4` in the bundled birpc). With
// `pool: 'forks'` (see showcase/scripts/vitest.config.ts) each file
// gets its own fresh worker + its own fresh 60s RPC budget.
//
// This module hosts the tmpdir / write / withTmp helpers and the
// FIXTURES_DIR / VALIDATE_PINS_SCRIPT path constants so we don't
// duplicate them across split files.
import fs from "fs";
import os from "os";
import path from "path";
import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export const FIXTURES_DIR = path.resolve(__dirname, "fixtures", "pins");
export const VALIDATE_PINS_SCRIPT = path.resolve(
__dirname,
"..",
"validate-pins.ts",
);
export function tmpdir(prefix = "validate-pins-"): string {
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
}
export function write(file: string, body: string): void {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, body, "utf-8");
}
/**
* Safe-cleanup helper: call body() but always rm -rf tmp in finally so
* an assertion failure doesn't leak temp directories into /tmp.
*/
export function withTmp<T>(body: (tmp: string) => T): T {
const tmp = tmpdir();
try {
return body(tmp);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
}
@@ -0,0 +1,42 @@
import { describe, it, expect } from "vitest";
import path from "path";
import { validateIntegration } from "../validate-runtime-routes.js";
// A fixture integration under fixtures/route-wiring models the OSS-451 shape:
// - shipped-ok (in features) → route exists → OK
// - base-route (in features) → runtimeUrl "/api/copilotkit" exists → OK
// - shipped-broken (in features) → route MISSING → VIOLATION (the OSS-451 bug)
// - unshipped-broken (NOT in features) → route MISSING → skipped (unshipped)
const FIXTURE = path.resolve(__dirname, "fixtures", "route-wiring");
describe("runtime-route wiring validator", () => {
const violations = validateIntegration(FIXTURE);
it("flags exactly the shipped demo whose route is missing (the OSS-451 class)", () => {
expect(violations).toHaveLength(1);
expect(violations[0].demo).toBe("shipped-broken");
expect(violations[0].runtimeUrl).toBe("/api/copilotkit-shipped-broken");
expect(violations[0].integration).toBe("route-wiring");
});
it("does NOT flag a shipped demo whose dedicated route exists", () => {
expect(violations.some((v) => v.demo === "shipped-ok")).toBe(false);
});
it("does NOT flag a shipped demo pointing at the shared /api/copilotkit route", () => {
expect(violations.some((v) => v.demo === "base-route")).toBe(false);
});
it("skips unshipped demos (not in manifest features) even when their route is missing", () => {
// unshipped-broken has a missing route but is intentionally not gated —
// it is not claimed to work. Promoting it into `features` would start
// enforcing it, catching the break at that PR.
expect(violations.some((v) => v.demo === "unshipped-broken")).toBe(false);
});
it("emits a stable baseline key for each violation", () => {
expect(violations[0].key).toBe(
"route-wiring:shipped-broken:/api/copilotkit-shipped-broken",
);
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,389 @@
import { describe, expect, it } from "vitest";
import {
asHost,
parseArgs,
resolveProbeTargets,
runVerify,
} from "../verify-deploy";
import type { ProbeRunner } from "../verify-deploy";
import { SERVICES, probeEnabled } from "../railway-envs";
describe("verify-deploy argv parsing", () => {
it("requires --env", () => {
expect(() => parseArgs([])).toThrow(/--env/);
});
it("accepts --env staging and --env prod", () => {
expect(parseArgs(["--env", "staging"]).env).toBe("staging");
expect(parseArgs(["--env=prod"]).env).toBe("prod");
});
it("rejects unknown envs", () => {
expect(() => parseArgs(["--env", "dev"])).toThrow(/Unknown env/);
});
it("accepts optional --services CSV", () => {
const parsed = parseArgs(["--env", "staging", "--services", "docs,shell"]);
expect(parsed.services).toEqual(["docs", "shell"]);
});
it("rejects empty --services= equals-form (mirrors space-form behavior)", () => {
expect(() => parseArgs(["--env", "staging", "--services="])).toThrow(
/--services/,
);
});
it("rejects --services space-form whose CSV is all empty entries (symmetry with equals-form)", () => {
// Bug: space-form previously only guarded `!v` on the raw next-arg,
// so `--services ,,` produced an empty list that fell through to a
// less-precise zero-targets error. Both forms must throw the same
// precise `--services requires a CSV value` here.
expect(() => parseArgs(["--env=staging", "--services", ",,"])).toThrow(
/--services requires a CSV value/,
);
});
it("defaults skipIneligible to true (skip-by-default — known-but-ineligible names are a legitimate state)", () => {
const parsed = parseArgs(["--env", "staging"]);
expect(parsed.skipIneligible).toBe(true);
});
it("accepts --skip-ineligible as an explicit no-op (now the default; back-compat with the staging precondition caller)", () => {
const parsed = parseArgs([
"--env",
"staging",
"--services",
"docs",
"--skip-ineligible",
]);
expect(parsed.skipIneligible).toBe(true);
});
it("accepts --strict-eligibility to opt OUT of skip-by-default (restore hard-refuse)", () => {
const parsed = parseArgs([
"--env",
"prod",
"--services",
"harness-workers",
"--strict-eligibility",
]);
expect(parsed.skipIneligible).toBe(false);
});
it("rejects bare trailing --env (no following value)", () => {
// Bug: `argv[++i]` was undefined and we deferred to a vague
// `resolveEnv` error. Must throw the precise message here.
expect(() => parseArgs(["--env"])).toThrow(
/--env requires a value \(staging\|prod\)/,
);
});
it("rejects --env= with empty value (symmetry with bare trailing --env)", () => {
expect(() => parseArgs(["--env="])).toThrow(
/--env requires a value \(staging\|prod\)/,
);
});
});
describe("resolveProbeTargets", () => {
it("filters SSOT to entries where probe[env] is true", () => {
const targets = resolveProbeTargets({ env: "staging" });
// docs is staging:true → must be present.
expect(targets.find((t) => t.name === "docs")).toBeDefined();
});
it("REFUSES when a probe-required service has no domain for the env", () => {
expect(() =>
resolveProbeTargets({
env: "staging",
overrides: {
docs: { domains: { staging: "", prod: "docs.copilotkit.ai" } },
},
}),
).toThrow(/missing.*staging.*domain/i);
});
it("honors --services filter (subset of probe-eligible)", () => {
const targets = resolveProbeTargets({
env: "staging",
services: ["docs"],
});
expect(targets.length).toBe(1);
expect(targets[0].name).toBe("docs");
});
it("REFUSES a typo'd service name (unknown service, not silent drop)", () => {
expect(() =>
resolveProbeTargets({ env: "staging", services: ["docss"] }),
).toThrow(/unknown service.*docss/i);
});
it("REFUSES a service that exists in SSOT but is not probe-eligible for the env", () => {
// Find a service whose probe[staging] is false.
// Use override seam to flip probe state without mutating SSOT.
// Since resolveProbeTargets doesn't expose a probe-flag override,
// we pick a real service name and an env where the SSOT probe is
// false. Search SERVICES for an entry where probe.staging===false.
// If none exists, this test still validates the error string for
// the more common typo case via the prior test; we focus on the
// distinct error phrasing.
//
// Practical assertion: an unknown name surfaces as "unknown
// service", which is structurally a different (clearer) error
// than "not probe-eligible". The two paths must be distinguished.
expect(() =>
resolveProbeTargets({ env: "staging", services: ["totally-fake"] }),
).toThrow(/unknown service/i);
});
it("REFUSES a non-probe-eligible service when skipIneligible is unset (function-level strict; CLI passes skipIneligible=true)", () => {
// The resolveProbeTargets PRIMITIVE stays strict when skipIneligible is
// not passed — an explicit caller that wants the hard refusal (or the
// CLI's `--strict-eligibility` opt-out) gets the distinct "not
// probe-eligible" error. The CLI itself now defaults skipIneligible=true
// (see parseArgs), so the verify-prod gate composes; this test pins the
// primitive's unset-flag contract, not the CLI default.
const ineligible = Object.keys(SERVICES).find(
(n) => SERVICES[n] !== undefined && !probeEnabled(n, "staging"),
);
expect(ineligible).toBeDefined();
expect(() =>
resolveProbeTargets({ env: "staging", services: [ineligible as string] }),
).toThrow(/not probe-eligible/i);
});
it("SKIPS non-probe-eligible services (skipIneligible) instead of crashing, keeping eligible ones", () => {
// The promote precondition probes the FULL promote set (service=all),
// which legitimately includes non-probe-eligible starters
// (probe.staging=false). Those must be SKIPPED, not crash the run.
const ineligible = Object.keys(SERVICES).find(
(n) => SERVICES[n] !== undefined && !probeEnabled(n, "staging"),
);
expect(ineligible).toBeDefined();
// "docs" is probe.staging=true — must survive the skip filter.
const targets = resolveProbeTargets({
env: "staging",
services: ["docs", ineligible as string],
skipIneligible: true,
});
const names = targets.map((t) => t.name);
expect(names).toContain("docs");
expect(names).not.toContain(ineligible);
});
it("still REFUSES an unknown service even with skipIneligible (typo is a real error, not a skip)", () => {
// skipIneligible only relaxes the probe.staging=false case; a name
// that is not in the SSOT at all is still a hard error.
expect(() =>
resolveProbeTargets({
env: "staging",
services: ["docs", "totally-fake"],
skipIneligible: true,
}),
).toThrow(/unknown service/i);
});
it("REFUSES an override domain carrying a scheme (ingress branding wired)", () => {
// The override seam in `resolveProbeTargets` bypasses `domainFor`,
// so `asHost` is the sole ingress validator on that path. A
// scheme-bearing override must surface as a hard throw, proving
// the `asHost(rawHost)` call is actually wired in.
expect(() =>
resolveProbeTargets({
env: "staging",
services: ["docs"],
overrides: {
docs: {
domains: { staging: "https://docs.test", prod: "docs.test" },
},
},
}),
).toThrow(/scheme/i);
});
it("REFUSES an override domain carrying a path/slash (ingress branding wired)", () => {
expect(() =>
resolveProbeTargets({
env: "staging",
services: ["docs"],
overrides: {
docs: {
domains: { staging: "docs.test/path", prod: "docs.test" },
},
},
}),
).toThrow(/path|slash/i);
});
});
describe("asHost validator", () => {
it("accepts a bare hostname literal", () => {
expect(() => asHost("docs.example.com")).not.toThrow();
// Returned value IS a string at runtime (brand is structural).
const h = asHost("docs.example.com");
expect(typeof h).toBe("string");
expect(h).toBe("docs.example.com");
});
it("rejects values with a scheme separator", () => {
expect(() => asHost("https://x")).toThrow(/scheme/i);
expect(() => asHost("http://docs.example.com")).toThrow(/scheme/i);
});
it("rejects values containing a path or slash", () => {
expect(() => asHost("x/y")).toThrow(/path|slash/i);
expect(() => asHost("docs.example.com/")).toThrow(/path|slash/i);
});
it("rejects the empty string", () => {
expect(() => asHost("")).toThrow(/empty/i);
});
it("rejects leading/trailing whitespace", () => {
expect(() => asHost(" docs.example.com")).toThrow(/whitespace/i);
expect(() => asHost("docs.example.com ")).toThrow(/whitespace/i);
expect(() => asHost("\tdocs.example.com")).toThrow(/whitespace/i);
});
it("rejects userinfo '@'", () => {
expect(() => asHost("user@docs.example.com")).toThrow(/userinfo|@/);
});
it("rejects query '?'", () => {
expect(() => asHost("docs.example.com?x=1")).toThrow(/query|\?/);
});
it("rejects fragment '#'", () => {
expect(() => asHost("docs.example.com#frag")).toThrow(/fragment|#/);
});
it("rejects ASCII control characters (newline, CR, NUL, tab inside)", () => {
// These would survive the trim() check (because the offending
// char is interior, not leading/trailing) but must still be
// caught by the explicit control-char rule. NUL is the classic
// injection vector; newline/CR can cause header smuggling at any
// downstream `https://${host}/...` composition.
expect(() => asHost("docs.example\ncom")).toThrow(/control/i);
expect(() => asHost("docs.example\rcom")).toThrow(/control/i);
expect(() => asHost("docs.example\x00com")).toThrow(/control/i);
expect(() => asHost("docs.example\x7fcom")).toThrow(/control/i);
expect(() => asHost("docs.example\tcom")).toThrow(/control/i);
});
it("rejects a ':port' suffix", () => {
// domainFor() returns bare hostnames; ports are not part of the
// verify-pipeline contract. Reject with a precise diagnostic.
expect(() => asHost("docs.example.com:8080")).toThrow(/port/i);
expect(() => asHost("localhost:3000")).toThrow(/port/i);
});
it("rejects characters outside the DNS-label charset", () => {
// Underscore, unicode — none are in [A-Za-z0-9.-]. Leading/trailing
// whitespace is caught earlier by the trim check; interior
// whitespace is rejected here by the charset rule. `:` is caught
// by the port suffix check.
expect(() => asHost("docs_example.com")).toThrow(/charset|DNS/);
expect(() => asHost("docs.exämple.com")).toThrow(/charset|DNS/);
expect(() => asHost("docs!example.com")).toThrow(/charset|DNS/);
// Pure positive: real SSOT-shape hostnames still pass.
expect(() => asHost("docs.example.com")).not.toThrow();
expect(() => asHost("a-b.c-d.example")).not.toThrow();
expect(() => asHost("harness-staging-2ee4.up.railway.app")).not.toThrow();
});
});
describe("runVerify driver dispatch", () => {
it("calls the driver for each target and fails loud on any red", async () => {
const calls: string[] = [];
const runner: ProbeRunner = async (target) => {
calls.push(`${target.driver}:${target.host}`);
if (target.name === "docs") {
return { ok: false, error: "DOM string missing" };
}
return { ok: true };
};
const summary = await runVerify({
env: "staging",
services: ["docs", "shell"],
runner,
});
expect(calls).toContain("docs:docs.staging.copilotkit.ai");
expect(calls).toContain("shell:showcase.staging.copilotkit.ai");
expect(summary.failed.map((f) => f.name)).toEqual(["docs"]);
expect(summary.exitCode).toBe(1);
});
it("exits 0 when all probes green", async () => {
const summary = await runVerify({
env: "staging",
services: ["docs"],
runner: async () => ({ ok: true }),
});
expect(summary.exitCode).toBe(0);
});
it("with skipIneligible, exits 0 probing only eligible services when the set mixes in non-eligible ones", async () => {
// Mirrors the promote precondition `service=all` shape: a
// probe-eligible service (docs) mixed with a probe.staging=false
// service (a starter-*). The eligible one is probed normally; the
// ineligible one is skipped — no crash, exit 0 when greens pass.
const ineligible = Object.keys(SERVICES).find(
(n) => SERVICES[n] !== undefined && !probeEnabled(n, "staging"),
);
expect(ineligible).toBeDefined();
const calls: string[] = [];
const runner: ProbeRunner = async (target) => {
calls.push(target.name);
return { ok: true };
};
const summary = await runVerify({
env: "staging",
services: ["docs", ineligible as string],
skipIneligible: true,
runner,
});
expect(calls).toContain("docs");
expect(calls).not.toContain(ineligible);
expect(summary.exitCode).toBe(0);
});
it("exits 0 (nothing to probe) when EVERY requested service is known-but-ineligible (verify-prod prod-only case)", async () => {
// The verify-prod gate calls verify-deploy directly with the promoted
// set, which can be entirely probe-ineligible services (e.g. just
// `harness-workers`, probe.prod=false). All are skipped → zero targets,
// but this is an EXPECTED no-op, NOT the vacuous-green fault: exit 0 with
// a clear "nothing to probe" note. Distinct from the empty-filter FAIL
// case below (length 0 there; here length>0 + all-ineligible).
const ineligible = Object.keys(SERVICES).find(
(n) => SERVICES[n] !== undefined && !probeEnabled(n, "prod"),
);
expect(ineligible).toBeDefined();
let probed = false;
const summary = await runVerify({
env: "prod",
services: [ineligible as string],
skipIneligible: true,
runner: async () => {
probed = true;
return { ok: true };
},
});
expect(probed).toBe(false);
expect(summary.exitCode).toBe(0);
expect(summary.failed.length).toBe(0);
});
it("FAILS LOUD on zero resolved targets (never silently exit 0)", async () => {
// resolveProbeTargets now throws on unknown service names, so the
// zero-target shape can only happen via the API (empty filter
// set). We exercise the runVerify guard directly: a runner that's
// never called and an exit code of 1 with a clear diagnostic.
const summary = await runVerify({
env: "staging",
services: [],
runner: async () => ({ ok: true }),
});
expect(summary.exitCode).not.toBe(0);
expect(summary.failed.length).toBeGreaterThan(0);
});
});
@@ -0,0 +1,75 @@
#!/usr/bin/env bats
# Tests for verify-prod-display.sh — derives the verify-prod value shown in the
# promote-run Slack alert.
#
# The bug this guards (see run 27144525566 follow-up): the verify-prod job's
# empty-CSV skip branch exits 0, so the GitHub job `result` is `success` even
# though prod was NEVER probed. The notify step used to read that raw `result`
# and rendered a misleading `verify-prod=success`. The display value must
# instead distinguish:
# * success — prod was actually probed and passed (job result success +
# status output "success").
# * skipped — nothing promoted, so prod verification was skipped (job result
# success + status output "skipped").
# * failure — a real probe failure or contract violation (job result
# failure; the status output was never written).
# * <result> — any other job result (cancelled, etc.) passes through.
#
# NB on assertion gating: bats does NOT run test bodies under `set -e`. Only the
# FINAL command decides pass/fail, so every non-final assertion is written
# `[[ ... ]] || fail "msg"` to force a hard failure with a diagnostic.
fail() {
echo "$1" >&2
return 1
}
setup() {
SCRIPT="$BATS_TEST_DIRNAME/../verify-prod-display.sh"
[ -x "$SCRIPT" ] || fail "verify-prod-display.sh missing or not executable at $SCRIPT"
}
# run_display <PROD> <PROD_STATUS> — invoke the script with the two inputs and
# capture stdout into $output (bats convention).
run_display() {
PROD="$1" PROD_STATUS="$2" run "$SCRIPT"
}
@test "prod probed and passed -> success" {
run_display "success" "success"
[ "$status" -eq 0 ] || fail "expected exit 0, got $status"
[ "$output" = "success" ] || fail "expected 'success', got '$output'"
}
@test "nothing promoted, prod skipped -> skipped (not a misleading success)" {
run_display "success" "skipped"
[ "$status" -eq 0 ] || fail "expected exit 0, got $status"
[ "$output" = "skipped" ] || fail "expected 'skipped', got '$output'"
}
@test "real probe failure -> failure (status output never written)" {
run_display "failure" ""
[ "$status" -eq 0 ] || fail "expected exit 0, got $status"
[ "$output" = "failure" ] || fail "expected 'failure', got '$output'"
}
@test "cancelled job result passes through" {
run_display "cancelled" ""
[ "$status" -eq 0 ] || fail "expected exit 0, got $status"
[ "$output" = "cancelled" ] || fail "expected 'cancelled', got '$output'"
}
@test "skipped job result (verify-prod never ran) passes through" {
run_display "skipped" ""
[ "$status" -eq 0 ] || fail "expected exit 0, got $status"
[ "$output" = "skipped" ] || fail "expected 'skipped', got '$output'"
}
@test "defensive: job result success but status output empty -> falls back to result" {
# If verify-prod somehow exits 0 without writing status (should not happen,
# but be robust), do NOT fabricate a 'success'/'skipped' — surface the raw
# job result so the signal is never invented.
run_display "success" ""
[ "$status" -eq 0 ] || fail "expected exit 0, got $status"
[ "$output" = "success" ] || fail "expected fallback to 'success', got '$output'"
}
@@ -0,0 +1,433 @@
/**
* Tests for the Railway image-ref gate (`verify-railway-image-refs.ts`)
* and the SSOT fields it consumes (`railway-envs.ts`).
*
* Style note: validators are pure and exported; the GraphQL fetch is
* the only impure surface and is exercised manually (per the script's
* docstring). We unit-test the pure validators against synthesized
* inputs — no Railway API calls.
*/
import { describe, it, expect } from "vitest";
import {
findMissingServices,
findUntrackedServices,
isStarterFleetService,
summarizeFailures,
validateImage,
} from "../verify-railway-image-refs";
import { SERVICES, repoNameFor } from "../railway-envs";
import type { ServiceEntry } from "../railway-envs";
describe("ServiceEntry gateIgnore field", () => {
it("is optional on the type and defaults to falsy when unset", () => {
// Every real SSOT entry has gateIgnore unset (undefined / falsy). There is
// no longer ANY gateIgnore:true entry: the `harness-workers` pool-fleet
// worker, formerly the sole gate-ignored (staging-only) service, has been
// backfilled as a dual-env (prod + staging) gateValidated:true service —
// both env entries carry an explicit `repoName: "showcase-harness"`, so it
// now fits the gate's image-ref shape and the opt-out is dropped.
// See its SSOT entry in railway-envs.ts for the rationale.
// `showcase-strands-typescript` is now provisioned dual-env
// (gateValidated:true, no gateIgnore), so it falls into the default-falsy
// branch below. S2: the 12 starter-<slug> services are likewise NO LONGER
// gate-ignored — they are fully gate-managed (gateValidated, no
// gateIgnore), exactly like every showcase-* agent.
const GATE_IGNORED = new Set<string>([]);
const isGateIgnored = (name: string): boolean => GATE_IGNORED.has(name);
for (const [name, entry] of Object.entries(SERVICES)) {
const gi = (entry as ServiceEntry).gateIgnore;
if (isGateIgnored(name)) {
expect(gi, `${name} gateIgnore`).toBe(true);
continue;
}
expect(gi === undefined || gi === false, `${name} gateIgnore`).toBe(true);
}
});
});
describe("findUntrackedServices (Railway -> SSOT direction)", () => {
it("returns empty when every Railway-reported service is in the SSOT", () => {
// The SSOT keys themselves are by definition all in the SSOT, so
// passing them as "Railway-reported" should yield zero untracked.
const all = new Set(Object.keys(SERVICES));
expect(findUntrackedServices(all)).toEqual([]);
});
it("flags a Railway service that is not in the SSOT", () => {
const railway = new Set<string>([
"showcase-mastra", // tracked
"phantom-relay", // untracked
]);
expect(findUntrackedServices(railway)).toEqual(["phantom-relay"]);
});
it("returns names sorted for stable output", () => {
const railway = new Set<string>([
"zeta-svc",
"alpha-svc",
"showcase-mastra",
]);
expect(findUntrackedServices(railway)).toEqual(["alpha-svc", "zeta-svc"]);
});
it("tolerates the 12 SSOT-managed starters via the normal SSOT-membership branch (S2)", () => {
// S2: starter-langgraph-python / starter-mastra are now SSOT entries, so
// they are tolerated by the `if (entry) continue` SSOT-membership branch
// exactly like every other tracked service — NOT by the starter carve-out.
const railway = new Set<string>([
"showcase-mastra", // tracked
"starter-langgraph-python", // SSOT-managed starter (S2) — tracked
"starter-mastra", // SSOT-managed starter (S2) — tracked
]);
expect(findUntrackedServices(railway)).toEqual([]);
});
it("tolerates a NON-SSOT `starter-*` live service via the narrow carve-out", () => {
// The narrowed carve-out only covers a stray/in-flight starter that is
// NOT (yet) in the SSOT — e.g. a brand-new starter slug provisioned ahead
// of its SSOT entry. `starter-experimental-xyz` has no SSOT entry, so it
// falls past the SSOT-membership branch into isStarterFleetService() and
// is tolerated (the starter_smoke probe auto-discovers it by prefix).
const railway = new Set<string>([
"showcase-mastra", // tracked
"starter-experimental-xyz", // non-SSOT starter — narrow carve-out
]);
expect(findUntrackedServices(railway)).toEqual([]);
});
it("STILL flags a real (non-starter) untracked Railway service", () => {
// Drift detection for the tracked fleet must be preserved: a genuine
// out-of-band service (here a rogue `showcase-*`) is still a hard fail
// even when a tolerated starter-* service is present alongside it.
const railway = new Set<string>([
"showcase-mastra", // tracked
"starter-mastra", // SSOT-managed starter — tolerated
"showcase-rogue-untracked", // real drift — must be flagged
]);
expect(findUntrackedServices(railway)).toEqual([
"showcase-rogue-untracked",
]);
});
it("does NOT flag a service that the SSOT marks gateIgnore: true", () => {
// Inject a transient entry into SERVICES for this test, then remove.
const sentinel = "transient-third-party-relay";
(SERVICES as Record<string, ServiceEntry>)[sentinel] = {
serviceId: "00000000-0000-0000-0000-000000000000",
ciBuilt: false,
gateValidated: false,
gateIgnore: true,
probeDriver: "agent",
environments: {
prod: {
instanceId: "11111111-1111-1111-1111-111111111111",
domain: "transient-third-party-relay-production.up.railway.app",
probe: false,
},
staging: {
instanceId: "22222222-2222-2222-2222-222222222222",
domain: "transient-third-party-relay-staging.up.railway.app",
probe: false,
},
},
};
try {
const railway = new Set<string>([sentinel, "showcase-mastra"]);
expect(findUntrackedServices(railway)).toEqual([]);
} finally {
delete (SERVICES as Record<string, ServiceEntry>)[sentinel];
}
});
});
describe("isStarterFleetService predicate", () => {
it("matches names that start with the `starter-` prefix", () => {
expect(isStarterFleetService("starter-mastra")).toBe(true);
expect(isStarterFleetService("starter-langgraph-python")).toBe(true);
expect(isStarterFleetService("starter-")).toBe(true);
});
it("does NOT match tracked showcase / infra service names", () => {
expect(isStarterFleetService("showcase-mastra")).toBe(false);
expect(isStarterFleetService("dashboard")).toBe(false);
expect(isStarterFleetService("pocketbase")).toBe(false);
// The decommissioned `showcase-starter-*` services use the
// `showcase-` prefix, NOT `starter-`, so they are NOT starter-fleet.
expect(isStarterFleetService("showcase-starter-ag2")).toBe(false);
});
});
describe("findMissingServices — starter fleet IS required (S2)", () => {
it("requires the 12 SSOT-managed `starter-*` services like any tracked service", () => {
// S2 reversed the S1 decoupling: starters are gateValidated SSOT entries,
// so findMissingServices DEMANDS them when absent from Railway, exactly
// like a showcase-* agent. A present-set containing only starter-mastra
// must therefore still report starter-langgraph-python (and the showcase
// fleet) as missing — proving the carve-out is gone.
const present = new Set<string>(["starter-mastra"]);
const missing = findMissingServices("prod", present);
// starter-mastra is present → not missing; the other starters ARE.
expect(missing).not.toContain("starter-mastra");
expect(missing).toContain("starter-langgraph-python");
expect(missing).toContain("starter-adk");
// Sanity: the showcase fleet is still required when absent.
expect(missing).toContain("showcase-mastra");
});
});
describe("main() unknown-service policy", () => {
// We exercise the pure helper that main() uses, not main() itself
// (main wraps the live GraphQL call and process.exit; out of scope
// for a unit test).
it("reports an untracked Railway service as a hard violation", () => {
const railwayReported = new Set<string>([
"showcase-mastra",
"rogue-service",
]);
const untracked = findUntrackedServices(railwayReported);
expect(untracked).toContain("rogue-service");
// Hard-fail semantics: any non-empty result must cause the gate
// to exit non-zero. We assert the contract by checking the
// boolean the caller will branch on:
expect(untracked.length > 0).toBe(true);
});
});
describe("summarizeFailures", () => {
it("includes untracked Railway services in the failure block and exits non-zero", () => {
const out = summarizeFailures({
violations: [],
missingByEnv: { prod: [], staging: [] },
untracked: ["phantom-relay"],
checked: 50,
skipped: 0,
});
expect(out.shouldFail).toBe(true);
expect(out.lines.join("\n")).toMatch(/phantom-relay/);
expect(out.lines.join("\n")).toMatch(/not in the SSOT/i);
});
it("does not fail when nothing is wrong", () => {
const out = summarizeFailures({
violations: [],
missingByEnv: { prod: [], staging: [] },
untracked: [],
checked: 54,
skipped: 0,
});
expect(out.shouldFail).toBe(false);
});
it("flags shape violations", () => {
const out = summarizeFailures({
violations: [
{
service: "showcase-mastra",
env: "prod",
image: "ghcr.io/copilotkit/showcase-mastra:latest",
reason: "prod must be pinned to `@sha256:<digest>` (got `:latest`)",
},
],
missingByEnv: { prod: [], staging: [] },
untracked: [],
checked: 50,
skipped: 0,
});
expect(out.shouldFail).toBe(true);
expect(out.lines.join("\n")).toMatch(/showcase-mastra/);
});
it("flags missing services per env", () => {
const out = summarizeFailures({
violations: [],
missingByEnv: { prod: ["showcase-foo"], staging: [] },
untracked: [],
checked: 50,
skipped: 0,
});
expect(out.shouldFail).toBe(true);
expect(out.lines.join("\n")).toMatch(/showcase-foo/);
});
});
describe("WS-C: all gate-managed services gateValidated, with correct overrides", () => {
const FIVE_NEW = [
["dashboard", "showcase-shell-dashboard"],
["docs", "showcase-shell-docs"],
["dojo", "showcase-shell-dojo"],
["shell", "showcase-shell"],
["harness", "showcase-harness"],
] as const;
it("has 41 services in the SSOT (29 showcase/infra + 12 starter-*)", () => {
expect(Object.keys(SERVICES)).toHaveLength(41);
});
it("marks every gate-managed service gateValidated (no Phase-2 holdouts)", () => {
// There is no longer ANY gateIgnore:true / gateValidated:false holdout: the
// `harness-workers` worker, formerly the sole exception, has been
// backfilled as a dual-env gateValidated:true service. S2 brought the 12
// starter-<slug> services UNDER the gate (gateValidated:true);
// `showcase-strands-typescript` is provisioned in prod and gateValidated
// too — so EVERY service must now be gateValidated:true.
const GATE_IGNORED = new Set<string>([]);
const isGateIgnored = (name: string): boolean => GATE_IGNORED.has(name);
const unvalidated = Object.entries(SERVICES)
.filter(([name, entry]) => !entry.gateValidated && !isGateIgnored(name))
.map(([name]) => name);
expect(unvalidated).toEqual([]);
});
for (const [serviceKey, expectedRepo] of FIVE_NEW) {
it(`resolves ${serviceKey} -> ${expectedRepo} for both envs via repoNameFor`, () => {
expect(repoNameFor(serviceKey, "prod")).toBe(expectedRepo);
expect(repoNameFor(serviceKey, "staging")).toBe(expectedRepo);
});
it(`carries the per-env repoName directly on the SERVICES entry for ${serviceKey}`, () => {
const entry = SERVICES[serviceKey];
expect(entry.environments.prod.repoName).toBe(expectedRepo);
expect(entry.environments.staging.repoName).toBe(expectedRepo);
});
}
it("findMissingServices treats all 41 gateValidated services as targets (29 showcase/infra + 12 starters)", () => {
// With nothing "present", every gateValidated service should appear in
// the missing set. After S2 brought the 12 starter-<slug> services under
// the gate (gateValidated:true, dual-env), showcase-strands-typescript
// was provisioned in prod (gateValidated:true), and the prod harness-workers
// backfill flipped that worker to gateValidated:true (dual-env), that means
// all 41 — the 29 showcase/infra gateValidated services plus the 12
// starters. (harness-workers is now gateValidated and dual-env, so it IS
// required in both envs here.)
const missingProd = findMissingServices("prod", new Set<string>());
const missingStaging = findMissingServices("staging", new Set<string>());
expect(missingProd).toHaveLength(41);
expect(missingStaging).toHaveLength(41);
// The 12 starters are now demanded in BOTH envs.
expect(missingProd).toContain("starter-adk");
expect(missingStaging).toContain("starter-mastra");
});
});
describe("WS-C: shape validation for the five newly-gated services", () => {
const PROD_DIGEST = "@sha256:" + "a".repeat(64);
const FIVE_NEW = [
{ key: "dashboard", repo: "showcase-shell-dashboard" },
{ key: "docs", repo: "showcase-shell-docs" },
{ key: "dojo", repo: "showcase-shell-dojo" },
{ key: "shell", repo: "showcase-shell" },
{ key: "harness", repo: "showcase-harness" },
] as const;
for (const { key, repo } of FIVE_NEW) {
it(`${key}: prod requires @sha256, :latest on prod fails`, () => {
const v = validateImage(`ghcr.io/copilotkit/${repo}:latest`, {
env: "prod",
repoName: repo,
});
expect(v).not.toBeNull();
expect(v?.reason).toMatch(/prod must be pinned to `@sha256:<digest>`/);
});
it(`${key}: prod accepts the canonical @sha256 shape`, () => {
const v = validateImage(`ghcr.io/copilotkit/${repo}${PROD_DIGEST}`, {
env: "prod",
repoName: repo,
});
expect(v).toBeNull();
});
it(`${key}: staging accepts :latest on the correct repo`, () => {
const v = validateImage(`ghcr.io/copilotkit/${repo}:latest`, {
env: "staging",
repoName: repo,
});
expect(v).toBeNull();
});
it(`${key}: staging rejects @sha256 (must float on :latest)`, () => {
const v = validateImage(`ghcr.io/copilotkit/${repo}${PROD_DIGEST}`, {
env: "staging",
repoName: repo,
});
expect(v).not.toBeNull();
expect(v?.reason).toMatch(/staging must float on :latest/);
});
it(`${key}: rejects the wrong GHCR repo name on prod`, () => {
// E.g. ghcr.io/copilotkit/dashboard@sha256:... — what the gate
// would see if someone added gateValidated:true without the
// matching repoNameOverride. Repo NAME must match override.
const wrongRepo = `ghcr.io/copilotkit/${key}${PROD_DIGEST}`;
const v = validateImage(wrongRepo, { env: "prod", repoName: repo });
expect(v).not.toBeNull();
expect(v?.reason).toMatch(/image repo name mismatches expected/);
});
it(`${key}: rejects the wrong GHCR repo name on staging`, () => {
const wrongRepo = `ghcr.io/copilotkit/${key}:latest`;
const v = validateImage(wrongRepo, {
env: "staging",
repoName: repo,
});
expect(v).not.toBeNull();
expect(v?.reason).toMatch(/image repo name mismatches expected/);
});
}
});
describe("WS-C: malformed ref negatives", () => {
it("rejects `:sha256-<hex>` on prod (missing the @ separator)", () => {
// Shape: ghcr.io/copilotkit/<repo>:sha256-<hex>
// Looks vaguely like a digest pin but is actually a *tag* whose
// literal name starts with "sha256-". This is the closest shape
// to the 2026-04-21 "atest" corruption and must fail loudly.
const bad = "ghcr.io/copilotkit/showcase-shell:sha256-" + "a".repeat(64);
const v = validateImage(bad, {
env: "prod",
repoName: "showcase-shell",
});
expect(v).not.toBeNull();
expect(v?.image).toBe(bad);
// Reason must mention canonical prod shape so the operator knows
// exactly what to fix.
expect(v?.reason).toMatch(/canonical (prod )?shape/);
});
it("rejects bare `@sha256:<too-short-hex>` on prod", () => {
const bad = "ghcr.io/copilotkit/showcase-shell@sha256:" + "a".repeat(10);
const v = validateImage(bad, {
env: "prod",
repoName: "showcase-shell",
});
expect(v).not.toBeNull();
});
it("rejects a truncated `atest`-style tag on staging", () => {
// The exact 2026-04-21 corruption shape from the script docstring.
const bad = "ghcr.io/copilotkit/showcase-shell-dashboardatest";
const v = validateImage(bad, {
env: "staging",
repoName: "showcase-shell-dashboard",
});
expect(v).not.toBeNull();
});
it("rejects non-ghcr.io registries on both envs", () => {
const prodBad =
"docker.io/copilotkit/showcase-shell@sha256:" + "b".repeat(64);
const stagingBad = "docker.io/copilotkit/showcase-shell:latest";
expect(
validateImage(prodBad, { env: "prod", repoName: "showcase-shell" }),
).not.toBeNull();
expect(
validateImage(stagingBad, {
env: "staging",
repoName: "showcase-shell",
}),
).not.toBeNull();
});
});
+170
View File
@@ -0,0 +1,170 @@
/**
* aggregate-build-results.ts — run in the `aggregate-build-results` job
* of showcase_build.yml AFTER actions/download-artifact has extracted
* every per-slot `build-result-<dispatch_name>` artifact into
* $INPUT_DIR/build-result-<dispatch_name>/result.json.
*
* Responsibilities:
* 1. Read every per-slot result.json under $INPUT_DIR.
* 2. Merge via mergeBuildResultFiles (single source of contract truth).
* 3. Write the canonical $OUTPUT_DIR/results.json (uploaded as the
* `build-results` artifact for cross-workflow consumption).
* 4. Append `results=...` and `any_success=true|false` to $GITHUB_OUTPUT
* so the redeploy-staging guard and the deploy workflow can read
* them as job-level outputs.
*
* No GitHub API calls. No job-name parsing. Pure filesystem aggregation.
*
* Testability: env reading lives in the CLI entrypoint at the bottom;
* the core is exported as `run({inputDir, outputDir, githubOutput})` so
* tests can drive it with temp dirs.
*/
import {
appendFileSync,
mkdirSync,
readFileSync,
readdirSync,
writeFileSync,
} from "node:fs";
import { randomBytes } from "node:crypto";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { mergeBuildResultFiles, successSet } from "./lib/build-outputs";
function requireEnv(name: string): string {
const v = process.env[name];
if (typeof v !== "string" || v.length === 0) {
throw new Error(`aggregate-build-results: $${name} is required`);
}
return v;
}
export interface RunOptions {
inputDir: string;
outputDir: string;
githubOutput: string;
}
/**
* Read a single per-slot result.json. On failure (missing file, permission
* error, etc.), wraps the error with the offending slot directory so the
* job log identifies WHICH slot was the culprit instead of dumping a raw
* ENOENT against a long opaque path. We refuse to silently skip the slot —
* a missing per-slot artifact is a real defect (the build job's artifact
* upload step is broken or the matrix collapsed) and silently dropping it
* would let a failed build masquerade as "not present" downstream.
*/
function readSlotPayload(inputDir: string, slotDirName: string): string {
const path = join(inputDir, slotDirName, "result.json");
try {
return readFileSync(path, "utf-8");
} catch (e) {
const code =
e && typeof e === "object" && "code" in e
? String((e as { code?: unknown }).code)
: e instanceof Error
? e.message
: String(e);
throw new Error(
`aggregate-build-results: ${slotDirName} is missing result.json (${code})`,
{ cause: e },
);
}
}
/**
* Emit the per-job outputs to $GITHUB_OUTPUT. `results` uses the
* multi-line heredoc form, which is the GHA-recommended encoding for
* any value that might contain (or grow to contain) a newline — most
* importantly, it survives pretty-printed JSON or other multi-line
* payloads without truncation. A random delimiter token prevents
* collision with embedded payloads. `any_success` stays a plain
* key=value line since the value is a fixed boolean literal.
*
* Written BEFORE results.json so a $GITHUB_OUTPUT write failure
* (e.g. the file is missing / not writable) aborts before we publish
* an artifact the downstream jobs would consume without seeing the
* matching job output.
*/
function writeGithubOutput(
githubOutput: string,
resultsJson: string,
anySuccess: boolean,
): void {
const delimiter = `EOF_${randomBytes(8).toString("hex")}`;
appendFileSync(
githubOutput,
`results<<${delimiter}\n${resultsJson}\n${delimiter}\n`,
);
appendFileSync(
githubOutput,
`any_success=${anySuccess ? "true" : "false"}\n`,
);
}
export function run(opts: RunOptions): void {
const { inputDir, outputDir, githubOutput } = opts;
mkdirSync(outputDir, { recursive: true });
const slotDirs = readdirSync(inputDir, { withFileTypes: true })
.filter((d) => d.isDirectory() && d.name.startsWith("build-result-"))
.map((d) => d.name);
// The aggregator job is gated upstream on `has_changes == 'true'`, so the
// build matrix is guaranteed non-empty by the time we run. A zero-slot
// input dir therefore signals a BROKEN per-slot artifact download (e.g.
// expired artifacts, wrong run-id, transient download error) — NOT a
// legitimate empty build set. Silently emitting `any_success=false` with
// `results=[]` would be indistinguishable from "all builds failed" and
// would push deploy down the false-green path where it probes the full
// service set against stale `:latest`. Fail loud instead.
if (slotDirs.length === 0) {
throw new Error(
`aggregate-build-results: found 0 build-result-* slot dirs in ${inputDir}` +
`the per-slot artifact download produced nothing; this indicates a broken ` +
`download, not an empty build set (the job only runs when >=1 service was scheduled).`,
);
}
const payloads = slotDirs.map((name) => readSlotPayload(inputDir, name));
const merged = mergeBuildResultFiles(payloads);
const resultsJson = JSON.stringify(merged);
const anySuccess = successSet(merged).length > 0;
// Emit $GITHUB_OUTPUT first so a write failure here doesn't leave a
// published results.json artifact without a matching job output.
writeGithubOutput(githubOutput, resultsJson, anySuccess);
// Trailing newline for consistency with conventional JSON-on-disk
// tooling (POSIX line, diff-friendly).
writeFileSync(join(outputDir, "results.json"), `${resultsJson}\n`);
}
function main(): void {
run({
inputDir: requireEnv("INPUT_DIR"),
outputDir: requireEnv("OUTPUT_DIR"),
githubOutput: requireEnv("GITHUB_OUTPUT"),
});
}
// CLI entrypoint: only run main() when invoked directly (e.g. `tsx
// aggregate-build-results.ts`), NOT when imported by a test. Comparing
// `import.meta.url` against process.argv[1] is the standard ESM idiom.
const invokedDirectly = (() => {
try {
return (
typeof process !== "undefined" &&
Array.isArray(process.argv) &&
process.argv[1] === fileURLToPath(import.meta.url)
);
} catch {
return false;
}
})();
if (invokedDirectly) {
main();
}
+81
View File
@@ -0,0 +1,81 @@
// showcase/scripts/analyze-fixtures.ts
// One-time migration tool: categorize existing monolith fixture files
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
interface FixtureMatch {
provider?: string;
model?: string;
userMessage?: string;
hasToolResult?: boolean;
turnIndex?: number;
toolCallId?: string;
toolName?: string;
context?: string;
}
interface Fixture {
_comment?: string;
match: FixtureMatch;
response: unknown;
}
interface FixtureFile {
_comment?: string;
fixtures: Fixture[];
}
const AIMOCK_DIR = path.resolve(__dirname, "..", "aimock");
// Read the three monolith files
const d5All: FixtureFile = JSON.parse(
readFileSync(path.join(AIMOCK_DIR, "d5-all.json"), "utf-8"),
);
const featureParity: FixtureFile = JSON.parse(
readFileSync(path.join(AIMOCK_DIR, "feature-parity.json"), "utf-8"),
);
const smoke: FixtureFile = JSON.parse(
readFileSync(path.join(AIMOCK_DIR, "smoke.json"), "utf-8"),
);
console.log(`d5-all.json: ${d5All.fixtures.length} fixtures`);
console.log(`feature-parity.json: ${featureParity.fixtures.length} fixtures`);
console.log(`smoke.json: ${smoke.fixtures.length} fixtures`);
// Find duplicates
const seen = new Map<string, { source: string; index: number }>();
let dupeCount = 0;
for (const [file, data] of [
["d5-all.json", d5All],
["feature-parity.json", featureParity],
] as const) {
for (let i = 0; i < data.fixtures.length; i++) {
const key = JSON.stringify(data.fixtures[i].match);
const existing = seen.get(key);
if (existing) {
dupeCount++;
console.log(
`DUPE: ${file}[${i}] == ${existing.source}[${existing.index}] match=${key.slice(0, 80)}...`,
);
} else {
seen.set(key, { source: file, index: i });
}
}
}
console.log(`\nTotal exact duplicates: ${dupeCount}`);
// Categorize by _comment field to identify demo cells
const byComment = new Map<string, Fixture[]>();
for (const f of [...d5All.fixtures, ...featureParity.fixtures]) {
const comment = f._comment ?? "unknown";
const list = byComment.get(comment) ?? [];
list.push(f);
byComment.set(comment, list);
}
console.log(`\nDistinct _comment groups: ${byComment.size}`);
for (const [comment, fixtures] of byComment) {
console.log(` ${comment}: ${fixtures.length} fixtures`);
}
File diff suppressed because it is too large Load Diff
+646
View File
@@ -0,0 +1,646 @@
// Bundle Demo Content
//
// Reads demo source files from all integration packages and produces a JSON
// bundle for the shell's Code tab. The resulting shape is flat:
//
// demos: Record<"<slug>::<demo-id>", {
// readme: string | null,
// files: { filename, language, content, highlighted?, highlightOrder? }[],
// backend_files: [], // retained for shape back-compat
// regions: Record<name, Region>,
// }>
//
// `highlightOrder` mirrors the position of a file inside the manifest's
// `highlight:` array (0-based). Consumers like shell-docs's <DemoSource>
// use it to render tabs in the manifest-author's preferred order rather
// than the bundler's alphabetical fallback. Files not flagged in
// `highlight:` have no `highlightOrder`.
//
// Files are scanned from the demo folder (`src/app/demos/<routeDir>/`)
// recursively, and any files listed in the manifest's `highlight:` field
// that sit OUTSIDE the demo folder (typically `src/agents/<agent>.py`) are
// merged in with their column-relative paths.
//
// Every path in `highlight:` must point to a real bundled file — otherwise
// the bundle fails. This keeps stale references from silently rotting.
//
// Usage: npx tsx showcase/scripts/bundle-demo-content.ts
//
// -----------------------------------------------------------------------------
// Named-region markers (inline, Option A)
// -----------------------------------------------------------------------------
// Authors can tag contiguous spans of a source file with a name so the shell's
// docs pages can pull in a specific snippet without hardcoding line numbers.
//
// Syntax (recognised in any comment style — // or # or <!-- -->):
//
// // @region[provider-setup]
// ... lines belonging to the region ...
// // @endregion[provider-setup]
//
// Rules:
// - Regions may nest (e.g. `@region[outer]` can contain `@region[inner]`).
// - Region names must be `[a-z0-9][a-z0-9-]*`; any marker with a malformed
// name is left untouched and the bundler errors out.
// - When the same region name appears in multiple files inside a cell, the
// bundler concatenates their bodies in the stable file order. This makes
// a "multi-file region" a natural consequence of marker placement rather
// than special syntax.
// - The markers themselves are stripped from the bundled file content so the
// `/code` viewer doesn't show them. The stripped content is what's stored
// in `files[].content`; the original region bodies are stored separately
// in `regions[<name>]`.
// - Start/end line numbers reflect post-strip positions (i.e. the line
// numbers an MDX page would show if it rendered the cleaned file).
// -----------------------------------------------------------------------------
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import yaml from "yaml";
import { findUnexpectedMultiFileRegions } from "./lib/demo-region-guard.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const ROOT = path.resolve(__dirname, "..");
const PACKAGES_DIR = path.join(ROOT, "integrations");
// demo-content is consumed by ALL shells:
// - shell: integration pages + demo drawer read the bundle at runtime
// - shell-docs: <Snippet> (docs routes) imports directly at build time
// - shell-dojo: demo content renders inside the dojo's cell viewer
// so we multi-emit. Paths array is iterated at write time.
const OUTPUT_PATHS = [
path.join(ROOT, "shell", "src", "data", "demo-content.json"),
path.join(ROOT, "shell-docs", "src", "data", "demo-content.json"),
path.join(ROOT, "shell-dojo", "src", "data", "demo-content.json"),
];
interface DemoFile {
filename: string;
language: string;
content: string;
highlighted?: boolean;
/**
* 0-based index of the file inside the manifest's `highlight:` array.
* Present iff `highlighted` is true. Lets consumers render highlighted
* files in author-defined order without re-doing the manifest lookup.
*/
highlightOrder?: number;
}
interface Region {
/** Source file (relative to the demo root / column root for externals). */
file: string;
/** 1-based line number of the first line inside the region (post strip). */
startLine: number;
/** 1-based inclusive line number of the last line inside the region. */
endLine: number;
/** The region's code, markers stripped. */
code: string;
/** Highlight-friendly language hint, propagated from the file extension. */
language: string;
}
interface DemoContent {
readme: string | null;
files: DemoFile[];
// Retained for JSON shape back-compat; always empty under the new rule
// that `/code` shows only the demo folder's actual contents + external
// highlights.
backend_files: DemoFile[];
/**
* Named regions extracted from `// @region[name] … // @endregion[name]`
* markers inside the cell's source files. Keyed by region name.
* Multi-file regions (same name in multiple files) are concatenated in
* the same stable order as `files`.
*/
regions: Record<string, Region>;
}
interface BundledContent {
demos: Record<string, DemoContent>; // key: "integration-slug::demo-id"
}
function detectLanguage(filename: string): string {
const ext = path.extname(filename).toLowerCase();
const map: Record<string, string> = {
".tsx": "typescript",
".ts": "typescript",
".jsx": "javascript",
".js": "javascript",
".py": "python",
".cs": "csharp",
".java": "java",
".css": "css",
".json": "json",
".yaml": "yaml",
".yml": "yaml",
".xml": "xml",
".md": "markdown",
".mdx": "markdown",
};
return map[ext] || "text";
}
// Skip generated / OS noise when walking demo folders.
const SKIP_EXACT = new Set([".DS_Store", "Thumbs.db"]);
const SKIP_DIRS = new Set(["__pycache__", "node_modules", ".next"]);
// Extensions to skip entirely. Includes compiled Python artefacts (.pyc) and
// binary-like assets we should NEVER pass through `fs.readFileSync(..., "utf-8")`
// — doing so mangles the bytes and injects garbage strings into the bundled
// `demo-content.json`. Images, fonts, archives, and PDFs all belong here.
const SKIP_EXTENSIONS = new Set([
".pyc",
// Images
".png",
".jpg",
".jpeg",
".gif",
".webp",
".ico",
".bmp",
".avif",
".tiff",
// Fonts
".woff",
".woff2",
".ttf",
".otf",
".eot",
// Documents / archives
".pdf",
".zip",
".tar",
".gz",
".tgz",
".bz2",
".7z",
".rar",
// Media
".mp4",
".mp3",
".wav",
".mov",
".webm",
".ogg",
]);
// ---------------------------------------------------------------------------
// Region extraction
// ---------------------------------------------------------------------------
/**
* Matches a start marker in any line-comment flavour:
* `// @region[name]` (JS/TS/Java/C#)
* `# @region[name]` (Python/YAML/Bash)
* `<!-- @region[name] -->` (HTML/MDX) — only `@region[name]` token matters
*
* We don't mandate a prefix because anything before `@region[` is noise:
* comment tokens, whitespace, etc. The whole line is dropped on strip.
*/
const REGION_START_RE = /@region\[([a-z0-9][a-z0-9-]*)\]/;
const REGION_END_RE = /@endregion\[([a-z0-9][a-z0-9-]*)\]/;
/**
* A loose detector for ANY `@region[...]` or `@endregion[...]` marker,
* including malformed names. We use this to reject bad syntax early instead
* of silently leaving a stray marker in the bundled output.
*/
const REGION_ANY_RE = /@(?:end)?region\[[^\]]*\]/;
interface ExtractedRegion {
startLine: number; // 1-based, post-strip
endLine: number; // 1-based, post-strip, inclusive
lines: string[];
}
/**
* Strip region markers from a file and return:
* - `cleaned`: the file contents with all marker lines removed
* - `regions`: a map of region name → extracted slice(s) of `cleaned`
*
* Nested regions are supported. A region whose start has no matching end
* (or vice-versa) throws — bundling should fail loudly rather than produce
* a silently-broken snippet.
*/
function extractRegions(
source: string,
fileLabel: string,
): { cleaned: string; regions: Record<string, ExtractedRegion[]> } {
const srcLines = source.split("\n");
const cleaned: string[] = [];
// Stack of active regions: name → start line (1-based index into cleaned).
const stack: Array<{ name: string; startLine: number }> = [];
const regions: Record<string, ExtractedRegion[]> = {};
// While a region is open we accumulate its body lines here (indexed by
// position in `stack` so nested regions each get their own buffer).
const buffers: string[][] = [];
for (const rawLine of srcLines) {
const startMatch = rawLine.match(REGION_START_RE);
const endMatch = rawLine.match(REGION_END_RE);
if (startMatch && endMatch) {
throw new Error(
`${fileLabel}: same line contains both @region and @endregion — that's not supported.`,
);
}
if (startMatch) {
const name = startMatch[1];
stack.push({ name, startLine: cleaned.length + 1 });
buffers.push([]);
continue;
}
if (endMatch) {
const name = endMatch[1];
const top = stack.pop();
const buf = buffers.pop();
if (!top || !buf) {
throw new Error(
`${fileLabel}: @endregion[${name}] without a matching @region[...].`,
);
}
if (top.name !== name) {
throw new Error(
`${fileLabel}: @endregion[${name}] does not match innermost open region @region[${top.name}].`,
);
}
const startLine = top.startLine;
const endLine = cleaned.length; // last line pushed into `cleaned`
if (endLine < startLine) {
// Empty region — still record, but as a zero-line span.
(regions[name] ||= []).push({
startLine,
endLine: startLine - 1,
lines: [],
});
} else {
(regions[name] ||= []).push({ startLine, endLine, lines: buf });
}
continue;
}
// Reject any stray, malformed marker that didn't match the strict regex.
if (REGION_ANY_RE.test(rawLine)) {
throw new Error(
`${fileLabel}: malformed region marker "${rawLine.trim()}". Use @region[kebab-case-name] / @endregion[kebab-case-name].`,
);
}
cleaned.push(rawLine);
// Push this line into every currently-open region buffer.
for (const buf of buffers) buf.push(rawLine);
}
if (stack.length > 0) {
const unclosed = stack.map((s) => s.name).join(", ");
throw new Error(`${fileLabel}: unclosed @region[${unclosed}].`);
}
return { cleaned: cleaned.join("\n"), regions };
}
function collectDemoFiles(
demoDir: string,
relPrefix: string,
demoKey: string,
): {
readme: string | null;
files: DemoFile[];
perFileRegions: Record<string, Record<string, ExtractedRegion[]>>;
} {
const out: DemoFile[] = [];
let readme: string | null = null;
const perFileRegions: Record<string, Record<string, ExtractedRegion[]>> = {};
const walk = (absDir: string, currentRel: string) => {
const entries = fs.readdirSync(absDir, { withFileTypes: true });
for (const entry of entries) {
if (SKIP_EXACT.has(entry.name)) continue;
const abs = path.join(absDir, entry.name);
const rel = currentRel ? `${currentRel}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
if (SKIP_DIRS.has(entry.name)) continue;
walk(abs, rel);
continue;
}
if (!entry.isFile()) continue;
if (SKIP_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) continue;
const raw = fs.readFileSync(abs, "utf-8");
// Extract & strip region markers before anything else sees the text.
const { cleaned, regions: fileRegions } = extractRegions(
raw,
`${demoKey}:${rel}`,
);
const bundledPath = relPrefix ? `${relPrefix}/${rel}` : rel;
if (entry.name === "README.md" || entry.name === "README.mdx") {
// Use the demo-dir root README as the readme; nested READMEs show
// up as regular files.
if (!currentRel && readme === null) {
readme = cleaned;
if (Object.keys(fileRegions).length > 0) {
perFileRegions[bundledPath] = fileRegions;
}
continue;
}
}
out.push({
filename: bundledPath,
language: detectLanguage(entry.name),
content: cleaned,
});
if (Object.keys(fileRegions).length > 0) {
perFileRegions[bundledPath] = fileRegions;
}
}
};
walk(demoDir, "");
return { readme, files: out, perFileRegions };
}
function main() {
console.log("Bundling demo content...\n");
const bundle: BundledContent = {
demos: {},
};
if (!fs.existsSync(PACKAGES_DIR)) {
console.log("No packages directory found.");
const json = JSON.stringify(bundle, null, 2) + "\n";
for (const outputPath of OUTPUT_PATHS) {
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
fs.writeFileSync(outputPath, json);
}
return;
}
const packageDirs = fs
.readdirSync(PACKAGES_DIR, { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => d.name);
for (const pkgDir of packageDirs) {
try {
const manifestPath = path.join(PACKAGES_DIR, pkgDir, "manifest.yaml");
if (!fs.existsSync(manifestPath)) continue;
const manifest = yaml.parse(fs.readFileSync(manifestPath, "utf-8"));
const slug = manifest.slug as string;
const demos = (manifest.demos || []) as Array<{
id: string;
route?: string;
command?: string;
highlight?: string[];
}>;
const pkgRoot = path.join(PACKAGES_DIR, pkgDir);
for (const demo of demos) {
// Informational-only demos (e.g. cli-start with a `command:` field)
// have no route/folder. Skip them — nothing to bundle.
if (!demo.route) continue;
const routeDir = demo.route.replace(/^\/demos\//, "");
const demoDir = path.join(pkgRoot, "src", "app", "demos", routeDir);
if (!fs.existsSync(demoDir)) {
throw new Error(
`${slug}::${demo.id}: demo folder does not exist at ${demoDir}.`,
);
}
const key = `${slug}::${demo.id}`;
// 1. Collect the demo folder's contents.
// The bundled `filename` for each is prefixed with the
// column-relative path so highlight: entries can be matched as
// full column-relative paths.
const demoRelPrefix = `src/app/demos/${routeDir}`;
const { readme, files, perFileRegions } = collectDemoFiles(
demoDir,
demoRelPrefix,
key,
);
// 2. Pull in any highlight: entries that sit OUTSIDE the demo folder
// (typically backend agents under src/agents/*). Error if a
// highlight path doesn't resolve to a real file.
const highlightList = demo.highlight ?? [];
const demoPathSet = new Set(files.map((f) => f.filename));
for (const hlPath of highlightList) {
if (demoPathSet.has(hlPath)) continue;
const absExternal = path.join(pkgRoot, hlPath);
if (!fs.existsSync(absExternal)) {
throw new Error(
`${key}: highlight path "${hlPath}" not found in demo folder nor at ${absExternal}.`,
);
}
if (!fs.statSync(absExternal).isFile()) {
throw new Error(
`${key}: highlight path "${hlPath}" exists but is not a regular file.`,
);
}
const raw = fs.readFileSync(absExternal, "utf-8");
const { cleaned, regions: fileRegions } = extractRegions(
raw,
`${key}:${hlPath}`,
);
files.push({
filename: hlPath,
language: detectLanguage(hlPath),
content: cleaned,
});
if (Object.keys(fileRegions).length > 0) {
perFileRegions[hlPath] = fileRegions;
}
}
// 3. Apply highlights. All `highlight:` entries must now resolve to
// bundled files (the step above guarantees that for external
// files; for files inside the demo folder we check here).
// `highlightOrder` records the manifest position (0-based) so
// consumers can render highlighted files in the author's order
// rather than the bundler's alphabetical fallback.
const highlightIndex = new Map<string, number>();
highlightList.forEach((h, idx) => {
if (!highlightIndex.has(h)) highlightIndex.set(h, idx);
});
const bundled = new Set(files.map((f) => f.filename));
for (const h of highlightIndex.keys()) {
if (!bundled.has(h)) {
throw new Error(
`${key}: manifest.highlight lists "${h}" but that file isn't in the bundle.`,
);
}
}
for (const f of files) {
const order = highlightIndex.get(f.filename);
if (order !== undefined) {
f.highlighted = true;
f.highlightOrder = order;
}
}
// Stable order: page.* first, then everything else alphabetical.
files.sort((a, b) => {
const aIsPage = /(^|\/)page\.[tj]sx?$/.test(a.filename);
const bIsPage = /(^|\/)page\.[tj]sx?$/.test(b.filename);
if (aIsPage !== bIsPage) return aIsPage ? -1 : 1;
return a.filename.localeCompare(b.filename);
});
// Collapse per-file regions into the public map in file-order. For
// multi-file regions we concatenate bodies with a blank separator and
// use the FIRST file's line span (there's no single coherent range
// across files — this is a best-effort pointer for tooling).
//
// `perFileRegions` can carry entries whose filename is NOT in
// `files` — specifically the demo-root README (README.md / README.mdx),
// which `collectDemoFiles` pulls out into the `readme` field rather
// than appending to `files`. Iterating only `fileOrder` would drop
// those regions silently. Build the effective order as: files (stable)
// first, then any leftover perFileRegions keys (typically README) in
// lexical order so multi-file regions prefer the demo source's span
// over the README's — README regions either fill in untouched names
// or get concatenated at the tail like any other contributor.
const regions: Record<string, Region> = {};
const fileOrder = files.map((f) => f.filename);
const knownInFiles = new Set(fileOrder);
const leftoverKeys = Object.keys(perFileRegions)
.filter((k) => !knownInFiles.has(k))
.sort();
const effectiveOrder = [...fileOrder, ...leftoverKeys];
const regionSources = new Map<string, Set<string>>();
for (const filename of effectiveOrder) {
const fileRegs = perFileRegions[filename];
if (!fileRegs) continue;
for (const [name, slices] of Object.entries(fileRegs)) {
if (slices.length === 0) continue;
const sources = regionSources.get(name) ?? new Set<string>();
sources.add(filename);
regionSources.set(name, sources);
}
}
const unexpectedMultiFileRegions = findUnexpectedMultiFileRegions(
[...regionSources.entries()].map(([regionName, filesForRegion]) => ({
demoKey: key,
regionName,
files: [...filesForRegion],
})),
);
if (unexpectedMultiFileRegions.length > 0) {
const details = unexpectedMultiFileRegions
.map(
({ regionName, files: regionFiles }) =>
`${key}: region "${regionName}" appears in multiple files:\n ${regionFiles.join("\n ")}`,
)
.join("\n\n");
throw new Error(
`${details}\n\nRename/delete the accidental duplicate region, or add an explicit allowlist entry in showcase/scripts/lib/demo-region-guard.ts if this is an intentional multi-file snippet.`,
);
}
for (const filename of effectiveOrder) {
const fileRegs = perFileRegions[filename];
if (!fileRegs) continue;
for (const [name, slices] of Object.entries(fileRegs)) {
for (const slice of slices) {
if (regions[name]) {
regions[name].code =
regions[name].code + "\n\n" + slice.lines.join("\n");
} else {
regions[name] = {
file: filename,
startLine: slice.startLine,
endLine: slice.endLine,
code: slice.lines.join("\n"),
language: detectLanguage(filename),
};
}
}
}
}
bundle.demos[key] = { readme, files, backend_files: [], regions };
const hlCount = files.filter((f) => f.highlighted).length;
const regionCount = Object.keys(regions).length;
console.log(
` ${key}: ${files.length} files${hlCount ? ` (${hlCount} highlighted)` : ""}${readme ? " + README" : ""}${regionCount ? ` + ${regionCount} regions` : ""}`,
);
}
} catch (err) {
// Propagate: a broken manifest must fail the bundle, not silently skip.
throw new Error(
`[bundle] Failed while processing package "${pkgDir}": ${(err as Error).message}`,
{ cause: err },
);
}
}
const json = JSON.stringify(bundle, null, 2) + "\n";
for (const outputPath of OUTPUT_PATHS) {
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
fs.writeFileSync(outputPath, json);
console.log(
`\nBundled ${Object.keys(bundle.demos).length} demos to ${outputPath}`,
);
}
}
try {
main();
} catch (err) {
console.error((err as Error).message);
process.exit(1);
}
if (process.argv.includes("--watch")) {
let timer: NodeJS.Timeout | null = null;
// Track the last failure so transitions are visible. The previous
// implementation logged a single `[watch] bundle failed` and then fell
// silent on both repeat failures (no news = assumed fine) and on
// recovery (no news = actually, it's fine again). Operators reading a
// dev log couldn't tell either way. Now we log on first-failure,
// distinguish repeat failures, and emit an explicit "recovered" note
// when the next successful run clears the state.
let lastWatchError: Error | null = null;
const rebundle = () => {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
try {
main();
if (lastWatchError) {
console.log("[watch] bundle recovered");
lastWatchError = null;
}
} catch (e) {
const err = e as Error;
if (lastWatchError && lastWatchError.message === err.message) {
console.error(`[watch] bundle still failing: ${err.message}`);
} else {
console.error("[watch] bundle failed:", err);
}
lastWatchError = err;
}
}, 200);
};
console.log("[watch] watching integrations/ for changes...\n");
fs.watch(PACKAGES_DIR, { recursive: true }, (_event, filename) => {
if (!filename) return;
// Rebundle for demo sources, agent sources, READMEs, and — critically —
// manifest.yaml edits.
if (
/(\/demos\/|\/agents\/|\/agent\/|\/mastra\/|README\.md$|manifest\.yaml$)/.test(
filename,
)
) {
rebundle();
}
});
}

Some files were not shown because too many files have changed in this diff Show More