/** * Tests for the starter-fleet Railway provisioner * (`provision-starter-fleet.ts`). * * Style note (mirrors redeploy-env / verify-railway-image-refs tests): the * Railway GraphQL API is the only impure surface, and it is dependency- * injected as a `RailwayGqlFn`. We exercise the pure derivation/credential * helpers and the idempotent provisioning core against a recording mock — * no live Railway calls, no `fetch` stubbing. Railway is an external * boundary, so a plain recording mock is appropriate (aimock is only for * LLM calls). */ import { describe, it, expect, vi } from "vitest"; import { deriveStarterTargets, fetchExistingServices, parseArgs, provisionStarterFleet, resolveRegistryCredentials, STARTER_FLEET_PREFIX, STARTER_HEALTHCHECK_PATH, STARTER_REGION, } from "../provision-starter-fleet"; import type { RailwayGqlFn, StarterTarget } from "../provision-starter-fleet"; import { STARTER_TO_COLUMN } from "../../harness/src/probes/helpers/starter-mapping"; import { STAGING_ENV_ID, PRODUCTION_ENV_ID } from "../railway-envs"; const PROJECT = "proj-test-id"; // ── Target derivation ─────────────────────────────────────────────────── describe("deriveStarterTargets", () => { it("derives exactly the 12 starters from the STARTER_TO_COLUMN SSOT", () => { const targets = deriveStarterTargets(); expect(targets).toHaveLength(12); expect(targets.length).toBe(Object.keys(STARTER_TO_COLUMN).length); }); it("uses the RAW starter slug for service name + image (NOT the remapped column slug)", () => { const targets = deriveStarterTargets(); const byName = new Map(targets.map((t) => [t.serviceName, t])); // adk -> column "google-adk", but the service/image use raw "adk". const adk = byName.get("starter-adk"); expect(adk, "starter-adk must be present").toBeDefined(); expect(adk!.image).toBe("ghcr.io/copilotkit/starter-adk:latest"); // The remapped column slug must NOT leak into the service name. expect(byName.has("starter-google-adk")).toBe(false); // langgraph-js -> column "langgraph-typescript"; raw slug wins. const lgjs = byName.get("starter-langgraph-js"); expect(lgjs, "starter-langgraph-js must be present").toBeDefined(); expect(lgjs!.image).toBe("ghcr.io/copilotkit/starter-langgraph-js:latest"); expect(byName.has("starter-langgraph-typescript")).toBe(false); }); it("derives the full expected name + image set for all 12", () => { const targets = deriveStarterTargets(); const names = targets.map((t) => t.serviceName).sort(); expect(names).toEqual( [ "adk", "agno", "crewai-crews", "langgraph-fastapi", "langgraph-js", "langgraph-python", "llamaindex", "mastra", "ms-agent-framework-dotnet", "ms-agent-framework-python", "pydantic-ai", "strands-python", ] .map((s) => `${STARTER_FLEET_PREFIX}${s}`) .sort(), ); for (const t of targets) { expect(t.image).toBe(`ghcr.io/copilotkit/${t.serviceName}:latest`); } }); it("derives from an injected mapping (pure)", () => { const targets = deriveStarterTargets({ foo: "bar", baz: "qux" }); expect(targets.map((t) => t.serviceName)).toEqual([ "starter-baz", "starter-foo", ]); }); }); // ── Registry credentials ──────────────────────────────────────────────── describe("resolveRegistryCredentials", () => { it("returns undefined when GITHUB_TOKEN is unset", () => { expect(resolveRegistryCredentials({})).toBeUndefined(); }); it("uses GHCR_USERNAME (preferred) as the username", () => { expect( resolveRegistryCredentials({ GITHUB_TOKEN: "ghp_x", GHCR_USERNAME: "ck-bot", GITHUB_ACTOR: "some-actor", }), ).toEqual({ username: "ck-bot", password: "ghp_x" }); }); it("falls back to GITHUB_ACTOR when GHCR_USERNAME is unset", () => { expect( resolveRegistryCredentials({ GITHUB_TOKEN: "ghp_x", GITHUB_ACTOR: "ci-actor", }), ).toEqual({ username: "ci-actor", password: "ghp_x" }); }); it("throws (fail loud) when a token is present but no username is available", () => { expect(() => resolveRegistryCredentials({ GITHUB_TOKEN: "ghp_x" })).toThrow( /no GHCR username/i, ); }); }); // ── Recording GraphQL mock ────────────────────────────────────────────── interface Call { query: string; variables: Record; } /** * Build a recording RailwayGqlFn. `projectServices` is the existing-services * response (defaults to an empty project). serviceCreate returns a synthetic * id derived from the requested name; serviceDomainCreate returns a synthetic * domain. All calls are recorded for assertion. */ function makeMockGql( projectServices: Array<{ id: string; name: string; stagingDomain?: string; }> = [], ): { gql: RailwayGqlFn; calls: Call[] } { const calls: Call[] = []; let createSeq = 0; const gql: RailwayGqlFn = vi.fn( async ( query: string, variables: Record = {}, ): Promise => { calls.push({ query, variables }); if (query.includes("project(id:")) { return { project: { services: { edges: projectServices.map((s) => ({ node: { id: s.id, name: s.name, serviceInstances: { edges: [ { node: { environmentId: STAGING_ENV_ID, domains: { serviceDomains: s.stagingDomain ? [{ domain: s.stagingDomain }] : [], }, }, }, ], }, }, })), }, }, } as T; } if (query.includes("serviceCreate(")) { const input = variables.input as { name: string }; createSeq += 1; return { serviceCreate: { id: `new-svc-${createSeq}`, name: input.name }, } as T; } if (query.includes("serviceInstanceUpdate(")) { return { serviceInstanceUpdate: true } as T; } if (query.includes("serviceInstanceRedeploy(")) { return { serviceInstanceRedeploy: true } as T; } if (query.includes("serviceDomainCreate(")) { const input = variables.input as { serviceId: string }; return { serviceDomainCreate: { domain: `${input.serviceId}.up.railway.app`, }, } as T; } throw new Error(`unexpected query in mock:\n${query}`); }, ) as RailwayGqlFn; return { gql, calls }; } const TWO_TARGETS: StarterTarget[] = [ { slug: "mastra", serviceName: "starter-mastra", image: "ghcr.io/copilotkit/starter-mastra:latest", }, { slug: "adk", serviceName: "starter-adk", image: "ghcr.io/copilotkit/starter-adk:latest", }, ]; /** No-op sleep so retry-bearing tests don't actually wait. */ const NO_SLEEP = async (): Promise => {}; // ── fetchExistingServices ─────────────────────────────────────────────── describe("fetchExistingServices", () => { it("indexes services by name and detects staging domains", async () => { const { gql } = makeMockGql([ { id: "svc-a", name: "starter-mastra", stagingDomain: "m.up.railway.app", }, { id: "svc-b", name: "starter-adk" }, ]); const map = await fetchExistingServices(gql, PROJECT, STAGING_ENV_ID); expect(map.get("starter-mastra")).toEqual({ id: "svc-a", hasStagingDomain: true, }); expect(map.get("starter-adk")).toEqual({ id: "svc-b", hasStagingDomain: false, }); }); it("throws when the project is null (bad id / no access)", async () => { const gql: RailwayGqlFn = vi.fn(async () => ({ project: null })) as never; await expect( fetchExistingServices(gql, PROJECT, STAGING_ENV_ID), ).rejects.toThrow(/returned null/); }); it("tolerates a service node with null serviceInstances (transitional service)", async () => { // A service in a transitional state can return a null serviceInstances // connection (or null .edges). An unguarded `.edges.find(...)` throws a // TypeError that aborts the ENTIRE fetch — before any starter is touched. // The fetch must coalesce and treat such a node as having no staging // instance / no domain rather than crash. const gql: RailwayGqlFn = vi.fn(async () => ({ project: { services: { edges: [ { node: { id: "svc-null-instances", name: "starter-mastra", serviceInstances: null, }, }, { node: { id: "svc-null-edges", name: "starter-adk", serviceInstances: { edges: null }, }, }, ], }, }, })) as never; const map = await fetchExistingServices(gql, PROJECT, STAGING_ENV_ID); expect(map.get("starter-mastra")).toEqual({ id: "svc-null-instances", hasStagingDomain: false, }); expect(map.get("starter-adk")).toEqual({ id: "svc-null-edges", hasStagingDomain: false, }); }); it("FAILS LOUD when the page-drain loop is truncated (hasNextPage still true at the bound)", async () => { // If the defensive page bound is reached while pageInfo.hasNextPage is // STILL true, the byName map is a TRUNCATED snapshot. Returning it // silently would feed erroneous create-vs-update decisions (a service on // an undrained page looks absent → CREATE → "already exists"). Refuse to // provision against a truncated snapshot. const gql: RailwayGqlFn = vi.fn( async (): Promise => ({ project: { services: { edges: [ { node: { id: "svc-loop", name: "starter-loop", serviceInstances: { edges: [] }, }, }, ], // Always more pages, never a usable cursor advance → the loop // exhausts its bound with hasNextPage still true. pageInfo: { hasNextPage: true, endCursor: "stuck-cursor" }, }, }, }) as T, ) as RailwayGqlFn; await expect( fetchExistingServices(gql, PROJECT, STAGING_ENV_ID), ).rejects.toThrow(/truncated/i); }); it("paginates the Relay ServiceConnection — services on page 2 are NOT truncated", async () => { // Railway's `project.services` is a Relay connection that page-limits. // With ~27 SSOT + 12 starter services in staging, a single un-paginated // query returns a TRUNCATED first page — a starter that lives on page 2 // looks absent, the provisioner takes the CREATE path, and serviceCreate // rejects with a non-transient "already exists" → the whole run ABORTS. // fetchExistingServices must follow `pageInfo.hasNextPage`/`endCursor` // until every page is drained, accumulating into one byName map. let afterSeen: unknown; const gql: RailwayGqlFn = vi.fn( async ( _query: string, variables: Record = {}, ): Promise => { const after = variables.after; afterSeen = after; const mkNode = (id: string, name: string) => ({ node: { id, name, serviceInstances: { edges: [ { node: { environmentId: STAGING_ENV_ID, domains: { serviceDomains: [] }, }, }, ], }, }, }); // Page 1: one service + hasNextPage. Page 2 (after cursor): the rest. if (!after) { return { project: { services: { edges: [mkNode("svc-page1", "starter-mastra")], pageInfo: { hasNextPage: true, endCursor: "cursor-1" }, }, }, } as T; } return { project: { services: { edges: [mkNode("svc-page2", "starter-adk")], pageInfo: { hasNextPage: false, endCursor: "cursor-2" }, }, }, } as T; }, ) as RailwayGqlFn; const map = await fetchExistingServices(gql, PROJECT, STAGING_ENV_ID); // Both pages' services are present — page 2 was NOT truncated away. expect(map.get("starter-mastra")?.id).toBe("svc-page1"); expect(map.get("starter-adk")?.id).toBe("svc-page2"); expect(map.size).toBe(2); // The second query carried the first page's endCursor as `after`. expect(afterSeen).toBe("cursor-1"); }); }); // ── provisionStarterFleet: create path ────────────────────────────────── describe("provisionStarterFleet — create path", () => { it("creates services with the correct GHCR image source", async () => { const { gql, calls } = makeMockGql(); const summary = await provisionStarterFleet({ gql, projectId: PROJECT, stagingEnvId: STAGING_ENV_ID, targets: TWO_TARGETS, }); expect(summary.records.map((r) => r.action)).toEqual([ "created", "created", ]); const creates = calls.filter((c) => c.query.includes("serviceCreate(")); expect(creates).toHaveLength(2); const createInputs = creates.map( (c) => c.variables.input as { name: string; environmentId: string; source: { image: string }; }, ); expect(createInputs[0]).toMatchObject({ projectId: PROJECT, name: "starter-mastra", source: { image: "ghcr.io/copilotkit/starter-mastra:latest" }, }); expect(createInputs[1]).toMatchObject({ name: "starter-adk", source: { image: "ghcr.io/copilotkit/starter-adk:latest" }, }); // CRITICAL: serviceCreate MUST scope the new instance to the STAGING // env — without environmentId Railway materializes the instance in the // default (production) environment, leaking a prod instance per starter. for (const ci of createInputs) { expect(ci.environmentId).toBe(STAGING_ENV_ID); expect(ci.environmentId).not.toBe(PRODUCTION_ENV_ID); } }); it("sets sleepApplication:false (always-on) + healthcheck '/' + region us-west1 on the staging instance", async () => { const { gql, calls } = makeMockGql(); await provisionStarterFleet({ gql, projectId: PROJECT, stagingEnvId: STAGING_ENV_ID, targets: TWO_TARGETS, }); const updates = calls.filter((c) => c.query.includes("serviceInstanceUpdate("), ); expect(updates).toHaveLength(2); for (const u of updates) { // ALWAYS targets the staging env — never prod. expect(u.variables.environmentId).toBe(STAGING_ENV_ID); expect(u.variables.environmentId).not.toBe(PRODUCTION_ENV_ID); const input = u.variables.input as Record; expect(input.sleepApplication).toBe(false); expect(input.healthcheckPath).toBe(STARTER_HEALTHCHECK_PATH); expect(input.healthcheckPath).toBe("/"); expect(input.region).toBe(STARTER_REGION); expect(input.region).toBe("us-west1"); } }); it("attaches registry credentials when provided", async () => { const { gql, calls } = makeMockGql(); await provisionStarterFleet({ gql, projectId: PROJECT, stagingEnvId: STAGING_ENV_ID, targets: TWO_TARGETS, registryCredentials: { username: "ck-bot", password: "ghp_x" }, }); const update = calls.find((c) => c.query.includes("serviceInstanceUpdate("), )!; const input = update.variables.input as Record; expect(input.registryCredentials).toEqual({ username: "ck-bot", password: "ghp_x", }); }); it("omits registry credentials when none are provided", async () => { const { gql, calls } = makeMockGql(); await provisionStarterFleet({ gql, projectId: PROJECT, stagingEnvId: STAGING_ENV_ID, targets: TWO_TARGETS, }); const update = calls.find((c) => c.query.includes("serviceInstanceUpdate("), )!; const input = update.variables.input as Record; expect(input.registryCredentials).toBeUndefined(); }); it("creates a staging-scoped domain per new service", async () => { const { gql, calls } = makeMockGql(); const summary = await provisionStarterFleet({ gql, projectId: PROJECT, stagingEnvId: STAGING_ENV_ID, targets: TWO_TARGETS, }); const domainCalls = calls.filter((c) => c.query.includes("serviceDomainCreate("), ); expect(domainCalls).toHaveLength(2); for (const d of domainCalls) { const input = d.variables.input as { environmentId: string }; expect(input.environmentId).toBe(STAGING_ENV_ID); } expect(summary.records.every((r) => r.domainAction === "created")).toBe( true, ); }); it("retries the post-create instance update on transient 'ServiceInstance not found'", async () => { // Railway materializes the env-scoped instance asynchronously after // serviceCreate, so the first serviceInstanceUpdate can fail with // "ServiceInstance not found". The provisioner must retry rather than // abort the whole fleet. let updateAttempts = 0; const gql: RailwayGqlFn = vi.fn( async ( query: string, variables: Record = {}, ): Promise => { if (query.includes("project(id:")) { return { project: { services: { edges: [] } } } as T; } if (query.includes("serviceCreate(")) { const input = variables.input as { name: string }; return { serviceCreate: { id: "new-1", name: input.name } } as T; } if (query.includes("serviceInstanceUpdate(")) { updateAttempts += 1; if (updateAttempts === 1) { throw new Error( "Railway GraphQL errors:\n - ServiceInstance not found", ); } return { serviceInstanceUpdate: true } as T; } if (query.includes("serviceInstanceRedeploy(")) { return { serviceInstanceRedeploy: true } as T; } if (query.includes("serviceDomainCreate(")) { return { serviceDomainCreate: { domain: "new-1.up.railway.app" }, } as T; } throw new Error(`unexpected query:\n${query}`); }, ) as RailwayGqlFn; const summary = await provisionStarterFleet({ gql, projectId: PROJECT, stagingEnvId: STAGING_ENV_ID, targets: [TWO_TARGETS[0]], sleepMs: NO_SLEEP, }); expect(updateAttempts).toBe(2); // failed once, succeeded on retry expect(summary.records[0].action).toBe("created"); }); it("retries Railway's INTERPOLATED 'Service not found' (id in message)", async () => { // Railway's real eventual-consistency message interpolates the service id: // "Service abc-123 not found" — NOT the contiguous "Service not found". // The transient regex must match the interpolated form so the post-create // race is retried; otherwise the run aborts on a benign timing error. let updateAttempts = 0; const gql: RailwayGqlFn = vi.fn( async ( query: string, variables: Record = {}, ): Promise => { if (query.includes("project(id:")) { return { project: { services: { edges: [] } } } as T; } if (query.includes("serviceCreate(")) { const input = variables.input as { name: string }; return { serviceCreate: { id: "new-1", name: input.name } } as T; } if (query.includes("serviceInstanceUpdate(")) { updateAttempts += 1; if (updateAttempts === 1) { throw new Error( "Railway GraphQL errors:\n - Service abc-123-def not found", ); } return { serviceInstanceUpdate: true } as T; } if (query.includes("serviceInstanceRedeploy(")) { return { serviceInstanceRedeploy: true } as T; } if (query.includes("serviceDomainCreate(")) { return { serviceDomainCreate: { domain: "new-1.up.railway.app" }, } as T; } throw new Error(`unexpected query:\n${query}`); }, ) as RailwayGqlFn; const summary = await provisionStarterFleet({ gql, projectId: PROJECT, stagingEnvId: STAGING_ENV_ID, targets: [TWO_TARGETS[0]], sleepMs: NO_SLEEP, }); expect(updateAttempts).toBe(2); // failed once on interpolated msg, retried expect(summary.records[0].action).toBe("created"); }); it("THROWS when serviceCreate returns no id (silent-success guard)", async () => { // serviceCreate must yield a non-empty service id. A null/empty id means // the create silently failed — every downstream mutation would target an // invalid service. Fail loud, naming the service. const gql: RailwayGqlFn = vi.fn( async (query: string): Promise => { if (query.includes("project(id:")) { return { project: { services: { edges: [] } } } as T; } if (query.includes("serviceCreate(")) { return { serviceCreate: { id: "", name: "starter-mastra" } } as T; } throw new Error(`unexpected query:\n${query}`); }, ) as RailwayGqlFn; await expect( provisionStarterFleet({ gql, projectId: PROJECT, stagingEnvId: STAGING_ENV_ID, targets: [TWO_TARGETS[0]], sleepMs: NO_SLEEP, }), ).rejects.toThrow(/serviceCreate.*starter-mastra/i); }); it("THROWS when serviceInstanceUpdate returns false (settings NOT applied)", async () => { // serviceInstanceUpdate returns Boolean! — a `false` return means // sleepApplication/healthcheck/image/creds were NOT applied. Reporting // success here would run the service WITHOUT sleep. Fail loud. const gql: RailwayGqlFn = vi.fn( async (query: string): Promise => { if (query.includes("project(id:")) { return { project: { services: { edges: [] } } } as T; } if (query.includes("serviceCreate(")) { return { serviceCreate: { id: "new-1", name: "starter-mastra" }, } as T; } if (query.includes("serviceInstanceUpdate(")) { return { serviceInstanceUpdate: false } as T; } throw new Error(`unexpected query:\n${query}`); }, ) as RailwayGqlFn; await expect( provisionStarterFleet({ gql, projectId: PROJECT, stagingEnvId: STAGING_ENV_ID, targets: [TWO_TARGETS[0]], sleepMs: NO_SLEEP, }), ).rejects.toThrow(/serviceInstanceUpdate.*starter-mastra/i); }); it("does NOT log 'configured ...' before the update result is verified", async () => { // The "configured ..." log line must come AFTER the update result is // verified — a false return must throw without ever claiming the settings // were configured. const logs: string[] = []; const gql: RailwayGqlFn = vi.fn( async (query: string): Promise => { if (query.includes("project(id:")) { return { project: { services: { edges: [] } } } as T; } if (query.includes("serviceCreate(")) { return { serviceCreate: { id: "new-1", name: "starter-mastra" }, } as T; } if (query.includes("serviceInstanceUpdate(")) { return { serviceInstanceUpdate: false } as T; } throw new Error(`unexpected query:\n${query}`); }, ) as RailwayGqlFn; await expect( provisionStarterFleet({ gql, projectId: PROJECT, stagingEnvId: STAGING_ENV_ID, targets: [TWO_TARGETS[0]], sleepMs: NO_SLEEP, log: (line) => logs.push(line), }), ).rejects.toThrow(); expect(logs.some((l) => l.includes("configured"))).toBe(false); }); it("THROWS when serviceDomainCreate returns no domain on the create path", async () => { // serviceDomainCreate must yield a domain on the create path. A // null/empty domain means the create silently failed. const gql: RailwayGqlFn = vi.fn( async (query: string): Promise => { if (query.includes("project(id:")) { return { project: { services: { edges: [] } } } as T; } if (query.includes("serviceCreate(")) { return { serviceCreate: { id: "new-1", name: "starter-mastra" }, } as T; } if (query.includes("serviceInstanceUpdate(")) { return { serviceInstanceUpdate: true } as T; } if (query.includes("serviceInstanceRedeploy(")) { return { serviceInstanceRedeploy: true } as T; } if (query.includes("serviceDomainCreate(")) { return { serviceDomainCreate: { domain: "" } } as T; } throw new Error(`unexpected query:\n${query}`); }, ) as RailwayGqlFn; await expect( provisionStarterFleet({ gql, projectId: PROJECT, stagingEnvId: STAGING_ENV_ID, targets: [TWO_TARGETS[0]], sleepMs: NO_SLEEP, }), ).rejects.toThrow(/serviceDomainCreate.*starter-mastra/i); }); it("absorbs a serviceCreate 'already exists' rejection → falls to update, fleet continues", async () => { // A snapshot-miss (Railway eventual consistency) makes a service look // absent → CREATE path. If serviceCreate then rejects with a non-transient // "already exists", the script must NOT abort: it re-fetches the service // id by name and falls through to the UPDATE path so the fleet converges. let createAttempts = 0; const gql: RailwayGqlFn = vi.fn( async ( query: string, variables: Record = {}, ): Promise => { if (query.includes("project(id:")) { // First snapshot is empty (miss). A re-fetch by name must surface // the now-visible service so the update path can use its id. if (createAttempts === 0) { return { project: { services: { edges: [] } } } as T; } return { project: { services: { edges: [ { node: { id: "found-mastra", name: "starter-mastra", serviceInstances: { edges: [] }, }, }, ], }, }, } as T; } if (query.includes("serviceCreate(")) { createAttempts += 1; const input = variables.input as { name: string }; throw new Error( `Railway GraphQL errors:\n - Service ${input.name} already exists`, ); } if (query.includes("serviceInstanceUpdate(")) { return { serviceInstanceUpdate: true } as T; } if (query.includes("serviceInstanceRedeploy(")) { return { serviceInstanceRedeploy: true } as T; } if (query.includes("serviceDomainCreate(")) { return { serviceDomainCreate: { domain: "found-mastra.up.railway.app" }, } as T; } throw new Error(`unexpected query:\n${query}`); }, ) as RailwayGqlFn; const summary = await provisionStarterFleet({ gql, projectId: PROJECT, stagingEnvId: STAGING_ENV_ID, targets: [TWO_TARGETS[0]], sleepMs: NO_SLEEP, }); // Did not abort: the target was processed via the update path with the // re-fetched id. expect(summary.records).toHaveLength(1); expect(summary.records[0].action).toBe("updated"); expect(summary.records[0].serviceId).toBe("found-mastra"); }); it("does NOT retry a non-transient error (fails loud)", async () => { const gql: RailwayGqlFn = vi.fn( async (query: string): Promise => { if (query.includes("project(id:")) { return { project: { services: { edges: [] } } } as T; } if (query.includes("serviceCreate(")) { return { serviceCreate: { id: "new-1", name: "starter-mastra" }, } as T; } if (query.includes("serviceInstanceUpdate(")) { throw new Error("Railway GraphQL errors:\n - Unauthorized"); } throw new Error(`unexpected query:\n${query}`); }, ) as RailwayGqlFn; await expect( provisionStarterFleet({ gql, projectId: PROJECT, stagingEnvId: STAGING_ENV_ID, targets: [TWO_TARGETS[0]], sleepMs: NO_SLEEP, }), ).rejects.toThrow(/Unauthorized/); }); it("does NOT treat a newline-joined multi-error blob as transient (no cross-line bridge)", async () => { // The transient regex must not bridge "Service" on one error line to // "not found" on a DIFFERENT line of a newline-joined GraphQL error blob. // Railway interpolates the id on a SINGLE line ("Service not found"), // which never contains a newline. A blob that pairs an unrelated "Service // ..." line with a "... not found" line is NOT the eventual-consistency // signal and must fail loud immediately (no wasted retries). let updateAttempts = 0; const gql: RailwayGqlFn = vi.fn( async (query: string): Promise => { if (query.includes("project(id:")) { return { project: { services: { edges: [] } } } as T; } if (query.includes("serviceCreate(")) { return { serviceCreate: { id: "new-1", name: "starter-mastra" }, } as T; } if (query.includes("serviceInstanceUpdate(")) { updateAttempts += 1; throw new Error( "Railway GraphQL errors:\n - Service config invalid\n - Volume not found", ); } throw new Error(`unexpected query:\n${query}`); }, ) as RailwayGqlFn; await expect( provisionStarterFleet({ gql, projectId: PROJECT, stagingEnvId: STAGING_ENV_ID, targets: [TWO_TARGETS[0]], sleepMs: NO_SLEEP, }), ).rejects.toThrow(/config invalid/); // Failed loud on the first attempt — not retried as transient. expect(updateAttempts).toBe(1); }); it("wraps the rethrow with retry-exhaustion context when the schedule is exhausted", async () => { // When every transient retry is exhausted, the final rethrow must carry // context (how many retries, that the error was transient) with the // original error as `cause`. const gql: RailwayGqlFn = vi.fn( async (query: string): Promise => { if (query.includes("project(id:")) { return { project: { services: { edges: [] } } } as T; } if (query.includes("serviceCreate(")) { return { serviceCreate: { id: "new-1", name: "starter-mastra" }, } as T; } if (query.includes("serviceInstanceUpdate(")) { // Always transient → exhausts the schedule. throw new Error( "Railway GraphQL errors:\n - ServiceInstance not found", ); } throw new Error(`unexpected query:\n${query}`); }, ) as RailwayGqlFn; let caught: unknown; try { await provisionStarterFleet({ gql, projectId: PROJECT, stagingEnvId: STAGING_ENV_ID, targets: [TWO_TARGETS[0]], sleepMs: NO_SLEEP, }); } catch (e) { caught = e; } expect(caught).toBeInstanceOf(Error); expect((caught as Error).message).toMatch(/exhausted.*retries.*transient/i); // The original transient error is preserved as the cause. expect((caught as Error).cause).toBeInstanceOf(Error); expect(((caught as Error).cause as Error).message).toMatch( /ServiceInstance not found/, ); }); }); // ── provisionStarterFleet: idempotent update path ─────────────────────── describe("provisionStarterFleet — idempotent update path", () => { it("UPDATES (does not re-create) a starter service that already exists", async () => { const { gql, calls } = makeMockGql([ { id: "existing-mastra", name: "starter-mastra", stagingDomain: "starter-mastra.up.railway.app", }, ]); const summary = await provisionStarterFleet({ gql, projectId: PROJECT, stagingEnvId: STAGING_ENV_ID, targets: TWO_TARGETS, }); const mastra = summary.records.find( (r) => r.serviceName === "starter-mastra", )!; const adk = summary.records.find((r) => r.serviceName === "starter-adk")!; // mastra already existed → updated, reuses its id, no create call for it. expect(mastra.action).toBe("updated"); expect(mastra.serviceId).toBe("existing-mastra"); // adk did not exist → created. expect(adk.action).toBe("created"); const creates = calls.filter((c) => c.query.includes("serviceCreate(")); expect(creates).toHaveLength(1); expect((creates[0].variables.input as { name: string }).name).toBe( "starter-adk", ); // The existing service still gets its instance settings re-applied // (converge sleep/healthcheck/region on re-run). const updates = calls.filter((c) => c.query.includes("serviceInstanceUpdate("), ); expect(updates).toHaveLength(2); }); it("does NOT create a duplicate domain when the existing service already has one", async () => { const { gql, calls } = makeMockGql([ { id: "existing-mastra", name: "starter-mastra", stagingDomain: "starter-mastra.up.railway.app", }, ]); const summary = await provisionStarterFleet({ gql, projectId: PROJECT, stagingEnvId: STAGING_ENV_ID, // only the already-domained service targets: [TWO_TARGETS[0]], }); const domainCalls = calls.filter((c) => c.query.includes("serviceDomainCreate("), ); expect(domainCalls).toHaveLength(0); expect(summary.records[0].domainAction).toBe("existing"); }); it("creates a domain for an existing service that lacks one", async () => { const { gql, calls } = makeMockGql([ { id: "existing-mastra", name: "starter-mastra" }, // no domain ]); await provisionStarterFleet({ gql, projectId: PROJECT, stagingEnvId: STAGING_ENV_ID, targets: [TWO_TARGETS[0]], }); const domainCalls = calls.filter((c) => c.query.includes("serviceDomainCreate("), ); expect(domainCalls).toHaveLength(1); }); }); // ── provisionStarterFleet: dry run ────────────────────────────────────── describe("provisionStarterFleet — dry run", () => { it("sends no mutations in dry-run mode (only the read query)", async () => { const { gql, calls } = makeMockGql(); const summary = await provisionStarterFleet({ gql, projectId: PROJECT, stagingEnvId: STAGING_ENV_ID, targets: TWO_TARGETS, dryRun: true, }); expect(calls.every((c) => c.query.includes("project(id:"))).toBe(true); expect(calls.filter((c) => c.query.includes("Create("))).toHaveLength(0); expect( calls.filter((c) => c.query.includes("serviceInstanceUpdate(")), ).toHaveLength(0); expect( calls.filter((c) => c.query.includes("serviceInstanceRedeploy(")), ).toHaveLength(0); expect(summary.records).toHaveLength(2); }); it("reports a NEW service's domain as 'would-create' (faithful preview, not 'skipped')", async () => { // A real run creates a domain for a new service, so dry-run must preview // that as "would-create" rather than the misleading "skipped". const { gql } = makeMockGql(); const summary = await provisionStarterFleet({ gql, projectId: PROJECT, stagingEnvId: STAGING_ENV_ID, targets: TWO_TARGETS, dryRun: true, }); expect( summary.records.every((r) => r.domainAction === "would-create"), ).toBe(true); }); it("reports an EXISTING already-domained service as 'existing' in dry-run", async () => { const { gql } = makeMockGql([ { id: "existing-mastra", name: "starter-mastra", stagingDomain: "starter-mastra.up.railway.app", }, ]); const summary = await provisionStarterFleet({ gql, projectId: PROJECT, stagingEnvId: STAGING_ENV_ID, targets: [TWO_TARGETS[0]], dryRun: true, }); expect(summary.records[0].domainAction).toBe("existing"); }); }); // ── provisionStarterFleet: redeploy (image must actually run) ──────────── describe("provisionStarterFleet — redeploy", () => { it("triggers serviceInstanceRedeploy after configuring a NEW service so the image actually runs", async () => { // serviceCreate + serviceInstanceUpdate(source.image) pins the image but // does NOT start a deployment (auto-updates only fire on a NEW digest // push; bin/railway documents update+redeploy as the canonical pattern). // Without an explicit redeploy the service never runs and starter_smoke // would never find it up. const { gql, calls } = makeMockGql(); await provisionStarterFleet({ gql, projectId: PROJECT, stagingEnvId: STAGING_ENV_ID, targets: TWO_TARGETS, }); const redeploys = calls.filter((c) => c.query.includes("serviceInstanceRedeploy("), ); expect(redeploys).toHaveLength(2); for (const r of redeploys) { expect(r.variables.environmentId).toBe(STAGING_ENV_ID); expect(r.variables.environmentId).not.toBe(PRODUCTION_ENV_ID); } }); it("triggers serviceInstanceRedeploy on the update path (re-asserted image must run)", async () => { const { gql, calls } = makeMockGql([ { id: "existing-mastra", name: "starter-mastra", stagingDomain: "starter-mastra.up.railway.app", }, ]); await provisionStarterFleet({ gql, projectId: PROJECT, stagingEnvId: STAGING_ENV_ID, targets: [TWO_TARGETS[0]], }); const redeploys = calls.filter((c) => c.query.includes("serviceInstanceRedeploy("), ); expect(redeploys).toHaveLength(1); expect(redeploys[0].variables.serviceId).toBe("existing-mastra"); }); it("sends NO redeploy in dry-run mode", async () => { const { gql, calls } = makeMockGql(); await provisionStarterFleet({ gql, projectId: PROJECT, stagingEnvId: STAGING_ENV_ID, targets: TWO_TARGETS, dryRun: true, }); expect( calls.filter((c) => c.query.includes("serviceInstanceRedeploy(")), ).toHaveLength(0); }); it("THROWS when serviceInstanceRedeploy returns false (image would not run)", async () => { // serviceInstanceRedeploy returns Boolean! (verified against // redeploy-env.ts:117 + bin/railway RestoreCommand). A `false` return // means Railway did not start a deployment — the image would never run, // so the provisioner must fail loud rather than report success. const gql: RailwayGqlFn = vi.fn( async (query: string): Promise => { if (query.includes("project(id:")) { return { project: { services: { edges: [] } } } as T; } if (query.includes("serviceCreate(")) { return { serviceCreate: { id: "new-1", name: "starter-mastra" }, } as T; } if (query.includes("serviceInstanceUpdate(")) { return { serviceInstanceUpdate: true } as T; } if (query.includes("serviceInstanceRedeploy(")) { return { serviceInstanceRedeploy: false } as T; } throw new Error(`unexpected query:\n${query}`); }, ) as RailwayGqlFn; await expect( provisionStarterFleet({ gql, projectId: PROJECT, stagingEnvId: STAGING_ENV_ID, targets: [TWO_TARGETS[0]], sleepMs: NO_SLEEP, }), ).rejects.toThrow(/image will not run/); }); it("THROWS when serviceInstanceRedeploy returns null (no deployment started)", async () => { const gql: RailwayGqlFn = vi.fn( async (query: string): Promise => { if (query.includes("project(id:")) { return { project: { services: { edges: [] } } } as T; } if (query.includes("serviceCreate(")) { return { serviceCreate: { id: "new-1", name: "starter-mastra" }, } as T; } if (query.includes("serviceInstanceUpdate(")) { return { serviceInstanceUpdate: true } as T; } if (query.includes("serviceInstanceRedeploy(")) { return { serviceInstanceRedeploy: null } as T; } throw new Error(`unexpected query:\n${query}`); }, ) as RailwayGqlFn; await expect( provisionStarterFleet({ gql, projectId: PROJECT, stagingEnvId: STAGING_ENV_ID, targets: [TWO_TARGETS[0]], sleepMs: NO_SLEEP, }), ).rejects.toThrow(/image will not run/); }); it("SUCCEEDS when serviceInstanceRedeploy returns true (the verified Boolean! contract)", async () => { const { gql } = makeMockGql(); const summary = await provisionStarterFleet({ gql, projectId: PROJECT, stagingEnvId: STAGING_ENV_ID, targets: [TWO_TARGETS[0]], sleepMs: NO_SLEEP, }); expect(summary.records[0].action).toBe("created"); }); }); // ── provisionStarterFleet: idempotent domain re-create (partial failure) ─ describe("provisionStarterFleet — domain already-exists is a benign no-op", () => { it("treats a 'domain already exists' error as existing and CONTINUES the fleet (does not abort)", async () => { // Partial-failure re-run: the start-of-run snapshot missed the domain // (Railway eventual consistency / a run that died mid-fleet), so the // provisioner re-issues serviceDomainCreate and Railway rejects it with a // NON-transient "already exists" error. This must converge (mark existing, // keep going), not abort the remaining fleet. let domainAttempts = 0; const gql: RailwayGqlFn = vi.fn( async ( query: string, variables: Record = {}, ): Promise => { if (query.includes("project(id:")) { return { project: { services: { edges: [] } } } as T; } if (query.includes("serviceCreate(")) { const input = variables.input as { name: string }; return { serviceCreate: { id: `svc-${input.name}`, name: input.name }, } as T; } if (query.includes("serviceInstanceUpdate(")) { return { serviceInstanceUpdate: true } as T; } if (query.includes("serviceInstanceRedeploy(")) { return { serviceInstanceRedeploy: true } as T; } if (query.includes("serviceDomainCreate(")) { domainAttempts += 1; // First target's domain create rejects as already-existing. if (domainAttempts === 1) { throw new Error( "Railway GraphQL errors:\n - A domain already exists for this service", ); } return { serviceDomainCreate: { domain: "svc.up.railway.app" }, } as T; } throw new Error(`unexpected query:\n${query}`); }, ) as RailwayGqlFn; const logs: string[] = []; const summary = await provisionStarterFleet({ gql, projectId: PROJECT, stagingEnvId: STAGING_ENV_ID, targets: TWO_TARGETS, sleepMs: NO_SLEEP, log: (line) => logs.push(line), }); // Both targets processed — the fleet did NOT abort. expect(summary.records).toHaveLength(2); // The duplicate-domain target is marked existing (benign no-op). expect(summary.records[0].domainAction).toBe("existing"); // The second target's domain succeeded normally. expect(summary.records[1].domainAction).toBe("created"); // Forensic trail: the benign no-op log line includes the ACTUAL matched // Railway error message, not just a generic "already exists" note. expect( logs.some((l) => l.includes("A domain already exists for this service")), ).toBe(true); }); it("still ABORTS on a genuine non-transient domain-create error", async () => { const gql: RailwayGqlFn = vi.fn( async (query: string): Promise => { if (query.includes("project(id:")) { return { project: { services: { edges: [] } } } as T; } if (query.includes("serviceCreate(")) { return { serviceCreate: { id: "svc-1", name: "starter-mastra" }, } as T; } if (query.includes("serviceInstanceUpdate(")) { return { serviceInstanceUpdate: true } as T; } if (query.includes("serviceInstanceRedeploy(")) { return { serviceInstanceRedeploy: true } as T; } if (query.includes("serviceDomainCreate(")) { throw new Error("Railway GraphQL errors:\n - Unauthorized"); } throw new Error(`unexpected query:\n${query}`); }, ) as RailwayGqlFn; await expect( provisionStarterFleet({ gql, projectId: PROJECT, stagingEnvId: STAGING_ENV_ID, targets: [TWO_TARGETS[0]], sleepMs: NO_SLEEP, }), ).rejects.toThrow(/Unauthorized/); }); }); // ── parseArgs: argv validation ─────────────────────────────────────────── describe("parseArgs", () => { it("parses known flags", () => { expect(parseArgs(["--dry-run"])).toEqual({ help: false, list: false, dryRun: true, }); expect(parseArgs(["--list"])).toMatchObject({ list: true }); expect(parseArgs(["--help"])).toMatchObject({ help: true }); expect(parseArgs([])).toEqual({ help: false, list: false, dryRun: false }); }); it("REJECTS an unrecognized flag (mistyped --dry-rn must not silently run live)", () => { expect(() => parseArgs(["--dry-rn"])).toThrow(/unknown|unrecognized/i); }); it("rejects any unrecognized dash-prefixed argument", () => { expect(() => parseArgs(["--list", "--bogus"])).toThrow( /unknown|unrecognized/i, ); expect(() => parseArgs(["-x"])).toThrow(/unknown|unrecognized/i); }); });