From e23c2e2278d2180499970eeccbcf52b09123923f Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Tue, 2 Aug 2022 16:37:44 -0600 Subject: [PATCH 01/15] Add functional tests This commit adds tests which run the script within a separate shell instance and verifies that the script makes the appropriate changes to the repo. I've tried to make these functional tests as readable as possible. In order to do that, I created a small framework which replicates the environment in which the script will run and then allows that environment to be modified per-test to suit the assertions being made. --- package.json | 1 + src/fs.test.ts | 2 +- src/functional.test.ts | 252 ++++++++++++++++++ src/monorepo-workflow-operations.test.ts | 7 +- src/package-manifest.test.ts | 2 +- src/package.test.ts | 9 +- src/project.test.ts | 7 +- src/release-specification.test.ts | 7 +- tests/functional/helpers/constants.ts | 10 + tests/functional/helpers/environment.ts | 93 +++++++ tests/functional/helpers/local-monorepo.ts | 181 +++++++++++++ tests/functional/helpers/local-repo.ts | 130 +++++++++ .../helpers/monorepo-environment.ts | 171 ++++++++++++ tests/functional/helpers/remote-repo.ts | 28 ++ tests/functional/helpers/repo.ts | 245 +++++++++++++++++ tests/functional/helpers/utils.ts | 87 ++++++ tests/functional/helpers/with.ts | 57 ++++ tests/helpers.ts | 69 +++++ tests/unit/helpers.ts | 68 ----- yarn.lock | 1 + 20 files changed, 1336 insertions(+), 91 deletions(-) create mode 100644 src/functional.test.ts create mode 100644 tests/functional/helpers/constants.ts create mode 100644 tests/functional/helpers/environment.ts create mode 100644 tests/functional/helpers/local-monorepo.ts create mode 100644 tests/functional/helpers/local-repo.ts create mode 100644 tests/functional/helpers/monorepo-environment.ts create mode 100644 tests/functional/helpers/remote-repo.ts create mode 100644 tests/functional/helpers/repo.ts create mode 100644 tests/functional/helpers/utils.ts create mode 100644 tests/functional/helpers/with.ts create mode 100644 tests/helpers.ts diff --git a/package.json b/package.json index 75aedf5..800cccb 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 f92f792..d692dc7 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 0000000..f071cc8 --- /dev/null +++ b/src/functional.test.ts @@ -0,0 +1,252 @@ +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 package's changelog 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: '1.0.0', + 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('commits the updates and saves the new commit to a new branch, then switches to that branch', async () => { + await withMonorepoProjectEnvironment( + { + packages: { + $root$: { + name: '@scope/monorepo', + version: '1.0.0', + 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', + }, + }, + }); + + // The most recent commit should be called the right thing, and + // should be the current one, and should also be called + // `release/YYYY-MM-DD` + const mostRecentCommitInfo = ( + await environment.runCommand('git', [ + 'log', + '--pretty=%D%x09%s%x09%H', + '--date-order', + '--max-count=1', + ]) + ).stdout + .trim() + .split('\x09'); + expect(mostRecentCommitInfo.slice(0, -1)).toStrictEqual([ + 'HEAD -> release/2022-06-24', + 'Release 2022-06-24', + ]); + // The most recent branch should point to the most recent commit + const commitIdOfMostRecentBranch = ( + await environment.runCommand('git', [ + 'rev-list', + '--branches', + '--date-order', + '--max-count=1', + ]) + ).stdout.trim(); + expect(mostRecentCommitInfo[2]).toStrictEqual( + commitIdOfMostRecentBranch, + ); + }, + ); + }); + }); +}); diff --git a/src/monorepo-workflow-operations.test.ts b/src/monorepo-workflow-operations.test.ts index 2d225cf..54475f0 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 1414c00..fc4021e 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 c694de0..8398508 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 95d7d7a..029f28a 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 bbf3bad..6cc28c6 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 0000000..e759c3b --- /dev/null +++ b/tests/functional/helpers/constants.ts @@ -0,0 +1,10 @@ +import path from 'path'; + +export 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 0000000..9fd4f6c --- /dev/null +++ b/tests/functional/helpers/environment.ts @@ -0,0 +1,93 @@ +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 options with which to configure the tool or the repos + * against which the tool is run. + * + * @property sandbox - 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: string; + + 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 } = options; + this.directoryPath = directoryPath; + 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 0000000..2d29690 --- /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 options with which to customize the tool or configuration files + * within this repo. + * + * @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< + PackageNickname extends string, +> extends LocalRepo { + /** + * The known packages within this repo (including the root). + */ + #packages: Record<'$root$' | PackageNickname, PackageSpecification>; + + /** + * The known workspaces within this repo. + */ + #workspaces: Record; + + constructor({ + packages, + workspaces, + ...rest + }: LocalMonorepoOptions) { + super(rest); + this.#packages = { + $root$: { + name: 'monorepo', + version: '1.0.0', + 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$' | PackageNickname, + 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$' | PackageNickname, + 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$' | PackageNickname, + 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$' | PackageNickname, + 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 0000000..804cba0 --- /dev/null +++ b/tests/functional/helpers/local-repo.ts @@ -0,0 +1,130 @@ +import path from 'path'; +import Repo, { RepoOptions } from './repo'; + +/** + * A set of options with which to customize the tool or configuration files + * within this repo. In addition to those listed in {@type 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', + ` +# 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). + +## [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 0000000..b443bb0 --- /dev/null +++ b/tests/functional/helpers/monorepo-environment.ts @@ -0,0 +1,171 @@ +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 } from './utils'; + +/** + * A set of options with which to configure the tool or the repos + * against which the tool is run. 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. + */ +export interface MonorepoEnvironmentOptions + 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 Environment such that the "local" repo becomes a + * monorepo. + */ +export default class MonorepoEnvironment< + PackageNickname extends string, +> extends Environment> { + readFileWithinPackage: LocalMonorepo['readFileWithinPackage']; + + writeFileWithinPackage: LocalMonorepo['writeFileWithinPackage']; + + readJsonFileWithinPackage: LocalMonorepo['readJsonFileWithinPackage']; + + #packages: Record; + + #today: Date | undefined; + + constructor({ today, ...rest }: MonorepoEnvironmentOptions) { + super(rest); + this.#packages = + rest.packages ?? ({} as Record); + 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); + } + + protected buildLocalRepo({ + packages = {} as Record, + workspaces = {}, + createInitialCommit = true, + }: MonorepoEnvironmentOptions) { + return new LocalMonorepo({ + environmentDirectoryPath: this.directoryPath, + remoteRepoDirectoryPath: this.remoteRepo.getWorkingDirectoryPath(), + packages, + workspaces, + createInitialCommit, + }); + } + + /** + * 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: Object.keys( + releaseSpecificationWithPackageNicknames.packages, + ).reduce((obj, packageNickname) => { + const packageSpecification = + this.#packages[packageNickname as PackageNickname]; + const versionSpecifier = + releaseSpecificationWithPackageNicknames.packages[ + packageNickname as 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; + } +} diff --git a/tests/functional/helpers/remote-repo.ts b/tests/functional/helpers/remote-repo.ts new file mode 100644 index 0000000..2a3c1e7 --- /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 0000000..6e110f8 --- /dev/null +++ b/tests/functional/helpers/repo.ts @@ -0,0 +1,245 @@ +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 options with which to customize the tool or configuration files + * within this 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: string; + + /** + * 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 now = new Date(); + const timeSincePreviousCommit = + this.#latestCommitTime === undefined + ? null + : now.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 = now; + 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', + } as NodeJS.ProcessEnv, + ...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 0000000..699c658 --- /dev/null +++ b/tests/functional/helpers/utils.ts @@ -0,0 +1,87 @@ +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. + * + * Note: This function will not work when given an object where any of the keys + * are optional. + * + * @param object - The object. + * @returns The keys of an object, typed according to the type of the object + * itself. + */ +export function knownKeysOf( + object: Record, +) { + 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 0000000..08dce51 --- /dev/null +++ b/tests/functional/helpers/with.ts @@ -0,0 +1,57 @@ +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 { + Object.keys(originalEnv).forEach((key) => { + process.env[key] = originalEnv[key]; + }); + } +} + +/** + * Builds a monorepo project in a temporary directory, then yields the given + * function with information about that project. + * + * @param options - The options with which to initialize the environment in + * which the project will be interacted with. + * @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, + PackageNickname extends string, +>( + options: Omit< + MonorepoEnvironmentOptions, + 'name' | '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 0000000..edd65c7 --- /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 4748aa1..21a851a 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 c25fcd4..f21e94d 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 From 4709c446da8f9b5fbf121207408a06f53e33e033 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Thu, 4 Aug 2022 13:36:00 -0600 Subject: [PATCH 02/15] Update the name of this test Co-authored-by: Mark Stacey --- src/functional.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/functional.test.ts b/src/functional.test.ts index f071cc8..af3b879 100644 --- a/src/functional.test.ts +++ b/src/functional.test.ts @@ -187,7 +187,7 @@ describe('create-release-branch (functional)', () => { ); }); - it('commits the updates and saves the new commit to a new branch, then switches to that branch', async () => { + it('switches to a new release branch and commits the changes', async () => { await withMonorepoProjectEnvironment( { packages: { From cc6a7c2ce8d0cb71a0092331c2029d4054e4c8b9 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Thu, 4 Aug 2022 13:13:44 -0600 Subject: [PATCH 03/15] Remove keys from process.env that were added in tests --- tests/functional/helpers/with.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/tests/functional/helpers/with.ts b/tests/functional/helpers/with.ts index 08dce51..5030abc 100644 --- a/tests/functional/helpers/with.ts +++ b/tests/functional/helpers/with.ts @@ -7,7 +7,8 @@ import MonorepoEnvironment, { * 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`. + * @param callback - The function to call that presumably will change + * `process.env`. * @returns Whatever the callback returns. */ export async function withProtectedProcessEnv(callback: () => Promise) { @@ -16,9 +17,18 @@ export async function withProtectedProcessEnv(callback: () => Promise) { try { return await callback(); } finally { - Object.keys(originalEnv).forEach((key) => { + 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]; + }); } } From 049fc6896d83396a1e2e17b79f9d2841ab5496b6 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Thu, 4 Aug 2022 13:19:48 -0600 Subject: [PATCH 04/15] No need to export ROOT_DIR --- tests/functional/helpers/constants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/functional/helpers/constants.ts b/tests/functional/helpers/constants.ts index e759c3b..ecfd112 100644 --- a/tests/functional/helpers/constants.ts +++ b/tests/functional/helpers/constants.ts @@ -1,6 +1,6 @@ import path from 'path'; -export const ROOT_DIR = path.resolve(__dirname, '../../..'); +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, From 49982004499fb954f345d4d7ddfc97b10591be1f Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Thu, 4 Aug 2022 13:20:15 -0600 Subject: [PATCH 05/15] Replace '...with which...' with better wording --- tests/functional/helpers/environment.ts | 6 +++--- tests/functional/helpers/local-monorepo.ts | 4 ++-- tests/functional/helpers/local-repo.ts | 5 ++--- tests/functional/helpers/monorepo-environment.ts | 7 ++++--- tests/functional/helpers/repo.ts | 3 +-- tests/functional/helpers/with.ts | 3 +-- 6 files changed, 13 insertions(+), 15 deletions(-) diff --git a/tests/functional/helpers/environment.ts b/tests/functional/helpers/environment.ts index 9fd4f6c..db04526 100644 --- a/tests/functional/helpers/environment.ts +++ b/tests/functional/helpers/environment.ts @@ -18,10 +18,10 @@ export interface PackageSpecification { } /** - * A set of options with which to configure the tool or the repos - * against which the tool is run. + * A set of configuration options for an {@link Environment}. * - * @property sandbox - The directory out of which this environment will operate. + * @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 diff --git a/tests/functional/helpers/local-monorepo.ts b/tests/functional/helpers/local-monorepo.ts index 2d29690..2ac01c2 100644 --- a/tests/functional/helpers/local-monorepo.ts +++ b/tests/functional/helpers/local-monorepo.ts @@ -4,8 +4,8 @@ import LocalRepo, { LocalRepoOptions } from './local-repo'; import { knownKeysOf } from './utils'; /** - * A set of options with which to customize the tool or configuration files - * within this repo. + * 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). diff --git a/tests/functional/helpers/local-repo.ts b/tests/functional/helpers/local-repo.ts index 804cba0..adfdd46 100644 --- a/tests/functional/helpers/local-repo.ts +++ b/tests/functional/helpers/local-repo.ts @@ -2,9 +2,8 @@ import path from 'path'; import Repo, { RepoOptions } from './repo'; /** - * A set of options with which to customize the tool or configuration files - * within this repo. In addition to those listed in {@type RepoOptions}, these - * include: + * 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. diff --git a/tests/functional/helpers/monorepo-environment.ts b/tests/functional/helpers/monorepo-environment.ts index b443bb0..d53ce83 100644 --- a/tests/functional/helpers/monorepo-environment.ts +++ b/tests/functional/helpers/monorepo-environment.ts @@ -11,13 +11,14 @@ import LocalMonorepo from './local-monorepo'; import { debug } from './utils'; /** - * A set of options with which to configure the tool or the repos - * against which the tool is run. In addition to the options listed - * in {@link EnvironmentOptions}, these include: + * 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 extends EnvironmentOptions { diff --git a/tests/functional/helpers/repo.ts b/tests/functional/helpers/repo.ts index 6e110f8..8b21789 100644 --- a/tests/functional/helpers/repo.ts +++ b/tests/functional/helpers/repo.ts @@ -5,8 +5,7 @@ import deepmerge from 'deepmerge'; import { debug, isErrorWithCode, sleepFor } from './utils'; /** - * A set of options with which to customize the tool or configuration files - * within this repo. + * A set of configuration options for a {@link Repo}. * * @property environmentDirectoryPath - The directory that holds the environment * that created this repo. diff --git a/tests/functional/helpers/with.ts b/tests/functional/helpers/with.ts index 5030abc..4678931 100644 --- a/tests/functional/helpers/with.ts +++ b/tests/functional/helpers/with.ts @@ -36,8 +36,7 @@ export async function withProtectedProcessEnv(callback: () => Promise) { * Builds a monorepo project in a temporary directory, then yields the given * function with information about that project. * - * @param options - The options with which to initialize the environment in - * which the project will be interacted with. + * @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. From 92c846d499ba21c3d23d564daf7cd9073b0cce40 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Thu, 4 Aug 2022 13:22:44 -0600 Subject: [PATCH 06/15] Set the latest commit time better --- tests/functional/helpers/repo.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/functional/helpers/repo.ts b/tests/functional/helpers/repo.ts index 8b21789..a4975d7 100644 --- a/tests/functional/helpers/repo.ts +++ b/tests/functional/helpers/repo.ts @@ -148,11 +148,10 @@ export default abstract class Repo { // 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 now = new Date(); const timeSincePreviousCommit = this.#latestCommitTime === undefined ? null - : now.getTime() - this.#latestCommitTime.getTime(); + : new Date().getTime() - this.#latestCommitTime.getTime(); if ( timeSincePreviousCommit !== null && @@ -163,7 +162,7 @@ export default abstract class Repo { await this.runCommand('git', ['add', '-A']); const result = await this.runCommand('git', ['commit', '-m', message]); - this.#latestCommitTime = now; + this.#latestCommitTime = new Date(); return result; } From 8b2114184e7201fd82b7dadfa0240b6b013e0e40 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Thu, 4 Aug 2022 13:30:25 -0600 Subject: [PATCH 07/15] Use buildChangelog --- tests/functional/helpers/local-repo.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/tests/functional/helpers/local-repo.ts b/tests/functional/helpers/local-repo.ts index adfdd46..f591e5d 100644 --- a/tests/functional/helpers/local-repo.ts +++ b/tests/functional/helpers/local-repo.ts @@ -1,5 +1,6 @@ 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 @@ -88,17 +89,13 @@ export default abstract class LocalRepo extends Repo { await this.writeFile( 'CHANGELOG.md', - ` -# 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). - + buildChangelog( + ` ## [Unreleased] [Unreleased]: https://github.com/example-org/example-repo/commits/main `.slice(1), + ), ); if (this.#createInitialCommit) { From a8fa5a9e795575ccb7899ee409fc112a6e260bff Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Thu, 4 Aug 2022 13:30:44 -0600 Subject: [PATCH 08/15] Don't make packages or workspaces optional --- tests/functional/helpers/monorepo-environment.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/tests/functional/helpers/monorepo-environment.ts b/tests/functional/helpers/monorepo-environment.ts index d53ce83..4031ead 100644 --- a/tests/functional/helpers/monorepo-environment.ts +++ b/tests/functional/helpers/monorepo-environment.ts @@ -22,8 +22,8 @@ import { debug } from './utils'; */ export interface MonorepoEnvironmentOptions extends EnvironmentOptions { - packages?: Record; - workspaces?: Record; + packages: Record; + workspaces: Record; today?: Date; } @@ -38,7 +38,7 @@ interface ReleaseSpecification { } /** - * This class configures Environment such that the "local" repo becomes a + * This class configures the environment such that the "local" repo becomes a * monorepo. */ export default class MonorepoEnvironment< @@ -54,10 +54,13 @@ export default class MonorepoEnvironment< #today: Date | undefined; - constructor({ today, ...rest }: MonorepoEnvironmentOptions) { + constructor({ + today, + packages, + ...rest + }: MonorepoEnvironmentOptions) { super(rest); - this.#packages = - rest.packages ?? ({} as Record); + this.#packages = packages; this.#today = today; this.readFileWithinPackage = this.localRepo.readFileWithinPackage.bind( this.localRepo, From be14668d0cb628c91562ea8136a652a989a7d31e Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Thu, 4 Aug 2022 13:31:06 -0600 Subject: [PATCH 09/15] Tweaks --- src/functional.test.ts | 4 ++-- tests/functional/helpers/local-monorepo.ts | 2 +- tests/functional/helpers/local-repo.ts | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/functional.test.ts b/src/functional.test.ts index af3b879..7cf7431 100644 --- a/src/functional.test.ts +++ b/src/functional.test.ts @@ -93,7 +93,7 @@ describe('create-release-branch (functional)', () => { packages: { $root$: { name: '@scope/monorepo', - version: '1.0.0', + version: '2022.1.1', directoryPath: '.', }, a: { @@ -193,7 +193,7 @@ describe('create-release-branch (functional)', () => { packages: { $root$: { name: '@scope/monorepo', - version: '1.0.0', + version: '2022.1.1', directoryPath: '.', }, a: { diff --git a/tests/functional/helpers/local-monorepo.ts b/tests/functional/helpers/local-monorepo.ts index 2ac01c2..f478f7b 100644 --- a/tests/functional/helpers/local-monorepo.ts +++ b/tests/functional/helpers/local-monorepo.ts @@ -43,7 +43,7 @@ export default class LocalMonorepo< this.#packages = { $root$: { name: 'monorepo', - version: '1.0.0', + version: '2022.1.1', directoryPath: '.', }, ...packages, diff --git a/tests/functional/helpers/local-repo.ts b/tests/functional/helpers/local-repo.ts index f591e5d..8e46de3 100644 --- a/tests/functional/helpers/local-repo.ts +++ b/tests/functional/helpers/local-repo.ts @@ -113,8 +113,8 @@ export default abstract class LocalRepo extends Repo { } /** - * Returns the name of the sole or main package that this repo represents. Overridden - * in subclasses. + * Returns the name of the sole or main package that this repo represents. + * Overridden in subclasses. */ protected abstract getPackageName(): string; From 86cedf0a65fbb953ecf0757cf59d99e360bfeca9 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Thu, 4 Aug 2022 13:34:59 -0600 Subject: [PATCH 10/15] Use knownKeysOf (and fix it for Partial records) --- tests/functional/helpers/monorepo-environment.ts | 11 ++++------- tests/functional/helpers/utils.ts | 5 +---- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/tests/functional/helpers/monorepo-environment.ts b/tests/functional/helpers/monorepo-environment.ts index 4031ead..7af5cf1 100644 --- a/tests/functional/helpers/monorepo-environment.ts +++ b/tests/functional/helpers/monorepo-environment.ts @@ -8,7 +8,7 @@ import Environment, { PackageSpecification, } from './environment'; import LocalMonorepo from './local-monorepo'; -import { debug } from './utils'; +import { debug, knownKeysOf } from './utils'; /** * A set of configuration options for a {@link MonorepoEnvironment}. In addition @@ -108,15 +108,12 @@ export default class MonorepoEnvironment< 'release-spec', ); const releaseSpecificationWithPackageNames = { - packages: Object.keys( + packages: knownKeysOf( releaseSpecificationWithPackageNicknames.packages, ).reduce((obj, packageNickname) => { - const packageSpecification = - this.#packages[packageNickname as PackageNickname]; + const packageSpecification = this.#packages[packageNickname]; const versionSpecifier = - releaseSpecificationWithPackageNicknames.packages[ - packageNickname as PackageNickname - ]; + releaseSpecificationWithPackageNicknames.packages[packageNickname]; return { ...obj, [packageSpecification.name]: versionSpecifier }; }, {}), }; diff --git a/tests/functional/helpers/utils.ts b/tests/functional/helpers/utils.ts index 699c658..e9481f6 100644 --- a/tests/functional/helpers/utils.ts +++ b/tests/functional/helpers/utils.ts @@ -31,15 +31,12 @@ function normalizeMultilineString(string: string): string { * also unnecessary if we have an object that we own and whose contents are * known exactly. * - * Note: This function will not work when given an object where any of the keys - * are optional. - * * @param object - The object. * @returns The keys of an object, typed according to the type of the object * itself. */ export function knownKeysOf( - object: Record, + object: Partial>, ) { return Object.keys(object) as K[]; } From 7ee99bbaeae4d9c70da3dbeaa060165a2bebb17a Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Thu, 4 Aug 2022 13:35:10 -0600 Subject: [PATCH 11/15] Remove this cast --- tests/functional/helpers/repo.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/functional/helpers/repo.ts b/tests/functional/helpers/repo.ts index a4975d7..7fe81cb 100644 --- a/tests/functional/helpers/repo.ts +++ b/tests/functional/helpers/repo.ts @@ -194,7 +194,7 @@ export default abstract class Repo { env: { ...env, DEBUG_COLORS: '1', - } as NodeJS.ProcessEnv, + }, ...remainingOptions, }); From eccfebab0bd8387cf567ef6a8d23c71b4bfc29cb Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Thu, 4 Aug 2022 14:24:02 -0600 Subject: [PATCH 12/15] Fix tests --- tests/functional/helpers/environment.ts | 7 ++- tests/functional/helpers/local-monorepo.ts | 20 +++---- .../helpers/monorepo-environment.ts | 60 +++++++++---------- tests/functional/helpers/repo.ts | 2 +- tests/functional/helpers/with.ts | 10 ++-- 5 files changed, 51 insertions(+), 48 deletions(-) diff --git a/tests/functional/helpers/environment.ts b/tests/functional/helpers/environment.ts index db04526..c4a9ac6 100644 --- a/tests/functional/helpers/environment.ts +++ b/tests/functional/helpers/environment.ts @@ -37,7 +37,9 @@ export interface EnvironmentOptions { * we need to take from within the test. */ export default abstract class Environment { - protected directoryPath: string; + protected directoryPath: EnvironmentOptions['directoryPath']; + + protected createInitialCommit: boolean; protected remoteRepo: Repo; @@ -58,8 +60,9 @@ export default abstract class Environment { createCommit: SpecificLocalRepo['createCommit']; constructor(options: EnvironmentOptions) { - const { directoryPath } = options; + const { directoryPath, createInitialCommit = true } = options; this.directoryPath = directoryPath; + this.createInitialCommit = createInitialCommit; this.remoteRepo = new RemoteRepo({ environmentDirectoryPath: directoryPath, }); diff --git a/tests/functional/helpers/local-monorepo.ts b/tests/functional/helpers/local-monorepo.ts index f478f7b..0945726 100644 --- a/tests/functional/helpers/local-monorepo.ts +++ b/tests/functional/helpers/local-monorepo.ts @@ -11,9 +11,9 @@ import { knownKeysOf } from './utils'; * root). * @property workspaces - The known workspaces within this repo. */ -export interface LocalMonorepoOptions +export interface LocalMonorepoOptions extends LocalRepoOptions { - packages: Record; + packages: Record; workspaces: Record; } @@ -22,23 +22,23 @@ export interface LocalMonorepoOptions * to a monorepo. */ export default class LocalMonorepo< - PackageNickname extends string, + WorkspacePackageNickname extends string, > extends LocalRepo { /** * The known packages within this repo (including the root). */ - #packages: Record<'$root$' | PackageNickname, PackageSpecification>; + #packages: Record<'$root$' | WorkspacePackageNickname, PackageSpecification>; /** * The known workspaces within this repo. */ - #workspaces: Record; + #workspaces: LocalMonorepoOptions['workspaces']; constructor({ packages, workspaces, ...rest - }: LocalMonorepoOptions) { + }: LocalMonorepoOptions) { super(rest); this.#packages = { $root$: { @@ -61,7 +61,7 @@ export default class LocalMonorepo< * @returns The content of the file. */ async readFileWithinPackage( - packageNickname: '$root$' | PackageNickname, + packageNickname: '$root$' | WorkspacePackageNickname, partialFilePath: string, ) { const packageDirectoryPath = this.#packages[packageNickname].directoryPath; @@ -80,7 +80,7 @@ export default class LocalMonorepo< * @returns The object which the JSON file holds. */ async readJsonFileWithinPackage( - packageNickname: '$root$' | PackageNickname, + packageNickname: '$root$' | WorkspacePackageNickname, partialFilePath: string, ) { const packageDirectoryPath = this.#packages[packageNickname].directoryPath; @@ -99,7 +99,7 @@ export default class LocalMonorepo< * @param contents - The desired contents of the file. */ async writeFileWithinPackage( - packageNickname: '$root$' | PackageNickname, + packageNickname: '$root$' | WorkspacePackageNickname, partialFilePath: string, contents: string, ): Promise { @@ -121,7 +121,7 @@ export default class LocalMonorepo< * @param object - The new object that the file should represent. */ async writeJsonFileWithinPackage( - packageNickname: '$root$' | PackageNickname, + packageNickname: '$root$' | WorkspacePackageNickname, partialFilePath: string, object: Record, ): Promise { diff --git a/tests/functional/helpers/monorepo-environment.ts b/tests/functional/helpers/monorepo-environment.ts index 7af5cf1..c6f4ebc 100644 --- a/tests/functional/helpers/monorepo-environment.ts +++ b/tests/functional/helpers/monorepo-environment.ts @@ -20,9 +20,10 @@ import { debug, knownKeysOf } from './utils'; * @property today - The date that will be used for new releases. Will be * translated to the TODAY environment variables. */ -export interface MonorepoEnvironmentOptions - extends EnvironmentOptions { - packages: Record; +export interface MonorepoEnvironmentOptions< + WorkspacePackageNickname extends string, +> extends EnvironmentOptions { + packages: Record; workspaces: Record; today?: Date; } @@ -33,8 +34,8 @@ export interface MonorepoEnvironmentOptions * @property packages - The workspace packages within this repo that will be * released. */ -interface ReleaseSpecification { - packages: Partial>; +interface ReleaseSpecification { + packages: Partial>; } /** @@ -42,25 +43,24 @@ interface ReleaseSpecification { * monorepo. */ export default class MonorepoEnvironment< - PackageNickname extends string, -> extends Environment> { - readFileWithinPackage: LocalMonorepo['readFileWithinPackage']; + WorkspacePackageNickname extends string, +> extends Environment> { + readFileWithinPackage: LocalMonorepo['readFileWithinPackage']; - writeFileWithinPackage: LocalMonorepo['writeFileWithinPackage']; + writeFileWithinPackage: LocalMonorepo['writeFileWithinPackage']; - readJsonFileWithinPackage: LocalMonorepo['readJsonFileWithinPackage']; + readJsonFileWithinPackage: LocalMonorepo['readJsonFileWithinPackage']; - #packages: Record; + #packages: MonorepoEnvironmentOptions['packages']; - #today: Date | undefined; + #today: MonorepoEnvironmentOptions['today']; constructor({ today, - packages, ...rest - }: MonorepoEnvironmentOptions) { + }: MonorepoEnvironmentOptions) { super(rest); - this.#packages = packages; + this.#packages = rest.packages; this.#today = today; this.readFileWithinPackage = this.localRepo.readFileWithinPackage.bind( this.localRepo, @@ -72,20 +72,6 @@ export default class MonorepoEnvironment< this.localRepo.readJsonFileWithinPackage.bind(this.localRepo); } - protected buildLocalRepo({ - packages = {} as Record, - workspaces = {}, - createInitialCommit = true, - }: MonorepoEnvironmentOptions) { - return new LocalMonorepo({ - environmentDirectoryPath: this.directoryPath, - remoteRepoDirectoryPath: this.remoteRepo.getWorkingDirectoryPath(), - packages, - workspaces, - createInitialCommit, - }); - } - /** * Runs the tool within the context of the project, editing the generated * release spec template automatically with the given information before @@ -101,7 +87,7 @@ export default class MonorepoEnvironment< async runTool({ releaseSpecification: releaseSpecificationWithPackageNicknames, }: { - releaseSpecification: ReleaseSpecification; + releaseSpecification: ReleaseSpecification; }): Promise> { const releaseSpecificationPath = path.join( this.directoryPath, @@ -169,4 +155,18 @@ cat "${releaseSpecificationPath}" > "$1" 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/repo.ts b/tests/functional/helpers/repo.ts index 7fe81cb..37f5705 100644 --- a/tests/functional/helpers/repo.ts +++ b/tests/functional/helpers/repo.ts @@ -28,7 +28,7 @@ export default abstract class Repo { /** * The directory that holds the environment that created this repo. */ - protected environmentDirectoryPath: string; + protected environmentDirectoryPath: RepoOptions['environmentDirectoryPath']; /** * The time at which the last commit was created. Used to determine whether we diff --git a/tests/functional/helpers/with.ts b/tests/functional/helpers/with.ts index 4678931..1095c83 100644 --- a/tests/functional/helpers/with.ts +++ b/tests/functional/helpers/with.ts @@ -33,7 +33,7 @@ export async function withProtectedProcessEnv(callback: () => Promise) { } /** - * Builds a monorepo project in a temporary directory, then yields the given + * 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. @@ -43,14 +43,14 @@ export async function withProtectedProcessEnv(callback: () => Promise) { */ export async function withMonorepoProjectEnvironment< CallbackReturnValue, - PackageNickname extends string, + WorkspacePackageNickname extends string, >( options: Omit< - MonorepoEnvironmentOptions, - 'name' | 'directoryPath' + MonorepoEnvironmentOptions, + 'directoryPath' >, callback: ( - environment: MonorepoEnvironment, + environment: MonorepoEnvironment, ) => Promise, ) { return withProtectedProcessEnv(async () => { From b500a00310a28538409421432d8754a02c1e3bb2 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Thu, 4 Aug 2022 14:24:12 -0600 Subject: [PATCH 13/15] Make the last assertions easier to read --- src/functional.test.ts | 46 +++++++++++++++++++++--------------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/src/functional.test.ts b/src/functional.test.ts index 7cf7431..5033f35 100644 --- a/src/functional.test.ts +++ b/src/functional.test.ts @@ -216,35 +216,35 @@ describe('create-release-branch (functional)', () => { }, }); - // The most recent commit should be called the right thing, and - // should be the current one, and should also be called - // `release/YYYY-MM-DD` - const mostRecentCommitInfo = ( - await environment.runCommand('git', [ - 'log', - '--pretty=%D%x09%s%x09%H', - '--date-order', - '--max-count=1', - ]) - ).stdout - .trim() - .split('\x09'); - expect(mostRecentCommitInfo.slice(0, -1)).toStrictEqual([ - 'HEAD -> release/2022-06-24', - 'Release 2022-06-24', - ]); - // The most recent branch should point to the most recent commit - const commitIdOfMostRecentBranch = ( + // Tests four things: + // * The latest commit should be called "Release YYYY-MM-DD" + // * The latest branch should be called "release/YYYY-MM-DD" + // * The latest branch should point to the latest commit + // * The latest commit should be the current commit (HEAD) + 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.trim(); - expect(mostRecentCommitInfo[2]).toStrictEqual( - commitIdOfMostRecentBranch, - ); + ).stdout; + expect(latestCommitSubject).toStrictEqual('Release 2022-06-24'); + expect(latestCommitRevs).toStrictEqual([ + 'HEAD', + 'release/2022-06-24', + ]); + expect(latestCommitId).toStrictEqual(latestBranchCommitId); }, ); }); From 4ff15a03eb0acb498f69b84fc5ecc7c164fe71e4 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Thu, 4 Aug 2022 14:36:37 -0600 Subject: [PATCH 14/15] Make the assertions even clearer --- src/functional.test.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/functional.test.ts b/src/functional.test.ts index 5033f35..b45f9f0 100644 --- a/src/functional.test.ts +++ b/src/functional.test.ts @@ -218,9 +218,9 @@ describe('create-release-branch (functional)', () => { // 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 - // * The latest commit should be the current commit (HEAD) const [latestCommitSubject, latestCommitId, latestCommitRevsMarker] = ( await environment.runCommand('git', [ @@ -240,11 +240,9 @@ describe('create-release-branch (functional)', () => { ]) ).stdout; expect(latestCommitSubject).toStrictEqual('Release 2022-06-24'); - expect(latestCommitRevs).toStrictEqual([ - 'HEAD', - 'release/2022-06-24', - ]); - expect(latestCommitId).toStrictEqual(latestBranchCommitId); + expect(latestCommitRevs).toContain('HEAD'); + expect(latestCommitRevs).toContain('release/2022-06-24'); + expect(latestBranchCommitId).toStrictEqual(latestCommitId); }, ); }); From 71dde410d7c1b7844b773bc038c5f85d2268e159 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Thu, 4 Aug 2022 15:12:41 -0600 Subject: [PATCH 15/15] Tweak the name of this test --- src/functional.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/functional.test.ts b/src/functional.test.ts index b45f9f0..4c1bb33 100644 --- a/src/functional.test.ts +++ b/src/functional.test.ts @@ -87,7 +87,7 @@ describe('create-release-branch (functional)', () => { ); }); - it("updates each of the specified package's changelog by adding a new section which lists all commits concerning the package over the entire history of the repo", async () => { + 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: {