alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
523 lines (463 loc) • 15.1 kB
text/typescript
import { basename as nodeBasename, join as nodeJoin } from "node:path";
import { $inject, AlephaError, type FileLike, Json } from "alepha";
import type {
CpOptions,
CreateFileOptions,
FileSystemProvider,
LsOptions,
MkdirOptions,
RmOptions,
} from "./FileSystemProvider.ts";
// ---------------------------------------------------------------------------------------------------------------------
export interface MemoryFileSystemProviderOptions {
/**
* Error to throw on mkdir operations (for testing error handling)
*/
mkdirError?: Error | null;
/**
* Error to throw on writeFile operations (for testing error handling)
*/
writeFileError?: Error | null;
/**
* Error to throw on readFile operations (for testing error handling)
*/
readFileError?: Error | null;
}
// ---------------------------------------------------------------------------------------------------------------------
/**
* In-memory implementation of FileSystemProvider for testing.
*
* This provider stores all files and directories in memory, making it ideal for
* unit tests that need to verify file operations without touching the real file system.
*
* @example
* ```typescript
* // In tests, substitute the real FileSystemProvider with MemoryFileSystemProvider
* const alepha = Alepha.create().with({
* provide: FileSystemProvider,
* use: MemoryFileSystemProvider,
* });
*
* // Run code that uses FileSystemProvider
* const service = alepha.inject(MyService);
* await service.saveFile("test.txt", "Hello World");
*
* // Verify the file was written
* const memoryFs = alepha.inject(MemoryFileSystemProvider);
* expect(memoryFs.files.get("test.txt")?.toString()).toBe("Hello World");
* ```
*/
export class MemoryFileSystemProvider implements FileSystemProvider {
protected json = $inject(Json);
/**
* In-memory storage for files (path -> content)
*/
public files = new Map<string, Buffer>();
/**
* In-memory storage for directories
*/
public directories = new Set<string>();
/**
* Track mkdir calls for test assertions
*/
public mkdirCalls: Array<{ path: string; options?: MkdirOptions }> = [];
/**
* Track writeFile calls for test assertions
*/
public writeFileCalls: Array<{ path: string; data: string }> = [];
/**
* Track readFile calls for test assertions
*/
public readFileCalls: Array<string> = [];
/**
* Track rm calls for test assertions
*/
public rmCalls: Array<{ path: string; options?: RmOptions }> = [];
/**
* Track join calls for test assertions
*/
public joinCalls: Array<string[]> = [];
/**
* Error to throw on mkdir (for testing error handling)
*/
public mkdirError: Error | null = null;
/**
* Error to throw on writeFile (for testing error handling)
*/
public writeFileError: Error | null = null;
/**
* Error to throw on readFile (for testing error handling)
*/
public readFileError: Error | null = null;
constructor(options: MemoryFileSystemProviderOptions = {}) {
this.mkdirError = options.mkdirError ?? null;
this.writeFileError = options.writeFileError ?? null;
this.readFileError = options.readFileError ?? null;
}
/**
* Join path segments using forward slashes.
* Uses Node's path.join for proper normalization (handles .. and .)
*/
public join(...paths: string[]): string {
this.joinCalls.push(paths);
return nodeJoin(...paths);
}
/**
* Normalize path separators to forward slashes for consistent internal storage.
* This ensures Windows paths work correctly in the in-memory file system.
*/
protected normalizePath(path: string): string {
return path.replace(/\\/g, "/");
}
/**
* Create a FileLike object from various sources.
*/
public createFile(options: CreateFileOptions): FileLike {
if ("path" in options) {
const filePath = options.path;
const buffer = this.files.get(filePath);
if (buffer === undefined) {
throw new AlephaError(
`ENOENT: no such file or directory, open '${filePath}'`,
);
}
return {
name: options.name ?? nodeBasename(filePath),
type: options.type ?? "application/octet-stream",
size: buffer.byteLength,
lastModified: Date.now(),
stream: () => {
throw new AlephaError(
"Stream not implemented in MemoryFileSystemProvider",
);
},
arrayBuffer: async (): Promise<ArrayBuffer> =>
buffer.buffer.slice(
buffer.byteOffset,
buffer.byteOffset + buffer.byteLength,
) as ArrayBuffer,
text: async () => buffer.toString("utf-8"),
};
}
if ("buffer" in options) {
const buffer = options.buffer;
return {
name: options.name ?? "file",
type: options.type ?? "application/octet-stream",
size: buffer.byteLength,
lastModified: Date.now(),
stream: () => {
throw new AlephaError(
"Stream not implemented in MemoryFileSystemProvider",
);
},
arrayBuffer: async (): Promise<ArrayBuffer> =>
buffer.buffer.slice(
buffer.byteOffset,
buffer.byteOffset + buffer.byteLength,
) as ArrayBuffer,
text: async () => buffer.toString("utf-8"),
};
}
if ("text" in options) {
const buffer = Buffer.from(options.text, "utf-8");
return {
name: options.name ?? "file.txt",
type: options.type ?? "text/plain",
size: buffer.byteLength,
lastModified: Date.now(),
stream: () => {
throw new AlephaError(
"Stream not implemented in MemoryFileSystemProvider",
);
},
arrayBuffer: async (): Promise<ArrayBuffer> =>
buffer.buffer.slice(
buffer.byteOffset,
buffer.byteOffset + buffer.byteLength,
) as ArrayBuffer,
text: async () => options.text,
};
}
throw new AlephaError(
"MemoryFileSystemProvider.createFile: unsupported options. Only buffer and text are supported.",
);
}
/**
* Remove a file or directory from memory.
*/
public async rm(path: string, options?: RmOptions): Promise<void> {
this.rmCalls.push({ path, options });
const exists = this.files.has(path) || this.directories.has(path);
if (!exists && !options?.force) {
throw new AlephaError(`ENOENT: no such file or directory, rm '${path}'`);
}
if (this.directories.has(path)) {
if (options?.recursive) {
// Remove directory and all contents
this.directories.delete(path);
for (const filePath of this.files.keys()) {
if (filePath.startsWith(`${path}/`)) {
this.files.delete(filePath);
}
}
for (const dirPath of this.directories) {
if (dirPath.startsWith(`${path}/`)) {
this.directories.delete(dirPath);
}
}
} else {
throw new AlephaError(
`EISDIR: illegal operation on a directory, rm '${path}'`,
);
}
} else {
this.files.delete(path);
}
}
/**
* Copy a file or directory in memory.
*/
public async cp(
src: string,
dest: string,
options?: CpOptions,
): Promise<void> {
if (this.directories.has(src)) {
this.directories.add(dest);
for (const [filePath, content] of this.files) {
if (filePath.startsWith(`${src}/`)) {
const newPath = filePath.replace(src, dest);
this.files.set(newPath, Buffer.from(content));
}
}
} else if (this.files.has(src)) {
const content = this.files.get(src)!;
this.files.set(dest, Buffer.from(content));
} else {
throw new AlephaError(`ENOENT: no such file or directory, cp '${src}'`);
}
}
/**
* Move/rename a file or directory in memory.
*/
public async mv(src: string, dest: string): Promise<void> {
if (this.directories.has(src)) {
// Move directory and contents
this.directories.delete(src);
this.directories.add(dest);
for (const [filePath, content] of this.files) {
if (filePath.startsWith(`${src}/`)) {
const newPath = filePath.replace(src, dest);
this.files.delete(filePath);
this.files.set(newPath, content);
}
}
} else if (this.files.has(src)) {
const content = this.files.get(src)!;
this.files.delete(src);
this.files.set(dest, content);
} else {
throw new AlephaError(`ENOENT: no such file or directory, mv '${src}'`);
}
}
/**
* Create a directory in memory.
*/
public async mkdir(path: string, options?: MkdirOptions): Promise<void> {
this.mkdirCalls.push({ path, options });
if (this.mkdirError) {
throw this.mkdirError;
}
const normalizedPath = this.normalizePath(path);
if (this.directories.has(normalizedPath) && !options?.recursive) {
throw new AlephaError(`EEXIST: file already exists, mkdir '${path}'`);
}
this.directories.add(normalizedPath);
// If recursive, create parent directories
if (options?.recursive) {
const parts = normalizedPath.split("/").filter(Boolean);
let current = "";
for (const part of parts) {
current = current ? `${current}/${part}` : part;
this.directories.add(current);
}
}
}
/**
* List files in a directory.
*/
public async ls(path: string, options?: LsOptions): Promise<string[]> {
const normalizedPath = this.normalizePath(path).replace(/\/$/, "");
const entries = new Set<string>();
// Find files in the directory
for (const filePath of this.files.keys()) {
const normalizedFilePath = this.normalizePath(filePath);
if (normalizedFilePath.startsWith(`${normalizedPath}/`)) {
const relativePath = normalizedFilePath.slice(
normalizedPath.length + 1,
);
const parts = relativePath.split("/");
if (options?.recursive) {
entries.add(relativePath);
} else {
entries.add(parts[0]);
}
}
}
// Find subdirectories
for (const dirPath of this.directories) {
const normalizedDirPath = this.normalizePath(dirPath);
if (
normalizedDirPath.startsWith(`${normalizedPath}/`) &&
normalizedDirPath !== normalizedPath
) {
const relativePath = normalizedDirPath.slice(normalizedPath.length + 1);
const parts = relativePath.split("/");
if (options?.recursive) {
entries.add(relativePath);
} else if (parts.length === 1) {
entries.add(parts[0]);
}
}
}
let result = Array.from(entries);
// Filter hidden files unless requested
if (!options?.hidden) {
result = result.filter((entry) => !entry.startsWith("."));
}
return result.sort();
}
/**
* Check if a file or directory exists in memory.
*/
public async exists(path: string): Promise<boolean> {
return this.files.has(path) || this.directories.has(path);
}
/**
* Read a file from memory.
*/
public async readFile(path: string): Promise<Buffer> {
this.readFileCalls.push(path);
if (this.readFileError) {
throw this.readFileError;
}
const content = this.files.get(path);
if (!content) {
throw new AlephaError(
`ENOENT: no such file or directory, open '${path}'`,
);
}
return content;
}
/**
* Read a file from memory as text.
*/
public async readTextFile(path: string): Promise<string> {
const buffer = await this.readFile(path);
return buffer.toString("utf-8");
}
/**
* Read a file from memory as JSON.
*/
public async readJsonFile<T = unknown>(path: string): Promise<T> {
const text = await this.readTextFile(path);
return this.json.parse(text) as T;
}
/**
* Write a file to memory.
*/
public async writeFile(
path: string,
data: Uint8Array | Buffer | string | FileLike,
): Promise<void> {
const dataStr =
typeof data === "string"
? data
: data instanceof Buffer || data instanceof Uint8Array
? data.toString("utf-8")
: await data.text();
this.writeFileCalls.push({ path, data: dataStr });
if (this.writeFileError) {
throw this.writeFileError;
}
const buffer =
typeof data === "string"
? Buffer.from(data, "utf-8")
: data instanceof Buffer
? data
: data instanceof Uint8Array
? Buffer.from(data)
: Buffer.from(await data.text(), "utf-8");
this.files.set(path, buffer);
}
/**
* Reset all in-memory state (useful between tests).
*/
public reset(): void {
this.files.clear();
this.directories.clear();
this.mkdirCalls = [];
this.writeFileCalls = [];
this.readFileCalls = [];
this.rmCalls = [];
this.joinCalls = [];
this.mkdirError = null;
this.writeFileError = null;
this.readFileError = null;
}
// ─────────────────────────────────────────────────────────────────────────────
// Test assertion helpers
// ─────────────────────────────────────────────────────────────────────────────
/**
* Check if a file was written during the test.
*
* @example
* ```typescript
* expect(fs.wasWritten("/project/tsconfig.json")).toBe(true);
* ```
*/
public wasWritten(path: string): boolean {
return this.writeFileCalls.some((call) => call.path === path);
}
/**
* Check if a file was written with content matching a pattern.
*
* @example
* ```typescript
* expect(fs.wasWrittenMatching("/project/tsconfig.json", /extends/)).toBe(true);
* ```
*/
public wasWrittenMatching(path: string, pattern: RegExp): boolean {
const call = this.writeFileCalls.find((c) => c.path === path);
return call ? pattern.test(call.data) : false;
}
/**
* Check if a file was read during the test.
*
* @example
* ```typescript
* expect(fs.wasRead("/project/package.json")).toBe(true);
* ```
*/
public wasRead(path: string): boolean {
return this.readFileCalls.includes(path);
}
/**
* Check if a file was deleted during the test.
*
* @example
* ```typescript
* expect(fs.wasDeleted("/project/old-file.txt")).toBe(true);
* ```
*/
public wasDeleted(path: string): boolean {
return this.rmCalls.some((call) => call.path === path);
}
/**
* Get the content of a file as a string (convenience method for testing).
*/
public getFileContent(path: string): string | undefined {
return this.files.get(path)?.toString("utf-8");
}
}