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..5e9caaa 100644 --- a/src/monorepo-workflow-operations.test.ts +++ b/src/monorepo-workflow-operations.test.ts @@ -1,1788 +1,1606 @@ 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. + */ +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. */ -type UnwrapPromise = T extends Promise ? U : never; +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. + * @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. + */ +async function setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist, + isEditorAvailable = false, + errorUponEditingReleaseSpec, + errorUponValidatingReleaseSpec, + errorUponPlanningRelease, + errorUponExecutingReleasePlan, +}: { + sandbox: Sandbox; + doesReleaseSpecFileExist: boolean; + isEditorAvailable?: boolean; + errorUponEditingReleaseSpec?: Error; + errorUponValidatingReleaseSpec?: Error; + errorUponPlanningRelease?: Error; + errorUponExecutingReleasePlan?: Error; +}) { + 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(); + } + + 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) { + await fs.promises.writeFile( + releaseSpecificationPath, + 'some release specification', + ); + } + + return { + project, + projectDirectoryPath, + today, + stdout, + stderr, + generateReleaseSpecificationTemplateForMonorepoSpy, + waitForUserToEditReleaseSpecificationSpy, + 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, - }); - }); + 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, + today, + stdout, + stderr, + executeReleasePlanSpy, + releasePlan, + } = await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: false, + isEditorAvailable: true, + }); + + await followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: false, + today, + stdout, + stderr, + }); + + expect(executeReleasePlanSpy).toHaveBeenCalledWith( + project, + releasePlan, + stderr, + ); + }); + }); + + 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, + today, + stdout, + stderr, + captureChangesInReleaseBranchSpy, + projectDirectoryPath, + releaseName, + } = await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: false, + isEditorAvailable: true, + }); + + await followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: false, + today, + stdout, + stderr, + }); + + expect(captureChangesInReleaseBranchSpy).toHaveBeenCalledWith( + projectDirectoryPath, + releaseName, + ); + }); + }); + + 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, }); - 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, - }, - ], - }, - ); - }); + await followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: false, + today, + stdout, + stderr, + }); + + expect(await fileExists(releaseSpecificationPath)).toBe(false); + }); + }); + + 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: false, + isEditorAvailable: true, + errorUponEditingReleaseSpec: new Error('oops'), }); - 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 expect( + followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: false, + 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, + captureChangesInReleaseBranchSpy, + } = await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: false, + isEditorAvailable: true, + errorUponEditingReleaseSpec: new Error('oops'), + }); + + await expect( + followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: false, + today, + stdout, + stderr, + }), + ).rejects.toThrow(expect.anything()); + + expect(captureChangesInReleaseBranchSpy).not.toHaveBeenCalled(); + }); + }); + + 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({ + 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 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, - ); - }); + await expect( + followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: false, + today, + stdout, + stderr, + }), + ).rejects.toThrow(expect.anything()); + + expect(await fileExists(releaseSpecificationPath)).toBe(false); + }); + }); + + it('throws an error produced while editing the release spec', 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'), - }, - }, - }); - - 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 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, }); - 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 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, }); - }); - 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); - }); + 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 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, and an editor is not available', () => { + it('does not attempt 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(); }); }); - 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('does not attempt 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, + }); + + 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, }); - 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 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, + ); + }); + }); + + 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, }); - 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); - }); + await followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: false, + today, + stdout, + stderr, + }); + + expect(await fileExists(releaseSpecificationPath)).toBe(true); + }); + }); + }); + + 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('attempts to execute the edited release spec', async () => { + await withSandbox(async (sandbox) => { + const { + project, + today, + stdout, + stderr, + executeReleasePlanSpy, + releasePlan, + } = await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: true, + }); + + await followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: false, + today, + stdout, + stderr, + }); + + expect(executeReleasePlanSpy).toHaveBeenCalledWith( + project, + releasePlan, + stderr, + ); + }); + }); + + 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, + today, + stdout, + stderr, + captureChangesInReleaseBranchSpy, + projectDirectoryPath, + releaseName, + } = await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: true, + }); + + await followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: false, + today, + stdout, + stderr, + }); + + expect(captureChangesInReleaseBranchSpy).toHaveBeenCalledWith( + projectDirectoryPath, + releaseName, + ); + }); + }); + + 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({ + sandbox, + doesReleaseSpecFileExist: true, }); - 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, - ); - }); + await followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: false, + today, + stdout, + stderr, + }); + + 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, }); - 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(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, }); - 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 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, and an editor is available', () => { + it('attempts to execute the release spec if it was successfully edited', async () => { + await withSandbox(async (sandbox) => { + const { + project, + today, + stdout, + stderr, + executeReleasePlanSpy, + releasePlan, + } = await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: false, + isEditorAvailable: true, }); - 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); - }); + 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 if editing, validating, and executing the release spec succeeds', async () => { + await withSandbox(async (sandbox) => { + const { + project, + today, + stdout, + stderr, + captureChangesInReleaseBranchSpy, + projectDirectoryPath, + releaseName, + } = await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: false, + isEditorAvailable: true, + }); + + await followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: true, + today, + stdout, + stderr, + }); + + expect(captureChangesInReleaseBranchSpy).toHaveBeenCalledWith( + projectDirectoryPath, + releaseName, + ); + }); + }); + + 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, }); + + await followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: true, + today, + stdout, + stderr, }); + + expect(await fileExists(releaseSpecificationPath)).toBe(false); }); + }); - 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 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: false, + 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, + captureChangesInReleaseBranchSpy, + } = await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: false, + isEditorAvailable: true, + errorUponEditingReleaseSpec: new Error('oops'), }); + + await expect( + followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: true, + today, + stdout, + stderr, + }), + ).rejects.toThrow(expect.anything()); + + expect(captureChangesInReleaseBranchSpy).not.toHaveBeenCalled(); }); }); - }); - 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, - }); - }); + 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({ + 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, - }, - }, - }); - - 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 expect( + followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: true, + today, + stdout, + stderr, + }), + ).rejects.toThrow(expect.anything()); + + expect(await fileExists(releaseSpecificationPath)).toBe(false); + }); + }); + + it('throws an error produced while editing the release spec', async () => { + await withSandbox(async (sandbox) => { + const { project, today, stdout, stderr } = + await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: false, + isEditorAvailable: true, + errorUponEditingReleaseSpec: new Error('oops'), }); - 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); - }); + await expect( + followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: true, + today, + stdout, + stderr, + }), + ).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, }); - 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, - ); - }); + 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, }); - 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 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, }); - 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()); - }); + 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 attempt 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 attempt 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, }); - 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(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, + ); }); + }); - 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(); + 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, }); + + 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, and an editor is available', () => { + 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, + }); + + await followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: true, + today, + stdout, + stderr, + }); + + expect( + generateReleaseSpecificationTemplateForMonorepoSpy, + ).toHaveBeenCalledWith({ + project, + isEditorAvailable: true, + }); + }); + }); + + it('attempts to execute the release spec if it was successfully edited', async () => { + await withSandbox(async (sandbox) => { + const { + project, + today, + stdout, + stderr, + executeReleasePlanSpy, + releasePlan, + } = await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: true, + isEditorAvailable: true, + }); + + 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 if editing, validating, and executing the release spec succeeds', async () => { + await withSandbox(async (sandbox) => { + const { + project, + today, + stdout, + stderr, + captureChangesInReleaseBranchSpy, + projectDirectoryPath, + releaseName, + } = await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: true, + isEditorAvailable: true, }); + + 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 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, }); - 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); + }); + }); + + 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'), }); - 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(expect.anything()); + + expect(executeReleasePlanSpy).not.toHaveBeenCalled(); + }); + }); - expect( - generateReleaseSpecificationTemplateForMonorepoSpy, - ).not.toHaveBeenCalled(); - expect( - waitForUserToEditReleaseSpecificationSpy, - ).not.toHaveBeenCalled(); - expect(updatePackageSpy).toHaveBeenNthCalledWith(1, { + 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, + captureChangesInReleaseBranchSpy, + } = await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: true, + isEditorAvailable: true, + errorUponEditingReleaseSpec: new Error('oops'), + }); + + await expect( + followMonorepoWorkflow({ project, - packageReleasePlan: { - package: project.rootPackage, - newVersion: '2022.6.12', - shouldUpdateChangelog: false, - }, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: true, + today, + stdout, stderr, + }), + ).rejects.toThrow(expect.anything()); + + expect(captureChangesInReleaseBranchSpy).not.toHaveBeenCalled(); + }); + }); + + 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({ + sandbox, + doesReleaseSpecFileExist: true, + 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(expect.anything()); + + expect(await fileExists(releaseSpecificationPath)).toBe(false); }); + }); - 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, - }, - }, + it('throws an error produced while editing the release spec', 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'); + }); + }); + + 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, }); - 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, + }), + ).rejects.toThrow(errorUponValidatingReleaseSpec); + + expect(await fileExists(releaseSpecificationPath)).toBe(true); }); + }); - 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('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, + errorUponPlanningRelease, }); - 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(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: true, + isEditorAvailable: true, + errorUponExecutingReleasePlan, }); - await expect( - fs.promises.readFile(releaseSpecPath, 'utf8'), - ).rejects.toThrow(/^ENOENT: no such file or directory/u); + 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 already exists, and an editor is not available', () => { + 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("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, - }, - }), - }, + it('does not attempt to execute the edited release spec', async () => { + await withSandbox(async (sandbox) => { + const { project, today, stdout, stderr, executeReleasePlanSpy } = + await setupFollowMonorepoWorkflow({ + sandbox, + doesReleaseSpecFileExist: true, + isEditorAvailable: 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, - ); + + await followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: true, + today, + stdout, + stderr, + }); + + expect(executeReleasePlanSpy).not.toHaveBeenCalled(); + }); + }); + + it('does not attempt 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()[0]).toMatch( + /^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 generated release spec file', async () => { + await withSandbox(async (sandbox) => { + const { project, today, stdout, stderr, releaseSpecificationPath } = + 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(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..ffc8e9d 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', ); @@ -107,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); @@ -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..1fc72ed 100644 --- a/tests/setupAfterEnv.ts +++ b/tests/setupAfterEnv.ts @@ -47,7 +47,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 .rejects.toThrow(expect.anything()) instead.', + ); } let resolutionValue: any; 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.