mirror of
https://github.com/CopilotKit/CopilotKit.git
synced 2026-09-14 16:26:20 +08:00
3c138ec450
PocketBase had no CI build path: `ghcr.io/copilotkit/showcase-pocketbase`
was a stale April `:latest`, and there was no way to ship pb_migrations /
pb_hooks changes without an ad-hoc manual build. Add a `pocketbase` slot to
showcase_build.yml's build matrix, mirroring the harness/aimock entries:
- dispatch_name `showcase-pocketbase`, context `showcase/pocketbase`, its
own Dockerfile, health `/api/health`, railway_id from the SSOT.
- a paths-filter key gated to `showcase/pocketbase/**` so the slot only
rebuilds when PB's own files change (the image is self-contained — no
shared-module copy), not on every showcase push.
- the workflow_dispatch service choice so PB is human-targetable.
Flip the SSOT entry (railway-envs.ts) to `ciBuilt: true` with
`dispatchName: "showcase-pocketbase"` so it is built+pushed (`:sha` +
`:latest`) and joins the default staging-redeploy scope; the build's
redeploy step only touches the matrix-intersect-success set, so PB still
only redeploys when its own files change. Regenerate
railway-envs.generated.json and the showcase_promote.yml service dropdown,
and update the SSOT/redeploy tests that pinned PB as out-of-band
(CI_BUILT_SERVICES 25 -> 26; webhooks stays the only non-CI-built service).
314 lines
10 KiB
TypeScript
314 lines
10 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { parseArgs, runRedeploy, resolveTargetServices } from "./redeploy-env";
|
|
import { SERVICES } from "./railway-envs";
|
|
|
|
describe("runRedeploy", () => {
|
|
let consoleErrSpy: ReturnType<typeof vi.spyOn>;
|
|
let consoleLogSpy: ReturnType<typeof vi.spyOn>;
|
|
let stdoutWriteSpy: ReturnType<typeof vi.spyOn>;
|
|
let summary: string;
|
|
|
|
beforeEach(() => {
|
|
consoleErrSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
// runRedeploy's per-service progress lines go to process.stdout.write
|
|
// (NOT console.log); spy on that too or tests spam the terminal.
|
|
stdoutWriteSpy = vi
|
|
.spyOn(process.stdout, "write")
|
|
.mockImplementation(() => true);
|
|
summary = "";
|
|
});
|
|
|
|
afterEach(() => {
|
|
consoleErrSpy.mockRestore();
|
|
consoleLogSpy.mockRestore();
|
|
stdoutWriteSpy.mockRestore();
|
|
});
|
|
|
|
const appendSummary = (s: string) => {
|
|
summary += s + "\n";
|
|
};
|
|
|
|
it("default scope (no --services) targets the 26 CI-built services (incl. pocketbase), NOT all 27", async () => {
|
|
const seenNames: string[] = [];
|
|
const redeploy = vi.fn(async (serviceId: string) => {
|
|
// Reverse-lookup the SSOT name from serviceId so the test can
|
|
// assert exact membership rather than counting opaquely.
|
|
const name = Object.entries(SERVICES).find(
|
|
([, e]) => e.serviceId === serviceId,
|
|
)?.[0];
|
|
if (name) seenNames.push(name);
|
|
return { ok: true as const };
|
|
});
|
|
|
|
const result = await runRedeploy({
|
|
env: "staging",
|
|
redeploy,
|
|
appendSummary,
|
|
// services omitted → default = CI_BUILT_SERVICES
|
|
});
|
|
|
|
expect(result.exitCode).toBe(0);
|
|
expect(result.attempted).toBe(26);
|
|
expect(result.succeeded).toBe(26);
|
|
expect(redeploy).toHaveBeenCalledTimes(26);
|
|
// pocketbase is now CI-built, so it IS in the default redeploy scope.
|
|
expect(seenNames).toContain("pocketbase");
|
|
// webhooks remains out-of-band.
|
|
expect(seenNames).not.toContain("webhooks");
|
|
});
|
|
|
|
it("default whole-env staging redeploy NEVER bounces webhooks (out-of-band)", async () => {
|
|
// Anti-regression: webhooks is released by its own repo's workflow
|
|
// and must stay out of the default redeploy scope. pocketbase, by
|
|
// contrast, is now CI-built and IS legitimately in the default scope.
|
|
const seenIds = new Set<string>();
|
|
const redeploy = vi.fn(async (serviceId: string) => {
|
|
seenIds.add(serviceId);
|
|
return { ok: true as const };
|
|
});
|
|
await runRedeploy({ env: "staging", redeploy, appendSummary });
|
|
expect(seenIds.has(SERVICES.webhooks.serviceId)).toBe(false);
|
|
expect(seenIds.has(SERVICES.pocketbase.serviceId)).toBe(true);
|
|
});
|
|
|
|
it("explicit --services list targets exactly that subset", async () => {
|
|
const seenIds: string[] = [];
|
|
const redeploy = vi.fn(async (serviceId: string) => {
|
|
seenIds.push(serviceId);
|
|
return { ok: true as const };
|
|
});
|
|
const result = await runRedeploy({
|
|
env: "staging",
|
|
redeploy,
|
|
appendSummary,
|
|
services: ["showcase-mastra", "showcase-ag2"],
|
|
});
|
|
expect(result.attempted).toBe(2);
|
|
expect(seenIds).toEqual([
|
|
SERVICES["showcase-ag2"].serviceId, // alphabetical iteration
|
|
SERVICES["showcase-mastra"].serviceId,
|
|
]);
|
|
});
|
|
|
|
it("targets the staging env id", async () => {
|
|
const calls: Array<{ environmentId: string }> = [];
|
|
const redeploy = vi.fn(async (_svc: string, environmentId: string) => {
|
|
calls.push({ environmentId });
|
|
return { ok: true as const };
|
|
});
|
|
await runRedeploy({
|
|
env: "staging",
|
|
redeploy,
|
|
appendSummary,
|
|
services: ["showcase-mastra"],
|
|
});
|
|
for (const c of calls) {
|
|
expect(c.environmentId).toBe("8edfef02-ea09-4a20-8689-261f21cc2849");
|
|
}
|
|
});
|
|
|
|
it("targets the prod env id when env=prod", async () => {
|
|
const calls: Array<{ environmentId: string }> = [];
|
|
const redeploy = vi.fn(async (_svc: string, environmentId: string) => {
|
|
calls.push({ environmentId });
|
|
return { ok: true as const };
|
|
});
|
|
await runRedeploy({
|
|
env: "prod",
|
|
redeploy,
|
|
appendSummary,
|
|
services: ["showcase-mastra"],
|
|
});
|
|
for (const c of calls) {
|
|
expect(c.environmentId).toBe("b14919f4-6417-429f-848d-c6ae2201e04f");
|
|
}
|
|
});
|
|
|
|
it("treats per-service failures as non-fatal and exits 0", async () => {
|
|
// Deterministic: fail on a specific named service so the test does
|
|
// not depend on the size of the default scope.
|
|
const failTarget = SERVICES["showcase-mastra"].serviceId;
|
|
const redeploy = vi.fn(async (serviceId: string) => {
|
|
if (serviceId === failTarget) {
|
|
return { ok: false as const, error: "boom" };
|
|
}
|
|
return { ok: true as const };
|
|
});
|
|
|
|
const result = await runRedeploy({
|
|
env: "staging",
|
|
redeploy,
|
|
appendSummary,
|
|
});
|
|
|
|
expect(result.exitCode).toBe(0);
|
|
expect(result.failed).toBe(1);
|
|
expect(result.succeeded + result.failed).toBe(result.attempted);
|
|
// Failure must be visible in the summary.
|
|
expect(summary).toMatch(/FAIL/);
|
|
expect(summary).toMatch(/boom/);
|
|
});
|
|
|
|
it("per-service catch records non-Error throws (null, string) as failures without crashing", async () => {
|
|
// Catch block previously did `(e as Error).message ?? String(e)`,
|
|
// which throws TypeError when e is null.
|
|
const failTarget = SERVICES["showcase-mastra"].serviceId;
|
|
const redeploy = vi.fn(async (serviceId: string) => {
|
|
if (serviceId === failTarget) {
|
|
// eslint-disable-next-line @typescript-eslint/no-throw-literal
|
|
throw null;
|
|
}
|
|
return { ok: true as const };
|
|
});
|
|
|
|
const result = await runRedeploy({
|
|
env: "staging",
|
|
redeploy,
|
|
appendSummary,
|
|
});
|
|
|
|
expect(result.exitCode).toBe(0);
|
|
expect(result.failed).toBe(1);
|
|
expect(summary).toMatch(/FAIL/);
|
|
});
|
|
|
|
it("env=prod returns non-zero exitCode on per-service failure", async () => {
|
|
// Prod isn't wired yet, but the design must NOT silently swallow
|
|
// prod per-service failures the way staging intentionally does.
|
|
const redeploy = vi.fn(async () => ({
|
|
ok: false as const,
|
|
error: "kaboom",
|
|
}));
|
|
const result = await runRedeploy({
|
|
env: "prod",
|
|
redeploy,
|
|
appendSummary,
|
|
services: ["showcase-mastra"],
|
|
});
|
|
expect(result.failed).toBe(1);
|
|
expect(result.exitCode).not.toBe(0);
|
|
});
|
|
|
|
it("env=prod returns exitCode 0 when all services succeed", async () => {
|
|
const redeploy = vi.fn(async () => ({ ok: true as const }));
|
|
const result = await runRedeploy({
|
|
env: "prod",
|
|
redeploy,
|
|
appendSummary,
|
|
services: ["showcase-mastra"],
|
|
});
|
|
expect(result.exitCode).toBe(0);
|
|
});
|
|
|
|
it("reports zero failures with empty failure list in summary", async () => {
|
|
const redeploy = vi.fn(async () => ({ ok: true as const }));
|
|
await runRedeploy({ env: "staging", redeploy, appendSummary });
|
|
expect(summary).toMatch(/0 failed/);
|
|
});
|
|
|
|
it("throws on a --services entry that doesn't resolve to a known SSOT key", async () => {
|
|
const redeploy = vi.fn();
|
|
await expect(
|
|
runRedeploy({
|
|
env: "staging",
|
|
redeploy,
|
|
appendSummary,
|
|
services: ["nonsense"],
|
|
}),
|
|
).rejects.toThrow(/Unknown service/);
|
|
expect(redeploy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects unknown env names", async () => {
|
|
const redeploy = vi.fn();
|
|
await expect(
|
|
runRedeploy({ env: "dev" as never, redeploy, appendSummary }),
|
|
).rejects.toThrow(/Unknown env/);
|
|
expect(redeploy).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("resolveTargetServices", () => {
|
|
it("accepts SSOT keys verbatim and resolves CI dispatch_names too", () => {
|
|
// The workflow passes detect-changes.outputs.matrix .dispatch_name
|
|
// values, but a human operator might pass SSOT keys directly.
|
|
// Both forms must resolve.
|
|
const resolved = resolveTargetServices([
|
|
"showcase-mastra", // already an SSOT key
|
|
"mastra", // dispatch_name → showcase-mastra
|
|
"shell-dashboard", // dispatch_name → dashboard
|
|
"showcase-aimock", // dispatch_name → aimock
|
|
]);
|
|
expect(resolved).toEqual(["showcase-mastra", "dashboard", "aimock"]);
|
|
// Dedupes the duplicate showcase-mastra ↔ mastra.
|
|
});
|
|
|
|
it("throws on inputs that match neither SSOT keys nor dispatch_names", () => {
|
|
expect(() => resolveTargetServices(["mastra", "garbage"])).toThrow(
|
|
/Unknown service "garbage"/,
|
|
);
|
|
});
|
|
|
|
it("returns the CI_BUILT_SERVICES set sorted when given undefined", () => {
|
|
const resolved = resolveTargetServices(undefined);
|
|
expect(resolved.length).toBe(26);
|
|
// pocketbase is now CI-built and part of the default scope.
|
|
expect(resolved).toContain("pocketbase");
|
|
// webhooks remains out-of-band.
|
|
expect(resolved).not.toContain("webhooks");
|
|
});
|
|
});
|
|
|
|
describe("parseArgs", () => {
|
|
it("parses bare env name", () => {
|
|
expect(parseArgs(["staging"])).toEqual({ env: "staging" });
|
|
});
|
|
|
|
it("parses --services with CSV value", () => {
|
|
expect(parseArgs(["staging", "--services", "mastra,ag2"])).toEqual({
|
|
env: "staging",
|
|
services: ["mastra", "ag2"],
|
|
});
|
|
});
|
|
|
|
it("parses --services=csv form", () => {
|
|
expect(parseArgs(["staging", "--services=mastra,ag2"])).toEqual({
|
|
env: "staging",
|
|
services: ["mastra", "ag2"],
|
|
});
|
|
});
|
|
|
|
it("throws when --services has no following value (bare flag at end)", () => {
|
|
expect(() => parseArgs(["staging", "--services"])).toThrow(
|
|
/--services requires a non-empty comma-separated value/,
|
|
);
|
|
});
|
|
|
|
it("throws when --services value is the empty string", () => {
|
|
expect(() => parseArgs(["staging", "--services", ""])).toThrow(
|
|
/--services requires a non-empty comma-separated value/,
|
|
);
|
|
});
|
|
|
|
it("throws when --services value is only commas/whitespace", () => {
|
|
expect(() => parseArgs(["staging", "--services", ","])).toThrow(
|
|
/--services requires a non-empty comma-separated value/,
|
|
);
|
|
});
|
|
|
|
it("throws when --services= value empties after split/filter", () => {
|
|
expect(() => parseArgs(["staging", "--services="])).toThrow(
|
|
/--services requires a non-empty comma-separated value/,
|
|
);
|
|
});
|
|
|
|
it("throws on unknown argument", () => {
|
|
expect(() => parseArgs(["staging", "--bogus"])).toThrow(/Unknown argument/);
|
|
});
|
|
|
|
it("throws on empty argv", () => {
|
|
expect(() => parseArgs([])).toThrow();
|
|
});
|
|
});
|