Skip to content

Commit

Permalink
Add RepositoryFilesystem (#21)
Browse files Browse the repository at this point in the history
When linting a project, we need to constantly read the files within
either the module template or the project. We want this tool to run as
fast as possible, and to do this, we can cache the metadata and content
of each file that we read so that we don't have to do it again. This
class assists with that.

---

Co-authored-by: Mark Stacey <markjstacey@gmail.com>
  • Loading branch information
mcmire and Gudahtt authored Nov 9, 2023
1 parent d4975fb commit dd455ba
Show file tree
Hide file tree
Showing 8 changed files with 571 additions and 1 deletion.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"test:watch": "jest --watch"
},
"dependencies": {
"@metamask/utils": "^8.2.0",
"execa": "^5.1.1"
},
"devDependencies": {
Expand Down
64 changes: 64 additions & 0 deletions src/misc-utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { writeFile } from '@metamask/utils/node';
import fs from 'fs';
import path from 'path';

import { getEntryStats } from './misc-utils';
import { withinSandbox } from '../tests/helpers';

describe('getEntryStats', () => {
describe('given a file', () => {
it('returns the stats for the file', async () => {
expect.assertions(3);

await withinSandbox(async (sandbox) => {
const filePath = path.join(sandbox.directoryPath, 'nonexistent.file');
await writeFile(filePath, '');

const stats = await getEntryStats(filePath);

expect(stats).toHaveProperty('atime');
expect(stats).toHaveProperty('ctime');
expect(stats).toHaveProperty('mtime');
});
});

it('returns null if the file does not exist', async () => {
expect.assertions(1);

await withinSandbox(async (sandbox) => {
const filePath = path.join(sandbox.directoryPath, 'nonexistent.file');

expect(await getEntryStats(filePath)).toBeNull();
});
});

it('re-throws a wrapped version of any other error that occurs, assigning it the same code and giving it a stack', async () => {
expect.assertions(1);

await withinSandbox(async (sandbox) => {
const filePath = path.join(sandbox.directoryPath, 'nonexistent.file');
await writeFile(filePath, '');
try {
// Make sandbox root directory non-executable.
await fs.promises.chmod(sandbox.directoryPath, 0o600);

await expect(getEntryStats(filePath)).rejects.toThrow(
expect.objectContaining({
message: `Could not get stats for file or directory '${filePath}'`,
code: 'EACCES',
stack: expect.any(String),
cause: expect.objectContaining({
message: `EACCES: permission denied, stat '${filePath}'`,
code: 'EACCES',
}),
}),
);
} finally {
// Make sandbox root directory executable again.
// Ideally, this should be handled by @metamask/utils.
await fs.promises.chmod(sandbox.directoryPath, 0o700);
}
});
});
});
});
27 changes: 27 additions & 0 deletions src/misc-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { isErrorWithCode, wrapError } from '@metamask/utils/node';
import type fs from 'fs';
import * as fsPromises from 'fs/promises';

/**
* Retrieves information about the file or directory using `fs.stat`.
*
* @param entryPath - The path to the file or directory.
* @returns The stats for the file or directory if it exists, or null if it does
* not exist.
* @throws An error with a stack trace if reading fails in any way.
*/
export async function getEntryStats(
entryPath: string,
): Promise<fs.Stats | null> {
try {
return await fsPromises.stat(entryPath);
} catch (error) {
if (isErrorWithCode(error) && error.code === 'ENOENT') {
return null;
}
throw wrapError(
error,
`Could not get stats for file or directory '${entryPath}'`,
);
}
}
233 changes: 233 additions & 0 deletions src/repository-filesystem.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
import * as utils from '@metamask/utils/node';
import {
ensureDirectoryStructureExists,
writeFile,
} from '@metamask/utils/node';
import fs from 'fs';
import { mock } from 'jest-mock-extended';
import path from 'path';

import { RepositoryFilesystem } from './repository-filesystem';
import { withinSandbox } from '../tests/helpers';

jest.mock('@metamask/utils/node', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
__esModule: true,
...jest.requireActual('@metamask/utils/node'),
};
});

const utilsMock = jest.mocked(utils);

