diff --git a/package.json b/package.json index 75aedf58..800cccb7 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "@types/yargs": "^17.0.10", "@typescript-eslint/eslint-plugin": "^4.21.0", "@typescript-eslint/parser": "^4.21.0", + "deepmerge": "^4.2.2", "eslint": "^7.23.0", "eslint-config-prettier": "^8.1.0", "eslint-plugin-import": "^2.22.1", diff --git a/src/fs.test.ts b/src/fs.test.ts index f92f792d..d692dc74 100644 --- a/src/fs.test.ts +++ b/src/fs.test.ts @@ -4,7 +4,7 @@ import util from 'util'; import rimraf from 'rimraf'; import { when } from 'jest-when'; import * as actionUtils from '@metamask/action-utils'; -import { withSandbox } from '../tests/unit/helpers'; +import { withSandbox } from '../tests/helpers'; import { readFile, writeFile, diff --git a/src/functional.test.ts b/src/functional.test.ts new file mode 100644 index 00000000..4c1bb33c --- /dev/null +++ b/src/functional.test.ts @@ -0,0 +1,250 @@ +import { withMonorepoProjectEnvironment } from '../tests/functional/helpers/with'; +import { buildChangelog } from '../tests/functional/helpers/utils'; + +describe('create-release-branch (functional)', () => { + describe('against a monorepo with independent versions', () => { + it('updates the version of the root package to be the current date along with the versions of the specified packages', async () => { + await withMonorepoProjectEnvironment( + { + packages: { + $root$: { + name: '@scope/monorepo', + version: '2022.1.1', + directoryPath: '.', + }, + a: { + name: '@scope/a', + version: '0.1.2', + directoryPath: 'packages/a', + }, + b: { + name: '@scope/b', + version: '1.1.4', + directoryPath: 'packages/b', + }, + c: { + name: '@scope/c', + version: '2.0.13', + directoryPath: 'packages/c', + }, + d: { + name: '@scope/d', + version: '1.2.3', + directoryPath: 'packages/d', + }, + e: { + name: '@scope/e', + version: '0.0.3', + directoryPath: 'packages/e', + }, + }, + workspaces: { + '.': ['packages/*'], + }, + today: new Date('2022-06-24'), + }, + async (environment) => { + await environment.runTool({ + releaseSpecification: { + packages: { + a: 'major', + b: 'minor', + c: 'patch', + d: '1.2.4', + }, + }, + }); + + expect(await environment.readJsonFile('package.json')).toMatchObject({ + version: '2022.6.24', + }); + expect( + await environment.readJsonFileWithinPackage('a', 'package.json'), + ).toMatchObject({ + version: '1.0.0', + }); + expect( + await environment.readJsonFileWithinPackage('b', 'package.json'), + ).toMatchObject({ + version: '1.2.0', + }); + expect( + await environment.readJsonFileWithinPackage('c', 'package.json'), + ).toMatchObject({ + version: '2.0.14', + }); + expect( + await environment.readJsonFileWithinPackage('d', 'package.json'), + ).toMatchObject({ + version: '1.2.4', + }); + expect( + await environment.readJsonFileWithinPackage('e', 'package.json'), + ).toMatchObject({ + version: '0.0.3', + }); + }, + ); + }); + + it("updates each of the specified packages' changelogs by adding a new section which lists all commits concerning the package over the entire history of the repo", async () => { + await withMonorepoProjectEnvironment( + { + packages: { + $root$: { + name: '@scope/monorepo', + version: '2022.1.1', + directoryPath: '.', + }, + a: { + name: '@scope/a', + version: '1.0.0', + directoryPath: 'packages/a', + }, + b: { + name: '@scope/b', + version: '1.0.0', + directoryPath: 'packages/b', + }, + }, + workspaces: { + '.': ['packages/*'], + }, + createInitialCommit: false, + }, + async (environment) => { + // Create an initial commit + await environment.writeFileWithinPackage( + 'a', + 'CHANGELOG.md', + buildChangelog(` + ## [Unreleased] + + [Unreleased]: https://github.com/example-org/example-repo + `), + ); + await environment.writeFileWithinPackage( + 'b', + 'CHANGELOG.md', + buildChangelog(` + ## [Unreleased] + + [Unreleased]: https://github.com/example-org/example-repo + `), + ); + await environment.createCommit('Initial commit'); + + // Create another commit that only changes "a" + await environment.writeFileWithinPackage( + 'a', + 'dummy.txt', + 'Some content', + ); + await environment.createCommit('Update "a"'); + + // Run the tool + await environment.runTool({ + releaseSpecification: { + packages: { + a: 'major', + b: 'major', + }, + }, + }); + + // Both changelogs should get updated, with an additional + // commit listed for "a" + expect( + await environment.readFileWithinPackage('a', 'CHANGELOG.md'), + ).toStrictEqual( + buildChangelog(` + ## [Unreleased] + + ## [2.0.0] + ### Uncategorized + - Update "a" + - Initial commit + + [Unreleased]: https://github.com/example-org/example-repo/compare/v2.0.0...HEAD + [2.0.0]: https://github.com/example-org/example-repo/releases/tag/v2.0.0 + `), + ); + expect( + await environment.readFileWithinPackage('b', 'CHANGELOG.md'), + ).toStrictEqual( + buildChangelog(` + ## [Unreleased] + + ## [2.0.0] + ### Uncategorized + - Initial commit + + [Unreleased]: https://github.com/example-org/example-repo/compare/v2.0.0...HEAD + [2.0.0]: https://github.com/example-org/example-repo/releases/tag/v2.0.0 + `), + ); + }, + ); + }); + + it('switches to a new release branch and commits the changes', async () => { + await withMonorepoProjectEnvironment( + { + packages: { + $root$: { + name: '@scope/monorepo', + version: '2022.1.1', + directoryPath: '.', + }, + a: { + name: '@scope/a', + version: '1.0.0', + directoryPath: 'packages/a', + }, + }, + workspaces: { + '.': ['packages/*'], + }, + today: new Date('2022-06-24'), + }, + async (environment) => { + await environment.runTool({ + releaseSpecification: { + packages: { + a: 'major', + }, + }, + }); + + // Tests four things: + // * The latest commit should be called "Release YYYY-MM-DD" + // * The latest commit should be the current commit (HEAD) + // * The latest branch should be called "release/YYYY-MM-DD" + // * The latest branch should point to the latest commit + const [latestCommitSubject, latestCommitId, latestCommitRevsMarker] = + ( + await environment.runCommand('git', [ + 'log', + '--pretty=%s%x09%H%x09%D', + '--date-order', + '--max-count=1', + ]) + ).stdout.split('\x09'); + const latestCommitRevs = latestCommitRevsMarker.split(' -> '); + const latestBranchCommitId = ( + await environment.runCommand('git', [ + 'rev-list', + '--branches', + '--date-order', + '--max-count=1', + ]) + ).stdout; + expect(latestCommitSubject).toStrictEqual('Release 2022-06-24'); + expect(latestCommitRevs).toContain('HEAD'); + expect(latestCommitRevs).toContain('release/2022-06-24'); + expect(latestBranchCommitId).toStrictEqual(latestCommitId); + }, + ); + }); + }); +}); diff --git a/src/monorepo-workflow-operations.test.ts b/src/monorepo-workflow-operations.test.ts index 2d225cf2..54475f09 100644 --- a/src/monorepo-workflow-operations.test.ts +++ b/src/monorepo-workflow-operations.test.ts @@ -1,11 +1,8 @@ import fs from 'fs'; import path from 'path'; import { SemVer } from 'semver'; -import { - withSandbox, - buildMockPackage, - buildMockProject, -} from '../tests/unit/helpers'; +import { withSandbox } from '../tests/helpers'; +import { buildMockPackage, buildMockProject } from '../tests/unit/helpers'; import { followMonorepoWorkflow } from './monorepo-workflow-operations'; import * as editorModule from './editor'; import * as envModule from './env'; diff --git a/src/package-manifest.test.ts b/src/package-manifest.test.ts index 1414c005..fc4021ef 100644 --- a/src/package-manifest.test.ts +++ b/src/package-manifest.test.ts @@ -1,7 +1,7 @@ import fs from 'fs'; import path from 'path'; import { SemVer } from 'semver'; -import { withSandbox } from '../tests/unit/helpers'; +import { withSandbox } from '../tests/helpers'; import { readPackageManifest } from './package-manifest'; describe('package-manifest', () => { diff --git a/src/package.test.ts b/src/package.test.ts index c694de06..8398508d 100644 --- a/src/package.test.ts +++ b/src/package.test.ts @@ -2,13 +2,10 @@ import fs from 'fs'; import path from 'path'; import { when } from 'jest-when'; import * as autoChangelog from '@metamask/auto-changelog'; -import { - buildMockProject, - buildMockManifest, - withSandbox, -} from '../tests/unit/helpers'; -import { readPackage, updatePackage } from './package'; +import { withSandbox } from '../tests/helpers'; +import { buildMockProject, buildMockManifest } from '../tests/unit/helpers'; import * as fsModule from './fs'; +import { readPackage, updatePackage } from './package'; import * as packageManifestModule from './package-manifest'; jest.mock('@metamask/auto-changelog'); diff --git a/src/project.test.ts b/src/project.test.ts index 95d7d7a4..029f28ab 100644 --- a/src/project.test.ts +++ b/src/project.test.ts @@ -1,11 +1,8 @@ import fs from 'fs'; import path from 'path'; import { when } from 'jest-when'; -import { - buildMockManifest, - buildMockPackage, - withSandbox, -} from '../tests/unit/helpers'; +import { withSandbox } from '../tests/helpers'; +import { buildMockManifest, buildMockPackage } from '../tests/unit/helpers'; import { readProject } from './project'; import * as packageModule from './package'; import * as repoModule from './repo'; diff --git a/src/release-specification.test.ts b/src/release-specification.test.ts index bbf3bad2..6cc28c60 100644 --- a/src/release-specification.test.ts +++ b/src/release-specification.test.ts @@ -4,11 +4,8 @@ import { when } from 'jest-when'; import { MockWritable } from 'stdio-mock'; import YAML from 'yaml'; import { SemVer } from 'semver'; -import { - withSandbox, - buildMockProject, - buildMockPackage, -} from '../tests/unit/helpers'; +import { withSandbox } from '../tests/helpers'; +import { buildMockProject, buildMockPackage } from '../tests/unit/helpers'; import { generateReleaseSpecificationTemplateForMonorepo, waitForUserToEditReleaseSpecification, diff --git a/tests/functional/helpers/constants.ts b/tests/functional/helpers/constants.ts new file mode 100644 index 00000000..ecfd1125 --- /dev/null +++ b/tests/functional/helpers/constants.ts @@ -0,0 +1,10 @@ +import path from 'path'; + +const ROOT_DIR = path.resolve(__dirname, '../../..'); +export const TOOL_EXECUTABLE_PATH = path.join(ROOT_DIR, 'src', 'cli.ts'); +export const TS_NODE_PATH = path.join( + ROOT_DIR, + 'node_modules', + '.bin', + 'ts-node', +); diff --git a/tests/functional/helpers/environment.ts b/tests/functional/helpers/environment.ts new file mode 100644 index 00000000..c4a9ac64 --- /dev/null +++ b/tests/functional/helpers/environment.ts @@ -0,0 +1,96 @@ +import LocalRepo from './local-repo'; +import RemoteRepo from './remote-repo'; +import Repo from './repo'; + +/** + * Describes the package that is used to initialize a polyrepo, or one of the + * packages that is used to initialize a monorepo. + * + * @property name - The desired name of the package. + * @property version - The desired version of the package. + * @property directory - The path relative to the repo's root directory that + * holds this package. + */ +export interface PackageSpecification { + name: string; + version?: string; + directoryPath: string; +} + +/** + * A set of configuration options for an {@link Environment}. + * + * @property directoryPath - The directory out of which this environment will + * operate. + * @property createInitialCommit - Usually when a repo is initialized, a commit + * is created (which will contain starting `package.json` files). You can use + * this option to disable that if you need to create your own commits for + * clarity. + */ +export interface EnvironmentOptions { + directoryPath: string; + createInitialCommit?: boolean; +} + +/** + * This class sets up each test and acts as a facade to all of the actions that + * we need to take from within the test. + */ +export default abstract class Environment { + protected directoryPath: EnvironmentOptions['directoryPath']; + + protected createInitialCommit: boolean; + + protected remoteRepo: Repo; + + protected localRepo: SpecificLocalRepo; + + readJsonFile: SpecificLocalRepo['readJsonFile']; + + readFile: SpecificLocalRepo['readFile']; + + updateJsonFile: SpecificLocalRepo['updateJsonFile']; + + writeJsonFile: SpecificLocalRepo['writeJsonFile']; + + writeFile: SpecificLocalRepo['writeFile']; + + runCommand: SpecificLocalRepo['runCommand']; + + createCommit: SpecificLocalRepo['createCommit']; + + constructor(options: EnvironmentOptions) { + const { directoryPath, createInitialCommit = true } = options; + this.directoryPath = directoryPath; + this.createInitialCommit = createInitialCommit; + this.remoteRepo = new RemoteRepo({ + environmentDirectoryPath: directoryPath, + }); + this.localRepo = this.buildLocalRepo(options); + this.readJsonFile = this.localRepo.readJsonFile.bind(this.localRepo); + this.readFile = this.localRepo.readFile.bind(this.localRepo); + this.updateJsonFile = this.localRepo.updateJsonFile.bind(this.localRepo); + this.writeJsonFile = this.localRepo.writeJsonFile.bind(this.localRepo); + this.writeFile = this.localRepo.writeFile.bind(this.localRepo); + this.runCommand = this.localRepo.runCommand.bind(this.localRepo); + this.createCommit = this.localRepo.createCommit.bind(this.localRepo); + } + + /** + * Creates two repos: a "remote" repo so that the tool can run commands such + * as `git fetch --tags`, and a "local" repo, which is the one against which + * the tool is run. + */ + async initialize() { + await this.remoteRepo.initialize(); + await this.localRepo.initialize(); + } + + /** + * This method is overridden in subclasses to return either a LocalPolyrepo or + * a LocalMonorepo, depending on the use case. + */ + protected abstract buildLocalRepo( + options: EnvironmentOptions, + ): SpecificLocalRepo; +} diff --git a/tests/functional/helpers/local-monorepo.ts b/tests/functional/helpers/local-monorepo.ts new file mode 100644 index 00000000..09457268 --- /dev/null +++ b/tests/functional/helpers/local-monorepo.ts @@ -0,0 +1,181 @@ +import path from 'path'; +import { PackageSpecification } from './environment'; +import LocalRepo, { LocalRepoOptions } from './local-repo'; +import { knownKeysOf } from './utils'; + +/** + * A set of configuration options for a {@link LocalMonorepo}. In addition + * to the options listed in {@link LocalRepoOptions}, these include: + * + * @property packages - The known packages within this repo (including the + * root). + * @property workspaces - The known workspaces within this repo. + */ +export interface LocalMonorepoOptions + extends LocalRepoOptions { + packages: Record; + workspaces: Record; +} + +/** + * Represents the repo that the tool is run against, containing logic specific + * to a monorepo. + */ +export default class LocalMonorepo< + WorkspacePackageNickname extends string, +> extends LocalRepo { + /** + * The known packages within this repo (including the root). + */ + #packages: Record<'$root$' | WorkspacePackageNickname, PackageSpecification>; + + /** + * The known workspaces within this repo. + */ + #workspaces: LocalMonorepoOptions['workspaces']; + + constructor({ + packages, + workspaces, + ...rest + }: LocalMonorepoOptions) { + super(rest); + this.#packages = { + $root$: { + name: 'monorepo', + version: '2022.1.1', + directoryPath: '.', + }, + ...packages, + }; + this.#workspaces = workspaces; + } + + /** + * Reads a file within a workspace package within the project. + * + * @param packageNickname - The nickname of the workspace package, as + * identified in the `packages` options passed to + * `withMonorepoProjectEnvironment`. + * @param partialFilePath - The path to the desired file within the package. + * @returns The content of the file. + */ + async readFileWithinPackage( + packageNickname: '$root$' | WorkspacePackageNickname, + partialFilePath: string, + ) { + const packageDirectoryPath = this.#packages[packageNickname].directoryPath; + return await this.readFile( + path.join(packageDirectoryPath, partialFilePath), + ); + } + + /** + * Reads a JSON file within a workspace package within the project. + * + * @param packageNickname - The nickname of the workspace package, as + * identified in the `packages` options passed to + * `withMonorepoProjectEnvironment`. + * @param partialFilePath - The path to the desired file within the package. + * @returns The object which the JSON file holds. + */ + async readJsonFileWithinPackage( + packageNickname: '$root$' | WorkspacePackageNickname, + partialFilePath: string, + ) { + const packageDirectoryPath = this.#packages[packageNickname].directoryPath; + return await this.readJsonFile( + path.join(packageDirectoryPath, partialFilePath), + ); + } + + /** + * Creates or overwrites a file within a workspace package within the project. + * + * @param packageNickname - The nickname of the workspace package, as + * identified in the `packages` options passed to + * `withMonorepoProjectEnvironment`. + * @param partialFilePath - The path to the desired file within the package. + * @param contents - The desired contents of the file. + */ + async writeFileWithinPackage( + packageNickname: '$root$' | WorkspacePackageNickname, + partialFilePath: string, + contents: string, + ): Promise { + const packageDirectoryPath = this.#packages[packageNickname].directoryPath; + await this.writeFile( + path.join(packageDirectoryPath, partialFilePath), + contents, + ); + } + + /** + * Creates or overwrites a JSON file within a workspace package within the + * project. + * + * @param packageNickname - The nickname of the workspace package, as + * identified in the `packages` options passed to + * `withMonorepoProjectEnvironment`. + * @param partialFilePath - The path to the desired file within the package. + * @param object - The new object that the file should represent. + */ + async writeJsonFileWithinPackage( + packageNickname: '$root$' | WorkspacePackageNickname, + partialFilePath: string, + object: Record, + ): Promise { + const packageDirectoryPath = this.#packages[packageNickname].directoryPath; + await this.writeJsonFile( + path.join(packageDirectoryPath, partialFilePath), + object, + ); + } + + /** + * Writes an initial package.json for the root package as well as any + * workspace packages (if specified). + */ + protected async afterCreate() { + await super.afterCreate(); + + await this.writeJsonFile('package.json', { private: true }); + + // Update manifests for root and workspace packages with `name`, `version`, + // and (optionally) `workspaces` + await Promise.all( + knownKeysOf(this.#packages).map((packageName) => { + const pkg = this.#packages[packageName]; + const content = { + name: pkg.name, + ...('version' in pkg ? { version: pkg.version } : {}), + ...(pkg.directoryPath in this.#workspaces + ? { workspaces: this.#workspaces[pkg.directoryPath] } + : {}), + }; + return this.updateJsonFile( + path.join(pkg.directoryPath, 'package.json'), + content, + ); + }), + ); + } + + /** + * Gets the name of the primary package that this project represents. + * + * @returns The name of the root package. + */ + protected getPackageName() { + return this.#packages.$root$.name; + } + + /** + * Gets the version of the primary package that this project represents. + * + * @returns The version of the root package. + */ + protected getPackageVersion() { + return this.#packages.$root$.version; + } +} diff --git a/tests/functional/helpers/local-repo.ts b/tests/functional/helpers/local-repo.ts new file mode 100644 index 00000000..8e46de35 --- /dev/null +++ b/tests/functional/helpers/local-repo.ts @@ -0,0 +1,126 @@ +import path from 'path'; +import Repo, { RepoOptions } from './repo'; +import { buildChangelog } from './utils'; + +/** + * A set of configuration options for a {@link LocalRepo}. In addition to the + * options listed in {@link RepoOptions}, these include: + * + * @property remoteRepoDirectoryPath - The directory that holds the "remote" + * companion of this repo. + * @property createInitialCommit - Usually when this repo is initialized, a + * commit is created (which will contain starting `package.json` files). You can + * use this option to disable that if you need to create your own commits for + * clarity. + */ +export interface LocalRepoOptions extends RepoOptions { + remoteRepoDirectoryPath: string; + createInitialCommit: boolean; +} + +/** + * A facade for the "local" repo, which is the repo with which the tool + * interacts. + */ +export default abstract class LocalRepo extends Repo { + /** + * The directory that holds the "remote" companion of this repo. + */ + #remoteRepoDirectoryPath: string; + + /** + * Usually when this repo is initialized, a commit is created (which will + * contain starting `package.json` files). You can use this option to disable + * that if you need to create your own commits for clarity. + */ + #createInitialCommit: boolean; + + constructor({ + remoteRepoDirectoryPath, + createInitialCommit, + ...rest + }: LocalRepoOptions) { + super(rest); + this.#remoteRepoDirectoryPath = remoteRepoDirectoryPath; + this.#createInitialCommit = createInitialCommit; + } + + /** + * Clones the "remote" repo. + */ + protected async create() { + await this.runCommand( + 'git', + ['clone', this.#remoteRepoDirectoryPath, this.getWorkingDirectoryPath()], + { cwd: path.resolve(this.getWorkingDirectoryPath(), '..') }, + ); + } + + /** + * Writes an initial `package.json` (based on the configured name and version) + * and changelog. Also creates an initial commit if this repo was configured + * with `createInitialCommit: true`. + */ + protected async afterCreate() { + await super.afterCreate(); + + // We reconfigure the repo such that it ostensibly has a remote that points + // to a https:// or git:// URL, yet secretly points to the repo cloned + // above. This way the tool is able to verify that the URL of `origin` is + // correct, but we don't actually have to hit the internet when we run `git + // fetch --tags`, etc. + await this.runCommand('git', ['remote', 'remove', 'origin']); + await this.runCommand('git', [ + 'remote', + 'add', + 'origin', + 'https://github.com/example-org/example-repo', + ]); + await this.runCommand('git', [ + 'config', + `url.${this.#remoteRepoDirectoryPath}.insteadOf`, + 'https://github.com/example-org/example-repo', + ]); + + await this.writeJsonFile('package.json', { + name: this.getPackageName(), + version: this.getPackageVersion(), + }); + + await this.writeFile( + 'CHANGELOG.md', + buildChangelog( + ` +## [Unreleased] + +[Unreleased]: https://github.com/example-org/example-repo/commits/main + `.slice(1), + ), + ); + + if (this.#createInitialCommit) { + await this.createCommit('Initial commit'); + } + } + + /** + * Returns the path of the directory where this repo is located. + * + * @returns `local-repo` within the environment directory. + */ + getWorkingDirectoryPath() { + return path.join(this.environmentDirectoryPath, 'local-repo'); + } + + /** + * Returns the name of the sole or main package that this repo represents. + * Overridden in subclasses. + */ + protected abstract getPackageName(): string; + + /** + * Returns the version of the sole or main package that this repo represents. + * Overridden in subclasses> + */ + protected abstract getPackageVersion(): string | undefined; +} diff --git a/tests/functional/helpers/monorepo-environment.ts b/tests/functional/helpers/monorepo-environment.ts new file mode 100644 index 00000000..c6f4ebc0 --- /dev/null +++ b/tests/functional/helpers/monorepo-environment.ts @@ -0,0 +1,172 @@ +import fs from 'fs'; +import path from 'path'; +import { ExecaReturnValue } from 'execa'; +import YAML from 'yaml'; +import { TOOL_EXECUTABLE_PATH, TS_NODE_PATH } from './constants'; +import Environment, { + EnvironmentOptions, + PackageSpecification, +} from './environment'; +import LocalMonorepo from './local-monorepo'; +import { debug, knownKeysOf } from './utils'; + +/** + * A set of configuration options for a {@link MonorepoEnvironment}. In addition + * to the options listed in {@link EnvironmentOptions}, these include: + * + * @property packages - The known packages within this repo (including the + * root). + * @property workspaces - The known workspaces within this repo. + * @property today - The date that will be used for new releases. Will be + * translated to the TODAY environment variables. + */ +export interface MonorepoEnvironmentOptions< + WorkspacePackageNickname extends string, +> extends EnvironmentOptions { + packages: Record; + workspaces: Record; + today?: Date; +} + +/** + * The release specification data. + * + * @property packages - The workspace packages within this repo that will be + * released. + */ +interface ReleaseSpecification { + packages: Partial>; +} + +/** + * This class configures the environment such that the "local" repo becomes a + * monorepo. + */ +export default class MonorepoEnvironment< + WorkspacePackageNickname extends string, +> extends Environment> { + readFileWithinPackage: LocalMonorepo['readFileWithinPackage']; + + writeFileWithinPackage: LocalMonorepo['writeFileWithinPackage']; + + readJsonFileWithinPackage: LocalMonorepo['readJsonFileWithinPackage']; + + #packages: MonorepoEnvironmentOptions['packages']; + + #today: MonorepoEnvironmentOptions['today']; + + constructor({ + today, + ...rest + }: MonorepoEnvironmentOptions) { + super(rest); + this.#packages = rest.packages; + this.#today = today; + this.readFileWithinPackage = this.localRepo.readFileWithinPackage.bind( + this.localRepo, + ); + this.writeFileWithinPackage = this.localRepo.writeFileWithinPackage.bind( + this.localRepo, + ); + this.readJsonFileWithinPackage = + this.localRepo.readJsonFileWithinPackage.bind(this.localRepo); + } + + /** + * Runs the tool within the context of the project, editing the generated + * release spec template automatically with the given information before + * continuing. + * + * @param args - The arguments to this function. + * @param args.releaseSpecification - An object which specifies which packages + * should be bumped, where keys are the *nicknames* of packages as specified + * in the set of options passed to `withMonorepoProjectEnvironment`. Will be + * used to fill in the release spec file that the tool generates. + * @returns The result of the command. + */ + async runTool({ + releaseSpecification: releaseSpecificationWithPackageNicknames, + }: { + releaseSpecification: ReleaseSpecification; + }): Promise> { + const releaseSpecificationPath = path.join( + this.directoryPath, + 'release-spec', + ); + const releaseSpecificationWithPackageNames = { + packages: knownKeysOf( + releaseSpecificationWithPackageNicknames.packages, + ).reduce((obj, packageNickname) => { + const packageSpecification = this.#packages[packageNickname]; + const versionSpecifier = + releaseSpecificationWithPackageNicknames.packages[packageNickname]; + return { ...obj, [packageSpecification.name]: versionSpecifier }; + }, {}), + }; + await fs.promises.writeFile( + releaseSpecificationPath, + YAML.stringify(releaseSpecificationWithPackageNames), + ); + + const releaseSpecificationEditorPath = path.join( + this.directoryPath, + 'release-spec-editor', + ); + await fs.promises.writeFile( + releaseSpecificationEditorPath, + ` +#!/bin/sh + +if [ -z "$1" ]; then + echo "ERROR: Must provide a path to edit." + exit 1 +fi + +cat "${releaseSpecificationPath}" > "$1" + `.trim(), + ); + await fs.promises.chmod(releaseSpecificationEditorPath, 0o777); + + const env = { + EDITOR: releaseSpecificationEditorPath, + ...(this.#today === undefined + ? {} + : { TODAY: this.#today.toISOString().replace(/T.+$/u, '') }), + }; + + const result = await this.localRepo.runCommand( + TS_NODE_PATH, + [ + '--transpileOnly', + TOOL_EXECUTABLE_PATH, + '--project-directory', + this.localRepo.getWorkingDirectoryPath(), + '--temp-directory', + path.join(this.localRepo.getWorkingDirectoryPath(), 'tmp'), + ], + { env }, + ); + + debug( + ['---- START OUTPUT -----', result.all, '---- END OUTPUT -----'].join( + '\n', + ), + ); + + return result; + } + + protected buildLocalRepo({ + packages, + workspaces, + createInitialCommit = true, + }: MonorepoEnvironmentOptions): LocalMonorepo { + return new LocalMonorepo({ + environmentDirectoryPath: this.directoryPath, + remoteRepoDirectoryPath: this.remoteRepo.getWorkingDirectoryPath(), + packages, + workspaces, + createInitialCommit, + }); + } +} diff --git a/tests/functional/helpers/remote-repo.ts b/tests/functional/helpers/remote-repo.ts new file mode 100644 index 00000000..2a3c1e7c --- /dev/null +++ b/tests/functional/helpers/remote-repo.ts @@ -0,0 +1,28 @@ +import fs from 'fs'; +import path from 'path'; +import Repo from './repo'; + +/** + * A facade for the "remote" repo, which only exists so that the tool can run + * `git fetch --tags`. + */ +export default class RemoteRepo extends Repo { + /** + * Creates a bare repo. + */ + async create() { + await fs.promises.mkdir(this.getWorkingDirectoryPath(), { + recursive: true, + }); + await this.runCommand('git', ['init', '--bare']); + } + + /** + * Returns the path of the directory where this repo is located. + * + * @returns `remote-repo` within the environment directory. + */ + getWorkingDirectoryPath() { + return path.join(this.environmentDirectoryPath, 'remote-repo'); + } +} diff --git a/tests/functional/helpers/repo.ts b/tests/functional/helpers/repo.ts new file mode 100644 index 00000000..37f57050 --- /dev/null +++ b/tests/functional/helpers/repo.ts @@ -0,0 +1,243 @@ +import fs from 'fs'; +import path from 'path'; +import execa, { ExecaChildProcess, Options as ExecaOptions } from 'execa'; +import deepmerge from 'deepmerge'; +import { debug, isErrorWithCode, sleepFor } from './utils'; + +/** + * A set of configuration options for a {@link Repo}. + * + * @property environmentDirectoryPath - The directory that holds the environment + * that created this repo. + */ +export interface RepoOptions { + environmentDirectoryPath: string; +} + +/** + * The minimum amount of time that the tests will ensure exists between commits. + * + * @see createCommit + */ +const MIN_TIME_BETWEEN_COMMITS = 500; + +/** + * A facade for a Git repository. + */ +export default abstract class Repo { + /** + * The directory that holds the environment that created this repo. + */ + protected environmentDirectoryPath: RepoOptions['environmentDirectoryPath']; + + /** + * The time at which the last commit was created. Used to determine whether we + * need to sleep before the next commit is created. + */ + #latestCommitTime: Date | undefined; + + constructor({ environmentDirectoryPath }: RepoOptions) { + this.environmentDirectoryPath = environmentDirectoryPath; + this.#latestCommitTime = undefined; + } + + /** + * Sets up the repo. + */ + async initialize() { + await this.create(); + await this.afterCreate(); + } + + /** + * Reads the contents of a file in the project that is expected to hold + * JSON data, with JSON deserialization/serialization handled automatically. + * + * @param partialFilePath - The path to the file, with the path to the project + * directory omitted. + * @returns The object which the JSON file holds. + */ + async readJsonFile( + partialFilePath: string, + ): Promise> { + return JSON.parse(await this.readFile(partialFilePath)); + } + + /** + * Reads the contents of a file in the project. + * + * @param partialFilePath - The path to the file, with the path to the project + * directory omitted. + * @returns The file contents. + */ + async readFile(partialFilePath: string): Promise { + return await fs.promises.readFile(this.pathTo(partialFilePath), 'utf8'); + } + + /** + * Updates the contents of a file in the project that is expected to hold JSON + * data, with JSON deserialization/serialization handled automatically. If the + * file does not exist, it is assumed to return `{}`. + * + * @param partialFilePath - The path to the file, with the path to the project + * directory omitted. + * @param updates - The updates to apply to the contents of the JSON file. + * @returns The result of `fs.promises.writeFile`. + */ + async updateJsonFile( + partialFilePath: string, + updates: Record, + ): Promise { + let newObject: Record; + + try { + const object = await this.readJsonFile(partialFilePath); + newObject = deepmerge(object, updates); + } catch (error) { + if (isErrorWithCode(error) && error.code === 'ENOENT') { + newObject = updates; + } else { + throw error; + } + } + + return await this.writeJsonFile(partialFilePath, newObject); + } + + /** + * Creates or overwrites a file in the project that is expected to hold JSON + * data, with JSON deserialization/serialization handled automatically. + * + * @param partialFilePath - The path to the file, with the path to the project + * directory omitted. + * @param object - The new object that the file should represent. + * @returns The result of `fs.promises.writeFile`. + */ + async writeJsonFile( + partialFilePath: string, + object: Record, + ): Promise { + return await this.writeFile(partialFilePath, JSON.stringify(object)); + } + + /** + * Creates or overwrites a file in the project. If the directory where the + * file is located does not exist, it will be created. + * + * @param partialFilePath - The path to the file, with the path to the project + * directory omitted. + * @param contents - The desired contents of the file. + * @returns The result of `fs.promises.writeFile`. + */ + async writeFile(partialFilePath: string, contents: string): Promise { + const fullFilePath = this.pathTo(partialFilePath); + await fs.promises.mkdir(path.dirname(fullFilePath), { recursive: true }); + return await fs.promises.writeFile(fullFilePath, contents); + } + + /** + * Creates a Git commit with the given message, ensuring that any unstaged + * changes are staged first (or allowing the commit to be created without any + * staged changes, if none exist). + * + * @param message - The commit message. + * @returns The result of the command. + */ + async createCommit(message: string): Promise> { + // When we are creating commits in tests, the dates of those commits may be + // so close together that it ends up confusing commands like `git rev-list` + // (which sorts commits in chronological order). Sleeping for a bit seems to + // solve this problem. + const timeSincePreviousCommit = + this.#latestCommitTime === undefined + ? null + : new Date().getTime() - this.#latestCommitTime.getTime(); + + if ( + timeSincePreviousCommit !== null && + timeSincePreviousCommit < MIN_TIME_BETWEEN_COMMITS + ) { + await sleepFor(MIN_TIME_BETWEEN_COMMITS - timeSincePreviousCommit); + } + + await this.runCommand('git', ['add', '-A']); + const result = await this.runCommand('git', ['commit', '-m', message]); + this.#latestCommitTime = new Date(); + return result; + } + + /** + * Runs a command within the context of the project. + * + * @param executableName - The executable to run. + * @param args - The arguments to the executable. + * @param options - Options to `execa`. + * @returns The result of the command. + */ + async runCommand( + executableName: string, + args?: readonly string[] | undefined, + options?: ExecaOptions | undefined, + ): Promise> { + const { env, ...remainingOptions } = + options === undefined ? { env: {} } : options; + + debug( + 'Running command `%s %s`...', + executableName, + args?.map((arg) => (arg.includes(' ') ? `"${arg}"` : arg)).join(' '), + ); + + const result = await execa(executableName, args, { + all: true, + cwd: this.getWorkingDirectoryPath(), + env: { + ...env, + DEBUG_COLORS: '1', + }, + ...remainingOptions, + }); + + debug( + 'Completed command `%s %s`', + executableName, + args?.map((arg) => (arg.includes(' ') ? `"${arg}"` : arg)).join(' '), + ); + + return result; + } + + /** + * Custom logic with which to create the repo. Can be overridden in + * subclasses. + */ + protected async create(): Promise { + // no-op + } + + /** + * Custom logic with which to further initialize the repo after it is created. + * By default, this configures Git to use an email and name for commits. + * Can be overridden in subclasses. + */ + protected async afterCreate(): Promise { + await this.runCommand('git', ['config', 'user.email', 'test@example.com']); + await this.runCommand('git', ['config', 'user.name', 'Test User']); + } + + /** + * Constructs the path of a file or directory within the project. + * + * @param partialEntryPath - The path to the file or directory, with the path + * to the project directory omitted. + * @returns The full path to the file or directory. + */ + protected pathTo(partialEntryPath: string): string { + return path.resolve(this.getWorkingDirectoryPath(), partialEntryPath); + } + + /** + * Returns the directory where the repo is located. Overridden in subclasses. + */ + abstract getWorkingDirectoryPath(): string; +} diff --git a/tests/functional/helpers/utils.ts b/tests/functional/helpers/utils.ts new file mode 100644 index 00000000..e9481f63 --- /dev/null +++ b/tests/functional/helpers/utils.ts @@ -0,0 +1,84 @@ +import createDebug from 'debug'; + +export const debug = createDebug('create-release-branch:tests'); + +/** + * Given a string, resets its indentation and removes leading and trailing + * whitespace (except for a trailing newline). + * + * @param string - The string. + * @returns The normalized string. + */ +function normalizeMultilineString(string: string): string { + const lines = string + .replace(/^[\n\r]+/u, '') + .replace(/[\n\r]+$/u, '') + .split('\n'); + const indentation = lines[0].match(/^([ ]+)/u)?.[1] ?? ''; + const normalizedString = lines + .map((line) => { + return line.replace(new RegExp(`^${indentation}`, 'u'), ''); + }) + .join('\n') + .trim(); + return `${normalizedString}\n`; +} + +/** + * `Object.keys()` is intentionally generic: it returns the keys of an object, + * but it cannot make guarantees about the contents of that object, so the type + * of the keys is merely `string[]`. While this is technically accurate, it is + * also unnecessary if we have an object that we own and whose contents are + * known exactly. + * + * @param object - The object. + * @returns The keys of an object, typed according to the type of the object + * itself. + */ +export function knownKeysOf( + object: Partial>, +) { + return Object.keys(object) as K[]; +} + +/** + * Type guard for determining whether the given value is an error object with a + * `code` property such as the type of error that Node throws for filesystem + * operations, etc. + * + * @param error - The object to check. + * @returns True or false, depending on the result. + */ +export function isErrorWithCode(error: unknown): error is { code: string } { + return typeof error === 'object' && error !== null && 'code' in error; +} + +/** + * Pauses execution for some time. + * + * @param duration - The number of milliseconds to pause. + */ +export async function sleepFor(duration: number): Promise { + await new Promise((resolve) => setTimeout(resolve, duration)); +} + +/** + * Builds a changelog by filling in the first part automatically, which never + * changes. + * + * @param variantContent - The part of the changelog that can change depending + * on what is expected or what sort of changes have been made to the repo so + * far. + * @returns The full changelog. + */ +export function buildChangelog(variantContent: string): string { + const invariantContent = normalizeMultilineString(` + # Changelog + All notable changes to this project will be documented in this file. + + The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), + and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + `); + + return `${invariantContent}\n${normalizeMultilineString(variantContent)}`; +} diff --git a/tests/functional/helpers/with.ts b/tests/functional/helpers/with.ts new file mode 100644 index 00000000..1095c83d --- /dev/null +++ b/tests/functional/helpers/with.ts @@ -0,0 +1,66 @@ +import { withSandbox } from '../../helpers'; +import MonorepoEnvironment, { + MonorepoEnvironmentOptions, +} from './monorepo-environment'; + +/** + * Runs the given function and ensures that even if `process.env` is changed + * during the function, it is restored afterward. + * + * @param callback - The function to call that presumably will change + * `process.env`. + * @returns Whatever the callback returns. + */ +export async function withProtectedProcessEnv(callback: () => Promise) { + const originalEnv = { ...process.env }; + + try { + return await callback(); + } finally { + const originalKeys = Object.keys(originalEnv); + const currentKeys = Object.keys(process.env); + + originalKeys.forEach((key) => { + process.env[key] = originalEnv[key]; + }); + + currentKeys + .filter((key) => !originalKeys.includes(key)) + .forEach((key) => { + delete process.env[key]; + }); + } +} + +/** + * Builds a monorepo project in a temporary directory, then calls the given + * function with information about that project. + * + * @param options - The configuration options for the environment. + * @param callback - A function which will be called with an object that can be + * used to interact with the project. + * @returns Whatever the callback returns. + */ +export async function withMonorepoProjectEnvironment< + CallbackReturnValue, + WorkspacePackageNickname extends string, +>( + options: Omit< + MonorepoEnvironmentOptions, + 'directoryPath' + >, + callback: ( + environment: MonorepoEnvironment, + ) => Promise, +) { + return withProtectedProcessEnv(async () => { + return withSandbox(async (sandbox) => { + const environment = new MonorepoEnvironment({ + ...options, + directoryPath: sandbox.directoryPath, + }); + await environment.initialize(); + return await callback(environment); + }); + }); +} diff --git a/tests/helpers.ts b/tests/helpers.ts new file mode 100644 index 00000000..edd65c75 --- /dev/null +++ b/tests/helpers.ts @@ -0,0 +1,69 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import util from 'util'; +import { nanoid } from 'nanoid'; +import rimraf from 'rimraf'; + +/** + * A promisified version of `rimraf`. + */ +const promisifiedRimraf = util.promisify(rimraf); + +/** + * Information about the sandbox provided to tests that need access to the + * filesystem. + */ +interface Sandbox { + directoryPath: string; +} + +/** + * The temporary directory that acts as a filesystem sandbox for tests. + */ +const TEMP_DIRECTORY_PATH = path.join( + os.tmpdir(), + 'create-release-branch-tests', +); + +/** + * Each test gets its own randomly generated directory in a temporary directory + * where it can perform filesystem operations. There is a miniscule chance + * that more than one test will receive the same name for its directory. If this + * happens, then all bets are off, and we should stop running tests, because + * the state that we expect to be isolated to a single test has now bled into + * another test. + * + * @param entryPath - The path to the directory. + * @throws If the directory already exists (or a file exists in its place). + */ +async function ensureFileEntryDoesNotExist(entryPath: string): Promise { + try { + await fs.promises.access(entryPath); + throw new Error(`${entryPath} already exists, cannot continue`); + } catch (error: any) { + if (error.code !== 'ENOENT') { + throw error; + } + } +} + +/** + * Creates a temporary directory to hold files that a test could write to, runs + * the given function, then ensures that the directory is removed afterward. + * + * @param fn - The function to call. + * @throws If the temporary directory already exists for some reason. This would + * indicate a bug in how the names of the directory is determined. + */ +export async function withSandbox(fn: (sandbox: Sandbox) => any) { + const directoryPath = path.join(TEMP_DIRECTORY_PATH, nanoid()); + await ensureFileEntryDoesNotExist(directoryPath); + await fs.promises.mkdir(directoryPath, { recursive: true }); + + try { + await fn({ directoryPath }); + } finally { + await promisifiedRimraf(directoryPath); + } +} diff --git a/tests/unit/helpers.ts b/tests/unit/helpers.ts index 4748aa17..21a851a4 100644 --- a/tests/unit/helpers.ts +++ b/tests/unit/helpers.ts @@ -1,10 +1,5 @@ -import fs from 'fs'; -import os from 'os'; import path from 'path'; -import util from 'util'; -import rimraf from 'rimraf'; import { SemVer } from 'semver'; -import { nanoid } from 'nanoid'; import type { Package } from '../../src/package'; import { PackageManifestFieldNames, @@ -21,69 +16,6 @@ type Unrequire = Omit & { [P in K]+?: T[P]; }; -/** - * Information about the sandbox provided to tests that need access to the - * filesystem. - */ -interface Sandbox { - directoryPath: string; -} - -/** - * A promisified version of `rimraf`. - */ -const promisifiedRimraf = util.promisify(rimraf); - -/** - * The temporary directory that acts as a filesystem sandbox for tests. - */ -const TEMP_DIRECTORY_PATH = path.join( - os.tmpdir(), - 'create-release-branch-tests', -); - -/** - * Each test gets its own randomly generated directory in a temporary directory - * where it can perform filesystem operations. There is a miniscule chance - * that more than one test will receive the same name for its directory. If this - * happens, then all bets are off, and we should stop running tests, because - * the state that we expect to be isolated to a single test has now bled into - * another test. - * - * @param entryPath - The path to the directory. - * @throws If the directory already exists (or a file exists in its place). - */ -async function ensureFileEntryDoesNotExist(entryPath: string): Promise { - try { - await fs.promises.access(entryPath); - throw new Error(`${entryPath} already exists, cannot continue`); - } catch (error: any) { - if (error.code !== 'ENOENT') { - throw error; - } - } -} - -/** - * Creates a temporary directory to hold files that a test could write to, runs - * the given function, then ensures that the directory is removed afterward. - * - * @param fn - The function to call. - * @throws If the temporary directory already exists for some reason. This would - * indicate a bug in how the names of the directory is determined. - */ -export async function withSandbox(fn: (sandbox: Sandbox) => any) { - const directoryPath = path.join(TEMP_DIRECTORY_PATH, nanoid()); - await ensureFileEntryDoesNotExist(directoryPath); - await fs.promises.mkdir(directoryPath, { recursive: true }); - - try { - await fn({ directoryPath }); - } finally { - await promisifiedRimraf(directoryPath); - } -} - /** * Builds a project object for use in tests. All properties have default * values, so you can specify only the properties you care about. diff --git a/yarn.lock b/yarn.lock index c25fcd4b..f21e94dd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -882,6 +882,7 @@ __metadata: "@typescript-eslint/eslint-plugin": ^4.21.0 "@typescript-eslint/parser": ^4.21.0 debug: ^4.3.4 + deepmerge: ^4.2.2 eslint: ^7.23.0 eslint-config-prettier: ^8.1.0 eslint-plugin-import: ^2.22.1