Run ngrx/effect outside of Angular's zone to prevent timeout in Protractor
Asked Answered
V

2

11

I just started to write e2e tests for my app and am running into timeout problems with Protractor and ngrx/effects.

I have the following effect dispatching an action every couple of minutes:

@Effect() setSessionTimer$ = this.actions$
        .ofType(Auth.ActionTypes.SET_SECONDS_LEFT)
        .map(toPayload)
        .switchMap(secondsLeft => Observable.concat(
            Observable.timer((secondsLeft - 60) * 1000).map(_ => new Auth.SessionExpiringAction(60)),
            Observable.timer(60 * 1000).map(_ => new Auth.SessionExpiredAction())
        ));

Trying to run a Protractor test causes the test to timeout with the following error, since Angular is not stable.

Failed: Timed out waiting for asynchronous Angular tasks to finish after 11 seconds. This may be because the current page is not an Angular application. Please see the FAQ for more details: https://github.com/angular/protractor/blob/master/docs/timeouts.md#waiting-for-angular While waiting for element with locator - Locator: By(css selector, .toolbar-title)

According to this issue (https://github.com/angular/protractor/issues/3349) I need to use NgZone to run an interval Observable outside of Angular. I have tried different combinations of this.ngZone.runOutsideAngular() but nothing worked and the tests keep on timing out.

For example this does not work:

@Effect() setSessionTimer$ = this.actions$
        .ofType(Auth.ActionTypes.SET_SECONDS_LEFT)
        .map(toPayload)
        .switchMap(secondsLeft => this.ngZone.runOutsideAngular(() => Observable.concat(
            Observable.timer((secondsLeft - 60) * 1000).map(_ => new Auth.SessionExpiringAction(60)),
            Observable.timer(60 * 1000).map(_ => new Auth.SessionExpiredAction())
        )));

I have no idea how to run the effect outside Angular. Has anybody successfully e2e tested their ngrx app?

Vezza answered 30/3, 2017 at 14:55 Comment(0)
B
35

The solution is to schedule the timer observable to run outside of NgZone and then re-enter the zone when something interesting occurs.

First you are going to need two utility functions that wrap any scheduler and cause the effect to enter or leave the zone:

import { Subscription } from 'rxjs/Subscription';
import { Scheduler } from 'rxjs/Scheduler';
import { NgZone } from '@angular/core';


class LeaveZoneSchduler {
  constructor(private zone: NgZone, private scheduler: Scheduler) { }

  schedule(...args: any[]): Subscription {
    return this.zone.runOutsideAngular(() => 
        this.scheduler.schedule.apply(this.scheduler, args)
    );
  }
}

class EnterZoneScheduler {
  constructor(private zone: NgZone, private scheduler: Scheduler) { }

  schedule(...args: any[]): Subscription {
    return this.zone.run(() => 
        this.scheduler.schedule.apply(this.scheduler, args)
    );
  }
}

export function leaveZone(zone: NgZone, scheduler: Scheduler): Scheduler {
  return new LeaveZoneSchduler(zone, scheduler) as any;
}

export function enterZone(zone: NgZone, scheduler: Scheduler): Scheduler {
  return new EnterZoneScheduler(zone, scheduler) as any;
}

Then using a scheduler (like asap or async) you can cause a stream to enter or leave the zone:

import { async } from 'rxjs/scheduler/async';
import { enterZone, leaveZone } from './util';

actions$.ofType('[Light] Turn On')
    .bufferTime(300, leaveZone(this.ngZone, async))
    .filter(messages => messages.length > 0)
    .observeOn(enterZone(this.ngZone, async))

Note that most of the time-based operators (like bufferTime, debounceTime, Observable.timer, etc) already accept an alternative scheduler. You only need observeOn to re-enter the zone when something interesting happens.

Buckinghamshire answered 3/4, 2017 at 12:30 Comment(5)
It works! How did you find that out about schedulers and observeOn?Vezza
Runs great!Thanks!Hatley
This worked perfectly! Scheduler has been deprecated though, it's now SchedulerLike.Ejaculation
Thanks Mike! Indeed some APIs are deprecated, just use the code sample from the next response by @mohlendo.Gentle
In the usage example it is better to use queue (import { queue } from 'rxjs/scheduler/async';) when leaving the zone - .observeOn(enterZone(this.ngZone, queue)). If async is used then change detection is run twice.Fridlund
R
17

For Angular 6 and RxJS 6 use the following code:

import { SchedulerLike, Subscription } from 'rxjs'
import { NgZone } from '@angular/core'

class LeaveZoneScheduler implements SchedulerLike {
  constructor(private zone: NgZone, private scheduler: SchedulerLike) { }

  schedule(...args: any[]): Subscription {
    return this.zone.runOutsideAngular(() =>
      this.scheduler.schedule.apply(this.scheduler, args)
    )
  }

  now (): number {
    return this.scheduler.now()
  }
}

class EnterZoneScheduler implements SchedulerLike {
  constructor(private zone: NgZone, private scheduler: SchedulerLike) { }

  schedule(...args: any[]): Subscription {
    return this.zone.run(() =>
      this.scheduler.schedule.apply(this.scheduler, args)
    )
  }

  now (): number {
    return this.scheduler.now()
  }
}

export function leaveZone(zone: NgZone, scheduler: SchedulerLike): SchedulerLike {
  return new LeaveZoneScheduler(zone, scheduler)
}

export function enterZone(zone: NgZone, scheduler: SchedulerLike): SchedulerLike {
  return new EnterZoneScheduler(zone, scheduler)
}

The effect should look like:

import { asyncScheduler, queueScheduler } from 'rxjs'
import { filter, observeOn, bufferTime } from 'rxjs/operators'
import { enterZone, leaveZone } from './util';

actions$.ofType('[Light] Turn On')
  .pipe(
    bufferTime(300, leaveZone(this.ngZone, asyncScheduler)),
    filter(messages => messages.length > 0),
    observeOn(enterZone(this.ngZone, queueScheduler)),
  )
Rig answered 1/6, 2018 at 7:31 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.