describe('RepositoryFilesystem', () => {
describe('readFile', () => {
describe('if the file has not already been read', () => {
it('reads the file from the repository directory', async () => {
jest.spyOn(utilsMock, 'readFile').mockResolvedValue('some content');
const repositoryFilesystem = new RepositoryFilesystem(
'/some/directory',
);

await repositoryFilesystem.readFile('some.file');

expect(utilsMock.readFile).toHaveBeenCalledWith(
'/some/directory/some.file',
);
});

it('returns the content of the file, with extra whitespace trimmed', async () => {
await withinSandbox(async ({ directoryPath: sandboxDirectoryPath }) => {
await writeFile(
path.join(sandboxDirectoryPath, 'some.file'),
' some content ',
);
const repositoryFilesystem = new RepositoryFilesystem(
sandboxDirectoryPath,
);

const content = await repositoryFilesystem.readFile('some.file');

expect(content).toBe('some content');
});
});
});

describe('if the file has already been read', () => {
it('does not read the file from the repository directory again', async () => {
jest.spyOn(utilsMock, 'readFile').mockResolvedValue('some content');
const repositoryFilesystem = new RepositoryFilesystem(
'/some/directory',
);
await repositoryFilesystem.readFile('/some/file');

await repositoryFilesystem.readFile('/some/file');

expect(utilsMock.readFile).toHaveBeenCalledTimes(1);
});

it('returns the content of the file, with extra whitespace trimmed', async () => {
await withinSandbox(async ({ directoryPath: sandboxDirectoryPath }) => {
await writeFile(
path.join(sandboxDirectoryPath, 'some.file'),
' some content ',
);
const repositoryFilesystem = new RepositoryFilesystem(
sandboxDirectoryPath,
);
await repositoryFilesystem.readFile('some.file');

const content = await repositoryFilesystem.readFile('some.file');

expect(content).toBe('some content');
});
});
});
});

describe('getEntryStats', () => {
describe('given a file', () => {
describe('if stats have not been requested for the file already', () => {
it('requests the stats for the file', async () => {
jest.spyOn(fs.promises, 'stat').mockResolvedValue(mock<fs.Stats>());
const repositoryFilesystem = new RepositoryFilesystem(
'/some/directory',
);

await repositoryFilesystem.getEntryStats('some-entry');

expect(fs.promises.stat).toHaveBeenCalledWith(
'/some/directory/some-entry',
);
});

it('returns stats for the file', async () => {
await withinSandbox(
async ({ directoryPath: sandboxDirectoryPath }) => {
await writeFile(path.join(sandboxDirectoryPath, 'some.file'), '');
const repositoryFilesystem = new RepositoryFilesystem(
sandboxDirectoryPath,
);

const stats = await repositoryFilesystem.getEntryStats(
'some.file',
);

expect(stats).toHaveProperty('atime');
expect(stats).toHaveProperty('ctime');
expect(stats).toHaveProperty('mtime');
},
);
});
});

describe('if stats have been requested for the file already', () => {
it('does not request the stats for the file again', async () => {
jest.spyOn(fs.promises, 'stat').mockResolvedValue(mock<fs.Stats>());
const repositoryFilesystem = new RepositoryFilesystem(
'/some/directory',
);
await repositoryFilesystem.getEntryStats('some-entry');

await repositoryFilesystem.getEntryStats('some-entry');

expect(fs.promises.stat).toHaveBeenCalledTimes(1);
});

it('returns stats for the file', async () => {
await withinSandbox(
async ({ directoryPath: sandboxDirectoryPath }) => {
await writeFile(path.join(sandboxDirectoryPath, 'some.file'), '');
const repositoryFilesystem = new RepositoryFilesystem(
sandboxDirectoryPath,
);
await repositoryFilesystem.getEntryStats('some.file');

const stats = await repositoryFilesystem.getEntryStats(
'some.file',
);

expect(stats).toHaveProperty('atime');
expect(stats).toHaveProperty('ctime');
expect(stats).toHaveProperty('mtime');
},
);
});
});
});

describe('given a directory', () => {
describe('if stats have not been requested for the directory already', () => {
it('requests the stats for the directory', async () => {
jest.spyOn(fs.promises, 'stat').mockResolvedValue(mock<fs.Stats>());
const repositoryFilesystem = new RepositoryFilesystem(
'/some/directory',
);

await repositoryFilesystem.getEntryStats('/another/directory');

expect(fs.promises.stat).toHaveBeenCalledWith(
'/some/directory/another/directory',
);
});

it('returns stats for the directory', async () => {
await withinSandbox(
async ({ directoryPath: sandboxDirectoryPath }) => {
await ensureDirectoryStructureExists(
path.join(sandboxDirectoryPath, 'some-directory'),
);
const repositoryFilesystem = new RepositoryFilesystem(
sandboxDirectoryPath,
);

const stats = await repositoryFilesystem.getEntryStats(
'some-directory',
);

expect(stats).toHaveProperty('atime');
expect(stats).toHaveProperty('ctime');
expect(stats).toHaveProperty('mtime');
},
);
});
});

describe('if stats have been requested for the directory already', () => {
it('does not request the stats for the directory again', async () => {
jest.spyOn(fs.promises, 'stat').mockResolvedValue(mock<fs.Stats>());
const repositoryFilesystem = new RepositoryFilesystem(
'/some/directory',
);
await repositoryFilesystem.getEntryStats('another-directory');

await repositoryFilesystem.getEntryStats('another-directory');

expect(fs.promises.stat).toHaveBeenCalledTimes(1);
});

it('returns stats for the directory', async () => {
await withinSandbox(
async ({ directoryPath: sandboxDirectoryPath }) => {
await ensureDirectoryStructureExists(
path.join(sandboxDirectoryPath, 'some-directory'),
);
const repositoryFilesystem = new RepositoryFilesystem(
sandboxDirectoryPath,
);
await repositoryFilesystem.getEntryStats('some-directory');

const stats = await repositoryFilesystem.getEntryStats(
'some-directory',
);

expect(stats).toHaveProperty('atime');
expect(stats).toHaveProperty('ctime');
expect(stats).toHaveProperty('mtime');
},
);
});
});
});
});
});
Loading

0 comments on commit dd455ba

Please sign in to comment.