@@ -37,7 +37,7 @@ import {
37
37
AppCheckInternalComponentName
38
38
} from '@firebase/app-check-interop-types' ;
39
39
import { makeFakeApp , createTestService } from '../test/utils' ;
40
- import { httpsCallable } from './service' ;
40
+ import { FunctionsService , httpsCallable } from './service' ;
41
41
import { FUNCTIONS_TYPE } from './constants' ;
42
42
import { FunctionsError } from './error' ;
43
43
@@ -308,21 +308,26 @@ describe('Firebase Functions > Call', () => {
308
308
309
309
describe ( 'Firebase Functions > Stream' , ( ) => {
310
310
let app : FirebaseApp ;
311
+ let functions : FunctionsService ;
312
+ let mockFetch : sinon . SinonStub ;
311
313
const region = 'us-central1' ;
312
314
313
- before ( ( ) => {
315
+ beforeEach ( ( ) => {
314
316
const useEmulator = ! ! process . env . FIREBASE_FUNCTIONS_EMULATOR_ORIGIN ;
315
317
const projectId = useEmulator
316
318
? 'functions-integration-test'
317
319
: TEST_PROJECT . projectId ;
318
320
const messagingSenderId = 'messaging-sender-id' ;
319
321
app = makeFakeApp ( { projectId, messagingSenderId } ) ;
322
+ functions = createTestService ( app , region ) ;
323
+ mockFetch = sinon . stub ( functions , 'fetchImpl' as any ) ;
320
324
} ) ;
321
325
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
+ } )
325
329
330
+ it ( 'successfully streams data and resolves final result' , async ( ) => {
326
331
const mockResponse = new ReadableStream ( {
327
332
start ( controller ) {
328
333
controller . enqueue ( new TextEncoder ( ) . encode ( 'data: {"message":"Hello"}\n' ) ) ;
@@ -349,14 +354,9 @@ describe('Firebase Functions > Stream', () => {
349
354
350
355
expect ( messages ) . to . deep . equal ( [ 'Hello' , 'World' ] ) ;
351
356
expect ( await streamResult . data ) . to . equal ( 'Final Result' ) ;
352
-
353
- mockFetch . restore ( ) ;
354
357
} ) ;
355
358
356
359
it ( 'handles network errors' , async ( ) => {
357
- const functions = createTestService ( app , region ) ;
358
- const mockFetch = sinon . stub ( globalThis , 'fetch' as any ) ;
359
-
360
360
mockFetch . rejects ( new Error ( 'Network error' ) ) ;
361
361
362
362
const func = httpsCallable < Record < string , any > , string , string > ( functions , 'errTest' ) ;
@@ -371,17 +371,11 @@ describe('Firebase Functions > Stream', () => {
371
371
errorThrown = true ;
372
372
expect ( ( error as FunctionsError ) . code ) . to . equal ( `${ FUNCTIONS_TYPE } /internal` ) ;
373
373
}
374
-
375
374
expect ( errorThrown ) . to . be . true ;
376
- expect ( streamResult . data ) . to . be . a ( 'promise' ) ;
377
-
378
- mockFetch . restore ( ) ;
375
+ expectError ( streamResult . data , "internal" , "Internal" ) ;
379
376
} ) ;
380
377
381
378
it ( 'handles server-side errors' , async ( ) => {
382
- const functions = createTestService ( app , region ) ;
383
- const mockFetch = sinon . stub ( globalThis , 'fetch' as any ) ;
384
-
385
379
const mockResponse = new ReadableStream ( {
386
380
start ( controller ) {
387
381
controller . enqueue ( new TextEncoder ( ) . encode ( 'data: {"error":{"status":"INVALID_ARGUMENT","message":"Invalid input"}}\n' ) ) ;
@@ -396,7 +390,7 @@ describe('Firebase Functions > Stream', () => {
396
390
statusText : 'OK' ,
397
391
} as Response ) ;
398
392
399
- const func = httpsCallable < Record < string , any > , string , string > ( functions , 'errTest ' ) ;
393
+ const func = httpsCallable < Record < string , any > , string , string > ( functions , 'stream ' ) ;
400
394
const streamResult = await func . stream ( { } ) ;
401
395
402
396
let errorThrown = false ;
@@ -412,8 +406,6 @@ describe('Firebase Functions > Stream', () => {
412
406
413
407
expect ( errorThrown ) . to . be . true ;
414
408
expectError ( streamResult . data , "invalid-argument" , "Invalid input" )
415
-
416
- mockFetch . restore ( ) ;
417
409
} ) ;
418
410
419
411
it ( 'includes authentication and app check tokens in request headers' , async ( ) => {
@@ -427,9 +419,23 @@ describe('Firebase Functions > Stream', () => {
427
419
authProvider . setComponent (
428
420
new Component ( 'auth-internal' , ( ) => authMock , ComponentType . PRIVATE )
429
421
) ;
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
+ ) ;
430
436
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 ) ;
433
439
434
440
const mockResponse = new ReadableStream ( {
435
441
start ( controller ) {
@@ -445,15 +451,151 @@ describe('Firebase Functions > Stream', () => {
445
451
statusText : 'OK' ,
446
452
} as Response ) ;
447
453
448
- const func = httpsCallable < Record < string , any > , string , string > ( functions , 'errTest ' ) ;
454
+ const func = httpsCallable < Record < string , any > , string , string > ( functions , 'stream ' ) ;
449
455
await func . stream ( { } ) ;
450
456
451
457
expect ( mockFetch . calledOnce ) . to . be . true ;
452
458
const [ _ , options ] = mockFetch . firstCall . args ;
453
459
expect ( options . headers [ 'Authorization' ] ) . to . equal ( 'Bearer auth-token' ) ;
454
460
expect ( options . headers [ 'Content-Type' ] ) . to . equal ( 'application/json' ) ;
455
461
expect ( options . headers [ 'Accept' ] ) . to . equal ( 'text/event-stream' ) ;
462
+ } ) ;
456
463
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" )
458
600
} ) ;
459
601
} ) ;
0 commit comments