Close #38795 in the XMLHttpRequest patch, when get `readystatechange` event, zone.js try to invoke `load` event listener first, then call `invokeTask` to finish the `XMLHttpRequest::send` macroTask, but if the request failed because the server can not be reached, the `load` event listener will not be invoked, so the `invokeTask` of the `XMLHttpRequest::send` will not be triggered either, so we will have a non finished macroTask there which will make the Zone not stable, also memory leak. So in this PR, if the `XMLHttpRequest.status = 0` when we get the `readystatechange` event, that means something wents wrong before we reached the server, we need to invoke the task to finish the macroTask. PR Close #38836
		
			
				
	
	
		
			421 lines
		
	
	
		
			13 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			421 lines
		
	
	
		
			13 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
/**
 | 
						|
 * @license
 | 
						|
 * Copyright Google LLC All Rights Reserved.
 | 
						|
 *
 | 
						|
 * Use of this source code is governed by an MIT-style license that can be
 | 
						|
 * found in the LICENSE file at https://angular.io/license
 | 
						|
 */
 | 
						|
 | 
						|
import {ifEnvSupports, ifEnvSupportsWithDone, supportPatchXHROnProperty, zoneSymbol} from '../test-util';
 | 
						|
declare const global: any;
 | 
						|
const wtfMock = global.wtfMock;
 | 
						|
 | 
						|
