From c30ce5c71e3dfc3631993d0c3b7ac983d882109a Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Thu, 21 Jul 2022 14:20:18 -0600 Subject: [PATCH 1/7] Simplify monorepo-workflow-operations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tests for `monorepo-workflow-operations.ts` are not as readable or maintainable as I'd like them to be. This commit attempts to fix that. * While there isn't a whole lot we can do in terms of the scenarios we're testing because the function makes a lot of calls and the logic inside of it is somewhat complicated, we can at least move code that is responsible for parsing the TODAY environment variable, building a ReleasePlan object from the release spec, and applying the updates to the repos themselves out into separate files. This simplifies the test data and removes a few tests entirely. * We also simplify the structure of the tests so that we aren't using so many nested `describe` blocks. This ends up being very difficult to keep straight, so the flattened layout here makes it a little more palatable. * We also simplify the setup code for each test. Currently we are mocking all of the dependencies for `followMonorepoWorkflow` in one go, but we're doing so in a way that forces the reader to wade through a bunch of type definitions. That isn't really that helpful. The most complicated part of reading the tests for `followMonorepoWorkflow` isn't the dependencies — it's the logic. So we take all of the decision points we have to make in the implementation and represent those as options to our setup function in the tests so it's as clear as possible which exact scenario is being tested just by reading the test. * Finally while we're moving things around, we can also move the check we perform to ensure that the new version of a package is greater than its existing version so that it is earlier in the entire workflow, i.e. from building the release plan to the validating the release specification. This also reduces the number of tests we have to write. --- src/initial-parameters.test.ts | 134 +- src/initial-parameters.ts | 11 +- src/main.test.ts | 5 + src/main.ts | 3 +- src/monorepo-workflow-operations.test.ts | 2762 +++++++++------------- src/monorepo-workflow-operations.ts | 165 +- src/package.ts | 2 +- src/release-plan.test.ts | 156 ++ src/release-plan.ts | 129 + src/release-specification.test.ts | 80 +- src/release-specification.ts | 32 +- src/repo.test.ts | 36 +- src/repo.ts | 34 +- src/workflow-operations.test.ts | 40 - src/workflow-operations.ts | 70 - tests/functional/helpers/repo.ts | 3 +- tests/helpers.ts | 14 +- tests/setupAfterEnv.ts | 50 +- tests/unit/helpers.ts | 58 +- 19 files changed, 1781 insertions(+), 2003 deletions(-) create mode 100644 src/release-plan.test.ts create mode 100644 src/release-plan.ts delete mode 100644 src/workflow-operations.test.ts delete mode 100644 src/workflow-operations.ts diff --git a/src/initial-parameters.test.ts b/src/initial-parameters.test.ts index 0420dc3..894b7ed 100644 --- a/src/initial-parameters.test.ts +++ b/src/initial-parameters.test.ts @@ -4,14 +4,24 @@ import { when } from 'jest-when'; import { buildMockProject, buildMockPackage } from '../tests/unit/helpers'; import { determineInitialParameters } from './initial-parameters'; import * as commandLineArgumentsModule from './command-line-arguments'; +import * as envModule from './env'; import * as projectModule from './project'; jest.mock('./command-line-arguments'); +jest.mock('./env'); jest.mock('./project'); describe('initial-parameters', () => { describe('determineInitialParameters', () => { - it('returns an object that contains data necessary to run the workflow', async () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('returns an object derived from command-line arguments and environment variables that contains data necessary to run the workflow', async () => { const project = buildMockProject(); when(jest.spyOn(commandLineArgumentsModule, 'readCommandLineArguments')) .calledWith(['arg1', 'arg2']) @@ -20,6 +30,9 @@ describe('initial-parameters', () => { tempDirectory: '/path/to/temp', reset: true, }); + jest + .spyOn(envModule, 'getEnvironmentVariables') + .mockReturnValue({ TODAY: '2022-06-22', EDITOR: undefined }); when(jest.spyOn(projectModule, 'readProject')) .calledWith('/path/to/project') .mockResolvedValue(project); @@ -33,10 +46,58 @@ describe('initial-parameters', () => { project, tempDirectoryPath: '/path/to/temp', reset: true, + today: new Date('2022-06-22'), + }); + }); + + it('resolves the given project directory relative to the current working directory', async () => { + const project = buildMockProject({ + rootPackage: buildMockPackage(), }); + when(jest.spyOn(commandLineArgumentsModule, 'readCommandLineArguments')) + .calledWith(['arg1', 'arg2']) + .mockResolvedValue({ + projectDirectory: 'project', + tempDirectory: undefined, + reset: true, + }); + jest + .spyOn(envModule, 'getEnvironmentVariables') + .mockReturnValue({ TODAY: undefined, EDITOR: undefined }); + const readProjectSpy = jest + .spyOn(projectModule, 'readProject') + .mockResolvedValue(project); + + await determineInitialParameters(['arg1', 'arg2'], '/path/to/cwd'); + + expect(readProjectSpy).toHaveBeenCalledWith('/path/to/cwd/project'); + }); + + it('resolves the given temporary directory relative to the current working directory', async () => { + const project = buildMockProject(); + when(jest.spyOn(commandLineArgumentsModule, 'readCommandLineArguments')) + .calledWith(['arg1', 'arg2']) + .mockResolvedValue({ + projectDirectory: '/path/to/project', + tempDirectory: 'tmp', + reset: true, + }); + jest + .spyOn(envModule, 'getEnvironmentVariables') + .mockReturnValue({ TODAY: undefined, EDITOR: undefined }); + when(jest.spyOn(projectModule, 'readProject')) + .calledWith('/path/to/project') + .mockResolvedValue(project); + + const config = await determineInitialParameters( + ['arg1', 'arg2'], + '/path/to/cwd', + ); + + expect(config.tempDirectoryPath).toStrictEqual('/path/to/cwd/tmp'); }); - it('uses a default temporary directory based on the name of the package if no such directory was passed as an input', async () => { + it('uses a default temporary directory based on the name of the package if no temporary directory was given', async () => { const project = buildMockProject({ rootPackage: buildMockPackage('@foo/bar'), }); @@ -47,24 +108,73 @@ describe('initial-parameters', () => { tempDirectory: undefined, reset: true, }); + jest + .spyOn(envModule, 'getEnvironmentVariables') + .mockReturnValue({ TODAY: undefined, EDITOR: undefined }); when(jest.spyOn(projectModule, 'readProject')) .calledWith('/path/to/project') .mockResolvedValue(project); const config = await determineInitialParameters( ['arg1', 'arg2'], - '/path/to/somewhere', + '/path/to/cwd', ); - expect(config).toStrictEqual({ - project, - tempDirectoryPath: path.join( - os.tmpdir(), - 'create-release-branch', - '@foo__bar', - ), - reset: true, - }); + expect(config.tempDirectoryPath).toStrictEqual( + path.join(os.tmpdir(), 'create-release-branch', '@foo__bar'), + ); + }); + + it('uses the current date if the TODAY environment variable was not provided', async () => { + const project = buildMockProject(); + const today = new Date('2022-01-01'); + when(jest.spyOn(commandLineArgumentsModule, 'readCommandLineArguments')) + .calledWith(['arg1', 'arg2']) + .mockResolvedValue({ + projectDirectory: '/path/to/project', + tempDirectory: undefined, + reset: true, + }); + jest + .spyOn(envModule, 'getEnvironmentVariables') + .mockReturnValue({ TODAY: undefined, EDITOR: undefined }); + when(jest.spyOn(projectModule, 'readProject')) + .calledWith('/path/to/project') + .mockResolvedValue(project); + jest.setSystemTime(today); + + const config = await determineInitialParameters( + ['arg1', 'arg2'], + '/path/to/cwd', + ); + + expect(config.today).toStrictEqual(today); + }); + + it('uses the current date if TODAY is not a parsable date', async () => { + const project = buildMockProject(); + const today = new Date('2022-01-01'); + when(jest.spyOn(commandLineArgumentsModule, 'readCommandLineArguments')) + .calledWith(['arg1', 'arg2']) + .mockResolvedValue({ + projectDirectory: '/path/to/project', + tempDirectory: undefined, + reset: true, + }); + jest + .spyOn(envModule, 'getEnvironmentVariables') + .mockReturnValue({ TODAY: 'asdfgdasf', EDITOR: undefined }); + when(jest.spyOn(projectModule, 'readProject')) + .calledWith('/path/to/project') + .mockResolvedValue(project); + jest.setSystemTime(today); + + const config = await determineInitialParameters( + ['arg1', 'arg2'], + '/path/to/cwd', + ); + + expect(config.today).toStrictEqual(today); }); }); }); diff --git a/src/initial-parameters.ts b/src/initial-parameters.ts index a409b41..c4b81a7 100644 --- a/src/initial-parameters.ts +++ b/src/initial-parameters.ts @@ -1,5 +1,6 @@ import os from 'os'; import path from 'path'; +import { getEnvironmentVariables } from './env'; import { readCommandLineArguments } from './command-line-arguments'; import { readProject, Project } from './project'; @@ -7,6 +8,7 @@ interface InitialParameters { project: Project; tempDirectoryPath: string; reset: boolean; + today: Date; } /** @@ -22,6 +24,8 @@ export async function determineInitialParameters( cwd: string, ): Promise { const inputs = await readCommandLineArguments(argv); + const { TODAY } = getEnvironmentVariables(); + const projectDirectoryPath = path.resolve(cwd, inputs.projectDirectory); const project = await readProject(projectDirectoryPath); const tempDirectoryPath = @@ -32,6 +36,11 @@ export async function determineInitialParameters( project.rootPackage.validatedManifest.name.replace('/', '__'), ) : path.resolve(cwd, inputs.tempDirectory); + const parsedTodayTimestamp = + TODAY === undefined ? NaN : new Date(TODAY).getTime(); + const today = isNaN(parsedTodayTimestamp) + ? new Date() + : new Date(parsedTodayTimestamp); - return { project, tempDirectoryPath, reset: inputs.reset }; + return { project, tempDirectoryPath, reset: inputs.reset, today }; } diff --git a/src/main.test.ts b/src/main.test.ts index 539275c..7869900 100644 --- a/src/main.test.ts +++ b/src/main.test.ts @@ -10,6 +10,7 @@ jest.mock('./monorepo-workflow-operations'); describe('main', () => { it('executes the monorepo workflow if the project is a monorepo', async () => { const project = buildMockProject({ isMonorepo: true }); + const today = new Date(); const stdout = fs.createWriteStream('/dev/null'); const stderr = fs.createWriteStream('/dev/null'); jest @@ -18,6 +19,7 @@ describe('main', () => { project, tempDirectoryPath: '/path/to/temp/directory', reset: false, + today, }); const followMonorepoWorkflowSpy = jest .spyOn(monorepoWorkflowOperations, 'followMonorepoWorkflow') @@ -34,6 +36,7 @@ describe('main', () => { project, tempDirectoryPath: '/path/to/temp/directory', firstRemovingExistingReleaseSpecification: false, + today, stdout, stderr, }); @@ -41,6 +44,7 @@ describe('main', () => { it('executes the polyrepo workflow if the project is within a polyrepo', async () => { const project = buildMockProject({ isMonorepo: false }); + const today = new Date(); const stdout = fs.createWriteStream('/dev/null'); const stderr = fs.createWriteStream('/dev/null'); jest @@ -49,6 +53,7 @@ describe('main', () => { project, tempDirectoryPath: '/path/to/temp/directory', reset: false, + today, }); const followMonorepoWorkflowSpy = jest .spyOn(monorepoWorkflowOperations, 'followMonorepoWorkflow') diff --git a/src/main.ts b/src/main.ts index 66a0fe7..0e1dcdc 100644 --- a/src/main.ts +++ b/src/main.ts @@ -25,7 +25,7 @@ export async function main({ stdout: Pick; stderr: Pick; }) { - const { project, tempDirectoryPath, reset } = + const { project, tempDirectoryPath, reset, today } = await determineInitialParameters(argv, cwd); if (project.isMonorepo) { @@ -36,6 +36,7 @@ export async function main({ project, tempDirectoryPath, firstRemovingExistingReleaseSpecification: reset, + today, stdout, stderr, }); diff --git a/src/monorepo-workflow-operations.test.ts b/src/monorepo-workflow-operations.test.ts index fd83ed1..6a6405b 100644 --- a/src/monorepo-workflow-operations.test.ts +++ b/src/monorepo-workflow-operations.test.ts @@ -1,1788 +1,1214 @@ import fs from 'fs'; import path from 'path'; -import { SemVer } from 'semver'; -import { withSandbox } from '../tests/helpers'; -import { - buildMockPackage, - buildMockMonorepoRootPackage, - buildMockProject, -} from '../tests/unit/helpers'; +import { when } from 'jest-when'; +import { MockWritable } from 'stdio-mock'; +import { withSandbox, Sandbox, isErrorWithCode } from '../tests/helpers'; +import { buildMockProject, Require } from '../tests/unit/helpers'; import { followMonorepoWorkflow } from './monorepo-workflow-operations'; import * as editorModule from './editor'; -import * as envModule from './env'; -import * as packageModule from './package'; -import type { Project } from './project'; +import type { Editor } from './editor'; import * as releaseSpecificationModule from './release-specification'; -import * as workflowOperations from './workflow-operations'; +import type { ReleaseSpecification } from './release-specification'; +import * as releasePlanModule from './release-plan'; +import type { ReleasePlan } from './release-plan'; +import * as repoModule from './repo'; jest.mock('./editor'); -jest.mock('./env'); -jest.mock('./package'); +jest.mock('./release-plan'); jest.mock('./release-specification'); -jest.mock('./workflow-operations'); +jest.mock('./repo'); /** - * Given a Promise type, returns the type inside. + * Tests the given path to determine whether it represents a file. + * + * @param entryPath - The path to a file (or directory) on the filesystem. + * @returns A promise for true if the file exists or false otherwise. */ -type UnwrapPromise = T extends Promise ? U : never; +async function fileExists(entryPath: string): Promise { + try { + const stats = await fs.promises.stat(entryPath); + return stats.isFile(); + } catch (error) { + if (isErrorWithCode(error) && error.code === 'ENOENT') { + return false; + } + + throw error; + } +} + +/** + * Mocks the dependencies for `followMonorepoWorkflow`. + * + * @returns The corresponding mock functions for each of the dependencies. + */ +function getDependencySpies() { + return { + determineEditorSpy: jest.spyOn(editorModule, 'determineEditor'), + generateReleaseSpecificationTemplateForMonorepoSpy: jest.spyOn( + releaseSpecificationModule, + 'generateReleaseSpecificationTemplateForMonorepo', + ), + waitForUserToEditReleaseSpecificationSpy: jest.spyOn( + releaseSpecificationModule, + 'waitForUserToEditReleaseSpecification', + ), + validateReleaseSpecificationSpy: jest.spyOn( + releaseSpecificationModule, + 'validateReleaseSpecification', + ), + planReleaseSpy: jest.spyOn(releasePlanModule, 'planRelease'), + executeReleasePlanSpy: jest.spyOn(releasePlanModule, 'executeReleasePlan'), + captureChangesInReleaseBranchSpy: jest.spyOn( + repoModule, + 'captureChangesInReleaseBranch', + ), + }; +} + +/** + * Builds a release specification object for use in tests. All properties have + * default values, so you can specify only the properties you care about. + * + * @param overrides - The properties you want to override in the mock release + * specification. + * @param overrides.packages - A mapping of package names to version specifiers. + * @param overrides.path - The path to the original release specification file. + * @returns The mock release specification. + */ +function buildMockReleaseSpecification({ + packages = {}, + path: releaseSpecificationPath, +}: Require, 'path'>): ReleaseSpecification { + return { packages, path: releaseSpecificationPath }; +} + +/** + * Builds a release plan object for use in tests. All properties have + * default values, so you can specify only the properties you care about. + * + * @param overrides - The properties you want to override in the mock release + * plan. + * @param overrides.releaseName - The name of the new release. For a polyrepo or + * a monorepo with fixed versions, this will be a version string with the shape + * `..`; for a monorepo with independent versions, this + * will be a version string with the shape `..-`. + * @param overrides.packages - Information about all of the packages in the + * project. For a polyrepo, this consists of the self-same package; for a + * monorepo it consists of the root package and any workspace packages. + * @returns The mock release specification. + */ +function buildMockReleasePlan({ + releaseName = 'release-name', + packages = [], +}: Partial = {}): ReleasePlan { + return { releaseName, packages }; +} + +/** + * Builds an editor object for use in tests. All properties have default values, + * so you can specify only the properties you care about. + * + * @param overrides - The properties you want to override in the mock editor. + * @param overrides.path - The path to the executable representing the editor. + * @param overrides.args - Command-line arguments to pass to the executable when + * calling it. + * @returns The mock editor. + */ +function buildMockEditor({ + path: editorPath = '/some/editor', + args = [], +}: Partial = {}): Editor { + return { path: editorPath, args }; +} + +/** + * Sets up an invocation of `followMonorepoWorkflow` so that a particular + * branch of logic within its implementation will be followed. + * + * @param args - The arguments. + * @param args.sandbox - The sandbox. + * @param args.doesReleaseSpecFileExist - Whether the release spec file should + * be created. + * @param args.isEditorAvailable - Whether `determineEditor` should return an + * editor object. + * @param args.errorUponEditingReleaseSpec - The error that + * `waitForUserToEditReleaseSpecification` will throw, or null/undefined if it + * should not throw. + * @returns Mock functions and other data that can be used in tests to make + * assertions. + */ +async function setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist, + isEditorAvailable = false, + errorUponEditingReleaseSpec = null, +}: { + sandbox: Sandbox; + doesReleaseSpecFileExist: boolean; + isEditorAvailable?: boolean; + errorUponEditingReleaseSpec?: Error | null; +}) { + const { + determineEditorSpy, + generateReleaseSpecificationTemplateForMonorepoSpy, + waitForUserToEditReleaseSpecificationSpy, + validateReleaseSpecificationSpy, + planReleaseSpy, + executeReleasePlanSpy, + captureChangesInReleaseBranchSpy, + } = getDependencySpies(); + const editor = buildMockEditor(); + const releaseSpecificationPath = path.join( + sandbox.directoryPath, + 'RELEASE_SPEC', + ); + const releaseSpecification = buildMockReleaseSpecification({ + path: releaseSpecificationPath, + }); + const releaseName = 'some-release-name'; + const releasePlan = buildMockReleasePlan({ releaseName }); + const projectDirectoryPath = '/path/to/project'; + const project = buildMockProject({ directoryPath: projectDirectoryPath }); + const today = new Date(); + const stdout = new MockWritable(); + const stderr = new MockWritable(); + determineEditorSpy.mockResolvedValue(isEditorAvailable ? editor : null); + when(generateReleaseSpecificationTemplateForMonorepoSpy) + .calledWith({ project, isEditorAvailable }) + .mockResolvedValue(''); + + if (errorUponEditingReleaseSpec) { + when(waitForUserToEditReleaseSpecificationSpy) + .calledWith(releaseSpecificationPath, editor) + .mockRejectedValue(errorUponEditingReleaseSpec); + } else { + when(waitForUserToEditReleaseSpecificationSpy) + .calledWith(releaseSpecificationPath, editor) + .mockResolvedValue(); + } + + when(validateReleaseSpecificationSpy) + .calledWith(project, releaseSpecificationPath) + .mockResolvedValue(releaseSpecification); + when(planReleaseSpy) + .calledWith({ project, releaseSpecification, today }) + .mockResolvedValue(releasePlan); + executeReleasePlanSpy.mockResolvedValue(); + captureChangesInReleaseBranchSpy.mockResolvedValue(); + + if (doesReleaseSpecFileExist) { + await fs.promises.writeFile( + releaseSpecificationPath, + 'some release specification', + ); + } + + return { + project, + projectDirectoryPath, + today, + stdout, + stderr, + executeReleasePlanSpy, + captureChangesInReleaseBranchSpy, + releasePlan, + releaseName, + releaseSpecificationPath, + }; +} describe('monorepo-workflow-operations', () => { describe('followMonorepoWorkflow', () => { - describe('when firstRemovingExistingReleaseSpecification is true', () => { - describe('when a release spec file does not already exist', () => { - describe('when an editor can be determined', () => { - describe('when the editor command completes successfully', () => { - it('generates a release spec, waits for the user to edit it, then applies it to the monorepo', async () => { - await withSandbox(async (sandbox) => { - const project = buildMockMonorepoProject({ - rootPackage: buildMockPackage('root', '2022.1.1', { - validatedManifest: { - private: true, - workspaces: ['packages/*'], - }, - }), - workspacePackages: { - a: buildMockPackage('a', '1.0.0', { - validatedManifest: { - private: false, - }, - }), - b: buildMockPackage('b', '1.0.0', { - validatedManifest: { - private: false, - }, - }), - c: buildMockPackage('c', '1.0.0', { - validatedManifest: { - private: false, - }, - }), - d: buildMockPackage('d', '1.0.0', { - validatedManifest: { - private: false, - }, - }), - }, - }); - const stdout = fs.createWriteStream('/dev/null'); - const stderr = fs.createWriteStream('/dev/null'); - const { - generateReleaseSpecificationTemplateForMonorepoSpy, - updatePackageSpy, - } = mockDependencies({ - determineEditor: { - path: '/some/editor', - args: [], - }, - getEnvironmentVariables: { - TODAY: '2022-06-12', - }, - validateReleaseSpecification: { - packages: { - a: releaseSpecificationModule.IncrementableVersionParts - .major, - b: releaseSpecificationModule.IncrementableVersionParts - .minor, - c: releaseSpecificationModule.IncrementableVersionParts - .patch, - d: new SemVer('1.2.3'), - }, - }, - }); - - await followMonorepoWorkflow({ - project, - tempDirectoryPath: sandbox.directoryPath, - firstRemovingExistingReleaseSpecification: true, - stdout, - stderr, - }); - - expect( - generateReleaseSpecificationTemplateForMonorepoSpy, - ).toHaveBeenCalled(); - expect(updatePackageSpy).toHaveBeenNthCalledWith(1, { - project, - packageReleasePlan: { - package: project.rootPackage, - newVersion: '2022.6.12', - shouldUpdateChangelog: false, - }, - stderr, - }); - expect(updatePackageSpy).toHaveBeenNthCalledWith(2, { - project, - packageReleasePlan: { - package: project.workspacePackages.a, - newVersion: '2.0.0', - shouldUpdateChangelog: true, - }, - stderr, - }); - expect(updatePackageSpy).toHaveBeenNthCalledWith(3, { - project, - packageReleasePlan: { - package: project.workspacePackages.b, - newVersion: '1.1.0', - shouldUpdateChangelog: true, - }, - stderr, - }); - expect(updatePackageSpy).toHaveBeenNthCalledWith(4, { - project, - packageReleasePlan: { - package: project.workspacePackages.c, - newVersion: '1.0.1', - shouldUpdateChangelog: true, - }, - stderr, - }); - expect(updatePackageSpy).toHaveBeenNthCalledWith(5, { - project, - packageReleasePlan: { - package: project.workspacePackages.d, - newVersion: '1.2.3', - shouldUpdateChangelog: true, - }, - stderr, - }); - }); - }); - - it('creates a new branch named after the generated release version', async () => { - await withSandbox(async (sandbox) => { - const project = buildMockMonorepoProject({ - rootPackage: buildMockPackage('root', '2022.1.1', { - validatedManifest: { - private: true, - workspaces: ['packages/*'], - }, - }), - workspacePackages: { - a: buildMockPackage('a', '1.0.0', { - validatedManifest: { - private: false, - }, - }), - }, - }); - const stdout = fs.createWriteStream('/dev/null'); - const stderr = fs.createWriteStream('/dev/null'); - const { captureChangesInReleaseBranchSpy } = mockDependencies({ - determineEditor: { - path: '/some/editor', - args: [], - }, - getEnvironmentVariables: { - TODAY: '2022-06-12', - }, - validateReleaseSpecification: { - packages: { - a: releaseSpecificationModule.IncrementableVersionParts - .major, - }, - }, - }); - - await followMonorepoWorkflow({ - project, - tempDirectoryPath: sandbox.directoryPath, - firstRemovingExistingReleaseSpecification: true, - stdout, - stderr, - }); - - expect(captureChangesInReleaseBranchSpy).toHaveBeenCalledWith( - project, - { - releaseName: '2022-06-12', - packages: [ - { - package: project.rootPackage, - newVersion: '2022.6.12', - shouldUpdateChangelog: false, - }, - { - package: project.workspacePackages.a, - newVersion: '2.0.0', - shouldUpdateChangelog: true, - }, - ], - }, - ); - }); - }); + describe('when firstRemovingExistingReleaseSpecification is false, the release spec file does not already exist, an editor is available, and editing will succeed', () => { + it('executes the edited release spec', async () => { + await withSandbox(async (sandbox) => { + const { + project, + today, + stdout, + stderr, + executeReleasePlanSpy, + releasePlan, + } = await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: false, + isEditorAvailable: true, + errorUponEditingReleaseSpec: null, + }); - it('removes the release spec file at the end', async () => { - await withSandbox(async (sandbox) => { - const project = buildMockMonorepoProject(); - const stdout = fs.createWriteStream('/dev/null'); - const stderr = fs.createWriteStream('/dev/null'); - mockDependencies({ - determineEditor: { - path: '/some/editor', - args: [], - }, - }); - - await followMonorepoWorkflow({ - project, - tempDirectoryPath: sandbox.directoryPath, - firstRemovingExistingReleaseSpecification: true, - stdout, - stderr, - }); - - await expect( - fs.promises.readFile( - path.join(sandbox.directoryPath, 'RELEASE_SPEC'), - 'utf8', - ), - ).rejects.toThrow(/^ENOENT: no such file or directory/u); - }); - }); + await followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: false, + today, + stdout, + stderr, + }); - it("throws if a version specifier for a package within the edited release spec, when applied, would result in no change to the package's version", async () => { - await withSandbox(async (sandbox) => { - const project = buildMockMonorepoProject({ - rootPackage: buildMockPackage('root', '2022.1.1', { - validatedManifest: { - private: true, - workspaces: ['packages/*'], - }, - }), - workspacePackages: { - a: buildMockPackage('a', '1.0.0', { - validatedManifest: { - private: false, - }, - }), - }, - }); - const stdout = fs.createWriteStream('/dev/null'); - const stderr = fs.createWriteStream('/dev/null'); - mockDependencies({ - determineEditor: { - path: '/some/editor', - args: [], - }, - getEnvironmentVariables: { - TODAY: '2022-06-12', - }, - validateReleaseSpecification: { - packages: { - a: new SemVer('1.0.0'), - }, - }, - }); - - await expect( - followMonorepoWorkflow({ - project, - tempDirectoryPath: sandbox.directoryPath, - firstRemovingExistingReleaseSpecification: true, - stdout, - stderr, - }), - ).rejects.toThrow( - /^Could not update package "a" to "1.0.0" as that is already the current version./u, - ); - }); - }); + expect(executeReleasePlanSpy).toHaveBeenCalledWith( + project, + releasePlan, + stderr, + ); + }); + }); - it("throws if a version specifier for a package within the edited release spec, when applied, would result in a backward change to the package's version", async () => { - await withSandbox(async (sandbox) => { - const project = buildMockMonorepoProject({ - rootPackage: buildMockPackage('root', '2022.1.1', { - validatedManifest: { - private: true, - workspaces: ['packages/*'], - }, - }), - workspacePackages: { - a: buildMockPackage('a', '1.0.3', { - validatedManifest: { - private: false, - }, - }), - }, - }); - const stdout = fs.createWriteStream('/dev/null'); - const stderr = fs.createWriteStream('/dev/null'); - mockDependencies({ - determineEditor: { - path: '/some/editor', - args: [], - }, - getEnvironmentVariables: { - TODAY: '2022-06-12', - }, - validateReleaseSpecification: { - packages: { - a: new SemVer('1.0.2'), - }, - }, - }); - - await expect( - followMonorepoWorkflow({ - project, - tempDirectoryPath: sandbox.directoryPath, - firstRemovingExistingReleaseSpecification: true, - stdout, - stderr, - }), - ).rejects.toThrow( - /^Could not update package "a" to "1.0.2" as it is less than the current version "1.0.3"./u, - ); - }); - }); + it('creates a new branch named after the generated release version', async () => { + await withSandbox(async (sandbox) => { + const { + project, + today, + stdout, + stderr, + captureChangesInReleaseBranchSpy, + projectDirectoryPath, + releaseName, + } = await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: false, + isEditorAvailable: true, + errorUponEditingReleaseSpec: null, + }); - it("does not remove the release spec file if a version specifier for a package within the edited release spec, when applied, would result in no change to the package's version", async () => { - await withSandbox(async (sandbox) => { - const project = buildMockMonorepoProject({ - rootPackage: buildMockPackage('root', '2022.1.1', { - validatedManifest: { - private: true, - workspaces: ['packages/*'], - }, - }), - workspacePackages: { - a: buildMockPackage('a', '1.0.0', { - validatedManifest: { - private: false, - }, - }), - }, - }); - const stdout = fs.createWriteStream('/dev/null'); - const stderr = fs.createWriteStream('/dev/null'); - mockDependencies({ - determineEditor: { - path: '/some/editor', - args: [], - }, - getEnvironmentVariables: { - TODAY: '2022-06-12', - }, - validateReleaseSpecification: { - packages: { - a: new SemVer('1.0.0'), - }, - }, - }); - const releaseSpecPath = path.join( - sandbox.directoryPath, - 'RELEASE_SPEC', - ); - await fs.promises.writeFile(releaseSpecPath, 'release spec'); - - await expect( - followMonorepoWorkflow({ - project, - tempDirectoryPath: sandbox.directoryPath, - firstRemovingExistingReleaseSpecification: true, - stdout, - stderr, - }), - ).rejects.toThrow(expect.anything()); - - expect(await fs.promises.stat(releaseSpecPath)).toStrictEqual( - expect.anything(), - ); - }); - }); + await followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: false, + today, + stdout, + stderr, }); - describe('when the editor command does not complete successfully', () => { - it('removes the release spec file', async () => { - await withSandbox(async (sandbox) => { - const project = buildMockMonorepoProject(); - const stdout = fs.createWriteStream('/dev/null'); - const stderr = fs.createWriteStream('/dev/null'); - mockDependencies({ - determineEditor: { - path: '/some/editor', - args: [], - }, - }); - jest - .spyOn( - releaseSpecificationModule, - 'waitForUserToEditReleaseSpecification', - ) - .mockRejectedValue(new Error('oops')); - - await expect( - followMonorepoWorkflow({ - project, - tempDirectoryPath: sandbox.directoryPath, - firstRemovingExistingReleaseSpecification: true, - stdout, - stderr, - }), - ).rejects.toThrow(expect.anything()); - - await expect( - fs.promises.readFile( - path.join(sandbox.directoryPath, 'RELEASE_SPEC'), - 'utf8', - ), - ).rejects.toThrow(/^ENOENT: no such file or directory/u); - }); + expect(captureChangesInReleaseBranchSpy).toHaveBeenCalledWith( + projectDirectoryPath, + releaseName, + ); + }); + }); + + it('removes the release spec file at the end', async () => { + await withSandbox(async (sandbox) => { + const { project, today, stdout, stderr, releaseSpecificationPath } = + await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: false, + isEditorAvailable: true, + errorUponEditingReleaseSpec: null, }); + + await followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: false, + today, + stdout, + stderr, }); + + expect(await fileExists(releaseSpecificationPath)).toBe(false); }); + }); + }); - describe('when an editor cannot be determined', () => { - it('merely generates a release spec and nothing more', async () => { - await withSandbox(async (sandbox) => { - const project = buildMockMonorepoProject(); - const stdout = fs.createWriteStream('/dev/null'); - const stderr = fs.createWriteStream('/dev/null'); - const { - generateReleaseSpecificationTemplateForMonorepoSpy, - waitForUserToEditReleaseSpecificationSpy, - validateReleaseSpecificationSpy, - updatePackageSpy, - captureChangesInReleaseBranchSpy, - } = mockDependencies({ - determineEditor: null, - }); - - await followMonorepoWorkflow({ - project, - tempDirectoryPath: sandbox.directoryPath, - firstRemovingExistingReleaseSpecification: true, - stdout, - stderr, - }); - - expect( - generateReleaseSpecificationTemplateForMonorepoSpy, - ).toHaveBeenCalled(); - expect( - waitForUserToEditReleaseSpecificationSpy, - ).not.toHaveBeenCalled(); - expect(validateReleaseSpecificationSpy).not.toHaveBeenCalled(); - expect(updatePackageSpy).not.toHaveBeenCalled(); - expect(captureChangesInReleaseBranchSpy).not.toHaveBeenCalled(); + describe('when firstRemovingExistingReleaseSpecification is false, the release spec file does not already exist, an editor is available, and editing will not succeed', () => { + it('does not try to execute the edited release spec', async () => { + await withSandbox(async (sandbox) => { + const { project, today, stdout, stderr, executeReleasePlanSpy } = + await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: false, + isEditorAvailable: true, + errorUponEditingReleaseSpec: new Error('oops'), }); + + await expect( + followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: false, + today, + stdout, + stderr, + }), + ).toBeRejected(); + + expect(executeReleasePlanSpy).not.toHaveBeenCalled(); + }); + }); + + it('does not try to create a new branch', async () => { + await withSandbox(async (sandbox) => { + const { + project, + today, + stdout, + stderr, + captureChangesInReleaseBranchSpy, + } = await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: false, + isEditorAvailable: true, + errorUponEditingReleaseSpec: new Error('oops'), }); + + await expect( + followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: false, + today, + stdout, + stderr, + }), + ).toBeRejected(); + + expect(captureChangesInReleaseBranchSpy).not.toHaveBeenCalled(); }); }); - describe('when a release spec file already exists', () => { - describe('when an editor can be determined', () => { - describe('when the editor command completes successfully', () => { - it('re-generates the release spec, waits for the user to edit it, then applies it to the monorepo', async () => { - await withSandbox(async (sandbox) => { - const project = buildMockMonorepoProject({ - rootPackage: buildMockPackage('root', '2022.1.1', { - validatedManifest: { - private: true, - workspaces: ['packages/*'], - }, - }), - workspacePackages: { - a: buildMockPackage('a', '1.0.0', { - validatedManifest: { - private: false, - }, - }), - }, - }); - const stdout = fs.createWriteStream('/dev/null'); - const stderr = fs.createWriteStream('/dev/null'); - const { - generateReleaseSpecificationTemplateForMonorepoSpy, - updatePackageSpy, - } = mockDependencies({ - determineEditor: { - path: '/some/editor', - args: [], - }, - getEnvironmentVariables: { - TODAY: '2022-06-12', - }, - validateReleaseSpecification: { - packages: { - a: releaseSpecificationModule.IncrementableVersionParts - .major, - }, - }, - }); - const releaseSpecPath = path.join( - sandbox.directoryPath, - 'RELEASE_SPEC', - ); - await fs.promises.writeFile(releaseSpecPath, 'release spec'); - - await followMonorepoWorkflow({ - project, - tempDirectoryPath: sandbox.directoryPath, - firstRemovingExistingReleaseSpecification: true, - stdout, - stderr, - }); - - expect( - generateReleaseSpecificationTemplateForMonorepoSpy, - ).toHaveBeenCalled(); - expect(updatePackageSpy).toHaveBeenNthCalledWith(1, { - project, - packageReleasePlan: { - package: project.rootPackage, - newVersion: '2022.6.12', - shouldUpdateChangelog: false, - }, - stderr, - }); - expect(updatePackageSpy).toHaveBeenNthCalledWith(2, { - project, - packageReleasePlan: { - package: project.workspacePackages.a, - newVersion: '2.0.0', - shouldUpdateChangelog: true, - }, - stderr, - }); - }); + it('removes the release spec file at the end', async () => { + await withSandbox(async (sandbox) => { + const { project, today, stdout, stderr, releaseSpecificationPath } = + await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: false, + isEditorAvailable: true, + errorUponEditingReleaseSpec: new Error('oops'), }); - it('creates a new branch named after the generated release version', async () => { - await withSandbox(async (sandbox) => { - const project = buildMockMonorepoProject({ - rootPackage: buildMockPackage('root', '2022.1.1', { - validatedManifest: { - private: true, - workspaces: ['packages/*'], - }, - }), - workspacePackages: { - a: buildMockPackage('a', '1.0.0', { - validatedManifest: { - private: false, - }, - }), - }, - }); - const stdout = fs.createWriteStream('/dev/null'); - const stderr = fs.createWriteStream('/dev/null'); - const { captureChangesInReleaseBranchSpy } = mockDependencies({ - determineEditor: { - path: '/some/editor', - args: [], - }, - getEnvironmentVariables: { - TODAY: '2022-06-12', - }, - validateReleaseSpecification: { - packages: { - a: releaseSpecificationModule.IncrementableVersionParts - .major, - }, - }, - }); - const releaseSpecPath = path.join( - sandbox.directoryPath, - 'RELEASE_SPEC', - ); - await fs.promises.writeFile(releaseSpecPath, 'release spec'); - - await followMonorepoWorkflow({ - project, - tempDirectoryPath: sandbox.directoryPath, - firstRemovingExistingReleaseSpecification: true, - stdout, - stderr, - }); - - expect(captureChangesInReleaseBranchSpy).toHaveBeenCalledWith( - project, - { - releaseName: '2022-06-12', - packages: [ - { - package: project.rootPackage, - newVersion: '2022.6.12', - shouldUpdateChangelog: false, - }, - { - package: project.workspacePackages.a, - newVersion: '2.0.0', - shouldUpdateChangelog: true, - }, - ], - }, - ); - }); - }); + await expect( + followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: false, + today, + stdout, + stderr, + }), + ).toBeRejected(); - it('removes the release spec file at the end', async () => { - await withSandbox(async (sandbox) => { - const project = buildMockMonorepoProject(); - const stdout = fs.createWriteStream('/dev/null'); - const stderr = fs.createWriteStream('/dev/null'); - mockDependencies({ - determineEditor: { - path: '/some/editor', - args: [], - }, - }); - const releaseSpecPath = path.join( - sandbox.directoryPath, - 'RELEASE_SPEC', - ); - await fs.promises.writeFile(releaseSpecPath, 'release spec'); - - await followMonorepoWorkflow({ - project, - tempDirectoryPath: sandbox.directoryPath, - firstRemovingExistingReleaseSpecification: true, - stdout, - stderr, - }); - - await expect( - fs.promises.readFile(releaseSpecPath, 'utf8'), - ).rejects.toThrow(/^ENOENT: no such file or directory/u); - }); - }); + expect(await fileExists(releaseSpecificationPath)).toBe(false); + }); + }); - it("throws if a version specifier for a package within the edited release spec, when applied, would result in no change to the package's version", async () => { - await withSandbox(async (sandbox) => { - const project = buildMockMonorepoProject({ - rootPackage: buildMockPackage('root', '2022.1.1', { - validatedManifest: { - private: true, - workspaces: ['packages/*'], - }, - }), - workspacePackages: { - a: buildMockPackage('a', '1.0.0', { - validatedManifest: { - private: false, - }, - }), - }, - }); - const stdout = fs.createWriteStream('/dev/null'); - const stderr = fs.createWriteStream('/dev/null'); - mockDependencies({ - determineEditor: { - path: '/some/editor', - args: [], - }, - getEnvironmentVariables: { - TODAY: '2022-06-12', - }, - validateReleaseSpecification: { - packages: { - a: new SemVer('1.0.0'), - }, - }, - }); - const releaseSpecPath = path.join( - sandbox.directoryPath, - 'RELEASE_SPEC', - ); - await fs.promises.writeFile(releaseSpecPath, 'release spec'); - - await expect( - followMonorepoWorkflow({ - project, - tempDirectoryPath: sandbox.directoryPath, - firstRemovingExistingReleaseSpecification: true, - stdout, - stderr, - }), - ).rejects.toThrow( - /^Could not update package "a" to "1.0.0" as that is already the current version./u, - ); - }); + it('throws the error', async () => { + await withSandbox(async (sandbox) => { + const { project, today, stdout, stderr } = + await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: false, + isEditorAvailable: true, + errorUponEditingReleaseSpec: new Error('oops'), }); - it("throws if a version specifier for a package within the edited release spec, when applied, would result in a backward change to the package's version", async () => { - await withSandbox(async (sandbox) => { - const project = buildMockMonorepoProject({ - rootPackage: buildMockPackage('root', '2022.1.1', { - validatedManifest: { - private: true, - workspaces: ['packages/*'], - }, - }), - workspacePackages: { - a: buildMockPackage('a', '1.0.3', { - validatedManifest: { - private: false, - }, - }), - }, - }); - const stdout = fs.createWriteStream('/dev/null'); - const stderr = fs.createWriteStream('/dev/null'); - mockDependencies({ - determineEditor: { - path: '/some/editor', - args: [], - }, - getEnvironmentVariables: { - TODAY: '2022-06-12', - }, - validateReleaseSpecification: { - packages: { - a: new SemVer('1.0.2'), - }, - }, - }); - const releaseSpecPath = path.join( - sandbox.directoryPath, - 'RELEASE_SPEC', - ); - await fs.promises.writeFile(releaseSpecPath, 'release spec'); - - await expect( - followMonorepoWorkflow({ - project, - tempDirectoryPath: sandbox.directoryPath, - firstRemovingExistingReleaseSpecification: true, - stdout, - stderr, - }), - ).rejects.toThrow( - /^Could not update package "a" to "1.0.2" as it is less than the current version "1.0.3"./u, - ); - }); - }); + await expect( + followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: false, + today, + stdout, + stderr, + }), + ).rejects.toThrow('oops'); + }); + }); + }); - it("does not remove the release spec file if a version specifier for a package within the edited release spec, when applied, would result in no change to the package's version", async () => { - await withSandbox(async (sandbox) => { - const project = buildMockMonorepoProject({ - rootPackage: buildMockPackage('root', '2022.1.1', { - validatedManifest: { - private: true, - workspaces: ['packages/*'], - }, - }), - workspacePackages: { - a: buildMockPackage('a', '1.0.0', { - validatedManifest: { - private: false, - }, - }), - }, - }); - const stdout = fs.createWriteStream('/dev/null'); - const stderr = fs.createWriteStream('/dev/null'); - mockDependencies({ - determineEditor: { - path: '/some/editor', - args: [], - }, - getEnvironmentVariables: { - TODAY: '2022-06-12', - }, - validateReleaseSpecification: { - packages: { - a: new SemVer('1.0.0'), - }, - }, - }); - const releaseSpecPath = path.join( - sandbox.directoryPath, - 'RELEASE_SPEC', - ); - await fs.promises.writeFile(releaseSpecPath, 'release spec'); - - await expect( - followMonorepoWorkflow({ - project, - tempDirectoryPath: sandbox.directoryPath, - firstRemovingExistingReleaseSpecification: true, - stdout, - stderr, - }), - ).rejects.toThrow(expect.anything()); - - expect(await fs.promises.stat(releaseSpecPath)).toStrictEqual( - expect.anything(), - ); - }); + describe('when firstRemovingExistingReleaseSpecification is false, the release spec file does not already exist, and an editor is not available', () => { + it('does not try to execute the edited release spec', async () => { + await withSandbox(async (sandbox) => { + const { project, today, stdout, stderr, executeReleasePlanSpy } = + await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: false, + isEditorAvailable: false, }); + + await followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: false, + today, + stdout, + stderr, + }); + + expect(executeReleasePlanSpy).not.toHaveBeenCalled(); + }); + }); + + it('does not try to create a new branch', async () => { + await withSandbox(async (sandbox) => { + const { + project, + today, + stdout, + stderr, + captureChangesInReleaseBranchSpy, + } = await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: false, + isEditorAvailable: false, + }); + + await followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: false, + today, + stdout, + stderr, }); - describe('when the editor command does not complete successfully', () => { - it('removes the release spec file', async () => { - await withSandbox(async (sandbox) => { - const project = buildMockMonorepoProject(); - const stdout = fs.createWriteStream('/dev/null'); - const stderr = fs.createWriteStream('/dev/null'); - mockDependencies({ - determineEditor: { - path: '/some/editor', - args: [], - }, - }); - jest - .spyOn( - releaseSpecificationModule, - 'waitForUserToEditReleaseSpecification', - ) - .mockRejectedValue(new Error('oops')); - const releaseSpecPath = path.join( - sandbox.directoryPath, - 'RELEASE_SPEC', - ); - await fs.promises.writeFile(releaseSpecPath, 'release spec'); - - await expect( - followMonorepoWorkflow({ - project, - tempDirectoryPath: sandbox.directoryPath, - firstRemovingExistingReleaseSpecification: true, - stdout, - stderr, - }), - ).rejects.toThrow(expect.anything()); - - await expect( - fs.promises.readFile(releaseSpecPath, 'utf8'), - ).rejects.toThrow(/^ENOENT: no such file or directory/u); - }); + expect(captureChangesInReleaseBranchSpy).not.toHaveBeenCalled(); + }); + }); + + it('prints a message', async () => { + await withSandbox(async (sandbox) => { + const { project, today, stdout, stderr } = + await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: false, + isEditorAvailable: false, }); + + await followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: false, + today, + stdout, + stderr, }); + + expect(stdout.data()[0]).toMatch( + /^A template has been generated that specifies this release/u, + ); }); + }); - describe('when an editor cannot be determined', () => { - it('merely re-generates a release spec and nothing more', async () => { - await withSandbox(async (sandbox) => { - const project = buildMockMonorepoProject(); - const stdout = fs.createWriteStream('/dev/null'); - const stderr = fs.createWriteStream('/dev/null'); - const { - generateReleaseSpecificationTemplateForMonorepoSpy, - waitForUserToEditReleaseSpecificationSpy, - validateReleaseSpecificationSpy, - updatePackageSpy, - captureChangesInReleaseBranchSpy, - } = mockDependencies({ - determineEditor: null, - }); - const releaseSpecPath = path.join( - sandbox.directoryPath, - 'RELEASE_SPEC', - ); - await fs.promises.writeFile(releaseSpecPath, 'release spec'); - - await followMonorepoWorkflow({ - project, - tempDirectoryPath: sandbox.directoryPath, - firstRemovingExistingReleaseSpecification: true, - stdout, - stderr, - }); - - expect( - generateReleaseSpecificationTemplateForMonorepoSpy, - ).toHaveBeenCalled(); - expect( - waitForUserToEditReleaseSpecificationSpy, - ).not.toHaveBeenCalled(); - expect(validateReleaseSpecificationSpy).not.toHaveBeenCalled(); - expect(updatePackageSpy).not.toHaveBeenCalled(); - expect(captureChangesInReleaseBranchSpy).not.toHaveBeenCalled(); + it('does not remove the release spec file', async () => { + await withSandbox(async (sandbox) => { + const { project, today, stdout, stderr, releaseSpecificationPath } = + await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: false, + isEditorAvailable: false, + errorUponEditingReleaseSpec: null, }); + + await followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: false, + today, + stdout, + stderr, }); + + expect(await fileExists(releaseSpecificationPath)).toBe(true); }); }); }); - describe('when firstRemovingExistingReleaseSpecification is false', () => { - describe('when a release spec file does not already exist', () => { - describe('when an editor can be determined', () => { - describe('when the editor command completes successfully', () => { - it('generates a release spec, waits for the user to edit it, then applies it to the monorepo', async () => { - await withSandbox(async (sandbox) => { - const project = buildMockMonorepoProject({ - rootPackage: buildMockPackage('root', '2022.1.1', { - validatedManifest: { - private: true, - workspaces: ['packages/*'], - }, - }), - workspacePackages: { - a: buildMockPackage('a', '1.0.0', { - validatedManifest: { - private: false, - }, - }), - b: buildMockPackage('b', '1.0.0', { - validatedManifest: { - private: false, - }, - }), - c: buildMockPackage('c', '1.0.0', { - validatedManifest: { - private: false, - }, - }), - d: buildMockPackage('d', '1.0.0', { - validatedManifest: { - private: false, - }, - }), - }, - }); - const stdout = fs.createWriteStream('/dev/null'); - const stderr = fs.createWriteStream('/dev/null'); - const { - generateReleaseSpecificationTemplateForMonorepoSpy, - updatePackageSpy, - } = mockDependencies({ - determineEditor: { - path: '/some/editor', - args: [], - }, - getEnvironmentVariables: { - TODAY: '2022-06-12', - }, - validateReleaseSpecification: { - packages: { - a: releaseSpecificationModule.IncrementableVersionParts - .major, - b: releaseSpecificationModule.IncrementableVersionParts - .minor, - c: releaseSpecificationModule.IncrementableVersionParts - .patch, - d: new SemVer('1.2.3'), - }, - }, - }); - - await followMonorepoWorkflow({ - project, - tempDirectoryPath: sandbox.directoryPath, - firstRemovingExistingReleaseSpecification: false, - stdout, - stderr, - }); - - expect( - generateReleaseSpecificationTemplateForMonorepoSpy, - ).toHaveBeenCalled(); - expect(updatePackageSpy).toHaveBeenNthCalledWith(1, { - project, - packageReleasePlan: { - package: project.rootPackage, - newVersion: '2022.6.12', - shouldUpdateChangelog: false, - }, - stderr, - }); - expect(updatePackageSpy).toHaveBeenNthCalledWith(2, { - project, - packageReleasePlan: { - package: project.workspacePackages.a, - newVersion: '2.0.0', - shouldUpdateChangelog: true, - }, - stderr, - }); - expect(updatePackageSpy).toHaveBeenNthCalledWith(3, { - project, - packageReleasePlan: { - package: project.workspacePackages.b, - newVersion: '1.1.0', - shouldUpdateChangelog: true, - }, - stderr, - }); - expect(updatePackageSpy).toHaveBeenNthCalledWith(4, { - project, - packageReleasePlan: { - package: project.workspacePackages.c, - newVersion: '1.0.1', - shouldUpdateChangelog: true, - }, - stderr, - }); - expect(updatePackageSpy).toHaveBeenNthCalledWith(5, { - project, - packageReleasePlan: { - package: project.workspacePackages.d, - newVersion: '1.2.3', - shouldUpdateChangelog: true, - }, - stderr, - }); - }); - }); + describe('when firstRemovingExistingReleaseSpecification is false and the release spec file already exists', () => { + it('executes the edited release spec', async () => { + await withSandbox(async (sandbox) => { + const { + project, + today, + stdout, + stderr, + executeReleasePlanSpy, + releasePlan, + } = await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: true, + }); - it('creates a new branch named after the generated release version', async () => { - await withSandbox(async (sandbox) => { - const project = buildMockMonorepoProject({ - rootPackage: buildMockPackage('root', '2022.1.1', { - validatedManifest: { - private: true, - workspaces: ['packages/*'], - }, - }), - workspacePackages: { - a: buildMockPackage('a', '1.0.0', { - validatedManifest: { - private: false, - }, - }), - }, - }); - const stdout = fs.createWriteStream('/dev/null'); - const stderr = fs.createWriteStream('/dev/null'); - const { captureChangesInReleaseBranchSpy } = mockDependencies({ - determineEditor: { - path: '/some/editor', - args: [], - }, - getEnvironmentVariables: { - TODAY: '2022-06-12', - }, - validateReleaseSpecification: { - packages: { - a: releaseSpecificationModule.IncrementableVersionParts - .major, - }, - }, - }); - - await followMonorepoWorkflow({ - project, - tempDirectoryPath: sandbox.directoryPath, - firstRemovingExistingReleaseSpecification: false, - stdout, - stderr, - }); - - expect(captureChangesInReleaseBranchSpy).toHaveBeenCalledWith( - project, - { - releaseName: '2022-06-12', - packages: [ - { - package: project.rootPackage, - newVersion: '2022.6.12', - shouldUpdateChangelog: false, - }, - { - package: project.workspacePackages.a, - newVersion: '2.0.0', - shouldUpdateChangelog: true, - }, - ], - }, - ); - }); - }); + await followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: false, + today, + stdout, + stderr, + }); - it('removes the release spec file at the end', async () => { - await withSandbox(async (sandbox) => { - const project = buildMockMonorepoProject(); - const stdout = fs.createWriteStream('/dev/null'); - const stderr = fs.createWriteStream('/dev/null'); - mockDependencies({ - determineEditor: { - path: '/some/editor', - args: [], - }, - }); - - await followMonorepoWorkflow({ - project, - tempDirectoryPath: sandbox.directoryPath, - firstRemovingExistingReleaseSpecification: false, - stdout, - stderr, - }); - - await expect( - fs.promises.readFile( - path.join(sandbox.directoryPath, 'RELEASE_SPEC'), - 'utf8', - ), - ).rejects.toThrow(/^ENOENT: no such file or directory/u); - }); - }); + expect(executeReleasePlanSpy).toHaveBeenCalledWith( + project, + releasePlan, + stderr, + ); + }); + }); - it("throws if a version specifier for a package within the edited release spec, when applied, would result in no change to the package's version", async () => { - await withSandbox(async (sandbox) => { - const project = buildMockMonorepoProject({ - rootPackage: buildMockPackage('root', '2022.1.1', { - validatedManifest: { - private: true, - workspaces: ['packages/*'], - }, - }), - workspacePackages: { - a: buildMockPackage('a', '1.0.0', { - validatedManifest: { - private: false, - }, - }), - }, - }); - const stdout = fs.createWriteStream('/dev/null'); - const stderr = fs.createWriteStream('/dev/null'); - mockDependencies({ - determineEditor: { - path: '/some/editor', - args: [], - }, - getEnvironmentVariables: { - TODAY: '2022-06-12', - }, - validateReleaseSpecification: { - packages: { - a: new SemVer('1.0.0'), - }, - }, - }); - - await expect( - followMonorepoWorkflow({ - project, - tempDirectoryPath: sandbox.directoryPath, - firstRemovingExistingReleaseSpecification: false, - stdout, - stderr, - }), - ).rejects.toThrow( - /^Could not update package "a" to "1.0.0" as that is already the current version./u, - ); - }); - }); + it('creates a new branch named after the generated release version', async () => { + await withSandbox(async (sandbox) => { + const { + project, + today, + stdout, + stderr, + captureChangesInReleaseBranchSpy, + projectDirectoryPath, + releaseName, + } = await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: true, + }); - it("throws if a version specifier for a package within the edited release spec, when applied, would result in a backward change to the package's version", async () => { - await withSandbox(async (sandbox) => { - const project = buildMockMonorepoProject({ - rootPackage: buildMockPackage('root', '2022.1.1', { - validatedManifest: { - private: true, - workspaces: ['packages/*'], - }, - }), - workspacePackages: { - a: buildMockPackage('a', '1.0.3', { - validatedManifest: { - private: false, - }, - }), - }, - }); - const stdout = fs.createWriteStream('/dev/null'); - const stderr = fs.createWriteStream('/dev/null'); - mockDependencies({ - determineEditor: { - path: '/some/editor', - args: [], - }, - getEnvironmentVariables: { - TODAY: '2022-06-12', - }, - validateReleaseSpecification: { - packages: { - a: new SemVer('1.0.2'), - }, - }, - }); - - await expect( - followMonorepoWorkflow({ - project, - tempDirectoryPath: sandbox.directoryPath, - firstRemovingExistingReleaseSpecification: false, - stdout, - stderr, - }), - ).rejects.toThrow( - /^Could not update package "a" to "1.0.2" as it is less than the current version "1.0.3"./u, - ); - }); - }); + await followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: false, + today, + stdout, + stderr, + }); + + expect(captureChangesInReleaseBranchSpy).toHaveBeenCalledWith( + projectDirectoryPath, + releaseName, + ); + }); + }); - it("does not remove the release spec file if a version specifier for a package within the edited release spec, when applied, would result in no change to the package's version", async () => { - await withSandbox(async (sandbox) => { - const project = buildMockMonorepoProject({ - rootPackage: buildMockPackage('root', '2022.1.1', { - validatedManifest: { - private: true, - workspaces: ['packages/*'], - }, - }), - workspacePackages: { - a: buildMockPackage('a', '1.0.0', { - validatedManifest: { - private: false, - }, - }), - }, - }); - const stdout = fs.createWriteStream('/dev/null'); - const stderr = fs.createWriteStream('/dev/null'); - mockDependencies({ - determineEditor: { - path: '/some/editor', - args: [], - }, - getEnvironmentVariables: { - TODAY: '2022-06-12', - }, - validateReleaseSpecification: { - packages: { - a: new SemVer('1.0.0'), - }, - }, - }); - - await expect( - followMonorepoWorkflow({ - project, - tempDirectoryPath: sandbox.directoryPath, - firstRemovingExistingReleaseSpecification: false, - stdout, - stderr, - }), - ).rejects.toThrow(expect.anything()); - - expect( - await fs.promises.stat( - path.join(sandbox.directoryPath, 'RELEASE_SPEC'), - ), - ).toStrictEqual(expect.anything()); - }); + it('removes the release spec file at the end', async () => { + await withSandbox(async (sandbox) => { + const { project, today, stdout, stderr, releaseSpecificationPath } = + await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: true, }); + + await followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: false, + today, + stdout, + stderr, }); - describe('when the editor command does not complete successfully', () => { - it('removes the release spec file', async () => { - await withSandbox(async (sandbox) => { - const project = buildMockMonorepoProject(); - const stdout = fs.createWriteStream('/dev/null'); - const stderr = fs.createWriteStream('/dev/null'); - mockDependencies({ - determineEditor: { - path: '/some/editor', - args: [], - }, - }); - jest - .spyOn( - releaseSpecificationModule, - 'waitForUserToEditReleaseSpecification', - ) - .mockRejectedValue(new Error('oops')); - - await expect( - followMonorepoWorkflow({ - project, - tempDirectoryPath: sandbox.directoryPath, - firstRemovingExistingReleaseSpecification: false, - stdout, - stderr, - }), - ).rejects.toThrow(expect.anything()); - - await expect( - fs.promises.readFile( - path.join(sandbox.directoryPath, 'RELEASE_SPEC'), - 'utf8', - ), - ).rejects.toThrow(/^ENOENT: no such file or directory/u); - }); + expect(await fileExists(releaseSpecificationPath)).toBe(false); + }); + }); + + it('does not remove the release spec file', async () => { + await withSandbox(async (sandbox) => { + const { project, today, stdout, stderr, releaseSpecificationPath } = + await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: false, + isEditorAvailable: false, + errorUponEditingReleaseSpec: null, }); + + await followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: false, + today, + stdout, + stderr, }); + + expect(await fileExists(releaseSpecificationPath)).toBe(true); }); + }); + }); - describe('when an editor cannot be determined', () => { - it('merely generates a release spec and nothing more', async () => { - await withSandbox(async (sandbox) => { - const project = buildMockMonorepoProject(); - const stdout = fs.createWriteStream('/dev/null'); - const stderr = fs.createWriteStream('/dev/null'); - const { - generateReleaseSpecificationTemplateForMonorepoSpy, - waitForUserToEditReleaseSpecificationSpy, - validateReleaseSpecificationSpy, - updatePackageSpy, - captureChangesInReleaseBranchSpy, - } = mockDependencies({ - determineEditor: null, - }); - - await followMonorepoWorkflow({ - project, - tempDirectoryPath: sandbox.directoryPath, - firstRemovingExistingReleaseSpecification: false, - stdout, - stderr, - }); - - expect( - generateReleaseSpecificationTemplateForMonorepoSpy, - ).toHaveBeenCalled(); - expect( - waitForUserToEditReleaseSpecificationSpy, - ).not.toHaveBeenCalled(); - expect(validateReleaseSpecificationSpy).not.toHaveBeenCalled(); - expect(updatePackageSpy).not.toHaveBeenCalled(); - expect(captureChangesInReleaseBranchSpy).not.toHaveBeenCalled(); - }); + describe('when firstRemovingExistingReleaseSpecification is true, the release spec file does not already exist, an editor is available, and editing will succeed', () => { + it('executes the edited release spec', async () => { + await withSandbox(async (sandbox) => { + const { + project, + today, + stdout, + stderr, + executeReleasePlanSpy, + releasePlan, + } = await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: false, + isEditorAvailable: true, + errorUponEditingReleaseSpec: null, + }); + + await followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: true, + today, + stdout, + stderr, + }); + + expect(executeReleasePlanSpy).toHaveBeenCalledWith( + project, + releasePlan, + stderr, + ); + }); + }); + + it('creates a new branch named after the generated release version', async () => { + await withSandbox(async (sandbox) => { + const { + project, + today, + stdout, + stderr, + captureChangesInReleaseBranchSpy, + projectDirectoryPath, + releaseName, + } = await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: false, + isEditorAvailable: true, + errorUponEditingReleaseSpec: null, + }); + + await followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: true, + today, + stdout, + stderr, }); + + expect(captureChangesInReleaseBranchSpy).toHaveBeenCalledWith( + projectDirectoryPath, + releaseName, + ); }); }); - describe('when a release spec file already exists', () => { - it('does not re-generate the release spec, but applies it to the monorepo', async () => { - await withSandbox(async (sandbox) => { - const project = buildMockMonorepoProject({ - rootPackage: buildMockPackage('root', '2022.1.1', { - validatedManifest: { - private: true, - workspaces: ['packages/*'], - }, - }), - workspacePackages: { - a: buildMockPackage('a', '1.0.0', { - validatedManifest: { - private: false, - }, - }), - }, + it('removes the release spec file at the end', async () => { + await withSandbox(async (sandbox) => { + const { project, today, stdout, stderr, releaseSpecificationPath } = + await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: false, + isEditorAvailable: true, + errorUponEditingReleaseSpec: null, }); - const stdout = fs.createWriteStream('/dev/null'); - const stderr = fs.createWriteStream('/dev/null'); - const { - generateReleaseSpecificationTemplateForMonorepoSpy, - waitForUserToEditReleaseSpecificationSpy, - updatePackageSpy, - } = mockDependencies({ - determineEditor: { - path: '/some/editor', - args: [], - }, - getEnvironmentVariables: { - TODAY: '2022-06-12', - }, - validateReleaseSpecification: { - packages: { - a: releaseSpecificationModule.IncrementableVersionParts.major, - }, - }, + + await followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: true, + today, + stdout, + stderr, + }); + + expect(await fileExists(releaseSpecificationPath)).toBe(false); + }); + }); + }); + + describe('when firstRemovingExistingReleaseSpecification is true, the release spec file does not already exist, an editor is available, and editing will not succeed', () => { + it('does not try to execute the edited release spec', async () => { + await withSandbox(async (sandbox) => { + const { project, today, stdout, stderr, executeReleasePlanSpy } = + await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: false, + isEditorAvailable: true, + errorUponEditingReleaseSpec: new Error('oops'), }); - const releaseSpecPath = path.join( - sandbox.directoryPath, - 'RELEASE_SPEC', - ); - await fs.promises.writeFile(releaseSpecPath, 'release spec'); - await followMonorepoWorkflow({ + await expect( + followMonorepoWorkflow({ project, tempDirectoryPath: sandbox.directoryPath, - firstRemovingExistingReleaseSpecification: false, + firstRemovingExistingReleaseSpecification: true, + today, + stdout, + stderr, + }), + ).toBeRejected(); + + expect(executeReleasePlanSpy).not.toHaveBeenCalled(); + }); + }); + + it('does not try to create a new branch', async () => { + await withSandbox(async (sandbox) => { + const { + project, + today, + stdout, + stderr, + captureChangesInReleaseBranchSpy, + } = await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: false, + isEditorAvailable: true, + errorUponEditingReleaseSpec: new Error('oops'), + }); + + await expect( + followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: true, + today, stdout, stderr, + }), + ).toBeRejected(); + + expect(captureChangesInReleaseBranchSpy).not.toHaveBeenCalled(); + }); + }); + + it('removes the release spec file at the end', async () => { + await withSandbox(async (sandbox) => { + const { project, today, stdout, stderr, releaseSpecificationPath } = + await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: false, + isEditorAvailable: true, + errorUponEditingReleaseSpec: new Error('oops'), }); - expect( - generateReleaseSpecificationTemplateForMonorepoSpy, - ).not.toHaveBeenCalled(); - expect( - waitForUserToEditReleaseSpecificationSpy, - ).not.toHaveBeenCalled(); - expect(updatePackageSpy).toHaveBeenNthCalledWith(1, { + await expect( + followMonorepoWorkflow({ project, - packageReleasePlan: { - package: project.rootPackage, - newVersion: '2022.6.12', - shouldUpdateChangelog: false, - }, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: true, + today, + stdout, stderr, + }), + ).toBeRejected(); + + expect(await fileExists(releaseSpecificationPath)).toBe(false); + }); + }); + + it('throws the error', async () => { + await withSandbox(async (sandbox) => { + const { project, today, stdout, stderr } = + await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: false, + isEditorAvailable: true, + errorUponEditingReleaseSpec: new Error('oops'), }); - expect(updatePackageSpy).toHaveBeenNthCalledWith(2, { + + await expect( + followMonorepoWorkflow({ project, - packageReleasePlan: { - package: project.workspacePackages.a, - newVersion: '2.0.0', - shouldUpdateChangelog: true, - }, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: true, + today, + stdout, stderr, + }), + ).rejects.toThrow('oops'); + }); + }); + }); + + describe('when firstRemovingExistingReleaseSpecification is true, the release spec file does not already exist, and an editor is not available', () => { + it('does not try to execute the edited release spec', async () => { + await withSandbox(async (sandbox) => { + const { project, today, stdout, stderr, executeReleasePlanSpy } = + await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: false, + isEditorAvailable: false, }); + + await followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: true, + today, + stdout, + stderr, + }); + + expect(executeReleasePlanSpy).not.toHaveBeenCalled(); + }); + }); + + it('does not try to create a new branch', async () => { + await withSandbox(async (sandbox) => { + const { + project, + today, + stdout, + stderr, + captureChangesInReleaseBranchSpy, + } = await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: false, + isEditorAvailable: false, + }); + + await followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: true, + today, + stdout, + stderr, + }); + + expect(captureChangesInReleaseBranchSpy).not.toHaveBeenCalled(); + }); + }); + + it('prints a message', async () => { + await withSandbox(async (sandbox) => { + const { project, today, stdout, stderr } = + await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: false, + isEditorAvailable: false, + }); + + await followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: true, + today, + stdout, + stderr, + }); + + expect(stdout.data()[0]).toMatch( + /^A template has been generated that specifies this release/u, + ); + }); + }); + + it('does not remove the release spec file', async () => { + await withSandbox(async (sandbox) => { + const { project, today, stdout, stderr, releaseSpecificationPath } = + await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: false, + isEditorAvailable: false, + errorUponEditingReleaseSpec: null, + }); + + await followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: true, + today, + stdout, + stderr, + }); + + expect(await fileExists(releaseSpecificationPath)).toBe(true); + }); + }); + }); + + describe('when firstRemovingExistingReleaseSpecification is true, the release spec file already exists, an editor is available, and editing will succeed', () => { + it('executes the edited release spec', async () => { + await withSandbox(async (sandbox) => { + const { + project, + today, + stdout, + stderr, + executeReleasePlanSpy, + releasePlan, + } = await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: true, + isEditorAvailable: true, + errorUponEditingReleaseSpec: null, + }); + + await followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: true, + today, + stdout, + stderr, + }); + + expect(executeReleasePlanSpy).toHaveBeenCalledWith( + project, + releasePlan, + stderr, + ); + }); + }); + + it('creates a new branch named after the generated release version', async () => { + await withSandbox(async (sandbox) => { + const { + project, + today, + stdout, + stderr, + captureChangesInReleaseBranchSpy, + projectDirectoryPath, + releaseName, + } = await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: true, + isEditorAvailable: true, + errorUponEditingReleaseSpec: null, + }); + + await followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: true, + today, + stdout, + stderr, }); + + expect(captureChangesInReleaseBranchSpy).toHaveBeenCalledWith( + projectDirectoryPath, + releaseName, + ); }); + }); - it('creates a new branch named after the generated release version', async () => { - await withSandbox(async (sandbox) => { - const project = buildMockMonorepoProject({ - rootPackage: buildMockPackage('root', '2022.1.1', { - validatedManifest: { - private: true, - workspaces: ['packages/*'], - }, - }), - workspacePackages: { - a: buildMockPackage('a', '1.0.0', { - validatedManifest: { - private: false, - }, - }), - }, + it('removes the release spec file at the end', async () => { + await withSandbox(async (sandbox) => { + const { project, today, stdout, stderr, releaseSpecificationPath } = + await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: true, + isEditorAvailable: true, + errorUponEditingReleaseSpec: null, }); - const stdout = fs.createWriteStream('/dev/null'); - const stderr = fs.createWriteStream('/dev/null'); - const { captureChangesInReleaseBranchSpy } = mockDependencies({ - determineEditor: { - path: '/some/editor', - args: [], - }, - getEnvironmentVariables: { - TODAY: '2022-06-12', - }, - validateReleaseSpecification: { - packages: { - a: releaseSpecificationModule.IncrementableVersionParts.major, - }, - }, + + await followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: true, + today, + stdout, + stderr, + }); + + expect(await fileExists(releaseSpecificationPath)).toBe(false); + }); + }); + }); + + describe('when firstRemovingExistingReleaseSpecification is true, the release spec file already exists, an editor is available, and editing will not succeed', () => { + it('does not try to execute the edited release spec', async () => { + await withSandbox(async (sandbox) => { + const { project, today, stdout, stderr, executeReleasePlanSpy } = + await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: true, + isEditorAvailable: true, + errorUponEditingReleaseSpec: new Error('oops'), }); - const releaseSpecPath = path.join( - sandbox.directoryPath, - 'RELEASE_SPEC', - ); - await fs.promises.writeFile(releaseSpecPath, 'release spec'); - await followMonorepoWorkflow({ + await expect( + followMonorepoWorkflow({ project, tempDirectoryPath: sandbox.directoryPath, - firstRemovingExistingReleaseSpecification: false, + firstRemovingExistingReleaseSpecification: true, + today, stdout, stderr, + }), + ).toBeRejected(); + + expect(executeReleasePlanSpy).not.toHaveBeenCalled(); + }); + }); + + it('does not try to create a new branch', async () => { + await withSandbox(async (sandbox) => { + const { + project, + today, + stdout, + stderr, + captureChangesInReleaseBranchSpy, + } = await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: true, + isEditorAvailable: true, + errorUponEditingReleaseSpec: new Error('oops'), + }); + + await expect( + followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: true, + today, + stdout, + stderr, + }), + ).toBeRejected(); + + expect(captureChangesInReleaseBranchSpy).not.toHaveBeenCalled(); + }); + }); + + it('removes the release spec file at the end', async () => { + await withSandbox(async (sandbox) => { + const { project, today, stdout, stderr, releaseSpecificationPath } = + await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: true, + isEditorAvailable: true, + errorUponEditingReleaseSpec: new Error('oops'), }); - expect(captureChangesInReleaseBranchSpy).toHaveBeenCalledWith( + await expect( + followMonorepoWorkflow({ project, - { - releaseName: '2022-06-12', - packages: [ - { - package: project.rootPackage, - newVersion: '2022.6.12', - shouldUpdateChangelog: false, - }, - { - package: project.workspacePackages.a, - newVersion: '2.0.0', - shouldUpdateChangelog: true, - }, - ], - }, - ); - }); + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: true, + today, + stdout, + stderr, + }), + ).toBeRejected(); + + expect(await fileExists(releaseSpecificationPath)).toBe(false); }); + }); - it('removes the release spec file at the end', async () => { - await withSandbox(async (sandbox) => { - const project = buildMockMonorepoProject(); - const stdout = fs.createWriteStream('/dev/null'); - const stderr = fs.createWriteStream('/dev/null'); - mockDependencies({ - determineEditor: { - path: '/some/editor', - args: [], - }, + it('throws the error', async () => { + await withSandbox(async (sandbox) => { + const { project, today, stdout, stderr } = + await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: true, + isEditorAvailable: true, + errorUponEditingReleaseSpec: new Error('oops'), }); - const releaseSpecPath = path.join( - sandbox.directoryPath, - 'RELEASE_SPEC', - ); - await fs.promises.writeFile(releaseSpecPath, 'release spec'); - await followMonorepoWorkflow({ + await expect( + followMonorepoWorkflow({ project, tempDirectoryPath: sandbox.directoryPath, - firstRemovingExistingReleaseSpecification: false, + firstRemovingExistingReleaseSpecification: true, + today, stdout, stderr, + }), + ).rejects.toThrow('oops'); + }); + }); + }); + + describe('when firstRemovingExistingReleaseSpecification is true, the release spec file already exists, and an editor is not available', () => { + it('does not try to execute the edited release spec', async () => { + await withSandbox(async (sandbox) => { + const { project, today, stdout, stderr, executeReleasePlanSpy } = + await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: true, + isEditorAvailable: false, }); - await expect( - fs.promises.readFile(releaseSpecPath, 'utf8'), - ).rejects.toThrow(/^ENOENT: no such file or directory/u); + await followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: true, + today, + stdout, + stderr, }); + + expect(executeReleasePlanSpy).not.toHaveBeenCalled(); }); + }); - it("throws if a version specifier for a package within the edited release spec, when applied, would result in no change to the package's version", async () => { - await withSandbox(async (sandbox) => { - const project = buildMockMonorepoProject({ - rootPackage: buildMockPackage('root', '2022.1.1', { - validatedManifest: { - private: true, - workspaces: ['packages/*'], - }, - }), - workspacePackages: { - a: buildMockPackage('a', '1.0.0', { - validatedManifest: { - private: false, - }, - }), - }, - }); - const stdout = fs.createWriteStream('/dev/null'); - const stderr = fs.createWriteStream('/dev/null'); - mockDependencies({ - determineEditor: { - path: '/some/editor', - args: [], - }, - getEnvironmentVariables: { - TODAY: '2022-06-12', - }, - validateReleaseSpecification: { - packages: { - a: new SemVer('1.0.0'), - }, - }, - }); - const releaseSpecPath = path.join( - sandbox.directoryPath, - 'RELEASE_SPEC', - ); - await fs.promises.writeFile(releaseSpecPath, 'release spec'); - - await expect( - followMonorepoWorkflow({ - project, - tempDirectoryPath: sandbox.directoryPath, - firstRemovingExistingReleaseSpecification: false, - stdout, - stderr, - }), - ).rejects.toThrow( - /^Could not update package "a" to "1.0.0" as that is already the current version./u, - ); + it('does not try to create a new branch', async () => { + await withSandbox(async (sandbox) => { + const { + project, + today, + stdout, + stderr, + captureChangesInReleaseBranchSpy, + } = await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: true, + isEditorAvailable: false, }); + + await followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: true, + today, + stdout, + stderr, + }); + + expect(captureChangesInReleaseBranchSpy).not.toHaveBeenCalled(); }); + }); - it("throws if a version specifier for a package within the edited release spec, when applied, would result in a backward change to the package's version", async () => { - await withSandbox(async (sandbox) => { - const project = buildMockMonorepoProject({ - rootPackage: buildMockPackage('root', '2022.1.1', { - validatedManifest: { - private: true, - workspaces: ['packages/*'], - }, - }), - workspacePackages: { - a: buildMockPackage('a', '1.0.3', { - validatedManifest: { - private: false, - }, - }), - }, - }); - const stdout = fs.createWriteStream('/dev/null'); - const stderr = fs.createWriteStream('/dev/null'); - mockDependencies({ - determineEditor: { - path: '/some/editor', - args: [], - }, - getEnvironmentVariables: { - TODAY: '2022-06-12', - }, - validateReleaseSpecification: { - packages: { - a: new SemVer('1.0.2'), - }, - }, + it('prints a message', async () => { + await withSandbox(async (sandbox) => { + const { project, today, stdout, stderr } = + await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: true, + isEditorAvailable: false, }); - const releaseSpecPath = path.join( - sandbox.directoryPath, - 'RELEASE_SPEC', - ); - await fs.promises.writeFile(releaseSpecPath, 'release spec'); - - await expect( - followMonorepoWorkflow({ - project, - tempDirectoryPath: sandbox.directoryPath, - firstRemovingExistingReleaseSpecification: false, - stdout, - stderr, - }), - ).rejects.toThrow( - /^Could not update package "a" to "1.0.2" as it is less than the current version "1.0.3"./u, - ); + + await followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: true, + today, + stdout, + stderr, }); + + expect(stdout.data()).toMatchObject([ + /^A template has been generated that specifies this release/u, + ]); }); + }); - it("does not remove the release spec file if a version specifier for a package within the edited release spec, when applied, would result in no change to the package's version", async () => { - await withSandbox(async (sandbox) => { - const project = buildMockMonorepoProject({ - rootPackage: buildMockPackage('root', '2022.1.1', { - validatedManifest: { - private: true, - workspaces: ['packages/*'], - }, - }), - workspacePackages: { - a: buildMockPackage('a', '1.0.0', { - validatedManifest: { - private: false, - }, - }), - }, - }); - const stdout = fs.createWriteStream('/dev/null'); - const stderr = fs.createWriteStream('/dev/null'); - mockDependencies({ - determineEditor: { - path: '/some/editor', - args: [], - }, - getEnvironmentVariables: { - TODAY: '2022-06-12', - }, - validateReleaseSpecification: { - packages: { - a: new SemVer('1.0.0'), - }, - }, + it('does not remove the release spec file', async () => { + await withSandbox(async (sandbox) => { + const { project, today, stdout, stderr, releaseSpecificationPath } = + await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: true, + isEditorAvailable: false, + errorUponEditingReleaseSpec: null, }); - const releaseSpecPath = path.join( - sandbox.directoryPath, - 'RELEASE_SPEC', - ); - await fs.promises.writeFile(releaseSpecPath, 'release spec'); - - await expect( - followMonorepoWorkflow({ - project, - tempDirectoryPath: sandbox.directoryPath, - firstRemovingExistingReleaseSpecification: false, - stdout, - stderr, - }), - ).rejects.toThrow(expect.anything()); - - expect(await fs.promises.stat(releaseSpecPath)).toStrictEqual( - expect.anything(), - ); + + await followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: true, + today, + stdout, + stderr, }); + + expect(await fileExists(releaseSpecificationPath)).toBe(true); }); }); }); }); }); - -/** - * Builds a project for use in tests which represents a monorepo. - * - * @param overrides - The properties that will go into the object. - * @returns The mock Project object. - */ -function buildMockMonorepoProject(overrides: Partial = {}) { - return buildMockProject({ - rootPackage: buildMockMonorepoRootPackage(), - workspacePackages: {}, - ...overrides, - }); -} - -/** - * Mocks dependencies that `followMonorepoWorkflow` uses internally. - * - * @param args - The arguments to this function. - * @param args.determineEditor - The return value for `determineEditor`. - * @param args.getEnvironmentVariables - The return value for - * `getEnvironmentVariables`. - * @param args.generateReleaseSpecificationTemplateForMonorepo - The return - * value for `generateReleaseSpecificationTemplateForMonorepo`. - * @param args.waitForUserToEditReleaseSpecification - The return value for - * `waitForUserToEditReleaseSpecification`. - * @param args.validateReleaseSpecification - The return value for - * `validateReleaseSpecification`. - * @param args.updatePackage - The return value for `updatePackage`. - * @param args.captureChangesInReleaseBranch - The return value for - * `captureChangesInReleaseBranch`. - * @returns Jest spy objects for the aforementioned dependencies. - */ -function mockDependencies({ - determineEditor: determineEditorValue = null, - getEnvironmentVariables: getEnvironmentVariablesValue = {}, - generateReleaseSpecificationTemplateForMonorepo: - generateReleaseSpecificationTemplateForMonorepoValue = '{}', - waitForUserToEditReleaseSpecification: - waitForUserToEditReleaseSpecificationValue = undefined, - validateReleaseSpecification: validateReleaseSpecificationValue = { - packages: {}, - }, - updatePackage: updatePackageValue = undefined, - captureChangesInReleaseBranch: captureChangesInReleaseBranchValue = undefined, -}: { - determineEditor?: UnwrapPromise< - ReturnType - >; - getEnvironmentVariables?: Partial< - ReturnType - >; - generateReleaseSpecificationTemplateForMonorepo?: UnwrapPromise< - ReturnType< - typeof releaseSpecificationModule.generateReleaseSpecificationTemplateForMonorepo - > - >; - waitForUserToEditReleaseSpecification?: UnwrapPromise< - ReturnType< - typeof releaseSpecificationModule.waitForUserToEditReleaseSpecification - > - >; - validateReleaseSpecification?: UnwrapPromise< - ReturnType - >; - updatePackage?: UnwrapPromise>; - captureChangesInReleaseBranch?: UnwrapPromise< - ReturnType - >; -}) { - jest - .spyOn(editorModule, 'determineEditor') - .mockResolvedValue(determineEditorValue); - jest.spyOn(envModule, 'getEnvironmentVariables').mockReturnValue({ - EDITOR: undefined, - TODAY: undefined, - ...getEnvironmentVariablesValue, - }); - const generateReleaseSpecificationTemplateForMonorepoSpy = jest - .spyOn( - releaseSpecificationModule, - 'generateReleaseSpecificationTemplateForMonorepo', - ) - .mockResolvedValue(generateReleaseSpecificationTemplateForMonorepoValue); - const waitForUserToEditReleaseSpecificationSpy = jest - .spyOn(releaseSpecificationModule, 'waitForUserToEditReleaseSpecification') - .mockResolvedValue(waitForUserToEditReleaseSpecificationValue); - const validateReleaseSpecificationSpy = jest - .spyOn(releaseSpecificationModule, 'validateReleaseSpecification') - .mockResolvedValue(validateReleaseSpecificationValue); - const updatePackageSpy = jest - .spyOn(packageModule, 'updatePackage') - .mockResolvedValue(updatePackageValue); - const captureChangesInReleaseBranchSpy = jest - .spyOn(workflowOperations, 'captureChangesInReleaseBranch') - .mockResolvedValue(captureChangesInReleaseBranchValue); - - return { - generateReleaseSpecificationTemplateForMonorepoSpy, - waitForUserToEditReleaseSpecificationSpy, - validateReleaseSpecificationSpy, - updatePackageSpy, - captureChangesInReleaseBranchSpy, - }; -} diff --git a/src/monorepo-workflow-operations.ts b/src/monorepo-workflow-operations.ts index 776b4c8..bbdf43d 100644 --- a/src/monorepo-workflow-operations.ts +++ b/src/monorepo-workflow-operations.ts @@ -1,8 +1,5 @@ import type { WriteStream } from 'fs'; import path from 'path'; -import util from 'util'; -import rimraf from 'rimraf'; -import { debug } from './misc-utils'; import { ensureDirectoryPathExists, fileExists, @@ -10,43 +7,14 @@ import { writeFile, } from './fs'; import { determineEditor } from './editor'; -import { getEnvironmentVariables } from './env'; -import { updatePackage } from './package'; import { Project } from './project'; +import { planRelease, executeReleasePlan } from './release-plan'; +import { captureChangesInReleaseBranch } from './repo'; import { generateReleaseSpecificationTemplateForMonorepo, waitForUserToEditReleaseSpecification, validateReleaseSpecification, - ReleaseSpecification, } from './release-specification'; -import { SemVer } from './semver'; -import { - captureChangesInReleaseBranch, - PackageReleasePlan, - ReleasePlan, -} from './workflow-operations'; - -/** - * A promisified version of `rimraf`. - */ -const promisifiedRimraf = util.promisify(rimraf); - -/** - * Creates a date from the value of the `TODAY` environment variable, falling - * back to the current date if it is invalid or was not provided. This will be - * used to assign a name to the new release in the case of a monorepo with - * independent versions. - * - * @returns A date that represents "today". - */ -function getToday() { - const { TODAY } = getEnvironmentVariables(); - const parsedTodayTimestamp = - TODAY === undefined ? NaN : new Date(TODAY).getTime(); - return isNaN(parsedTodayTimestamp) - ? new Date() - : new Date(parsedTodayTimestamp); -} /** * For a monorepo, the process works like this: @@ -75,6 +43,7 @@ function getToday() { * possible for a release specification that was created in a previous run to * stick around (due to an error). This will ensure that the file is removed * first. + * @param options.today - The current date. * @param options.stdout - A stream that can be used to write to standard out. * @param options.stderr - A stream that can be used to write to standard error. */ @@ -82,22 +51,23 @@ export async function followMonorepoWorkflow({ project, tempDirectoryPath, firstRemovingExistingReleaseSpecification, + today, stdout, stderr, }: { project: Project; tempDirectoryPath: string; firstRemovingExistingReleaseSpecification: boolean; + today: Date; stdout: Pick; stderr: Pick; }) { const releaseSpecificationPath = path.join(tempDirectoryPath, 'RELEASE_SPEC'); - if (firstRemovingExistingReleaseSpecification) { - await promisifiedRimraf(releaseSpecificationPath); - } - - if (await fileExists(releaseSpecificationPath)) { + if ( + !firstRemovingExistingReleaseSpecification && + (await fileExists(releaseSpecificationPath)) + ) { stdout.write( 'Release spec already exists. Picking back up from previous run.\n', ); @@ -137,118 +107,15 @@ export async function followMonorepoWorkflow({ project, releaseSpecificationPath, ); - const releasePlan = await planRelease( + const releasePlan = await planRelease({ project, releaseSpecification, - releaseSpecificationPath, - ); - await applyUpdatesToMonorepo(project, releasePlan, stderr); - await removeFile(releaseSpecificationPath); - await captureChangesInReleaseBranch(project, releasePlan); -} - -/** - * Uses the release specification to calculate the final versions of all of the - * packages that we want to update, as well as a new release name. - * - * @param project - Information about the whole project (e.g., names of packages - * and where they can found). - * @param releaseSpecification - A parsed version of the release spec entered by - * the user. - * @param releaseSpecificationPath - The path to the release specification file. - * @returns A promise for information about the new release. - */ -async function planRelease( - project: Project, - releaseSpecification: ReleaseSpecification, - releaseSpecificationPath: string, -): Promise { - const today = getToday(); - const newReleaseName = today.toISOString().replace(/T.+$/u, ''); - const newRootVersion = [ - today.getUTCFullYear(), - today.getUTCMonth() + 1, - today.getUTCDate(), - ].join('.'); - - const rootReleasePlan: PackageReleasePlan = { - package: project.rootPackage, - newVersion: newRootVersion, - shouldUpdateChangelog: false, - }; - - const workspaceReleasePlans: PackageReleasePlan[] = Object.keys( - releaseSpecification.packages, - ).map((packageName) => { - const pkg = project.workspacePackages[packageName]; - const versionSpecifier = releaseSpecification.packages[packageName]; - const currentVersion = pkg.validatedManifest.version; - let newVersion: SemVer; - - if (versionSpecifier instanceof SemVer) { - const comparison = versionSpecifier.compare(currentVersion); - - if (comparison === 0) { - throw new Error( - [ - `Could not update package "${packageName}" to "${versionSpecifier}" as that is already the current version.`, - `The release spec file has been retained for you to make the necessary fixes. Once you've done this, re-run this tool.`, - releaseSpecificationPath, - ].join('\n\n'), - ); - } else if (comparison < 0) { - throw new Error( - [ - `Could not update package "${packageName}" to "${versionSpecifier}" as it is less than the current version "${currentVersion}".`, - `The release spec file has been retained for you to make the necessary fixes. Once you've done this, re-run this tool.`, - releaseSpecificationPath, - ].join('\n\n'), - ); - } - - newVersion = versionSpecifier; - } else { - newVersion = new SemVer(currentVersion.toString()).inc(versionSpecifier); - } - - return { - package: pkg, - newVersion: newVersion.toString(), - shouldUpdateChangelog: true, - }; + today, }); - - return { - releaseName: newReleaseName, - packages: [rootReleasePlan, ...workspaceReleasePlans], - }; -} - -/** - * Bumps versions and updates changelogs of packages within the monorepo - * according to the release plan. - * - * @param project - Information about the whole project (e.g., names of packages - * and where they can found). - * @param releasePlan - Compiled instructions on how exactly to update the - * project in order to prepare a new release. - * @param stderr - A stream that can be used to write to standard error. - */ -async function applyUpdatesToMonorepo( - project: Project, - releasePlan: ReleasePlan, - stderr: Pick, -) { - await Promise.all( - releasePlan.packages.map(async (workspaceReleasePlan) => { - debug( - `Updating package ${workspaceReleasePlan.package.validatedManifest.name}...`, - ); - await updatePackage({ - project, - packageReleasePlan: workspaceReleasePlan, - stderr, - }); - }), + await executeReleasePlan(project, releasePlan, stderr); + await removeFile(releaseSpecificationPath); + await captureChangesInReleaseBranch( + project.directoryPath, + releasePlan.releaseName, ); } diff --git a/src/package.ts b/src/package.ts index 5eb9513..0b5a201 100644 --- a/src/package.ts +++ b/src/package.ts @@ -9,7 +9,7 @@ import { ValidatedPackageManifest, } from './package-manifest'; import { Project } from './project'; -import { PackageReleasePlan } from './workflow-operations'; +import { PackageReleasePlan } from './release-plan'; const MANIFEST_FILE_NAME = 'package.json'; const CHANGELOG_FILE_NAME = 'CHANGELOG.md'; diff --git a/src/release-plan.test.ts b/src/release-plan.test.ts new file mode 100644 index 0000000..0fcda29 --- /dev/null +++ b/src/release-plan.test.ts @@ -0,0 +1,156 @@ +import fs from 'fs'; +import { SemVer } from 'semver'; +import { buildMockProject, buildMockPackage } from '../tests/unit/helpers'; +import { planRelease, executeReleasePlan } from './release-plan'; +import { IncrementableVersionParts } from './release-specification'; +import * as packageUtils from './package'; + +jest.mock('./package'); + +describe('release-plan-utils', () => { + describe('planRelease', () => { + it('calculates final versions for all packages in the release spec', async () => { + const project = buildMockProject({ + rootPackage: buildMockPackage('root', '2022.1.1'), + workspacePackages: { + a: buildMockPackage('a', '1.0.0'), + b: buildMockPackage('b', '1.0.0'), + c: buildMockPackage('c', '1.0.0'), + d: buildMockPackage('d', '1.0.0'), + }, + }); + const releaseSpecification = { + packages: { + a: IncrementableVersionParts.major, + b: IncrementableVersionParts.minor, + c: IncrementableVersionParts.patch, + d: new SemVer('1.2.3'), + }, + path: '/path/to/release/spec', + }; + const today = new Date('2022-07-21'); + + const releasePlan = await planRelease({ + project, + releaseSpecification, + today, + }); + + expect(releasePlan).toMatchObject({ + releaseName: '2022-07-21', + packages: [ + { + package: project.rootPackage, + newVersion: '2022.7.21', + }, + { + package: project.workspacePackages.a, + newVersion: '2.0.0', + }, + { + package: project.workspacePackages.b, + newVersion: '1.1.0', + }, + { + package: project.workspacePackages.c, + newVersion: '1.0.1', + }, + { + package: project.workspacePackages.d, + newVersion: '1.2.3', + }, + ], + }); + }); + + it('records that the changelog for the root package does not need to be updated, while those for the workspace packages do', async () => { + const project = buildMockProject({ + rootPackage: buildMockPackage('root'), + workspacePackages: { + a: buildMockPackage('a', '1.0.0'), + b: buildMockPackage('b', '1.0.0'), + c: buildMockPackage('c', '1.0.0'), + d: buildMockPackage('d', '1.0.0'), + }, + }); + const releaseSpecification = { + packages: { + a: IncrementableVersionParts.major, + b: IncrementableVersionParts.major, + c: IncrementableVersionParts.patch, + d: new SemVer('1.2.3'), + }, + path: '/path/to/release/spec', + }; + const today = new Date('2022-07-21'); + + const releasePlan = await planRelease({ + project, + releaseSpecification, + today, + }); + + expect(releasePlan).toMatchObject({ + releaseName: '2022-07-21', + packages: [ + { + package: project.rootPackage, + shouldUpdateChangelog: false, + }, + { + package: project.workspacePackages.a, + shouldUpdateChangelog: true, + }, + { + package: project.workspacePackages.b, + shouldUpdateChangelog: true, + }, + { + package: project.workspacePackages.c, + shouldUpdateChangelog: true, + }, + { + package: project.workspacePackages.d, + shouldUpdateChangelog: true, + }, + ], + }); + }); + }); + + describe('executeReleasePlan', () => { + it('runs updatePackage for each package in the release plan', async () => { + const project = buildMockProject(); + const releasePlan = { + releaseName: 'some-release-name', + packages: [ + { + package: buildMockPackage(), + newVersion: '1.2.3', + shouldUpdateChangelog: true, + }, + { + package: buildMockPackage(), + newVersion: '1.2.3', + shouldUpdateChangelog: true, + }, + ], + }; + const stderr = fs.createWriteStream('/dev/null'); + const updatePackageSpy = jest.spyOn(packageUtils, 'updatePackage'); + + await executeReleasePlan(project, releasePlan, stderr); + + expect(updatePackageSpy).toHaveBeenNthCalledWith(1, { + project, + packageReleasePlan: releasePlan.packages[0], + stderr, + }); + expect(updatePackageSpy).toHaveBeenNthCalledWith(2, { + project, + packageReleasePlan: releasePlan.packages[1], + stderr, + }); + }); + }); +}); diff --git a/src/release-plan.ts b/src/release-plan.ts new file mode 100644 index 0000000..bcac7bd --- /dev/null +++ b/src/release-plan.ts @@ -0,0 +1,129 @@ +import type { WriteStream } from 'fs'; +import { SemVer } from 'semver'; +import { debug } from './misc-utils'; +import { Package, updatePackage } from './package'; +import { Project } from './project'; +import { ReleaseSpecification } from './release-specification'; + +/** + * Instructions for how to update the project in order to prepare it for a new + * release. + * + * @property releaseName - The name of the new release. For a polyrepo or a + * monorepo with fixed versions, this will be a version string with the shape + * `..`; for a monorepo with independent versions, this + * will be a version string with the shape `..-`. + * @property packages - Information about all of the packages in the project. + * For a polyrepo, this consists of the self-same package; for a monorepo it + * consists of the root package and any workspace packages. + */ +export interface ReleasePlan { + releaseName: string; + packages: PackageReleasePlan[]; +} + +/** + * Instructions for how to update a package within a project in order to prepare + * it for a new release. + * + * @property package - Information about the package. + * @property newVersion - The new version to which the package should be + * updated. + * @property shouldUpdateChangelog - Whether or not the changelog for the + * package should get updated. For a polyrepo, this will always be true; for a + * monorepo, this will be true only for workspace packages (the root package + * doesn't have a changelog, since it is a virtual package). + */ +export interface PackageReleasePlan { + package: Package; + newVersion: string; + shouldUpdateChangelog: boolean; +} + +/** + * Uses the release specification to calculate the final versions of all of the + * packages that we want to update, as well as a new release name. + * + * @param args - The arguments. + * @param args.project - Information about the whole project (e.g., names of + * packages and where they can found). + * @param args.releaseSpecification - A parsed version of the release spec + * entered by the user. + * @param args.today - The current date. + * @returns A promise for information about the new release. + */ +export async function planRelease({ + project, + releaseSpecification, + today, +}: { + project: Project; + releaseSpecification: ReleaseSpecification; + today: Date; +}): Promise { + const newReleaseName = today.toISOString().replace(/T.+$/u, ''); + const newRootVersion = [ + today.getUTCFullYear(), + today.getUTCMonth() + 1, + today.getUTCDate(), + ].join('.'); + + const rootReleasePlan: PackageReleasePlan = { + package: project.rootPackage, + newVersion: newRootVersion, + shouldUpdateChangelog: false, + }; + + const workspaceReleasePlans: PackageReleasePlan[] = Object.keys( + releaseSpecification.packages, + ).map((packageName) => { + const pkg = project.workspacePackages[packageName]; + const versionSpecifier = releaseSpecification.packages[packageName]; + const currentVersion = pkg.validatedManifest.version; + const newVersion = + versionSpecifier instanceof SemVer + ? versionSpecifier + : new SemVer(currentVersion.toString()).inc(versionSpecifier); + + return { + package: pkg, + newVersion: newVersion.toString(), + shouldUpdateChangelog: true, + }; + }); + + return { + releaseName: newReleaseName, + packages: [rootReleasePlan, ...workspaceReleasePlans], + }; +} + +/** + * Bumps versions and updates changelogs of packages within the monorepo + * according to the release plan. + * + * @param project - Information about the whole project (e.g., names of packages + * and where they can found). + * @param releasePlan - Compiled instructions on how exactly to update the + * project in order to prepare a new release. + * @param stderr - A stream that can be used to write to standard error. + */ +export async function executeReleasePlan( + project: Project, + releasePlan: ReleasePlan, + stderr: Pick, +) { + await Promise.all( + releasePlan.packages.map(async (workspaceReleasePlan) => { + debug( + `Updating package ${workspaceReleasePlan.package.validatedManifest.name}...`, + ); + await updatePackage({ + project, + packageReleasePlan: workspaceReleasePlan, + stderr, + }); + }), + ); +} diff --git a/src/release-specification.test.ts b/src/release-specification.test.ts index 6cc28c6..113ce58 100644 --- a/src/release-specification.test.ts +++ b/src/release-specification.test.ts @@ -242,6 +242,7 @@ packages: c: 'patch', d: new SemVer('1.2.3'), }, + path: releaseSpecificationPath, }); }); }); @@ -280,6 +281,7 @@ packages: a: 'major', c: 'patch', }, + path: releaseSpecificationPath, }); }); }); @@ -348,7 +350,7 @@ packages: }); }); - it('throws if any of the keys in the "packages" property do not match the names of any workspace packages', async () => { + it('throws if any of the keys in the "packages" object do not match the names of any workspace packages', async () => { await withSandbox(async (sandbox) => { const project = buildMockProject({ workspacePackages: { @@ -384,7 +386,7 @@ packages: }); }); - it('throws if any of the values in the "packages" property are not valid version specifiers', async () => { + it('throws if any one of the values in the "packages" object is an invalid version specifier', async () => { await withSandbox(async (sandbox) => { const project = buildMockProject({ workspacePackages: { @@ -422,5 +424,79 @@ packages: ); }); }); + + it('throws if any one of the values in the "packages" object is a version string that matches the current version of the package', async () => { + await withSandbox(async (sandbox) => { + const project = buildMockProject({ + workspacePackages: { + a: buildMockPackage('a', '1.2.3'), + b: buildMockPackage('b', '4.5.6'), + }, + }); + const releaseSpecificationPath = path.join( + sandbox.directoryPath, + 'release-spec', + ); + await fs.promises.writeFile( + releaseSpecificationPath, + YAML.stringify({ + packages: { + a: '1.2.3', + b: '4.5.6', + }, + }), + ); + + await expect( + validateReleaseSpecification(project, releaseSpecificationPath), + ).rejects.toThrow( + ` +Your release spec could not be processed due to the following issues: + +- Line 2: "1.2.3" is not a valid version specifier for package "a" + ("a" is already at version "1.2.3") +- Line 3: "4.5.6" is not a valid version specifier for package "b" + ("b" is already at version "4.5.6") +`.trim(), + ); + }); + }); + + it('throws if any one of the values in the "packages" object is a version string that is less than the current version of the package', async () => { + await withSandbox(async (sandbox) => { + const project = buildMockProject({ + workspacePackages: { + a: buildMockPackage('a', '1.2.3'), + b: buildMockPackage('b', '4.5.6'), + }, + }); + const releaseSpecificationPath = path.join( + sandbox.directoryPath, + 'release-spec', + ); + await fs.promises.writeFile( + releaseSpecificationPath, + YAML.stringify({ + packages: { + a: '1.2.2', + b: '4.5.5', + }, + }), + ); + + await expect( + validateReleaseSpecification(project, releaseSpecificationPath), + ).rejects.toThrow( + ` +Your release spec could not be processed due to the following issues: + +- Line 2: "1.2.2" is not a valid version specifier for package "a" + ("a" is at a greater version "1.2.3") +- Line 3: "4.5.5" is not a valid version specifier for package "b" + ("b" is at a greater version "4.5.6") +`.trim(), + ); + }); + }); }); }); diff --git a/src/release-specification.ts b/src/release-specification.ts index d85295e..1be5469 100644 --- a/src/release-specification.ts +++ b/src/release-specification.ts @@ -33,9 +33,11 @@ type VersionSpecifier = IncrementableVersionParts | SemVer; * it for a new release. * * @property packages - A mapping of package names to version specifiers. + * @property path - The path to the original release specification file. */ export interface ReleaseSpecification { packages: Record; + path: string; } /** @@ -223,6 +225,34 @@ export async function validateReleaseSpecification( lineNumber, }); } + + if (isValidSemver(versionSpecifier)) { + const comparison = new SemVer(versionSpecifier).compare( + project.workspacePackages[packageName].validatedManifest.version, + ); + + if (comparison === 0) { + errors.push({ + message: [ + `${JSON.stringify( + versionSpecifier, + )} is not a valid version specifier for package "${packageName}"`, + `("${packageName}" is already at version "${versionSpecifier}")`, + ], + lineNumber, + }); + } else if (comparison < 0) { + errors.push({ + message: [ + `${JSON.stringify( + versionSpecifier, + )} is not a valid version specifier for package "${packageName}"`, + `("${packageName}" is at a greater version "${project.workspacePackages[packageName].validatedManifest.version}")`, + ], + lineNumber, + }); + } + } }, ); @@ -283,5 +313,5 @@ export async function validateReleaseSpecification( {} as ReleaseSpecification['packages'], ); - return { packages }; + return { packages, path: releaseSpecificationPath }; } diff --git a/src/repo.test.ts b/src/repo.test.ts index 63b8f12..b406b2c 100644 --- a/src/repo.test.ts +++ b/src/repo.test.ts @@ -1,5 +1,9 @@ import { when } from 'jest-when'; -import { getStdoutFromGitCommandWithin, getRepositoryHttpsUrl } from './repo'; +import { + getStdoutFromGitCommandWithin, + getRepositoryHttpsUrl, + captureChangesInReleaseBranch, +} from './repo'; import * as miscUtils from './misc-utils'; jest.mock('./misc-utils'); @@ -84,4 +88,34 @@ describe('git-utils', () => { ); }); }); + + describe('captureChangesInReleaseBranch', () => { + it('checks out a new branch, stages all files, and creates a new commit', async () => { + const getStdoutFromCommandSpy = jest.spyOn( + miscUtils, + 'getStdoutFromCommand', + ); + + await captureChangesInReleaseBranch( + '/path/to/project', + 'some-release-name', + ); + + expect(getStdoutFromCommandSpy).toHaveBeenCalledWith( + 'git', + ['checkout', '-b', 'release/some-release-name'], + { cwd: '/path/to/project' }, + ); + expect(getStdoutFromCommandSpy).toHaveBeenCalledWith( + 'git', + ['add', '-A'], + { cwd: '/path/to/project' }, + ); + expect(getStdoutFromCommandSpy).toHaveBeenCalledWith( + 'git', + ['commit', '-m', 'Release some-release-name'], + { cwd: '/path/to/project' }, + ); + }); + }); }); diff --git a/src/repo.ts b/src/repo.ts index 188f6f2..e9eb3c7 100644 --- a/src/repo.ts +++ b/src/repo.ts @@ -21,7 +21,7 @@ async function getStdoutFromCommandWithin( * Runs a Git command within the given repository, obtaining the immediate * output. * - * @param repositoryDirectoryPath - The directory of the repository. + * @param repositoryDirectoryPath - The path to the repository directory. * @param args - The arguments to the command. * @returns The standard output of the command. * @throws An execa error object if the command fails in some way. @@ -44,7 +44,7 @@ export async function getStdoutFromGitCommandWithin( * If the URL of the "origin" remote matches neither pattern, an error is * thrown. * - * @param repositoryDirectoryPath - The path to the project directory. + * @param repositoryDirectoryPath - The path to the repository directory. * @returns The HTTPS URL of the repository, e.g. * `https://github.com/OrganizationName/RepositoryName`. */ @@ -78,3 +78,33 @@ export async function getRepositoryHttpsUrl( throw new Error(`Unrecognized URL for git remote "origin": ${gitConfigUrl}`); } + +/** + * This function does three things: + * + * 1. Stages all of the changes which have been made to the repo thus far and + * creates a new Git commit which carries the name of the new release. + * 2. Creates a new branch pointed to that commit (which also carries the name + * of the new release). + * 3. Switches to that branch. + * + * @param repositoryDirectoryPath - The path to the repository directory. + * @param releaseName - The name of the release, which will be used to name the + * commit and the branch. + */ +export async function captureChangesInReleaseBranch( + repositoryDirectoryPath: string, + releaseName: string, +) { + await getStdoutFromGitCommandWithin(repositoryDirectoryPath, [ + 'checkout', + '-b', + `release/${releaseName}`, + ]); + await getStdoutFromGitCommandWithin(repositoryDirectoryPath, ['add', '-A']); + await getStdoutFromGitCommandWithin(repositoryDirectoryPath, [ + 'commit', + '-m', + `Release ${releaseName}`, + ]); +} diff --git a/src/workflow-operations.test.ts b/src/workflow-operations.test.ts deleted file mode 100644 index 635d807..0000000 --- a/src/workflow-operations.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { buildMockProject } from '../tests/unit/helpers'; -import { captureChangesInReleaseBranch } from './workflow-operations'; -import * as repoModule from './repo'; - -jest.mock('./repo'); - -describe('workflow-operations', () => { - describe('captureChangesInReleaseBranch', () => { - it('checks out a new branch named after the name of the release, stages all changes, then commits them to the branch', async () => { - const project = buildMockProject({ - directoryPath: '/path/to/project', - }); - const releasePlan = { - releaseName: 'release-name', - packages: [], - }; - const getStdoutFromGitCommandWithinSpy = jest - .spyOn(repoModule, 'getStdoutFromGitCommandWithin') - .mockResolvedValue('the output'); - - await captureChangesInReleaseBranch(project, releasePlan); - - expect(getStdoutFromGitCommandWithinSpy).toHaveBeenNthCalledWith( - 1, - '/path/to/project', - ['checkout', '-b', 'release/release-name'], - ); - expect(getStdoutFromGitCommandWithinSpy).toHaveBeenNthCalledWith( - 2, - '/path/to/project', - ['add', '-A'], - ); - expect(getStdoutFromGitCommandWithinSpy).toHaveBeenNthCalledWith( - 3, - '/path/to/project', - ['commit', '-m', 'Release release-name'], - ); - }); - }); -}); diff --git a/src/workflow-operations.ts b/src/workflow-operations.ts deleted file mode 100644 index 0120a2f..0000000 --- a/src/workflow-operations.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { Package } from './package'; -import { Project } from './project'; -import { getStdoutFromGitCommandWithin } from './repo'; - -/** - * Instructions for how to update the project in order to prepare it for a new - * release. - * - * @property releaseName - The name of the new release. For a polyrepo or a - * monorepo with fixed versions, this will be a version string with the shape - * `..`; for a monorepo with independent versions, this - * will be a version string with the shape `..-`. - * @property packages - Information about all of the packages in the project. - * For a polyrepo, this consists of the self-same package; for a monorepo it - * consists of the root package and any workspace packages. - */ -export interface ReleasePlan { - releaseName: string; - packages: PackageReleasePlan[]; -} - -/** - * Instructions for how to update a package within a project in order to prepare - * it for a new release. - * - * @property package - Information about the package. - * @property newVersion - The new version to which the package should be - * updated. - * @property shouldUpdateChangelog - Whether or not the changelog for the - * package should get updated. For a polyrepo, this will always be true; for a - * monorepo, this will be true only for workspace packages (the root package - * doesn't have a changelog, since it is a virtual package). - */ -export interface PackageReleasePlan { - package: Package; - newVersion: string; - shouldUpdateChangelog: boolean; -} - -/** - * This function does three things: - * - * 1. Stages all of the changes which have been made to the repo thus far and - * creates a new Git commit which carries the name of the new release. - * 2. Creates a new branch pointed to that commit (which also carries the name - * of the new release). - * 3. Switches to that branch. - * - * @param project - Information about the whole project (e.g., names of packages - * and where they can found). - * @param releasePlan - Compiled instructions on how exactly to update the - * project in order to prepare a new release. - */ -export async function captureChangesInReleaseBranch( - project: Project, - releasePlan: ReleasePlan, -) { - await getStdoutFromGitCommandWithin(project.directoryPath, [ - 'checkout', - '-b', - `release/${releasePlan.releaseName}`, - ]); - await getStdoutFromGitCommandWithin(project.directoryPath, ['add', '-A']); - await getStdoutFromGitCommandWithin(project.directoryPath, [ - 'commit', - '-m', - `Release ${releasePlan.releaseName}`, - ]); -} diff --git a/tests/functional/helpers/repo.ts b/tests/functional/helpers/repo.ts index 37f5705..dd3bc8a 100644 --- a/tests/functional/helpers/repo.ts +++ b/tests/functional/helpers/repo.ts @@ -2,7 +2,8 @@ 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'; +import { isErrorWithCode } from '../../helpers'; +import { debug, sleepFor } from './utils'; /** * A set of configuration options for a {@link Repo}. diff --git a/tests/helpers.ts b/tests/helpers.ts index edd65c7..db5bf23 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -14,7 +14,7 @@ const promisifiedRimraf = util.promisify(rimraf); * Information about the sandbox provided to tests that need access to the * filesystem. */ -interface Sandbox { +export interface Sandbox { directoryPath: string; } @@ -67,3 +67,15 @@ export async function withSandbox(fn: (sandbox: Sandbox) => any) { await promisifiedRimraf(directoryPath); } } + +/** + * 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; +} diff --git a/tests/setupAfterEnv.ts b/tests/setupAfterEnv.ts index 42ea374..a618f6f 100644 --- a/tests/setupAfterEnv.ts +++ b/tests/setupAfterEnv.ts @@ -5,6 +5,7 @@ declare global { namespace jest { interface Matchers { toResolve(): Promise; + toBeRejected(): Promise; } } } @@ -47,7 +48,9 @@ expect.extend({ */ async toResolve(promise: Promise) { if (this.isNot) { - throw new Error('Using `.not.toResolve(...)` is not supported.'); + throw new Error( + 'Using `.not.toResolve()` is not supported. Use .toReject() instead.', + ); } let resolutionValue: any; @@ -77,4 +80,49 @@ expect.extend({ pass: true, }; }, + + /** + * Tests that the given promise is rejected within a certain amount of time + * (which defaults to the time that Jest tests wait before timing out as + * configured in the Jest configuration file). + * + * Inspired by . + * + * @param promise - The promise to test. + * @returns The result of the matcher. + */ + async toBeRejected(promise: Promise) { + if (this.isNot) { + throw new Error( + 'Using `.not.toBeRejected()` is not supported. Use .toResolve() instead.', + ); + } + + let resolutionValue: any; + let rejectionValue: any; + + try { + resolutionValue = await Promise.race([ + promise, + treatUnresolvedAfter(TIME_TO_WAIT_UNTIL_UNRESOLVED), + ]); + } catch (e) { + rejectionValue = e; + } + + return rejectionValue !== undefined || resolutionValue === UNRESOLVED + ? { + message: () => + `This message should never get produced because .isNot is disallowed.`, + pass: true, + } + : { + message: () => { + return `Expected promise to be rejected after ${TIME_TO_WAIT_UNTIL_UNRESOLVED}ms, but it ${ + rejectionValue === undefined ? 'did not' : 'resolved' + }.`; + }, + pass: false, + }; + }, }); diff --git a/tests/unit/helpers.ts b/tests/unit/helpers.ts index 56064b2..3b0adb6 100644 --- a/tests/unit/helpers.ts +++ b/tests/unit/helpers.ts @@ -6,6 +6,12 @@ import { PackageManifestFieldNames } from '../../src/package-manifest'; import type { ValidatedPackageManifest } from '../../src/package-manifest'; import type { Project } from '../../src/project'; +/** + * Returns a version of the given record type where optionality is removed from + * the designated keys. + */ +export type Require = Omit & { [P in K]-?: T[P] }; + /** * Returns a version of the given record type where optionality is added to * the designated keys. @@ -104,58 +110,6 @@ export function buildMockPackage( }; } -/** - * Builds a package for use in tests which is designed to be the root package of - * a monorepo. - * - * @param args - The name of the package (optional), the version of the package - * (optional) and the properties that will go into the object (optional). - * @returns The mock Package object. - */ -export function buildMockMonorepoRootPackage( - ...args: - | [string, string | SemVer, MockPackageOverrides] - | [string, string | SemVer] - | [string, MockPackageOverrides] - | [string] - | [MockPackageOverrides] - | [] -): Package { - let name, version, overrides; - - switch (args.length) { - case 0: - name = 'package'; - version = '1.0.0'; - overrides = {}; - break; - case 1: - name = isPlainObject(args[0]) ? 'package' : args[0]; - version = '1.0.0'; - overrides = isPlainObject(args[0]) ? args[0] : {}; - break; - case 2: - name = args[0]; - version = isPlainObject(args[1]) ? '1.0.0' : args[1]; - overrides = isPlainObject(args[1]) ? args[1] : {}; - break; - default: - name = args[0]; - version = args[1]; - overrides = args[2]; - } - - const { validatedManifest, ...rest } = overrides; - return buildMockPackage(name, version, { - validatedManifest: { - private: true, - workspaces: ['packages/*'], - ...validatedManifest, - }, - ...rest, - }); -} - /** * Builds a manifest object for use in tests. All properties have default * values, so you can specify only the properties you care about. From b43ecdb441833ac1ae9a53feeabad4c0d8436479 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Wed, 24 Aug 2022 16:27:21 -0600 Subject: [PATCH 2/7] Use rejects.toThrow instead of toBeRejected --- src/monorepo-workflow-operations.test.ts | 18 +++++----- tests/setupAfterEnv.ts | 46 ------------------------ 2 files changed, 9 insertions(+), 55 deletions(-) diff --git a/src/monorepo-workflow-operations.test.ts b/src/monorepo-workflow-operations.test.ts index 6a6405b..52877d1 100644 --- a/src/monorepo-workflow-operations.test.ts +++ b/src/monorepo-workflow-operations.test.ts @@ -332,7 +332,7 @@ describe('monorepo-workflow-operations', () => { stdout, stderr, }), - ).toBeRejected(); + ).rejects.toThrow(expect.anything()); expect(executeReleasePlanSpy).not.toHaveBeenCalled(); }); @@ -362,7 +362,7 @@ describe('monorepo-workflow-operations', () => { stdout, stderr, }), - ).toBeRejected(); + ).rejects.toThrow(expect.anything()); expect(captureChangesInReleaseBranchSpy).not.toHaveBeenCalled(); }); @@ -387,7 +387,7 @@ describe('monorepo-workflow-operations', () => { stdout, stderr, }), - ).toBeRejected(); + ).rejects.toThrow(expect.anything()); expect(await fileExists(releaseSpecificationPath)).toBe(false); }); @@ -734,7 +734,7 @@ describe('monorepo-workflow-operations', () => { stdout, stderr, }), - ).toBeRejected(); + ).rejects.toThrow(expect.anything()); expect(executeReleasePlanSpy).not.toHaveBeenCalled(); }); @@ -764,7 +764,7 @@ describe('monorepo-workflow-operations', () => { stdout, stderr, }), - ).toBeRejected(); + ).rejects.toThrow(expect.anything()); expect(captureChangesInReleaseBranchSpy).not.toHaveBeenCalled(); }); @@ -789,7 +789,7 @@ describe('monorepo-workflow-operations', () => { stdout, stderr, }), - ).toBeRejected(); + ).rejects.toThrow(expect.anything()); expect(await fileExists(releaseSpecificationPath)).toBe(false); }); @@ -1028,7 +1028,7 @@ describe('monorepo-workflow-operations', () => { stdout, stderr, }), - ).toBeRejected(); + ).rejects.toThrow(expect.anything()); expect(executeReleasePlanSpy).not.toHaveBeenCalled(); }); @@ -1058,7 +1058,7 @@ describe('monorepo-workflow-operations', () => { stdout, stderr, }), - ).toBeRejected(); + ).rejects.toThrow(expect.anything()); expect(captureChangesInReleaseBranchSpy).not.toHaveBeenCalled(); }); @@ -1083,7 +1083,7 @@ describe('monorepo-workflow-operations', () => { stdout, stderr, }), - ).toBeRejected(); + ).rejects.toThrow(expect.anything()); expect(await fileExists(releaseSpecificationPath)).toBe(false); }); diff --git a/tests/setupAfterEnv.ts b/tests/setupAfterEnv.ts index a618f6f..f5e479e 100644 --- a/tests/setupAfterEnv.ts +++ b/tests/setupAfterEnv.ts @@ -5,7 +5,6 @@ declare global { namespace jest { interface Matchers { toResolve(): Promise; - toBeRejected(): Promise; } } } @@ -80,49 +79,4 @@ expect.extend({ pass: true, }; }, - - /** - * Tests that the given promise is rejected within a certain amount of time - * (which defaults to the time that Jest tests wait before timing out as - * configured in the Jest configuration file). - * - * Inspired by . - * - * @param promise - The promise to test. - * @returns The result of the matcher. - */ - async toBeRejected(promise: Promise) { - if (this.isNot) { - throw new Error( - 'Using `.not.toBeRejected()` is not supported. Use .toResolve() instead.', - ); - } - - let resolutionValue: any; - let rejectionValue: any; - - try { - resolutionValue = await Promise.race([ - promise, - treatUnresolvedAfter(TIME_TO_WAIT_UNTIL_UNRESOLVED), - ]); - } catch (e) { - rejectionValue = e; - } - - return rejectionValue !== undefined || resolutionValue === UNRESOLVED - ? { - message: () => - `This message should never get produced because .isNot is disallowed.`, - pass: true, - } - : { - message: () => { - return `Expected promise to be rejected after ${TIME_TO_WAIT_UNTIL_UNRESOLVED}ms, but it ${ - rejectionValue === undefined ? 'did not' : 'resolved' - }.`; - }, - pass: false, - }; - }, }); From 519d5499b694a80922acebed8d6cd832dc662e44 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Wed, 24 Aug 2022 16:54:56 -0600 Subject: [PATCH 3/7] Update error message produced when .not.toResolve() is used --- tests/setupAfterEnv.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/setupAfterEnv.ts b/tests/setupAfterEnv.ts index f5e479e..1fc72ed 100644 --- a/tests/setupAfterEnv.ts +++ b/tests/setupAfterEnv.ts @@ -48,7 +48,7 @@ expect.extend({ async toResolve(promise: Promise) { if (this.isNot) { throw new Error( - 'Using `.not.toResolve()` is not supported. Use .toReject() instead.', + 'Using `.not.toResolve()` is not supported. Use .rejects.toThrow(expect.anything()) instead.', ); } From 39c85d87aed75f8746d2f775ebab24d82a78cc3d Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Thu, 25 Aug 2022 14:00:51 -0600 Subject: [PATCH 4/7] Clarify these test names --- src/monorepo-workflow-operations.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/monorepo-workflow-operations.test.ts b/src/monorepo-workflow-operations.test.ts index 52877d1..8addfd7 100644 --- a/src/monorepo-workflow-operations.test.ts +++ b/src/monorepo-workflow-operations.test.ts @@ -221,7 +221,7 @@ async function setupFollowMonorepoWorkflow({ describe('monorepo-workflow-operations', () => { describe('followMonorepoWorkflow', () => { - describe('when firstRemovingExistingReleaseSpecification is false, the release spec file does not already exist, an editor is available, and editing will succeed', () => { + describe('when firstRemovingExistingReleaseSpecification is false, the release spec file does not already exist, an editor is available, and editing the release spec will succeed', () => { it('executes the edited release spec', async () => { await withSandbox(async (sandbox) => { const { @@ -312,7 +312,7 @@ describe('monorepo-workflow-operations', () => { }); }); - describe('when firstRemovingExistingReleaseSpecification is false, the release spec file does not already exist, an editor is available, and editing will not succeed', () => { + describe('when firstRemovingExistingReleaseSpecification is false, the release spec file does not already exist, an editor is available, and editing the release spec will not succeed', () => { it('does not try to execute the edited release spec', async () => { await withSandbox(async (sandbox) => { const { project, today, stdout, stderr, executeReleasePlanSpy } = @@ -623,7 +623,7 @@ describe('monorepo-workflow-operations', () => { }); }); - describe('when firstRemovingExistingReleaseSpecification is true, the release spec file does not already exist, an editor is available, and editing will succeed', () => { + describe('when firstRemovingExistingReleaseSpecification is true, the release spec file does not already exist, an editor is available, and editing the release spec will succeed', () => { it('executes the edited release spec', async () => { await withSandbox(async (sandbox) => { const { @@ -714,7 +714,7 @@ describe('monorepo-workflow-operations', () => { }); }); - describe('when firstRemovingExistingReleaseSpecification is true, the release spec file does not already exist, an editor is available, and editing will not succeed', () => { + describe('when firstRemovingExistingReleaseSpecification is true, the release spec file does not already exist, an editor is available, and editing the release spec will not succeed', () => { it('does not try to execute the edited release spec', async () => { await withSandbox(async (sandbox) => { const { project, today, stdout, stderr, executeReleasePlanSpy } = @@ -917,7 +917,7 @@ describe('monorepo-workflow-operations', () => { }); }); - describe('when firstRemovingExistingReleaseSpecification is true, the release spec file already exists, an editor is available, and editing will succeed', () => { + describe('when firstRemovingExistingReleaseSpecification is true, the release spec file already exists, an editor is available, and editing the release spec will succeed', () => { it('executes the edited release spec', async () => { await withSandbox(async (sandbox) => { const { @@ -1008,7 +1008,7 @@ describe('monorepo-workflow-operations', () => { }); }); - describe('when firstRemovingExistingReleaseSpecification is true, the release spec file already exists, an editor is available, and editing will not succeed', () => { + describe('when firstRemovingExistingReleaseSpecification is true, the release spec file already exists, an editor is available, and editing the release spec will not succeed', () => { it('does not try to execute the edited release spec', async () => { await withSandbox(async (sandbox) => { const { project, today, stdout, stderr, executeReleasePlanSpy } = From 9c04d444e2042c16970d3d4dea601af2968b72e7 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Thu, 25 Aug 2022 14:02:23 -0600 Subject: [PATCH 5/7] Remove copypasta'd test --- src/monorepo-workflow-operations.test.ts | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/src/monorepo-workflow-operations.test.ts b/src/monorepo-workflow-operations.test.ts index 8addfd7..ef3f350 100644 --- a/src/monorepo-workflow-operations.test.ts +++ b/src/monorepo-workflow-operations.test.ts @@ -598,29 +598,6 @@ describe('monorepo-workflow-operations', () => { expect(await fileExists(releaseSpecificationPath)).toBe(false); }); }); - - it('does not remove the release spec file', async () => { - await withSandbox(async (sandbox) => { - const { project, today, stdout, stderr, releaseSpecificationPath } = - await setupFollowMonorepoWorkflow({ - sandbox, - doesReleaseSpecFileExist: false, - isEditorAvailable: false, - errorUponEditingReleaseSpec: null, - }); - - await followMonorepoWorkflow({ - project, - tempDirectoryPath: sandbox.directoryPath, - firstRemovingExistingReleaseSpecification: false, - today, - stdout, - stderr, - }); - - expect(await fileExists(releaseSpecificationPath)).toBe(true); - }); - }); }); describe('when firstRemovingExistingReleaseSpecification is true, the release spec file does not already exist, an editor is available, and editing the release spec will succeed', () => { From fbc5bf415790580d82b360e9971d38a78b63c448 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Thu, 25 Aug 2022 14:22:13 -0600 Subject: [PATCH 6/7] Add missing tests --- src/monorepo-workflow-operations.test.ts | 98 ++++++++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/src/monorepo-workflow-operations.test.ts b/src/monorepo-workflow-operations.test.ts index ef3f350..6694f55 100644 --- a/src/monorepo-workflow-operations.test.ts +++ b/src/monorepo-workflow-operations.test.ts @@ -211,6 +211,8 @@ async function setupFollowMonorepoWorkflow({ today, stdout, stderr, + generateReleaseSpecificationTemplateForMonorepoSpy, + waitForUserToEditReleaseSpecificationSpy, executeReleasePlanSpy, captureChangesInReleaseBranchSpy, releasePlan, @@ -516,6 +518,34 @@ describe('monorepo-workflow-operations', () => { }); describe('when firstRemovingExistingReleaseSpecification is false and the release spec file already exists', () => { + it('does not open the editor', async () => { + await withSandbox(async (sandbox) => { + const { + project, + today, + stdout, + stderr, + waitForUserToEditReleaseSpecificationSpy, + } = await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: true, + }); + + await followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: false, + today, + stdout, + stderr, + }); + + expect( + waitForUserToEditReleaseSpecificationSpy, + ).not.toHaveBeenCalled(); + }); + }); + it('executes the edited release spec', async () => { await withSandbox(async (sandbox) => { const { @@ -895,6 +925,39 @@ describe('monorepo-workflow-operations', () => { }); describe('when firstRemovingExistingReleaseSpecification is true, the release spec file already exists, an editor is available, and editing the release spec will succeed', () => { + it('generates a new release spec instead of using the existing one', async () => { + await withSandbox(async (sandbox) => { + const { + project, + today, + stdout, + stderr, + generateReleaseSpecificationTemplateForMonorepoSpy, + } = await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: true, + isEditorAvailable: true, + errorUponEditingReleaseSpec: null, + }); + + await followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: true, + today, + stdout, + stderr, + }); + + expect( + generateReleaseSpecificationTemplateForMonorepoSpy, + ).toHaveBeenCalledWith({ + project, + isEditorAvailable: true, + }); + }); + }); + it('executes the edited release spec', async () => { await withSandbox(async (sandbox) => { const { @@ -986,6 +1049,41 @@ describe('monorepo-workflow-operations', () => { }); describe('when firstRemovingExistingReleaseSpecification is true, the release spec file already exists, an editor is available, and editing the release spec will not succeed', () => { + it('generates a new release spec instead of using the existing one', async () => { + await withSandbox(async (sandbox) => { + const { + project, + today, + stdout, + stderr, + generateReleaseSpecificationTemplateForMonorepoSpy, + } = await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: true, + isEditorAvailable: true, + errorUponEditingReleaseSpec: new Error('oops'), + }); + + await expect( + followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: true, + today, + stdout, + stderr, + }), + ).rejects.toThrow(expect.anything()); + + expect( + generateReleaseSpecificationTemplateForMonorepoSpy, + ).toHaveBeenCalledWith({ + project, + isEditorAvailable: true, + }); + }); + }); + it('does not try to execute the edited release spec', async () => { await withSandbox(async (sandbox) => { const { project, today, stdout, stderr, executeReleasePlanSpy } = From aca7cce4a026221f9da66c99bf1cfa66710579c5 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Thu, 25 Aug 2022 15:32:01 -0600 Subject: [PATCH 7/7] Fill in more missing tests, fix bug with detecting whether editor is available --- src/monorepo-workflow-operations.test.ts | 513 ++++++++++++++++++----- src/monorepo-workflow-operations.ts | 2 +- 2 files changed, 416 insertions(+), 99 deletions(-) diff --git a/src/monorepo-workflow-operations.test.ts b/src/monorepo-workflow-operations.test.ts index 6694f55..5e9caaa 100644 --- a/src/monorepo-workflow-operations.test.ts +++ b/src/monorepo-workflow-operations.test.ts @@ -134,8 +134,13 @@ function buildMockEditor({ * @param args.isEditorAvailable - Whether `determineEditor` should return an * editor object. * @param args.errorUponEditingReleaseSpec - The error that - * `waitForUserToEditReleaseSpecification` will throw, or null/undefined if it - * should not throw. + * `waitForUserToEditReleaseSpecification` will throw. + * @param args.errorUponValidatingReleaseSpec - The error that + * `validateReleaseSpecification` will throw. + * @param args.errorUponPlanningRelease - The error that `planRelease` will + * throw. + * @param args.errorUponExecutingReleasePlan - The error that + * `executeReleasePlan` will throw. * @returns Mock functions and other data that can be used in tests to make * assertions. */ @@ -143,12 +148,18 @@ async function setupFollowMonorepoWorkflow({ sandbox, doesReleaseSpecFileExist, isEditorAvailable = false, - errorUponEditingReleaseSpec = null, + errorUponEditingReleaseSpec, + errorUponValidatingReleaseSpec, + errorUponPlanningRelease, + errorUponExecutingReleasePlan, }: { sandbox: Sandbox; doesReleaseSpecFileExist: boolean; isEditorAvailable?: boolean; - errorUponEditingReleaseSpec?: Error | null; + errorUponEditingReleaseSpec?: Error; + errorUponValidatingReleaseSpec?: Error; + errorUponPlanningRelease?: Error; + errorUponExecutingReleasePlan?: Error; }) { const { determineEditorSpy, @@ -189,13 +200,32 @@ async function setupFollowMonorepoWorkflow({ .mockResolvedValue(); } - when(validateReleaseSpecificationSpy) - .calledWith(project, releaseSpecificationPath) - .mockResolvedValue(releaseSpecification); - when(planReleaseSpy) - .calledWith({ project, releaseSpecification, today }) - .mockResolvedValue(releasePlan); - executeReleasePlanSpy.mockResolvedValue(); + if (errorUponValidatingReleaseSpec) { + when(validateReleaseSpecificationSpy) + .calledWith(project, releaseSpecificationPath) + .mockRejectedValue(errorUponValidatingReleaseSpec); + } else { + when(validateReleaseSpecificationSpy) + .calledWith(project, releaseSpecificationPath) + .mockResolvedValue(releaseSpecification); + } + + if (errorUponPlanningRelease) { + when(planReleaseSpy) + .calledWith({ project, releaseSpecification, today }) + .mockRejectedValue(errorUponPlanningRelease); + } else { + when(planReleaseSpy) + .calledWith({ project, releaseSpecification, today }) + .mockResolvedValue(releasePlan); + } + + if (errorUponExecutingReleasePlan) { + executeReleasePlanSpy.mockRejectedValue(errorUponExecutingReleasePlan); + } else { + executeReleasePlanSpy.mockResolvedValue(); + } + captureChangesInReleaseBranchSpy.mockResolvedValue(); if (doesReleaseSpecFileExist) { @@ -223,8 +253,8 @@ async function setupFollowMonorepoWorkflow({ describe('monorepo-workflow-operations', () => { describe('followMonorepoWorkflow', () => { - describe('when firstRemovingExistingReleaseSpecification is false, the release spec file does not already exist, an editor is available, and editing the release spec will succeed', () => { - it('executes the edited release spec', async () => { + describe('when firstRemovingExistingReleaseSpecification is false, the release spec file does not already exist, and an editor is available', () => { + it('attempts to execute the release spec if it was successfully edited', async () => { await withSandbox(async (sandbox) => { const { project, @@ -237,7 +267,6 @@ describe('monorepo-workflow-operations', () => { sandbox, doesReleaseSpecFileExist: false, isEditorAvailable: true, - errorUponEditingReleaseSpec: null, }); await followMonorepoWorkflow({ @@ -257,7 +286,7 @@ describe('monorepo-workflow-operations', () => { }); }); - it('creates a new branch named after the generated release version', async () => { + it('creates a new branch named after the generated release version if editing, validating, and executing the release spec succeeds', async () => { await withSandbox(async (sandbox) => { const { project, @@ -271,7 +300,6 @@ describe('monorepo-workflow-operations', () => { sandbox, doesReleaseSpecFileExist: false, isEditorAvailable: true, - errorUponEditingReleaseSpec: null, }); await followMonorepoWorkflow({ @@ -290,14 +318,13 @@ describe('monorepo-workflow-operations', () => { }); }); - it('removes the release spec file at the end', async () => { + it('removes the release spec file after editing, validating, and executing the release spec', async () => { await withSandbox(async (sandbox) => { const { project, today, stdout, stderr, releaseSpecificationPath } = await setupFollowMonorepoWorkflow({ sandbox, doesReleaseSpecFileExist: false, isEditorAvailable: true, - errorUponEditingReleaseSpec: null, }); await followMonorepoWorkflow({ @@ -312,10 +339,8 @@ describe('monorepo-workflow-operations', () => { expect(await fileExists(releaseSpecificationPath)).toBe(false); }); }); - }); - describe('when firstRemovingExistingReleaseSpecification is false, the release spec file does not already exist, an editor is available, and editing the release spec will not succeed', () => { - it('does not try to execute the edited release spec', async () => { + it('does not attempt to execute the release spec if it was not successfully edited', async () => { await withSandbox(async (sandbox) => { const { project, today, stdout, stderr, executeReleasePlanSpy } = await setupFollowMonorepoWorkflow({ @@ -340,7 +365,7 @@ describe('monorepo-workflow-operations', () => { }); }); - it('does not try to create a new branch', async () => { + it('does not attempt to create a new branch if the release spec was not successfully edited', async () => { await withSandbox(async (sandbox) => { const { project, @@ -370,7 +395,7 @@ describe('monorepo-workflow-operations', () => { }); }); - it('removes the release spec file at the end', async () => { + it('removes the release spec file even if it was not successfully edited', async () => { await withSandbox(async (sandbox) => { const { project, today, stdout, stderr, releaseSpecificationPath } = await setupFollowMonorepoWorkflow({ @@ -395,7 +420,7 @@ describe('monorepo-workflow-operations', () => { }); }); - it('throws the error', async () => { + it('throws an error produced while editing the release spec', async () => { await withSandbox(async (sandbox) => { const { project, today, stdout, stderr } = await setupFollowMonorepoWorkflow({ @@ -417,10 +442,88 @@ describe('monorepo-workflow-operations', () => { ).rejects.toThrow('oops'); }); }); + + it('does not remove the generated release spec file if it was successfully edited but an error is thrown while validating the release spec', async () => { + await withSandbox(async (sandbox) => { + const errorUponValidatingReleaseSpec = new Error('oops'); + const { project, today, stdout, stderr, releaseSpecificationPath } = + await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: false, + isEditorAvailable: true, + errorUponValidatingReleaseSpec, + }); + + await expect( + followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: false, + today, + stdout, + stderr, + }), + ).rejects.toThrow(errorUponValidatingReleaseSpec); + + expect(await fileExists(releaseSpecificationPath)).toBe(true); + }); + }); + + it('does not remove the generated release spec file if it was successfully edited but an error is thrown while planning the release', async () => { + await withSandbox(async (sandbox) => { + const errorUponPlanningRelease = new Error('oops'); + const { project, today, stdout, stderr, releaseSpecificationPath } = + await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: false, + isEditorAvailable: true, + errorUponPlanningRelease, + }); + + await expect( + followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: false, + today, + stdout, + stderr, + }), + ).rejects.toThrow(errorUponPlanningRelease); + + expect(await fileExists(releaseSpecificationPath)).toBe(true); + }); + }); + + it('does not remove the generated release spec file if it was successfully edited but an error is thrown while executing the release plan', async () => { + await withSandbox(async (sandbox) => { + const errorUponExecutingReleasePlan = new Error('oops'); + const { project, today, stdout, stderr, releaseSpecificationPath } = + await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: false, + isEditorAvailable: true, + errorUponExecutingReleasePlan, + }); + + await expect( + followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: false, + today, + stdout, + stderr, + }), + ).rejects.toThrow(errorUponExecutingReleasePlan); + + expect(await fileExists(releaseSpecificationPath)).toBe(true); + }); + }); }); describe('when firstRemovingExistingReleaseSpecification is false, the release spec file does not already exist, and an editor is not available', () => { - it('does not try to execute the edited release spec', async () => { + it('does not attempt to execute the edited release spec', async () => { await withSandbox(async (sandbox) => { const { project, today, stdout, stderr, executeReleasePlanSpy } = await setupFollowMonorepoWorkflow({ @@ -442,7 +545,7 @@ describe('monorepo-workflow-operations', () => { }); }); - it('does not try to create a new branch', async () => { + it('does not attempt to create a new branch', async () => { await withSandbox(async (sandbox) => { const { project, @@ -493,14 +596,13 @@ describe('monorepo-workflow-operations', () => { }); }); - it('does not remove the release spec file', async () => { + it('does not remove the generated release spec file', async () => { await withSandbox(async (sandbox) => { const { project, today, stdout, stderr, releaseSpecificationPath } = await setupFollowMonorepoWorkflow({ sandbox, doesReleaseSpecFileExist: false, isEditorAvailable: false, - errorUponEditingReleaseSpec: null, }); await followMonorepoWorkflow({ @@ -546,7 +648,7 @@ describe('monorepo-workflow-operations', () => { }); }); - it('executes the edited release spec', async () => { + it('attempts to execute the edited release spec', async () => { await withSandbox(async (sandbox) => { const { project, @@ -577,7 +679,7 @@ describe('monorepo-workflow-operations', () => { }); }); - it('creates a new branch named after the generated release version', async () => { + it('creates a new branch named after the generated release version if validating and executing the release spec succeeds', async () => { await withSandbox(async (sandbox) => { const { project, @@ -608,7 +710,7 @@ describe('monorepo-workflow-operations', () => { }); }); - it('removes the release spec file at the end', async () => { + it('removes the release spec file after validating and executing the release spec', async () => { await withSandbox(async (sandbox) => { const { project, today, stdout, stderr, releaseSpecificationPath } = await setupFollowMonorepoWorkflow({ @@ -628,10 +730,85 @@ describe('monorepo-workflow-operations', () => { expect(await fileExists(releaseSpecificationPath)).toBe(false); }); }); + + it('does not remove the generated release spec file if an error is thrown while validating the release spec', async () => { + await withSandbox(async (sandbox) => { + const errorUponValidatingReleaseSpec = new Error('oops'); + const { project, today, stdout, stderr, releaseSpecificationPath } = + await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: true, + errorUponValidatingReleaseSpec, + }); + + await expect( + followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: false, + today, + stdout, + stderr, + }), + ).rejects.toThrow(errorUponValidatingReleaseSpec); + + expect(await fileExists(releaseSpecificationPath)).toBe(true); + }); + }); + + it('does not remove the generated release spec file if an error is thrown while planning the release', async () => { + await withSandbox(async (sandbox) => { + const errorUponPlanningRelease = new Error('oops'); + const { project, today, stdout, stderr, releaseSpecificationPath } = + await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: true, + errorUponPlanningRelease, + }); + + await expect( + followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: false, + today, + stdout, + stderr, + }), + ).rejects.toThrow(errorUponPlanningRelease); + + expect(await fileExists(releaseSpecificationPath)).toBe(true); + }); + }); + + it('does not remove the generated release spec file if an error is thrown while executing the release plan', async () => { + await withSandbox(async (sandbox) => { + const errorUponExecutingReleasePlan = new Error('oops'); + const { project, today, stdout, stderr, releaseSpecificationPath } = + await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: true, + errorUponExecutingReleasePlan, + }); + + await expect( + followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: false, + today, + stdout, + stderr, + }), + ).rejects.toThrow(errorUponExecutingReleasePlan); + + expect(await fileExists(releaseSpecificationPath)).toBe(true); + }); + }); }); - describe('when firstRemovingExistingReleaseSpecification is true, the release spec file does not already exist, an editor is available, and editing the release spec will succeed', () => { - it('executes the edited release spec', async () => { + describe('when firstRemovingExistingReleaseSpecification is true, the release spec file does not already exist, and an editor is available', () => { + it('attempts to execute the release spec if it was successfully edited', async () => { await withSandbox(async (sandbox) => { const { project, @@ -644,7 +821,6 @@ describe('monorepo-workflow-operations', () => { sandbox, doesReleaseSpecFileExist: false, isEditorAvailable: true, - errorUponEditingReleaseSpec: null, }); await followMonorepoWorkflow({ @@ -664,7 +840,7 @@ describe('monorepo-workflow-operations', () => { }); }); - it('creates a new branch named after the generated release version', async () => { + it('creates a new branch named after the generated release version if editing, validating, and executing the release spec succeeds', async () => { await withSandbox(async (sandbox) => { const { project, @@ -678,7 +854,6 @@ describe('monorepo-workflow-operations', () => { sandbox, doesReleaseSpecFileExist: false, isEditorAvailable: true, - errorUponEditingReleaseSpec: null, }); await followMonorepoWorkflow({ @@ -697,14 +872,13 @@ describe('monorepo-workflow-operations', () => { }); }); - it('removes the release spec file at the end', async () => { + it('removes the release spec file after editing, validating, and executing the release spec', async () => { await withSandbox(async (sandbox) => { const { project, today, stdout, stderr, releaseSpecificationPath } = await setupFollowMonorepoWorkflow({ sandbox, doesReleaseSpecFileExist: false, isEditorAvailable: true, - errorUponEditingReleaseSpec: null, }); await followMonorepoWorkflow({ @@ -719,10 +893,8 @@ describe('monorepo-workflow-operations', () => { expect(await fileExists(releaseSpecificationPath)).toBe(false); }); }); - }); - describe('when firstRemovingExistingReleaseSpecification is true, the release spec file does not already exist, an editor is available, and editing the release spec will not succeed', () => { - it('does not try to execute the edited release spec', async () => { + it('does not attempt to execute the release spec if it was not successfully edited', async () => { await withSandbox(async (sandbox) => { const { project, today, stdout, stderr, executeReleasePlanSpy } = await setupFollowMonorepoWorkflow({ @@ -747,7 +919,7 @@ describe('monorepo-workflow-operations', () => { }); }); - it('does not try to create a new branch', async () => { + it('does not attempt to create a new branch if the release spec was not successfully edited', async () => { await withSandbox(async (sandbox) => { const { project, @@ -777,7 +949,7 @@ describe('monorepo-workflow-operations', () => { }); }); - it('removes the release spec file at the end', async () => { + it('removes the release spec file even if it was not successfully edited', async () => { await withSandbox(async (sandbox) => { const { project, today, stdout, stderr, releaseSpecificationPath } = await setupFollowMonorepoWorkflow({ @@ -802,7 +974,7 @@ describe('monorepo-workflow-operations', () => { }); }); - it('throws the error', async () => { + it('throws an error produced while editing the release spec', async () => { await withSandbox(async (sandbox) => { const { project, today, stdout, stderr } = await setupFollowMonorepoWorkflow({ @@ -824,10 +996,88 @@ describe('monorepo-workflow-operations', () => { ).rejects.toThrow('oops'); }); }); + + it('does not remove the generated release spec file if it was successfully edited but an error is thrown while validating the release spec', async () => { + await withSandbox(async (sandbox) => { + const errorUponValidatingReleaseSpec = new Error('oops'); + const { project, today, stdout, stderr, releaseSpecificationPath } = + await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: false, + isEditorAvailable: true, + errorUponValidatingReleaseSpec, + }); + + await expect( + followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: true, + today, + stdout, + stderr, + }), + ).rejects.toThrow(errorUponValidatingReleaseSpec); + + expect(await fileExists(releaseSpecificationPath)).toBe(true); + }); + }); + + it('does not remove the generated release spec file if it was successfully edited but an error is thrown while planning the release', async () => { + await withSandbox(async (sandbox) => { + const errorUponPlanningRelease = new Error('oops'); + const { project, today, stdout, stderr, releaseSpecificationPath } = + await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: false, + isEditorAvailable: true, + errorUponPlanningRelease, + }); + + await expect( + followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: true, + today, + stdout, + stderr, + }), + ).rejects.toThrow(errorUponPlanningRelease); + + expect(await fileExists(releaseSpecificationPath)).toBe(true); + }); + }); + + it('does not remove the generated release spec file if it was successfully edited but an error is thrown while executing the release plan', async () => { + await withSandbox(async (sandbox) => { + const errorUponExecutingReleasePlan = new Error('oops'); + const { project, today, stdout, stderr, releaseSpecificationPath } = + await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: false, + isEditorAvailable: true, + errorUponExecutingReleasePlan, + }); + + await expect( + followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: true, + today, + stdout, + stderr, + }), + ).rejects.toThrow(errorUponExecutingReleasePlan); + + expect(await fileExists(releaseSpecificationPath)).toBe(true); + }); + }); }); describe('when firstRemovingExistingReleaseSpecification is true, the release spec file does not already exist, and an editor is not available', () => { - it('does not try to execute the edited release spec', async () => { + it('does not attempt to execute the edited release spec', async () => { await withSandbox(async (sandbox) => { const { project, today, stdout, stderr, executeReleasePlanSpy } = await setupFollowMonorepoWorkflow({ @@ -849,7 +1099,7 @@ describe('monorepo-workflow-operations', () => { }); }); - it('does not try to create a new branch', async () => { + it('does not attempt to create a new branch', async () => { await withSandbox(async (sandbox) => { const { project, @@ -900,14 +1150,13 @@ describe('monorepo-workflow-operations', () => { }); }); - it('does not remove the release spec file', async () => { + it('does not remove the generated release spec file', async () => { await withSandbox(async (sandbox) => { const { project, today, stdout, stderr, releaseSpecificationPath } = await setupFollowMonorepoWorkflow({ sandbox, doesReleaseSpecFileExist: false, isEditorAvailable: false, - errorUponEditingReleaseSpec: null, }); await followMonorepoWorkflow({ @@ -924,7 +1173,7 @@ describe('monorepo-workflow-operations', () => { }); }); - describe('when firstRemovingExistingReleaseSpecification is true, the release spec file already exists, an editor is available, and editing the release spec will succeed', () => { + describe('when firstRemovingExistingReleaseSpecification is true, the release spec file already exists, and an editor is available', () => { it('generates a new release spec instead of using the existing one', async () => { await withSandbox(async (sandbox) => { const { @@ -937,7 +1186,6 @@ describe('monorepo-workflow-operations', () => { sandbox, doesReleaseSpecFileExist: true, isEditorAvailable: true, - errorUponEditingReleaseSpec: null, }); await followMonorepoWorkflow({ @@ -958,7 +1206,7 @@ describe('monorepo-workflow-operations', () => { }); }); - it('executes the edited release spec', async () => { + it('attempts to execute the release spec if it was successfully edited', async () => { await withSandbox(async (sandbox) => { const { project, @@ -971,7 +1219,6 @@ describe('monorepo-workflow-operations', () => { sandbox, doesReleaseSpecFileExist: true, isEditorAvailable: true, - errorUponEditingReleaseSpec: null, }); await followMonorepoWorkflow({ @@ -991,7 +1238,7 @@ describe('monorepo-workflow-operations', () => { }); }); - it('creates a new branch named after the generated release version', async () => { + it('creates a new branch named after the generated release version if editing, validating, and executing the release spec succeeds', async () => { await withSandbox(async (sandbox) => { const { project, @@ -1005,7 +1252,6 @@ describe('monorepo-workflow-operations', () => { sandbox, doesReleaseSpecFileExist: true, isEditorAvailable: true, - errorUponEditingReleaseSpec: null, }); await followMonorepoWorkflow({ @@ -1024,14 +1270,13 @@ describe('monorepo-workflow-operations', () => { }); }); - it('removes the release spec file at the end', async () => { + it('removes the release spec file after editing, validating, and executing the release spec', async () => { await withSandbox(async (sandbox) => { const { project, today, stdout, stderr, releaseSpecificationPath } = await setupFollowMonorepoWorkflow({ sandbox, doesReleaseSpecFileExist: true, isEditorAvailable: true, - errorUponEditingReleaseSpec: null, }); await followMonorepoWorkflow({ @@ -1046,17 +1291,40 @@ describe('monorepo-workflow-operations', () => { expect(await fileExists(releaseSpecificationPath)).toBe(false); }); }); - }); - describe('when firstRemovingExistingReleaseSpecification is true, the release spec file already exists, an editor is available, and editing the release spec will not succeed', () => { - it('generates a new release spec instead of using the existing one', async () => { + it('does not attempt to execute the release spec if it was not successfully edited', async () => { + await withSandbox(async (sandbox) => { + const { project, today, stdout, stderr, executeReleasePlanSpy } = + await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: true, + isEditorAvailable: true, + errorUponEditingReleaseSpec: new Error('oops'), + }); + + await expect( + followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: true, + today, + stdout, + stderr, + }), + ).rejects.toThrow(expect.anything()); + + expect(executeReleasePlanSpy).not.toHaveBeenCalled(); + }); + }); + + it('does not attempt to create a new branch if the release spec was not successfully edited', async () => { await withSandbox(async (sandbox) => { const { project, today, stdout, stderr, - generateReleaseSpecificationTemplateForMonorepoSpy, + captureChangesInReleaseBranchSpy, } = await setupFollowMonorepoWorkflow({ sandbox, doesReleaseSpecFileExist: true, @@ -1075,18 +1343,13 @@ describe('monorepo-workflow-operations', () => { }), ).rejects.toThrow(expect.anything()); - expect( - generateReleaseSpecificationTemplateForMonorepoSpy, - ).toHaveBeenCalledWith({ - project, - isEditorAvailable: true, - }); + expect(captureChangesInReleaseBranchSpy).not.toHaveBeenCalled(); }); }); - it('does not try to execute the edited release spec', async () => { + it('removes the release spec file even if it was not successfully edited', async () => { await withSandbox(async (sandbox) => { - const { project, today, stdout, stderr, executeReleasePlanSpy } = + const { project, today, stdout, stderr, releaseSpecificationPath } = await setupFollowMonorepoWorkflow({ sandbox, doesReleaseSpecFileExist: true, @@ -1105,24 +1368,19 @@ describe('monorepo-workflow-operations', () => { }), ).rejects.toThrow(expect.anything()); - expect(executeReleasePlanSpy).not.toHaveBeenCalled(); + expect(await fileExists(releaseSpecificationPath)).toBe(false); }); }); - it('does not try to create a new branch', async () => { + it('throws an error produced while editing the release spec', async () => { await withSandbox(async (sandbox) => { - const { - project, - today, - stdout, - stderr, - captureChangesInReleaseBranchSpy, - } = await setupFollowMonorepoWorkflow({ - sandbox, - doesReleaseSpecFileExist: true, - isEditorAvailable: true, - errorUponEditingReleaseSpec: new Error('oops'), - }); + const { project, today, stdout, stderr } = + await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: true, + isEditorAvailable: true, + errorUponEditingReleaseSpec: new Error('oops'), + }); await expect( followMonorepoWorkflow({ @@ -1133,20 +1391,45 @@ describe('monorepo-workflow-operations', () => { stdout, stderr, }), - ).rejects.toThrow(expect.anything()); + ).rejects.toThrow('oops'); + }); + }); - expect(captureChangesInReleaseBranchSpy).not.toHaveBeenCalled(); + it('does not remove the generated release spec file if it was successfully edited but an error is thrown while validating the release spec', async () => { + await withSandbox(async (sandbox) => { + const errorUponValidatingReleaseSpec = new Error('oops'); + const { project, today, stdout, stderr, releaseSpecificationPath } = + await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: true, + isEditorAvailable: true, + errorUponValidatingReleaseSpec, + }); + + await expect( + followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: true, + today, + stdout, + stderr, + }), + ).rejects.toThrow(errorUponValidatingReleaseSpec); + + expect(await fileExists(releaseSpecificationPath)).toBe(true); }); }); - it('removes the release spec file at the end', async () => { + it('does not remove the generated release spec file if it was successfully edited but an error is thrown while planning the release', async () => { await withSandbox(async (sandbox) => { + const errorUponPlanningRelease = new Error('oops'); const { project, today, stdout, stderr, releaseSpecificationPath } = await setupFollowMonorepoWorkflow({ sandbox, doesReleaseSpecFileExist: true, isEditorAvailable: true, - errorUponEditingReleaseSpec: new Error('oops'), + errorUponPlanningRelease, }); await expect( @@ -1158,20 +1441,21 @@ describe('monorepo-workflow-operations', () => { stdout, stderr, }), - ).rejects.toThrow(expect.anything()); + ).rejects.toThrow(errorUponPlanningRelease); - expect(await fileExists(releaseSpecificationPath)).toBe(false); + expect(await fileExists(releaseSpecificationPath)).toBe(true); }); }); - it('throws the error', async () => { + it('does not remove the generated release spec file if it was successfully edited but an error is thrown while executing the release plan', async () => { await withSandbox(async (sandbox) => { - const { project, today, stdout, stderr } = + const errorUponExecutingReleasePlan = new Error('oops'); + const { project, today, stdout, stderr, releaseSpecificationPath } = await setupFollowMonorepoWorkflow({ sandbox, doesReleaseSpecFileExist: true, isEditorAvailable: true, - errorUponEditingReleaseSpec: new Error('oops'), + errorUponExecutingReleasePlan, }); await expect( @@ -1183,13 +1467,47 @@ describe('monorepo-workflow-operations', () => { stdout, stderr, }), - ).rejects.toThrow('oops'); + ).rejects.toThrow(errorUponExecutingReleasePlan); + + expect(await fileExists(releaseSpecificationPath)).toBe(true); }); }); }); describe('when firstRemovingExistingReleaseSpecification is true, the release spec file already exists, and an editor is not available', () => { - it('does not try to execute the edited release spec', async () => { + it('generates a new release spec instead of using the existing one', async () => { + await withSandbox(async (sandbox) => { + const { + project, + today, + stdout, + stderr, + generateReleaseSpecificationTemplateForMonorepoSpy, + } = await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: true, + isEditorAvailable: false, + }); + + await followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: true, + today, + stdout, + stderr, + }); + + expect( + generateReleaseSpecificationTemplateForMonorepoSpy, + ).toHaveBeenCalledWith({ + project, + isEditorAvailable: false, + }); + }); + }); + + it('does not attempt to execute the edited release spec', async () => { await withSandbox(async (sandbox) => { const { project, today, stdout, stderr, executeReleasePlanSpy } = await setupFollowMonorepoWorkflow({ @@ -1211,7 +1529,7 @@ describe('monorepo-workflow-operations', () => { }); }); - it('does not try to create a new branch', async () => { + it('does not attempt to create a new branch', async () => { await withSandbox(async (sandbox) => { const { project, @@ -1256,20 +1574,19 @@ describe('monorepo-workflow-operations', () => { stderr, }); - expect(stdout.data()).toMatchObject([ + expect(stdout.data()[0]).toMatch( /^A template has been generated that specifies this release/u, - ]); + ); }); }); - it('does not remove the release spec file', async () => { + it('does not remove the generated release spec file', async () => { await withSandbox(async (sandbox) => { const { project, today, stdout, stderr, releaseSpecificationPath } = await setupFollowMonorepoWorkflow({ sandbox, doesReleaseSpecFileExist: true, isEditorAvailable: false, - errorUponEditingReleaseSpec: null, }); await followMonorepoWorkflow({ diff --git a/src/monorepo-workflow-operations.ts b/src/monorepo-workflow-operations.ts index bbdf43d..ffc8e9d 100644 --- a/src/monorepo-workflow-operations.ts +++ b/src/monorepo-workflow-operations.ts @@ -77,7 +77,7 @@ export async function followMonorepoWorkflow({ const releaseSpecificationTemplate = await generateReleaseSpecificationTemplateForMonorepo({ project, - isEditorAvailable: editor !== undefined, + isEditorAvailable: editor !== null, }); await ensureDirectoryPathExists(tempDirectoryPath); await writeFile(releaseSpecificationPath, releaseSpecificationTemplate);