import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { expandImageConsumers, makeLiveRedeploy, parseArgs, runRedeploy, resolveTargetServices, } from "./redeploy-env"; import { ENV_IDS, ENV_ID_BY_NAME, PRODUCTION_ENV_ID, SERVICES, } from "./railway-envs"; describe("runRedeploy", () => { let consoleErrSpy: ReturnType; let consoleLogSpy: ReturnType; let stdoutWriteSpy: ReturnType; 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 = ""; // runRedeploy unconditionally honors $REDEPLOY_SUMMARY_JSON — if the // test process inherits it (e.g. from a CI step), every runRedeploy // call here would write a real file. Stub it out (undefined deletes // the var) so tests never touch the filesystem. vi.stubEnv("REDEPLOY_SUMMARY_JSON", undefined); }); afterEach(() => { consoleErrSpy.mockRestore(); consoleLogSpy.mockRestore(); stdoutWriteSpy.mockRestore(); // vi.restoreAllMocks/mockRestore do NOT undo stubEnv — unstub // explicitly or the stub leaks into other files under fork reuse. vi.unstubAllEnvs(); }); const appendSummary = (s: string) => { summary += s + "\n"; }; it("default staging scope = 39 CI-built services + their imageOf consumers (harness-workers)", 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 ∪ imageOf consumers }); expect(result.exitCode).toBe(0); // 39 CI-built (27 showcase/infra incl. showcase-strands-typescript, // now dual-env, + 12 starters) + harness-workers (imageOf consumer of // showcase-harness) = 40. All 39 declare staging, so the env-aware // default scope keeps every one of them. expect(result.attempted).toBe(40); expect(result.succeeded).toBe(40); expect(redeploy).toHaveBeenCalledTimes(40); // pocketbase is now CI-built, so it IS in the default redeploy scope. expect(seenNames).toContain("pocketbase"); // The TypeScript Strands integration declares staging, so it IS in the // staging default scope (and now prod too — see below). expect(seenNames).toContain("showcase-strands-typescript"); // S2: starters are CI-built, so they JOIN the default redeploy scope. expect(seenNames).toContain("starter-adk"); expect(seenNames).toContain("starter-mastra"); // harness-workers runs the shared showcase-harness image (imageOf: // "harness") and must follow the scheduler into the staging scope. expect(seenNames).toContain("harness-workers"); // webhooks remains out-of-band. expect(seenNames).not.toContain("webhooks"); }); it("explicit --services harness pulls in its imageOf consumer harness-workers (staging)", async () => { // THE regression behind PR #5352: CI rebuilds showcase-harness:latest // and passes only the built slot (`showcase-harness` dispatch_name) to // redeploy-env.ts — the workers run the SAME image and silently kept // the stale one. The scope must expand to every imageOf consumer. 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-harness"], // the build matrix dispatch_name }); expect(result.attempted).toBe(2); expect(seenIds).toEqual([ SERVICES.harness.serviceId, // alphabetical iteration SERVICES["harness-workers"].serviceId, ]); }); it("CONTRACT PIN: an explicitly-named service is attempted even in an env it does not declare (harness-workers on prod)", async () => { // Documented in the header: the env filter applies ONLY to consumers // added by imageOf expansion — a service the caller explicitly names // in --services is attempted even in an env it does not declare. // harness-workers is staging-only, yet an explicit prod request must // still fire against the prod env id. If a future "cleanup" // env-filters explicit requests, this test fails loudly against that // documented contract; change the docs AND this pin together or not // at all. const calls: Array<{ serviceId: string; environmentId: string }> = []; const redeploy = vi.fn(async (serviceId: string, environmentId: string) => { calls.push({ serviceId, environmentId }); return { ok: true as const }; }); const result = await runRedeploy({ env: "prod", redeploy, appendSummary, services: ["harness-workers"], }); expect(result.attempted).toBe(1); expect(calls).toEqual([ { serviceId: SERVICES["harness-workers"].serviceId, environmentId: PRODUCTION_ENV_ID, }, ]); }); it("default prod scope includes the dual-env worker (harness-workers) and dual-env showcase-strands-typescript", async () => { // The default scope is env-aware: a service joins the prod scope when it // declares a prod env. harness-workers is now dual-env (the prod worker was // backfilled into the SSOT), so the env-aware imageOf expansion pulls it // into the prod scope as a showcase-harness consumer. showcase-strands-typescript // is also dual-env and joins. The prod default = the 40 services that // declare prod (27 CI-built showcase/infra incl. showcase-strands-typescript // + 12 starters + the imageOf-consumer harness-workers). const seenNames: string[] = []; const redeploy = vi.fn(async (serviceId: string) => { 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: "prod", redeploy, appendSummary, }); expect(result.attempted).toBe(40); // harness-workers is now dual-env, so a prod redeploy of its showcase-harness // image bounces the prod worker too (it used to be silently skipped). expect(seenNames).toContain("harness-workers"); // showcase-strands-typescript is dual-env, so it joins the prod scope. expect(seenNames).toContain("showcase-strands-typescript"); // S2: starters ARE in the default prod scope (CI-built, dual-env). expect(seenNames).toContain("starter-adk"); }); 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(); 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) { 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("a registered NON-staging third env gets fail-loud semantics (exit 1 on per-service failure)", async () => { // Exit-code policy is fail-loud by DEFAULT: staging is the single // documented carve-out (non-fatal; verify-deploy is its gate). A future // "preview" env must inherit prod-style fatal semantics without anyone // remembering to extend an env allowlist. ENV_ID_BY_NAME.preview = "preview-env-id-000"; try { const redeploy = vi.fn(async () => ({ ok: false as const, error: "boom", })); const result = await runRedeploy({ env: "preview", redeploy, appendSummary, services: ["showcase-mastra"], }); expect(result.failed).toBe(1); expect(result.exitCode).toBe(1); // The "non-fatal" summary note is staging-only — a fatal env must // not claim its failures are non-fatal. expect(summary).not.toMatch(/non-fatal/); } finally { delete ENV_ID_BY_NAME.preview; } }); it("staging keeps the non-fatal note in the failure summary (the documented carve-out)", async () => { const redeploy = vi.fn(async () => ({ ok: false as const, error: "boom", })); const result = await runRedeploy({ env: "staging", redeploy, appendSummary, services: ["showcase-mastra"], }); expect(result.exitCode).toBe(0); expect(summary).toMatch(/non-fatal/); }); it("sanitizes per-service THROWN error messages through sanitizeErrorBody", async () => { // The non-throw FAIL path gets pre-sanitized errors from // makeLiveRedeploy, but a rejection thrown by the redeploy fn (e.g. an // AbortSignal timeout wrapping a Cloudflare HTML page) bypassed that — // the raw message landed in the records + markdown summary. const redeploy = vi.fn(async () => { throw new Error(`boom