diff --git a/package.json b/package.json index 970d615f9..0eca849fe 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,11 @@ "require": "./dist/index.js", "types": "./dist/types/index.d.ts" }, + "./node": { + "import": "./dist/node.mjs", + "require": "./dist/node.js", + "types": "./dist/types/node.d.ts" + }, "./package.json": "./package.json" }, "main": "./dist/index.js", @@ -65,6 +70,7 @@ "@metamask/eslint-config-nodejs": "^12.0.0", "@metamask/eslint-config-typescript": "^12.0.0", "@types/jest": "^28.1.7", + "@types/jest-when": "^3.5.3", "@types/node": "^17.0.23", "@typescript-eslint/eslint-plugin": "^5.43.0", "@typescript-eslint/parser": "^5.43.0", @@ -79,8 +85,10 @@ "eslint-plugin-promise": "^6.1.1", "jest": "^29.2.2", "jest-it-up": "^2.0.2", + "jest-when": "^3.6.0", "prettier": "^2.7.1", "prettier-plugin-packagejson": "^2.3.0", + "rimraf": "^5.0.5", "stdio-mock": "^1.2.0", "ts-jest": "^29.0.3", "ts-node": "^10.7.0", diff --git a/src/fs.test.ts b/src/fs.test.ts new file mode 100644 index 000000000..cdebf69da --- /dev/null +++ b/src/fs.test.ts @@ -0,0 +1,642 @@ +import fs from 'fs'; +import { when } from 'jest-when'; +import os from 'os'; +import path from 'path'; +import util from 'util'; + +import { + createSandbox, + directoryExists, + ensureDirectoryStructureExists, + fileExists, + forceRemove, + readFile, + readJsonFile, + writeFile, + writeJsonFile, +} from './fs'; + +const { withinSandbox } = createSandbox('utils'); + +describe('fs', () => { + describe('readFile', () => { + it('reads the contents of the given file as a UTF-8-encoded string', async () => { + await withinSandbox(async (sandbox) => { + const filePath = path.join(sandbox.directoryPath, 'test.file'); + + await fs.promises.writeFile(filePath, 'some content 😄'); + + expect(await readFile(filePath)).toBe('some content 😄'); + }); + }); + + it('re-throws a wrapped version of any error that occurs, assigning it the same code and giving it a stack', async () => { + await withinSandbox(async (sandbox) => { + const filePath = path.join(sandbox.directoryPath, 'nonexistent.file'); + + await expect(readFile(filePath)).rejects.toThrow( + expect.objectContaining({ + message: `Could not read file '${filePath}'`, + code: 'ENOENT', + stack: expect.any(String), + cause: expect.objectContaining({ + message: `ENOENT: no such file or directory, open '${filePath}'`, + code: 'ENOENT', + }), + }), + ); + }); + }); + }); + + describe('writeFile', () => { + it('writes the given data to the given file', async () => { + await withinSandbox(async (sandbox) => { + const filePath = path.join(sandbox.directoryPath, 'test.file'); + + await writeFile(filePath, 'some content 😄'); + + expect(await fs.promises.readFile(filePath, 'utf8')).toBe( + 'some content 😄', + ); + }); + }); + + it('re-throws a wrapped version of any error that occurs, assigning it the same code and giving it a stack', async () => { + await withinSandbox(async (sandbox) => { + // Make sandbox root directory non-readable + await fs.promises.chmod(sandbox.directoryPath, 0o600); + const filePath = path.join(sandbox.directoryPath, 'test.file'); + + await expect(writeFile(filePath, 'some content 😄')).rejects.toThrow( + expect.objectContaining({ + message: `Could not write file '${filePath}'`, + code: 'EACCES', + stack: expect.any(String), + cause: expect.objectContaining({ + code: 'EACCES', + }), + }), + ); + }); + }); + }); + + describe('readJsonFile', () => { + describe('not given a custom parser', () => { + it('reads the contents of the given file as a UTF-8-encoded string and parses it using the JSON module', async () => { + await withinSandbox(async (sandbox) => { + const filePath = path.join(sandbox.directoryPath, 'test.json'); + + await fs.promises.writeFile(filePath, '{"foo": "bar 😄"}'); + + expect(await readJsonFile(filePath)).toStrictEqual({ foo: 'bar 😄' }); + }); + }); + + it('re-throws a wrapped version of any error that occurs, assigning it the same code and giving it a stack', async () => { + await withinSandbox(async (sandbox) => { + const filePath = path.join(sandbox.directoryPath, 'nonexistent.json'); + + await expect(readJsonFile(filePath)).rejects.toThrow( + expect.objectContaining({ + message: `Could not read JSON file '${filePath}'`, + code: 'ENOENT', + stack: expect.any(String), + cause: expect.objectContaining({ + message: `ENOENT: no such file or directory, open '${filePath}'`, + code: 'ENOENT', + }), + }), + ); + }); + }); + }); + + describe('given a custom parser', () => { + it('reads the contents of the given file as a UTF-8-encoded string and parses it using the custom parser', async () => { + await withinSandbox(async (sandbox) => { + const filePath = path.join(sandbox.directoryPath, 'test.json'); + const parser = { + parse(content: string) { + return { content }; + }, + }; + + await fs.promises.writeFile(filePath, '{"foo": "bar 😄"}'); + + expect(await readJsonFile(filePath, { parser })).toStrictEqual({ + content: '{"foo": "bar 😄"}', + }); + }); + }); + + it('re-throws a wrapped version of any error that occurs, assigning it the same code and giving it a stack', async () => { + await withinSandbox(async (sandbox) => { + const filePath = path.join(sandbox.directoryPath, 'nonexistent.json'); + const parser = { + parse(content: string) { + return { content }; + }, + }; + + await expect(readJsonFile(filePath, { parser })).rejects.toThrow( + expect.objectContaining({ + message: `Could not read JSON file '${filePath}'`, + code: 'ENOENT', + stack: expect.any(String), + cause: expect.objectContaining({ + message: `ENOENT: no such file or directory, open '${filePath}'`, + code: 'ENOENT', + }), + }), + ); + }); + }); + }); + }); + + describe('writeJsonFile', () => { + describe('not given a custom stringifier', () => { + it('writes the given data to the given file as JSON (not reformatting it by default)', async () => { + await withinSandbox(async (sandbox) => { + const filePath = path.join(sandbox.directoryPath, 'test.json'); + + await writeJsonFile(filePath, { foo: 'bar 😄' }); + + expect(await fs.promises.readFile(filePath, 'utf8')).toBe( + '{"foo":"bar 😄"}', + ); + }); + }); + + it('writes the given data to the given file as JSON (not reformatting it if "prettify" is false)', async () => { + await withinSandbox(async (sandbox) => { + const filePath = path.join(sandbox.directoryPath, 'test.json'); + + await writeJsonFile(filePath, { foo: 'bar 😄' }, { prettify: false }); + + expect(await fs.promises.readFile(filePath, 'utf8')).toBe( + '{"foo":"bar 😄"}', + ); + }); + }); + + it('writes the given data to the given file as JSON (reformatting it if "prettify" is true)', async () => { + await withinSandbox(async (sandbox) => { + const filePath = path.join(sandbox.directoryPath, 'test.json'); + + await writeJsonFile(filePath, { foo: 'bar 😄' }, { prettify: true }); + + expect(await fs.promises.readFile(filePath, 'utf8')).toBe( + '{\n "foo": "bar 😄"\n}', + ); + }); + }); + + it('re-throws a wrapped version of any error that occurs, assigning it the same code and giving it a stack', async () => { + await withinSandbox(async (sandbox) => { + // Make sandbox root directory non-readable + await fs.promises.chmod(sandbox.directoryPath, 0o600); + const filePath = path.join(sandbox.directoryPath, 'test.json'); + + await expect( + writeJsonFile(filePath, { foo: 'bar 😄' }), + ).rejects.toThrow( + expect.objectContaining({ + message: `Could not write JSON file '${filePath}'`, + code: 'EACCES', + stack: expect.any(String), + cause: expect.objectContaining({ + code: 'EACCES', + }), + }), + ); + }); + }); + }); + + describe('given a custom stringifier', () => { + it('writes the given data to the given file as JSON, using the stringifier (not reformatting it by default)', async () => { + await withinSandbox(async (sandbox) => { + const filePath = path.join(sandbox.directoryPath, 'test.json'); + const stringifier = { + stringify( + json: any, + replacer?: + | ((this: any, key: string, value: any) => any) + | (number | string)[] + | null, + space?: string, + ) { + return ( + `${util.inspect(json)}\n` + + `replacer: ${util.inspect(replacer)}, space: ${util.inspect( + space, + )}` + ); + }, + }; + + await writeJsonFile(filePath, { foo: 'bar 😄' }, { stringifier }); + + expect(await fs.promises.readFile(filePath, 'utf8')).toBe( + `{ foo: 'bar 😄' }\nreplacer: undefined, space: undefined`, + ); + }); + }); + + it('writes the given data to the given file as JSON (not reformatting it if "prettify" is false)', async () => { + await withinSandbox(async (sandbox) => { + const filePath = path.join(sandbox.directoryPath, 'test.json'); + const stringifier = { + stringify( + json: any, + replacer?: + | ((this: any, key: string, value: any) => any) + | (number | string)[] + | null, + space?: string, + ) { + return ( + `${util.inspect(json)}\n` + + `replacer: ${util.inspect(replacer)}, space: ${util.inspect( + space, + )}` + ); + }, + }; + + await writeJsonFile( + filePath, + { foo: 'bar 😄' }, + { stringifier, prettify: false }, + ); + + expect(await fs.promises.readFile(filePath, 'utf8')).toBe( + `{ foo: 'bar 😄' }\nreplacer: undefined, space: undefined`, + ); + }); + }); + + it('writes the given data to the given file as JSON (reformatting it if "prettify" is true)', async () => { + await withinSandbox(async (sandbox) => { + const filePath = path.join(sandbox.directoryPath, 'test.json'); + const stringifier = { + stringify( + json: any, + replacer?: + | ((this: any, key: string, value: any) => any) + | (number | string)[] + | null, + space?: string, + ) { + return ( + `${util.inspect(json)}\n` + + `replacer: ${util.inspect(replacer)}, space: ${util.inspect( + space, + )}` + ); + }, + }; + + await writeJsonFile( + filePath, + { foo: 'bar 😄' }, + { stringifier, prettify: true }, + ); + + expect(await fs.promises.readFile(filePath, 'utf8')).toBe( + `{ foo: 'bar 😄' }\nreplacer: null, space: ' '`, + ); + }); + }); + + it('re-throws a wrapped version of any error that occurs, assigning it the same code and giving it a stack', async () => { + await withinSandbox(async (sandbox) => { + // Make sandbox root directory non-readable + await fs.promises.chmod(sandbox.directoryPath, 0o600); + const filePath = path.join(sandbox.directoryPath, 'test.json'); + const stringifier = { + stringify( + json: any, + replacer?: + | ((this: any, key: string, value: any) => any) + | (number | string)[] + | null, + space?: string, + ) { + return ( + `${util.inspect(json)}\n` + + `replacer: ${util.inspect(replacer)}, space: ${util.inspect( + space, + )}` + ); + }, + }; + + await expect( + writeJsonFile(filePath, { foo: 'bar 😄' }, { stringifier }), + ).rejects.toThrow( + expect.objectContaining({ + message: `Could not write JSON file '${filePath}'`, + code: 'EACCES', + stack: expect.any(String), + cause: expect.objectContaining({ + code: 'EACCES', + }), + }), + ); + }); + }); + }); + }); + + describe('fileExists', () => { + it('returns true if the given path refers to an existing file', async () => { + await withinSandbox(async (sandbox) => { + const filePath = path.join(sandbox.directoryPath, 'test.file'); + await fs.promises.writeFile(filePath, 'some content'); + + expect(await fileExists(filePath)).toBe(true); + }); + }); + + it('returns false if the given path refers to something that is not a file', async () => { + await withinSandbox(async (sandbox) => { + const directoryPath = path.join( + sandbox.directoryPath, + 'test-directory', + ); + await fs.promises.mkdir(directoryPath); + + expect(await fileExists(directoryPath)).toBe(false); + }); + }); + + it('returns false if the given path does not refer to any existing entry', async () => { + await withinSandbox(async (sandbox) => { + const filePath = path.join(sandbox.directoryPath, 'nonexistent-entry'); + + expect(await fileExists(filePath)).toBe(false); + }); + }); + + it('re-throws a wrapped version of any error that occurs, assigning it the same code and giving it a stack', async () => { + const entryPath = '/some/file'; + const error: any = new Error('oops'); + error.code = 'ESOMETHING'; + error.stack = 'some stack'; + when(jest.spyOn(fs.promises, 'stat')) + .calledWith(entryPath) + .mockRejectedValue(error); + + await expect(fileExists(entryPath)).rejects.toThrow( + expect.objectContaining({ + message: `Could not determine if file exists '${entryPath}'`, + code: 'ESOMETHING', + stack: expect.any(String), + cause: error, + }), + ); + }); + }); + + describe('directoryExists', () => { + it('returns true if the given path refers to an existing directory', async () => { + await withinSandbox(async (sandbox) => { + const directoryPath = path.join( + sandbox.directoryPath, + 'test-directory', + ); + await fs.promises.mkdir(directoryPath); + + expect(await directoryExists(directoryPath)).toBe(true); + }); + }); + + it('returns false if the given path refers to something that is not a directory', async () => { + await withinSandbox(async (sandbox) => { + const filePath = path.join(sandbox.directoryPath, 'test.file'); + await fs.promises.writeFile(filePath, 'some content'); + + expect(await directoryExists(filePath)).toBe(false); + }); + }); + + it('returns false if the given path does not refer to any existing entry', async () => { + await withinSandbox(async (sandbox) => { + const directoryPath = path.join( + sandbox.directoryPath, + 'nonexistent-entry', + ); + + expect(await directoryExists(directoryPath)).toBe(false); + }); + }); + + it('re-throws a wrapped version of any error that occurs, assigning it the same code and giving it a stack', async () => { + const entryPath = '/some/file'; + const error: any = new Error('oops'); + error.code = 'ESOMETHING'; + error.stack = 'some stack'; + when(jest.spyOn(fs.promises, 'stat')) + .calledWith(entryPath) + .mockRejectedValue(error); + + await expect(fileExists(entryPath)).rejects.toThrow( + expect.objectContaining({ + message: `Could not determine if file exists '${entryPath}'`, + cause: error, + stack: expect.any(String), + }), + ); + }); + }); + + describe('ensureDirectoryStructureExists', () => { + it('creates directories leading up to and including the given path', async () => { + await withinSandbox(async (sandbox) => { + const directoryPath = path.join(sandbox.directoryPath, 'a', 'b', 'c'); + + await ensureDirectoryStructureExists(directoryPath); + + // None of the `await`s below should throw. + expect( + await fs.promises.readdir(path.join(sandbox.directoryPath, 'a')), + ).toStrictEqual(expect.anything()); + expect( + await fs.promises.readdir(path.join(sandbox.directoryPath, 'a', 'b')), + ).toStrictEqual(expect.anything()); + expect( + await fs.promises.readdir( + path.join(sandbox.directoryPath, 'a', 'b', 'c'), + ), + ).toStrictEqual(expect.anything()); + }); + }); + + it('does not throw an error, returning undefined, if the given directory already exists', async () => { + await withinSandbox(async (sandbox) => { + const directoryPath = path.join(sandbox.directoryPath, 'a', 'b', 'c'); + await fs.promises.mkdir(path.join(sandbox.directoryPath, 'a')); + await fs.promises.mkdir(path.join(sandbox.directoryPath, 'a', 'b')); + await fs.promises.mkdir( + path.join(sandbox.directoryPath, 'a', 'b', 'c'), + ); + + expect( + await ensureDirectoryStructureExists(directoryPath), + ).toBeUndefined(); + }); + }); + + it('re-throws a wrapped version of any error that occurs, assigning it the same code and giving it a stack', async () => { + await withinSandbox(async (sandbox) => { + // Make sandbox root directory non-readable + await fs.promises.chmod(sandbox.directoryPath, 0o600); + const directoryPath = path.join( + sandbox.directoryPath, + 'test-directory', + ); + + await expect( + ensureDirectoryStructureExists(directoryPath), + ).rejects.toThrow( + expect.objectContaining({ + message: `Could not create directory path '${directoryPath}'`, + code: 'EACCES', + stack: expect.any(String), + cause: expect.objectContaining({ + code: 'EACCES', + }), + }), + ); + }); + }); + }); + + describe('forceRemove', () => { + describe('given a file path', () => { + it('removes the file', async () => { + await withinSandbox(async (sandbox) => { + const filePath = path.join(sandbox.directoryPath, 'test.file'); + await fs.promises.writeFile(filePath, 'some content'); + + expect(await forceRemove(filePath)).toBeUndefined(); + }); + }); + + it('does nothing if the path does not exist', async () => { + await withinSandbox(async (sandbox) => { + const filePath = path.join(sandbox.directoryPath, 'test.file'); + expect(await forceRemove(filePath)).toBeUndefined(); + }); + }); + + it('re-throws a wrapped version of any error that occurs, assigning it the same code and giving it a stack', async () => { + const filePath = '/some/file'; + const error: any = new Error('oops'); + error.code = 'ESOMETHING'; + error.stack = 'some stack'; + when(jest.spyOn(fs.promises, 'rm')) + .calledWith(filePath, { + recursive: true, + force: true, + }) + .mockRejectedValue(error); + + await expect(forceRemove(filePath)).rejects.toThrow( + expect.objectContaining({ + message: `Could not remove file or directory '${filePath}'`, + code: 'ESOMETHING', + stack: expect.any(String), + cause: error, + }), + ); + }); + }); + + describe('given a directory path', () => { + it('removes the directory', async () => { + await withinSandbox(async (sandbox) => { + const directoryPath = path.join( + sandbox.directoryPath, + 'test-directory', + ); + await fs.promises.mkdir(directoryPath); + + expect(await forceRemove(directoryPath)).toBeUndefined(); + }); + }); + + it('does nothing if the path does not exist', async () => { + await withinSandbox(async (sandbox) => { + const directoryPath = path.join( + sandbox.directoryPath, + 'test-directory', + ); + expect(await forceRemove(directoryPath)).toBeUndefined(); + }); + }); + + it('re-throws a wrapped version of any error that occurs, assigning it the same code and giving it a stack', async () => { + const directoryPath = '/some/directory'; + const error: any = new Error('oops'); + error.code = 'ESOMETHING'; + error.stack = 'some stack'; + when(jest.spyOn(fs.promises, 'rm')) + .calledWith(directoryPath, { + recursive: true, + force: true, + }) + .mockRejectedValue(error); + + await expect(forceRemove(directoryPath)).rejects.toThrow( + expect.objectContaining({ + message: `Could not remove file or directory '${directoryPath}'`, + code: 'ESOMETHING', + cause: error, + stack: expect.any(String), + }), + ); + }); + }); + }); + + describe('createSandbox', () => { + it('does not create the sandbox directory immediately', async () => { + createSandbox('utils-fs'); + + const sandboxDirectoryPath = path.join(os.tmpdir(), 'utils-fs'); + + await expect(fs.promises.stat(sandboxDirectoryPath)).rejects.toThrow( + 'ENOENT', + ); + }); + + it('returns an object with a "withinSandbox" function which creates the sandbox directory', async () => { + const { withinSandbox: withinTestSandbox } = createSandbox('utils-fs'); + + await withinTestSandbox(async ({ directoryPath }) => { + expect(await fs.promises.stat(directoryPath)).toStrictEqual( + expect.anything(), + ); + }); + }); + + it('removes the sandbox directory after the function given to "withinSandbox" ends', async () => { + const { withinSandbox: withinTestSandbox } = createSandbox('utils-fs'); + let sandboxDirectoryPath: string; + + await withinTestSandbox(async ({ directoryPath }) => { + sandboxDirectoryPath = directoryPath; + }); + + // @ts-expect-error We can assume sandboxDirectoryPath is defined. + await expect(fs.promises.stat(sandboxDirectoryPath)).rejects.toThrow( + 'ENOENT', + ); + }); + }); +}); diff --git a/src/fs.ts b/src/fs.ts new file mode 100644 index 000000000..5dfd3c15a --- /dev/null +++ b/src/fs.ts @@ -0,0 +1,258 @@ +// This file is intended to be used only in a Node.js context. +/* eslint-disable import/no-nodejs-modules */ + +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { isErrorWithCode, wrapError } from './errors'; +import type { Json } from './json'; + +/** + * Information about the file sandbox provided to tests that need temporary + * access to the filesystem. + */ +export type FileSandbox = { + directoryPath: string; + withinSandbox: ( + test: (args: { directoryPath: string }) => Promise, + ) => Promise; +}; + +/** + * Reads the file at the given path, assuming its content is encoded as UTF-8. + * + * @param filePath - The path to the file. + * @returns The content of the file. + * @throws An error with a stack trace if reading fails in any way. + */ +export async function readFile(filePath: string): Promise { + try { + return await fs.promises.readFile(filePath, 'utf8'); + } catch (error) { + throw wrapError(error, `Could not read file '${filePath}'`); + } +} + +/** + * Writes content to the file at the given path. + * + * @param filePath - The path to the file. + * @param content - The new content of the file. + * @throws An error with a stack trace if writing fails in any way. + */ +export async function writeFile( + filePath: string, + content: string, +): Promise { + try { + await fs.promises.mkdir(path.dirname(filePath), { recursive: true }); + await fs.promises.writeFile(filePath, content); + } catch (error) { + throw wrapError(error, `Could not write file '${filePath}'`); + } +} + +/** + * Reads the assumed JSON file at the given path, attempts to parse it, and + * returns the resulting object. Supports a custom parser (in case you want to + * use the [JSON5](https://www.npmjs.com/package/json5) package instead). + * + * @param filePath - The path segments pointing to the JSON file. Will be passed + * to path.join(). + * @param options - Options to this function. + * @param options.parser - The parser object to use. Defaults to `JSON`. + * @param options.parser.parse - A function that parses JSON data. + * @returns The object corresponding to the parsed JSON file, typed against the + * struct. + * @throws An error with a stack trace if reading fails in any way, or if the + * parsed value is not a plain object. + */ +export async function readJsonFile( + filePath: string, + { + parser = JSON, + }: { + parser?: { parse: typeof JSON.parse }; + } = {}, +): Promise { + try { + const content = await fs.promises.readFile(filePath, 'utf8'); + return parser.parse(content); + } catch (error) { + throw wrapError(error, `Could not read JSON file '${filePath}'`); + } +} + +/** + * Attempts to write the given JSON-like value to the file at the given path. + * Adds a newline to the end of the file. Supports a custom parser (in case you + * want to use the [JSON5](https://www.npmjs.com/package/json5) package + * instead). + * + * @param filePath - The path to write the JSON file to, including the file + * itself. + * @param jsonValue - The JSON-like value to write to the file. Make sure that + * JSON.stringify can handle it. + * @param options - The options to this function. + * @param options.prettify - Whether to format the JSON as it is turned into a + * string such that it is broken up into separate lines (using 2 spaces as + * indentation). + * @param options.stringifier - The stringifier to use. Defaults to `JSON`. + * @param options.stringifier.stringify - A function that stringifies JSON. + * @returns The object corresponding to the parsed JSON file, typed against the + * struct. + * @throws An error with a stack trace if writing fails in any way. + */ +export async function writeJsonFile( + filePath: string, + jsonValue: Json, + { + stringifier = JSON, + prettify = false, + }: { + stringifier?: { + stringify: typeof JSON.stringify; + }; + prettify?: boolean; + } = {}, +): Promise { + try { + await fs.promises.mkdir(path.dirname(filePath), { recursive: true }); + const json = prettify + ? stringifier.stringify(jsonValue, null, ' ') + : stringifier.stringify(jsonValue); + await fs.promises.writeFile(filePath, json); + } catch (error) { + throw wrapError(error, `Could not write JSON file '${filePath}'`); + } +} + +/** + * Tests the given path to determine whether it represents a file. + * + * @param filePath - The path to a (supposed) file on the filesystem. + * @returns A promise for true if the file exists or false otherwise. + * @throws An error with a stack trace if reading fails in any way. + */ +export async function fileExists(filePath: string): Promise { + try { + const stats = await fs.promises.stat(filePath); + return stats.isFile(); + } catch (error) { + if (isErrorWithCode(error) && error.code === 'ENOENT') { + return false; + } + + throw wrapError(error, `Could not determine if file exists '${filePath}'`); + } +} + +/** + * Tests the given path to determine whether it represents a file. + * + * @param directoryPath - The path to a (supposed) directory on the filesystem. + * @returns A promise for true if the file exists or false otherwise. + * @throws An error with a stack trace if reading fails in any way. + */ +export async function directoryExists(directoryPath: string): Promise { + try { + const stats = await fs.promises.stat(directoryPath); + return stats.isDirectory(); + } catch (error) { + if (isErrorWithCode(error) && error.code === 'ENOENT') { + return false; + } + + throw wrapError( + error, + `Could not determine if file exists '${directoryPath}'`, + ); + } +} + +/** + * Creates the given directory along with any directories leading up to the + * directory. If the directory already exists, this is a no-op. + * + * @param directoryPath - The path to the desired directory. + * @returns What `fs.promises.mkdir` returns. + * @throws An error with a stack trace if reading fails in any way. + */ +export async function ensureDirectoryStructureExists( + directoryPath: string, +): Promise { + try { + return await fs.promises.mkdir(directoryPath, { recursive: true }); + } catch (error) { + throw wrapError( + error, + `Could not create directory path '${directoryPath}'`, + ); + } +} + +/** + * Removes the given file or directory, if it exists. + * + * @param entryPath - The path to the file or directory. + * @returns What `fs.promises.rm` returns. + * @throws An error with a stack trace if removal fails in any way. + */ +export async function forceRemove(entryPath: string): Promise { + try { + return await fs.promises.rm(entryPath, { + recursive: true, + force: true, + }); + } catch (error) { + throw wrapError(error, `Could not remove file or directory '${entryPath}'`); + } +} + +/** + * Constructs a sandbox object which can be used in tests that need temporary + * access to the filesystem. + * + * @param projectName - The name of the project. + * @returns The sandbox object. This contains a `withinSandbox` function which + * can be used in tests (see example). + * @example + * ```typescript + * const { withinSandbox } = createSandbox('utils'); + * + * // ... later ... + * + * it('does something with the filesystem', async () => { + * await withinSandbox(async ({ directoryPath }) => { + * await fs.promises.writeFile( + * path.join(directoryPath, 'some-file'), + * 'some content', + * 'utf8' + * ); + * }) + * }); + * ``` + */ +export function createSandbox(projectName: string): FileSandbox { + const directoryPath = path.join(os.tmpdir(), projectName); + + return { + directoryPath, + async withinSandbox( + test: (args: { directoryPath: string }) => Promise, + ) { + if (await directoryExists(directoryPath)) { + throw new Error(`${directoryPath} already exists, cannot continue`); + } + + await ensureDirectoryStructureExists(directoryPath); + + try { + await test({ directoryPath }); + } finally { + await forceRemove(directoryPath); + } + }, + }; +} diff --git a/src/node.ts b/src/node.ts new file mode 100644 index 000000000..c81d25a1f --- /dev/null +++ b/src/node.ts @@ -0,0 +1,2 @@ +export * from '.'; +export * from './fs'; diff --git a/yarn.lock b/yarn.lock index 29bd2552e..a2fadb98c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -907,6 +907,15 @@ __metadata: languageName: node linkType: hard +"@jest/expect-utils@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/expect-utils@npm:29.7.0" + dependencies: + jest-get-type: ^29.6.3 + checksum: 75eb177f3d00b6331bcaa057e07c0ccb0733a1d0a1943e1d8db346779039cb7f103789f16e502f888a3096fb58c2300c38d1f3748b36a7fa762eb6f6d1b160ed + languageName: node + linkType: hard + "@jest/expect@npm:^29.2.2": version: 29.2.2 resolution: "@jest/expect@npm:29.2.2" @@ -998,6 +1007,15 @@ __metadata: languageName: node linkType: hard +"@jest/schemas@npm:^29.6.3": + version: 29.6.3 + resolution: "@jest/schemas@npm:29.6.3" + dependencies: + "@sinclair/typebox": ^0.27.8 + checksum: 910040425f0fc93cd13e68c750b7885590b8839066dfa0cd78e7def07bbb708ad869381f725945d66f2284de5663bbecf63e8fdd856e2ae6e261ba30b1687e93 + languageName: node + linkType: hard + "@jest/source-map@npm:^29.2.0": version: 29.2.0 resolution: "@jest/source-map@npm:29.2.0" @@ -1084,6 +1102,20 @@ __metadata: languageName: node linkType: hard +"@jest/types@npm:^29.6.3": + version: 29.6.3 + resolution: "@jest/types@npm:29.6.3" + dependencies: + "@jest/schemas": ^29.6.3 + "@types/istanbul-lib-coverage": ^2.0.0 + "@types/istanbul-reports": ^3.0.0 + "@types/node": "*" + "@types/yargs": ^17.0.8 + chalk: ^4.0.0 + checksum: a0bcf15dbb0eca6bdd8ce61a3fb055349d40268622a7670a3b2eb3c3dbafe9eb26af59938366d520b86907b9505b0f9b29b85cec11579a9e580694b87cd90fcc + languageName: node + linkType: hard + "@jridgewell/gen-mapping@npm:^0.1.0": version: 0.1.1 resolution: "@jridgewell/gen-mapping@npm:0.1.1" @@ -1260,6 +1292,7 @@ __metadata: "@scure/base": ^1.1.3 "@types/debug": ^4.1.7 "@types/jest": ^28.1.7 + "@types/jest-when": ^3.5.3 "@types/node": ^17.0.23 "@typescript-eslint/eslint-plugin": ^5.43.0 "@typescript-eslint/parser": ^5.43.0 @@ -1275,9 +1308,11 @@ __metadata: eslint-plugin-promise: ^6.1.1 jest: ^29.2.2 jest-it-up: ^2.0.2 + jest-when: ^3.6.0 pony-cause: ^2.1.10 prettier: ^2.7.1 prettier-plugin-packagejson: ^2.3.0 + rimraf: ^5.0.5 semver: ^7.5.4 stdio-mock: ^1.2.0 superstruct: ^1.0.3 @@ -1447,6 +1482,13 @@ __metadata: languageName: node linkType: hard +"@sinclair/typebox@npm:^0.27.8": + version: 0.27.8 + resolution: "@sinclair/typebox@npm:0.27.8" + checksum: 00bd7362a3439021aa1ea51b0e0d0a0e8ca1351a3d54c606b115fdcc49b51b16db6e5f43b4fe7a28c38688523e22a94d49dd31168868b655f0d4d50f032d07a1 + languageName: node + linkType: hard + "@sinonjs/commons@npm:^1.7.0": version: 1.8.3 resolution: "@sinonjs/commons@npm:1.8.3" @@ -1608,6 +1650,25 @@ __metadata: languageName: node linkType: hard +"@types/jest-when@npm:^3.5.3": + version: 3.5.3 + resolution: "@types/jest-when@npm:3.5.3" + dependencies: + "@types/jest": "*" + checksum: 5dc2661ad570b80b8f97d7dabc50a0229f21818eefc73024b88a70216c2666f56bd97769fef565263d7242878f8187531bb14427dcb06709e9b98a1671668e9d + languageName: node + linkType: hard + +"@types/jest@npm:*": + version: 29.5.5 + resolution: "@types/jest@npm:29.5.5" + dependencies: + expect: ^29.0.0 + pretty-format: ^29.0.0 + checksum: 56e55cde9949bcc0ee2fa34ce5b7c32c2bfb20e53424aa4ff3a210859eeaaa3fdf6f42f81a3f655238039cdaaaf108b054b7a8602f394e6c52b903659338d8c6 + languageName: node + linkType: hard + "@types/jest@npm:^28.1.7": version: 28.1.8 resolution: "@types/jest@npm:28.1.8" @@ -2918,6 +2979,13 @@ __metadata: languageName: node linkType: hard +"diff-sequences@npm:^29.6.3": + version: 29.6.3 + resolution: "diff-sequences@npm:29.6.3" + checksum: f4914158e1f2276343d98ff5b31fc004e7304f5470bf0f1adb2ac6955d85a531a6458d33e87667f98f6ae52ebd3891bb47d420bb48a5bd8b7a27ee25b20e33aa + languageName: node + linkType: hard + "diff@npm:^4.0.1": version: 4.0.2 resolution: "diff@npm:4.0.2" @@ -3604,6 +3672,19 @@ __metadata: languageName: node linkType: hard +"expect@npm:^29.0.0": + version: 29.7.0 + resolution: "expect@npm:29.7.0" + dependencies: + "@jest/expect-utils": ^29.7.0 + jest-get-type: ^29.6.3 + jest-matcher-utils: ^29.7.0 + jest-message-util: ^29.7.0 + jest-util: ^29.7.0 + checksum: 9257f10288e149b81254a0fda8ffe8d54a7061cd61d7515779998b012579d2b8c22354b0eb901daf0145f347403da582f75f359f4810c007182ad3fb318b5c0c + languageName: node + linkType: hard + "expect@npm:^29.2.2": version: 29.2.2 resolution: "expect@npm:29.2.2" @@ -3947,6 +4028,21 @@ __metadata: languageName: node linkType: hard +"glob@npm:^10.3.7": + version: 10.3.10 + resolution: "glob@npm:10.3.10" + dependencies: + foreground-child: ^3.1.0 + jackspeak: ^2.3.5 + minimatch: ^9.0.1 + minipass: ^5.0.0 || ^6.0.2 || ^7.0.0 + path-scurry: ^1.10.1 + bin: + glob: dist/esm/bin.mjs + checksum: 4f2fe2511e157b5a3f525a54092169a5f92405f24d2aed3142f4411df328baca13059f4182f1db1bf933e2c69c0bd89e57ae87edd8950cba8c7ccbe84f721cf3 + languageName: node + linkType: hard + "glob@npm:^7.1.3, glob@npm:^7.1.4": version: 7.2.3 resolution: "glob@npm:7.2.3" @@ -4648,6 +4744,19 @@ __metadata: languageName: node linkType: hard +"jackspeak@npm:^2.3.5": + version: 2.3.6 + resolution: "jackspeak@npm:2.3.6" + dependencies: + "@isaacs/cliui": ^8.0.2 + "@pkgjs/parseargs": ^0.11.0 + dependenciesMeta: + "@pkgjs/parseargs": + optional: true + checksum: 57d43ad11eadc98cdfe7496612f6bbb5255ea69fe51ea431162db302c2a11011642f50cfad57288bd0aea78384a0612b16e131944ad8ecd09d619041c8531b54 + languageName: node + linkType: hard + "jest-changed-files@npm:^29.2.0": version: 29.2.0 resolution: "jest-changed-files@npm:29.2.0" @@ -4774,6 +4883,18 @@ __metadata: languageName: node linkType: hard +"jest-diff@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-diff@npm:29.7.0" + dependencies: + chalk: ^4.0.0 + diff-sequences: ^29.6.3 + jest-get-type: ^29.6.3 + pretty-format: ^29.7.0 + checksum: 08e24a9dd43bfba1ef07a6374e5af138f53137b79ec3d5cc71a2303515335898888fa5409959172e1e05de966c9e714368d15e8994b0af7441f0721ee8e1bb77 + languageName: node + linkType: hard + "jest-docblock@npm:^29.2.0": version: 29.2.0 resolution: "jest-docblock@npm:29.2.0" @@ -4824,6 +4945,13 @@ __metadata: languageName: node linkType: hard +"jest-get-type@npm:^29.6.3": + version: 29.6.3 + resolution: "jest-get-type@npm:29.6.3" + checksum: 88ac9102d4679d768accae29f1e75f592b760b44277df288ad76ce5bf038c3f5ce3719dea8aa0f035dac30e9eb034b848ce716b9183ad7cc222d029f03e92205 + languageName: node + linkType: hard + "jest-haste-map@npm:^29.2.1": version: 29.2.1 resolution: "jest-haste-map@npm:29.2.1" @@ -4894,6 +5022,18 @@ __metadata: languageName: node linkType: hard +"jest-matcher-utils@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-matcher-utils@npm:29.7.0" + dependencies: + chalk: ^4.0.0 + jest-diff: ^29.7.0 + jest-get-type: ^29.6.3 + pretty-format: ^29.7.0 + checksum: d7259e5f995d915e8a37a8fd494cb7d6af24cd2a287b200f831717ba0d015190375f9f5dc35393b8ba2aae9b2ebd60984635269c7f8cff7d85b077543b7744cd + languageName: node + linkType: hard + "jest-message-util@npm:^28.1.3": version: 28.1.3 resolution: "jest-message-util@npm:28.1.3" @@ -4928,6 +5068,23 @@ __metadata: languageName: node linkType: hard +"jest-message-util@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-message-util@npm:29.7.0" + dependencies: + "@babel/code-frame": ^7.12.13 + "@jest/types": ^29.6.3 + "@types/stack-utils": ^2.0.0 + chalk: ^4.0.0 + graceful-fs: ^4.2.9 + micromatch: ^4.0.4 + pretty-format: ^29.7.0 + slash: ^3.0.0 + stack-utils: ^2.0.3 + checksum: a9d025b1c6726a2ff17d54cc694de088b0489456c69106be6b615db7a51b7beb66788bea7a59991a019d924fbf20f67d085a445aedb9a4d6760363f4d7d09930 + languageName: node + linkType: hard + "jest-mock@npm:^29.2.2": version: 29.2.2 resolution: "jest-mock@npm:29.2.2" @@ -5104,6 +5261,20 @@ __metadata: languageName: node linkType: hard +"jest-util@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-util@npm:29.7.0" + dependencies: + "@jest/types": ^29.6.3 + "@types/node": "*" + chalk: ^4.0.0 + ci-info: ^3.2.0 + graceful-fs: ^4.2.9 + picomatch: ^2.2.3 + checksum: 042ab4980f4ccd4d50226e01e5c7376a8556b472442ca6091a8f102488c0f22e6e8b89ea874111d2328a2080083bf3225c86f3788c52af0bd0345a00eb57a3ca + languageName: node + linkType: hard + "jest-validate@npm:^29.2.2": version: 29.2.2 resolution: "jest-validate@npm:29.2.2" @@ -5134,6 +5305,15 @@ __metadata: languageName: node linkType: hard +"jest-when@npm:^3.6.0": + version: 3.6.0 + resolution: "jest-when@npm:3.6.0" + peerDependencies: + jest: ">= 25" + checksum: ed32ed84e5802bb6fec98966cdf862ce59677551be33d3795e6ac7a6207acf467ed559573d671c8d94c59f7fc0780cf358d2d165d81cdc7d9611250d975ee024 + languageName: node + linkType: hard + "jest-worker@npm:^29.2.1": version: 29.2.1 resolution: "jest-worker@npm:29.2.1" @@ -6305,6 +6485,17 @@ __metadata: languageName: node linkType: hard +"pretty-format@npm:^29.0.0, pretty-format@npm:^29.7.0": + version: 29.7.0 + resolution: "pretty-format@npm:29.7.0" + dependencies: + "@jest/schemas": ^29.6.3 + ansi-styles: ^5.0.0 + react-is: ^18.0.0 + checksum: 032c1602383e71e9c0c02a01bbd25d6759d60e9c7cf21937dde8357aa753da348fcec5def5d1002c9678a8524d5fe099ad98861286550ef44de8808cc61e43b6 + languageName: node + linkType: hard + "pretty-format@npm:^29.2.1": version: 29.2.1 resolution: "pretty-format@npm:29.2.1" @@ -6564,6 +6755,17 @@ __metadata: languageName: node linkType: hard +"rimraf@npm:^5.0.5": + version: 5.0.5 + resolution: "rimraf@npm:5.0.5" + dependencies: + glob: ^10.3.7 + bin: + rimraf: dist/esm/bin.mjs + checksum: d66eef829b2e23b16445f34e73d75c7b7cf4cbc8834b04720def1c8f298eb0753c3d76df77325fad79d0a2c60470525d95f89c2475283ad985fd7441c32732d1 + languageName: node + linkType: hard + "rollup@npm:^3.2.5": version: 3.29.4 resolution: "rollup@npm:3.29.4"