@@ -798,18 +798,112 @@ describe("coordinator runtime integration", () => {
798798 expect ( calledSecondDag ) . toBe ( false ) ;
799799 } ) ;
800800
801- it ( "returns empty serialized_dags for DagFileParseRequest", async ( ) => {
801+ describe ( " DagFileParseRequest", ( ) => {
802802 const parseRequest = {
803803 type : "DagFileParseRequest" ,
804804 file : "/dags/test.mjs" ,
805805 bundle_path : "/dags" ,
806806 } ;
807807
808- const result = await driveSupervisor ( parseRequest ) ;
808+ async function parse ( ) : Promise < Record < string , unknown > > {
809+ const result = await driveSupervisor ( parseRequest ) ;
810+ return result . firstResponse ! . body as Record < string , unknown > ;
811+ }
812+
813+ it ( "answers with the Dags the bundle declared in TypeScript" , async ( ) => {
814+ testDag . task ( "extract" , async ( ) => undefined ) ( ) ;
815+ otherDag . task ( "stage" , async ( ) => undefined ) ( ) ;
816+
817+ const body = await parse ( ) ;
818+
819+ expect ( body . type ) . toBe ( "DagFileParsingResult" ) ;
820+ expect ( body . fileloc ) . toBe ( "/dags/test.mjs" ) ;
821+ const dags = body . serialized_dags as { data : Record < string , unknown > } [ ] ;
822+ expect ( dags . map ( ( entry ) => ( entry . data . dag as Record < string , unknown > ) . dag_id ) ) . toEqual ( [
823+ "test_dag" ,
824+ "other_dag" ,
825+ ] ) ;
826+ expect ( dags [ 0 ] ! . data . __version ) . toBe ( 3 ) ;
827+ expect ( ( dags [ 0 ] ! . data . dag as Record < string , unknown > ) . relative_fileloc ) . toBe ( "test.mjs" ) ;
828+ expect ( body . import_errors ) . toBeUndefined ( ) ;
829+ } ) ;
830+
831+ it ( "runs no handler body while parsing" , async ( ) => {
832+ let ran = false ;
833+ testDag . task ( "extract" , async ( ) => {
834+ ran = true ;
835+ } ) ( ) ;
836+ otherDag . task ( "stage" , async ( ) => undefined ) ( ) ;
837+
838+ await parse ( ) ;
839+
840+ expect ( ran ) . toBe ( false ) ;
841+ } ) ;
842+
843+ it ( "answers with nothing when the bundle only binds handlers to Python Dags" , async ( ) => {
844+ // A Python Dag's graph belongs to the Python file that declares it, so a
845+ // bundle of task handlers has no Dag of its own to serialize.
846+ bundle = new Bundle ( new TaskHandler ( "py_dag" , "transform" , async ( ) => undefined ) ) ;
847+
848+ const body = await parse ( ) ;
849+
850+ expect ( body . serialized_dags ) . toEqual ( [ ] ) ;
851+ expect ( body . import_errors ) . toBeUndefined ( ) ;
852+ } ) ;
853+
854+ it ( "reports an uncalled task as an import error rather than failing the parse" , async ( ) => {
855+ testDag . task ( "extract" , async ( ) => undefined ) ( ) ;
856+ testDag . task ( "orphan" , async ( ) => undefined ) ;
809857
810- const body = result . firstResponse ! . body as Record < string , unknown > ;
811- expect ( body . type ) . toBe ( "DagFileParsingResult" ) ;
812- expect ( body . serialized_dags ) . toEqual ( [ ] ) ;
858+ const body = await parse ( ) ;
859+
860+ expect ( body . serialized_dags ) . toEqual ( [ ] ) ;
861+ // Keyed by the bundle-relative path, which is how Airflow ties the row
862+ // to the file it came from.
863+ expect ( body . import_errors ) . toEqual ( {
864+ "test.mjs" : expect . stringContaining ( 'Task "orphan" of Dag "test_dag" is never called' ) ,
865+ } ) ;
866+ } ) ;
867+
868+ it ( "merges several failing Dags into the one row Airflow keeps per file" , async ( ) => {
869+ testDag . task ( "extract" , async ( ) => undefined ) ( ) ;
870+ for ( const dagId of [ "broken_a" , "broken_b" ] ) {
871+ const broken = new Dag ( dagId , { schedule : "" } ) ;
872+ broken . task ( "t" , async ( ) => undefined ) ( ) ;
873+ bundle . register ( broken ) ;
874+ }
875+
876+ const body = await parse ( ) ;
877+
878+ const errors = body . import_errors as Record < string , string > ;
879+ expect ( Object . keys ( errors ) ) . toEqual ( [ "test.mjs" ] ) ;
880+ expect ( errors [ "test.mjs" ] ) . toContain ( 'Dag "broken_a"' ) ;
881+ expect ( errors [ "test.mjs" ] ) . toContain ( 'Dag "broken_b"' ) ;
882+ } ) ;
883+
884+ it ( "keeps the Dags it can serialize when one of them cannot be" , async ( ) => {
885+ testDag . task ( "extract" , async ( ) => undefined ) ( ) ;
886+ // An empty schedule is rejected by the serializer, and only that Dag is
887+ // lost: the rest of the bundle still parses.
888+ const broken = new Dag ( "broken_dag" , { schedule : "" } ) ;
889+ broken . task ( "t" , async ( ) => undefined ) ( ) ;
890+ bundle . register ( broken ) ;
891+
892+ const body = await parse ( ) ;
893+
894+ const dags = body . serialized_dags as { data : Record < string , unknown > } [ ] ;
895+ expect ( dags . map ( ( entry ) => ( entry . data . dag as Record < string , unknown > ) . dag_id ) ) . toEqual ( [
896+ "test_dag" ,
897+ "other_dag" ,
898+ ] ) ;
899+ // One row per file, so the failing Dag is named in the message rather
900+ // than appended to the key.
901+ expect ( body . import_errors ) . toEqual ( {
902+ "test.mjs" : expect . stringContaining (
903+ 'Dag "broken_dag": schedule for Dag "broken_dag" is empty' ,
904+ ) ,
905+ } ) ;
906+ } ) ;
813907 } ) ;
814908
815909 it ( "auto-pushes return_value XCom when handler returns a value" , async ( ) => {
0 commit comments