describe('XMLHttpRequest', function() {
 | 
						|
  let testZone: Zone;
 | 
						|
 | 
						|
  beforeEach(() => {
 | 
						|
    testZone = Zone.current.fork({name: 'test'});
 | 
						|
  });
 | 
						|
 | 
						|
  it('should intercept XHRs and treat them as MacroTasks', function(done) {
 | 
						|
    let req: XMLHttpRequest;
 | 
						|
    let onStable: any;
 | 
						|
    const testZoneWithWtf = Zone.current.fork((Zone as any)['wtfZoneSpec']).fork({
 | 
						|
      name: 'TestZone',
 | 
						|
      onHasTask: (delegate: ZoneDelegate, curr: Zone, target: Zone, hasTask: HasTaskState) => {
 | 
						|
        if (!hasTask.macroTask) {
 | 
						|
          onStable && onStable();
 | 
						|
        }
 | 
						|
      }
 | 
						|
    });
 | 
						|
 | 
						|
    testZoneWithWtf.run(() => {
 | 
						|
      req = new XMLHttpRequest();
 | 
						|
      const logs: string[] = [];
 | 
						|
      req.onload = () => {
 | 
						|
        logs.push('onload');
 | 
						|
      };
 | 
						|
      onStable = function() {
 | 
						|
        expect(wtfMock.log[wtfMock.log.length - 2])
 | 
						|
            .toEqual('> Zone:invokeTask:XMLHttpRequest.send("<root>::ProxyZone::WTF::TestZone")');
 | 
						|
        expect(wtfMock.log[wtfMock.log.length - 1])
 | 
						|
            .toEqual('< Zone:invokeTask:XMLHttpRequest.send');
 | 
						|
        if (supportPatchXHROnProperty()) {
 | 
						|
          expect(wtfMock.log[wtfMock.log.length - 3])
 | 
						|
              .toMatch(/\< Zone\:invokeTask.*addEventListener\:load/);
 | 
						|
          expect(wtfMock.log[wtfMock.log.length - 4])
 | 
						|
              .toMatch(/\> Zone\:invokeTask.*addEventListener\:load/);
 | 
						|
        }
 | 
						|
        // if browser can patch onload
 | 
						|
        if ((req as any)[zoneSymbol('loadfalse')]) {
 | 
						|
          expect(logs).toEqual(['onload']);
 | 
						|
        }
 | 
						|
        done();
 | 
						|
      };
 | 
						|
 | 
						|
      req.open('get', '/', true);
 | 
						|
      req.send();
 | 
						|
      const lastScheduled = wtfMock.log[wtfMock.log.length - 1];
 | 
						|
      expect(lastScheduled).toMatch('# Zone:schedule:macroTask:XMLHttpRequest.send');
 | 
						|
    }, null, undefined, 'unit-test');
 | 
						|
  });
 | 
						|
 | 
						|
  it('should not trigger Zone callback of internal onreadystatechange', function(done) {
 | 
						|
    const scheduleSpy = jasmine.createSpy('schedule');
 | 
						|
    const xhrZone = Zone.current.fork({
 | 
						|
      name: 'xhr',
 | 
						|
      onScheduleTask: (delegate: ZoneDelegate, currentZone: Zone, targetZone, task: Task) => {
 | 
						|
        if (task.type === 'eventTask') {
 | 
						|
          scheduleSpy(task.source);
 | 
						|
        }
 | 
						|
        return delegate.scheduleTask(targetZone, task);
 | 
						|
      }
 | 
						|
    });
 | 
						|
 | 
						|
    xhrZone.run(() => {
 | 
						|
      const req = new XMLHttpRequest();
 | 
						|
      req.onload = function() {
 | 
						|
        expect(Zone.current.name).toEqual('xhr');
 | 
						|
        if (supportPatchXHROnProperty()) {
 | 
						|
          expect(scheduleSpy).toHaveBeenCalled();
 | 
						|
        }
 | 
						|
        done();
 | 
						|
      };
 | 
						|
      req.open('get', '/', true);
 | 
						|
      req.send();
 | 
						|
    });
 | 
						|
  });
 | 
						|
 | 
						|
  it('should work with onreadystatechange', function(done) {
 | 
						|
    let req: XMLHttpRequest;
 | 
						|
 | 
						|
    testZone.run(function() {
 | 
						|
      req = new XMLHttpRequest();
 | 
						|
      req.onreadystatechange = function() {
 | 
						|
        // Make sure that the wrapCallback will only be called once
 | 
						|
        req.onreadystatechange = null as any;
 | 
						|
        expect(Zone.current).toBe(testZone);
 | 
						|
        done();
 | 
						|
      };
 | 
						|
      req.open('get', '/', true);
 | 
						|
    });
 | 
						|
 | 
						|
    req!.send();
 | 
						|
  });
 | 
						|
 | 
						|
  it('should return null when access ontimeout first time without error', function() {
 | 
						|
    let req: XMLHttpRequest = new XMLHttpRequest();
 | 
						|
    expect(req.ontimeout).toBe(null);
 | 
						|
  });
 | 
						|
 | 
						|
  const supportsOnProgress = function() {
 | 
						|
    return 'onprogress' in (new XMLHttpRequest());
 | 
						|
  };
 | 
						|
 | 
						|
  (<any>supportsOnProgress).message = 'XMLHttpRequest.onprogress';
 | 
						|
 | 
						|
  describe('onprogress', ifEnvSupports(supportsOnProgress, function() {
 | 
						|
             it('should work with onprogress', function(done) {
 | 
						|
               let req: XMLHttpRequest;
 | 
						|
               testZone.run(function() {
 | 
						|
                 req = new XMLHttpRequest();
 | 
						|
                 req.onprogress = function() {
 | 
						|
                   // Make sure that the wrapCallback will only be called once
 | 
						|
                   req.onprogress = null as any;
 | 
						|
                   expect(Zone.current).toBe(testZone);
 | 
						|
                   done();
 | 
						|
                 };
 | 
						|
                 req.open('get', '/', true);
 | 
						|
               });
 | 
						|
 | 
						|
               req!.send();
 | 
						|
             });
 | 
						|
 | 
						|
             it('should allow canceling of an XMLHttpRequest', function(done) {
 | 
						|
               const spy = jasmine.createSpy('spy');
 | 
						|
               let req: XMLHttpRequest;
 | 
						|
               let pending = false;
 | 
						|
 | 
						|
               const trackingTestZone = Zone.current.fork({
 | 
						|
                 name: 'tracking test zone',
 | 
						|
                 onHasTask:
 | 
						|
                     (delegate: ZoneDelegate, current: Zone, target: Zone,
 | 
						|
                      hasTaskState: HasTaskState) => {
 | 
						|
                       if (hasTaskState.change == 'macroTask') {
 | 
						|
                         pending = hasTaskState.macroTask;
 | 
						|
                       }
 | 
						|
                       delegate.hasTask(target, hasTaskState);
 | 
						|
                     }
 | 
						|
               });
 | 
						|
 | 
						|
               trackingTestZone.run(function() {
 | 
						|
                 req = new XMLHttpRequest();
 | 
						|
                 req.onreadystatechange = function() {
 | 
						|
                   if (req.readyState === XMLHttpRequest.DONE) {
 | 
						|
                     if (req.status !== 0) {
 | 
						|
                       spy();
 | 
						|
                     }
 | 
						|
                   }
 | 
						|
                 };
 | 
						|
                 req.open('get', '/', true);
 | 
						|
 | 
						|
                 req.send();
 | 
						|
                 req.abort();
 | 
						|
               });
 | 
						|
 | 
						|
               setTimeout(function() {
 | 
						|
                 expect(spy).not.toHaveBeenCalled();
 | 
						|
                 expect(pending).toEqual(false);
 | 
						|
                 done();
 | 
						|
               }, 0);
 | 
						|
             });
 | 
						|
 | 
						|
             it('should allow aborting an XMLHttpRequest after its completed', function(done) {
 | 
						|
               let req: XMLHttpRequest;
 | 
						|
 | 
						|
               testZone.run(function() {
 | 
						|
                 req = new XMLHttpRequest();
 | 
						|
                 req.onreadystatechange = function() {
 | 
						|
                   if (req.readyState === XMLHttpRequest.DONE) {
 | 
						|
                     if (req.status !== 0) {
 | 
						|
                       setTimeout(function() {
 | 
						|
                         req.abort();
 | 
						|
                         done();
 | 
						|
                       }, 0);
 | 
						|
                     }
 | 
						|
                   }
 | 
						|
                 };
 | 
						|
                 req.open('get', '/', true);
 | 
						|
 | 
						|
                 req.send();
 | 
						|
               });
 | 
						|
             });
 | 
						|
           }));
 | 
						|
 | 
						|
  it('should preserve other setters', function() {
 | 
						|
    const req = new XMLHttpRequest();
 | 
						|
    req.open('get', '/', true);
 | 
						|
    req.send();
 | 
						|
    try {
 | 
						|
      req.responseType = 'document';
 | 
						|
      expect(req.responseType).toBe('document');
 | 
						|
    } catch (e) {
 | 
						|
      // Android browser: using this setter throws, this should be preserved
 | 
						|
      expect(e.message).toBe('INVALID_STATE_ERR: DOM Exception 11');
 | 
						|
    }
 | 
						|
  });
 | 
						|
 | 
						|
  it('should work with synchronous XMLHttpRequest', function() {
 | 
						|
    const log: HasTaskState[] = [];
 | 
						|
    Zone.current
 | 
						|
        .fork({
 | 
						|
          name: 'sync-xhr-test',
 | 
						|
          onHasTask: function(
 | 
						|
              delegate: ZoneDelegate, current: Zone, target: Zone, hasTaskState: HasTaskState) {
 | 
						|
            log.push(hasTaskState);
 | 
						|
            delegate.hasTask(target, hasTaskState);
 | 
						|
          }
 | 
						|
        })
 | 
						|
        .run(() => {
 | 
						|
          const req = new XMLHttpRequest();
 | 
						|
          req.open('get', '/', false);
 | 
						|
          req.send();
 | 
						|
        });
 | 
						|
    expect(log).toEqual([]);
 | 
						|
  });
 | 
						|
 | 
						|
  it('should preserve static constants', function() {
 | 
						|
    expect(XMLHttpRequest.UNSENT).toEqual(0);
 | 
						|
    expect(XMLHttpRequest.OPENED).toEqual(1);
 | 
						|
    expect(XMLHttpRequest.HEADERS_RECEIVED).toEqual(2);
 | 
						|
    expect(XMLHttpRequest.LOADING).toEqual(3);
 | 
						|
    expect(XMLHttpRequest.DONE).toEqual(4);
 | 
						|
  });
 | 
						|
 | 
						|
  it('should work properly when send request multiple times on single xmlRequest instance',
 | 
						|
     function(done) {
 | 
						|
       testZone.run(function() {
 | 
						|
         const req = new XMLHttpRequest();
 | 
						|
         req.open('get', '/', true);
 | 
						|
         req.send();
 | 
						|
         req.onload = function() {
 | 
						|
           req.onload = null as any;
 | 
						|
           req.open('get', '/', true);
 | 
						|
           req.onload = function() {
 | 
						|
             done();
 | 
						|
           };
 | 
						|
           expect(() => {
 | 
						|
             req.send();
 | 
						|
           }).not.toThrow();
 | 
						|
         };
 | 
						|
       });
 | 
						|
     });
 | 
						|
 | 
						|
  it('should keep taskcount correctly when abort was called multiple times before request is done',
 | 
						|
     function(done) {
 | 
						|
       testZone.run(function() {
 | 
						|
         const req = new XMLHttpRequest();
 | 
						|
         req.open('get', '/', true);
 | 
						|
         req.send();
 | 
						|
         req.addEventListener('readystatechange', function(ev) {
 | 
						|
           if (req.readyState >= 2) {
 | 
						|
             expect(() => {
 | 
						|
               req.abort();
 | 
						|
             }).not.toThrow();
 | 
						|
             done();
 | 
						|
           }
 | 
						|
         });
 | 
						|
       });
 | 
						|
     });
 | 
						|
 | 
						|
  it('should close xhr request if error happened when connect', function(done) {
 | 
						|
    const logs: boolean[] = [];
 | 
						|
    Zone.current
 | 
						|
        .fork({
 | 
						|
          name: 'xhr',
 | 
						|
          onHasTask:
 | 
						|
              (delegate: ZoneDelegate, curr: Zone, target: Zone, taskState: HasTaskState) => {
 | 
						|
                if (taskState.change === 'macroTask') {
 | 
						|
                  logs.push(taskState.macroTask);
 | 
						|
                }
 | 
						|
                return delegate.hasTask(target, taskState);
 | 
						|
              }
 | 
						|
        })
 | 
						|
        .run(function() {
 | 
						|
          const req = new XMLHttpRequest();
 | 
						|
          req.open('get', 'http://notexists.url', true);
 | 
						|
          req.send();
 | 
						|
          req.addEventListener('error', () => {
 | 
						|
            expect(logs).toEqual([true, false]);
 | 
						|
            done();
 | 
						|
          });
 | 
						|
        });
 | 
						|
  });
 | 
						|
 | 
						|
  it('should trigger readystatechange if xhr request trigger cors error', (done) => {
 | 
						|
    const req = new XMLHttpRequest();
 | 
						|
    let err: any = null;
 | 
						|
    try {
 | 
						|
      req.open('get', 'file:///test', true);
 | 
						|
    } catch (err) {
 | 
						|
      // in IE, open will throw Access is denied error
 | 
						|
      done();
 | 
						|
      return;
 | 
						|
    }
 | 
						|
    req.addEventListener('readystatechange', function(ev) {
 | 
						|
      if (req.readyState === 4) {
 | 
						|
        const xhrScheduled = (req as any)[zoneSymbol('xhrScheduled')];
 | 
						|
        const task = (req as any)[zoneSymbol('xhrTask')];
 | 
						|
        if (xhrScheduled === false) {
 | 
						|
          expect(task.state).toEqual('scheduling');
 | 
						|
          setTimeout(() => {
 | 
						|
            if (err) {
 | 
						|
              expect(task.state).toEqual('unknown');
 | 
						|
            } else {
 | 
						|
              expect(task.state).toEqual('notScheduled');
 | 
						|
            }
 | 
						|
            done();
 | 
						|
          });
 | 
						|
        } else {
 | 
						|
          expect(task.state).toEqual('scheduled');
 | 
						|
          done();
 | 
						|
        }
 | 
						|
      }
 | 
						|
    });
 | 
						|
    try {
 | 
						|
      req.send();
 | 
						|
    } catch (error) {
 | 
						|
      err = error;
 | 
						|
    }
 | 
						|
  });
 | 
						|
 | 
						|
  it('should invoke task if xhr request trigger cors error', (done) => {
 | 
						|
    const logs: string[] = [];
 | 
						|
    const zone = Zone.current.fork({
 | 
						|
      name: 'xhr',
 | 
						|
      onHasTask: (delegate: ZoneDelegate, curr: Zone, target: Zone, hasTask: HasTaskState) => {
 | 
						|
        logs.push(JSON.stringify(hasTask));
 | 
						|
      }
 | 
						|
    });
 | 
						|
    const req = new XMLHttpRequest();
 | 
						|
    try {
 | 
						|
      req.open('get', 'file:///test', true);
 | 
						|
    } catch (err) {
 | 
						|
      // in IE, open will throw Access is denied error
 | 
						|
      done();
 | 
						|
      return;
 | 
						|
    }
 | 
						|
    zone.run(() => {
 | 
						|
      let isError = false;
 | 
						|
      let timerId = null;
 | 
						|
      try {
 | 
						|
        timerId = (window as any)[zoneSymbol('setTimeout')](() => {
 | 
						|
          expect(logs).toEqual([
 | 
						|
            `{"microTask":false,"macroTask":true,"eventTask":false,"change":"macroTask"}`,
 | 
						|
            `{"microTask":false,"macroTask":false,"eventTask":false,"change":"macroTask"}`
 | 
						|
          ]);
 | 
						|
          done();
 | 
						|
        }, 500);
 | 
						|
        req.send();
 | 
						|
      } catch (error) {
 | 
						|
        isError = true;
 | 
						|
        (window as any)[zoneSymbol('clearTimeout')](timerId);
 | 
						|
        done();
 | 
						|
      }
 | 
						|
    });
 | 
						|
  });
 | 
						|
 | 
						|
  it('should not throw error when get XMLHttpRequest.prototype.onreadystatechange the first time',
 | 
						|
     function() {
 | 
						|
       const func = function() {
 | 
						|
         testZone.run(function() {
 | 
						|
           const req = new XMLHttpRequest();
 | 
						|
           req.onreadystatechange;
 | 
						|
         });
 | 
						|
       };
 | 
						|
       expect(func).not.toThrow();
 | 
						|
     });
 | 
						|
 | 
						|
  it('should be in the zone when use XMLHttpRequest.addEventListener', function(done) {
 | 
						|
    testZone.run(function() {
 | 
						|
      // sometimes this case will cause timeout
 | 
						|
      // so we set it longer
 | 
						|
      const interval = (<any>jasmine).DEFAULT_TIMEOUT_INTERVAL;
 | 
						|
      (<any>jasmine).DEFAULT_TIMEOUT_INTERVAL = 5000;
 | 
						|
      const req = new XMLHttpRequest();
 | 
						|
      req.open('get', '/', true);
 | 
						|
      req.addEventListener('readystatechange', function() {
 | 
						|
        if (req.readyState === 4) {
 | 
						|
          // expect(Zone.current.name).toEqual('test');
 | 
						|
          (<any>jasmine).DEFAULT_TIMEOUT_INTERVAL = interval;
 | 
						|
          done();
 | 
						|
        }
 | 
						|
      });
 | 
						|
      req.send();
 | 
						|
    });
 | 
						|
  });
 | 
						|
 | 
						|
  it('should return origin listener when call xhr.onreadystatechange',
 | 
						|
     ifEnvSupportsWithDone(supportPatchXHROnProperty, function(done: Function) {
 | 
						|
       testZone.run(function() {
 | 
						|
         // sometimes this case will cause timeout
 | 
						|
         // so we set it longer
 | 
						|
         const req = new XMLHttpRequest();
 | 
						|
         req.open('get', '/', true);
 | 
						|
         const interval = (<any>jasmine).DEFAULT_TIMEOUT_INTERVAL;
 | 
						|
         (<any>jasmine).DEFAULT_TIMEOUT_INTERVAL = 5000;
 | 
						|
         const listener = req.onreadystatechange = function() {
 | 
						|
           if (req.readyState === 4) {
 | 
						|
             (<any>jasmine).DEFAULT_TIMEOUT_INTERVAL = interval;
 | 
						|
             done();
 | 
						|
           }
 | 
						|
         };
 | 
						|
         expect(req.onreadystatechange).toBe(listener);
 | 
						|
         req.onreadystatechange = function() {
 | 
						|
           return listener.call(this);
 | 
						|
         };
 | 
						|
         req.send();
 | 
						|
       });
 | 
						|
     }));
 | 
						|
});
 |