diff --git a/modules/effects/spec/actions.spec.ts b/modules/effects/spec/actions.spec.ts index 4773a7a117..a503504905 100644 --- a/modules/effects/spec/actions.spec.ts +++ b/modules/effects/spec/actions.spec.ts @@ -287,4 +287,38 @@ describe('Actions', function() { dispatcher.next(multiply({ by: 2 })); dispatcher.complete(); }); + + it('should support more than 5 actions', () => { + const log = createAction('logarithm'); + const expected = [ + divide.type, + ADD, + square.type, + SUBTRACT, + multiply.type, + log.type, + ]; + + actions$ + .pipe( + // Mixing all of them, more than 5. It still works, but we loose the type info + ofType(divide, ADD, square, SUBTRACT, multiply, log), + map(update => update.type), + toArray() + ) + .subscribe({ + next(actual) { + expect(actual).toEqual(expected); + }, + }); + + // Actions under test, in specific order + dispatcher.next(divide({ by: 1 })); + dispatcher.next({ type: ADD }); + dispatcher.next(square()); + dispatcher.next({ type: SUBTRACT }); + dispatcher.next(multiply({ by: 2 })); + dispatcher.next(log()); + dispatcher.complete(); + }); }); diff --git a/modules/effects/spec/map_to_action.spec.ts b/modules/effects/spec/map_to_action.spec.ts new file mode 100644 index 0000000000..dbd8bc8f78 --- /dev/null +++ b/modules/effects/spec/map_to_action.spec.ts @@ -0,0 +1,286 @@ +import { cold, hot } from 'jasmine-marbles'; +import { mergeMap, take, switchMap } from 'rxjs/operators'; +import { createAction, Action } from '@ngrx/store'; +import { mapToAction } from '@ngrx/effects'; +import { throwError, Subject } from 'rxjs'; + +describe('mapToAction operator', () => { + /** + * Helper function that converts a string (or array of letters) into the + * object, each property of which is a letter that is assigned an Action + * with type as that letter. + * + * e.g. genActions('abc') would result in + * { + * 'a': {type: 'a'}, + * 'b': {type: 'b'}, + * 'c': {type: 'c'}, + * } + */ + function genActions(marbles: string): { [marble: string]: Action } { + return marbles.split('').reduce( + (acc, marble) => { + return { + ...acc, + [marble]: createAction(marble)(), + }; + }, + {} as { [marble: string]: Action } + ); + } + + it('should call project functon', () => { + const sources$ = hot('-a-b', genActions('ab')); + + const actual$ = new Subject(); + const project = jasmine + .createSpy('project') + .and.callFake((...args: [Action, number]) => { + actual$.next(args); + return cold('(v|)', genActions('v')); + }); + const error = () => createAction('e')(); + + sources$.pipe(mapToAction(project, error)).subscribe(); + + expect(actual$).toBeObservable( + cold(' -a-b', { + a: [createAction('a')(), 0], + b: [createAction('b')(), 1], + }) + ); + }); + + it('should emit output action', () => { + const sources$ = hot(' -a', genActions('a')); + const project = () => cold('(v|)', genActions('v')); + const error = () => createAction('e')(); + const expected$ = cold('-v', genActions('v')); + + const output$ = sources$.pipe(mapToAction(project, error)); + + expect(output$).toBeObservable(expected$); + }); + + it('should take any type of Observable as an Input', () => { + const sources$ = hot(' -a', { a: 'a string' }); + const project = () => cold('(v|)', genActions('v')); + const error = () => createAction('e')(); + const expected$ = cold('-v', genActions('v')); + + const output$ = sources$.pipe(mapToAction(project, error)); + + expect(output$).toBeObservable(expected$); + }); + + it('should emit output action with config passed', () => { + const sources$ = hot(' -a', genActions('a')); + // Completes + const project = () => cold('(v|)', genActions('v')); + const error = () => createAction('e')(); + // offset by source delay and doesn't complete + const expected$ = cold('-v--', genActions('v')); + + const output$ = sources$.pipe(mapToAction({ project, error })); + + expect(output$).toBeObservable(expected$); + }); + + it('should call the error callback when error in the project occurs', () => { + const sources$ = hot(' -a', genActions('a')); + const project = () => throwError('error'); + const error = () => createAction('e')(); + const expected$ = cold('-e', genActions('e')); + + const output$ = sources$.pipe(mapToAction(project, error)); + + expect(output$).toBeObservable(expected$); + }); + + it('should continue listen to the sources actions after error occurs', () => { + const sources$ = hot('-a--b', genActions('ab')); + const project = (action: Action) => + action.type === 'a' ? throwError('error') : cold('(v|)', genActions('v')); + const error = () => createAction('e')(); + // error handler action is dispatched and next action with type b is also + // handled + const expected$ = cold('-e--v', genActions('ev')); + + const output$ = sources$.pipe(mapToAction(project, error)); + + expect(output$).toBeObservable(expected$); + }); + + it('should emit multiple output actions when project produces many actions', () => { + const sources$ = hot(' -a', genActions('a')); + const project = () => cold('v-w-x-(y|)', genActions('vwxy')); + const error = () => createAction('e')(); + // offset by source delay and doesn't complete + const expected$ = cold('-v-w-x-y--', genActions('vwxy')); + + const output$ = sources$.pipe(mapToAction(project, error)); + + expect(output$).toBeObservable(expected$); + }); + + it('should emit multiple output actions when project produces many actions with config passed', () => { + const sources$ = hot(' -a', genActions('a')); + const project = () => cold('v-w-x-(y|)', genActions('vwxy')); + const error = () => createAction('e')(); + // offset by source delay + const expected$ = cold('-v-w-x-y', genActions('vwxy')); + + const output$ = sources$.pipe(mapToAction({ project, error })); + + expect(output$).toBeObservable(expected$); + }); + + it('should emit multiple output actions when source produces many actions', () => { + const sources$ = hot(' -a--b', genActions('ab')); + const project = () => cold('(v|)', genActions('v')); + const error = () => createAction('e')(); + + const expected$ = cold('-v--v-', genActions('v')); + + const output$ = sources$.pipe(mapToAction(project, error)); + + expect(output$).toBeObservable(expected$); + }); + + it('should emit multiple output actions when source produces many actions with config passed', () => { + const sources$ = hot(' -a--b', genActions('ab')); + const project = () => cold('(v|)', genActions('v')); + const error = () => createAction('e')(); + + const expected$ = cold('-v--v-', genActions('v')); + + const output$ = sources$.pipe(mapToAction(project, error)); + + expect(output$).toBeObservable(expected$); + }); + + it('should flatten projects with concatMap by default', () => { + const sources$ = hot(' -a--b', genActions('ab')); + const project = () => cold('v------(w|)', genActions('vw')); + const error = () => createAction('e')(); + + // Even thought source produced actions one right after another, operator + // wait for the project to complete before handling second source action. + const expected$ = cold('-v------(wv)---w', genActions('vw')); + + const output$ = sources$.pipe(mapToAction(project, error)); + + expect(output$).toBeObservable(expected$); + }); + + it('should flatten projects with concatMap by default with config passed', () => { + const sources$ = hot(' -a--b', genActions('ab')); + const project = () => cold('v------(w|)', genActions('vw')); + const error = () => createAction('e')(); + + // Even thought source produced actions one right after another, operator + // wait for the project to complete before handling second source action. + const expected$ = cold('-v------(wv)---w', genActions('vw')); + + const output$ = sources$.pipe(mapToAction({ project, error })); + + expect(output$).toBeObservable(expected$); + }); + + it('should use provided flattening operator', () => { + const sources$ = hot(' -a--b', genActions('ab')); + const project = () => cold('v------(w|)', genActions('vw')); + const error = () => createAction('e')(); + + // Merge map starts project streams in parallel + const expected$ = cold('-v--v---w--w', genActions('vw')); + + const output$ = sources$.pipe( + mapToAction({ project, error, operator: mergeMap }) + ); + + expect(output$).toBeObservable(expected$); + }); + + it('should use provided complete callback', () => { + const sources$ = hot(' -a', genActions('a')); + const project = () => cold('v-|', genActions('v')); + const error = () => createAction('e')(); + const complete = () => createAction('c')(); + + // Completed is the last action + const expected$ = cold('-v-c', genActions('vc')); + + const output$ = sources$.pipe(mapToAction({ project, error, complete })); + + expect(output$).toBeObservable(expected$); + }); + + it('should pass number of observables that project emitted and input action to complete callback', () => { + const sources$ = hot('-a', genActions('a')); + const project = () => cold('v-w-|', genActions('v')); + const error = () => createAction('e')(); + + const actual$ = new Subject(); + + const complete = jasmine + .createSpy('complete') + .and.callFake((...args: [number, Action]) => { + actual$.next(args); + return createAction('c')(); + }); + + sources$.pipe(mapToAction({ project, error, complete })).subscribe(); + + expect(actual$).toBeObservable( + cold('-----a', { + a: [2, createAction('a')()], + }) + ); + }); + + it('should use provided unsubscribe callback', () => { + const sources$ = hot(' -a-b', genActions('ab')); + const project = () => cold('v-----w|', genActions('vw')); + const error = () => createAction('e')(); + const unsubscribe = () => createAction('u')(); + + // switchMap causes unsubscription + const expected$ = cold('-v-(uv)--w', genActions('vuw')); + + const output$ = sources$.pipe( + mapToAction({ project, error, unsubscribe, operator: switchMap }) + ); + + expect(output$).toBeObservable(expected$); + }); + + it( + 'should pass number of observables that project emitted before' + + ' unsubscribing and prior input action to unsubsubscribe callback', + () => { + const sources$ = hot('-a-b', genActions('ab')); + const project = () => cold('vw----v|', genActions('vw')); + const error = () => createAction('e')(); + + const actual$ = new Subject(); + + const unsubscribe = jasmine + .createSpy('unsubscribe') + .and.callFake((...args: [number, Action]) => { + actual$.next(args); + return createAction('u')(); + }); + + sources$ + .pipe(mapToAction({ project, error, unsubscribe, operator: switchMap })) + .subscribe(); + + expect(actual$).toBeObservable( + cold('---a', { + a: [2, createAction('a')()], + }) + ); + } + ); +}); diff --git a/modules/effects/src/index.ts b/modules/effects/src/index.ts index 827dfc61a1..0008709339 100644 --- a/modules/effects/src/index.ts +++ b/modules/effects/src/index.ts @@ -8,6 +8,7 @@ export { EffectsModule } from './effects_module'; export { EffectSources } from './effect_sources'; export { EffectNotification } from './effect_notification'; export { ROOT_EFFECTS_INIT } from './effects_root_module'; +export { mapToAction } from './map_to_action'; export { OnIdentifyEffects, OnRunEffects, diff --git a/modules/effects/src/map_to_action.ts b/modules/effects/src/map_to_action.ts new file mode 100644 index 0000000000..f590d7ebfe --- /dev/null +++ b/modules/effects/src/map_to_action.ts @@ -0,0 +1,170 @@ +import { Action } from '@ngrx/store'; +import { + defer, + merge, + Notification, + Observable, + OperatorFunction, + Subject, + NotificationKind, +} from 'rxjs'; +import { + concatMap, + dematerialize, + filter, + finalize, + map, + materialize, +} from 'rxjs/operators'; + +/** Represents config with named paratemeters for mapToAction */ +export interface MapToActionConfig< + Input, + OutputAction extends Action, + ErrorAction extends Action, + CompleteAction extends Action, + UnsubscribeAction extends Action +> { + // Project function that produces the output actions in success cases + project: (input: Input, index: number) => Observable; + // Error handle function for project + // error that happened during project execution + // input value that project errored with + error: (error: any, input: Input) => ErrorAction; + // Optional complete action provider + // count is the number of actions project emitted before completion + // input value that project completed with + complete?: (count: number, input: Input) => CompleteAction; + // Optional flattening operator + operator?: ( + project: (input: Input, index: number) => Observable + ) => OperatorFunction; + // Optional unsubscribe action provider + // count is the number of actions project emitted before unsubscribing + // input value that was unsubscribed from + unsubscribe?: (count: number, input: Input) => UnsubscribeAction; +} + +/** + * Wraps project fn with error handling making it safe to use in Effects. + * Takes either config with named properties that represent different possible + * callbacks or project/error callbacks that are required. + */ +export function mapToAction< + Input, + OutputAction extends Action, + ErrorAction extends Action +>( + project: (input: Input, index: number) => Observable, + error: (error: any, input: Input) => ErrorAction +): (source: Observable) => Observable; +export function mapToAction< + Input, + OutputAction extends Action, + ErrorAction extends Action, + CompleteAction extends Action = never, + UnsubscribeAction extends Action = never +>( + config: MapToActionConfig< + Input, + OutputAction, + ErrorAction, + CompleteAction, + UnsubscribeAction + > +): ( + source: Observable +) => Observable< + OutputAction | ErrorAction | CompleteAction | UnsubscribeAction +>; +export function mapToAction< + Input, + OutputAction extends Action, + ErrorAction extends Action, + CompleteAction extends Action = never, + UnsubscribeAction extends Action = never +>( + /** Allow to take either config object or project/error functions */ + configOrProject: + | MapToActionConfig< + Input, + OutputAction, + ErrorAction, + CompleteAction, + UnsubscribeAction + > + | ((input: Input, index: number) => Observable), + errorFn?: (error: any, input: Input) => ErrorAction +): ( + source: Observable +) => Observable< + OutputAction | ErrorAction | CompleteAction | UnsubscribeAction +> { + const { project, error, complete, operator, unsubscribe } = + typeof configOrProject === 'function' + ? { + project: configOrProject, + error: errorFn!, + operator: concatMap, + complete: undefined, + unsubscribe: undefined, + } + : { ...configOrProject, operator: configOrProject.operator || concatMap }; + + type ResultAction = + | OutputAction + | ErrorAction + | CompleteAction + | UnsubscribeAction; + return source => + defer( + (): Observable => { + const subject = new Subject(); + return merge( + source.pipe( + operator((input, index) => + defer(() => { + let completed = false; + let errored = false; + let projectedCount = 0; + return project(input, index).pipe( + materialize(), + map( + (notification): Notification | undefined => { + switch (notification.kind) { + case NotificationKind.ERROR: + errored = true; + return new Notification( + NotificationKind.NEXT, + error(notification.error, input) + ); + case NotificationKind.COMPLETE: + completed = true; + return complete + ? new Notification( + NotificationKind.NEXT, + complete(projectedCount, input) + ) + : undefined; + default: + ++projectedCount; + return notification; + } + } + ), + filter((n): n is NonNullable => n != null), + dematerialize(), + finalize(() => { + if (!completed && !errored && unsubscribe) { + subject.next(unsubscribe(projectedCount, input)); + } + }) + ); + }) + ) + ), + subject + ); + } + ); +} diff --git a/package.json b/package.json index 8749f80852..87d8775702 100644 --- a/package.json +++ b/package.json @@ -130,7 +130,7 @@ "fs-extra": "^2.1.2", "glob": "^7.1.2", "husky": "^1.2.0", - "jasmine": "^2.5.3", + "jasmine": "^3.4.0", "jasmine-core": "~2.5.2", "jasmine-marbles": "^0.4.0", "jasmine-spec-reporter": "~3.2.0", diff --git a/projects/example-app/src/app/auth/effects/auth.effects.ts b/projects/example-app/src/app/auth/effects/auth.effects.ts index 39cf0ac0a2..5235a46dd5 100644 --- a/projects/example-app/src/app/auth/effects/auth.effects.ts +++ b/projects/example-app/src/app/auth/effects/auth.effects.ts @@ -60,8 +60,11 @@ export class AuthEffects { return dialogRef.afterClosed(); }), - map(result => - result ? AuthActions.logout() : AuthActions.logoutConfirmationDismiss() + map( + result => + result + ? AuthActions.logout() + : AuthActions.logoutConfirmationDismiss() ) ) ); diff --git a/projects/ngrx.io/content/guide/effects/index.md b/projects/ngrx.io/content/guide/effects/index.md index c4adf2b038..275ce39f5e 100644 --- a/projects/ngrx.io/content/guide/effects/index.md +++ b/projects/ngrx.io/content/guide/effects/index.md @@ -96,7 +96,7 @@ Effects are injectable service classes with distinct parts: - An injectable `Actions` service that provides an observable stream of _all_ actions dispatched _after_ the latest state has been reduced. - Metadata is attached to the observable streams using the `createEffect` function. The metadata is used to register the streams that are subscribed to the store. Any action returned from the effect stream is then dispatched back to the `Store`. -- Actions are filtered using a pipeable `ofType` operator. The `ofType` operator takes one more action types as arguments to filter on which actions to act upon. +- Actions are filtered using a pipeable [`ofType` operator](guide/effects/operators#oftype). The `ofType` operator takes one or more action types as arguments to filter on which actions to act upon. - Effects are subscribed to the `Store` observable. - Services are injected into effects to interact with external APIs and handle streams. @@ -178,6 +178,39 @@ export class MovieEffects { The `loadMovies$` effect returns a new observable in case an error occurs while fetching movies. The inner observable handles any errors or completions and returns a new observable so that the outer stream does not die. You still use the `catchError` operator to handle error events, but return an observable of a new action that is dispatched to the `Store`. +### using `mapToAction` operator + +Alternatively, we recommend to use the [`mapToAction`](guide/effects/operators#maptoaction) operator to catch any +potential errors. + + +import { Injectable } from '@angular/core'; +import { Actions, Effect, ofType } from '@ngrx/effects'; +import { of } from 'rxjs'; +import { map, mergeMap } from 'rxjs/operators'; + +@Injectable() +export class MovieEffects { + + loadMovies$ = createEffect(() => + this.actions$.pipe( + ofType('[Movies Page] Load Movies'), + mapToAction({ + project: () => this.moviesService.getAll().pipe.map(response => + ({type: '[Movies API] Movies Loaded Success', movies: response })), + error: () => { type: '[Movies API] Movies Loaded Error' }, + operator: mergeMap, + }) + ) + ); + + constructor( + private actions$: Actions, + private moviesService: MoviesService + ) {} +} + + ## Registering root effects After you've written your Effects class, you must register it so the effects start running. To register root-level effects, add the `EffectsModule.forRoot()` method with an array of your effects to your `AppModule`. diff --git a/projects/ngrx.io/content/guide/effects/operators.md b/projects/ngrx.io/content/guide/effects/operators.md new file mode 100644 index 0000000000..18fb6ad541 --- /dev/null +++ b/projects/ngrx.io/content/guide/effects/operators.md @@ -0,0 +1,175 @@ +# Effects operators + +As part of the `Effects` library, NgRx provides some useful operators that are frequently +used. + + +## `ofType` + +The `ofType` operator filters the stream of actions based on either string +values (that represent `type`s of actions) or Action Creators. + +The generic for the `Actions` must be provided in order for type +inference to work properly with string values. Action Creators that are based on +`createAction` function do not have the same limitation. + +The `ofType` operator takes up to 5 arguments with proper type inference. It can +take even more, however the type would be inferred as an `Action` interface. + + +import { Injectable } from '@angular/core'; +import { Actions, ofType, createEffect } from '@ngrx/effects'; +import { of } from 'rxjs'; +import { catchError, exhaustMap, map } from 'rxjs/operators'; +import { + LoginPageActions, + AuthApiActions, +} from '../actions'; +import { Credentials } from '../models/user'; +import { AuthService } from '../services/auth.service'; + +@Injectable() +export class AuthEffects { + login$ = createEffect(() => + this.actions$.pipe( + // Filters by Action Creator 'login' + ofType(LoginPageActions.login), + exhaustMap(action => + this.authService.login(action.credentials).pipe( + map(user => AuthApiActions.loginSuccess({ user })), + catchError(error => of(AuthApiActions.loginFailure({ error }))) + ) + ) + ) + ); + + constructor( + private actions$: Actions, + private authService: AuthService + ) {} +} + + +## `mapToAction` + +Many effects are used to call APIs and due to the nature of network communication +some of them may fail. That means that the service call should ideally be wrapped +with `catchError` to transform the failed request into another Action. + +Not only `catchError` is necessary, it's has to be used before the stream is +flattened or has to be constructed to re-subscribe to the source Observable. +Missing such error handling results in bugs that are hard to discover. + +Even people who are familiar with these mistakes still make them sometimes. + +The `mapToAction` operator wraps the `project` function that should return the main +"happy path" Observable that emits Action(s). It also requires an `error` callback +to be provided, so that steam can be flattened safely. + + +import { Injectable } from '@angular/core'; +import { Actions, ofType, createEffect, mapToAction } from '@ngrx/effects'; +import { of } from 'rxjs'; +import { catchError, exhaustMap, map } from 'rxjs/operators'; +import { + LoginPageActions, + AuthApiActions, +} from '../actions'; +import { Credentials } from '../models/user'; +import { AuthService } from '../services/auth.service'; + +@Injectable() +export class AuthEffects { + login$ = createEffect(() => + this.actions$.pipe( + ofType(LoginPageActions.login), + mapToAction( + // Happy path callback + action => this.authService.login(action.credentials).pipe( + map(user => AuthApiActions.loginSuccess({ user }))), + // error callback + error => AuthApiActions.loginFailure({ error }), + ) + ) + ); + + constructor( + private actions$: Actions, + private authService: AuthService + ) {} +} + + +Notice that it is no longer necessary to wrap the Error Action with the static `of` +Observable. + +### `mapToAction` signatures +The `mapToAction` function has two signatures. The simple one takes two callbacks. + + +function mapToAction< + Input, + OutputAction extends Action, + ErrorAction extends Action +>( + project: (input: Input, index: number) => Observable<OutputAction>, + error: (error: any, input: Input) => ErrorAction +): (source: Observable<Input>) => Observable<OutputAction | ErrorAction>; + + +- `project`: A callback that is provided with an input value (often an Action) and +expects the output result that is wrapped with Action +- `error`: A callback that is called if `project` throws an error + +`mapToAction` uses the `concatMap` flattening strategy by default. When a more +configurable option is needed or for more advanced use cases you can provide +the config object. + + + +/** Represents the config with named parameters for mapToAction */ +export interface MapToActionConfig< + Input, + OutputAction extends Action, + ErrorAction extends Action, + CompleteAction extends Action, + UnsubscribeAction extends Action +> { + project: (input: Input, index: number) => Observable<OutputAction>; + error: (error: any, input: Input) => ErrorAction; + complete?: (count: number, input: Input) => CompleteAction; + operator?: <Input, OutputAction>( + project: (input: Input, index: number) => Observable<OutputAction> + ) => OperatorFunction<Input, OutputAction>; + unsubscribe?: (count: number, input: Input) => UnsubscribeAction; +} + +function mapToAction< + Input, + OutputAction extends Action, + ErrorAction extends Action, + CompleteAction extends Action = never, + UnsubscribeAction extends Action = never +>( + config: MapToActionConfig< + Input, + OutputAction, + ErrorAction, + CompleteAction, + UnsubscribeAction + > +): ( + source: Observable<Input> +) => Observable< + OutputAction | ErrorAction | CompleteAction | UnsubscribeAction +>; + + +- `project`: A callback that is provided with an input value (often an Action) and +expects the output result that is wrapped with Action +- `error`: A callback that is called if `project` throws an error +- `complete`: Optional complete action provider, when `project` completes +- `operator`: Optional flattening operator. `concatMap` is used by default +- `unsubscribe`: Optional unsubscribe action provider, when `project` is unsubscribed +(e.g. in case of the `switchMap` flattening operator when a new value arrives) + diff --git a/projects/ngrx.io/content/navigation.json b/projects/ngrx.io/content/navigation.json index b29830c491..47b1c3a842 100644 --- a/projects/ngrx.io/content/navigation.json +++ b/projects/ngrx.io/content/navigation.json @@ -170,6 +170,10 @@ { "title": "Lifecycle", "url": "guide/effects/lifecycle" + }, + { + "title": "Operators", + "url": "guide/effects/operators" } ] }, diff --git a/projects/ngrx.io/package.json b/projects/ngrx.io/package.json index a59c62925d..1213f6a19c 100644 --- a/projects/ngrx.io/package.json +++ b/projects/ngrx.io/package.json @@ -69,7 +69,7 @@ "copy-404-page": "node scripts/copy-404-page" }, "engines": { - "node": ">=10.9.0 <11.12.0", + "node": ">=10.9.0 <=11.12.0", "npm": ">=5.3.0", "yarn": ">=1.9.2 <2.0.0" }, diff --git a/yarn.lock b/yarn.lock index cb52782a11..dfaf0e3453 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6284,6 +6284,11 @@ jasmine-core@~3.3.0: resolved "https://registry.yarnpkg.com/jasmine-core/-/jasmine-core-3.3.0.tgz#dea1cdc634bc93c7e0d4ad27185df30fa971b10e" integrity sha512-3/xSmG/d35hf80BEN66Y6g9Ca5l/Isdeg/j6zvbTYlTzeKinzmaTM4p9am5kYqOmE05D7s1t8FGjzdSnbUbceA== +jasmine-core@~3.4.0: + version "3.4.0" + resolved "https://registry.yarnpkg.com/jasmine-core/-/jasmine-core-3.4.0.tgz#2a74618e966026530c3518f03e9f845d26473ce3" + integrity sha512-HU/YxV4i6GcmiH4duATwAbJQMlE0MsDIR5XmSVxURxKHn3aGAdbY1/ZJFmVRbKtnLwIxxMJD7gYaPsypcbYimg== + jasmine-marbles@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/jasmine-marbles/-/jasmine-marbles-0.4.0.tgz#de72331d189d4968e4b1e78b638e51654040c755" @@ -6304,6 +6309,14 @@ jasmine@^2.5.3: glob "^7.0.6" jasmine-core "~2.99.0" +jasmine@^3.4.0: + version "3.4.0" + resolved "https://registry.yarnpkg.com/jasmine/-/jasmine-3.4.0.tgz#0fa68903ff0c9697459cd044b44f4dcef5ec8bdc" + integrity sha512-sR9b4n+fnBFDEd7VS2el2DeHgKcPiMVn44rtKFumq9q7P/t8WrxsVIZPob4UDdgcDNCwyDqwxCt4k9TDRmjPoQ== + dependencies: + glob "^7.1.3" + jasmine-core "~3.4.0" + jasmine@~3.3.1: version "3.3.1" resolved "https://registry.yarnpkg.com/jasmine/-/jasmine-3.3.1.tgz#d61bb1dd8888859bd11ea83074a78ee13d949905"