Files
2026-07-13 12:58:18 +08:00

1353 lines
47 KiB
TypeScript

/**
* 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<string, unknown>;
}
/**
* 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 <T = unknown>(
query: string,
variables: Record<string, unknown> = {},
): Promise<T> => {
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<void> => {};
// ── 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 <T = unknown>(): Promise<T> =>
({
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 <T = unknown>(
_query: string,
variables: Record<string, unknown> = {},
): Promise<T> => {
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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 <T = unknown>(
query: string,
variables: Record<string, unknown> = {},
): Promise<T> => {
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 <id> 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 <T = unknown>(
query: string,
variables: Record<string, unknown> = {},
): Promise<T> => {
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 <T = unknown>(query: string): Promise<T> => {
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 <T = unknown>(query: string): Promise<T> => {
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 <T = unknown>(query: string): Promise<T> => {
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 <T = unknown>(query: string): Promise<T> => {
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 <T = unknown>(
query: string,
variables: Record<string, unknown> = {},
): Promise<T> => {
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 <T = unknown>(query: string): Promise<T> => {
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 <id> 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 <T = unknown>(query: string): Promise<T> => {
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 <T = unknown>(query: string): Promise<T> => {
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 <T = unknown>(query: string): Promise<T> => {
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 <T = unknown>(query: string): Promise<T> => {
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 <T = unknown>(
query: string,
variables: Record<string, unknown> = {},
): Promise<T> => {
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 <T = unknown>(query: string): Promise<T> => {
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);
});
});