1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-12-24 16:49:26 +01:00
bitwarden-browser/libs/common/spec/fake-state.ts
Matt Gibson 9c1e2ebd67
Typescript-strict-plugin (#12235)
* Use typescript-strict-plugin to iteratively turn on strict

* Add strict testing to pipeline

Can be executed locally through either `npm run test:types` for full type checking including spec files, or `npx tsc-strict` for only tsconfig.json included files.

* turn on strict for scripts directory

* Use plugin for all tsconfigs in monorepo

vscode is capable of executing tsc with plugins, but uses the most relevant tsconfig to do so. If the plugin is not a part of that config, it is skipped and developers get no feedback of strict compile time issues. These updates remedy that at the cost of slightly more complex removal of the plugin when the time comes.

* remove plugin from configs that extend one that already has it

* Update workspace settings to honor strict plugin

* Apply strict-plugin to native message test runner

* Update vscode workspace to use root tsc version

* `./node_modules/.bin/update-strict-comments` 🤖

This is a one-time operation. All future files should adhere to strict type checking.

* Add fixme to `ts-strict-ignore` comments

* `update-strict-comments` 🤖

repeated for new merge files
2024-12-09 20:58:50 +01:00

280 lines
8.9 KiB
TypeScript

// 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 { FakeAccountService } from "./fake-account-service";
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, { syncValue }: { syncValue: boolean } = { syncValue: true }) {
this.stateSubject.next({
syncValue,
combinedState: [this.userId, state],
});
}
async update<TCombine>(
configureState: (state: T, dependency: TCombine) => T,
options?: StateUpdateOptions<T, TCombine>,
): Promise<T> {
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 accountService: FakeAccountService,
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));
}
get userId() {
return this.accountService.activeUserId;
}
nextState(state: T, { syncValue }: { syncValue: boolean } = { syncValue: true }) {
this.stateSubject.next({
syncValue,
combinedState: [this.userId, state],
});
}
async update<TCombine>(
configureState: (state: T, dependency: TCombine) => T,
options?: StateUpdateOptions<T, TCombine>,
): Promise<[UserId, T]> {
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.userId, current];
}
const newState = configureState(current, combinedDependencies);
this.nextState(newState);
this.nextMock([this.userId, newState]);
return [this.userId, 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();
}
}