mirror of
https://github.com/bitwarden/browser
synced 2025-12-06 00:13:28 +00:00
refactor: introduce @bitwarden/state and other common libs (#15772)
* refactor: introduce @bitwarden/serialization * refactor: introduce @bitwarden/guid * refactor: introduce @bitwaren/client-type * refactor: introduce @bitwarden/core-test-utils * refactor: introduce @bitwarden/state and @bitwarden/state-test-utils Creates initial project structure for centralized application state management. Part of modularization effort to extract state code from common. * Added state provider documentation to README. * Changed callouts to Github format. * Fixed linting on file name. * Forced git to accept rename --------- Co-authored-by: Todd Martin <tmartin@bitwarden.com>
This commit is contained in:
@@ -1,342 +1,9 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { BehaviorSubject, map, Observable, of, switchMap, take } from "rxjs";
|
||||
|
||||
import {
|
||||
GlobalState,
|
||||
GlobalStateProvider,
|
||||
KeyDefinition,
|
||||
ActiveUserState,
|
||||
SingleUserState,
|
||||
SingleUserStateProvider,
|
||||
StateProvider,
|
||||
ActiveUserStateProvider,
|
||||
DerivedState,
|
||||
DeriveDefinition,
|
||||
DerivedStateProvider,
|
||||
UserKeyDefinition,
|
||||
ActiveUserAccessor,
|
||||
} from "../src/platform/state";
|
||||
import { UserId } from "../src/types/guid";
|
||||
import { DerivedStateDependencies } from "../src/types/state";
|
||||
|
||||
import {
|
||||
FakeActiveUserState,
|
||||
FakeDerivedState,
|
||||
FakeGlobalState,
|
||||
FakeSingleUserState,
|
||||
} from "./fake-state";
|
||||
|
||||
export interface MinimalAccountService {
|
||||
activeUserId: UserId | null;
|
||||
activeAccount$: Observable<{ id: UserId } | null>;
|
||||
}
|
||||
|
||||
export class FakeActiveUserAccessor implements MinimalAccountService, ActiveUserAccessor {
|
||||
private _subject: BehaviorSubject<UserId | null>;
|
||||
|
||||
constructor(startingUser: UserId | null) {
|
||||
this._subject = new BehaviorSubject(startingUser);
|
||||
this.activeAccount$ = this._subject
|
||||
.asObservable()
|
||||
.pipe(map((id) => (id != null ? { id } : null)));
|
||||
this.activeUserId$ = this._subject.asObservable();
|
||||
}
|
||||
|
||||
get activeUserId(): UserId {
|
||||
return this._subject.value;
|
||||
}
|
||||
|
||||
activeUserId$: Observable<UserId>;
|
||||
|
||||
activeAccount$: Observable<{ id: UserId }>;
|
||||
|
||||
switch(user: UserId | null) {
|
||||
this._subject.next(user);
|
||||
}
|
||||
}
|
||||
|
||||
export class FakeGlobalStateProvider implements GlobalStateProvider {
|
||||
mock = mock<GlobalStateProvider>();
|
||||
establishedMocks: Map<string, FakeGlobalState<unknown>> = new Map();
|
||||
states: Map<string, GlobalState<unknown>> = new Map();
|
||||
get<T>(keyDefinition: KeyDefinition<T>): GlobalState<T> {
|
||||
this.mock.get(keyDefinition);
|
||||
const cacheKey = this.cacheKey(keyDefinition);
|
||||
let result = this.states.get(cacheKey);
|
||||
|
||||
if (result == null) {
|
||||
let fake: FakeGlobalState<T>;
|
||||
// Look for established mock
|
||||
if (this.establishedMocks.has(keyDefinition.key)) {
|
||||
fake = this.establishedMocks.get(keyDefinition.key) as FakeGlobalState<T>;
|
||||
} else {
|
||||
fake = new FakeGlobalState<T>();
|
||||
}
|
||||
fake.keyDefinition = keyDefinition;
|
||||
result = fake;
|
||||
this.states.set(cacheKey, result);
|
||||
|
||||
result = new FakeGlobalState<T>();
|
||||
this.states.set(cacheKey, result);
|
||||
}
|
||||
return result as GlobalState<T>;
|
||||
}
|
||||
|
||||
private cacheKey(keyDefinition: KeyDefinition<unknown>) {
|
||||
return `${keyDefinition.fullName}_${keyDefinition.stateDefinition.defaultStorageLocation}`;
|
||||
}
|
||||
|
||||
getFake<T>(keyDefinition: KeyDefinition<T>): FakeGlobalState<T> {
|
||||
return this.get(keyDefinition) as FakeGlobalState<T>;
|
||||
}
|
||||
|
||||
mockFor<T>(keyDefinition: KeyDefinition<T>, initialValue?: T): FakeGlobalState<T> {
|
||||
const cacheKey = this.cacheKey(keyDefinition);
|
||||
if (!this.states.has(cacheKey)) {
|
||||
this.states.set(cacheKey, new FakeGlobalState<T>(initialValue));
|
||||
}
|
||||
return this.states.get(cacheKey) as FakeGlobalState<T>;
|
||||
}
|
||||
}
|
||||
|
||||
export class FakeSingleUserStateProvider implements SingleUserStateProvider {
|
||||
mock = mock<SingleUserStateProvider>();
|
||||
states: Map<string, SingleUserState<unknown>> = new Map();
|
||||
|
||||
constructor(
|
||||
readonly updateSyncCallback?: (
|
||||
key: UserKeyDefinition<unknown>,
|
||||
userId: UserId,
|
||||
newValue: unknown,
|
||||
) => Promise<void>,
|
||||
) {}
|
||||
|
||||
get<T>(userId: UserId, userKeyDefinition: UserKeyDefinition<T>): SingleUserState<T> {
|
||||
this.mock.get(userId, userKeyDefinition);
|
||||
const cacheKey = this.cacheKey(userId, userKeyDefinition);
|
||||
let result = this.states.get(cacheKey);
|
||||
|
||||
if (result == null) {
|
||||
result = this.buildFakeState(userId, userKeyDefinition);
|
||||
this.states.set(cacheKey, result);
|
||||
}
|
||||
return result as SingleUserState<T>;
|
||||
}
|
||||
|
||||
getFake<T>(
|
||||
userId: UserId,
|
||||
userKeyDefinition: UserKeyDefinition<T>,
|
||||
{ allowInit }: { allowInit: boolean } = { allowInit: true },
|
||||
): FakeSingleUserState<T> {
|
||||
if (!allowInit && this.states.get(this.cacheKey(userId, userKeyDefinition)) == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.get(userId, userKeyDefinition) as FakeSingleUserState<T>;
|
||||
}
|
||||
|
||||
mockFor<T>(
|
||||
userId: UserId,
|
||||
userKeyDefinition: UserKeyDefinition<T>,
|
||||
initialValue?: T,
|
||||
): FakeSingleUserState<T> {
|
||||
const cacheKey = this.cacheKey(userId, userKeyDefinition);
|
||||
if (!this.states.has(cacheKey)) {
|
||||
this.states.set(cacheKey, this.buildFakeState(userId, userKeyDefinition, initialValue));
|
||||
}
|
||||
return this.states.get(cacheKey) as FakeSingleUserState<T>;
|
||||
}
|
||||
|
||||
private buildFakeState<T>(
|
||||
userId: UserId,
|
||||
userKeyDefinition: UserKeyDefinition<T>,
|
||||
initialValue?: T,
|
||||
) {
|
||||
const state = new FakeSingleUserState(userId, initialValue, async (...args) => {
|
||||
await this.updateSyncCallback?.(userKeyDefinition, ...args);
|
||||
});
|
||||
state.keyDefinition = userKeyDefinition;
|
||||
return state;
|
||||
}
|
||||
|
||||
private cacheKey(userId: UserId, userKeyDefinition: UserKeyDefinition<unknown>) {
|
||||
return `${userKeyDefinitionCacheKey(userKeyDefinition)}_${userId}`;
|
||||
}
|
||||
}
|
||||
|
||||
export class FakeActiveUserStateProvider implements ActiveUserStateProvider {
|
||||
activeUserId$: Observable<UserId | null>;
|
||||
states: Map<string, FakeActiveUserState<unknown>> = new Map();
|
||||
|
||||
constructor(
|
||||
public accountServiceAccessor: MinimalAccountService,
|
||||
readonly updateSyncCallback?: (
|
||||
key: UserKeyDefinition<unknown>,
|
||||
userId: UserId,
|
||||
newValue: unknown,
|
||||
) => Promise<void>,
|
||||
) {
|
||||
this.activeUserId$ = accountServiceAccessor.activeAccount$.pipe(map((a) => a?.id));
|
||||
}
|
||||
|
||||
get<T>(userKeyDefinition: UserKeyDefinition<T>): ActiveUserState<T> {
|
||||
const cacheKey = userKeyDefinitionCacheKey(userKeyDefinition);
|
||||
let result = this.states.get(cacheKey);
|
||||
|
||||
if (result == null) {
|
||||
result = this.buildFakeState(userKeyDefinition);
|
||||
this.states.set(cacheKey, result);
|
||||
}
|
||||
return result as ActiveUserState<T>;
|
||||
}
|
||||
|
||||
getFake<T>(
|
||||
userKeyDefinition: UserKeyDefinition<T>,
|
||||
{ allowInit }: { allowInit: boolean } = { allowInit: true },
|
||||
): FakeActiveUserState<T> {
|
||||
if (!allowInit && this.states.get(userKeyDefinitionCacheKey(userKeyDefinition)) == null) {
|
||||
return null;
|
||||
}
|
||||
return this.get(userKeyDefinition) as FakeActiveUserState<T>;
|
||||
}
|
||||
|
||||
mockFor<T>(userKeyDefinition: UserKeyDefinition<T>, initialValue?: T): FakeActiveUserState<T> {
|
||||
const cacheKey = userKeyDefinitionCacheKey(userKeyDefinition);
|
||||
if (!this.states.has(cacheKey)) {
|
||||
this.states.set(cacheKey, this.buildFakeState(userKeyDefinition, initialValue));
|
||||
}
|
||||
return this.states.get(cacheKey) as FakeActiveUserState<T>;
|
||||
}
|
||||
|
||||
private buildFakeState<T>(userKeyDefinition: UserKeyDefinition<T>, initialValue?: T) {
|
||||
const state = new FakeActiveUserState<T>(
|
||||
this.accountServiceAccessor,
|
||||
initialValue,
|
||||
async (...args) => {
|
||||
await this.updateSyncCallback?.(userKeyDefinition, ...args);
|
||||
},
|
||||
);
|
||||
state.keyDefinition = userKeyDefinition;
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
function userKeyDefinitionCacheKey(userKeyDefinition: UserKeyDefinition<unknown>) {
|
||||
return `${userKeyDefinition.fullName}_${userKeyDefinition.stateDefinition.defaultStorageLocation}`;
|
||||
}
|
||||
|
||||
export class FakeStateProvider implements StateProvider {
|
||||
mock = mock<StateProvider>();
|
||||
getUserState$<T>(userKeyDefinition: UserKeyDefinition<T>, userId?: UserId): Observable<T> {
|
||||
this.mock.getUserState$(userKeyDefinition, userId);
|
||||
if (userId) {
|
||||
return this.getUser<T>(userId, userKeyDefinition).state$;
|
||||
}
|
||||
|
||||
return this.getActive(userKeyDefinition).state$;
|
||||
}
|
||||
|
||||
getUserStateOrDefault$<T>(
|
||||
userKeyDefinition: UserKeyDefinition<T>,
|
||||
config: { userId: UserId | undefined; defaultValue?: T },
|
||||
): Observable<T> {
|
||||
const { userId, defaultValue = null } = config;
|
||||
this.mock.getUserStateOrDefault$(userKeyDefinition, config);
|
||||
if (userId) {
|
||||
return this.getUser<T>(userId, userKeyDefinition).state$;
|
||||
}
|
||||
|
||||
return this.activeUserId$.pipe(
|
||||
take(1),
|
||||
switchMap((userId) =>
|
||||
userId != null ? this.getUser(userId, userKeyDefinition).state$ : of(defaultValue),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async setUserState<T>(
|
||||
userKeyDefinition: UserKeyDefinition<T>,
|
||||
value: T | null,
|
||||
userId?: UserId,
|
||||
): Promise<[UserId, T | null]> {
|
||||
await this.mock.setUserState(userKeyDefinition, value, userId);
|
||||
if (userId) {
|
||||
return [userId, await this.getUser(userId, userKeyDefinition).update(() => value)];
|
||||
} else {
|
||||
return await this.getActive(userKeyDefinition).update(() => value);
|
||||
}
|
||||
}
|
||||
|
||||
getActive<T>(userKeyDefinition: UserKeyDefinition<T>): ActiveUserState<T> {
|
||||
return this.activeUser.get(userKeyDefinition);
|
||||
}
|
||||
|
||||
getGlobal<T>(keyDefinition: KeyDefinition<T>): GlobalState<T> {
|
||||
return this.global.get(keyDefinition);
|
||||
}
|
||||
|
||||
getUser<T>(userId: UserId, userKeyDefinition: UserKeyDefinition<T>): SingleUserState<T> {
|
||||
return this.singleUser.get(userId, userKeyDefinition);
|
||||
}
|
||||
|
||||
getDerived<TFrom, TTo, TDeps extends DerivedStateDependencies>(
|
||||
parentState$: Observable<TFrom>,
|
||||
deriveDefinition: DeriveDefinition<unknown, TTo, TDeps>,
|
||||
dependencies: TDeps,
|
||||
): DerivedState<TTo> {
|
||||
return this.derived.get(parentState$, deriveDefinition, dependencies);
|
||||
}
|
||||
|
||||
constructor(private activeAccountAccessor: MinimalAccountService) {}
|
||||
|
||||
private distributeSingleUserUpdate(
|
||||
key: UserKeyDefinition<unknown>,
|
||||
userId: UserId,
|
||||
newState: unknown,
|
||||
) {
|
||||
if (this.activeUser.accountServiceAccessor.activeUserId === userId) {
|
||||
const state = this.activeUser.getFake(key, { allowInit: false });
|
||||
state?.nextState(newState, { syncValue: false });
|
||||
}
|
||||
}
|
||||
|
||||
private distributeActiveUserUpdate(
|
||||
key: UserKeyDefinition<unknown>,
|
||||
userId: UserId,
|
||||
newState: unknown,
|
||||
) {
|
||||
this.singleUser
|
||||
.getFake(userId, key, { allowInit: false })
|
||||
?.nextState(newState, { syncValue: false });
|
||||
}
|
||||
|
||||
global: FakeGlobalStateProvider = new FakeGlobalStateProvider();
|
||||
singleUser: FakeSingleUserStateProvider = new FakeSingleUserStateProvider(
|
||||
this.distributeSingleUserUpdate.bind(this),
|
||||
);
|
||||
activeUser: FakeActiveUserStateProvider = new FakeActiveUserStateProvider(
|
||||
this.activeAccountAccessor,
|
||||
this.distributeActiveUserUpdate.bind(this),
|
||||
);
|
||||
derived: FakeDerivedStateProvider = new FakeDerivedStateProvider();
|
||||
activeUserId$: Observable<UserId> = this.activeUser.activeUserId$;
|
||||
}
|
||||
|
||||
export class FakeDerivedStateProvider implements DerivedStateProvider {
|
||||
states: Map<string, DerivedState<unknown>> = new Map();
|
||||
get<TFrom, TTo, TDeps extends DerivedStateDependencies>(
|
||||
parentState$: Observable<TFrom>,
|
||||
deriveDefinition: DeriveDefinition<TFrom, TTo, TDeps>,
|
||||
dependencies: TDeps,
|
||||
): DerivedState<TTo> {
|
||||
let result = this.states.get(deriveDefinition.buildCacheKey()) as DerivedState<TTo>;
|
||||
|
||||
if (result == null) {
|
||||
result = new FakeDerivedState(parentState$, deriveDefinition, dependencies);
|
||||
this.states.set(deriveDefinition.buildCacheKey(), result);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
export {
|
||||
MinimalAccountService,
|
||||
FakeActiveUserAccessor,
|
||||
FakeGlobalStateProvider,
|
||||
FakeSingleUserStateProvider,
|
||||
FakeActiveUserStateProvider,
|
||||
FakeStateProvider,
|
||||
FakeDerivedStateProvider,
|
||||
} from "@bitwarden/state-test-utils";
|
||||
|
||||
@@ -1,275 +1,6 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Observable, ReplaySubject, concatMap, filter, firstValueFrom, map, timeout } from "rxjs";
|
||||
|
||||
import {
|
||||
DerivedState,
|
||||
GlobalState,
|
||||
SingleUserState,
|
||||
ActiveUserState,
|
||||
KeyDefinition,
|
||||
DeriveDefinition,
|
||||
UserKeyDefinition,
|
||||
} from "../src/platform/state";
|
||||
// eslint-disable-next-line import/no-restricted-paths -- using unexposed options for clean typing in test class
|
||||
import { StateUpdateOptions } from "../src/platform/state/state-update-options";
|
||||
// eslint-disable-next-line import/no-restricted-paths -- using unexposed options for clean typing in test class
|
||||
import { CombinedState, activeMarker } from "../src/platform/state/user-state";
|
||||
import { UserId } from "../src/types/guid";
|
||||
import { DerivedStateDependencies } from "../src/types/state";
|
||||
|
||||
import { MinimalAccountService } from "./fake-state-provider";
|
||||
|
||||
const DEFAULT_TEST_OPTIONS: StateUpdateOptions<any, any> = {
|
||||
shouldUpdate: () => true,
|
||||
combineLatestWith: null,
|
||||
msTimeout: 10,
|
||||
};
|
||||
|
||||
function populateOptionsWithDefault(
|
||||
options: StateUpdateOptions<any, any>,
|
||||
): StateUpdateOptions<any, any> {
|
||||
return {
|
||||
...DEFAULT_TEST_OPTIONS,
|
||||
...options,
|
||||
};
|
||||
}
|
||||
|
||||
export class FakeGlobalState<T> implements GlobalState<T> {
|
||||
// eslint-disable-next-line rxjs/no-exposed-subjects -- exposed for testing setup
|
||||
stateSubject = new ReplaySubject<T>(1);
|
||||
|
||||
constructor(initialValue?: T) {
|
||||
this.stateSubject.next(initialValue ?? null);
|
||||
}
|
||||
|
||||
nextState(state: T) {
|
||||
this.stateSubject.next(state);
|
||||
}
|
||||
|
||||
async update<TCombine>(
|
||||
configureState: (state: T, dependency: TCombine) => T,
|
||||
options?: StateUpdateOptions<T, TCombine>,
|
||||
): Promise<T> {
|
||||
options = populateOptionsWithDefault(options);
|
||||
if (this.stateSubject["_buffer"].length == 0) {
|
||||
// throw a more helpful not initialized error
|
||||
throw new Error(
|
||||
"You must initialize the state with a value before calling update. Try calling `stateSubject.next(initialState)` before calling update",
|
||||
);
|
||||
}
|
||||
const current = await firstValueFrom(this.state$.pipe(timeout(100)));
|
||||
const combinedDependencies =
|
||||
options.combineLatestWith != null
|
||||
? await firstValueFrom(options.combineLatestWith.pipe(timeout(options.msTimeout)))
|
||||
: null;
|
||||
if (!options.shouldUpdate(current, combinedDependencies)) {
|
||||
return current;
|
||||
}
|
||||
const newState = configureState(current, combinedDependencies);
|
||||
this.stateSubject.next(newState);
|
||||
this.nextMock(newState);
|
||||
return newState;
|
||||
}
|
||||
|
||||
/** Tracks update values resolved by `FakeState.update` */
|
||||
nextMock = jest.fn<void, [T]>();
|
||||
|
||||
get state$() {
|
||||
return this.stateSubject.asObservable();
|
||||
}
|
||||
|
||||
private _keyDefinition: KeyDefinition<T> | null = null;
|
||||
get keyDefinition() {
|
||||
if (this._keyDefinition == null) {
|
||||
throw new Error(
|
||||
"Key definition not yet set, usually this means your sut has not asked for this state yet",
|
||||
);
|
||||
}
|
||||
return this._keyDefinition;
|
||||
}
|
||||
set keyDefinition(value: KeyDefinition<T>) {
|
||||
this._keyDefinition = value;
|
||||
}
|
||||
}
|
||||
|
||||
export class FakeSingleUserState<T> implements SingleUserState<T> {
|
||||
// eslint-disable-next-line rxjs/no-exposed-subjects -- exposed for testing setup
|
||||
stateSubject = new ReplaySubject<{
|
||||
syncValue: boolean;
|
||||
combinedState: CombinedState<T>;
|
||||
}>(1);
|
||||
|
||||
state$: Observable<T>;
|
||||
combinedState$: Observable<CombinedState<T>>;
|
||||
|
||||
constructor(
|
||||
readonly userId: UserId,
|
||||
initialValue?: T,
|
||||
updateSyncCallback?: (userId: UserId, newValue: T) => Promise<void>,
|
||||
) {
|
||||
// Inform the state provider of updates to keep active user states in sync
|
||||
this.stateSubject
|
||||
.pipe(
|
||||
filter((next) => next.syncValue),
|
||||
concatMap(async ({ combinedState }) => {
|
||||
await updateSyncCallback?.(...combinedState);
|
||||
}),
|
||||
)
|
||||
.subscribe();
|
||||
this.nextState(initialValue ?? null, { syncValue: initialValue != null });
|
||||
|
||||
this.combinedState$ = this.stateSubject.pipe(map((v) => v.combinedState));
|
||||
this.state$ = this.combinedState$.pipe(map(([_userId, state]) => state));
|
||||
}
|
||||
|
||||
nextState(state: T | null, { syncValue }: { syncValue: boolean } = { syncValue: true }) {
|
||||
this.stateSubject.next({
|
||||
syncValue,
|
||||
combinedState: [this.userId, state],
|
||||
});
|
||||
}
|
||||
|
||||
async update<TCombine>(
|
||||
configureState: (state: T | null, dependency: TCombine) => T | null,
|
||||
options?: StateUpdateOptions<T, TCombine>,
|
||||
): Promise<T | null> {
|
||||
options = populateOptionsWithDefault(options);
|
||||
const current = await firstValueFrom(this.state$.pipe(timeout(options.msTimeout)));
|
||||
const combinedDependencies =
|
||||
options.combineLatestWith != null
|
||||
? await firstValueFrom(options.combineLatestWith.pipe(timeout(options.msTimeout)))
|
||||
: null;
|
||||
if (!options.shouldUpdate(current, combinedDependencies)) {
|
||||
return current;
|
||||
}
|
||||
const newState = configureState(current, combinedDependencies);
|
||||
this.nextState(newState);
|
||||
this.nextMock(newState);
|
||||
return newState;
|
||||
}
|
||||
|
||||
/** Tracks update values resolved by `FakeState.update` */
|
||||
nextMock = jest.fn<void, [T]>();
|
||||
private _keyDefinition: UserKeyDefinition<T> | null = null;
|
||||
get keyDefinition() {
|
||||
if (this._keyDefinition == null) {
|
||||
throw new Error(
|
||||
"Key definition not yet set, usually this means your sut has not asked for this state yet",
|
||||
);
|
||||
}
|
||||
return this._keyDefinition;
|
||||
}
|
||||
set keyDefinition(value: UserKeyDefinition<T>) {
|
||||
this._keyDefinition = value;
|
||||
}
|
||||
}
|
||||
export class FakeActiveUserState<T> implements ActiveUserState<T> {
|
||||
[activeMarker]: true;
|
||||
|
||||
// eslint-disable-next-line rxjs/no-exposed-subjects -- exposed for testing setup
|
||||
stateSubject = new ReplaySubject<{
|
||||
syncValue: boolean;
|
||||
combinedState: CombinedState<T>;
|
||||
}>(1);
|
||||
|
||||
state$: Observable<T>;
|
||||
combinedState$: Observable<CombinedState<T>>;
|
||||
|
||||
constructor(
|
||||
private activeAccountAccessor: MinimalAccountService,
|
||||
initialValue?: T,
|
||||
updateSyncCallback?: (userId: UserId, newValue: T) => Promise<void>,
|
||||
) {
|
||||
// Inform the state provider of updates to keep single user states in sync
|
||||
this.stateSubject.pipe(
|
||||
filter((next) => next.syncValue),
|
||||
concatMap(async ({ combinedState }) => {
|
||||
await updateSyncCallback?.(...combinedState);
|
||||
}),
|
||||
);
|
||||
this.nextState(initialValue ?? null, { syncValue: initialValue != null });
|
||||
|
||||
this.combinedState$ = this.stateSubject.pipe(map((v) => v.combinedState));
|
||||
this.state$ = this.combinedState$.pipe(map(([_userId, state]) => state));
|
||||
}
|
||||
|
||||
nextState(state: T | null, { syncValue }: { syncValue: boolean } = { syncValue: true }) {
|
||||
this.stateSubject.next({
|
||||
syncValue,
|
||||
combinedState: [this.activeAccountAccessor.activeUserId, state],
|
||||
});
|
||||
}
|
||||
|
||||
async update<TCombine>(
|
||||
configureState: (state: T | null, dependency: TCombine) => T | null,
|
||||
options?: StateUpdateOptions<T, TCombine>,
|
||||
): Promise<[UserId, T | null]> {
|
||||
options = populateOptionsWithDefault(options);
|
||||
const current = await firstValueFrom(this.state$.pipe(timeout(options.msTimeout)));
|
||||
const combinedDependencies =
|
||||
options.combineLatestWith != null
|
||||
? await firstValueFrom(options.combineLatestWith.pipe(timeout(options.msTimeout)))
|
||||
: null;
|
||||
if (!options.shouldUpdate(current, combinedDependencies)) {
|
||||
return [this.activeAccountAccessor.activeUserId, current];
|
||||
}
|
||||
const newState = configureState(current, combinedDependencies);
|
||||
this.nextState(newState);
|
||||
this.nextMock([this.activeAccountAccessor.activeUserId, newState]);
|
||||
return [this.activeAccountAccessor.activeUserId, newState];
|
||||
}
|
||||
|
||||
/** Tracks update values resolved by `FakeState.update` */
|
||||
nextMock = jest.fn<void, [[UserId, T]]>();
|
||||
|
||||
private _keyDefinition: UserKeyDefinition<T> | null = null;
|
||||
get keyDefinition() {
|
||||
if (this._keyDefinition == null) {
|
||||
throw new Error(
|
||||
"Key definition not yet set, usually this means your sut has not asked for this state yet",
|
||||
);
|
||||
}
|
||||
return this._keyDefinition;
|
||||
}
|
||||
set keyDefinition(value: UserKeyDefinition<T>) {
|
||||
this._keyDefinition = value;
|
||||
}
|
||||
}
|
||||
|
||||
export class FakeDerivedState<TFrom, TTo, TDeps extends DerivedStateDependencies>
|
||||
implements DerivedState<TTo>
|
||||
{
|
||||
// eslint-disable-next-line rxjs/no-exposed-subjects -- exposed for testing setup
|
||||
stateSubject = new ReplaySubject<TTo>(1);
|
||||
|
||||
constructor(
|
||||
parentState$: Observable<TFrom>,
|
||||
deriveDefinition: DeriveDefinition<TFrom, TTo, TDeps>,
|
||||
dependencies: TDeps,
|
||||
) {
|
||||
parentState$
|
||||
.pipe(
|
||||
concatMap(async (v) => {
|
||||
const newState = deriveDefinition.derive(v, dependencies);
|
||||
if (newState instanceof Promise) {
|
||||
return newState;
|
||||
}
|
||||
return Promise.resolve(newState);
|
||||
}),
|
||||
)
|
||||
.subscribe((newState) => {
|
||||
this.stateSubject.next(newState);
|
||||
});
|
||||
}
|
||||
|
||||
forceValue(value: TTo): Promise<TTo> {
|
||||
this.stateSubject.next(value);
|
||||
return Promise.resolve(value);
|
||||
}
|
||||
forceValueMock = this.forceValue as jest.MockedFunction<typeof this.forceValue>;
|
||||
|
||||
get state$() {
|
||||
return this.stateSubject.asObservable();
|
||||
}
|
||||
}
|
||||
export {
|
||||
FakeGlobalState,
|
||||
FakeSingleUserState,
|
||||
FakeActiveUserState,
|
||||
FakeDerivedState,
|
||||
} from "@bitwarden/state-test-utils";
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
|
||||
@@ -78,57 +77,4 @@ export const mockFromSdk = (stub: any) => {
|
||||
return `${stub}_fromSdk`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Tracks the emissions of the given observable.
|
||||
*
|
||||
* Call this function before you expect any emissions and then use code that will cause the observable to emit values,
|
||||
* then assert after all expected emissions have occurred.
|
||||
* @param observable
|
||||
* @returns An array that will be populated with all emissions of the observable.
|
||||
*/
|
||||
export function trackEmissions<T>(observable: Observable<T>): T[] {
|
||||
const emissions: T[] = [];
|
||||
observable.subscribe((value) => {
|
||||
switch (value) {
|
||||
case undefined:
|
||||
case null:
|
||||
emissions.push(value);
|
||||
return;
|
||||
default:
|
||||
// process by type
|
||||
break;
|
||||
}
|
||||
|
||||
switch (typeof value) {
|
||||
case "string":
|
||||
case "number":
|
||||
case "boolean":
|
||||
emissions.push(value);
|
||||
break;
|
||||
case "symbol":
|
||||
// Cheating types to make symbols work at all
|
||||
emissions.push(value.toString() as T);
|
||||
break;
|
||||
default: {
|
||||
emissions.push(clone(value));
|
||||
}
|
||||
}
|
||||
});
|
||||
return emissions;
|
||||
}
|
||||
|
||||
function clone(value: any): any {
|
||||
if (global.structuredClone != undefined) {
|
||||
return structuredClone(value);
|
||||
} else {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
}
|
||||
|
||||
export async function awaitAsync(ms = 1) {
|
||||
if (ms < 1) {
|
||||
await Promise.resolve();
|
||||
} else {
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
export { trackEmissions, awaitAsync } from "@bitwarden/core-test-utils";
|
||||
|
||||
Reference in New Issue
Block a user