From 016508d2453eed3aedcd173cabd88edfbeedcbd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=A8=20Audrey=20=E2=9C=A8?= Date: Mon, 28 Apr 2025 13:24:50 -0400 Subject: [PATCH] increase state consistency of generator code --- libs/common/src/tools/rx.spec.ts | 1504 +++++++++-------- libs/common/src/tools/rx.ts | 51 + ...fault-credential-generator.service.spec.ts | 88 +- .../default-credential-generator.service.ts | 87 +- 4 files changed, 951 insertions(+), 779 deletions(-) diff --git a/libs/common/src/tools/rx.spec.ts b/libs/common/src/tools/rx.spec.ts index ee1de1c9118..5177dcaa2a5 100644 --- a/libs/common/src/tools/rx.spec.ts +++ b/libs/common/src/tools/rx.spec.ts @@ -3,7 +3,7 @@ * @jest-environment ../../../../shared/test.environment.ts */ // @ts-strict-ignore this file explicitly tests what happens when types are ignored -import { of, firstValueFrom, Subject, tap, EmptyError } from "rxjs"; +import { of, firstValueFrom, Subject, tap, EmptyError, BehaviorSubject } from "rxjs"; import { awaitAsync, trackEmissions } from "../../spec"; @@ -16,733 +16,825 @@ import { reduceCollection, withLatestReady, pin, + memoizedMap, } from "./rx"; -describe("errorOnChange", () => { - it("emits a single value when the input emits only once", async () => { - const source$ = new Subject(); - const results: number[] = []; - source$.pipe(errorOnChange()).subscribe((v) => results.push(v)); +describe("tools rx utilites", () => { + describe("errorOnChange", () => { + it("emits a single value when the input emits only once", async () => { + const source$ = new Subject(); + const results: number[] = []; + source$.pipe(errorOnChange()).subscribe((v) => results.push(v)); - source$.next(1); + source$.next(1); - expect(results).toEqual([1]); + expect(results).toEqual([1]); + }); + + it("emits when the input emits", async () => { + const source$ = new Subject(); + const results: number[] = []; + source$.pipe(errorOnChange()).subscribe((v) => results.push(v)); + + source$.next(1); + source$.next(1); + + expect(results).toEqual([1, 1]); + }); + + it("errors when the input errors", async () => { + const source$ = new Subject(); + const expected = {}; + let error: any = null; + source$.pipe(errorOnChange()).subscribe({ error: (v: unknown) => (error = v) }); + + source$.error(expected); + + expect(error).toBe(expected); + }); + + it("completes when the input completes", async () => { + const source$ = new Subject(); + let complete: boolean = false; + source$.pipe(errorOnChange()).subscribe({ complete: () => (complete = true) }); + + source$.complete(); + + expect(complete).toBe(true); + }); + + it("errors when the input changes", async () => { + const source$ = new Subject(); + let error: any = null; + source$.pipe(errorOnChange()).subscribe({ error: (v: unknown) => (error = v) }); + + source$.next(1); + source$.next(2); + + expect(error).toEqual({ expectedValue: 1, actualValue: 2 }); + }); + + it("emits when the extracted value remains constant", async () => { + type Foo = { foo: string }; + const source$ = new Subject(); + const results: Foo[] = []; + source$.pipe(errorOnChange((v) => v.foo)).subscribe((v) => results.push(v)); + + source$.next({ foo: "bar" }); + source$.next({ foo: "bar" }); + + expect(results).toEqual([{ foo: "bar" }, { foo: "bar" }]); + }); + + it("errors when an extracted value changes", async () => { + type Foo = { foo: string }; + const source$ = new Subject(); + let error: any = null; + source$.pipe(errorOnChange((v) => v.foo)).subscribe({ error: (v: unknown) => (error = v) }); + + source$.next({ foo: "bar" }); + source$.next({ foo: "baz" }); + + expect(error).toEqual({ expectedValue: "bar", actualValue: "baz" }); + }); + + it("constructs an error when the extracted value changes", async () => { + type Foo = { foo: string }; + const source$ = new Subject(); + let error: any = null; + source$ + .pipe( + errorOnChange( + (v) => v.foo, + (expected, actual) => ({ expected, actual }), + ), + ) + .subscribe({ error: (v: unknown) => (error = v) }); + + source$.next({ foo: "bar" }); + source$.next({ foo: "baz" }); + + expect(error).toEqual({ expected: "bar", actual: "baz" }); + }); }); - it("emits when the input emits", async () => { - const source$ = new Subject(); - const results: number[] = []; - source$.pipe(errorOnChange()).subscribe((v) => results.push(v)); + describe("reduceCollection", () => { + it.each([[null], [undefined], [[]]])( + "should return the default value when the collection is %p", + async (value: number[]) => { + const reduce = (acc: number, value: number) => acc + value; + const source$ = of(value); - source$.next(1); - source$.next(1); + const result$ = source$.pipe(reduceCollection(reduce, 100)); + const result = await firstValueFrom(result$); - expect(results).toEqual([1, 1]); - }); + expect(result).toEqual(100); + }, + ); - it("errors when the input errors", async () => { - const source$ = new Subject(); - const expected = {}; - let error: any = null; - source$.pipe(errorOnChange()).subscribe({ error: (v: unknown) => (error = v) }); - - source$.error(expected); - - expect(error).toBe(expected); - }); - - it("completes when the input completes", async () => { - const source$ = new Subject(); - let complete: boolean = false; - source$.pipe(errorOnChange()).subscribe({ complete: () => (complete = true) }); - - source$.complete(); - - expect(complete).toBe(true); - }); - - it("errors when the input changes", async () => { - const source$ = new Subject(); - let error: any = null; - source$.pipe(errorOnChange()).subscribe({ error: (v: unknown) => (error = v) }); - - source$.next(1); - source$.next(2); - - expect(error).toEqual({ expectedValue: 1, actualValue: 2 }); - }); - - it("emits when the extracted value remains constant", async () => { - type Foo = { foo: string }; - const source$ = new Subject(); - const results: Foo[] = []; - source$.pipe(errorOnChange((v) => v.foo)).subscribe((v) => results.push(v)); - - source$.next({ foo: "bar" }); - source$.next({ foo: "bar" }); - - expect(results).toEqual([{ foo: "bar" }, { foo: "bar" }]); - }); - - it("errors when an extracted value changes", async () => { - type Foo = { foo: string }; - const source$ = new Subject(); - let error: any = null; - source$.pipe(errorOnChange((v) => v.foo)).subscribe({ error: (v: unknown) => (error = v) }); - - source$.next({ foo: "bar" }); - source$.next({ foo: "baz" }); - - expect(error).toEqual({ expectedValue: "bar", actualValue: "baz" }); - }); - - it("constructs an error when the extracted value changes", async () => { - type Foo = { foo: string }; - const source$ = new Subject(); - let error: any = null; - source$ - .pipe( - errorOnChange( - (v) => v.foo, - (expected, actual) => ({ expected, actual }), - ), - ) - .subscribe({ error: (v: unknown) => (error = v) }); - - source$.next({ foo: "bar" }); - source$.next({ foo: "baz" }); - - expect(error).toEqual({ expected: "bar", actual: "baz" }); - }); -}); - -describe("reduceCollection", () => { - it.each([[null], [undefined], [[]]])( - "should return the default value when the collection is %p", - async (value: number[]) => { + it("should reduce the collection to a single value", async () => { const reduce = (acc: number, value: number) => acc + value; - const source$ = of(value); + const source$ = of([1, 2, 3]); - const result$ = source$.pipe(reduceCollection(reduce, 100)); + const result$ = source$.pipe(reduceCollection(reduce, 0)); const result = await firstValueFrom(result$); - expect(result).toEqual(100); - }, - ); - - it("should reduce the collection to a single value", async () => { - const reduce = (acc: number, value: number) => acc + value; - const source$ = of([1, 2, 3]); - - const result$ = source$.pipe(reduceCollection(reduce, 0)); - const result = await firstValueFrom(result$); - - expect(result).toEqual(6); - }); -}); - -describe("distinctIfShallowMatch", () => { - it("emits a single value", async () => { - const source$ = of({ foo: true }); - const pipe$ = source$.pipe(distinctIfShallowMatch()); - - const result = trackEmissions(pipe$); - await awaitAsync(); - - expect(result).toEqual([{ foo: true }]); - }); - - it("emits different values", async () => { - const source$ = of({ foo: true }, { foo: false }); - const pipe$ = source$.pipe(distinctIfShallowMatch()); - - const result = trackEmissions(pipe$); - await awaitAsync(); - - expect(result).toEqual([{ foo: true }, { foo: false }]); - }); - - it("emits new keys", async () => { - const source$ = of({ foo: true }, { foo: true, bar: true }); - const pipe$ = source$.pipe(distinctIfShallowMatch()); - - const result = trackEmissions(pipe$); - await awaitAsync(); - - expect(result).toEqual([{ foo: true }, { foo: true, bar: true }]); - }); - - it("suppresses identical values", async () => { - const source$ = of({ foo: true }, { foo: true }); - const pipe$ = source$.pipe(distinctIfShallowMatch()); - - const result = trackEmissions(pipe$); - await awaitAsync(); - - expect(result).toEqual([{ foo: true }]); - }); - - it("suppresses removed keys", async () => { - const source$ = of({ foo: true, bar: true }, { foo: true }); - const pipe$ = source$.pipe(distinctIfShallowMatch()); - - const result = trackEmissions(pipe$); - await awaitAsync(); - - expect(result).toEqual([{ foo: true, bar: true }]); - }); -}); - -describe("anyComplete", () => { - it("emits true when its input completes", () => { - const input$ = new Subject(); - - const emissions: boolean[] = []; - anyComplete(input$).subscribe((e) => emissions.push(e)); - input$.complete(); - - expect(emissions).toEqual([true]); - }); - - it("completes when its input is already complete", () => { - const input = new Subject(); - input.complete(); - - let completed = false; - anyComplete(input).subscribe({ complete: () => (completed = true) }); - - expect(completed).toBe(true); - }); - - it("completes when any input completes", () => { - const input$ = new Subject(); - const completing$ = new Subject(); - - let completed = false; - anyComplete([input$, completing$]).subscribe({ complete: () => (completed = true) }); - completing$.complete(); - - expect(completed).toBe(true); - }); - - it("ignores emissions", () => { - const input$ = new Subject(); - - const emissions: boolean[] = []; - anyComplete(input$).subscribe((e) => emissions.push(e)); - input$.next(1); - input$.next(2); - input$.complete(); - - expect(emissions).toEqual([true]); - }); - - it("forwards errors", () => { - const input$ = new Subject(); - const expected = { some: "error" }; - - let error = null; - anyComplete(input$).subscribe({ error: (e: unknown) => (error = e) }); - input$.error(expected); - - expect(error).toEqual(expected); - }); -}); - -describe("ready", () => { - it("connects when subscribed", () => { - const watch$ = new Subject(); - let connected = false; - const source$ = new Subject().pipe(tap({ subscribe: () => (connected = true) })); - - // precondition: ready$ should be cold - const ready$ = source$.pipe(ready(watch$)); - expect(connected).toBe(false); - - ready$.subscribe(); - - expect(connected).toBe(true); - }); - - it("suppresses source emissions until its watch emits", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const ready$ = source$.pipe(ready(watch$)); - const results: number[] = []; - ready$.subscribe((n) => results.push(n)); - - // precondition: no emissions - source$.next(1); - expect(results).toEqual([]); - - watch$.next(); - - expect(results).toEqual([1]); - }); - - it("suppresses source emissions until all watches emit", () => { - const watchA$ = new Subject(); - const watchB$ = new Subject(); - const source$ = new Subject(); - const ready$ = source$.pipe(ready([watchA$, watchB$])); - const results: number[] = []; - ready$.subscribe((n) => results.push(n)); - - // preconditions: no emissions - source$.next(1); - expect(results).toEqual([]); - watchA$.next(); - expect(results).toEqual([]); - - watchB$.next(); - - expect(results).toEqual([1]); - }); - - it("emits the last source emission when its watch emits", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const ready$ = source$.pipe(ready(watch$)); - const results: number[] = []; - ready$.subscribe((n) => results.push(n)); - - // precondition: no emissions - source$.next(1); - expect(results).toEqual([]); - - source$.next(2); - watch$.next(); - - expect(results).toEqual([2]); - }); - - it("emits all source emissions after its watch emits", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const ready$ = source$.pipe(ready(watch$)); - const results: number[] = []; - ready$.subscribe((n) => results.push(n)); - - watch$.next(); - source$.next(1); - source$.next(2); - - expect(results).toEqual([1, 2]); - }); - - it("ignores repeated watch emissions", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const ready$ = source$.pipe(ready(watch$)); - const results: number[] = []; - ready$.subscribe((n) => results.push(n)); - - watch$.next(); - source$.next(1); - watch$.next(); - source$.next(2); - watch$.next(); - - expect(results).toEqual([1, 2]); - }); - - it("completes when its source completes", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const ready$ = source$.pipe(ready(watch$)); - let completed = false; - ready$.subscribe({ complete: () => (completed = true) }); - - source$.complete(); - - expect(completed).toBeTruthy(); - }); - - it("errors when its source errors", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const ready$ = source$.pipe(ready(watch$)); - const expected = { some: "error" }; - let error = null; - ready$.subscribe({ error: (e: unknown) => (error = e) }); - - source$.error(expected); - - expect(error).toEqual(expected); - }); - - it("errors when its watch errors", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const ready$ = source$.pipe(ready(watch$)); - const expected = { some: "error" }; - let error = null; - ready$.subscribe({ error: (e: unknown) => (error = e) }); - - watch$.error(expected); - - expect(error).toEqual(expected); - }); - - it("errors when its watch completes before emitting", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const ready$ = source$.pipe(ready(watch$)); - let error = null; - ready$.subscribe({ error: (e: unknown) => (error = e) }); - - watch$.complete(); - - expect(error).toBeInstanceOf(EmptyError); - }); -}); - -describe("withLatestReady", () => { - it("connects when subscribed", () => { - const watch$ = new Subject(); - let connected = false; - const source$ = new Subject().pipe(tap({ subscribe: () => (connected = true) })); - - // precondition: ready$ should be cold - const ready$ = source$.pipe(withLatestReady(watch$)); - expect(connected).toBe(false); - - ready$.subscribe(); - - expect(connected).toBe(true); - }); - - it("suppresses source emissions until its watch emits", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const ready$ = source$.pipe(withLatestReady(watch$)); - const results: [number, string][] = []; - ready$.subscribe((n) => results.push(n)); - - // precondition: no emissions - source$.next(1); - expect(results).toEqual([]); - - watch$.next("watch"); - - expect(results).toEqual([[1, "watch"]]); - }); - - it("emits the last source emission when its watch emits", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const ready$ = source$.pipe(withLatestReady(watch$)); - const results: [number, string][] = []; - ready$.subscribe((n) => results.push(n)); - - // precondition: no emissions - source$.next(1); - expect(results).toEqual([]); - - source$.next(2); - watch$.next("watch"); - - expect(results).toEqual([[2, "watch"]]); - }); - - it("emits all source emissions after its watch emits", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const ready$ = source$.pipe(withLatestReady(watch$)); - const results: [number, string][] = []; - ready$.subscribe((n) => results.push(n)); - - watch$.next("watch"); - source$.next(1); - source$.next(2); - - expect(results).toEqual([ - [1, "watch"], - [2, "watch"], - ]); - }); - - it("appends the latest watch emission", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const ready$ = source$.pipe(withLatestReady(watch$)); - const results: [number, string][] = []; - ready$.subscribe((n) => results.push(n)); - - watch$.next("ignored"); - watch$.next("watch"); - source$.next(1); - watch$.next("ignored"); - watch$.next("watch"); - source$.next(2); - - expect(results).toEqual([ - [1, "watch"], - [2, "watch"], - ]); - }); - - it("completes when its source completes", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const ready$ = source$.pipe(withLatestReady(watch$)); - let completed = false; - ready$.subscribe({ complete: () => (completed = true) }); - - source$.complete(); - - expect(completed).toBeTruthy(); - }); - - it("errors when its source errors", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const ready$ = source$.pipe(withLatestReady(watch$)); - const expected = { some: "error" }; - let error = null; - ready$.subscribe({ error: (e: unknown) => (error = e) }); - - source$.error(expected); - - expect(error).toEqual(expected); - }); - - it("errors when its watch errors", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const ready$ = source$.pipe(withLatestReady(watch$)); - const expected = { some: "error" }; - let error = null; - ready$.subscribe({ error: (e: unknown) => (error = e) }); - - watch$.error(expected); - - expect(error).toEqual(expected); - }); - - it("errors when its watch completes before emitting", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const ready$ = source$.pipe(withLatestReady(watch$)); - let error = null; - ready$.subscribe({ error: (e: unknown) => (error = e) }); - - watch$.complete(); - - expect(error).toBeInstanceOf(EmptyError); - }); -}); - -describe("on", () => { - it("connects when subscribed", () => { - const watch$ = new Subject(); - let connected = false; - const source$ = new Subject().pipe(tap({ subscribe: () => (connected = true) })); - - // precondition: on$ should be cold - const on$ = source$.pipe(on(watch$)); - expect(connected).toBeFalsy(); - - on$.subscribe(); - - expect(connected).toBeTruthy(); - }); - - it("suppresses source emissions until `on` emits", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const results: number[] = []; - source$.pipe(on(watch$)).subscribe((n) => results.push(n)); - - // precondition: on$ should be cold - source$.next(1); - expect(results).toEqual([]); - - watch$.next(); - - expect(results).toEqual([1]); - }); - - it("repeats source emissions when `on` emits", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const results: number[] = []; - source$.pipe(on(watch$)).subscribe((n) => results.push(n)); - source$.next(1); - - watch$.next(); - watch$.next(); - - expect(results).toEqual([1, 1]); - }); - - it("updates source emissions when `on` emits", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const results: number[] = []; - source$.pipe(on(watch$)).subscribe((n) => results.push(n)); - - source$.next(1); - watch$.next(); - source$.next(2); - watch$.next(); - - expect(results).toEqual([1, 2]); - }); - - it("emits a value when `on` emits before the source is ready", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const results: number[] = []; - source$.pipe(on(watch$)).subscribe((n) => results.push(n)); - - watch$.next(); - source$.next(1); - - expect(results).toEqual([1]); - }); - - it("ignores repeated `on` emissions before the source is ready", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const results: number[] = []; - source$.pipe(on(watch$)).subscribe((n) => results.push(n)); - - watch$.next(); - watch$.next(); - source$.next(1); - - expect(results).toEqual([1]); - }); - - it("emits only the latest source emission when `on` emits", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const results: number[] = []; - source$.pipe(on(watch$)).subscribe((n) => results.push(n)); - source$.next(1); - - watch$.next(); - - source$.next(2); - source$.next(3); - watch$.next(); - - expect(results).toEqual([1, 3]); - }); - - it("completes when its source completes", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - let complete: boolean = false; - source$.pipe(on(watch$)).subscribe({ complete: () => (complete = true) }); - - source$.complete(); - - expect(complete).toBeTruthy(); - }); - - it("completes when its watch completes", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - let complete: boolean = false; - source$.pipe(on(watch$)).subscribe({ complete: () => (complete = true) }); - - watch$.complete(); - - expect(complete).toBeTruthy(); - }); - - it("errors when its source errors", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const expected = { some: "error" }; - let error = null; - source$.pipe(on(watch$)).subscribe({ error: (e: unknown) => (error = e) }); - - source$.error(expected); - - expect(error).toEqual(expected); - }); - - it("errors when its watch errors", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const expected = { some: "error" }; - let error = null; - source$.pipe(on(watch$)).subscribe({ error: (e: unknown) => (error = e) }); - - watch$.error(expected); - - expect(error).toEqual(expected); - }); -}); - -describe("pin", () => { - it("emits the first value", async () => { - const input = new Subject(); - const result: unknown[] = []; - - input.pipe(pin()).subscribe((v) => result.push(v)); - input.next(1); - - expect(result).toEqual([1]); - }); - - it("filters repeated emissions", async () => { - const input = new Subject(); - const result: unknown[] = []; - - input.pipe(pin({ distinct: (p, c) => p == c })).subscribe((v) => result.push(v)); - input.next(1); - input.next(1); - - expect(result).toEqual([1]); - }); - - it("errors if multiple emissions occur", async () => { - const input = new Subject(); - let error: any = null!; - - input.pipe(pin()).subscribe({ - error: (e: unknown) => { - error = e; - }, + expect(result).toEqual(6); }); - input.next(1); - input.next(1); - - expect(error).toBeInstanceOf(Error); - expect(error.message).toMatch(/^unknown/); }); - it("names the pinned observables if multiple emissions occur", async () => { - const input = new Subject(); - let error: any = null!; + describe("distinctIfShallowMatch", () => { + it("emits a single value", async () => { + const source$ = of({ foo: true }); + const pipe$ = source$.pipe(distinctIfShallowMatch()); - input.pipe(pin({ name: () => "example" })).subscribe({ - error: (e: unknown) => { - error = e; - }, + const result = trackEmissions(pipe$); + await awaitAsync(); + + expect(result).toEqual([{ foo: true }]); }); - input.next(1); - input.next(1); - expect(error).toBeInstanceOf(Error); - expect(error.message).toMatch(/^example/); + it("emits different values", async () => { + const source$ = of({ foo: true }, { foo: false }); + const pipe$ = source$.pipe(distinctIfShallowMatch()); + + const result = trackEmissions(pipe$); + await awaitAsync(); + + expect(result).toEqual([{ foo: true }, { foo: false }]); + }); + + it("emits new keys", async () => { + const source$ = of({ foo: true }, { foo: true, bar: true }); + const pipe$ = source$.pipe(distinctIfShallowMatch()); + + const result = trackEmissions(pipe$); + await awaitAsync(); + + expect(result).toEqual([{ foo: true }, { foo: true, bar: true }]); + }); + + it("suppresses identical values", async () => { + const source$ = of({ foo: true }, { foo: true }); + const pipe$ = source$.pipe(distinctIfShallowMatch()); + + const result = trackEmissions(pipe$); + await awaitAsync(); + + expect(result).toEqual([{ foo: true }]); + }); + + it("suppresses removed keys", async () => { + const source$ = of({ foo: true, bar: true }, { foo: true }); + const pipe$ = source$.pipe(distinctIfShallowMatch()); + + const result = trackEmissions(pipe$); + await awaitAsync(); + + expect(result).toEqual([{ foo: true, bar: true }]); + }); }); - it("errors if indistinct emissions occur", async () => { - const input = new Subject(); - let error: any = null!; + describe("anyComplete", () => { + it("emits true when its input completes", () => { + const input$ = new Subject(); - input - .pipe(pin({ distinct: (p, c) => p == c })) - .subscribe({ error: (e: unknown) => (error = e) }); - input.next(1); - input.next(2); + const emissions: boolean[] = []; + anyComplete(input$).subscribe((e) => emissions.push(e)); + input$.complete(); - expect(error).toBeInstanceOf(Error); - expect(error.message).toMatch(/^unknown/); + expect(emissions).toEqual([true]); + }); + + it("completes when its input is already complete", () => { + const input = new Subject(); + input.complete(); + + let completed = false; + anyComplete(input).subscribe({ complete: () => (completed = true) }); + + expect(completed).toBe(true); + }); + + it("completes when any input completes", () => { + const input$ = new Subject(); + const completing$ = new Subject(); + + let completed = false; + anyComplete([input$, completing$]).subscribe({ complete: () => (completed = true) }); + completing$.complete(); + + expect(completed).toBe(true); + }); + + it("ignores emissions", () => { + const input$ = new Subject(); + + const emissions: boolean[] = []; + anyComplete(input$).subscribe((e) => emissions.push(e)); + input$.next(1); + input$.next(2); + input$.complete(); + + expect(emissions).toEqual([true]); + }); + + it("forwards errors", () => { + const input$ = new Subject(); + const expected = { some: "error" }; + + let error = null; + anyComplete(input$).subscribe({ error: (e: unknown) => (error = e) }); + input$.error(expected); + + expect(error).toEqual(expected); + }); + }); + + describe("ready", () => { + it("connects when subscribed", () => { + const watch$ = new Subject(); + let connected = false; + const source$ = new Subject().pipe(tap({ subscribe: () => (connected = true) })); + + // precondition: ready$ should be cold + const ready$ = source$.pipe(ready(watch$)); + expect(connected).toBe(false); + + ready$.subscribe(); + + expect(connected).toBe(true); + }); + + it("suppresses source emissions until its watch emits", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(ready(watch$)); + const results: number[] = []; + ready$.subscribe((n) => results.push(n)); + + // precondition: no emissions + source$.next(1); + expect(results).toEqual([]); + + watch$.next(); + + expect(results).toEqual([1]); + }); + + it("suppresses source emissions until all watches emit", () => { + const watchA$ = new Subject(); + const watchB$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(ready([watchA$, watchB$])); + const results: number[] = []; + ready$.subscribe((n) => results.push(n)); + + // preconditions: no emissions + source$.next(1); + expect(results).toEqual([]); + watchA$.next(); + expect(results).toEqual([]); + + watchB$.next(); + + expect(results).toEqual([1]); + }); + + it("emits the last source emission when its watch emits", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(ready(watch$)); + const results: number[] = []; + ready$.subscribe((n) => results.push(n)); + + // precondition: no emissions + source$.next(1); + expect(results).toEqual([]); + + source$.next(2); + watch$.next(); + + expect(results).toEqual([2]); + }); + + it("emits all source emissions after its watch emits", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(ready(watch$)); + const results: number[] = []; + ready$.subscribe((n) => results.push(n)); + + watch$.next(); + source$.next(1); + source$.next(2); + + expect(results).toEqual([1, 2]); + }); + + it("ignores repeated watch emissions", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(ready(watch$)); + const results: number[] = []; + ready$.subscribe((n) => results.push(n)); + + watch$.next(); + source$.next(1); + watch$.next(); + source$.next(2); + watch$.next(); + + expect(results).toEqual([1, 2]); + }); + + it("completes when its source completes", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(ready(watch$)); + let completed = false; + ready$.subscribe({ complete: () => (completed = true) }); + + source$.complete(); + + expect(completed).toBeTruthy(); + }); + + it("errors when its source errors", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(ready(watch$)); + const expected = { some: "error" }; + let error = null; + ready$.subscribe({ error: (e: unknown) => (error = e) }); + + source$.error(expected); + + expect(error).toEqual(expected); + }); + + it("errors when its watch errors", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(ready(watch$)); + const expected = { some: "error" }; + let error = null; + ready$.subscribe({ error: (e: unknown) => (error = e) }); + + watch$.error(expected); + + expect(error).toEqual(expected); + }); + + it("errors when its watch completes before emitting", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(ready(watch$)); + let error = null; + ready$.subscribe({ error: (e: unknown) => (error = e) }); + + watch$.complete(); + + expect(error).toBeInstanceOf(EmptyError); + }); + }); + + describe("withLatestReady", () => { + it("connects when subscribed", () => { + const watch$ = new Subject(); + let connected = false; + const source$ = new Subject().pipe(tap({ subscribe: () => (connected = true) })); + + // precondition: ready$ should be cold + const ready$ = source$.pipe(withLatestReady(watch$)); + expect(connected).toBe(false); + + ready$.subscribe(); + + expect(connected).toBe(true); + }); + + it("suppresses source emissions until its watch emits", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(withLatestReady(watch$)); + const results: [number, string][] = []; + ready$.subscribe((n) => results.push(n)); + + // precondition: no emissions + source$.next(1); + expect(results).toEqual([]); + + watch$.next("watch"); + + expect(results).toEqual([[1, "watch"]]); + }); + + it("emits the last source emission when its watch emits", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(withLatestReady(watch$)); + const results: [number, string][] = []; + ready$.subscribe((n) => results.push(n)); + + // precondition: no emissions + source$.next(1); + expect(results).toEqual([]); + + source$.next(2); + watch$.next("watch"); + + expect(results).toEqual([[2, "watch"]]); + }); + + it("emits all source emissions after its watch emits", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(withLatestReady(watch$)); + const results: [number, string][] = []; + ready$.subscribe((n) => results.push(n)); + + watch$.next("watch"); + source$.next(1); + source$.next(2); + + expect(results).toEqual([ + [1, "watch"], + [2, "watch"], + ]); + }); + + it("appends the latest watch emission", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(withLatestReady(watch$)); + const results: [number, string][] = []; + ready$.subscribe((n) => results.push(n)); + + watch$.next("ignored"); + watch$.next("watch"); + source$.next(1); + watch$.next("ignored"); + watch$.next("watch"); + source$.next(2); + + expect(results).toEqual([ + [1, "watch"], + [2, "watch"], + ]); + }); + + it("completes when its source completes", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(withLatestReady(watch$)); + let completed = false; + ready$.subscribe({ complete: () => (completed = true) }); + + source$.complete(); + + expect(completed).toBeTruthy(); + }); + + it("errors when its source errors", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(withLatestReady(watch$)); + const expected = { some: "error" }; + let error = null; + ready$.subscribe({ error: (e: unknown) => (error = e) }); + + source$.error(expected); + + expect(error).toEqual(expected); + }); + + it("errors when its watch errors", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(withLatestReady(watch$)); + const expected = { some: "error" }; + let error = null; + ready$.subscribe({ error: (e: unknown) => (error = e) }); + + watch$.error(expected); + + expect(error).toEqual(expected); + }); + + it("errors when its watch completes before emitting", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(withLatestReady(watch$)); + let error = null; + ready$.subscribe({ error: (e: unknown) => (error = e) }); + + watch$.complete(); + + expect(error).toBeInstanceOf(EmptyError); + }); + }); + + describe("on", () => { + it("connects when subscribed", () => { + const watch$ = new Subject(); + let connected = false; + const source$ = new Subject().pipe(tap({ subscribe: () => (connected = true) })); + + // precondition: on$ should be cold + const on$ = source$.pipe(on(watch$)); + expect(connected).toBeFalsy(); + + on$.subscribe(); + + expect(connected).toBeTruthy(); + }); + + it("suppresses source emissions until `on` emits", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const results: number[] = []; + source$.pipe(on(watch$)).subscribe((n) => results.push(n)); + + // precondition: on$ should be cold + source$.next(1); + expect(results).toEqual([]); + + watch$.next(); + + expect(results).toEqual([1]); + }); + + it("repeats source emissions when `on` emits", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const results: number[] = []; + source$.pipe(on(watch$)).subscribe((n) => results.push(n)); + source$.next(1); + + watch$.next(); + watch$.next(); + + expect(results).toEqual([1, 1]); + }); + + it("updates source emissions when `on` emits", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const results: number[] = []; + source$.pipe(on(watch$)).subscribe((n) => results.push(n)); + + source$.next(1); + watch$.next(); + source$.next(2); + watch$.next(); + + expect(results).toEqual([1, 2]); + }); + + it("emits a value when `on` emits before the source is ready", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const results: number[] = []; + source$.pipe(on(watch$)).subscribe((n) => results.push(n)); + + watch$.next(); + source$.next(1); + + expect(results).toEqual([1]); + }); + + it("ignores repeated `on` emissions before the source is ready", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const results: number[] = []; + source$.pipe(on(watch$)).subscribe((n) => results.push(n)); + + watch$.next(); + watch$.next(); + source$.next(1); + + expect(results).toEqual([1]); + }); + + it("emits only the latest source emission when `on` emits", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const results: number[] = []; + source$.pipe(on(watch$)).subscribe((n) => results.push(n)); + source$.next(1); + + watch$.next(); + + source$.next(2); + source$.next(3); + watch$.next(); + + expect(results).toEqual([1, 3]); + }); + + it("completes when its source completes", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + let complete: boolean = false; + source$.pipe(on(watch$)).subscribe({ complete: () => (complete = true) }); + + source$.complete(); + + expect(complete).toBeTruthy(); + }); + + it("completes when its watch completes", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + let complete: boolean = false; + source$.pipe(on(watch$)).subscribe({ complete: () => (complete = true) }); + + watch$.complete(); + + expect(complete).toBeTruthy(); + }); + + it("errors when its source errors", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const expected = { some: "error" }; + let error = null; + source$.pipe(on(watch$)).subscribe({ error: (e: unknown) => (error = e) }); + + source$.error(expected); + + expect(error).toEqual(expected); + }); + + it("errors when its watch errors", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const expected = { some: "error" }; + let error = null; + source$.pipe(on(watch$)).subscribe({ error: (e: unknown) => (error = e) }); + + watch$.error(expected); + + expect(error).toEqual(expected); + }); + }); + + describe("pin", () => { + it("emits the first value", async () => { + const input = new Subject(); + const result: unknown[] = []; + + input.pipe(pin()).subscribe((v) => result.push(v)); + input.next(1); + + expect(result).toEqual([1]); + }); + + it("filters repeated emissions", async () => { + const input = new Subject(); + const result: unknown[] = []; + + input.pipe(pin({ distinct: (p, c) => p == c })).subscribe((v) => result.push(v)); + input.next(1); + input.next(1); + + expect(result).toEqual([1]); + }); + + it("errors if multiple emissions occur", async () => { + const input = new Subject(); + let error: any = null!; + + input.pipe(pin()).subscribe({ + error: (e: unknown) => { + error = e; + }, + }); + input.next(1); + input.next(1); + + expect(error).toBeInstanceOf(Error); + expect(error.message).toMatch(/^unknown/); + }); + + it("names the pinned observables if multiple emissions occur", async () => { + const input = new Subject(); + let error: any = null!; + + input.pipe(pin({ name: () => "example" })).subscribe({ + error: (e: unknown) => { + error = e; + }, + }); + input.next(1); + input.next(1); + + expect(error).toBeInstanceOf(Error); + expect(error.message).toMatch(/^example/); + }); + + it("errors if indistinct emissions occur", async () => { + const input = new Subject(); + let error: any = null!; + + input + .pipe(pin({ distinct: (p, c) => p == c })) + .subscribe({ error: (e: unknown) => (error = e) }); + input.next(1); + input.next(2); + + expect(error).toBeInstanceOf(Error); + expect(error.message).toMatch(/^unknown/); + }); + }); + + describe("memoizedMap", () => { + it("maps a value", () => { + const source$ = new Subject(); + const result$ = new BehaviorSubject({}); + const expectedResult = {}; + source$.pipe(memoizedMap(() => expectedResult)).subscribe(result$); + + source$.next("foo"); + + expect(result$.value).toEqual(expectedResult); + }); + + it("caches a mapped result", () => { + const source$ = new Subject(); + const result$ = new BehaviorSubject({}); + const map = jest.fn(() => ({})); + source$.pipe(memoizedMap(map)).subscribe(result$); + + source$.next("foo"); + source$.next("foo"); + + expect(map).toHaveBeenCalledTimes(1); + }); + + it("caches the last mapped result", () => { + const source$ = new Subject(); + const result$ = new BehaviorSubject({}); + const map = jest.fn(() => ({})); + source$.pipe(memoizedMap(map)).subscribe(result$); + + source$.next("foo"); + source$.next("foo"); + source$.next("bar"); + source$.next("foo"); + + expect(map).toHaveBeenCalledTimes(3); + }); + + it("caches multiple mapped results", () => { + const source$ = new Subject(); + const result$ = new BehaviorSubject({}); + const map = jest.fn(() => ({})); + source$.pipe(memoizedMap(map, { size: 2 })).subscribe(result$); + + source$.next("foo"); + source$.next("bar"); + source$.next("foo"); + source$.next("bar"); + + expect(map).toHaveBeenCalledTimes(2); + }); + + it("caches a result by key", () => { + const source$ = new Subject<{ key: string }>(); + const result$ = new BehaviorSubject({}); + const map = jest.fn(() => ({})); + source$.pipe(memoizedMap(map, { key: (s) => s.key })).subscribe(result$); + + // the messages are not equal; the keys are + source$.next({ key: "foo" }); + source$.next({ key: "foo" }); + source$.next({ key: "bar" }); + source$.next({ key: "bar" }); + + expect(map).toHaveBeenCalledTimes(2); + }); + }); + + it("errors", () => { + const source$ = new Subject(); + let error: unknown = null; + source$.pipe(memoizedMap(() => {})).subscribe({ error: (e: unknown) => (error = e) }); + const expectedError = {}; + + source$.error(expectedError); + + expect(error).toEqual(expectedError); + }); + + it("completes", () => { + const source$ = new Subject(); + let completed = false; + source$.pipe(memoizedMap(() => {})).subscribe({ complete: () => (completed = true) }); + + source$.complete(); + + expect(completed).toEqual(true); }); }); diff --git a/libs/common/src/tools/rx.ts b/libs/common/src/tools/rx.ts index 8a801cb9798..5fb4bba1e05 100644 --- a/libs/common/src/tools/rx.ts +++ b/libs/common/src/tools/rx.ts @@ -21,6 +21,8 @@ import { pairwise, MonoTypeOperatorFunction, Cons, + scan, + filter, } from "rxjs"; import { ObservableTuple } from "./rx.rxjs"; @@ -245,3 +247,52 @@ export function pin(options?: { }), ); } + +/** maps a value to a source and keeps a LRC cache of the results + * @param mapResult - maps the stream to a result; this function must return + * a value. It must not return null or undefined. + * @param options.size - the number of entries in the cache + * @param options.key - maps the source to a cache key + * @remarks - LRC is least recently created + */ +export function memoizedMap>( + mapResult: (source: Source) => Result, + options?: { size?: number; key?: (source: Source) => unknown }, +): OperatorFunction { + return pipe( + // scan's accumulator contains the cache + scan( + ([cache], source) => { + const key: unknown = options?.key?.(source) ?? source; + + // cache hit? + let result = cache?.get(key); + if (result) { + return [cache, result] as const; + } + + // cache miss + result = mapResult(source); + cache?.set(key, result); + + // trim cache + const overage = cache.size - (options?.size ?? 1); + if (overage > 0) { + Array.from(cache?.keys() ?? []) + .slice(0, overage) + .forEach((k) => cache?.delete(k)); + } + + return [cache, result] as const; + }, + // FIXME: upgrade to a least-recently-used cache + [new Map(), null] as [Map, Source | null], + ), + + // encapsulate cache + map(([, result]) => result), + + // preserve `NonNullable` constraint on `Result` + filter((result): result is Result => !!result), + ); +} diff --git a/libs/tools/generator/core/src/services/default-credential-generator.service.spec.ts b/libs/tools/generator/core/src/services/default-credential-generator.service.spec.ts index d870f4ead41..81e7ae6ac63 100644 --- a/libs/tools/generator/core/src/services/default-credential-generator.service.spec.ts +++ b/libs/tools/generator/core/src/services/default-credential-generator.service.spec.ts @@ -1,11 +1,14 @@ -import { BehaviorSubject, firstValueFrom, of } from "rxjs"; +import { BehaviorSubject, Subject, firstValueFrom, of } from "rxjs"; import { Account } from "@bitwarden/common/auth/abstractions/account.service"; +import { ConsoleLogService } from "@bitwarden/common/platform/services/console-log.service"; import { Site, VendorId } from "@bitwarden/common/tools/extension"; import { Bitwarden } from "@bitwarden/common/tools/extension/vendor/bitwarden"; import { Vendor } from "@bitwarden/common/tools/extension/vendor/data"; +import { SemanticLogger, ifEnabledSemanticLoggerProvider } from "@bitwarden/common/tools/log"; import { UserId } from "@bitwarden/common/types/guid"; +import { awaitAsync } from "../../../../../common/spec"; import { Algorithm, CredentialAlgorithm, @@ -13,8 +16,10 @@ import { ForwarderExtensionId, GeneratorMetadata, Profile, + Type, } from "../metadata"; import { CredentialGeneratorProviders } from "../providers"; +import { GenerateRequest, GeneratedCredential } from "../types"; import { DefaultCredentialGeneratorService } from "./default-credential-generator.service"; @@ -36,19 +41,15 @@ describe("DefaultCredentialGeneratorService", () => { let service: DefaultCredentialGeneratorService; let providers: MockTwoLevelPartial; let system: any; - let mockLogger: any; + let log: SemanticLogger; let mockExtension: { settings: jest.Mock }; let account: Account; let createService: (overrides?: any) => DefaultCredentialGeneratorService; beforeEach(() => { - mockLogger = { - info: jest.fn(), - debug: jest.fn(), - panic: jest.fn().mockImplementationOnce((c, m) => { - throw new Error(m ?? c); - }), - }; + log = ifEnabledSemanticLoggerProvider(false, new ConsoleLogService(true), { + from: "DefaultCredentialGeneratorService tests", + }); mockExtension = { settings: jest.fn() }; @@ -61,7 +62,7 @@ describe("DefaultCredentialGeneratorService", () => { }; system = { - log: jest.fn().mockReturnValue(mockLogger), + log: jest.fn().mockReturnValue(log), extension: mockExtension, }; @@ -96,33 +97,46 @@ describe("DefaultCredentialGeneratorService", () => { describe("generate$", () => { it("should generate credentials when provided a specific algorithm", async () => { - const mockEngine = { generate: jest.fn().mockReturnValue(of("generatedPassword")) }; + const mockEngine = { + generate: jest + .fn() + .mockReturnValue( + of( + new GeneratedCredential("generatedPassword", Type.password, Date.now(), "unit test"), + ), + ), + }; const mockMetadata = { - id: "testAlgorithm", + id: Algorithm.password, engine: { create: jest.fn().mockReturnValue(mockEngine) }, } as unknown as GeneratorMetadata; const mockSettings = new BehaviorSubject({ length: 12 }); - providers.metadata!.metadata = jest.fn().mockReturnValue(mockMetadata); service = createService({ settings: () => mockSettings as any, }); + const on$ = new Subject(); + const account$ = new BehaviorSubject(account); + const result$ = new BehaviorSubject(null); - const dependencies = { - on$: of({ algorithm: "testAlgorithm" as CredentialAlgorithm }), - account$: of(account), - }; + service.generate$({ on$, account$ }).subscribe(result$); + on$.next({ algorithm: Algorithm.password }); + await awaitAsync(); - const result = await firstValueFrom(service.generate$(dependencies)); - - expect(result).toBe("generatedPassword"); - expect(providers.metadata!.metadata).toHaveBeenCalledWith("testAlgorithm"); + expect(result$.value?.credential).toEqual("generatedPassword"); + expect(providers.metadata!.metadata).toHaveBeenCalledWith(Algorithm.password); expect(mockMetadata.engine.create).toHaveBeenCalled(); expect(mockEngine.generate).toHaveBeenCalled(); }); it("should determine preferred algorithm from credential type and generate credentials", async () => { - const mockEngine = { generate: jest.fn().mockReturnValue(of("generatedPassword")) }; + const mockEngine = { + generate: jest + .fn() + .mockReturnValue( + of(new GeneratedCredential("generatedPassword", "password", Date.now(), "unit test")), + ), + }; const mockMetadata = { id: "testAlgorithm", engine: { create: jest.fn().mockReturnValue(mockEngine) }, @@ -137,14 +151,16 @@ describe("DefaultCredentialGeneratorService", () => { settings: () => mockSettings as any, }); - const dependencies = { - on$: of({ type: "password" as CredentialType }), - account$: of(account), - }; + const on$ = new Subject(); + const account$ = new BehaviorSubject(account); + const result$ = new BehaviorSubject(null); - const result = await firstValueFrom(service.generate$(dependencies)); + service.generate$({ on$, account$ }).subscribe(result$); + on$.next({ type: Type.password }); + await awaitAsync(); - expect(result).toBe("generatedPassword"); + expect(result$.value?.credential).toBe("generatedPassword"); + expect(result$.value?.category).toBe(Type.password); expect(providers.metadata!.metadata).toHaveBeenCalledWith("testAlgorithm"); }); }); @@ -219,10 +235,6 @@ describe("DefaultCredentialGeneratorService", () => { expect(() => service.algorithm("invalidAlgo" as CredentialAlgorithm)).toThrow( "invalid credential algorithm", ); - expect(mockLogger.panic).toHaveBeenCalledWith( - { algorithm: "invalidAlgo" }, - "invalid credential algorithm", - ); }); }); @@ -248,10 +260,6 @@ describe("DefaultCredentialGeneratorService", () => { providers.metadata!.metadata = jest.fn().mockReturnValue(null); expect(() => service.forwarder(invalidVendorId)).toThrow("invalid vendor"); - expect(mockLogger.panic).toHaveBeenCalledWith( - { algorithm: invalidVendorId }, - "invalid vendor", - ); }); }); @@ -314,10 +322,6 @@ describe("DefaultCredentialGeneratorService", () => { expect(() => service.settings(mockMetadata, { account$: of(account) })).toThrow( "failed to load settings; profile metadata not found", ); - expect(mockLogger.panic).toHaveBeenCalledWith( - { algorithm: "test", profile: "account" }, - "failed to load settings; profile metadata not found", - ); }); }); @@ -347,10 +351,6 @@ describe("DefaultCredentialGeneratorService", () => { expect(() => service.policy$(mockMetadata, { account$: of(account) })).toThrow( "failed to load policy; profile metadata not found", ); - expect(mockLogger.panic).toHaveBeenCalledWith( - { algorithm: "test", profile: "account" }, - "failed to load policy; profile metadata not found", - ); }); }); }); diff --git a/libs/tools/generator/core/src/services/default-credential-generator.service.ts b/libs/tools/generator/core/src/services/default-credential-generator.service.ts index f97225590e3..56c9b63c3fc 100644 --- a/libs/tools/generator/core/src/services/default-credential-generator.service.ts +++ b/libs/tools/generator/core/src/services/default-credential-generator.service.ts @@ -1,15 +1,18 @@ import { - EMPTY, - combineLatest, + ReplaySubject, concatMap, - distinctUntilChanged, filter, + first, map, of, + share, shareReplay, + switchAll, switchMap, takeUntil, tap, + timer, + zip, } from "rxjs"; import { Account } from "@bitwarden/common/auth/abstractions/account.service"; @@ -17,7 +20,7 @@ import { BoundDependency, OnDependency } from "@bitwarden/common/tools/dependenc import { VendorId } from "@bitwarden/common/tools/extension"; import { SemanticLogger } from "@bitwarden/common/tools/log"; import { SystemServiceProvider } from "@bitwarden/common/tools/providers"; -import { anyComplete, withLatestReady } from "@bitwarden/common/tools/rx"; +import { anyComplete, memoizedMap } from "@bitwarden/common/tools/rx"; import { UserStateSubject } from "@bitwarden/common/tools/state/user-state-subject"; import { CredentialGeneratorService } from "../abstractions"; @@ -34,6 +37,9 @@ import { CredentialGeneratorProviders } from "../providers"; import { GenerateRequest } from "../types"; import { isAlgorithmRequest, isTypeRequest } from "../types/metadata-request"; +const ALGORITHM_CACHE_SIZE = 10; +const THREE_MINUTES = 3 * 60 * 1000; + export class DefaultCredentialGeneratorService implements CredentialGeneratorService { /** Instantiate the `DefaultCredentialGeneratorService`. * @param provide application services required by the credential generator. @@ -49,50 +55,73 @@ export class DefaultCredentialGeneratorService implements CredentialGeneratorSer private readonly log: SemanticLogger; generate$(dependencies: OnDependency & BoundDependency<"account", Account>) { - // `on$` is partitioned into several streams so that the generator - // engine and settings refresh only when their respective inputs change - const on$ = dependencies.on$.pipe(shareReplay({ refCount: true, bufferSize: 1 })); + const request$ = dependencies.on$.pipe(shareReplay({ refCount: true, bufferSize: 1 })); const account$ = dependencies.account$.pipe(shareReplay({ refCount: true, bufferSize: 1 })); // load algorithm metadata - const metadata$ = on$.pipe( - switchMap((requested) => { - if (isAlgorithmRequest(requested)) { - return of(requested.algorithm); - } else if (isTypeRequest(requested)) { - return this.provide.metadata.preference$(requested.type, { account$ }); + const metadata$ = request$.pipe( + switchMap((request) => { + if (isAlgorithmRequest(request)) { + return of(request.algorithm); + } else if (isTypeRequest(request)) { + return this.provide.metadata.preference$(request.type, { account$ }).pipe(first()); } else { - this.log.warn(requested, "algorithm or category required"); - return EMPTY; + this.log.panic(request, "algorithm or category required"); } }), filter((algorithm): algorithm is CredentialAlgorithm => !!algorithm), - distinctUntilChanged(), - map((algorithm) => this.provide.metadata.metadata(algorithm)), + memoizedMap((algorithm) => this.provide.metadata.metadata(algorithm), { + size: ALGORITHM_CACHE_SIZE, + }), shareReplay({ refCount: true, bufferSize: 1 }), ); // load the active profile's settings - const profile$ = on$.pipe( - map((request) => request.profile ?? Profile.account), - distinctUntilChanged(), - ); - const settings$ = combineLatest([metadata$, profile$]).pipe( - tap(([metadata, profile]) => - this.log.debug({ algorithm: metadata.id, profile }, "settings loaded"), + const settings$ = zip(request$, metadata$).pipe( + memoizedMap( + ([request, metadata]) => { + const profile = request.profile ?? Profile.account; + const algorithm = metadata.id; + + // settings stays hot and buffers the most recent value in the cache + // for the next `request` + const settings$ = this.settings(metadata, { account$ }, profile).pipe( + tap(() => this.log.debug({ algorithm, profile }, "settings update received")), + share({ + connector: () => new ReplaySubject(1, THREE_MINUTES), + resetOnRefCountZero: () => timer(THREE_MINUTES), + }), + tap({ + subscribe: () => this.log.debug({ algorithm, profile }, "settings hot"), + complete: () => this.log.debug({ algorithm, profile }, "settings cold"), + }), + first(), + ); + + this.log.debug({ algorithm, profile }, "settings cached"); + return settings$; + }, + { key: ([, metadata]) => metadata.id }, ), - switchMap(([metadata, profile]) => this.settings(metadata, { account$ }, profile)), + switchAll(), ); // load the algorithm's engine const engine$ = metadata$.pipe( - tap((metadata) => this.log.debug({ algorithm: metadata.id }, "engine selected")), - map((meta) => meta.engine.create(this.provide.generator)), + memoizedMap( + (metadata) => { + const engine = metadata.engine.create(this.provide.generator); + + this.log.debug({ algorithm: metadata.id }, "engine cached"); + return engine; + }, + { size: ALGORITHM_CACHE_SIZE }, + ), ); // generation proper - const generate$ = on$.pipe( - withLatestReady(settings$, engine$), + const generate$ = zip([request$, settings$, engine$]).pipe( + tap(([request]) => this.log.debug(request, "generating credential")), concatMap(([request, settings, engine]) => engine.generate(request, settings)), takeUntil(anyComplete([settings$])), );