diff --git a/packages/utility-features/file-system/.eslintrc.json b/packages/utility-features/file-system/.eslintrc.json new file mode 100644 index 0000000000..b15115cb69 --- /dev/null +++ b/packages/utility-features/file-system/.eslintrc.json @@ -0,0 +1,6 @@ +{ + "extends": "@k8slens/eslint-config/eslint", + "parserOptions": { + "project": "./tsconfig.json" + } +} diff --git a/packages/utility-features/file-system/.prettierrc b/packages/utility-features/file-system/.prettierrc new file mode 100644 index 0000000000..edd47b479e --- /dev/null +++ b/packages/utility-features/file-system/.prettierrc @@ -0,0 +1 @@ +"@k8slens/eslint-config/prettier" diff --git a/packages/utility-features/file-system/index.ts b/packages/utility-features/file-system/index.ts new file mode 100644 index 0000000000..de9a2fc239 --- /dev/null +++ b/packages/utility-features/file-system/index.ts @@ -0,0 +1,21 @@ +export type { DeleteFile } from "./src/delete-file/delete-file.injectable"; +export type { PathExists } from "./src/path-exists/path-exists.injectable"; +export type { ReadFile } from "./src/read-file/read-file.injectable"; +export type { ReadJsonFile } from "./src/read-json-file/read-json-file.injectable"; +export type { ReadYamlFile } from "./src/read-yaml-file/read-yaml-file.injectable"; +export type { WriteFile } from "./src/write-file/write-file.injectable"; +export type { WriteJsonFile } from "./src/write-json-file/write-json-file.injectable"; +export type { WriteYamlFile } from "./src/write-yaml-file/write-yaml-file.injectable"; + +export { deleteFileInjectionToken } from "./src/delete-file/delete-file.injectable"; +export { pathExistsInjectionToken } from "./src/path-exists/path-exists.injectable"; +export { readFileInjectionToken } from "./src/read-file/read-file.injectable"; +export { readJsonFileInjectionToken } from "./src/read-json-file/read-json-file.injectable"; +export { readYamlFileInjectionToken } from "./src/read-yaml-file/read-yaml-file.injectable"; +export { writeFileInjectionToken } from "./src/write-file/write-file.injectable"; +export { writeJsonFileInjectionToken } from "./src/write-json-file/write-json-file.injectable"; +export { writeYamlFileInjectionToken } from "./src/write-yaml-file/write-yaml-file.injectable"; + +export { fileSystemFeature } from "./src/feature"; + +export { testUtils } from "./src/test-utils"; diff --git a/packages/utility-features/file-system/jest.config.js b/packages/utility-features/file-system/jest.config.js new file mode 100644 index 0000000000..c6074967eb --- /dev/null +++ b/packages/utility-features/file-system/jest.config.js @@ -0,0 +1 @@ +module.exports = require("@k8slens/jest").monorepoPackageConfig(__dirname).configForNode; diff --git a/packages/utility-features/file-system/package.json b/packages/utility-features/file-system/package.json new file mode 100644 index 0000000000..bb20f12a88 --- /dev/null +++ b/packages/utility-features/file-system/package.json @@ -0,0 +1,43 @@ +{ + "name": "@k8slens/file-system", + "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/feature-core": "^6.5.0-alpha.5", + "@ogre-tools/fp": "^16.1.0", + "@ogre-tools/injectable": "^16.1.0", + "@ogre-tools/injectable-extension-for-auto-registration": "^16.1.0", + "fs-extra": "^10.1.0", + "js-yaml": "^4.1.0", + "lodash": "^4.17.15" + }, + "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" + } +} diff --git a/packages/utility-features/file-system/src/delete-file/delete-file.injectable.ts b/packages/utility-features/file-system/src/delete-file/delete-file.injectable.ts new file mode 100644 index 0000000000..8c213bd1f6 --- /dev/null +++ b/packages/utility-features/file-system/src/delete-file/delete-file.injectable.ts @@ -0,0 +1,28 @@ +import { getInjectable, getInjectionToken } from "@ogre-tools/injectable"; +import fsInjectable from "../fs/fs.injectable"; +import type { AsyncCallSuccess } from "@lensapp/utils"; +import { getSuccess } from "@lensapp/utils"; + +export type DeleteFile = (filePath: string) => AsyncCallSuccess; + +export const deleteFileInjectionToken = getInjectionToken({ + id: "delete-file-injection-token", +}); + +const deleteFileInjectable = getInjectable({ + id: "delete-file", + + instantiate: (di): DeleteFile => { + const unlink = di.inject(fsInjectable).unlink; + + return async (filePath) => { + await unlink(filePath); + + return getSuccess(undefined); + }; + }, + + injectionToken: deleteFileInjectionToken, +}); + +export default deleteFileInjectable; diff --git a/packages/utility-features/file-system/src/delete-file/delete-file.test.ts b/packages/utility-features/file-system/src/delete-file/delete-file.test.ts new file mode 100644 index 0000000000..e8eda6a229 --- /dev/null +++ b/packages/utility-features/file-system/src/delete-file/delete-file.test.ts @@ -0,0 +1,56 @@ +import { createContainer } from "@ogre-tools/injectable"; +import { registerFeature } from "@lensapp/feature-core"; +import { fileSystemFeature } from "../feature"; +import asyncFn, { AsyncFnMock } from "@async-fn/jest"; +import fsInjectable from "../fs/fs.injectable"; +import deleteFileInjectable, { DeleteFile } from "./delete-file.injectable"; +import { AsyncCallResult, getSuccess } from "@lensapp/utils"; + +describe("delete-file", () => { + let fsUnlinkMock: AsyncFnMock<(filePath: string) => Promise>; + let deleteFile: DeleteFile; + + beforeEach(() => { + const di = createContainer("irrelevant"); + + registerFeature(di, fileSystemFeature); + + fsUnlinkMock = asyncFn(); + + const fsStub = { + unlink: fsUnlinkMock, + }; + + di.override(fsInjectable, () => fsStub as any); + + deleteFile = di.inject(deleteFileInjectable); + }); + + describe("when called", () => { + let actualPromise: AsyncCallResult; + + beforeEach(() => { + actualPromise = deleteFile("./some-directory/some-file.js"); + }); + + it("calls for unlink from file system", () => { + expect(fsUnlinkMock).toHaveBeenCalledWith("./some-directory/some-file.js"); + }); + + it("when unlink resolves, resolves with success", async () => { + await fsUnlinkMock.resolve(); + + const actual = await actualPromise; + + expect(actual).toEqual(getSuccess(undefined)); + }); + + it("when unlink rejects, rejects with the original error", () => { + const someError = new Error("some-error"); + + fsUnlinkMock.reject(someError); + + return expect(actualPromise).rejects.toBe(someError); + }); + }); +}); diff --git a/packages/utility-features/file-system/src/feature.ts b/packages/utility-features/file-system/src/feature.ts new file mode 100644 index 0000000000..3d2f0c6149 --- /dev/null +++ b/packages/utility-features/file-system/src/feature.ts @@ -0,0 +1,15 @@ +import { autoRegister } from "@ogre-tools/injectable-extension-for-auto-registration"; +import { getFeature } from "@k8slens/feature-core"; + +export const fileSystemFeature = getFeature({ + id: "fs", + + register: (di) => { + autoRegister({ + di, + targetModule: module, + + getRequireContexts: () => [require.context("./", true, /\.injectable\.(ts|tsx)$/)], + }); + }, +}); diff --git a/packages/utility-features/file-system/src/fs/fs.injectable.ts b/packages/utility-features/file-system/src/fs/fs.injectable.ts new file mode 100644 index 0000000000..77c19d4c3f --- /dev/null +++ b/packages/utility-features/file-system/src/fs/fs.injectable.ts @@ -0,0 +1,10 @@ +import { getInjectable } from "@ogre-tools/injectable"; +import fse from "fs-extra"; + +const fsInjectable = getInjectable({ + id: "fs", + instantiate: () => fse, + causesSideEffects: true, +}); + +export default fsInjectable; diff --git a/packages/utility-features/file-system/src/fs/fs.test.ts b/packages/utility-features/file-system/src/fs/fs.test.ts new file mode 100644 index 0000000000..516d1a5173 --- /dev/null +++ b/packages/utility-features/file-system/src/fs/fs.test.ts @@ -0,0 +1,15 @@ +import fsInjectable from "./fs.injectable"; +import { createContainer } from "@ogre-tools/injectable"; +import fse from "fs-extra"; + +describe("fs", () => { + it("is fs-extra", () => { + const di = createContainer("irrelevant"); + + di.register(fsInjectable); + + const fs = di.inject(fsInjectable); + + expect(fs).toBe(fse); + }); +}); diff --git a/packages/utility-features/file-system/src/path-exists/path-exists.injectable.ts b/packages/utility-features/file-system/src/path-exists/path-exists.injectable.ts new file mode 100644 index 0000000000..a6204f63e9 --- /dev/null +++ b/packages/utility-features/file-system/src/path-exists/path-exists.injectable.ts @@ -0,0 +1,27 @@ +import { getInjectable, getInjectionToken } from "@ogre-tools/injectable"; +import fsInjectable from "../fs/fs.injectable"; +import type { AsyncCallSuccess } from "@lensapp/utils"; +import { getSuccess } from "@lensapp/utils"; + +export type PathExists = (path: string) => AsyncCallSuccess; + +export const pathExistsInjectionToken = getInjectionToken({ + id: "path-exists-injection-token", +}); + +const pathExistsInjectable = getInjectable({ + id: "path-exists", + instantiate: (di): PathExists => { + const pathExists = di.inject(fsInjectable).pathExists; + + return async (filePath: string) => { + const result = await pathExists(filePath); + + return getSuccess(result); + }; + }, + + injectionToken: pathExistsInjectionToken, +}); + +export default pathExistsInjectable; diff --git a/packages/utility-features/file-system/src/path-exists/path-exists.test.ts b/packages/utility-features/file-system/src/path-exists/path-exists.test.ts new file mode 100644 index 0000000000..e26125cbb9 --- /dev/null +++ b/packages/utility-features/file-system/src/path-exists/path-exists.test.ts @@ -0,0 +1,58 @@ +import { createContainer } from "@ogre-tools/injectable"; +import { registerFeature } from "@lensapp/feature-core"; +import { fileSystemFeature } from "../feature"; +import asyncFn, { AsyncFnMock } from "@async-fn/jest"; +import fsInjectable from "../fs/fs.injectable"; +import type { PathExists } from "./path-exists.injectable"; +import pathExistsInjectable from "./path-exists.injectable"; +import type { AsyncCallResult } from "@lensapp/utils"; +import { getSuccess } from "@lensapp/utils"; + +describe("path-exists", () => { + let fsPathExistsMock: AsyncFnMock<(filePath: string) => Promise>; + let pathExists: PathExists; + + beforeEach(() => { + const di = createContainer("irrelevant"); + + registerFeature(di, fileSystemFeature); + + fsPathExistsMock = asyncFn(); + + const fsStub = { + pathExists: fsPathExistsMock, + }; + + di.override(fsInjectable, () => fsStub as any); + + pathExists = di.inject(pathExistsInjectable); + }); + + describe("when called", () => { + let actualPromise: AsyncCallResult; + + beforeEach(() => { + actualPromise = pathExists("./some-directory/some-file.js"); + }); + + it("calls for filesystem", () => { + expect(fsPathExistsMock).toHaveBeenCalledWith("./some-directory/some-file.js"); + }); + + it("when call resolves with true, resolves with true", async () => { + await fsPathExistsMock.resolve(true); + + const actual = await actualPromise; + + expect(actual).toEqual(getSuccess(true)); + }); + + it("when call resolves with false, resolves with false", async () => { + await fsPathExistsMock.resolve(false); + + const actual = await actualPromise; + + expect(actual).toEqual(getSuccess(false)); + }); + }); +}); diff --git a/packages/utility-features/file-system/src/path/get-dirname.injectable.ts b/packages/utility-features/file-system/src/path/get-dirname.injectable.ts new file mode 100644 index 0000000000..06f16ab388 --- /dev/null +++ b/packages/utility-features/file-system/src/path/get-dirname.injectable.ts @@ -0,0 +1,12 @@ +import { getInjectable } from "@ogre-tools/injectable"; +import path from "path"; + +export type GetDirnameOfPath = (path: string) => string; + +const getDirnameOfPathInjectable = getInjectable({ + id: "get-dirname-of-path", + instantiate: (): GetDirnameOfPath => path.dirname, + causesSideEffects: true, +}); + +export default getDirnameOfPathInjectable; diff --git a/packages/utility-features/file-system/src/path/get-dirname.test.ts b/packages/utility-features/file-system/src/path/get-dirname.test.ts new file mode 100644 index 0000000000..354361b879 --- /dev/null +++ b/packages/utility-features/file-system/src/path/get-dirname.test.ts @@ -0,0 +1,15 @@ +import { createContainer } from "@ogre-tools/injectable"; +import path from "path"; +import getDirnameOfPathInjectable from "./get-dirname.injectable"; + +describe("get-dirname", () => { + it("is exactly dirname from path module", () => { + const di = createContainer("irrelevant"); + + di.register(getDirnameOfPathInjectable); + + const getDirnameOfPath = di.inject(getDirnameOfPathInjectable); + + expect(getDirnameOfPath).toBe(path.dirname); + }); +}); diff --git a/packages/utility-features/file-system/src/read-file/read-file.injectable.ts b/packages/utility-features/file-system/src/read-file/read-file.injectable.ts new file mode 100644 index 0000000000..33ea8955ce --- /dev/null +++ b/packages/utility-features/file-system/src/read-file/read-file.injectable.ts @@ -0,0 +1,28 @@ +import { getInjectable, getInjectionToken } from "@ogre-tools/injectable"; +import fsInjectable from "../fs/fs.injectable"; +import type { AsyncCallSuccess } from "@lensapp/utils"; +import { getSuccess } from "@lensapp/utils"; + +export type ReadFile = (filePath: string) => AsyncCallSuccess; + +export const readFileInjectionToken = getInjectionToken({ + id: "read-file-injection-token", +}); + +const readFileInjectable = getInjectable({ + id: "read-file", + + instantiate: (di): ReadFile => { + const { readFile } = di.inject(fsInjectable); + + return async (filePath) => { + const response = await readFile(filePath, "utf-8"); + + return getSuccess(response); + }; + }, + + injectionToken: readFileInjectionToken, +}); + +export default readFileInjectable; diff --git a/packages/utility-features/file-system/src/read-file/read-file.test.ts b/packages/utility-features/file-system/src/read-file/read-file.test.ts new file mode 100644 index 0000000000..0353a110f7 --- /dev/null +++ b/packages/utility-features/file-system/src/read-file/read-file.test.ts @@ -0,0 +1,59 @@ +import { createContainer } from "@ogre-tools/injectable"; +import { registerFeature } from "@lensapp/feature-core"; +import { fileSystemFeature } from "../feature"; +import fsInjectable from "../fs/fs.injectable"; +import asyncFn, { AsyncFnMock } from "@async-fn/jest"; +import type { ReadFile } from "./read-file.injectable"; +import readFileInjectable from "./read-file.injectable"; +import type { CallResult } from "@lensapp/utils"; +import { getSuccess } from "@lensapp/utils"; + +describe("read-file", () => { + let fsReadFileMock: AsyncFnMock<(fileName: string, encoding: string) => Promise>; + + let readFile: ReadFile; + + beforeEach(() => { + const di = createContainer("irrelevant"); + + registerFeature(di, fileSystemFeature); + + fsReadFileMock = asyncFn(); + + const fsStub = { + readFile: fsReadFileMock, + }; + + di.override(fsInjectable, () => fsStub as any); + + readFile = di.inject(readFileInjectable); + }); + + describe("when called", () => { + let actualPromise: Promise>; + + beforeEach(() => { + actualPromise = readFile("some-file.js"); + }); + + it("calls for file from file system", () => { + expect(fsReadFileMock).toHaveBeenCalledWith("some-file.js", "utf-8"); + }); + + it("when reading file resolves, resolves with success", async () => { + await fsReadFileMock.resolve("some-content"); + + const actual = await actualPromise; + + expect(actual).toEqual(getSuccess("some-content")); + }); + + it("when reading file rejects, throws", async () => { + const someError = new Error("some-error"); + + fsReadFileMock.reject(someError); + + return expect(actualPromise).rejects.toBe(someError); + }); + }); +}); diff --git a/packages/utility-features/file-system/src/read-json-file/read-json-file.injectable.ts b/packages/utility-features/file-system/src/read-json-file/read-json-file.injectable.ts new file mode 100644 index 0000000000..fcf1f17982 --- /dev/null +++ b/packages/utility-features/file-system/src/read-json-file/read-json-file.injectable.ts @@ -0,0 +1,28 @@ +import { getInjectable, getInjectionToken } from "@ogre-tools/injectable"; +import type { AsyncCallSuccess } from "@lensapp/utils"; +import { getSuccess } from "@lensapp/utils"; +import readFileInjectable from "../read-file/read-file.injectable"; + +export type ReadJsonFile = (filePath: string) => AsyncCallSuccess; + +export const readJsonFileInjectionToken = getInjectionToken({ + id: "read-json-file-injection-token", +}); + +const readJsonFileInjectable = getInjectable({ + id: "read-json-file", + + instantiate: (di): ReadJsonFile => { + const readFile = di.inject(readFileInjectable); + + return async (filePath) => { + const call = await readFile(filePath); + + return getSuccess(JSON.parse(call.response)); + }; + }, + + injectionToken: readJsonFileInjectionToken, +}); + +export default readJsonFileInjectable; diff --git a/packages/utility-features/file-system/src/read-json-file/read-json-file.test.ts b/packages/utility-features/file-system/src/read-json-file/read-json-file.test.ts new file mode 100644 index 0000000000..726bb74c21 --- /dev/null +++ b/packages/utility-features/file-system/src/read-json-file/read-json-file.test.ts @@ -0,0 +1,57 @@ +import { createContainer } from "@ogre-tools/injectable"; +import readJsonFileInjectable, { ReadJsonFile } from "./read-json-file.injectable"; +import { registerFeature } from "@lensapp/feature-core"; +import { fileSystemFeature } from "../feature"; +import { CallResult, getSuccess } from "@lensapp/utils"; +import asyncFn, { AsyncFnMock } from "@async-fn/jest"; +import fsInjectable from "../fs/fs.injectable"; + +describe("read-json-file", () => { + let fsReadFileMock: AsyncFnMock<(fileName: string, encoding: string) => Promise>; + + let readJsonFile: ReadJsonFile; + + beforeEach(() => { + const di = createContainer("irrelevant"); + + registerFeature(di, fileSystemFeature); + + fsReadFileMock = asyncFn(); + + const fsStub = { + readFile: fsReadFileMock, + }; + + di.override(fsInjectable, () => fsStub as any); + + readJsonFile = di.inject(readJsonFileInjectable); + }); + + describe("when called", () => { + let actualPromise: Promise>; + + beforeEach(() => { + actualPromise = readJsonFile("some-file.js"); + }); + + it("calls for file from file system", () => { + expect(fsReadFileMock).toHaveBeenCalledWith("some-file.js", "utf-8"); + }); + + it("when reading file resolves, resolves with success", async () => { + await fsReadFileMock.resolve(JSON.stringify({ some: "content" })); + + const actual = await actualPromise; + + expect(actual).toEqual(getSuccess({ some: "content" })); + }); + + it("when reading file rejects, throws", async () => { + const someError = new Error("some-error"); + + fsReadFileMock.reject(someError); + + return expect(actualPromise).rejects.toBe(someError); + }); + }); +}); diff --git a/packages/utility-features/file-system/src/read-yaml-file/read-yaml-file.injectable.ts b/packages/utility-features/file-system/src/read-yaml-file/read-yaml-file.injectable.ts new file mode 100644 index 0000000000..f533e37a58 --- /dev/null +++ b/packages/utility-features/file-system/src/read-yaml-file/read-yaml-file.injectable.ts @@ -0,0 +1,31 @@ +import { getInjectable, getInjectionToken } from "@ogre-tools/injectable"; +import type { AsyncCallSuccess } from "@lensapp/utils"; +import { getSuccess } from "@lensapp/utils"; +import readFileInjectable from "../read-file/read-file.injectable"; +import yaml from "js-yaml"; + +export type ReadYamlFile = (filePath: string) => AsyncCallSuccess; + +export const readYamlFileInjectionToken = getInjectionToken({ + id: "read-yaml-file-injection-token", +}); + +const readYamlFileInjectable = getInjectable({ + id: "read-yaml-file", + + instantiate: (di): ReadYamlFile => { + const readFile = di.inject(readFileInjectable); + + return async (filePath: string) => { + const call = await readFile(filePath); + + const parsedResponse = yaml.load(call.response) as object; + + return getSuccess(parsedResponse); + }; + }, + + injectionToken: readYamlFileInjectionToken, +}); + +export default readYamlFileInjectable; diff --git a/packages/utility-features/file-system/src/read-yaml-file/read-yaml-file.test.ts b/packages/utility-features/file-system/src/read-yaml-file/read-yaml-file.test.ts new file mode 100644 index 0000000000..2b6c12a222 --- /dev/null +++ b/packages/utility-features/file-system/src/read-yaml-file/read-yaml-file.test.ts @@ -0,0 +1,57 @@ +import { createContainer } from "@ogre-tools/injectable"; +import readYamlFileInjectable, { ReadYamlFile } from "./read-yaml-file.injectable"; +import { registerFeature } from "@lensapp/feature-core"; +import { fileSystemFeature } from "../feature"; +import { CallResult, getSuccess } from "@lensapp/utils"; +import asyncFn, { AsyncFnMock } from "@async-fn/jest"; +import fsInjectable from "../fs/fs.injectable"; + +describe("read-yaml-file", () => { + let fsReadFileMock: AsyncFnMock<(fileName: string, encoding: string) => Promise>; + + let readYamlFile: ReadYamlFile; + + beforeEach(() => { + const di = createContainer("irrelevant"); + + registerFeature(di, fileSystemFeature); + + fsReadFileMock = asyncFn(); + + const fsStub = { + readFile: fsReadFileMock, + }; + + di.override(fsInjectable, () => fsStub as any); + + readYamlFile = di.inject(readYamlFileInjectable); + }); + + describe("when called", () => { + let actualPromise: Promise>; + + beforeEach(() => { + actualPromise = readYamlFile("some-file.js"); + }); + + it("calls for file from file system", () => { + expect(fsReadFileMock).toHaveBeenCalledWith("some-file.js", "utf-8"); + }); + + it("when reading file resolves, resolves with success", async () => { + await fsReadFileMock.resolve("some: content"); + + const actual = await actualPromise; + + expect(actual).toEqual(getSuccess({ some: "content" })); + }); + + it("when reading file rejects, throws", async () => { + const someError = new Error("some-error"); + + fsReadFileMock.reject(someError); + + return expect(actualPromise).rejects.toBe(someError); + }); + }); +}); diff --git a/packages/utility-features/file-system/src/test-utils/index.ts b/packages/utility-features/file-system/src/test-utils/index.ts new file mode 100644 index 0000000000..625622d363 --- /dev/null +++ b/packages/utility-features/file-system/src/test-utils/index.ts @@ -0,0 +1,3 @@ +import { overrideFsWithFakes } from "./override-fs-with-fakes"; + +export const testUtils = { overrideFsWithFakes }; diff --git a/packages/utility-features/file-system/src/test-utils/override-fs-with-fakes.ts b/packages/utility-features/file-system/src/test-utils/override-fs-with-fakes.ts new file mode 100644 index 0000000000..b354972013 --- /dev/null +++ b/packages/utility-features/file-system/src/test-utils/override-fs-with-fakes.ts @@ -0,0 +1,47 @@ +import type { DiContainer } from "@ogre-tools/injectable"; +import readFileInjectable from "../read-file/read-file.injectable"; +import pathExistsInjectable from "../path-exists/path-exists.injectable"; +import deleteFileInjectable from "../delete-file/delete-file.injectable"; +import { getSuccess } from "@lensapp/utils"; +import writeFileInjectable from "../write-file/write-file.injectable"; + +export const overrideFsWithFakes = (di: DiContainer, state = new Map()) => { + const readFile = readFileFor(state); + + di.override(readFileInjectable, () => readFile); + + di.override(writeFileInjectable, () => async (filePath, contents) => { + state.set(filePath, contents); + + return getSuccess(undefined); + }); + + di.override( + pathExistsInjectable, + () => (filePath: string) => Promise.resolve(getSuccess(state.has(filePath))) + ); + + di.override(deleteFileInjectable, () => async (filePath: string) => { + state.delete(filePath); + + return getSuccess(undefined); + }); +}; + +const readFileFor = (state: Map) => (filePath: string) => { + const fileContent = state.get(filePath); + + if (!fileContent) { + const existingFilePaths = [...state.keys()].join('", "'); + + const error = new Error( + `Tried to access file ${filePath} which does not exist. Existing file paths are: "${existingFilePaths}"` + ); + + (error as any).code = "ENOENT"; + + throw error; + } + + return Promise.resolve(getSuccess(fileContent)); +}; diff --git a/packages/utility-features/file-system/src/write-file/write-file.injectable.ts b/packages/utility-features/file-system/src/write-file/write-file.injectable.ts new file mode 100644 index 0000000000..9da9ad813b --- /dev/null +++ b/packages/utility-features/file-system/src/write-file/write-file.injectable.ts @@ -0,0 +1,40 @@ +import { getInjectable, getInjectionToken } from "@ogre-tools/injectable"; +import type { WriteFileOptions } from "fs-extra"; +import fsInjectable from "../fs/fs.injectable"; +import getDirnameOfPathInjectable from "../path/get-dirname.injectable"; +import { AsyncCallSuccess, getSuccess } from "@lensapp/utils"; + +export type WriteFile = ( + filePath: string, + content: string | Buffer, + opts?: WriteFileOptions +) => AsyncCallSuccess; + +export const writeFileInjectionToken = getInjectionToken({ + id: "write-file-injection-token", +}); + +const writeFileInjectable = getInjectable({ + id: "write-file", + + instantiate: (di): WriteFile => { + const { writeFile, ensureDir } = di.inject(fsInjectable); + const getDirnameOfPath = di.inject(getDirnameOfPathInjectable); + + return async (filePath, content) => { + await ensureDir(getDirnameOfPath(filePath), { + mode: 0o755, + }); + + await writeFile(filePath, content, { + encoding: "utf-8", + }); + + return getSuccess(undefined); + }; + }, + + injectionToken: writeFileInjectionToken, +}); + +export default writeFileInjectable; diff --git a/packages/utility-features/file-system/src/write-file/write-file.test.ts b/packages/utility-features/file-system/src/write-file/write-file.test.ts new file mode 100644 index 0000000000..990d181cd3 --- /dev/null +++ b/packages/utility-features/file-system/src/write-file/write-file.test.ts @@ -0,0 +1,85 @@ +import { createContainer } from "@ogre-tools/injectable"; +import { registerFeature } from "@lensapp/feature-core"; +import { fileSystemFeature } from "../feature"; +import asyncFn, { AsyncFnMock } from "@async-fn/jest"; +import fsInjectable from "../fs/fs.injectable"; +import writeFileInjectable, { WriteFile } from "./write-file.injectable"; +import { AsyncCallResult, getSuccess } from "@lensapp/utils"; + +describe("write-file", () => { + let fsWriteFileMock: AsyncFnMock<(filePath: string) => Promise>; + let fsEnsureDirMock: AsyncFnMock<(directoryPath: string) => Promise>; + let writeFile: WriteFile; + + beforeEach(() => { + const di = createContainer("irrelevant"); + + registerFeature(di, fileSystemFeature); + + fsWriteFileMock = asyncFn(); + fsEnsureDirMock = asyncFn(); + + const fsStub = { + writeFile: fsWriteFileMock, + ensureDir: fsEnsureDirMock, + }; + + di.override(fsInjectable, () => fsStub as any); + + writeFile = di.inject(writeFileInjectable); + }); + + describe("when called", () => { + let actualPromise: AsyncCallResult; + + beforeEach(() => { + actualPromise = writeFile("./some-directory/some-other-directory/some-file.js", "some-content"); + }); + + it("makes sure that directory exists", () => { + expect(fsEnsureDirMock).toHaveBeenCalledWith("./some-directory/some-other-directory", { mode: 493 }); + }); + + it("does not write file yet", () => { + expect(fsWriteFileMock).not.toHaveBeenCalled(); + }); + + describe("when ensuring existence of directory is resolves with success", () => { + beforeEach(async () => { + await fsEnsureDirMock.resolve(); + }); + + it("writes file to filesystem", () => { + expect(fsWriteFileMock).toHaveBeenCalledWith( + "./some-directory/some-other-directory/some-file.js", + "some-content", + { encoding: "utf-8" }, + ); + }); + + it("when writing resolves with success, resolves with success", async () => { + await fsWriteFileMock.resolve(); + + const actual = await actualPromise; + + expect(actual).toEqual(getSuccess(undefined)); + }); + + it("when writing rejects with failure, resolves with failure", () => { + const someError = new Error("some-error"); + + fsWriteFileMock.reject(someError); + + return expect(actualPromise).rejects.toBe(someError); + }); + }); + + it("when ensuring existence of directory rejects, rejects with the original error", () => { + const someError = new Error("some-error"); + + fsEnsureDirMock.reject(someError); + + return expect(actualPromise).rejects.toBe(someError); + }); + }); +}); diff --git a/packages/utility-features/file-system/src/write-json-file/write-json-file.injectable.ts b/packages/utility-features/file-system/src/write-json-file/write-json-file.injectable.ts new file mode 100644 index 0000000000..67549480bf --- /dev/null +++ b/packages/utility-features/file-system/src/write-json-file/write-json-file.injectable.ts @@ -0,0 +1,28 @@ +import { getInjectable, getInjectionToken } from "@ogre-tools/injectable"; +import type { JsonValue } from "type-fest"; +import writeFileInjectable from "../write-file/write-file.injectable"; +import type { AsyncCallSuccess } from "@lensapp/utils"; + +export type WriteJsonFile = ( + filePath: string, + contents: JsonValue +) => AsyncCallSuccess; + +export const writeJsonFileInjectionToken = getInjectionToken({ + id: "write-json-file-injection-token", +}); + +const writeJsonFileInjectable = getInjectable({ + id: "write-json-file", + + instantiate: (di): WriteJsonFile => { + const writeFile = di.inject(writeFileInjectable); + + return async (filePath, content) => + writeFile(filePath, JSON.stringify(content, null, 2)); + }, + + injectionToken: writeJsonFileInjectionToken, +}); + +export default writeJsonFileInjectable; diff --git a/packages/utility-features/file-system/src/write-json-file/write-json-file.test.ts b/packages/utility-features/file-system/src/write-json-file/write-json-file.test.ts new file mode 100644 index 0000000000..ce95530e48 --- /dev/null +++ b/packages/utility-features/file-system/src/write-json-file/write-json-file.test.ts @@ -0,0 +1,86 @@ +import { createContainer } from "@ogre-tools/injectable"; +import { registerFeature } from "@lensapp/feature-core"; +import { fileSystemFeature } from "../feature"; +import asyncFn, { AsyncFnMock } from "@async-fn/jest"; +import fsInjectable from "../fs/fs.injectable"; +import { AsyncCallResult, getSuccess } from "@lensapp/utils"; +import type { WriteJsonFile } from "./write-json-file.injectable"; +import writeJsonFileInjectable from "./write-json-file.injectable"; + +describe("write-json-file", () => { + let fsWriteFileMock: AsyncFnMock<(filePath: string) => Promise>; + let fsEnsureDirMock: AsyncFnMock<(directoryPath: string) => Promise>; + let writeJsonFile: WriteJsonFile; + + beforeEach(() => { + const di = createContainer("irrelevant"); + + registerFeature(di, fileSystemFeature); + + fsWriteFileMock = asyncFn(); + fsEnsureDirMock = asyncFn(); + + const fsStub = { + writeFile: fsWriteFileMock, + ensureDir: fsEnsureDirMock, + }; + + di.override(fsInjectable, () => fsStub as any); + + writeJsonFile = di.inject(writeJsonFileInjectable); + }); + + describe("when called", () => { + let actualPromise: AsyncCallResult; + + beforeEach(() => { + actualPromise = writeJsonFile("./some-directory/some-other-directory/some-file.json", { some: "content" }); + }); + + it("makes sure that directory exists", () => { + expect(fsEnsureDirMock).toHaveBeenCalledWith("./some-directory/some-other-directory", { mode: 493 }); + }); + + it("does not write file yet", () => { + expect(fsWriteFileMock).not.toHaveBeenCalled(); + }); + + describe("when ensuring existence of directory is resolves with success", () => { + beforeEach(async () => { + await fsEnsureDirMock.resolve(); + }); + + it("writes file to filesystem", () => { + expect(fsWriteFileMock).toHaveBeenCalledWith( + "./some-directory/some-other-directory/some-file.json", + JSON.stringify({ some: "content" }, null, 2), + { encoding: "utf-8" }, + ); + }); + + it("when writing resolves with success, resolves with success", async () => { + await fsWriteFileMock.resolve(); + + const actual = await actualPromise; + + expect(actual).toEqual(getSuccess(undefined)); + }); + + it("when writing rejects with failure, resolves with failure", () => { + const someError = new Error("some-error"); + + fsWriteFileMock.reject(someError); + + return expect(actualPromise).rejects.toBe(someError); + }); + }); + + it("when ensuring existence of directory rejects, rejects with the original error", () => { + const someError = new Error("some-error"); + + fsEnsureDirMock.reject(someError); + + return expect(actualPromise).rejects.toBe(someError); + }); + }); +}); diff --git a/packages/utility-features/file-system/src/write-yaml-file/write-yaml-file.injectable.ts b/packages/utility-features/file-system/src/write-yaml-file/write-yaml-file.injectable.ts new file mode 100644 index 0000000000..86c5778ba7 --- /dev/null +++ b/packages/utility-features/file-system/src/write-yaml-file/write-yaml-file.injectable.ts @@ -0,0 +1,27 @@ +import { getInjectable, getInjectionToken } from "@ogre-tools/injectable"; +import writeFileInjectable from "../write-file/write-file.injectable"; +import type { AsyncCallSuccess } from "@lensapp/utils"; +import yaml from "js-yaml"; + +export type WriteYamlFile = ( + filePath: string, + contents: object +) => AsyncCallSuccess; + +export const writeYamlFileInjectionToken = getInjectionToken({ + id: "write-yaml-file-injection-token", +}); + +const writeYamlFileInjectable = getInjectable({ + id: "write-yaml-file", + + instantiate: (di): WriteYamlFile => { + const writeFile = di.inject(writeFileInjectable); + + return async (filePath, content) => writeFile(filePath, yaml.dump(content)); + }, + + injectionToken: writeYamlFileInjectionToken, +}); + +export default writeYamlFileInjectable; diff --git a/packages/utility-features/file-system/src/write-yaml-file/write-yaml-file.test.ts b/packages/utility-features/file-system/src/write-yaml-file/write-yaml-file.test.ts new file mode 100644 index 0000000000..af6e553ba3 --- /dev/null +++ b/packages/utility-features/file-system/src/write-yaml-file/write-yaml-file.test.ts @@ -0,0 +1,85 @@ +import { createContainer } from "@ogre-tools/injectable"; +import { registerFeature } from "@lensapp/feature-core"; +import { fileSystemFeature } from "../feature"; +import asyncFn, { AsyncFnMock } from "@async-fn/jest"; +import fsInjectable from "../fs/fs.injectable"; +import { AsyncCallResult, getSuccess } from "@lensapp/utils"; +import writeYamlFileInjectable, { WriteYamlFile } from "./write-yaml-file.injectable"; + +describe("write-yaml-file", () => { + let fsWriteFileMock: AsyncFnMock<(filePath: string) => Promise>; + let fsEnsureDirMock: AsyncFnMock<(directoryPath: string) => Promise>; + let writeYamlFile: WriteYamlFile; + + beforeEach(() => { + const di = createContainer("irrelevant"); + + registerFeature(di, fileSystemFeature); + + fsWriteFileMock = asyncFn(); + fsEnsureDirMock = asyncFn(); + + const fsStub = { + writeFile: fsWriteFileMock, + ensureDir: fsEnsureDirMock, + }; + + di.override(fsInjectable, () => fsStub as any); + + writeYamlFile = di.inject(writeYamlFileInjectable); + }); + + describe("when called", () => { + let actualPromise: AsyncCallResult; + + beforeEach(() => { + actualPromise = writeYamlFile("./some-directory/some-other-directory/some-file.yml", { some: "content" }); + }); + + it("makes sure that directory exists", () => { + expect(fsEnsureDirMock).toHaveBeenCalledWith("./some-directory/some-other-directory", { mode: 493 }); + }); + + it("does not write file yet", () => { + expect(fsWriteFileMock).not.toHaveBeenCalled(); + }); + + describe("when ensuring existence of directory is resolves with success", () => { + beforeEach(async () => { + await fsEnsureDirMock.resolve(); + }); + + it("writes file to filesystem", () => { + expect(fsWriteFileMock).toHaveBeenCalledWith( + "./some-directory/some-other-directory/some-file.yml", + "some: content\n", + { encoding: "utf-8" }, + ); + }); + + it("when writing resolves with success, resolves with success", async () => { + await fsWriteFileMock.resolve(); + + const actual = await actualPromise; + + expect(actual).toEqual(getSuccess(undefined)); + }); + + it("when writing rejects with failure, resolves with failure", () => { + const someError = new Error("some-error"); + + fsWriteFileMock.reject(someError); + + return expect(actualPromise).rejects.toBe(someError); + }); + }); + + it("when ensuring existence of directory rejects, rejects with the original error", () => { + const someError = new Error("some-error"); + + fsEnsureDirMock.reject(someError); + + return expect(actualPromise).rejects.toBe(someError); + }); + }); +}); diff --git a/packages/utility-features/file-system/tsconfig.json b/packages/utility-features/file-system/tsconfig.json new file mode 100644 index 0000000000..1819203dc1 --- /dev/null +++ b/packages/utility-features/file-system/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "@k8slens/typescript/config/base.json", + "include": ["**/*.ts"] +} diff --git a/packages/utility-features/file-system/webpack.config.js b/packages/utility-features/file-system/webpack.config.js new file mode 100644 index 0000000000..3183f30179 --- /dev/null +++ b/packages/utility-features/file-system/webpack.config.js @@ -0,0 +1 @@ +module.exports = require("@k8slens/webpack").configForNode;