@@ -15,6 +15,7 @@ import { describe, it, expect, vi } from "vitest";
1515import {
1616 deriveStarterTargets ,
1717 fetchExistingServices ,
18+ parseArgs ,
1819 provisionStarterFleet ,
1920 resolveRegistryCredentials ,
2021 STARTER_FLEET_PREFIX ,
@@ -193,6 +194,10 @@ function makeMockGql(
193194 return { serviceInstanceUpdate : true } as T ;
194195 }
195196
197+ if ( query . includes ( "serviceInstanceRedeploy(" ) ) {
198+ return { serviceInstanceRedeploy : true } as T ;
199+ }
200+
196201 if ( query . includes ( "serviceDomainCreate(" ) ) {
197202 const input = variables . input as { serviceId : string } ;
198203 return {
@@ -409,6 +414,9 @@ describe("provisionStarterFleet — create path", () => {
409414 }
410415 return { serviceInstanceUpdate : true } as T ;
411416 }
417+ if ( query . includes ( "serviceInstanceRedeploy(" ) ) {
418+ return { serviceInstanceRedeploy : true } as T ;
419+ }
412420 if ( query . includes ( "serviceDomainCreate(" ) ) {
413421 return {
414422 serviceDomainCreate : { domain : "new-1.up.railway.app" } ,
@@ -558,6 +566,228 @@ describe("provisionStarterFleet — dry run", () => {
558566 expect (
559567 calls . filter ( ( c ) => c . query . includes ( "serviceInstanceUpdate(" ) ) ,
560568 ) . toHaveLength ( 0 ) ;
569+ expect (
570+ calls . filter ( ( c ) => c . query . includes ( "serviceInstanceRedeploy(" ) ) ,
571+ ) . toHaveLength ( 0 ) ;
572+ expect ( summary . records ) . toHaveLength ( 2 ) ;
573+ } ) ;
574+
575+ it ( "reports a NEW service's domain as 'would-create' (faithful preview, not 'skipped')" , async ( ) => {
576+ // A real run creates a domain for a new service, so dry-run must preview
577+ // that as "would-create" rather than the misleading "skipped".
578+ const { gql } = makeMockGql ( ) ;
579+ const summary = await provisionStarterFleet ( {
580+ gql,
581+ projectId : PROJECT ,
582+ stagingEnvId : STAGING_ENV_ID ,
583+ targets : TWO_TARGETS ,
584+ dryRun : true ,
585+ } ) ;
586+ expect (
587+ summary . records . every ( ( r ) => r . domainAction === "would-create" ) ,
588+ ) . toBe ( true ) ;
589+ } ) ;
590+
591+ it ( "reports an EXISTING already-domained service as 'existing' in dry-run" , async ( ) => {
592+ const { gql } = makeMockGql ( [
593+ {
594+ id : "existing-mastra" ,
595+ name : "starter-mastra" ,
596+ stagingDomain : "starter-mastra.up.railway.app" ,
597+ } ,
598+ ] ) ;
599+ const summary = await provisionStarterFleet ( {
600+ gql,
601+ projectId : PROJECT ,
602+ stagingEnvId : STAGING_ENV_ID ,
603+ targets : [ TWO_TARGETS [ 0 ] ] ,
604+ dryRun : true ,
605+ } ) ;
606+ expect ( summary . records [ 0 ] . domainAction ) . toBe ( "existing" ) ;
607+ } ) ;
608+ } ) ;
609+
610+ // ── provisionStarterFleet: redeploy (image must actually run) ────────────
611+
612+ describe ( "provisionStarterFleet — redeploy" , ( ) => {
613+ it ( "triggers serviceInstanceRedeploy after configuring a NEW service so the image actually runs" , async ( ) => {
614+ // serviceCreate + serviceInstanceUpdate(source.image) pins the image but
615+ // does NOT start a deployment (auto-updates only fire on a NEW digest
616+ // push; bin/railway documents update+redeploy as the canonical pattern).
617+ // Without an explicit redeploy the service never runs and starter_smoke
618+ // would never find it up.
619+ const { gql, calls } = makeMockGql ( ) ;
620+ await provisionStarterFleet ( {
621+ gql,
622+ projectId : PROJECT ,
623+ stagingEnvId : STAGING_ENV_ID ,
624+ targets : TWO_TARGETS ,
625+ } ) ;
626+ const redeploys = calls . filter ( ( c ) =>
627+ c . query . includes ( "serviceInstanceRedeploy(" ) ,
628+ ) ;
629+ expect ( redeploys ) . toHaveLength ( 2 ) ;
630+ for ( const r of redeploys ) {
631+ expect ( r . variables . environmentId ) . toBe ( STAGING_ENV_ID ) ;
632+ expect ( r . variables . environmentId ) . not . toBe ( PRODUCTION_ENV_ID ) ;
633+ }
634+ } ) ;
635+
636+ it ( "triggers serviceInstanceRedeploy on the update path (re-asserted image must run)" , async ( ) => {
637+ const { gql, calls } = makeMockGql ( [
638+ {
639+ id : "existing-mastra" ,
640+ name : "starter-mastra" ,
641+ stagingDomain : "starter-mastra.up.railway.app" ,
642+ } ,
643+ ] ) ;
644+ await provisionStarterFleet ( {
645+ gql,
646+ projectId : PROJECT ,
647+ stagingEnvId : STAGING_ENV_ID ,
648+ targets : [ TWO_TARGETS [ 0 ] ] ,
649+ } ) ;
650+ const redeploys = calls . filter ( ( c ) =>
651+ c . query . includes ( "serviceInstanceRedeploy(" ) ,
652+ ) ;
653+ expect ( redeploys ) . toHaveLength ( 1 ) ;
654+ expect ( redeploys [ 0 ] . variables . serviceId ) . toBe ( "existing-mastra" ) ;
655+ } ) ;
656+
657+ it ( "sends NO redeploy in dry-run mode" , async ( ) => {
658+ const { gql, calls } = makeMockGql ( ) ;
659+ await provisionStarterFleet ( {
660+ gql,
661+ projectId : PROJECT ,
662+ stagingEnvId : STAGING_ENV_ID ,
663+ targets : TWO_TARGETS ,
664+ dryRun : true ,
665+ } ) ;
666+ expect (
667+ calls . filter ( ( c ) => c . query . includes ( "serviceInstanceRedeploy(" ) ) ,
668+ ) . toHaveLength ( 0 ) ;
669+ } ) ;
670+ } ) ;
671+
672+ // ── provisionStarterFleet: idempotent domain re-create (partial failure) ─
673+
674+ describe ( "provisionStarterFleet — domain already-exists is a benign no-op" , ( ) => {
675+ it ( "treats a 'domain already exists' error as existing and CONTINUES the fleet (does not abort)" , async ( ) => {
676+ // Partial-failure re-run: the start-of-run snapshot missed the domain
677+ // (Railway eventual consistency / a run that died mid-fleet), so the
678+ // provisioner re-issues serviceDomainCreate and Railway rejects it with a
679+ // NON-transient "already exists" error. This must converge (mark existing,
680+ // keep going), not abort the remaining fleet.
681+ let domainAttempts = 0 ;
682+ const gql : RailwayGqlFn = vi . fn (
683+ async < T = unknown > (
684+ query : string ,
685+ variables : Record < string , unknown > = { } ,
686+ ) : Promise < T > => {
687+ if ( query . includes ( "project(id:" ) ) {
688+ return { project : { services : { edges : [ ] } } } as T ;
689+ }
690+ if ( query . includes ( "serviceCreate(" ) ) {
691+ const input = variables . input as { name : string } ;
692+ return {
693+ serviceCreate : { id : `svc-${ input . name } ` , name : input . name } ,
694+ } as T ;
695+ }
696+ if ( query . includes ( "serviceInstanceUpdate(" ) ) {
697+ return { serviceInstanceUpdate : true } as T ;
698+ }
699+ if ( query . includes ( "serviceInstanceRedeploy(" ) ) {
700+ return { serviceInstanceRedeploy : true } as T ;
701+ }
702+ if ( query . includes ( "serviceDomainCreate(" ) ) {
703+ domainAttempts += 1 ;
704+ // First target's domain create rejects as already-existing.
705+ if ( domainAttempts === 1 ) {
706+ throw new Error (
707+ "Railway GraphQL errors:\n - A domain already exists for this service" ,
708+ ) ;
709+ }
710+ return {
711+ serviceDomainCreate : { domain : "svc.up.railway.app" } ,
712+ } as T ;
713+ }
714+ throw new Error ( `unexpected query:\n${ query } ` ) ;
715+ } ,
716+ ) as RailwayGqlFn ;
717+
718+ const summary = await provisionStarterFleet ( {
719+ gql,
720+ projectId : PROJECT ,
721+ stagingEnvId : STAGING_ENV_ID ,
722+ targets : TWO_TARGETS ,
723+ sleepMs : NO_SLEEP ,
724+ } ) ;
725+ // Both targets processed — the fleet did NOT abort.
561726 expect ( summary . records ) . toHaveLength ( 2 ) ;
727+ // The duplicate-domain target is marked existing (benign no-op).
728+ expect ( summary . records [ 0 ] . domainAction ) . toBe ( "existing" ) ;
729+ // The second target's domain succeeded normally.
730+ expect ( summary . records [ 1 ] . domainAction ) . toBe ( "created" ) ;
731+ } ) ;
732+
733+ it ( "still ABORTS on a genuine non-transient domain-create error" , async ( ) => {
734+ const gql : RailwayGqlFn = vi . fn (
735+ async < T = unknown > ( query : string ) : Promise < T > => {
736+ if ( query . includes ( "project(id:" ) ) {
737+ return { project : { services : { edges : [ ] } } } as T ;
738+ }
739+ if ( query . includes ( "serviceCreate(" ) ) {
740+ return {
741+ serviceCreate : { id : "svc-1" , name : "starter-mastra" } ,
742+ } as T ;
743+ }
744+ if ( query . includes ( "serviceInstanceUpdate(" ) ) {
745+ return { serviceInstanceUpdate : true } as T ;
746+ }
747+ if ( query . includes ( "serviceInstanceRedeploy(" ) ) {
748+ return { serviceInstanceRedeploy : true } as T ;
749+ }
750+ if ( query . includes ( "serviceDomainCreate(" ) ) {
751+ throw new Error ( "Railway GraphQL errors:\n - Unauthorized" ) ;
752+ }
753+ throw new Error ( `unexpected query:\n${ query } ` ) ;
754+ } ,
755+ ) as RailwayGqlFn ;
756+
757+ await expect (
758+ provisionStarterFleet ( {
759+ gql,
760+ projectId : PROJECT ,
761+ stagingEnvId : STAGING_ENV_ID ,
762+ targets : [ TWO_TARGETS [ 0 ] ] ,
763+ sleepMs : NO_SLEEP ,
764+ } ) ,
765+ ) . rejects . toThrow ( / U n a u t h o r i z e d / ) ;
766+ } ) ;
767+ } ) ;
768+
769+ // ── parseArgs: argv validation ───────────────────────────────────────────
770+
771+ describe ( "parseArgs" , ( ) => {
772+ it ( "parses known flags" , ( ) => {
773+ expect ( parseArgs ( [ "--dry-run" ] ) ) . toEqual ( {
774+ help : false ,
775+ list : false ,
776+ dryRun : true ,
777+ } ) ;
778+ expect ( parseArgs ( [ "--list" ] ) ) . toMatchObject ( { list : true } ) ;
779+ expect ( parseArgs ( [ "--help" ] ) ) . toMatchObject ( { help : true } ) ;
780+ expect ( parseArgs ( [ ] ) ) . toEqual ( { help : false , list : false , dryRun : false } ) ;
781+ } ) ;
782+
783+ it ( "REJECTS an unrecognized flag (mistyped --dry-rn must not silently run live)" , ( ) => {
784+ expect ( ( ) => parseArgs ( [ "--dry-rn" ] ) ) . toThrow ( / u n k n o w n | u n r e c o g n i z e d / i) ;
785+ } ) ;
786+
787+ it ( "rejects any unrecognized dash-prefixed argument" , ( ) => {
788+ expect ( ( ) => parseArgs ( [ "--list" , "--bogus" ] ) ) . toThrow (
789+ / u n k n o w n | u n r e c o g n i z e d / i,
790+ ) ;
791+ expect ( ( ) => parseArgs ( [ "-x" ] ) ) . toThrow ( / u n k n o w n | u n r e c o g n i z e d / i) ;
562792 } ) ;
563793} ) ;
0 commit comments