mirror of
https://github.com/lensapp/lens.git
synced 2025-05-20 05:10:56 +00:00
feat: Introduce Feature for persisted state
Co-authored-by: Janne Savolainen <janne.savolainen@live.fi> Signed-off-by: Iku-turso <mikko.aspiala@gmail.com>
This commit is contained in:
parent
d56bd1bd69
commit
82b370c09c
6
packages/utility-features/persisted-state/.eslintrc.json
Normal file
6
packages/utility-features/persisted-state/.eslintrc.json
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"extends": "@k8slens/eslint-config/eslint",
|
||||||
|
"parserOptions": {
|
||||||
|
"project": "./tsconfig.json"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
packages/utility-features/persisted-state/.prettierrc
Normal file
1
packages/utility-features/persisted-state/.prettierrc
Normal file
@ -0,0 +1 @@
|
|||||||
|
"@k8slens/eslint-config/prettier"
|
||||||
17
packages/utility-features/persisted-state/index.ts
Normal file
17
packages/utility-features/persisted-state/index.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { feature } from "./src/feature";
|
||||||
|
|
||||||
|
export {
|
||||||
|
createPersistedStateInjectionToken,
|
||||||
|
persistedStateInjectionToken,
|
||||||
|
} from "./src/create-persisted-state/create-persisted-state.injectable";
|
||||||
|
|
||||||
|
export type {
|
||||||
|
CreatePersistedState,
|
||||||
|
CreatePersistedStateConfig,
|
||||||
|
PersistedState,
|
||||||
|
PersistedStateResult,
|
||||||
|
NonPendingPersistedStateResult,
|
||||||
|
PendingPersistedStateResult,
|
||||||
|
} from "./src/create-persisted-state/create-persisted-state.injectable";
|
||||||
|
|
||||||
|
export default feature;
|
||||||
1
packages/utility-features/persisted-state/jest.config.js
Normal file
1
packages/utility-features/persisted-state/jest.config.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
module.exports = require("@k8slens/jest").monorepoPackageConfig(__dirname).configForNode;
|
||||||
45
packages/utility-features/persisted-state/package.json
Normal file
45
packages/utility-features/persisted-state/package.json
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"name": "@k8slens/persisted-state",
|
||||||
|
"private": false,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "TBD",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"types": "dist/index.d.ts",
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/lensapp/lens.git"
|
||||||
|
},
|
||||||
|
"type": "commonjs",
|
||||||
|
"author": {
|
||||||
|
"name": "OpenLens Authors",
|
||||||
|
"email": "info@k8slens.dev"
|
||||||
|
},
|
||||||
|
"license": "MIT",
|
||||||
|
"homepage": "https://github.com/lensapp/lens",
|
||||||
|
"scripts": {
|
||||||
|
"build": "lens-webpack-build",
|
||||||
|
"clean": "rimraf dist/",
|
||||||
|
"test:unit": "jest --coverage --runInBand",
|
||||||
|
"lint": "lens-lint",
|
||||||
|
"lint:fix": "lens-lint --fix"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@k8slens/app-paths": "^0.1.0",
|
||||||
|
"@k8slens/file-system": "^0.1.0",
|
||||||
|
"@k8slens/logger": "1.0.0-alpha.6",
|
||||||
|
"@ogre-tools/fp": "^16.1.0",
|
||||||
|
"@ogre-tools/injectable": "^16.1.0",
|
||||||
|
"@ogre-tools/injectable-extension-for-auto-registration": "^16.1.0",
|
||||||
|
"@ogre-tools/injectable-extension-for-mobx": "^16.1.0",
|
||||||
|
"lodash": "^4.17.15",
|
||||||
|
"mobx": "^6.7.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@k8slens/eslint-config": "^6.5.0-alpha.3",
|
||||||
|
"@k8slens/react-testing-library-discovery": "^1.0.0-alpha.4",
|
||||||
|
"@k8slens/webpack": "^6.5.0-alpha.6"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,231 @@
|
|||||||
|
import {
|
||||||
|
getInjectable,
|
||||||
|
getInjectionToken,
|
||||||
|
lifecycleEnum,
|
||||||
|
} from "@ogre-tools/injectable";
|
||||||
|
import type { AnySchema } from "ajv";
|
||||||
|
|
||||||
|
import { computed, IComputedValue, observable, runInAction } from "mobx";
|
||||||
|
import type { JsonValue } from "type-fest";
|
||||||
|
import {
|
||||||
|
readJsonFileInjectionToken,
|
||||||
|
writeJsonFileInjectionToken,
|
||||||
|
} from "@lensapp/fs";
|
||||||
|
import { validateJsonSchema, withThrownFailuresUnless } from "@lensapp/utils";
|
||||||
|
import { constant } from "lodash/fp";
|
||||||
|
import { logErrorInjectionToken } from "@lensapp/logging";
|
||||||
|
import {
|
||||||
|
appPathsInjectionToken,
|
||||||
|
joinPathsInjectionToken,
|
||||||
|
} from "@lensapp/app-paths";
|
||||||
|
|
||||||
|
export type CreatePersistedStateConfig<T extends JsonValue> = {
|
||||||
|
id: string;
|
||||||
|
schema: AnySchema;
|
||||||
|
defaultValue: T;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NonPendingPersistedStateResult<T extends JsonValue> = {
|
||||||
|
pending: false;
|
||||||
|
value: T;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PendingPersistedStateResult = {
|
||||||
|
pending: true;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PersistedStateResult<T extends JsonValue> =
|
||||||
|
| NonPendingPersistedStateResult<T>
|
||||||
|
| PendingPersistedStateResult;
|
||||||
|
|
||||||
|
export const persistedStateInjectionToken = getInjectionToken<
|
||||||
|
PersistedState<any>
|
||||||
|
>({
|
||||||
|
id: "persisted-state-injection-token",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createPersistedStateInjectionToken =
|
||||||
|
getInjectionToken<CreatePersistedState>({
|
||||||
|
id: "create-persisted-state-injection-token",
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface PersistedState<T extends JsonValue> {
|
||||||
|
result: IComputedValue<PersistedStateResult<T>>;
|
||||||
|
getAsyncValue: () => Promise<T>;
|
||||||
|
set: (newValue: T) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CreatePersistedState = <T extends JsonValue>(
|
||||||
|
config: CreatePersistedStateConfig<T>
|
||||||
|
) => PersistedState<T>;
|
||||||
|
|
||||||
|
export const stateBoxInjectable = getInjectable({
|
||||||
|
id: "persisted-state-box",
|
||||||
|
|
||||||
|
instantiate: () => observable.box(),
|
||||||
|
|
||||||
|
lifecycle: lifecycleEnum.keyedSingleton({
|
||||||
|
getInstanceKey: (di, id: string) => id,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createPersistedStateInjectable = getInjectable({
|
||||||
|
id: "create-persisted-state",
|
||||||
|
|
||||||
|
instantiate: (di) => {
|
||||||
|
const readJsonFileWithThrownFailures = di.inject(
|
||||||
|
readJsonFileInjectionToken
|
||||||
|
);
|
||||||
|
|
||||||
|
const readJsonFile = withThrownFailuresUnless(
|
||||||
|
errorIsAboutMissingFile,
|
||||||
|
errorIsAboutAnythingElse
|
||||||
|
)(readJsonFileWithThrownFailures);
|
||||||
|
|
||||||
|
const writeJsonFile = withThrownFailuresUnless(constant(true))(
|
||||||
|
di.inject(writeJsonFileInjectionToken)
|
||||||
|
);
|
||||||
|
|
||||||
|
const logError = di.inject(logErrorInjectionToken);
|
||||||
|
const appPaths = di.inject(appPathsInjectionToken);
|
||||||
|
const joinPaths = di.inject(joinPathsInjectionToken);
|
||||||
|
|
||||||
|
const alreadyCreated = new Set<string>();
|
||||||
|
|
||||||
|
return <T extends JsonValue>({
|
||||||
|
id: persistedStateId,
|
||||||
|
defaultValue,
|
||||||
|
schema,
|
||||||
|
}: CreatePersistedStateConfig<T>) => {
|
||||||
|
if (alreadyCreated.has(persistedStateId)) {
|
||||||
|
throw new Error(
|
||||||
|
`Tried to create persisted state for "${persistedStateId}", but it was already created`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const validateSchema = validateJsonSchema(getFileSchema(schema));
|
||||||
|
|
||||||
|
alreadyCreated.add(persistedStateId);
|
||||||
|
|
||||||
|
const stateJsonPath = joinPaths(
|
||||||
|
appPaths.userData,
|
||||||
|
`persisted-states/${persistedStateId}.json`
|
||||||
|
);
|
||||||
|
|
||||||
|
const valueBox = observable.box(defaultValue);
|
||||||
|
const pendingBox = observable.box(true);
|
||||||
|
|
||||||
|
let stallAsyncValue = readJsonFile(stateJsonPath).then(
|
||||||
|
(stateValueCall) => {
|
||||||
|
if (!stateValueCall.callWasSuccessful) {
|
||||||
|
if (!errorIsAboutMissingFile(stateValueCall.error.cause)) {
|
||||||
|
logError(
|
||||||
|
`Tried to read persisted states from "${stateJsonPath}" but it failed with:\n\n${stateValueCall.error.message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
runInAction(() => {
|
||||||
|
pendingBox.set(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = stateValueCall.response;
|
||||||
|
|
||||||
|
const validated = validateSchema(response);
|
||||||
|
|
||||||
|
if (validated.valid) {
|
||||||
|
runInAction(() => {
|
||||||
|
valueBox.set(response.value);
|
||||||
|
pendingBox.set(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
result: computed(() =>
|
||||||
|
pendingBox.get()
|
||||||
|
? { pending: true as const }
|
||||||
|
: {
|
||||||
|
pending: false as const,
|
||||||
|
value: valueBox.get(),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
|
||||||
|
set: async (newValue: T) => {
|
||||||
|
if (pendingBox.get()) {
|
||||||
|
throw new Error(
|
||||||
|
`Tried to set a persisted state for "${persistedStateId}", but call for the existing persisted state hadn't finished yet`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestGoodValue = valueBox.get();
|
||||||
|
|
||||||
|
const validateSchema = validateJsonSchema(schema);
|
||||||
|
|
||||||
|
const validated = validateSchema(newValue);
|
||||||
|
|
||||||
|
if (!validated.valid) {
|
||||||
|
throw new Error(
|
||||||
|
`Tried to set value of persisted state "${persistedStateId}" but validation of new value failed with:\n\n${JSON.stringify(
|
||||||
|
validated.validationErrors,
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
runInAction(() => {
|
||||||
|
valueBox.set(newValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
const resultPromise = writeJsonFile(stateJsonPath, {
|
||||||
|
version: 1,
|
||||||
|
value: newValue,
|
||||||
|
});
|
||||||
|
|
||||||
|
stallAsyncValue = resultPromise as Promise<any>;
|
||||||
|
|
||||||
|
const result = await resultPromise;
|
||||||
|
|
||||||
|
if (!result.callWasSuccessful) {
|
||||||
|
runInAction(() => {
|
||||||
|
valueBox.set(latestGoodValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
logError(
|
||||||
|
`Tried to persist state to "${stateJsonPath}" but attempt failed with:\n\n${result.error.message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getAsyncValue: async () => {
|
||||||
|
await stallAsyncValue;
|
||||||
|
|
||||||
|
return valueBox.get();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
injectionToken: createPersistedStateInjectionToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
const getFileSchema = (valueSchema: AnySchema) => ({
|
||||||
|
type: "object",
|
||||||
|
|
||||||
|
properties: {
|
||||||
|
version: { type: "number" },
|
||||||
|
value: valueSchema,
|
||||||
|
},
|
||||||
|
|
||||||
|
required: ["version", "value"],
|
||||||
|
additionalProperties: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const errorIsAboutMissingFile = (error: unknown) =>
|
||||||
|
(error as any).code === "ENOENT";
|
||||||
|
|
||||||
|
const errorIsAboutAnythingElse = () => true;
|
||||||
@ -0,0 +1,356 @@
|
|||||||
|
import asyncFn, { AsyncFnMock } from "@async-fn/jest";
|
||||||
|
import {
|
||||||
|
createContainer,
|
||||||
|
DiContainer,
|
||||||
|
getInjectable,
|
||||||
|
} from "@ogre-tools/injectable";
|
||||||
|
import { reaction, runInAction } from "mobx";
|
||||||
|
import {
|
||||||
|
CreatePersistedState,
|
||||||
|
createPersistedStateInjectionToken,
|
||||||
|
PersistedState,
|
||||||
|
PersistedStateResult,
|
||||||
|
} from "./create-persisted-state.injectable";
|
||||||
|
import { registerFeature } from "@lensapp/feature-core";
|
||||||
|
import {
|
||||||
|
ReadJsonFile,
|
||||||
|
readJsonFileInjectionToken,
|
||||||
|
WriteJsonFile,
|
||||||
|
writeJsonFileInjectionToken,
|
||||||
|
} from "@lensapp/fs";
|
||||||
|
import { getSuccess } from "@lensapp/utils";
|
||||||
|
import { logErrorInjectionToken } from "@lensapp/logging";
|
||||||
|
import { appPathsInjectionToken } from "@lensapp/app-paths";
|
||||||
|
import { getPromiseStatus, useFakeTime } from "@lensapp/test-utils";
|
||||||
|
import { registerMobX } from "@ogre-tools/injectable-extension-for-mobx";
|
||||||
|
import { feature } from "../feature";
|
||||||
|
|
||||||
|
describe("create persisted state", () => {
|
||||||
|
let di: DiContainer;
|
||||||
|
let readJsonFileMock: AsyncFnMock<ReadJsonFile>;
|
||||||
|
let writeJsonFileMock: AsyncFnMock<WriteJsonFile>;
|
||||||
|
let logErrorMock: jest.Mock;
|
||||||
|
let createPersistedState: CreatePersistedState;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
useFakeTime();
|
||||||
|
|
||||||
|
di = createContainer("irrelevant");
|
||||||
|
|
||||||
|
registerFeature(di, feature);
|
||||||
|
|
||||||
|
di.register(appPathsFakeInjectable);
|
||||||
|
|
||||||
|
registerMobX(di);
|
||||||
|
|
||||||
|
readJsonFileMock = asyncFn();
|
||||||
|
di.override(readJsonFileInjectionToken, () => readJsonFileMock);
|
||||||
|
|
||||||
|
writeJsonFileMock = asyncFn();
|
||||||
|
di.override(writeJsonFileInjectionToken, () => writeJsonFileMock);
|
||||||
|
|
||||||
|
logErrorMock = jest.fn();
|
||||||
|
di.override(logErrorInjectionToken, () => logErrorMock);
|
||||||
|
|
||||||
|
createPersistedState = di.inject(createPersistedStateInjectionToken);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when a persisted state is created", () => {
|
||||||
|
let actualPersistedState: PersistedState<string>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
actualPersistedState = createPersistedState<string>({
|
||||||
|
id: "some-persisted-state-id",
|
||||||
|
defaultValue: "some-default-value",
|
||||||
|
|
||||||
|
schema: {
|
||||||
|
oneOf: [
|
||||||
|
{
|
||||||
|
enum: ["some-existing-value", "some-changed-value"],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
type: "null",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads persisted value", () => {
|
||||||
|
expect(readJsonFileMock).toHaveBeenCalledWith(
|
||||||
|
"/some-user-data-directory/persisted-states/some-persisted-state-id.json"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("when persisted state with conflicting ID is created, throws", () => {
|
||||||
|
expect(() => {
|
||||||
|
createPersistedState({
|
||||||
|
id: "some-persisted-state-id",
|
||||||
|
defaultValue: "irrelevant",
|
||||||
|
schema: {},
|
||||||
|
});
|
||||||
|
}).toThrow(
|
||||||
|
'Tried to create persisted state for "some-persisted-state-id", but it was already created'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when value is accessed as promise instead of observing", () => {
|
||||||
|
let actualPromise: Promise<string>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
actualPromise = actualPersistedState.getAsyncValue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not resolve yet", async () => {
|
||||||
|
const promiseStatus = await getPromiseStatus(actualPromise);
|
||||||
|
|
||||||
|
expect(promiseStatus.fulfilled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when existing value resolves", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await readJsonFileMock.resolve(
|
||||||
|
getSuccess({
|
||||||
|
version: 1,
|
||||||
|
value: "some-existing-value",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves as the existing value", async () => {
|
||||||
|
expect(await actualPromise).toBe("some-existing-value");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when value is accessed as observation instead of promise", () => {
|
||||||
|
let observedResult: PersistedStateResult<string>;
|
||||||
|
let observedValue: string | undefined;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
reaction(
|
||||||
|
() => actualPersistedState.result.get(),
|
||||||
|
|
||||||
|
(newValue) => {
|
||||||
|
observedResult = newValue;
|
||||||
|
observedValue = newValue.pending ? undefined : newValue.value;
|
||||||
|
},
|
||||||
|
|
||||||
|
{ fireImmediately: true }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("state observes as pending", () => {
|
||||||
|
expect(observedResult.pending).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("when new value is set before existing value has resolved, throws", () => {
|
||||||
|
return expect(actualPersistedState.set("irrelevant")).rejects.toThrow(
|
||||||
|
'Tried to set a persisted state for "some-persisted-state-id", but call for the existing persisted state hadn\'t finished yet'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when existing value resolves", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
writeJsonFileMock.mockClear();
|
||||||
|
|
||||||
|
await readJsonFileMock.resolve(
|
||||||
|
getSuccess({
|
||||||
|
version: 1,
|
||||||
|
value: "some-existing-value",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when value is accessed as promise instead of observing", () => {
|
||||||
|
let actualPromise: Promise<string>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
readJsonFileMock.mockClear();
|
||||||
|
actualPromise = actualPersistedState.getAsyncValue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves as the existing value", async () => {
|
||||||
|
expect(await actualPromise).toBe("some-existing-value");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not call for existing values again", () => {
|
||||||
|
expect(readJsonFileMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("state observes as the existing value", () => {
|
||||||
|
expect(observedValue).toBe("some-existing-value");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not persist the value again", () => {
|
||||||
|
expect(writeJsonFileMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when the state is changed to a valid value", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
runInAction(async () => {
|
||||||
|
await actualPersistedState.set("some-changed-value");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when value is accessed as promise instead of observing", () => {
|
||||||
|
let actualPromise: Promise<string>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
readJsonFileMock.mockClear();
|
||||||
|
actualPromise = actualPersistedState.getAsyncValue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not resolve yet", async () => {
|
||||||
|
const promiseStatus = await getPromiseStatus(actualPromise);
|
||||||
|
|
||||||
|
expect(promiseStatus.fulfilled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not call for existing values again", () => {
|
||||||
|
expect(readJsonFileMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when persisting resolves", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await writeJsonFileMock.resolve(getSuccess(undefined));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves as the changed value", async () => {
|
||||||
|
expect(await actualPromise).toBe("some-changed-value");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists the value", () => {
|
||||||
|
expect(writeJsonFileMock).toHaveBeenCalledWith(
|
||||||
|
"/some-user-data-directory/persisted-states/some-persisted-state-id.json",
|
||||||
|
{ version: 1, value: "some-changed-value" }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("state observes as the changed value", () => {
|
||||||
|
expect(observedValue).toBe("some-changed-value");
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when persisting resolves", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await writeJsonFileMock.resolve(getSuccess(undefined));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not log error", () => {
|
||||||
|
expect(logErrorMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("observed value is still the changed value", () => {
|
||||||
|
expect(observedValue).toBe("some-changed-value");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when persisting rejects", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await writeJsonFileMock.reject(new Error("some-error"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs error", () => {
|
||||||
|
expect(logErrorMock).toHaveBeenCalledWith(
|
||||||
|
'Tried to persist state to "/some-user-data-directory/persisted-states/some-persisted-state-id.json" but attempt failed with:\n\nsome-error'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("observed value is the latest good value", () => {
|
||||||
|
expect(observedValue).toBe("some-existing-value");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when the state is changed to an invalid value", () => {
|
||||||
|
let error: any;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
runInAction(async () => {
|
||||||
|
try {
|
||||||
|
await actualPersistedState.set("some-invalid-value");
|
||||||
|
} catch (e) {
|
||||||
|
error = e;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not persist the value", () => {
|
||||||
|
expect(writeJsonFileMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("state observes as latest good value", () => {
|
||||||
|
expect(observedValue).toBe("some-existing-value");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws", () => {
|
||||||
|
expect(error.message).toEqual(
|
||||||
|
expect.stringContaining(
|
||||||
|
'Tried to set value of persisted state "some-persisted-state-id" but validation of new value failed with:'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when reading resolves with invalid content", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await readJsonFileMock.resolve(getSuccess({ version: 1, value: 42 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("observes as pending (eternally)", () => {
|
||||||
|
expect(observedResult.pending).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when reading rejects with error for non existing file", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
const errorAboutMissingFile = new Error("irrelevant");
|
||||||
|
|
||||||
|
(errorAboutMissingFile as any).code = "ENOENT";
|
||||||
|
|
||||||
|
await readJsonFileMock.reject(errorAboutMissingFile);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not log error", () => {
|
||||||
|
expect(logErrorMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("state observes as default value", () => {
|
||||||
|
expect(observedValue).toBe("some-default-value");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when reading rejects with any other error than for non existing file", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
const anyOtherError = new Error("some error");
|
||||||
|
|
||||||
|
await readJsonFileMock.reject(anyOtherError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs error", () => {
|
||||||
|
expect(logErrorMock).toHaveBeenCalledWith(
|
||||||
|
'Tried to read persisted states from "/some-user-data-directory/persisted-states/some-persisted-state-id.json" but it failed with:\n\nsome error'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("state observes as default value", () => {
|
||||||
|
expect(observedValue).toBe("some-default-value");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const appPathsFakeInjectable = getInjectable({
|
||||||
|
id: "app-paths-fake",
|
||||||
|
instantiate: () =>
|
||||||
|
({
|
||||||
|
userData: "/some-user-data-directory",
|
||||||
|
} as any),
|
||||||
|
injectionToken: appPathsInjectionToken,
|
||||||
|
});
|
||||||
18
packages/utility-features/persisted-state/src/feature.ts
Normal file
18
packages/utility-features/persisted-state/src/feature.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { autoRegister } from "@ogre-tools/injectable-extension-for-auto-registration";
|
||||||
|
import { getFeature } from "@k8slens/feature-core";
|
||||||
|
import fsFeature from "@k8slens/file-system";
|
||||||
|
import loggingFeature from "@k8slens/logger";
|
||||||
|
import appPathsFeature from "@k8slens/app-paths";
|
||||||
|
|
||||||
|
export const feature = getFeature({
|
||||||
|
id: "persisted-state",
|
||||||
|
dependencies: [fsFeature, loggingFeature, appPathsFeature],
|
||||||
|
|
||||||
|
register: (di) => {
|
||||||
|
autoRegister({
|
||||||
|
di,
|
||||||
|
targetModule: module,
|
||||||
|
getRequireContexts: () => [require.context("./", true, /\.injectable\.(ts|tsx)$/)],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
4
packages/utility-features/persisted-state/tsconfig.json
Normal file
4
packages/utility-features/persisted-state/tsconfig.json
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"extends": "@k8slens/typescript/config/base.json",
|
||||||
|
"include": ["**/*.ts"]
|
||||||
|
}
|
||||||
@ -0,0 +1 @@
|
|||||||
|
module.exports = require("@k8slens/webpack").configForNode;
|
||||||
Loading…
Reference in New Issue
Block a user