@@ -37,7 +37,7 @@ import {
3737 AppCheckInternalComponentName
3838} from '@firebase/app-check-interop-types' ;
3939import { makeFakeApp , createTestService } from '../test/utils' ;
40- import { httpsCallable } from './service' ;
40+ import { FunctionsService , httpsCallable } from './service' ;
4141import { FUNCTIONS_TYPE } from './constants' ;
4242import { FunctionsError } from './error' ;
4343
@@ -308,21 +308,26 @@ describe('Firebase Functions > Call', () => {
308308
309309describe ( 'Firebase Functions > Stream' , ( ) => {
310310 let app : FirebaseApp ;
311+ let functions : FunctionsService ;
312+ let mockFetch : sinon . SinonStub ;
311313 const region = 'us-central1' ;
312314
313- before ( ( ) => {
315+ beforeEach ( ( ) => {
314316 const useEmulator = ! ! process . env . FIREBASE_FUNCTIONS_EMULATOR_ORIGIN ;
315317 const projectId = useEmulator
316318 ? 'functions-integration-test'
317319 : TEST_PROJECT . projectId ;
318320 const messagingSenderId = 'messaging-sender-id' ;
319321 app = makeFakeApp ( { projectId, messagingSenderId } ) ;
322+ functions = createTestService ( app , region ) ;
323+ mockFetch = sinon . stub ( functions , 'fetchImpl' as any ) ;
320324 } ) ;
321325
322- it ( 'successfully streams data and resolves final result' , async ( ) => {
323- const functions = createTestService ( app , region ) ;
324- const mockFetch = sinon . stub ( globalThis , 'fetch' as any ) ;
326+ afterEach ( ( ) => {
327+ mockFetch . restore ( ) ;
328+ } )
325329
330+ it ( 'successfully streams data and resolves final result' , async ( ) => {
326331 const mockResponse = new ReadableStream ( {
327332 start ( controller ) {
328333 controller . enqueue ( new TextEncoder ( ) . encode ( 'data: {"message":"Hello"}\n' ) ) ;
@@ -349,14 +354,9 @@ describe('Firebase Functions > Stream', () => {
349354
350355 expect ( messages ) . to . deep . equal ( [ 'Hello' , 'World' ] ) ;
351356 expect ( await streamResult . data ) . to . equal ( 'Final Result' ) ;
352-
353- mockFetch . restore ( ) ;
354357 } ) ;
355358
356359 it ( 'handles network errors' , async ( ) => {
357- const functions = createTestService ( app , region ) ;
358- const mockFetch = sinon . stub ( globalThis , 'fetch' as any ) ;
359-
360360 mockFetch . rejects ( new Error ( 'Network error' ) ) ;
361361
362362 const func = httpsCallable < Record < string , any > , string , string > ( functions , 'errTest' ) ;
@@ -371,17 +371,11 @@ describe('Firebase Functions > Stream', () => {
371371 errorThrown = true ;
372372 expect ( ( error as FunctionsError ) . code ) . to . equal ( `${ FUNCTIONS_TYPE } /internal` ) ;
373373 }
374-
375374 expect ( errorThrown ) . to . be . true ;
376- expect ( streamResult . data ) . to . be . a ( 'promise' ) ;
377-
378- mockFetch . restore ( ) ;
375+ expectError ( streamResult . data , "internal" , "Internal" ) ;
379376 } ) ;
380377
381378 it ( 'handles server-side errors' , async ( ) => {
382- const functions = createTestService ( app , region ) ;
383- const mockFetch = sinon . stub ( globalThis , 'fetch' as any ) ;
384-
385379 const mockResponse = new ReadableStream ( {
386380 start ( controller ) {
387381 controller . enqueue ( new TextEncoder ( ) . encode ( 'data: {"error":{"status":"INVALID_ARGUMENT","message":"Invalid input"}}\n' ) ) ;
@@ -396,7 +390,7 @@ describe('Firebase Functions > Stream', () => {
396390 statusText : 'OK' ,
397391 } as Response ) ;
398392
399- const func = httpsCallable < Record < string , any > , string , string > ( functions , 'errTest ' ) ;
393+ const func = httpsCallable < Record < string , any > , string , string > ( functions , 'stream ' ) ;
400394 const streamResult = await func . stream ( { } ) ;
401395
402396 let errorThrown = false ;
@@ -412,8 +406,6 @@ describe('Firebase Functions > Stream', () => {
412406
413407 expect ( errorThrown ) . to . be . true ;
414408 expectError ( streamResult . data , "invalid-argument" , "Invalid input" )
415-
416- mockFetch . restore ( ) ;
417409 } ) ;
418410
419411 it ( 'includes authentication and app check tokens in request headers' , async ( ) => {
@@ -427,9 +419,23 @@ describe('Firebase Functions > Stream', () => {
427419 authProvider . setComponent (
428420 new Component ( 'auth-internal' , ( ) => authMock , ComponentType . PRIVATE )
429421 ) ;
422+ const appCheckMock : FirebaseAppCheckInternal = {
423+ getToken : async ( ) => ( { token : 'app-check-token' } )
424+ } as unknown as FirebaseAppCheckInternal ;
425+ const appCheckProvider = new Provider < AppCheckInternalComponentName > (
426+ 'app-check-internal' ,
427+ new ComponentContainer ( 'test' )
428+ ) ;
429+ appCheckProvider . setComponent (
430+ new Component (
431+ 'app-check-internal' ,
432+ ( ) => appCheckMock ,
433+ ComponentType . PRIVATE
434+ )
435+ ) ;
430436
431- const functions = createTestService ( app , region , authProvider ) ;
432- const mockFetch = sinon . stub ( globalThis , 'fetch ' as any ) ;
437+ const functions = createTestService ( app , region , authProvider , undefined , appCheckProvider ) ;
438+ const mockFetch = sinon . stub ( functions , 'fetchImpl ' as any ) ;
433439
434440 const mockResponse = new ReadableStream ( {
435441 start ( controller ) {
@@ -445,15 +451,151 @@ describe('Firebase Functions > Stream', () => {
445451 statusText : 'OK' ,
446452 } as Response ) ;
447453
448- const func = httpsCallable < Record < string , any > , string , string > ( functions , 'errTest ' ) ;
454+ const func = httpsCallable < Record < string , any > , string , string > ( functions , 'stream ' ) ;
449455 await func . stream ( { } ) ;
450456
451457 expect ( mockFetch . calledOnce ) . to . be . true ;
452458 const [ _ , options ] = mockFetch . firstCall . args ;
453459 expect ( options . headers [ 'Authorization' ] ) . to . equal ( 'Bearer auth-token' ) ;
454460 expect ( options . headers [ 'Content-Type' ] ) . to . equal ( 'application/json' ) ;
455461 expect ( options . headers [ 'Accept' ] ) . to . equal ( 'text/event-stream' ) ;
462+ } ) ;
456463
457- mockFetch . restore ( ) ;
464+ it ( 'aborts during initial fetch' , async ( ) => {
465+ const controller = new AbortController ( ) ;
466+
467+ // Create a fetch that rejects when aborted
468+ const fetchPromise = new Promise < Response > ( ( _ , reject ) => {
469+ controller . signal . addEventListener ( 'abort' , ( ) => {
470+ const error = new Error ( 'The operation was aborted' ) ;
471+ error . name = 'AbortError' ;
472+ reject ( error ) ;
473+ } ) ;
474+ } ) ;
475+ mockFetch . returns ( fetchPromise ) ;
476+
477+ const func = httpsCallable < Record < string , any > , string , string > ( functions , 'streamTest' ) ;
478+ const streamPromise = func . stream ( { } , { signal : controller . signal } ) ;
479+
480+ controller . abort ( ) ;
481+
482+ const streamResult = await streamPromise ;
483+
484+ // Verify fetch was called with abort signal
485+ expect ( mockFetch . calledOnce ) . to . be . true ;
486+ const [ _ , options ] = mockFetch . firstCall . args ;
487+ expect ( options . signal ) . to . equal ( controller . signal ) ;
488+
489+ // Verify stream iteration throws AbortError
490+ let errorThrown = false ;
491+ try {
492+ for await ( const _ of streamResult . stream ) {
493+ // Should not execute
494+ }
495+ } catch ( error ) {
496+ errorThrown = true ;
497+ expect ( ( error as FunctionsError ) . code ) . to . equal ( `${ FUNCTIONS_TYPE } /cancelled` ) ;
498+ }
499+ expect ( errorThrown ) . to . be . true ;
500+ expectError ( streamResult . data , "cancelled" , "Request was cancelled" )
501+ } ) ;
502+
503+ it ( 'aborts during streaming' , async ( ) => {
504+ const controller = new AbortController ( ) ;
505+
506+ const mockResponse = new ReadableStream ( {
507+ async start ( controller ) {
508+ controller . enqueue ( new TextEncoder ( ) . encode ( 'data: {"message":"First"}\n' ) ) ;
509+ // Add delay to simulate network latency
510+ await new Promise ( resolve => setTimeout ( resolve , 50 ) ) ;
511+ controller . enqueue ( new TextEncoder ( ) . encode ( 'data: {"message":"Second"}\n' ) ) ;
512+ await new Promise ( resolve => setTimeout ( resolve , 50 ) ) ;
513+ controller . enqueue ( new TextEncoder ( ) . encode ( 'data: {"result":"Final"}\n' ) ) ;
514+ controller . close ( ) ;
515+ }
516+ } ) ;
517+
518+ mockFetch . resolves ( {
519+ body : mockResponse ,
520+ headers : new Headers ( { 'Content-Type' : 'text/event-stream' } ) ,
521+ status : 200 ,
522+ statusText : 'OK' ,
523+ } as Response ) ;
524+
525+ const func = httpsCallable < Record < string , any > , string , string > ( functions , 'streamTest' ) ;
526+ const streamResult = await func . stream ( { } , { signal : controller . signal } ) ;
527+
528+ const messages : string [ ] = [ ] ;
529+ try {
530+ for await ( const message of streamResult . stream ) {
531+ messages . push ( message ) ;
532+ if ( messages . length === 1 ) {
533+ // Abort after receiving first message
534+ controller . abort ( ) ;
535+ }
536+ }
537+ throw new Error ( 'Stream should have been aborted' ) ;
538+ } catch ( error ) {
539+ expect ( ( error as FunctionsError ) . code ) . to . equal ( `${ FUNCTIONS_TYPE } /cancelled` ) ;
540+ }
541+ expect ( messages ) . to . deep . equal ( [ 'First' ] ) ;
542+ expectError ( streamResult . data , "cancelled" , "Request was cancelled" )
543+ } ) ;
544+
545+ it ( 'fails immediately with pre-aborted signal' , async ( ) => {
546+ mockFetch . callsFake ( ( url : string , options : RequestInit ) => {
547+ if ( options . signal ?. aborted ) {
548+ const error = new Error ( 'The operation was aborted' ) ;
549+ error . name = 'AbortError' ;
550+ return Promise . reject ( error ) ;
551+ }
552+ return Promise . resolve ( new Response ( ) ) ;
553+ } ) ;
554+ const func = httpsCallable < Record < string , any > , string , string > ( functions , 'streamTest' ) ;
555+ const streamResult = await func . stream ( { } , { signal : AbortSignal . abort ( ) } ) ;
556+
557+ let errorThrown = false ;
558+ try {
559+ for await ( const _ of streamResult . stream ) {
560+ // Should not execute
561+ }
562+ } catch ( error ) {
563+ errorThrown = true ;
564+ expect ( ( error as FunctionsError ) . code ) . to . equal ( `${ FUNCTIONS_TYPE } /cancelled` ) ;
565+ }
566+ expect ( errorThrown ) . to . be . true ;
567+ expectError ( streamResult . data , "cancelled" , "Request was cancelled" )
568+ } ) ;
569+
570+ it ( 'properly handles AbortSignal.timeout()' , async ( ) => {
571+ const timeoutMs = 50 ;
572+ const signal = AbortSignal . timeout ( timeoutMs ) ;
573+
574+ mockFetch . callsFake ( async ( url : string , options : RequestInit ) => {
575+ await new Promise ( ( resolve , reject ) => {
576+ options . signal ?. addEventListener ( 'abort' , ( ) => {
577+ const error = new Error ( 'The operation was aborted' ) ;
578+ error . name = 'AbortError' ;
579+ reject ( error ) ;
580+ } ) ;
581+ setTimeout ( resolve , timeoutMs * 3 ) ;
582+ } ) ;
583+
584+ // If we get here, timeout didn't occur
585+ return new Response ( ) ;
586+ } ) ;
587+
588+ const func = httpsCallable < Record < string , any > , string , string > ( functions , 'streamTest' ) ;
589+ const streamResult = await func . stream ( { } , { signal } ) ;
590+
591+ try {
592+ for await ( const message of streamResult . stream ) {
593+ // Should not execute
594+ }
595+ throw new Error ( 'Stream should have timed out' ) ;
596+ } catch ( error ) {
597+ expect ( ( error as FunctionsError ) . code ) . to . equal ( `${ FUNCTIONS_TYPE } /cancelled` ) ;
598+ }
599+ expectError ( streamResult . data , "cancelled" , "Request was cancelled" )
458600 } ) ;
459601} ) ;
0 commit comments