diff --git a/package.json b/package.json index 440e899..2bede4e 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,7 @@ "prettier": "^2.7.1", "prettier-plugin-packagejson": "^2.3.0", "rimraf": "^3.0.2", + "stdio-mock": "^1.2.0", "ts-jest": "^28.0.7", "ts-node": "^10.7.0", "typedoc": "^0.23.15", diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..9f2fe14 --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,5 @@ +/** + * The number of milliseconds in an hour, used to determine when to pull the + * latest changes for previously cached repositories. + */ +export const ONE_HOUR = 60 * 60 * 1000; diff --git a/src/establish-metamask-repository.test.ts b/src/establish-metamask-repository.test.ts new file mode 100644 index 0000000..2692609 --- /dev/null +++ b/src/establish-metamask-repository.test.ts @@ -0,0 +1,325 @@ +import execa from 'execa'; +import path from 'path'; + +import { establishMetaMaskRepository } from './establish-metamask-repository'; +import { FakeOutputLogger } from '../tests/fake-output-logger'; +import type { PrimaryExecaFunction } from '../tests/helpers'; +import { fakeDateOnly, withinSandbox } from '../tests/helpers'; +import { setupToolWithMockRepository } from '../tests/setup-tool-with-mock-repository'; + +jest.mock('execa'); + +const execaMock = jest.mocked(execa); + +describe('establishMetaMaskRepository', () => { + beforeEach(() => { + fakeDateOnly(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('given the path to an existing directory that is not a Git repository', () => { + it('throws', async () => { + await withinSandbox(async ({ directoryPath: sandboxDirectoryPath }) => { + const outputLogger = new FakeOutputLogger(); + + await expect( + establishMetaMaskRepository({ + repositoryReference: sandboxDirectoryPath, + workingDirectoryPath: sandboxDirectoryPath, + cachedRepositoriesDirectoryPath: sandboxDirectoryPath, + outputLogger, + }), + ).rejects.toThrow( + `"${sandboxDirectoryPath}" is not a Git repository, cannot proceed.`, + ); + }); + }); + }); + + describe('given the path to an existing repository relative to the working directory', () => { + it('does not pull the latest changes', async () => { + await withinSandbox(async ({ directoryPath: sandboxDirectoryPath }) => { + const workingDirectoryPath = path.join(sandboxDirectoryPath, 'working'); + const { cachedRepositoriesDirectoryPath, repository } = + await setupToolWithMockRepository({ + execaMock, + sandboxDirectoryPath, + repository: { + create: true, + parentDirectoryPath: workingDirectoryPath, + }, + }); + const outputLogger = new FakeOutputLogger(); + + await establishMetaMaskRepository({ + repositoryReference: repository.name, + workingDirectoryPath, + cachedRepositoriesDirectoryPath, + outputLogger, + }); + + expect(execaMock).not.toHaveBeenNthCalledWith(3, 'git', ['pull'], { + cwd: repository.directoryPath, + }); + }); + }); + + it('returns information about the repository, even if the default branch is not selected', async () => { + const fetchHeadModifiedDate = new Date('2023-01-01T00:00:00Z'); + + await withinSandbox(async ({ directoryPath: sandboxDirectoryPath }) => { + const workingDirectoryPath = path.join(sandboxDirectoryPath, 'working'); + const { cachedRepositoriesDirectoryPath, repository } = + await setupToolWithMockRepository({ + execaMock, + sandboxDirectoryPath, + repository: { + name: 'some-repo', + create: true, + parentDirectoryPath: workingDirectoryPath, + commandMocks: { + 'git symbolic-ref HEAD': () => ({ + result: { + stdout: 'refs/heads/some-branch', + }, + }), + 'git rev-parse --verify main': () => ({ + error: new Error('not found'), + }), + 'git rev-parse --verify master': () => ({ + result: { + stdout: '', + }, + }), + }, + fetchHead: { modifiedDate: fetchHeadModifiedDate }, + }, + validRepositories: [], + }); + const outputLogger = new FakeOutputLogger(); + + const metaMaskRepository = await establishMetaMaskRepository({ + repositoryReference: 'some-repo', + workingDirectoryPath, + cachedRepositoriesDirectoryPath, + outputLogger, + }); + + expect(metaMaskRepository).toMatchObject({ + shortname: 'some-repo', + directoryPath: repository.directoryPath, + defaultBranchName: 'master', + currentBranchName: 'some-branch', + lastFetchedDate: fetchHeadModifiedDate, + }); + }); + }); + }); + + describe('given the name of a known MetaMask repository', () => { + describe('if the repository has already been cloned', () => { + it('throws if the default branch is not selected', async () => { + await withinSandbox(async ({ directoryPath: sandboxDirectoryPath }) => { + const { cachedRepositoriesDirectoryPath, repository } = + await setupToolWithMockRepository({ + execaMock, + sandboxDirectoryPath, + repository: { + create: true, + commandMocks: { + 'git symbolic-ref HEAD': () => ({ + result: { + stdout: 'refs/heads/NOT-main', + }, + }), + 'git rev-parse --verify main': () => ({ + result: { + stdout: '', + }, + }), + }, + }, + }); + const outputLogger = new FakeOutputLogger(); + + await expect( + establishMetaMaskRepository({ + repositoryReference: repository.name, + workingDirectoryPath: sandboxDirectoryPath, + cachedRepositoriesDirectoryPath, + outputLogger, + }), + ).rejects.toThrow( + `Error establishing repository "${repository.directoryPath}": The default branch "main" does not seem to be selected. You'll need to return it to this branch manually.`, + ); + }); + }); + + it('pulls the default branch', async () => { + await withinSandbox(async ({ directoryPath: sandboxDirectoryPath }) => { + const { cachedRepositoriesDirectoryPath, repository } = + await setupToolWithMockRepository({ + execaMock, + sandboxDirectoryPath, + repository: { + create: true, + }, + }); + const outputLogger = new FakeOutputLogger(); + + await establishMetaMaskRepository({ + repositoryReference: repository.name, + workingDirectoryPath: sandboxDirectoryPath, + cachedRepositoriesDirectoryPath, + outputLogger, + }); + + expect(execaMock).toHaveBeenNthCalledWith(4, 'git', ['pull'], { + cwd: repository.directoryPath, + }); + }); + }); + + it('returns information about the repository', async () => { + const now = new Date('2023-01-01T00:00:00Z'); + jest.setSystemTime(now); + + await withinSandbox(async ({ directoryPath: sandboxDirectoryPath }) => { + const { cachedRepositoriesDirectoryPath, repository } = + await setupToolWithMockRepository({ + execaMock, + sandboxDirectoryPath, + repository: { + name: 'some-repo', + create: true, + commandMocks: { + 'git symbolic-ref HEAD': () => ({ + result: { + stdout: 'refs/heads/main', + }, + }), + 'git rev-parse --verify main': () => ({ + result: { + stdout: '', + }, + }), + }, + }, + }); + const outputLogger = new FakeOutputLogger(); + + const metaMaskRepository = await establishMetaMaskRepository({ + repositoryReference: 'some-repo', + workingDirectoryPath: sandboxDirectoryPath, + cachedRepositoriesDirectoryPath, + outputLogger, + }); + + expect(metaMaskRepository).toMatchObject({ + currentBranchName: 'main', + defaultBranchName: 'main', + directoryPath: repository.directoryPath, + shortname: 'some-repo', + lastFetchedDate: now, + }); + }); + }); + }); + + describe('if the repository has not already been cloned', () => { + it('clones the repository', async () => { + await withinSandbox(async ({ directoryPath: sandboxDirectoryPath }) => { + const { cachedRepositoriesDirectoryPath, repository } = + await setupToolWithMockRepository({ + execaMock, + sandboxDirectoryPath, + validRepositories: [ + { + name: 'some-repo', + fork: false, + archived: false, + }, + ], + repository: { + name: 'some-repo', + create: false, + }, + }); + const outputLogger = new FakeOutputLogger(); + + await establishMetaMaskRepository({ + repositoryReference: 'some-repo', + workingDirectoryPath: sandboxDirectoryPath, + cachedRepositoriesDirectoryPath, + outputLogger, + }); + + expect(execaMock).toHaveBeenNthCalledWith(2, 'gh', [ + 'repo', + 'clone', + `MetaMask/some-repo`, + repository.directoryPath, + ]); + }); + }); + + it('returns information about the repository', async () => { + const now = new Date('2023-01-01T01:00:01Z'); + jest.setSystemTime(now); + + await withinSandbox(async ({ directoryPath: sandboxDirectoryPath }) => { + const { cachedRepositoriesDirectoryPath, repository } = + await setupToolWithMockRepository({ + execaMock, + sandboxDirectoryPath, + validRepositories: [ + { + name: 'some-repo', + fork: false, + archived: false, + }, + ], + repository: { + name: 'some-repo', + create: false, + commandMocks: { + 'git symbolic-ref HEAD': () => ({ + result: { + stdout: 'refs/heads/master', + }, + }), + 'git rev-parse --verify main': () => ({ + error: new Error('not found'), + }), + 'git rev-parse --verify master': () => ({ + result: { + stdout: '', + }, + }), + }, + }, + }); + const outputLogger = new FakeOutputLogger(); + + const metaMaskRepository = await establishMetaMaskRepository({ + repositoryReference: 'some-repo', + workingDirectoryPath: sandboxDirectoryPath, + cachedRepositoriesDirectoryPath, + outputLogger, + }); + + expect(metaMaskRepository).toMatchObject({ + currentBranchName: 'master', + defaultBranchName: 'master', + directoryPath: repository.directoryPath, + shortname: 'some-repo', + lastFetchedDate: now, + }); + }); + }); + }); + }); +}); diff --git a/src/establish-metamask-repository.ts b/src/establish-metamask-repository.ts new file mode 100644 index 0000000..bf3cf7e --- /dev/null +++ b/src/establish-metamask-repository.ts @@ -0,0 +1,259 @@ +import { + directoryExists, + ensureDirectoryStructureExists, +} from '@metamask/utils/node'; +import execa from 'execa'; +import fs from 'fs'; +import path from 'path'; + +import { createModuleLogger, projectLogger } from './logging-utils'; +import type { AbstractOutputLogger } from './output-logger'; +import { RepositoryFilesystem } from './repository-filesystem'; +import { + ensureDefaultBranchIsUpToDate, + getBranchInfo, + getCurrentBranchName, +} from './repository-utils'; +import type { BranchInfo } from './repository-utils'; +import { resolveRepositoryReference } from './resolve-repository-reference'; + +const log = createModuleLogger(projectLogger, 'establish-metamask-repository'); + +type ConfirmedRepository = BranchInfo & { + shortname: string; + directoryPath: string; +}; + +/** + * A repository within the MetaMask organization on GitHub which has been cloned + * to the local filesystem. More concretely, this could either be a template or + * a project that requires linting. + */ +export type MetaMaskRepository = ConfirmedRepository & { + fs: RepositoryFilesystem; +}; + +/** + * Information about a repository we know exists on the filesystem. + */ +export type ExistingRepository = ConfirmedRepository & { + isKnownMetaMaskRepository: boolean; +}; + +/** + * Ensures that there is a proper repository to lint. A repository may be one of + * two things: either A) the "short name" of a known repository under the GitHub + * MetaMask organization, or B) the path to a directory on the local filesystem. + * In the case of a MetaMask repository, this function takes care of + * automatically cloning the repository to a temporary location (unless it has + * already been cloned), then bringing the repository up to date with its + * default branch. + * + * @param args - The arguments to this function. + * @param args.repositoryReference - Either the name of a MetaMask repository, + * such as "utils", or the path to a local Git repository. + * @param args.workingDirectoryPath - The directory where this tool was run. + * @param args.cachedRepositoriesDirectoryPath - The directory where MetaMask + * repositories will be (or have been) cloned. + * @param args.outputLogger - Writable streams for output messages. + * @returns The repository. + */ +export async function establishMetaMaskRepository({ + repositoryReference, + workingDirectoryPath, + cachedRepositoriesDirectoryPath, + outputLogger, +}: { + repositoryReference: string; + workingDirectoryPath: string; + cachedRepositoriesDirectoryPath: string; + outputLogger: AbstractOutputLogger; +}): Promise { + const existingRepository = await ensureRepositoryExists({ + repositoryReference, + workingDirectoryPath, + cachedRepositoriesDirectoryPath, + outputLogger, + }); + log('Repository is', existingRepository.shortname); + + const repositoryFilesystem = new RepositoryFilesystem( + existingRepository.directoryPath, + ); + + if (existingRepository.isKnownMetaMaskRepository) { + await requireDefaultBranchSelected(existingRepository); + + const updatedLastFetchedDate = await ensureDefaultBranchIsUpToDate( + existingRepository.directoryPath, + existingRepository.lastFetchedDate, + ); + return { + ...existingRepository, + lastFetchedDate: updatedLastFetchedDate, + fs: repositoryFilesystem, + }; + } + + return { + ...existingRepository, + fs: repositoryFilesystem, + }; +} + +/** + * Ensures that a lintable repository exists. If given the path to a local Git + * repository, then nothing really happens. If given the name of a MetaMask + * repository, then it is cloned if it has not already been cloned. Either way, + * this function collects information about the repository which is useful for + * bringing it up to date later if need be. + * + * @param args - The arguments to this function. + * @param args.repositoryReference - Either the name of a MetaMask repository, + * such as "utils", or the path to a local Git repository. + * @param args.workingDirectoryPath - The directory where this tool was run. + * @param args.cachedRepositoriesDirectoryPath - The directory where MetaMask + * repositories will be (or have been) cloned. + * @param args.outputLogger - Writable streams for output messages. + * @returns Information about the repository. + * @throws If given a repository reference that cannot be resolved to the name + * of a MetaMask repository or a local directory, or if the resolved directory + * is not a Git repository. + */ +async function ensureRepositoryExists({ + repositoryReference, + workingDirectoryPath, + cachedRepositoriesDirectoryPath, + outputLogger, +}: { + repositoryReference: string; + workingDirectoryPath: string; + cachedRepositoriesDirectoryPath: string; + outputLogger: AbstractOutputLogger; +}): Promise { + const { + repositoryShortname, + repositoryDirectoryPath, + repositoryDirectoryExists, + isKnownMetaMaskRepository, + } = await resolveRepositoryReference({ + repositoryReference, + workingDirectoryPath, + cachedRepositoriesDirectoryPath, + }); + const isGitRepository = await directoryExists( + path.join(repositoryDirectoryPath, '.git'), + ); + + if (repositoryDirectoryExists && !isGitRepository) { + throw new Error( + `"${repositoryDirectoryPath}" is not a Git repository, cannot proceed.`, + ); + } + + let branchInfo: BranchInfo; + if (isGitRepository) { + log('Repository has been cloned already to', repositoryDirectoryPath); + branchInfo = await getBranchInfo(repositoryDirectoryPath); + } else { + branchInfo = await cloneRepository({ + repositoryShortname, + repositoryDirectoryPath, + cachedRepositoriesDirectoryPath, + outputLogger, + }); + } + + return { + ...branchInfo, + shortname: repositoryShortname, + directoryPath: repositoryDirectoryPath, + isKnownMetaMaskRepository, + }; +} + +/** + * Clones a MetaMask repository using the `gh` tool. + * + * @param args - The arguments to this function. + * @param args.repositoryShortname - The name of the repository minus the + * organization (so, "utils" instead of "MetaMask/utils"). + * @param args.repositoryDirectoryPath - The path where the repository should be + * cloned to. + * @param args.cachedRepositoriesDirectoryPath - The parent directory in which + * to keep the new repository. + * @param args.outputLogger - Writable streams for output messages. + * @throws If `repositoryDirectoryPath` is not within + * `cachedRepositoriesDirectoryPath`, or you do not have `gh` installed. + */ +async function cloneRepository({ + repositoryShortname, + repositoryDirectoryPath, + cachedRepositoriesDirectoryPath, + outputLogger, +}: { + repositoryShortname: string; + repositoryDirectoryPath: string; + cachedRepositoriesDirectoryPath: string; + outputLogger: AbstractOutputLogger; +}) { + /* istanbul ignore next: There's no way to reproduce this; this is just to be absolutely sure */ + if ( + path.dirname(repositoryDirectoryPath) !== cachedRepositoriesDirectoryPath + ) { + throw new Error( + 'You seem to be pointing to a directory outside the cached repositories directory. Refusing to proceed to avoid data loss.', + ); + } + + log('Assuming', repositoryShortname, 'is the name of a MetaMask repo'); + + log('Removing existing', repositoryDirectoryPath); + await fs.promises.rm(repositoryDirectoryPath, { + recursive: true, + force: true, + }); + + log('Cloning', repositoryShortname, 'to', repositoryDirectoryPath); + outputLogger.logToStdout( + `Cloning repository MetaMask/${repositoryShortname}, please wait...`, + ); + await ensureDirectoryStructureExists(cachedRepositoriesDirectoryPath); + // NOTE: This requires that you have `gh` installed locally + await execa('gh', [ + 'repo', + 'clone', + `MetaMask/${repositoryShortname}`, + repositoryDirectoryPath, + ]); + const currentBranchName = await getCurrentBranchName(repositoryDirectoryPath); + const defaultBranchName = currentBranchName; + + return { + currentBranchName, + defaultBranchName, + lastFetchedDate: new Date(), + }; +} + +/** + * In order to update a previously cloned MetaMask repository, the repository + * must be on its default branch. This function checks that this is so. + * + * @param existingRepository - The arguments to this function. + * @param existingRepository.directoryPath - The path to the repository. + * @param existingRepository.currentBranchName - The name of the currently selected branch. + * @param existingRepository.defaultBranchName - The name of the default branch. + * @throws If the current branch and default branch are not the same. + */ +export async function requireDefaultBranchSelected({ + directoryPath, + currentBranchName, + defaultBranchName, +}: ExistingRepository) { + if (currentBranchName !== defaultBranchName) { + throw new Error( + `Error establishing repository "${directoryPath}": The default branch "${defaultBranchName}" does not seem to be selected. You'll need to return it to this branch manually.`, + ); + } +} diff --git a/src/logging-utils.ts b/src/logging-utils.ts new file mode 100644 index 0000000..f5d3dbe --- /dev/null +++ b/src/logging-utils.ts @@ -0,0 +1,5 @@ +import { createProjectLogger, createModuleLogger } from '@metamask/utils/node'; + +export const projectLogger = createProjectLogger('module-lint'); + +export { createModuleLogger }; diff --git a/src/output-logger.test.ts b/src/output-logger.test.ts new file mode 100644 index 0000000..6bb9924 --- /dev/null +++ b/src/output-logger.test.ts @@ -0,0 +1,101 @@ +import { MockWritable } from 'stdio-mock'; + +import { OutputLogger } from './output-logger'; + +describe('OutputLogger', () => { + describe('logToStdout', () => { + describe('given a single string', () => { + it('prints the string as a line to stdout', () => { + const stdout = new MockWritable(); + const stderr = new MockWritable(); + const outputLogger = new OutputLogger({ stdout, stderr }); + + outputLogger.logToStdout('hello'); + + expect(stdout.data()).toStrictEqual(['hello\n']); + }); + + it('does nothing to stderr', () => { + const stdout = new MockWritable(); + const stderr = new MockWritable(); + const outputLogger = new OutputLogger({ stdout, stderr }); + + outputLogger.logToStdout('hello'); + + expect(stderr.data()).toStrictEqual([]); + }); + }); + + describe('given a format string plus values', () => { + it('prints the formatted version of the string as a line to stdout', () => { + const stdout = new MockWritable(); + const stderr = new MockWritable(); + const outputLogger = new OutputLogger({ stdout, stderr }); + + outputLogger.logToStdout('shine on you %o', { crazy: 'diamond' }); + + expect(stdout.data()).toStrictEqual([ + "shine on you { crazy: 'diamond' }\n", + ]); + }); + + it('does nothing to stderr', () => { + const stdout = new MockWritable(); + const stderr = new MockWritable(); + const outputLogger = new OutputLogger({ stdout, stderr }); + + outputLogger.logToStdout('shine on you %o', { crazy: 'diamond' }); + + expect(stderr.data()).toStrictEqual([]); + }); + }); + }); + + describe('logToStderr', () => { + describe('given a single string', () => { + it('prints the string as a line to stderr', () => { + const stdout = new MockWritable(); + const stderr = new MockWritable(); + const outputLogger = new OutputLogger({ stdout, stderr }); + + outputLogger.logToStderr('hello'); + + expect(stderr.data()).toStrictEqual(['hello\n']); + }); + + it('does nothing to stdout', () => { + const stdout = new MockWritable(); + const stderr = new MockWritable(); + const outputLogger = new OutputLogger({ stdout, stderr }); + + outputLogger.logToStderr('hello'); + + expect(stdout.data()).toStrictEqual([]); + }); + }); + + describe('given a format string plus values', () => { + it('prints the formatted version of the string as a line to stderr', () => { + const stdout = new MockWritable(); + const stderr = new MockWritable(); + const outputLogger = new OutputLogger({ stdout, stderr }); + + outputLogger.logToStderr('shine on you %o', { crazy: 'diamond' }); + + expect(stderr.data()).toStrictEqual([ + "shine on you { crazy: 'diamond' }\n", + ]); + }); + + it('does nothing to stdout', () => { + const stdout = new MockWritable(); + const stderr = new MockWritable(); + const outputLogger = new OutputLogger({ stdout, stderr }); + + outputLogger.logToStderr('shine on you %o', { crazy: 'diamond' }); + + expect(stdout.data()).toStrictEqual([]); + }); + }); + }); +}); diff --git a/src/output-logger.ts b/src/output-logger.ts new file mode 100644 index 0000000..172f4d5 --- /dev/null +++ b/src/output-logger.ts @@ -0,0 +1,96 @@ +import type { WriteStream } from 'fs'; +import { format } from 'util'; + +/** + * Represents the commonality between an output stream such as `process.stdout` + * and the MockWritable interface from `stdio-mock`. In other words, this + * type exists so that we can designate that a function takes a writable stream + * without enforcing that it must be a Stream object. + */ +export type SimpleWriteStream = Pick; + +/** + * The minimal interface that an output logger is expected to have. + */ +export type AbstractOutputLogger = { + /** + * Writes a line to the standard out stream. + * + * @param args - Arguments to `stream.write`: either one or more + * messages, or a format string followed by values. + */ + logToStdout(...args: [string, ...any]): void; + + /** + * Writes a line to the standard error stream. + * + * @param args - Arguments to `stream.write`: either one or more + * messages, or a format string followed by values. + */ + logToStderr(...args: [string, ...any]): void; +}; + +/** + * A simple interface over the two streams commonly used to output messages to + * the terminal. Designed such that a fake version of this class can be used in + * tests. + */ +export class OutputLogger implements AbstractOutputLogger { + #stdout: SimpleWriteStream; + + #stderr: SimpleWriteStream; + + /** + * Constructs an OutputLogger instance. + * + * @param args - The arguments. + * @param args.stdout - The standard out stream. + * @param args.stderr - The standard error stream. + */ + constructor({ + stdout, + stderr, + }: { + stdout: SimpleWriteStream; + stderr: SimpleWriteStream; + }) { + this.#stdout = stdout; + this.#stderr = stderr; + } + + /** + * Writes a line to the standard out stream. + * + * @param args - Either one or more messages, or a format string followed by + * values. + */ + logToStdout(...args: [string, ...any]) { + logToStream(this.#stdout, args); + } + + /** + * Writes a line to the standard error stream. + * + * @param args - Either one or more messages, or a format string followed by + * values. + */ + logToStderr(...args: [string, ...any]) { + logToStream(this.#stderr, args); + } +} + +/** + * Writes a line to the given stream. + * + * @param stream - The stream. + * @param args - Arguments to `stream.write`: either one or more messages, or a + * format string followed by values. + */ +export function logToStream(stream: SimpleWriteStream, args: [string, ...any]) { + const [messageOrFormatString, ...rest] = args; + if (rest.length > 0) { + stream.write(format(`${messageOrFormatString}\n`, ...rest)); + } else { + stream.write(`${messageOrFormatString}\n`); + } +} diff --git a/src/repository-utils.test.ts b/src/repository-utils.test.ts new file mode 100644 index 0000000..3e39541 --- /dev/null +++ b/src/repository-utils.test.ts @@ -0,0 +1,295 @@ +import { writeFile } from '@metamask/utils/node'; +import execa from 'execa'; +import fs from 'fs'; +import path from 'path'; + +import { + ensureDefaultBranchIsUpToDate, + getBranchInfo, + getCurrentBranchName, + getDefaultBranchName, + getLastFetchedDate, +} from './repository-utils'; +import type { PrimaryExecaFunction } from '../tests/helpers'; +import { fakeDateOnly, mockExeca, withinSandbox } from '../tests/helpers'; + +jest.mock('execa'); + +const execaMock = jest.mocked(execa); + +describe('getBranchInfo', () => { + beforeEach(() => { + fakeDateOnly(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('returns the current branch, default branch, and last fetched date for the given repository', async () => { + const now = new Date('2023-01-01T00:00:00.000Z'); + jest.setSystemTime(now); + + await withinSandbox(async ({ directoryPath: sandboxDirectoryPath }) => { + mockExeca(execaMock, [ + { + args: [ + 'git', + ['symbolic-ref', '--quiet', 'HEAD'], + { cwd: sandboxDirectoryPath }, + ], + result: { stdout: 'refs/heads/foo' }, + }, + { + args: [ + 'git', + ['rev-parse', '--verify', '--quiet', 'main'], + { cwd: sandboxDirectoryPath }, + ], + result: { stdout: '' }, + }, + ]); + const fetchHeadPath = path.join( + sandboxDirectoryPath, + '.git', + 'FETCH_HEAD', + ); + await writeFile(fetchHeadPath, ''); + await fs.promises.utimes(fetchHeadPath, now, now); + + const branchInfo = await getBranchInfo(sandboxDirectoryPath); + + expect(Object.keys(branchInfo)).toStrictEqual([ + 'currentBranchName', + 'defaultBranchName', + 'lastFetchedDate', + ]); + expect(branchInfo).toMatchObject({ + currentBranchName: 'foo', + defaultBranchName: 'main', + lastFetchedDate: now, + }); + }); + }); +}); + +describe('getCurrentBranchName', () => { + it('returns the name of the branch that HEAD refers to', async () => { + await withinSandbox(async ({ directoryPath: sandboxDirectoryPath }) => { + mockExeca(execaMock, [ + { + args: [ + 'git', + ['symbolic-ref', '--quiet', 'HEAD'], + { cwd: sandboxDirectoryPath }, + ], + result: { stdout: 'refs/heads/current-branch' }, + }, + ]); + + const branchName = await getCurrentBranchName(sandboxDirectoryPath); + + expect(branchName).toBe('current-branch'); + }); + }); + + it('throws if HEAD does not refer to a branch (say, for a detached HEAD)', async () => { + await withinSandbox(async ({ directoryPath: sandboxDirectoryPath }) => { + mockExeca(execaMock, [ + { + args: [ + 'git', + ['symbolic-ref', '--quiet', 'HEAD'], + { cwd: sandboxDirectoryPath }, + ], + result: { stdout: 'abc123' }, + }, + ]); + + await expect(getCurrentBranchName(sandboxDirectoryPath)).rejects.toThrow( + `The repository '${sandboxDirectoryPath}' does not seem to be on a branch. Perhaps HEAD is detached? Either way, you will need to return this repo to the default branch manually.`, + ); + }); + }); +}); + +describe('getDefaultBranchName', () => { + it('returns "main" if the main branch exists', async () => { + await withinSandbox(async ({ directoryPath: sandboxDirectoryPath }) => { + mockExeca(execaMock, [ + { + args: [ + 'git', + ['rev-parse', '--verify', '--quiet', 'main'], + { cwd: sandboxDirectoryPath }, + ], + result: { stdout: '' }, + }, + { + args: [ + 'git', + ['rev-parse', '--verify', '--quiet', 'master'], + { cwd: sandboxDirectoryPath }, + ], + result: { stdout: '' }, + }, + ]); + + const branchName = await getDefaultBranchName(sandboxDirectoryPath); + + expect(branchName).toBe('main'); + }); + }); + + it('returns "master" if the main branch exists', async () => { + await withinSandbox(async ({ directoryPath: sandboxDirectoryPath }) => { + mockExeca(execaMock, [ + { + args: [ + 'git', + ['rev-parse', '--verify', '--quiet', 'main'], + { cwd: sandboxDirectoryPath }, + ], + error: new Error('not found'), + }, + { + args: [ + 'git', + ['rev-parse', '--verify', '--quiet', 'master'], + { cwd: sandboxDirectoryPath }, + ], + result: { stdout: '' }, + }, + ]); + + const branchName = await getDefaultBranchName(sandboxDirectoryPath); + + expect(branchName).toBe('master'); + }); + }); + + it('throws if neither master nor main exists', async () => { + await withinSandbox(async ({ directoryPath: sandboxDirectoryPath }) => { + mockExeca(execaMock, [ + { + args: [ + 'git', + ['rev-parse', '--verify', '--quiet', 'main'], + { cwd: sandboxDirectoryPath }, + ], + error: new Error('not found'), + }, + { + args: [ + 'git', + ['rev-parse', '--verify', '--quiet', 'master'], + { cwd: sandboxDirectoryPath }, + ], + error: new Error('not found'), + }, + ]); + + await expect(getDefaultBranchName(sandboxDirectoryPath)).rejects.toThrow( + `Could not detect default branch name for repository '${sandboxDirectoryPath}'.`, + ); + }); + }); +}); + +describe('getLastFetchedDate', () => { + it('returns the time that .git/FETCH_HEAD was last modified', async () => { + const now = new Date('2023-01-01T00:00:00Z'); + + await withinSandbox(async ({ directoryPath: sandboxDirectoryPath }) => { + await writeFile( + path.join(sandboxDirectoryPath, '.git', 'FETCH_HEAD'), + '', + ); + await fs.promises.utimes( + path.join(sandboxDirectoryPath, '.git', 'FETCH_HEAD'), + now, + now, + ); + + const lastFetchedDate = await getLastFetchedDate(sandboxDirectoryPath); + + expect(lastFetchedDate?.getTime()).toStrictEqual(now.getTime()); + }); + }); + + it('returns null if .git/FETCH_HEAD does not exist', async () => { + await withinSandbox(async ({ directoryPath: sandboxDirectoryPath }) => { + const lastFetchedDate = await getLastFetchedDate(sandboxDirectoryPath); + + expect(lastFetchedDate).toBeNull(); + }); + }); +}); + +describe('ensureDefaultBranchIsUpToDate', () => { + beforeEach(() => { + fakeDateOnly(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('pulls in the latest changes for the default branch if no last fetched date has been recorded', async () => { + const repositoryDirectoryPath = '/some/directory'; + mockExeca(execaMock, [ + { + args: ['git', ['pull'], { cwd: repositoryDirectoryPath }], + result: { stdout: '' }, + }, + ]); + + await ensureDefaultBranchIsUpToDate(repositoryDirectoryPath, null); + + expect(execaMock).toHaveBeenCalledWith('git', ['pull'], { + cwd: repositoryDirectoryPath, + }); + }); + + it('pulls in the latest changes for the default branch if the last fetched date is more than an hour in the past', async () => { + const repositoryDirectoryPath = '/some/directory'; + const lastFetchedDate = new Date('2023-01-01T00:00:00Z'); + const now = new Date('2023-01-01T01:00:01Z'); + jest.setSystemTime(now); + mockExeca(execaMock, [ + { + args: ['git', ['pull'], { cwd: repositoryDirectoryPath }], + result: { stdout: '' }, + }, + ]); + + await ensureDefaultBranchIsUpToDate( + repositoryDirectoryPath, + lastFetchedDate, + ); + + expect(execaMock).toHaveBeenCalledWith('git', ['pull'], { + cwd: repositoryDirectoryPath, + }); + }); + + it('does not pull in the latest changes for the default branch if the last fetched date is less than an hour in the past', async () => { + const repositoryDirectoryPath = '/some/directory'; + const lastFetchedDate = new Date('2023-01-01T00:00:00Z'); + const now = new Date('2023-01-01T00:00:59Z'); + jest.setSystemTime(now); + mockExeca(execaMock, [ + { + args: ['git', ['pull'], { cwd: repositoryDirectoryPath }], + result: { stdout: '' }, + }, + ]); + + await ensureDefaultBranchIsUpToDate( + repositoryDirectoryPath, + lastFetchedDate, + ); + + expect(execaMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/repository-utils.ts b/src/repository-utils.ts new file mode 100644 index 0000000..6e973b6 --- /dev/null +++ b/src/repository-utils.ts @@ -0,0 +1,143 @@ +import execa from 'execa'; +import path from 'path'; + +import { ONE_HOUR } from './constants'; +import { createModuleLogger, projectLogger } from './logging-utils'; +import { getEntryStats } from './misc-utils'; + +const log = createModuleLogger(projectLogger, 'existing-repository'); + +/** + * Information about a Git branch. + */ +export type BranchInfo = { + currentBranchName: string; + defaultBranchName: string; + lastFetchedDate: Date | null; +}; + +/** + * Collects the current and default branch name of the given repository as well + * as the time when commits were last fetched. + * + * @param repositoryDirectoryPath - The path to the repository. + */ +export async function getBranchInfo( + repositoryDirectoryPath: string, +): Promise { + const currentBranchName = await getCurrentBranchName(repositoryDirectoryPath); + const defaultBranchName = await getDefaultBranchName(repositoryDirectoryPath); + const lastFetchedDate = await getLastFetchedDate(repositoryDirectoryPath); + return { + currentBranchName, + defaultBranchName, + lastFetchedDate, + }; +} + +/** + * Retrieves the name of the branch that the given repository is currently + * pointing to (i.e., HEAD). + * + * @param repositoryDirectoryPath - The path to the repository. + * @returns The name of the current branch. + * @throws If HEAD is not pointing to a branch, but rather an arbitrary commit. + */ +export async function getCurrentBranchName( + repositoryDirectoryPath: string, +): Promise { + log('Running: git symbolic-ref --quiet HEAD'); + const { stdout } = await execa('git', ['symbolic-ref', '--quiet', 'HEAD'], { + cwd: repositoryDirectoryPath, + }); + const match = stdout.match(/^refs\/heads\/(.+)$/u); + const currentBranchName = match?.[1] ?? null; + if (!currentBranchName) { + throw new Error( + `The repository '${repositoryDirectoryPath}' does not seem to be on a branch. Perhaps HEAD is detached? Either way, you will need to return this repo to the default branch manually.`, + ); + } + return currentBranchName; +} + +/** + * Retrieves the default branch of the given repository, that is, the branch + * that represents the main line of development. Unfortunately there's no good + * way to obtain this, so this function just tries "main" followed by "master". + * + * @param repositoryDirectoryPath - The path to the repository. + * @returns The default branch name. + * @throws If neither "main" nor "master" exist. + */ +export async function getDefaultBranchName( + repositoryDirectoryPath: string, +): Promise { + try { + log('Running: git rev-parse --verify --quiet main'); + await execa('git', ['rev-parse', '--verify', '--quiet', 'main'], { + cwd: repositoryDirectoryPath, + }); + return 'main'; + } catch (error) { + log('Command `git rev-parse --verify --quiet main` failed:', error); + } + + try { + log('Running: git rev-parse --verify --quiet master'); + await execa('git', ['rev-parse', '--verify', '--quiet', 'master'], { + cwd: repositoryDirectoryPath, + }); + return 'master'; + } catch (error) { + log('Command `git rev-parse --verify --quiet master` failed:', error); + } + + throw new Error( + `Could not detect default branch name for repository '${repositoryDirectoryPath}'.`, + ); +} + +/** + * Retrieves the date/time that any commits were last fetched for the given + * repository by checking the modification time of `.git/FETCH_HEAD`. + * + * @param repositoryDirectoryPath - The path to the repository. + * @returns The date of the last fetch if it has occurred, or null otherwise. + */ +export async function getLastFetchedDate( + repositoryDirectoryPath: string, +): Promise { + const stats = await getEntryStats( + path.join(repositoryDirectoryPath, '.git', 'FETCH_HEAD'), + ); + return stats ? stats.mtime : null; +} + +/** + * Ensures that the repository has fresh commits (where fresh means one hour or + * younger). + * + * @param repositoryDirectoryPath - The path to the repository. + * @param lastFetchedDate - The date/time when the repository + * was last fetched. + * @returns The last fetched date if it has been an hour or less, or now + * otherwise. + */ +export async function ensureDefaultBranchIsUpToDate( + repositoryDirectoryPath: string, + lastFetchedDate: Date | null, +) { + const now = new Date(); + if ( + lastFetchedDate && + now.getTime() - lastFetchedDate.getTime() <= ONE_HOUR + ) { + return lastFetchedDate; + } + + log('Running: git pull'); + await execa('git', ['pull'], { + cwd: repositoryDirectoryPath, + }); + return now; +} diff --git a/src/resolve-repository-reference.test.ts b/src/resolve-repository-reference.test.ts new file mode 100644 index 0000000..ffcd1df --- /dev/null +++ b/src/resolve-repository-reference.test.ts @@ -0,0 +1,216 @@ +import { ensureDirectoryStructureExists } from '@metamask/utils/node'; +import execa from 'execa'; +import path from 'path'; + +import { resolveRepositoryReference } from './resolve-repository-reference'; +import type { PrimaryExecaFunction } from '../tests/helpers'; +import { mockExeca, withinSandbox } from '../tests/helpers'; + +jest.mock('execa'); + +const execaMock = jest.mocked(execa); + +describe('resolveRepositoryReference', () => { + describe('given the path of a directory relative to the working directory', () => { + it('derives the shortname from the basename and assumes that the directory is not a clone of a known MetaMask repository', async () => { + await withinSandbox(async ({ directoryPath: sandboxDirectoryPath }) => { + const workingDirectoryPath = path.join(sandboxDirectoryPath, 'working'); + const repositoryDirectoryPath = path.join( + workingDirectoryPath, + 'subdir', + 'some-repo', + ); + await ensureDirectoryStructureExists(repositoryDirectoryPath); + + const resolvedRepositoryReference = await resolveRepositoryReference({ + repositoryReference: 'subdir/some-repo', + workingDirectoryPath, + cachedRepositoriesDirectoryPath: sandboxDirectoryPath, + }); + + expect(resolvedRepositoryReference).toStrictEqual({ + repositoryShortname: 'some-repo', + repositoryDirectoryPath, + repositoryDirectoryExists: true, + isKnownMetaMaskRepository: false, + }); + }); + }); + }); + + describe('given an absolute path to a directory somewhere in the filesystem', () => { + it('derives the shortname from the basename and assumes that the directory is not a clone of a known MetaMask repository', async () => { + await withinSandbox(async ({ directoryPath: sandboxDirectoryPath }) => { + const repositoryDirectoryPath = path.join( + sandboxDirectoryPath, + 'some-repo', + ); + await ensureDirectoryStructureExists(repositoryDirectoryPath); + + const resolvedRepositoryReference = await resolveRepositoryReference({ + repositoryReference: repositoryDirectoryPath, + workingDirectoryPath: sandboxDirectoryPath, + cachedRepositoriesDirectoryPath: sandboxDirectoryPath, + }); + + expect(resolvedRepositoryReference).toStrictEqual({ + repositoryShortname: 'some-repo', + repositoryDirectoryPath, + repositoryDirectoryExists: true, + isKnownMetaMaskRepository: false, + }); + }); + }); + }); + + describe('given the path to a previously cached MetaMask repository', () => { + it('indicates that the repository is known', async () => { + await withinSandbox(async ({ directoryPath: sandboxDirectoryPath }) => { + const cachedRepositoriesDirectoryPath = path.join( + sandboxDirectoryPath, + 'cache', + ); + const repositoryDirectoryPath = path.join( + cachedRepositoriesDirectoryPath, + 'some-repo', + ); + await ensureDirectoryStructureExists(repositoryDirectoryPath); + mockExeca(execaMock, [ + { + args: [ + 'gh', + ['api', 'orgs/MetaMask/repos', '--cache', '1h', '--paginate'], + ], + result: { + stdout: JSON.stringify([ + { name: 'some-repo', fork: false, archived: false }, + ]), + }, + }, + ]); + + const resolvedRepositoryReference = await resolveRepositoryReference({ + repositoryReference: 'some-repo', + workingDirectoryPath: sandboxDirectoryPath, + cachedRepositoriesDirectoryPath, + }); + + expect(resolvedRepositoryReference).toStrictEqual({ + repositoryShortname: 'some-repo', + repositoryDirectoryPath, + repositoryDirectoryExists: true, + isKnownMetaMaskRepository: true, + }); + }); + }); + }); + + describe('given the path to a repository that was cached but is somehow not a known MetaMask repository', () => { + it('indicates that the repository is unknown', async () => { + await withinSandbox(async ({ directoryPath: sandboxDirectoryPath }) => { + const cachedRepositoriesDirectoryPath = path.join( + sandboxDirectoryPath, + 'cache', + ); + const repositoryDirectoryPath = path.join( + cachedRepositoriesDirectoryPath, + 'some-repo', + ); + await ensureDirectoryStructureExists(repositoryDirectoryPath); + mockExeca(execaMock, [ + { + args: [ + 'gh', + ['api', 'orgs/MetaMask/repos', '--cache', '1h', '--paginate'], + ], + result: { + stdout: JSON.stringify([]), + }, + }, + ]); + + const resolvedRepositoryReference = await resolveRepositoryReference({ + repositoryReference: 'some-repo', + workingDirectoryPath: sandboxDirectoryPath, + cachedRepositoriesDirectoryPath, + }); + + expect(resolvedRepositoryReference).toStrictEqual({ + repositoryShortname: 'some-repo', + repositoryDirectoryPath, + repositoryDirectoryExists: true, + isKnownMetaMaskRepository: false, + }); + }); + }); + }); + + describe('given the path to known MetaMask repository that has not been cached yet', () => { + it('indicates that the directory does not exist', async () => { + await withinSandbox(async ({ directoryPath: sandboxDirectoryPath }) => { + const cachedRepositoriesDirectoryPath = path.join( + sandboxDirectoryPath, + 'cache', + ); + const repositoryDirectoryPath = path.join( + cachedRepositoriesDirectoryPath, + 'some-repo', + ); + mockExeca(execaMock, [ + { + args: [ + 'gh', + ['api', 'orgs/MetaMask/repos', '--cache', '1h', '--paginate'], + ], + result: { + stdout: JSON.stringify([ + { name: 'some-repo', fork: false, archived: false }, + ]), + }, + }, + ]); + + const resolvedRepositoryReference = await resolveRepositoryReference({ + repositoryReference: 'some-repo', + workingDirectoryPath: sandboxDirectoryPath, + cachedRepositoriesDirectoryPath, + }); + + expect(resolvedRepositoryReference).toStrictEqual({ + repositoryShortname: 'some-repo', + repositoryDirectoryPath, + repositoryDirectoryExists: false, + isKnownMetaMaskRepository: true, + }); + }); + }); + }); + + describe('given the path to a non-existent directory', () => { + it('throws', async () => { + mockExeca(execaMock, [ + { + args: [ + 'gh', + ['api', 'orgs/MetaMask/repos', '--cache', '1h', '--paginate'], + ], + result: { + stdout: JSON.stringify([]), + }, + }, + ]); + + await expect( + resolveRepositoryReference({ + repositoryReference: '/tmp/clearly-not-a-directory', + workingDirectoryPath: '/tmp/working/dir', + cachedRepositoriesDirectoryPath: '/tmp/cache', + }), + ).rejects.toThrow( + new Error( + "Could not resolve '/tmp/clearly-not-a-directory' as it is neither a reference to a directory nor the name of a known MetaMask repository.", + ), + ); + }); + }); +}); diff --git a/src/resolve-repository-reference.ts b/src/resolve-repository-reference.ts new file mode 100644 index 0000000..02dd1b5 --- /dev/null +++ b/src/resolve-repository-reference.ts @@ -0,0 +1,107 @@ +import { directoryExists } from '@metamask/utils/node'; +import path from 'path'; + +import { ensureMetaMaskRepositoriesLoaded } from './ensure-metamask-repositories-loaded'; +import { createModuleLogger, projectLogger } from './logging-utils'; + +const log = createModuleLogger(projectLogger, 'resolve-repository-reference'); + +type ResolvedRepositoryReference = { + repositoryShortname: string; + repositoryDirectoryPath: string; + repositoryDirectoryExists: boolean; + isKnownMetaMaskRepository: boolean; +}; + +/** + * A "repository reference" may be: + * + * 1. The path of an existing directory relative to this tool's working + * directory. + * 2. The absolute path of an existing directory. + * 3. The path to a previously cloned MetaMask repository. + * 4. The name of a MetaMask repository. + * + * This function determines which one it is. + * + * @param args - The arguments to this function. + * @param args.repositoryReference - Either the name of a MetaMask repository, + * such as "utils", or the path to a local Git repository. + * @param args.workingDirectoryPath - The directory where this tool was run. + * @param args.cachedRepositoriesDirectoryPath - The directory where MetaMask + * repositories will be (or have been) cloned. + * @returns Information about the repository being referred to. + * @throws If given a repository reference that cannot be resolved to the name + * of a MetaMask repository or a local directory. + */ +export async function resolveRepositoryReference({ + repositoryReference, + workingDirectoryPath, + cachedRepositoriesDirectoryPath, +}: { + repositoryReference: string; + workingDirectoryPath: string; + cachedRepositoriesDirectoryPath: string; +}): Promise { + const possibleRealDirectoryPath = path.resolve( + workingDirectoryPath, + repositoryReference, + ); + if (await directoryExists(possibleRealDirectoryPath)) { + return { + repositoryShortname: path.basename(possibleRealDirectoryPath), + repositoryDirectoryPath: possibleRealDirectoryPath, + repositoryDirectoryExists: true, + isKnownMetaMaskRepository: false, + }; + } + + const cachedRepositoryDirectoryPath = path.join( + cachedRepositoriesDirectoryPath, + repositoryReference, + ); + const cachedRepositoryExists = await directoryExists( + cachedRepositoryDirectoryPath, + ); + const isKnownMetaMaskRepository = await isValidMetaMaskRepositoryName( + repositoryReference, + ); + + if (cachedRepositoryExists || isKnownMetaMaskRepository) { + return { + repositoryShortname: repositoryReference, + repositoryDirectoryPath: cachedRepositoryDirectoryPath, + repositoryDirectoryExists: cachedRepositoryExists, + isKnownMetaMaskRepository, + }; + } + + log( + 'possibleRealDirectoryPath', + possibleRealDirectoryPath, + 'cachedRepositoryDirectoryPath', + cachedRepositoryDirectoryPath, + 'cachedRepositoryExists', + cachedRepositoryExists, + ); + + throw new Error( + `Could not resolve '${repositoryReference}' as it is neither a reference to a directory nor the name of a known MetaMask repository.`, + ); +} + +/** + * Determines whether the given string matches a known repository under the + * MetaMask GitHub organization. + * + * @param repositoryName - The name of the repository to check. + */ +export async function isValidMetaMaskRepositoryName(repositoryName: string) { + const metaMaskRepositories = await ensureMetaMaskRepositoriesLoaded(); + return metaMaskRepositories.some( + (repository) => + !repository.fork && + !repository.archived && + repository.name === repositoryName, + ); +} diff --git a/tests/fake-output-logger.ts b/tests/fake-output-logger.ts new file mode 100644 index 0000000..0fb587f --- /dev/null +++ b/tests/fake-output-logger.ts @@ -0,0 +1,45 @@ +import { MockWritable } from 'stdio-mock'; + +import type { AbstractOutputLogger } from '../src/output-logger'; +import { logToStream } from '../src/output-logger'; + +export class FakeOutputLogger implements AbstractOutputLogger { + /** + * The fake standard out stream. Lines written can be accessed via `.data()`. + */ + stdout: MockWritable; + + /** + * The fake standard error stream. Lines written can be accessed via + * `.data()`. + */ + stderr: MockWritable; + + /** + * Constructs a FakeOutputLogger. + */ + constructor() { + this.stdout = new MockWritable(); + this.stderr = new MockWritable(); + } + + /** + * Writes a line to the fake standard out stream. + * + * @param args - Either one or more messages, or a format string followed by + * values. + */ + logToStdout(...args: [string, ...any]) { + logToStream(this.stdout, args); + } + + /** + * Writes a line to the fake standard error stream. + * + * @param args - Either one or more messages, or a format string followed by + * values. + */ + logToStderr(...args: [string, ...any]) { + logToStream(this.stderr, args); + } +} diff --git a/tests/helpers.ts b/tests/helpers.ts index 6b61c48..9f8b442 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -1,8 +1,8 @@ import { createSandbox } from '@metamask/utils/node'; import type { ExecaChildProcess, - Options as ExecaOptions, ExecaReturnValue, + Options as ExecaOptions, } from 'execa'; import { mock } from 'jest-mock-extended'; import { inspect, isDeepStrictEqual } from 'util'; @@ -20,6 +20,25 @@ export type PrimaryExecaFunction = ( options?: ExecaOptions | undefined, ) => ExecaChildProcess; +/** + * Represents the result of either a successful or failed invocation of `execa`. + * + * @property result - The desired properties of the resulting object in the case + * of success. + * @property error - The desired error in the case of failure. + */ +export type ExecaMockInvocationResult = + | { result?: Partial } + | { error?: Error }; + +/** + * An element in the array of mocks that you can pass to `mockExeca` in order to + * mock a particular invocation of `execa`. + */ +export type ExecaInvocationMock = { + args: Parameters; +} & ExecaMockInvocationResult; + /** * Builds an object that represents a successful result returned by `execa`. * This kind of object is usually a bit cumbersome to build because it's a @@ -47,16 +66,7 @@ export function buildExecaResult( */ export function mockExeca( execaMock: jest.MockedFn, - invocationMocks: ({ - args: Parameters; - } & ( - | { - result?: Partial; - } - | { - error?: Error; - } - ))[], + invocationMocks: ExecaInvocationMock[], ) { execaMock.mockImplementation((...args): ExecaChildProcess => { for (const invocationMock of invocationMocks) { @@ -78,3 +88,27 @@ export function mockExeca( throw new Error(`Unmocked invocation of execa() with ${inspect(args)}`); }); } + +/** + * Uses Jest's fake timers to fake Date only. + */ +export function fakeDateOnly() { + jest.useFakeTimers({ + doNotFake: [ + 'hrtime', + 'nextTick', + 'performance', + 'queueMicrotask', + 'requestAnimationFrame', + 'cancelAnimationFrame', + 'requestIdleCallback', + 'cancelIdleCallback', + 'setImmediate', + 'clearImmediate', + 'setInterval', + 'clearInterval', + 'setTimeout', + 'clearTimeout', + ], + }); +} diff --git a/tests/setup-tool-with-mock-repository.ts b/tests/setup-tool-with-mock-repository.ts new file mode 100644 index 0000000..e8c4a4a --- /dev/null +++ b/tests/setup-tool-with-mock-repository.ts @@ -0,0 +1,352 @@ +import { + ensureDirectoryStructureExists, + writeFile, +} from '@metamask/utils/node'; +import fs from 'fs'; +import path from 'path'; + +import type { + PrimaryExecaFunction, + ExecaMockInvocationResult, + ExecaInvocationMock, +} from './helpers'; +import { mockExeca } from './helpers'; + +/** + * Mock results for commands that this tool runs. + */ +type CommandMocks = Record< + | 'git symbolic-ref HEAD' + | 'git rev-parse --verify main' + | 'git rev-parse --verify master' + | 'git pull' + | 'git clone', + () => ExecaMockInvocationResult +>; + +/** + * Used by `setupToolWithMockRepository` to customize a repository depending on + * the test in question. + */ +type RepositoryConfigurationOptions = { + /** + * The name of the repository, which will become the basename of the + * repository's directory. + */ + name?: string; + /** + * Marks whether or not this repository shows up as archived in the response + * data of the HTTP request used to pull MetaMask repositories. If true, the + * repository will not be linted. + */ + isArchived?: boolean; + /** + * Marks whether or not this repository shows up as a fork in the response + * data of the HTTP request used to pull MetaMask repositories. If true, the + * repository will not be linted. + */ + isFork?: boolean; + /** + * Whether or not to create the repository. (Some tests rely on the repository + * not to exist.) + */ + create?: boolean; + /** + * Where to create the repository. By default, a repository will be placed in + * the cached repositories directory, but sometimes it's useful to put it + * somewhere else. + */ + parentDirectoryPath?: string; + /** + * Options for the `.git/FETCH_DATE` file in the repository. + */ + fetchHead?: { modifiedDate: Date } | null; + /** + * Mocks for the Git commands that this tool runs. + * + * Each key is shorthand for a particular command; each value is a function + * that should either return an `execa` result object or throw an `execa` + * error object. All keys are optional; default actions are listed for each + * command below. + * + * @property "git symbolic-ref HEAD" - Determines the current branch name. + * (Default: `refs/heads/main`.) + * @property "git rev-parse --verify main" - Verifies that `main` exists. + * (Default: In `setupToolWithMockRepositories`, this is successful; in + * `setupToolWithMockRepository`, this is not.) + * @property "git rev-parse --verify master" - Verifies that `main` exists. + * (Default: This is not successful.) + * @property "git pull" - Pulls the latest changes on the default branch. + * (Default: This is successful.) + * @property "git clone" - Clones the repository. (Default: This is + * successful.) + */ + commandMocks?: Partial; +}; + +/** + * A "complete" version of RepositoryConfiguration, with all properties filled + * in. + */ +type RepositoryConfiguration = Required< + Omit +> & { directoryPath: string; commandMocks: CommandMocks }; + +/** + * A repository that the GitHub API is expected to return. + */ +type GitHubRepository = { + name: string; + fork: boolean; + archived: boolean; +}; + +/** + * This tool features two kinds of interactions with the "outside world": it + * shells out in order to perform operations on a Git repository, and it makes + * HTTP requests in order to validate a given repository name matches one of the + * available MetaMask repositories. Since interacting with a MetaMask repository + * may involve an SSH key, and since the list of MetaMask repositories can + * change at any time, in order to write deterministic and maintainable tests we + * must mock these interactions. + * + * To make testing easier, then, this function provides an option to create a + * fake repository in a sandbox, then mocks execution of commands via `execa` as + * well as the HTTP request responsible for pulling MetaMask repositories in + * order to satisfy various requirements in tests that exercise the + * aforementioned interactions. The exact commands executed, the return data for + * the HTTP request, and whether or not the repository is even created is + * customizable. This function also sets a default value for + * `validRepositoriesCachePath` and `cachedRepositoriesDirectoryPath`, as that + * is a basic requirement for higher level operations. + * + * @param args - The arguments to this function. + * @param args.execaMock - The mock version of `execa`. + * @param args.sandboxDirectoryPath - The path to the sandbox directory where we + * can create the repository. + * @param args.repository - Configuration options for the repository involved in + * the test. + * @param args.validRepositories - The list of valid repositories which will be + * used to populate the valid repositories cache. + */ +export async function setupToolWithMockRepository({ + execaMock, + sandboxDirectoryPath, + repository: repositoryConfigurationOptions = {}, + validRepositories: configuredValidRepositories, +}: { + execaMock: jest.MockedFn; + sandboxDirectoryPath: string; + repository?: RepositoryConfigurationOptions; + validRepositories?: { name: string; fork: boolean; archived: boolean }[]; +}) { + const cachedRepositoriesDirectoryPath = path.join( + sandboxDirectoryPath, + 'repositories', + ); + const repositoryConfiguration = fillOutRepositoryConfiguration( + repositoryConfigurationOptions, + cachedRepositoriesDirectoryPath, + ); + + if (repositoryConfiguration.create) { + await createMockRepository(repositoryConfiguration); + } + + const validRepositories = configuredValidRepositories ?? [ + { + name: repositoryConfiguration.name, + fork: repositoryConfiguration.isFork, + archived: repositoryConfiguration.isArchived, + }, + ]; + + const execaInvocationMocks = buildExecaInvocationMocks(validRepositories, [ + repositoryConfiguration, + ]); + mockExeca(execaMock, execaInvocationMocks); + + return { + cachedRepositoriesDirectoryPath, + repository: repositoryConfiguration, + }; +} + +/** + * Using the given configuration, creates a fake repository at a certain + * directory. This directory can then be used and accessed by the tool as though + * it were a real repository. + * + * @param repositoryConfiguration - Instructions for how to create the + * repository. + * @param repositoryConfiguration.directoryPath - The directory that will + * represent the repository. + * @param repositoryConfiguration.fetchHead - Configuration for the `FETCH_HEAD` + * file. + */ +async function createMockRepository({ + directoryPath, + fetchHead, +}: RepositoryConfiguration): Promise { + await ensureDirectoryStructureExists(path.join(directoryPath, '.git')); + + if (fetchHead) { + const { modifiedDate } = fetchHead; + await writeFile(path.join(directoryPath, '.git', 'FETCH_HEAD'), ''); + await fs.promises.utimes( + path.join(directoryPath, '.git', 'FETCH_HEAD'), + modifiedDate, + modifiedDate, + ); + } +} + +/** + * Mocks commands that this tool executes. There are two kinds of commands. One + * command is for retrieving the set of existing MetaMask repositories; the + * other is for interacting with repositories (the module template and any + * projects being linted). + * + * @param validRepositories - The set of valid repositories. + * @param repositoryConfigurations - Customizations for repositories that are + * being interacted with. + * @returns The set of mocks that will be passed into `mockExeca`. + */ +function buildExecaInvocationMocks( + validRepositories: GitHubRepository[], + repositoryConfigurations: RepositoryConfiguration[], +): ExecaInvocationMock[] { + return [ + { + args: [ + 'gh', + ['api', 'orgs/MetaMask/repos', '--cache', '1h', '--paginate'], + ], + result: { + stdout: JSON.stringify(validRepositories), + }, + }, + ...repositoryConfigurations.flatMap((repositoryConfiguration) => { + const repositoryExecaInvocationMocks: ExecaInvocationMock[] = [ + { + args: [ + 'git', + ['symbolic-ref', '--quiet', 'HEAD'], + { cwd: repositoryConfiguration.directoryPath }, + ], + ...repositoryConfiguration.commandMocks['git symbolic-ref HEAD'](), + }, + { + args: [ + 'git', + ['rev-parse', '--verify', '--quiet', 'main'], + { cwd: repositoryConfiguration.directoryPath }, + ], + ...repositoryConfiguration.commandMocks[ + 'git rev-parse --verify main' + ](), + }, + { + args: [ + 'git', + ['rev-parse', '--verify', '--quiet', 'master'], + { cwd: repositoryConfiguration.directoryPath }, + ], + ...repositoryConfiguration.commandMocks[ + 'git rev-parse --verify master' + ](), + }, + { + args: [ + 'git', + ['pull'], + { cwd: repositoryConfiguration.directoryPath }, + ], + ...repositoryConfiguration.commandMocks['git pull'](), + }, + { + args: [ + 'gh', + [ + 'repo', + 'clone', + `MetaMask/${repositoryConfiguration.name}`, + repositoryConfiguration.directoryPath, + ], + ], + ...repositoryConfiguration.commandMocks['git clone'](), + }, + ]; + return repositoryExecaInvocationMocks; + }), + ]; +} + +/** + * Given an options object, builds a complete object that will be used to + * instruct `setupToolWithMockRepository`, fill in missing properties with + * reasonable defaults. This is done so that it is possible to remove irrelevant + * information from tests. + * + * By default, it is assumed that the name of a repository is "some-repo", it is + * a known MetaMask repository, it has not been fetched before, its default + * branch is "main", "master" does not exist, and pulling the repo will work. + * It is also assumed that if `git clone` is run, that will work too. + * + * @param repositoryConfigurationOptions - The repository configuration options. + * @param cachedRepositoriesDirectoryPath - The directory where repositories are + * cached. + * @returns The configured repository. + */ +function fillOutRepositoryConfiguration( + repositoryConfigurationOptions: RepositoryConfigurationOptions, + cachedRepositoriesDirectoryPath: string, +): RepositoryConfiguration { + const { + name = 'some-repo', + parentDirectoryPath: givenParentDirectoryPath, + isFork = false, + isArchived = false, + fetchHead: givenFetchHead = null, + create = false, + commandMocks: givenCommandMocks = {}, + ...rest + } = repositoryConfigurationOptions; + + const parentDirectoryPath = + givenParentDirectoryPath ?? cachedRepositoriesDirectoryPath; + + const directoryPath = path.join(parentDirectoryPath, name); + + const commandMocks = { + 'git symbolic-ref HEAD': () => ({ result: { stdout: 'refs/heads/main' } }), + 'git rev-parse --verify main': () => ({ + result: { stdout: '' }, + }), + 'git rev-parse --verify master': () => ({ + error: new Error('Failed to run: git rev-parse --verify master'), + }), + 'git pull': () => ({ + result: { stdout: '' }, + }), + 'git clone': () => ({ + result: { stdout: '' }, + }), + ...givenCommandMocks, + }; + + const fetchHead = givenFetchHead + ? { modifiedDate: givenFetchHead.modifiedDate ?? new Date() } + : null; + + return { + name, + directoryPath, + isFork, + isArchived, + fetchHead, + create, + commandMocks, + ...rest, + }; +} diff --git a/yarn.lock b/yarn.lock index f91f0af..f503dd5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1019,6 +1019,7 @@ __metadata: prettier: ^2.7.1 prettier-plugin-packagejson: ^2.3.0 rimraf: ^3.0.2 + stdio-mock: ^1.2.0 ts-jest: ^28.0.7 ts-node: ^10.7.0 typedoc: ^0.23.15 @@ -6491,6 +6492,13 @@ __metadata: languageName: node linkType: hard +"stdio-mock@npm:^1.2.0": + version: 1.2.0 + resolution: "stdio-mock@npm:1.2.0" + checksum: 5c8739e11fc5a18cd5ef0b2e8d900cd1cd4e851f69915d3a829ebdaefd5292ece17f29981516636f9b9acb3026d8c802de6f138280a293cc484160a6c67e65ef + languageName: node + linkType: hard + "string-length@npm:^4.0.1": version: 4.0.1 resolution: "string-length@npm:4.0.1"