From 3d5b0142603776c8e16295273111f83c651d88e5 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Mon, 11 Jul 2022 10:49:44 -0600 Subject: [PATCH 01/41] Use Babel for Jest coverage provider v8 isn't quite reliable when using Node 14 for development (it has too many false negatives). We can consider changing this back if/when we switch to Node 16 or later, but for now we will stick to Babel. --- jest.config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jest.config.js b/jest.config.js index beb8598..83e1504 100644 --- a/jest.config.js +++ b/jest.config.js @@ -33,7 +33,7 @@ module.exports = { // ], // Indicates which provider should be used to instrument code for coverage - coverageProvider: 'v8', + coverageProvider: 'babel', // A list of reporter names that Jest uses when writing coverage reports coverageReporters: ['html', 'json-summary', 'text'], From 6a42f71edf3566ac0007b09c6ffab35dee6dcf5b Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Mon, 11 Jul 2022 13:45:17 -0600 Subject: [PATCH 02/41] Add flow for monorepo w/ independent versions This commit introduces a rough cut of a flow which supports a monorepo with an independent versioning strategy. This flow: * Generates a "release specification" file containing all of the workspace packages within the repo (this will be changed to all _updated_ packages in a future commit) * Opens the user's editor (if one can is detected) and waits for them to edit it * Parses the edited version of the release spec * Applies the release spec by: * Updating the version of the root package with the current date (note: the format of the version string will be changed in a future commit) * Updating the versions of all of the packages listed in the release spec * Adds new sections to the changelogs of all of the packages listed in the release spec --- jest.config.js | 14 +- package.json | 20 + src/editor-utils.test.ts | 115 ++ src/editor-utils.ts | 56 + src/env-utils.test.ts | 27 + src/env-utils.ts | 16 + src/file-utils.test.ts | 297 ++++ src/file-utils.ts | 153 ++ src/git-utils.test.ts | 82 ++ src/git-utils.ts | 80 + src/index.test.ts | 9 - src/index.ts | 21 +- src/initialization-utils.test.ts | 62 + src/initialization-utils.ts | 31 + src/inputs-utils.ts | 38 + src/main.test.ts | 62 + src/main.ts | 45 + src/misc-utils.test.ts | 200 +++ src/misc-utils.ts | 184 +++ src/monorepo-workflow-utils.test.ts | 1794 +++++++++++++++++++++++ src/monorepo-workflow-utils.ts | 244 +++ src/package-manifest-utils.test.ts | 352 +++++ src/package-manifest-utils.ts | 348 +++++ src/package-utils.test.ts | 223 +++ src/package-utils.ts | 139 ++ src/project-utils.test.ts | 76 + src/project-utils.ts | 77 + src/release-specification-utils.test.ts | 417 ++++++ src/release-specification-utils.ts | 298 ++++ src/semver-utils.ts | 2 + src/workflow-utils.test.ts | 38 + src/workflow-utils.ts | 74 + tests/unit/helpers.ts | 169 +++ tsconfig.json | 9 +- yarn.lock | 135 +- 35 files changed, 5875 insertions(+), 32 deletions(-) create mode 100644 src/editor-utils.test.ts create mode 100644 src/editor-utils.ts create mode 100644 src/env-utils.test.ts create mode 100644 src/env-utils.ts create mode 100644 src/file-utils.test.ts create mode 100644 src/file-utils.ts create mode 100644 src/git-utils.test.ts create mode 100644 src/git-utils.ts delete mode 100644 src/index.test.ts create mode 100644 src/initialization-utils.test.ts create mode 100644 src/initialization-utils.ts create mode 100644 src/inputs-utils.ts create mode 100644 src/main.test.ts create mode 100644 src/main.ts create mode 100644 src/misc-utils.test.ts create mode 100644 src/misc-utils.ts create mode 100644 src/monorepo-workflow-utils.test.ts create mode 100644 src/monorepo-workflow-utils.ts create mode 100644 src/package-manifest-utils.test.ts create mode 100644 src/package-manifest-utils.ts create mode 100644 src/package-utils.test.ts create mode 100644 src/package-utils.ts create mode 100644 src/project-utils.test.ts create mode 100644 src/project-utils.ts create mode 100644 src/release-specification-utils.test.ts create mode 100644 src/release-specification-utils.ts create mode 100644 src/semver-utils.ts create mode 100644 src/workflow-utils.test.ts create mode 100644 src/workflow-utils.ts create mode 100644 tests/unit/helpers.ts diff --git a/jest.config.js b/jest.config.js index 83e1504..220d289 100644 --- a/jest.config.js +++ b/jest.config.js @@ -28,9 +28,11 @@ module.exports = { coverageDirectory: 'coverage', // An array of regexp pattern strings used to skip coverage collection - // coveragePathIgnorePatterns: [ - // "/node_modules/" - // ], + coveragePathIgnorePatterns: [ + '/node_modules/', + '/src/index.ts', + '/src/inputs-utils.ts', + ], // Indicates which provider should be used to instrument code for coverage coverageProvider: 'babel', @@ -110,7 +112,7 @@ module.exports = { resetMocks: true, // Reset the module registry before running each individual test - // resetModules: false, + resetModules: true, // A path to a custom resolver // resolver: undefined, @@ -159,9 +161,7 @@ module.exports = { // ], // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped - // testPathIgnorePatterns: [ - // "/node_modules/" - // ], + testPathIgnorePatterns: ['/node_modules/', '/src/old/'], // The regexp pattern or array of patterns that Jest uses to detect test files // testRegex: [], diff --git a/package.json b/package.json index a3cb95d..ae6f0d2 100644 --- a/package.json +++ b/package.json @@ -29,10 +29,16 @@ "@metamask/eslint-config-jest": "^9.0.0", "@metamask/eslint-config-nodejs": "^9.0.0", "@metamask/eslint-config-typescript": "^9.0.1", + "@types/debug": "^4.1.7", "@types/jest": "^28.1.4", + "@types/jest-when": "^3.5.2", "@types/node": "^17.0.23", + "@types/rimraf": "^3.0.2", + "@types/which": "^2.0.1", + "@types/yargs": "^17.0.10", "@typescript-eslint/eslint-plugin": "^4.21.0", "@typescript-eslint/parser": "^4.21.0", + "deepmerge": "^4.2.2", "eslint": "^7.23.0", "eslint-config-prettier": "^8.1.0", "eslint-plugin-import": "^2.22.1", @@ -42,9 +48,12 @@ "eslint-plugin-prettier": "^3.3.1", "jest": "^28.0.0", "jest-it-up": "^2.0.2", + "jest-when": "^3.5.1", + "nanoid": "^3.3.4", "prettier": "^2.2.1", "prettier-plugin-packagejson": "^2.2.17", "rimraf": "^3.0.2", + "stdio-mock": "^1.2.0", "ts-jest": "^28.0.0", "ts-node": "^10.7.0", "typescript": "^4.2.4" @@ -61,5 +70,16 @@ "allowScripts": { "@lavamoat/preinstall-always-fail": false } + }, + "dependencies": { + "@metamask/action-utils": "^0.0.2", + "@metamask/utils": "^2.0.0", + "debug": "^4.3.4", + "execa": "^5.0.0", + "glob": "^8.0.3", + "semver": "^7.3.7", + "which": "^2.0.2", + "yaml": "^2.1.1", + "yargs": "^17.5.1" } } diff --git a/src/editor-utils.test.ts b/src/editor-utils.test.ts new file mode 100644 index 0000000..d434a56 --- /dev/null +++ b/src/editor-utils.test.ts @@ -0,0 +1,115 @@ +import { when } from 'jest-when'; +import { determineEditor } from './editor-utils'; +import * as envUtils from './env-utils'; +import * as miscUtils from './misc-utils'; + +jest.mock('./env-utils'); +jest.mock('./misc-utils'); + +describe('editor-utils', () => { + describe('determineEditor', () => { + it('returns information about the editor from EDITOR if it resolves to an executable', async () => { + jest + .spyOn(envUtils, 'getEnvironmentVariables') + .mockReturnValue({ EDITOR: 'editor', TODAY: undefined }); + when(jest.spyOn(miscUtils, 'resolveExecutable')) + .calledWith('editor') + .mockResolvedValue('/path/to/resolved-editor'); + + expect(await determineEditor()).toStrictEqual({ + path: '/path/to/resolved-editor', + args: [], + }); + }); + + it('falls back to VSCode if it exists and if EDITOR does not point to an executable', async () => { + jest + .spyOn(envUtils, 'getEnvironmentVariables') + .mockReturnValue({ EDITOR: 'editor', TODAY: undefined }); + when(jest.spyOn(miscUtils, 'resolveExecutable')) + .calledWith('editor') + .mockResolvedValue(null) + .calledWith('code') + .mockResolvedValue('/path/to/code'); + + expect(await determineEditor()).toStrictEqual({ + path: '/path/to/code', + args: ['--wait'], + }); + }); + + it('returns null if resolving EDITOR returns null and resolving VSCode returns null', async () => { + jest + .spyOn(envUtils, 'getEnvironmentVariables') + .mockReturnValue({ EDITOR: 'editor', TODAY: undefined }); + when(jest.spyOn(miscUtils, 'resolveExecutable')) + .calledWith('editor') + .mockResolvedValue(null) + .calledWith('code') + .mockResolvedValue(null); + + expect(await determineEditor()).toBeNull(); + }); + + it('returns null if resolving EDITOR returns null and resolving VSCode throws', async () => { + jest + .spyOn(envUtils, 'getEnvironmentVariables') + .mockReturnValue({ EDITOR: 'editor', TODAY: undefined }); + when(jest.spyOn(miscUtils, 'resolveExecutable')) + .calledWith('editor') + .mockResolvedValue(null) + .calledWith('code') + .mockRejectedValue(new Error('some error')); + + expect(await determineEditor()).toBeNull(); + }); + + it('returns null if resolving EDITOR throws and resolving VSCode returns null', async () => { + jest + .spyOn(envUtils, 'getEnvironmentVariables') + .mockReturnValue({ EDITOR: 'editor', TODAY: undefined }); + when(jest.spyOn(miscUtils, 'resolveExecutable')) + .calledWith('editor') + .mockRejectedValue(new Error('some error')) + .calledWith('code') + .mockResolvedValue(null); + + expect(await determineEditor()).toBeNull(); + }); + + it('returns null if resolving EDITOR throws and resolving VSCode throws', async () => { + jest + .spyOn(envUtils, 'getEnvironmentVariables') + .mockReturnValue({ EDITOR: 'editor', TODAY: undefined }); + when(jest.spyOn(miscUtils, 'resolveExecutable')) + .calledWith('editor') + .mockRejectedValue(new Error('some error')) + .calledWith('code') + .mockRejectedValue(new Error('some error')); + + expect(await determineEditor()).toBeNull(); + }); + + it('returns null if EDITOR is unset and resolving VSCode returns null', async () => { + jest + .spyOn(envUtils, 'getEnvironmentVariables') + .mockReturnValue({ EDITOR: undefined, TODAY: undefined }); + when(jest.spyOn(miscUtils, 'resolveExecutable')) + .calledWith('code') + .mockResolvedValue(null); + + expect(await determineEditor()).toBeNull(); + }); + + it('returns null if EDITOR is unset and resolving VSCode throws', async () => { + jest + .spyOn(envUtils, 'getEnvironmentVariables') + .mockReturnValue({ EDITOR: undefined, TODAY: undefined }); + when(jest.spyOn(miscUtils, 'resolveExecutable')) + .calledWith('code') + .mockRejectedValue(new Error('some error')); + + expect(await determineEditor()).toBeNull(); + }); + }); +}); diff --git a/src/editor-utils.ts b/src/editor-utils.ts new file mode 100644 index 0000000..e18e222 --- /dev/null +++ b/src/editor-utils.ts @@ -0,0 +1,56 @@ +import { getEnvironmentVariables } from './env-utils'; +import { debug, resolveExecutable } from './misc-utils'; + +/** + * Information about the editor present on the user's computer. + * + * @property path - The path to the executable representing the editor. + * @property args - Command-line arguments to pass to the executable when + * calling it. + */ +export interface Editor { + path: string; + args: string[]; +} + +/** + * Looks for an executable that represents a code editor on your computer. Tries + * the `EDITOR` environment variable first, falling back to the executable that + * represents VSCode (`code`). + * + * @returns A promise that contains information about the found editor (path and + * arguments), or null otherwise. + */ +export async function determineEditor(): Promise { + let executablePath: string | null = null; + const executableArgs: string[] = []; + const { EDITOR } = getEnvironmentVariables(); + + if (EDITOR !== undefined) { + try { + executablePath = await resolveExecutable(EDITOR); + } catch (error) { + debug( + `Could not resolve executable ${EDITOR} (${error}), falling back to VSCode`, + ); + } + } + + if (executablePath === null) { + try { + executablePath = await resolveExecutable('code'); + // Waits until the file is closed before returning + executableArgs.push('--wait'); + } catch (error) { + debug( + `Could not resolve path to VSCode: ${error}, continuing regardless`, + ); + } + } + + if (executablePath !== null) { + return { path: executablePath, args: executableArgs }; + } + + return null; +} diff --git a/src/env-utils.test.ts b/src/env-utils.test.ts new file mode 100644 index 0000000..701e3c8 --- /dev/null +++ b/src/env-utils.test.ts @@ -0,0 +1,27 @@ +import { getEnvironmentVariables } from './env-utils'; + +describe('env-utils', () => { + describe('getEnvironmentVariables', () => { + let existingProcessEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + existingProcessEnv = { ...process.env }; + }); + + afterEach(() => { + Object.keys(existingProcessEnv).forEach((key) => { + process.env[key] = existingProcessEnv[key]; + }); + }); + + it('returns only the environment variables from process.env that we use in this tool', () => { + process.env.EDITOR = 'editor'; + process.env.TODAY = 'today'; + + expect(getEnvironmentVariables()).toStrictEqual({ + EDITOR: 'editor', + TODAY: 'today', + }); + }); + }); +}); diff --git a/src/env-utils.ts b/src/env-utils.ts new file mode 100644 index 0000000..22d8055 --- /dev/null +++ b/src/env-utils.ts @@ -0,0 +1,16 @@ +interface Env { + EDITOR: string | undefined; + TODAY: string | undefined; +} + +/** + * Returns all of the environment variables that this tool uses. + * + * @returns An object with a selection of properties from `process.env` that + * this tool needs to access, whether their values are defined or not. + */ +export function getEnvironmentVariables(): Env { + return ['EDITOR', 'TODAY'].reduce((object, key) => { + return { ...object, [key]: process.env[key] }; + }, {} as Env); +} diff --git a/src/file-utils.test.ts b/src/file-utils.test.ts new file mode 100644 index 0000000..009809c --- /dev/null +++ b/src/file-utils.test.ts @@ -0,0 +1,297 @@ +import fs from 'fs'; +import path from 'path'; +import util from 'util'; +import rimraf from 'rimraf'; +import { when } from 'jest-when'; +import * as actionUtils from '@metamask/action-utils'; +import { withSandbox } from '../tests/unit/helpers'; +import { + readFile, + writeFile, + readJsonObjectFile, + writeJsonFile, + fileExists, + ensureDirectoryPathExists, + removeFile, +} from './file-utils'; + +jest.mock('@metamask/action-utils'); + +const promisifiedRimraf = util.promisify(rimraf); + +describe('file-utils', () => { + describe('readFile', () => { + it('reads the contents of the given file as a UTF-8-encoded string', async () => { + await withSandbox(async (sandbox) => { + const filePath = path.join(sandbox.directoryPath, 'test'); + + await fs.promises.writeFile(filePath, 'some content 😄'); + + expect(await readFile(filePath)).toStrictEqual('some content 😄'); + }); + }); + + it('re-throws any error that occurs, assigning it the same code, a wrapped message, and a new stack', async () => { + await withSandbox(async (sandbox) => { + const filePath = path.join(sandbox.directoryPath, 'nonexistent'); + + await expect(readFile(filePath)).rejects.toThrow( + expect.objectContaining({ + message: expect.stringMatching( + new RegExp( + `^Could not read file '${filePath}': ENOENT: no such file or directory, open '${filePath}'`, + 'u', + ), + ), + code: 'ENOENT', + stack: expect.anything(), + }), + ); + }); + }); + }); + + describe('writeFile', () => { + it('writes the given data to the given file', async () => { + await withSandbox(async (sandbox) => { + const filePath = path.join(sandbox.directoryPath, 'test'); + + await writeFile(filePath, 'some content 😄'); + + expect(await fs.promises.readFile(filePath, 'utf8')).toStrictEqual( + 'some content 😄', + ); + }); + }); + + it('re-throws any error that occurs, assigning it the same code, a wrapped message, and a new stack', async () => { + await withSandbox(async (sandbox) => { + await promisifiedRimraf(sandbox.directoryPath); + const filePath = path.join(sandbox.directoryPath, 'test'); + + await expect(writeFile(filePath, 'some content 😄')).rejects.toThrow( + expect.objectContaining({ + message: expect.stringMatching( + new RegExp( + `^Could not write file '${filePath}': ENOENT: no such file or directory, open '${filePath}'`, + 'u', + ), + ), + code: 'ENOENT', + stack: expect.anything(), + }), + ); + }); + }); + }); + + describe('readJsonObjectFile', () => { + it('uses readJsonObjectFile from @metamask/action-utils to parse the contents of the given JSON file as an object', async () => { + const filePath = '/some/file'; + when(jest.spyOn(actionUtils, 'readJsonObjectFile')) + .calledWith(filePath) + .mockResolvedValue({ some: 'object' }); + + expect(await readJsonObjectFile(filePath)).toStrictEqual({ + some: 'object', + }); + }); + + it('re-throws any error that occurs, assigning it the same code, a wrapped message, and a new stack', async () => { + const filePath = '/some/file'; + const error: any = new Error('oops'); + error.code = 'ESOMETHING'; + error.stack = 'some stack'; + when(jest.spyOn(actionUtils, 'readJsonObjectFile')) + .calledWith(filePath) + .mockRejectedValue(error); + + await expect(readJsonObjectFile(filePath)).rejects.toThrow( + expect.objectContaining({ + message: `Could not read JSON file '${filePath}': oops`, + code: 'ESOMETHING', + stack: 'some stack', + }), + ); + }); + }); + + describe('writeJsonFile', () => { + it('uses writeJsonFile from @metamask/action-utils to write the given object to the given file as JSON', async () => { + const filePath = '/some/file'; + when(jest.spyOn(actionUtils, 'writeJsonFile')) + .calledWith(filePath, { some: 'object' }) + .mockResolvedValue(undefined); + + expect(await writeJsonFile(filePath, { some: 'object' })).toBeUndefined(); + }); + + it('re-throws any error that occurs, assigning it the same code, a wrapped message, and a new stack', async () => { + const filePath = '/some/file'; + const error: any = new Error('oops'); + error.code = 'ESOMETHING'; + error.stack = 'some stack'; + when(jest.spyOn(actionUtils, 'writeJsonFile')) + .calledWith(filePath, { some: 'object' }) + .mockRejectedValue(error); + + await expect(writeJsonFile(filePath, { some: 'object' })).rejects.toThrow( + expect.objectContaining({ + message: `Could not write JSON file '${filePath}': oops`, + code: 'ESOMETHING', + stack: 'some stack', + }), + ); + }); + }); + + describe('fileExists', () => { + it('returns true if the given path refers to an existing file', async () => { + await withSandbox(async (sandbox) => { + const filePath = path.join(sandbox.directoryPath, 'test'); + await fs.promises.writeFile(filePath, 'some content'); + + expect(await fileExists(filePath)).toBe(true); + }); + }); + + it('returns false if the given path refers to something that is not a file', async () => { + await withSandbox(async (sandbox) => { + const dirPath = path.join(sandbox.directoryPath, 'test'); + await fs.promises.mkdir(dirPath); + + expect(await fileExists(dirPath)).toBe(false); + }); + }); + + it('returns false if the given path does not refer to any existing entry', async () => { + await withSandbox(async (sandbox) => { + const filePath = path.join(sandbox.directoryPath, 'nonexistent'); + + expect(await fileExists(filePath)).toBe(false); + }); + }); + + it('re-throws any error that occurs, assigning it the same code, a wrapped message, and a new stack', async () => { + const entryPath = '/some/file'; + const error: any = new Error('oops'); + error.code = 'ESOMETHING'; + error.stack = 'some stack'; + when(jest.spyOn(fs.promises, 'stat')) + .calledWith(entryPath) + .mockRejectedValue(error); + + await expect(fileExists(entryPath)).rejects.toThrow( + expect.objectContaining({ + message: `Could not determine if file exists '${entryPath}': oops`, + code: 'ESOMETHING', + stack: 'some stack', + }), + ); + }); + }); + + describe('ensureDirectoryPathExists', () => { + it('creates directories leading up to and including the given path', async () => { + await withSandbox(async (sandbox) => { + const directoryPath = path.join( + sandbox.directoryPath, + 'foo', + 'bar', + 'baz', + ); + + await ensureDirectoryPathExists(directoryPath); + + // We don't really need this expectations, but it is here to satisfy + // ESLint + const results = await Promise.all([ + fs.promises.readdir(path.join(sandbox.directoryPath, 'foo')), + fs.promises.readdir(path.join(sandbox.directoryPath, 'foo', 'bar')), + fs.promises.readdir( + path.join(sandbox.directoryPath, 'foo', 'bar', 'baz'), + ), + ]); + expect(JSON.parse(JSON.stringify(results))).toStrictEqual([ + ['bar'], + ['baz'], + [], + ]); + }); + }); + + it('does nothing if the given directory already exists', async () => { + await withSandbox(async (sandbox) => { + const directoryPath = path.join( + sandbox.directoryPath, + 'foo', + 'bar', + 'baz', + ); + await fs.promises.mkdir(path.join(sandbox.directoryPath, 'foo')); + await fs.promises.mkdir(path.join(sandbox.directoryPath, 'foo', 'bar')); + await fs.promises.mkdir( + path.join(sandbox.directoryPath, 'foo', 'bar', 'baz'), + ); + + // We don't really need this expectations, but it is here to satisfy + // ESLint + expect(await ensureDirectoryPathExists(directoryPath)).toBeUndefined(); + }); + }); + + it('re-throws any error that occurs, assigning it the same code, a wrapped message, and a new stack', async () => { + const directoryPath = '/some/directory'; + const error: any = new Error('oops'); + error.code = 'ESOMETHING'; + error.stack = 'some stack'; + when(jest.spyOn(fs.promises, 'mkdir')) + .calledWith(directoryPath, { recursive: true }) + .mockRejectedValue(error); + + await expect(ensureDirectoryPathExists(directoryPath)).rejects.toThrow( + expect.objectContaining({ + message: `Could not create directory path '${directoryPath}': oops`, + code: 'ESOMETHING', + stack: 'some stack', + }), + ); + }); + }); + + describe('removeFile', () => { + it('removes the file at the given path', async () => { + await withSandbox(async (sandbox) => { + const filePath = path.join(sandbox.directoryPath, 'foo'); + await fs.promises.writeFile(filePath, 'some content'); + + expect(await removeFile(filePath)).toBeUndefined(); + }); + }); + + it('does nothing if the given file does not exist', async () => { + await withSandbox(async (sandbox) => { + const filePath = path.join(sandbox.directoryPath, 'foo'); + expect(await removeFile(filePath)).toBeUndefined(); + }); + }); + + it('re-throws any error that occurs, assigning it the same code, a wrapped message, and a new stack', async () => { + const filePath = '/some/file'; + const error: any = new Error('oops'); + error.code = 'ESOMETHING'; + error.stack = 'some stack'; + when(jest.spyOn(fs.promises, 'rm')) + .calledWith(filePath, { force: true }) + .mockRejectedValue(error); + + await expect(removeFile(filePath)).rejects.toThrow( + expect.objectContaining({ + message: `Could not remove file '${filePath}': oops`, + code: 'ESOMETHING', + stack: 'some stack', + }), + ); + }); + }); +}); diff --git a/src/file-utils.ts b/src/file-utils.ts new file mode 100644 index 0000000..4e70459 --- /dev/null +++ b/src/file-utils.ts @@ -0,0 +1,153 @@ +import fs from 'fs'; +import { + readJsonObjectFile as underlyingReadJsonObjectFile, + writeJsonFile as underlyingWriteJsonFile, +} from '@metamask/action-utils'; +import { wrapError, isErrorWithCode } from './misc-utils'; + +/** + * Reads the file at the given path, assuming its content is encoded as UTF-8. + * + * @param filePath - The path to the file. + * @returns The content of the file. + * @throws If reading fails in any way. + */ +export async function readFile(filePath: string): Promise { + try { + return await fs.promises.readFile(filePath, 'utf8'); + } catch (error) { + throw wrapError( + error, + ({ message }) => `Could not read file '${filePath}': ${message}`, + ); + } +} + +/** + * Writes content to the file at the given path. + * + * @param filePath - The path to the file. + * @param content - The new content of the file. + * @throws If writing fails in any way. + */ +export async function writeFile( + filePath: string, + content: string, +): Promise { + try { + await fs.promises.writeFile(filePath, content); + } catch (error) { + throw wrapError( + error, + ({ message }) => `Could not write file '${filePath}': ${message}`, + ); + } +} + +/** + * Reads the assumed JSON file at the given path, attempts to parse it, and + * returns the resulting object. + * + * Throws if failing to read or parse, or if the parsed JSON value is not a + * plain object. + * + * @param filePath - The path segments pointing to the JSON file. Will be passed + * to path.join(). + * @returns The object corresponding to the parsed JSON file. + */ +export async function readJsonObjectFile( + filePath: string, +): Promise> { + try { + return await underlyingReadJsonObjectFile(filePath); + } catch (error) { + throw wrapError( + error, + ({ message }) => `Could not read JSON file '${filePath}': ${message}`, + ); + } +} + +/** + * Attempts to write the given JSON-like value to the file at the given path. + * Adds a newline to the end of the file. + * + * @param filePath - The path to write the JSON file to, including the file + * itself. + * @param jsonValue - The JSON-like value to write to the file. Make sure that + * JSON.stringify can handle it. + */ +export async function writeJsonFile( + filePath: string, + jsonValue: unknown, +): Promise { + try { + await underlyingWriteJsonFile(filePath, jsonValue); + } catch (error) { + throw wrapError( + error, + ({ message }) => `Could not write JSON file '${filePath}': ${message}`, + ); + } +} + +/** + * 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. + */ +export 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 wrapError( + error, + ({ message }) => + `Could not determine if file exists '${entryPath}': ${message}`, + ); + } +} + +/** + * Creates the given directory along with any directories leading up to the + * directory. If the directory already exists, this is a no-op. + * + * @param directoryPath - The path to the desired directory. + * @returns What `fs.promises.mkdir` returns. + */ +export async function ensureDirectoryPathExists( + directoryPath: string, +): Promise { + try { + return await fs.promises.mkdir(directoryPath, { recursive: true }); + } catch (error) { + throw wrapError( + error, + ({ message }) => + `Could not create directory path '${directoryPath}': ${message}`, + ); + } +} + +/** + * Removes the given file, if it exists. + * + * @param filePath - The path to the file. + * @returns What `fs.promises.rm` returns. + */ +export async function removeFile(filePath: string): Promise { + try { + return await fs.promises.rm(filePath, { force: true }); + } catch (error) { + throw wrapError( + error, + ({ message }) => `Could not remove file '${filePath}': ${message}`, + ); + } +} diff --git a/src/git-utils.test.ts b/src/git-utils.test.ts new file mode 100644 index 0000000..4201879 --- /dev/null +++ b/src/git-utils.test.ts @@ -0,0 +1,82 @@ +import { when } from 'jest-when'; +import * as miscUtils from './misc-utils'; +import { + getStdoutFromGitCommandWithin, + getRepositoryHttpsUrl, +} from './git-utils'; + +jest.mock('./misc-utils'); + +describe('git-utils', () => { + describe('getStdoutFromGitCommandWithin', () => { + it('calls getStdoutFromCommand with "git" as the command, passing the given args and using the given directory as the working directory', async () => { + when(jest.spyOn(miscUtils, 'getStdoutFromCommand')) + .calledWith('git', ['foo', 'bar'], { cwd: '/path/to/repo' }) + .mockResolvedValue('the output'); + + const output = await getStdoutFromGitCommandWithin('/path/to/repo', [ + 'foo', + 'bar', + ]); + + expect(output).toStrictEqual('the output'); + }); + }); + + describe('getRepositoryHttpsUrl', () => { + it('returns the URL of the "origin" remote of the given repo if it looks like a HTTPS public GitHub repo URL', async () => { + const projectDirectoryPath = '/path/to/project'; + when(jest.spyOn(miscUtils, 'getStdoutFromCommand')) + .calledWith('git', ['config', '--get', 'remote.origin.url'], { + cwd: projectDirectoryPath, + }) + .mockResolvedValue('https://github.com/foo'); + + expect(await getRepositoryHttpsUrl(projectDirectoryPath)).toStrictEqual( + 'https://github.com/foo', + ); + }); + + it('converts a private GitHub repo URL into a public one', async () => { + const projectDirectoryPath = '/path/to/project'; + when(jest.spyOn(miscUtils, 'getStdoutFromCommand')) + .calledWith('git', ['config', '--get', 'remote.origin.url'], { + cwd: projectDirectoryPath, + }) + .mockResolvedValue('git@github.com:Foo/Bar.git'); + + expect(await getRepositoryHttpsUrl(projectDirectoryPath)).toStrictEqual( + 'https://github.com/Foo/Bar', + ); + }); + + it('throws if the URL of the "origin" remote is in an invalid format', async () => { + const projectDirectoryPath = '/path/to/project'; + when(jest.spyOn(miscUtils, 'getStdoutFromCommand')) + .calledWith('git', ['config', '--get', 'remote.origin.url'], { + cwd: projectDirectoryPath, + }) + .mockResolvedValueOnce('foo') + .mockResolvedValueOnce('http://github.com/Foo/Bar') + .mockResolvedValueOnce('https://gitbar.foo/Foo/Bar') + .mockResolvedValueOnce('git@gitbar.foo:Foo/Bar.git') + .mockResolvedValueOnce('git@github.com:Foo/Bar.foo'); + + await expect(getRepositoryHttpsUrl(projectDirectoryPath)).rejects.toThrow( + 'Unrecognized URL for git remote "origin": foo', + ); + await expect(getRepositoryHttpsUrl(projectDirectoryPath)).rejects.toThrow( + 'Unrecognized URL for git remote "origin": http://github.com/Foo/Bar', + ); + await expect(getRepositoryHttpsUrl(projectDirectoryPath)).rejects.toThrow( + 'Unrecognized URL for git remote "origin": https://gitbar.foo/Foo/Bar', + ); + await expect(getRepositoryHttpsUrl(projectDirectoryPath)).rejects.toThrow( + 'Unrecognized URL for git remote "origin": git@gitbar.foo:Foo/Bar.git', + ); + await expect(getRepositoryHttpsUrl(projectDirectoryPath)).rejects.toThrow( + 'Unrecognized URL for git remote "origin": git@github.com:Foo/Bar.foo', + ); + }); + }); +}); diff --git a/src/git-utils.ts b/src/git-utils.ts new file mode 100644 index 0000000..4128966 --- /dev/null +++ b/src/git-utils.ts @@ -0,0 +1,80 @@ +import { getStdoutFromCommand } from './misc-utils'; + +/** + * Runs a command within the given directory, obtaining the immediate output. + * + * @param directoryPath - The path to the directory. + * @param command - The command to execute. + * @param args - The positional arguments to the command. + * @returns The standard output of the command. + * @throws An execa error object if the command fails in some way. + */ +async function getStdoutFromCommandWithin( + directoryPath: string, + command: string, + args?: readonly string[] | undefined, +): Promise { + return await getStdoutFromCommand(command, args, { cwd: directoryPath }); +} + +/** + * Runs a Git command within the given directory, obtaining the immediate + * output. + * + * @param repoDirectory - The directory of the repository. + * @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. + */ +export async function getStdoutFromGitCommandWithin( + repoDirectory: string, + args: readonly string[], +) { + return await getStdoutFromCommandWithin(repoDirectory, 'git', args); +} + +/** + * Gets the HTTPS URL of the primary remote with which the given project has + * been configured. Assumes that the git config `remote.origin.url` string + * matches one of: + * + * - https://github.com/OrganizationName/RepositoryName + * - git@github.com:OrganizationName/RepositoryName.git + * + * If the URL of the "origin" remote matches neither pattern, an error is + * thrown. + * + * @param projectDirectoryPath - The path to the project directory. + * @returns The HTTPS URL of the repository, e.g. + * `https://github.com/OrganizationName/RepositoryName`. + */ +export async function getRepositoryHttpsUrl( + projectDirectoryPath: string, +): Promise { + const httpsPrefix = 'https://github.com'; + const sshPrefixRegex = /^git@github\.com:/u; + const sshPostfixRegex = /\.git$/u; + const gitConfigUrl = await getStdoutFromCommandWithin( + projectDirectoryPath, + 'git', + ['config', '--get', 'remote.origin.url'], + ); + + if (gitConfigUrl.startsWith(httpsPrefix)) { + return gitConfigUrl; + } + + // Extracts "OrganizationName/RepositoryName" from + // "git@github.com:OrganizationName/RepositoryName.git" and returns the + // corresponding HTTPS URL. + if ( + gitConfigUrl.match(sshPrefixRegex) && + gitConfigUrl.match(sshPostfixRegex) + ) { + return `${httpsPrefix}/${gitConfigUrl + .replace(sshPrefixRegex, '') + .replace(sshPostfixRegex, '')}`; + } + + throw new Error(`Unrecognized URL for git remote "origin": ${gitConfigUrl}`); +} diff --git a/src/index.test.ts b/src/index.test.ts deleted file mode 100644 index 3845788..0000000 --- a/src/index.test.ts +++ /dev/null @@ -1,9 +0,0 @@ -import greeter from '.'; - -describe('Test', () => { - it('greets', () => { - const name = 'Huey'; - const result = greeter(name); - expect(result).toStrictEqual('Hello, Huey!'); - }); -}); diff --git a/src/index.ts b/src/index.ts index 6972c11..e97044b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,18 @@ +import { main } from './main'; + /** - * Example function that returns a greeting for the given name. - * - * @param name - The name to greet. - * @returns The greeting. + * The entrypoint to this script. */ -export default function greeter(name: string): string { - return `Hello, ${name}!`; +async function index() { + await main({ + argv: process.argv, + cwd: process.cwd(), + stdout: process.stdout, + stderr: process.stderr, + }); } + +index().catch((error) => { + console.error(error.stack); + process.exit(1); +}); diff --git a/src/initialization-utils.test.ts b/src/initialization-utils.test.ts new file mode 100644 index 0000000..2722922 --- /dev/null +++ b/src/initialization-utils.test.ts @@ -0,0 +1,62 @@ +import os from 'os'; +import path from 'path'; +import { when } from 'jest-when'; +import { buildMockProject, buildMockPackage } from '../tests/unit/helpers'; +import { initialize } from './initialization-utils'; +import * as inputsUtils from './inputs-utils'; +import * as projectUtils from './project-utils'; + +jest.mock('./inputs-utils'); +jest.mock('./project-utils'); + +describe('initialize', () => { + it('returns an object that contains data necessary to run the workflow', async () => { + const project = buildMockProject(); + when(jest.spyOn(inputsUtils, 'readInputs')) + .calledWith(['arg1', 'arg2']) + .mockResolvedValue({ + projectDirectory: '/path/to/project', + tempDirectory: '/path/to/temp', + reset: true, + }); + when(jest.spyOn(projectUtils, 'readProject')) + .calledWith('/path/to/project') + .mockResolvedValue(project); + + const config = await initialize(['arg1', 'arg2'], '/path/to/somewhere'); + + expect(config).toStrictEqual({ + project, + tempDirectoryPath: '/path/to/temp', + reset: true, + }); + }); + + it('uses a default temporary directory based on the name of the package if no such directory was passed as an input', async () => { + const project = buildMockProject({ + rootPackage: buildMockPackage('@foo/bar'), + }); + when(jest.spyOn(inputsUtils, 'readInputs')) + .calledWith(['arg1', 'arg2']) + .mockResolvedValue({ + projectDirectory: '/path/to/project', + tempDirectory: undefined, + reset: true, + }); + when(jest.spyOn(projectUtils, 'readProject')) + .calledWith('/path/to/project') + .mockResolvedValue(project); + + const config = await initialize(['arg1', 'arg2'], '/path/to/somewhere'); + + expect(config).toStrictEqual({ + project, + tempDirectoryPath: path.join( + os.tmpdir(), + 'create-release-branch', + '@foo__bar', + ), + reset: true, + }); + }); +}); diff --git a/src/initialization-utils.ts b/src/initialization-utils.ts new file mode 100644 index 0000000..74ecee4 --- /dev/null +++ b/src/initialization-utils.ts @@ -0,0 +1,31 @@ +import os from 'os'; +import path from 'path'; +import { readProject, Project } from './project-utils'; +import { readInputs } from './inputs-utils'; + +/** + * Reads the inputs given to this script via `process.argv` and uses them to + * gather data we can use to proceed. + * + * @param argv - The arguments to this script. + * @param cwd - The directory in which this script was executed. + * @returns Information we need to proceed with the script. + */ +export async function initialize( + argv: string[], + cwd: string, +): Promise<{ project: Project; tempDirectoryPath: string; reset: boolean }> { + const inputs = await readInputs(argv); + const projectDirectoryPath = path.resolve(cwd, inputs.projectDirectory); + const project = await readProject(projectDirectoryPath); + const tempDirectoryPath = + inputs.tempDirectory === undefined + ? path.join( + os.tmpdir(), + 'create-release-branch', + project.rootPackage.manifest.name.replace('/', '__'), + ) + : path.resolve(cwd, inputs.tempDirectory); + + return { project, tempDirectoryPath, reset: inputs.reset }; +} diff --git a/src/inputs-utils.ts b/src/inputs-utils.ts new file mode 100644 index 0000000..91c149d --- /dev/null +++ b/src/inputs-utils.ts @@ -0,0 +1,38 @@ +import yargs from 'yargs/yargs'; +import { hideBin } from 'yargs/helpers'; + +export interface Inputs { + projectDirectory: string; + tempDirectory: string | undefined; + reset: boolean; +} + +/** + * Parse the arguments provided on the command line. + * + * @param argv - The name of this script and its arguments (as obtained via + * `process.argv`). + * @returns A promise for the `yargs` arguments object. + */ +export async function readInputs(argv: string[]): Promise { + return await yargs(hideBin(argv)) + .usage('This script generates a release PR.') + .option('project-directory', { + alias: 'd', + describe: 'The directory that holds your project.', + default: '.', + }) + .option('temp-directory', { + describe: + 'The directory that is used to hold temporary files, such as the release spec template.', + type: 'string', + }) + .option('reset', { + describe: + 'Removes any cached files from a previous run that may have been created.', + type: 'boolean', + default: false, + }) + .help() + .parse(); +} diff --git a/src/main.test.ts b/src/main.test.ts new file mode 100644 index 0000000..ead89bd --- /dev/null +++ b/src/main.test.ts @@ -0,0 +1,62 @@ +import fs from 'fs'; +import { buildMockProject } from '../tests/unit/helpers'; +import * as initializationUtils from './initialization-utils'; +import * as monorepoWorkflowUtils from './monorepo-workflow-utils'; +import { main } from './main'; + +jest.mock('./initialization-utils'); +jest.mock('./monorepo-workflow-utils'); + +describe('main', () => { + it('executes the monorepo workflow if the project is a monorepo', async () => { + const project = buildMockProject({ isMonorepo: true }); + const stdout = fs.createWriteStream('/dev/null'); + const stderr = fs.createWriteStream('/dev/null'); + jest.spyOn(initializationUtils, 'initialize').mockResolvedValue({ + project, + tempDirectoryPath: '/path/to/temp/directory', + reset: false, + }); + const followMonorepoWorkflowSpy = jest + .spyOn(monorepoWorkflowUtils, 'followMonorepoWorkflow') + .mockResolvedValue(); + + await main({ + argv: [], + cwd: '/path/to/somewhere', + stdout, + stderr, + }); + + expect(followMonorepoWorkflowSpy).toHaveBeenCalledWith({ + project, + tempDirectoryPath: '/path/to/temp/directory', + firstRemovingExistingReleaseSpecification: false, + stdout, + stderr, + }); + }); + + it('executes the polyrepo workflow if the project is within a polyrepo', async () => { + const project = buildMockProject({ isMonorepo: false }); + const stdout = fs.createWriteStream('/dev/null'); + const stderr = fs.createWriteStream('/dev/null'); + jest.spyOn(initializationUtils, 'initialize').mockResolvedValue({ + project, + tempDirectoryPath: '/path/to/temp/directory', + reset: false, + }); + const followMonorepoWorkflowSpy = jest + .spyOn(monorepoWorkflowUtils, 'followMonorepoWorkflow') + .mockResolvedValue(); + + await main({ + argv: [], + cwd: '/path/to/somewhere', + stdout, + stderr, + }); + + expect(followMonorepoWorkflowSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..2fd500a --- /dev/null +++ b/src/main.ts @@ -0,0 +1,45 @@ +import type { WriteStream } from 'fs'; +import { initialize } from './initialization-utils'; +import { followMonorepoWorkflow } from './monorepo-workflow-utils'; + +/** + * The main function for this script. + * + * @param args - The arguments. + * @param args.argv - The name of this script and its arguments (as obtained via + * `process.argv`). + * @param args.cwd - The directory in which this script was executed. + * @param args.stdout - A stream that can be used to write to standard out. + * @param args.stderr - A stream that can be used to write to standard error. + */ +export async function main({ + argv, + cwd, + stdout, + stderr, +}: { + argv: string[]; + cwd: string; + stdout: Pick; + stderr: Pick; +}) { + const { project, tempDirectoryPath, reset } = await initialize(argv, cwd); + + if (project.isMonorepo) { + stdout.write( + 'Project appears to have workspaces. Following monorepo workflow.\n', + ); + await followMonorepoWorkflow({ + project, + tempDirectoryPath, + firstRemovingExistingReleaseSpecification: reset, + stdout, + stderr, + }); + } else { + stdout.write( + 'Project does not appear to have any workspaces. Following polyrepo workflow.\n', + ); + // TODO + } +} diff --git a/src/misc-utils.test.ts b/src/misc-utils.test.ts new file mode 100644 index 0000000..fe3d200 --- /dev/null +++ b/src/misc-utils.test.ts @@ -0,0 +1,200 @@ +import * as whichModule from 'which'; +import * as execaModule from 'execa'; +import { + isErrorWithCode, + isErrorWithMessage, + isErrorWithStack, + wrapError, + knownKeysOf, + resolveExecutable, + getStdoutFromCommand, + runCommand, +} from './misc-utils'; + +jest.mock('which'); +jest.mock('execa'); + +describe('misc-utils', () => { + describe('isErrorWithCode', () => { + it('returns true if given an object with a "code" property', () => { + expect(isErrorWithCode({ code: 'some code' })).toBe(true); + }); + + it('returns false if given null', () => { + expect(isErrorWithCode(null)).toBe(false); + }); + + it('returns false if given undefined', () => { + expect(isErrorWithCode(undefined)).toBe(false); + }); + + it('returns false if given something that is not typeof object', () => { + expect(isErrorWithCode(12345)).toBe(false); + }); + + it('returns false if given an object that does not have a "code" property', () => { + expect(isErrorWithCode({})).toBe(false); + }); + }); + + describe('isErrorWithMessage', () => { + it('returns true if given an object with a "message" property', () => { + expect(isErrorWithMessage({ message: 'some message' })).toBe(true); + }); + + it('returns false if given null', () => { + expect(isErrorWithMessage(null)).toBe(false); + }); + + it('returns false if given undefined', () => { + expect(isErrorWithMessage(undefined)).toBe(false); + }); + + it('returns false if given something that is not typeof object', () => { + expect(isErrorWithMessage(12345)).toBe(false); + }); + + it('returns false if given an object that does not have a "message" property', () => { + expect(isErrorWithMessage({})).toBe(false); + }); + }); + + describe('isErrorWithStack', () => { + it('returns true if given an object with a "stack" property', () => { + expect(isErrorWithStack({ stack: 'some stack' })).toBe(true); + }); + + it('returns false if given null', () => { + expect(isErrorWithStack(null)).toBe(false); + }); + + it('returns false if given undefined', () => { + expect(isErrorWithStack(undefined)).toBe(false); + }); + + it('returns false if given something that is not typeof object', () => { + expect(isErrorWithStack(12345)).toBe(false); + }); + + it('returns false if given an object that does not have a "stack" property', () => { + expect(isErrorWithStack({})).toBe(false); + }); + }); + + describe('wrapError', () => { + it('wraps the given error object by prepending the given prefix to its message', () => { + const error = new Error('Some message'); + + expect( + wrapError(error, ({ message }) => `Some prefix: ${message}`), + ).toMatchObject({ + message: 'Some prefix: Some message', + }); + }); + + it('returns a new error object that retains the "code" property of the original error object', () => { + const error: any = new Error('foo'); + error.code = 'ESOMETHING'; + + expect(wrapError(error)).toMatchObject({ + code: 'ESOMETHING', + }); + }); + + it('returns a new error object that retains the "stack" property of the original error object', () => { + const error: any = new Error('foo'); + error.stack = 'some stack'; + + expect(wrapError(error)).toMatchObject({ + stack: 'some stack', + }); + }); + + it('wraps the given string by prepending the given prefix to it', () => { + expect( + wrapError('Some message', ({ message }) => `Some prefix: ${message}`), + ).toMatchObject({ + message: 'Some prefix: Some message', + }); + }); + }); + + describe('knownKeysOf', () => { + it('returns the keys of an object', () => { + const object = { + foo: 'bar', + baz: 'qux', + fizz: 'buzz', + }; + expect(knownKeysOf(object)).toStrictEqual(['foo', 'baz', 'fizz']); + }); + }); + + describe('resolveExecutable', () => { + it('returns the fullpath of the given executable as returned by "which"', async () => { + jest + .spyOn(whichModule, 'default') + .mockResolvedValue('/path/to/executable'); + + expect(await resolveExecutable('executable')).toStrictEqual( + '/path/to/executable', + ); + }); + + it('returns null if the given executable cannot be found', async () => { + jest + .spyOn(whichModule, 'default') + .mockRejectedValue(new Error('not found: executable')); + + expect(await resolveExecutable('executable')).toBeNull(); + }); + + it('throws the error that "which" throws if it is not a "not found" error', async () => { + jest + .spyOn(whichModule, 'default') + .mockRejectedValue(new Error('something else')); + + await expect(resolveExecutable('executable')).rejects.toThrow( + 'something else', + ); + }); + }); + + describe('getStdoutFromCommand', () => { + it('executes the given command and returns a version of the standard out from the command with whitespace trimmed', async () => { + const execaSpy = jest + .spyOn(execaModule, 'default') + // Typecast: It's difficult to provide a full return value for execa + .mockResolvedValue({ stdout: ' some output ' } as any); + + const output = await getStdoutFromCommand( + 'some command', + ['arg1', 'arg2'], + { all: true }, + ); + + expect(execaSpy).toHaveBeenCalledWith('some command', ['arg1', 'arg2'], { + all: true, + }); + expect(output).toStrictEqual('some output'); + }); + }); + + describe('runCommand', () => { + it('runs the command, discarding its output', async () => { + const execaSpy = jest + .spyOn(execaModule, 'default') + // Typecast: It's difficult to provide a full return value for execa + .mockResolvedValue({ stdout: ' some output ' } as any); + + const result = await runCommand('some command', ['arg1', 'arg2'], { + all: true, + }); + + expect(execaSpy).toHaveBeenCalledWith('some command', ['arg1', 'arg2'], { + all: true, + }); + expect(result).toBeUndefined(); + }); + }); +}); diff --git a/src/misc-utils.ts b/src/misc-utils.ts new file mode 100644 index 0000000..a3cfe59 --- /dev/null +++ b/src/misc-utils.ts @@ -0,0 +1,184 @@ +import which from 'which'; +import execa from 'execa'; +import createDebug from 'debug'; + +export { isTruthyString } from '@metamask/action-utils'; +export { hasProperty, isNullOrUndefined, isObject } from '@metamask/utils'; + +/** + * A logger object for the implementation part of this project. + * + * @see The [debug](https://www.npmjs.com/package/debug) package. + */ +export const debug = createDebug('create-release-branch:impl'); + +/** + * 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. + */ +export type Unrequire = Omit & { + [P in K]+?: T[P]; +}; + +/** + * 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; +} + +/** + * Type guard for determining whether the given value is an error object with a + * `message` property, such as an instance of Error. + * + * @param error - The object to check. + * @returns True or false, depending on the result. + */ +export function isErrorWithMessage( + error: unknown, +): error is { message: string } { + return typeof error === 'object' && error !== null && 'message' in error; +} + +/** + * Type guard for determining whether the given value is an error object with a + * `stack` property, such as an instance of Error. + * + * @param error - The object to check. + * @returns True or false, depending on the result. + */ +export function isErrorWithStack(error: unknown): error is { stack: string } { + return typeof error === 'object' && error !== null && 'stack' in error; +} + +/** + * Builds a new error object by optionally prepending a prefix or appending a + * suffix to the error's message (or the error itself, it is a string). Retains + * the `code` and `stack` of the original error object if they exist. + * + * This function is useful to reframe error messages in general, but is + * _critical_ when interacting with any of Node's filesystem functions as + * provided via `fs.promises`, because these do not produce stack traces in the + * case of an I/O error (see ). + * + * @param errorLike - Any value that can be thrown. + * @param buildMessage - A function that can be used to build the message of the + * new error object. It's passed an object that has a `message` property, and + * returns that `message` by default. + * @returns A new error object. + */ +export function wrapError( + errorLike: unknown, + buildMessage: (props: { message: string }) => string = (props) => + props.message, +) { + const message = isErrorWithMessage(errorLike) + ? errorLike.message + : String(errorLike); + const code = isErrorWithCode(errorLike) ? errorLike.code : undefined; + const stack = isErrorWithStack(errorLike) ? errorLike.stack : undefined; + const errorWithStack: Error & { + code?: string | undefined; + stack?: string | undefined; + } = new Error(buildMessage({ message })); + + if (code !== undefined) { + errorWithStack.code = code; + } + + if (stack !== undefined) { + errorWithStack.stack = stack; + } + + return errorWithStack; +} + +/** + * `Object.keys()` is intentionally generic: it returns the keys of an object, + * but it cannot make guarantees about the contents of that object, so the type + * of the keys is merely `string[]`. While this is technically accurate, it is + * also unnecessary if we have an object that we own and whose contents are + * known exactly. + * + * Note: This function will not work when given an object where any of the keys + * are optional. + * + * @param object - The object. + * @returns The keys of an object, typed according to the type of the object + * itself. + */ +export function knownKeysOf( + object: Record, +) { + return Object.keys(object) as K[]; +} + +/** + * Tests the given path to determine whether it represents an executable. + * + * @param executablePath - The path to an executable. + * @returns A promise for true or false, depending on the result. + */ +export async function resolveExecutable( + executablePath: string, +): Promise { + try { + return await which(executablePath); + } catch (error) { + if ( + isErrorWithMessage(error) && + new RegExp(`^not found: ${executablePath}$`, 'u').test(error.message) + ) { + return null; + } + + throw error; + } +} + +/** + * Runs a command, retrieving the standard output with leading and trailing + * whitespace removed. + * + * @param command - The command to execute. + * @param args - The positional arguments to the command. + * @param options - The options to `execa`. + * @returns The standard output of the command. + * @throws An `execa` error object if the command fails in some way. + * @see `execa`. + */ +export async function getStdoutFromCommand( + command: string, + args?: readonly string[] | undefined, + options?: execa.Options | undefined, +): Promise { + return (await execa(command, args, options)).stdout.trim(); +} + +/** + * Runs a command, discarding its output. + * + * @param command - The command to execute. + * @param args - The positional arguments to the command. + * @param options - The options to `execa`. + * @throws An `execa` error object if the command fails in some way. + * @see `execa`. + */ +export async function runCommand( + command: string, + args?: readonly string[] | undefined, + options?: execa.Options | undefined, +): Promise { + await execa(command, args, options); +} diff --git a/src/monorepo-workflow-utils.test.ts b/src/monorepo-workflow-utils.test.ts new file mode 100644 index 0000000..9ec281d --- /dev/null +++ b/src/monorepo-workflow-utils.test.ts @@ -0,0 +1,1794 @@ +import fs from 'fs'; +import path from 'path'; +import { SemVer } from 'semver'; +import { + withSandbox, + buildMockPackage, + buildMockProject, +} from '../tests/unit/helpers'; +import { followMonorepoWorkflow } from './monorepo-workflow-utils'; +import * as editorUtils from './editor-utils'; +import * as envUtils from './env-utils'; +import * as packageUtils from './package-utils'; +import type { Package } from './package-utils'; +import type { ValidatedManifest } from './package-manifest-utils'; +import type { Project } from './project-utils'; +import * as releaseSpecificationUtils from './release-specification-utils'; +import * as workflowUtils from './workflow-utils'; + +jest.mock('./editor-utils'); +jest.mock('./env-utils'); +jest.mock('./package-utils'); +jest.mock('./release-specification-utils'); +jest.mock('./workflow-utils'); + +/** + * Given a Promise type, returns the type inside. + */ +type UnwrapPromise = T extends Promise ? U : never; + +describe('monorepo-workflow-utils', () => { + 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', { + manifest: { + private: true, + workspaces: ['packages/*'], + }, + }), + workspacePackages: { + a: buildMockPackage('a', '1.0.0', { + manifest: { + private: false, + }, + }), + b: buildMockPackage('b', '1.0.0', { + manifest: { + private: false, + }, + }), + c: buildMockPackage('c', '1.0.0', { + manifest: { + private: false, + }, + }), + d: buildMockPackage('d', '1.0.0', { + manifest: { + 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: releaseSpecificationUtils.IncrementableVersionParts + .major, + b: releaseSpecificationUtils.IncrementableVersionParts + .minor, + c: releaseSpecificationUtils.IncrementableVersionParts + .patch, + d: new SemVer('1.2.3'), + }, + }, + }); + + await followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: true, + stdout, + stderr, + }); + + expect( + generateReleaseSpecificationTemplateForMonorepoSpy, + ).toHaveBeenCalled(); + expect(updatePackageSpy).toHaveBeenNthCalledWith(1, { + project, + packageReleasePlan: { + package: project.rootPackage, + newVersion: '2022.6.12', + shouldUpdateChangelog: false, + }, + stderr, + }); + expect(updatePackageSpy).toHaveBeenNthCalledWith(2, { + project, + packageReleasePlan: { + package: project.workspacePackages.a, + newVersion: '2.0.0', + shouldUpdateChangelog: true, + }, + stderr, + }); + expect(updatePackageSpy).toHaveBeenNthCalledWith(3, { + project, + packageReleasePlan: { + package: project.workspacePackages.b, + newVersion: '1.1.0', + shouldUpdateChangelog: true, + }, + stderr, + }); + expect(updatePackageSpy).toHaveBeenNthCalledWith(4, { + project, + packageReleasePlan: { + package: project.workspacePackages.c, + newVersion: '1.0.1', + shouldUpdateChangelog: true, + }, + stderr, + }); + expect(updatePackageSpy).toHaveBeenNthCalledWith(5, { + project, + packageReleasePlan: { + package: project.workspacePackages.d, + newVersion: '1.2.3', + shouldUpdateChangelog: true, + }, + stderr, + }); + }); + }); + + it('creates a new branch named after the generated release version', async () => { + await withSandbox(async (sandbox) => { + const project = buildMockMonorepoProject({ + rootPackage: buildMockPackage('root', '2022.1.1', { + manifest: { + private: true, + workspaces: ['packages/*'], + }, + }), + workspacePackages: { + a: buildMockPackage('a', '1.0.0', { + manifest: { + 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: releaseSpecificationUtils.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, + }, + ], + }, + ); + }); + }); + + 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); + }); + }); + + 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', { + manifest: { + private: true, + workspaces: ['packages/*'], + }, + }), + workspacePackages: { + a: buildMockPackage('a', '1.0.0', { + manifest: { + 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 apply version specifier "1.0.0" to package "a" because the current and new versions would end up being the same./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', { + manifest: { + private: true, + workspaces: ['packages/*'], + }, + }), + workspacePackages: { + a: buildMockPackage('a', '1.0.0', { + manifest: { + 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'); + + try { + await followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: true, + stdout, + stderr, + }); + } catch { + // ignore the error + } + + expect(await fs.promises.stat(releaseSpecPath)).toStrictEqual( + expect.anything(), + ); + }); + }); + }); + + 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( + releaseSpecificationUtils, + 'waitForUserToEditReleaseSpecification', + ) + .mockRejectedValue(new Error('oops')); + + try { + await followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: true, + stdout, + stderr, + }); + } catch { + // ignore the error above + } + + await expect( + fs.promises.readFile( + path.join(sandbox.directoryPath, 'RELEASE_SPEC'), + 'utf8', + ), + ).rejects.toThrow(/^ENOENT: no such file or directory/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: true, + stdout, + stderr, + }); + + expect( + generateReleaseSpecificationTemplateForMonorepoSpy, + ).toHaveBeenCalled(); + expect( + waitForUserToEditReleaseSpecificationSpy, + ).not.toHaveBeenCalled(); + expect(validateReleaseSpecificationSpy).not.toHaveBeenCalled(); + expect(updatePackageSpy).not.toHaveBeenCalled(); + expect(captureChangesInReleaseBranchSpy).not.toHaveBeenCalled(); + }); + }); + }); + }); + + describe('when a release spec file already exists', () => { + describe('when an editor can be determined', () => { + describe('when the editor command completes successfully', () => { + it('re-generates the release spec, waits for the user to edit it, then applies it to the monorepo', async () => { + await withSandbox(async (sandbox) => { + const project = buildMockMonorepoProject({ + rootPackage: buildMockPackage('root', '2022.1.1', { + manifest: { + private: true, + workspaces: ['packages/*'], + }, + }), + workspacePackages: { + a: buildMockPackage('a', '1.0.0', { + manifest: { + 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: releaseSpecificationUtils.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('creates a new branch named after the generated release version', async () => { + await withSandbox(async (sandbox) => { + const project = buildMockMonorepoProject({ + rootPackage: buildMockPackage('root', '2022.1.1', { + manifest: { + private: true, + workspaces: ['packages/*'], + }, + }), + workspacePackages: { + a: buildMockPackage('a', '1.0.0', { + manifest: { + 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: releaseSpecificationUtils.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, + }, + ], + }, + ); + }); + }); + + 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); + }); + }); + + 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', { + manifest: { + private: true, + workspaces: ['packages/*'], + }, + }), + workspacePackages: { + a: buildMockPackage('a', '1.0.0', { + manifest: { + 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 apply version specifier "1.0.0" to package "a" because the current and new versions would end up being the same./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', { + manifest: { + private: true, + workspaces: ['packages/*'], + }, + }), + workspacePackages: { + a: buildMockPackage('a', '1.0.0', { + manifest: { + 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'); + + try { + await followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: true, + stdout, + stderr, + }); + } catch { + // ignore the error + } + + expect(await fs.promises.stat(releaseSpecPath)).toStrictEqual( + expect.anything(), + ); + }); + }); + }); + + 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( + releaseSpecificationUtils, + 'waitForUserToEditReleaseSpecification', + ) + .mockRejectedValue(new Error('oops')); + const releaseSpecPath = path.join( + sandbox.directoryPath, + 'RELEASE_SPEC', + ); + await fs.promises.writeFile(releaseSpecPath, 'release spec'); + + try { + await followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: true, + stdout, + stderr, + }); + } catch { + // ignore the error above + } + + await expect( + fs.promises.readFile(releaseSpecPath, 'utf8'), + ).rejects.toThrow(/^ENOENT: no such file or directory/u); + }); + }); + }); + }); + + describe('when an editor cannot be determined', () => { + it('merely re-generates a release spec and nothing more', async () => { + await withSandbox(async (sandbox) => { + const project = buildMockMonorepoProject(); + const stdout = fs.createWriteStream('/dev/null'); + const stderr = fs.createWriteStream('/dev/null'); + const { + generateReleaseSpecificationTemplateForMonorepoSpy, + waitForUserToEditReleaseSpecificationSpy, + validateReleaseSpecificationSpy, + updatePackageSpy, + captureChangesInReleaseBranchSpy, + } = mockDependencies({ + determineEditor: null, + }); + const releaseSpecPath = path.join( + sandbox.directoryPath, + 'RELEASE_SPEC', + ); + await fs.promises.writeFile(releaseSpecPath, 'release spec'); + + await followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: true, + stdout, + stderr, + }); + + expect( + generateReleaseSpecificationTemplateForMonorepoSpy, + ).toHaveBeenCalled(); + expect( + waitForUserToEditReleaseSpecificationSpy, + ).not.toHaveBeenCalled(); + expect(validateReleaseSpecificationSpy).not.toHaveBeenCalled(); + expect(updatePackageSpy).not.toHaveBeenCalled(); + expect(captureChangesInReleaseBranchSpy).not.toHaveBeenCalled(); + }); + }); + }); + }); + }); + + 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', { + manifest: { + private: true, + workspaces: ['packages/*'], + }, + }), + workspacePackages: { + a: buildMockPackage('a', '1.0.0', { + manifest: { + private: false, + }, + }), + b: buildMockPackage('b', '1.0.0', { + manifest: { + private: false, + }, + }), + c: buildMockPackage('c', '1.0.0', { + manifest: { + private: false, + }, + }), + d: buildMockPackage('d', '1.0.0', { + manifest: { + 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: releaseSpecificationUtils.IncrementableVersionParts + .major, + b: releaseSpecificationUtils.IncrementableVersionParts + .minor, + c: releaseSpecificationUtils.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('creates a new branch named after the generated release version', async () => { + await withSandbox(async (sandbox) => { + const project = buildMockMonorepoProject({ + rootPackage: buildMockPackage('root', '2022.1.1', { + manifest: { + private: true, + workspaces: ['packages/*'], + }, + }), + workspacePackages: { + a: buildMockPackage('a', '1.0.0', { + manifest: { + 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: releaseSpecificationUtils.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, + }, + ], + }, + ); + }); + }); + + 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); + }); + }); + + 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', { + manifest: { + private: true, + workspaces: ['packages/*'], + }, + }), + workspacePackages: { + a: buildMockPackage('a', '1.0.0', { + manifest: { + 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 apply version specifier "1.0.0" to package "a" because the current and new versions would end up being the same./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', { + manifest: { + private: true, + workspaces: ['packages/*'], + }, + }), + workspacePackages: { + a: buildMockPackage('a', '1.0.0', { + manifest: { + 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'), + }, + }, + }); + + try { + await followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: false, + stdout, + stderr, + }); + } catch { + // ignore the error + } + + expect( + await fs.promises.stat( + path.join(sandbox.directoryPath, 'RELEASE_SPEC'), + ), + ).toStrictEqual(expect.anything()); + }); + }); + }); + + 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( + releaseSpecificationUtils, + 'waitForUserToEditReleaseSpecification', + ) + .mockRejectedValue(new Error('oops')); + + try { + await followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: false, + stdout, + stderr, + }); + } catch { + // ignore the error above + } + + await expect( + fs.promises.readFile( + path.join(sandbox.directoryPath, 'RELEASE_SPEC'), + 'utf8', + ), + ).rejects.toThrow(/^ENOENT: no such file or directory/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(); + }); + }); + }); + }); + + describe('when a release spec file already exists', () => { + describe('when the editor command completes successfully', () => { + 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', { + manifest: { + private: true, + workspaces: ['packages/*'], + }, + }), + workspacePackages: { + a: buildMockPackage('a', '1.0.0', { + manifest: { + private: false, + }, + }), + }, + }); + 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: releaseSpecificationUtils.IncrementableVersionParts + .major, + }, + }, + }); + const releaseSpecPath = path.join( + sandbox.directoryPath, + 'RELEASE_SPEC', + ); + await fs.promises.writeFile(releaseSpecPath, 'release spec'); + + await followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: false, + stdout, + stderr, + }); + + expect( + generateReleaseSpecificationTemplateForMonorepoSpy, + ).not.toHaveBeenCalled(); + expect( + waitForUserToEditReleaseSpecificationSpy, + ).not.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('creates a new branch named after the generated release version', async () => { + await withSandbox(async (sandbox) => { + const project = buildMockMonorepoProject({ + rootPackage: buildMockPackage('root', '2022.1.1', { + manifest: { + private: true, + workspaces: ['packages/*'], + }, + }), + workspacePackages: { + a: buildMockPackage('a', '1.0.0', { + manifest: { + 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: releaseSpecificationUtils.IncrementableVersionParts + .major, + }, + }, + }); + const releaseSpecPath = path.join( + sandbox.directoryPath, + 'RELEASE_SPEC', + ); + await fs.promises.writeFile(releaseSpecPath, 'release spec'); + + 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, + }, + ], + }, + ); + }); + }); + + 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: false, + stdout, + stderr, + }); + + await expect( + fs.promises.readFile(releaseSpecPath, 'utf8'), + ).rejects.toThrow(/^ENOENT: no such file or directory/u); + }); + }); + + 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', { + manifest: { + private: true, + workspaces: ['packages/*'], + }, + }), + workspacePackages: { + a: buildMockPackage('a', '1.0.0', { + manifest: { + private: false, + }, + }), + }, + }); + const stdout = fs.createWriteStream('/dev/null'); + const stderr = fs.createWriteStream('/dev/null'); + mockDependencies({ + determineEditor: { + path: '/some/editor', + args: [], + }, + getEnvironmentVariables: { + TODAY: '2022-06-12', + }, + validateReleaseSpecification: { + packages: { + a: new SemVer('1.0.0'), + }, + }, + }); + const releaseSpecPath = path.join( + sandbox.directoryPath, + 'RELEASE_SPEC', + ); + await fs.promises.writeFile(releaseSpecPath, 'release spec'); + + await expect( + followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: false, + stdout, + stderr, + }), + ).rejects.toThrow( + /^Could not apply version specifier "1.0.0" to package "a" because the current and new versions would end up being the same./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', { + manifest: { + private: true, + workspaces: ['packages/*'], + }, + }), + workspacePackages: { + a: buildMockPackage('a', '1.0.0', { + manifest: { + 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'); + + try { + await followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: false, + stdout, + stderr, + }); + } catch { + // ignore the error + } + + expect(await fs.promises.stat(releaseSpecPath)).toStrictEqual( + expect.anything(), + ); + }); + }); + }); + + 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( + releaseSpecificationUtils, + 'waitForUserToEditReleaseSpecification', + ) + .mockRejectedValue(new Error('oops')); + const releaseSpecPath = path.join( + sandbox.directoryPath, + 'RELEASE_SPEC', + ); + await fs.promises.writeFile(releaseSpecPath, 'release spec'); + + try { + await followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: false, + stdout, + stderr, + }); + } catch { + // ignore the error above + } + + await expect( + fs.promises.readFile(releaseSpecPath, 'utf8'), + ).rejects.toThrow(/^ENOENT: no such file or directory/u); + }); + }); + }); + }); + }); + }); +}); + +/** + * Builds a project for use in tests, where `directoryPath` and `repositoryUrl` + * do not have to be provided (they are filled in with reasonable defaults). + * + * @param overrides - The properties that will go into the object. + * @returns The mock Project object. + */ +/* +function buildMockProject( + overrides: Unrequire, +): Project { + const { + directoryPath = '/path/to/project', + repositoryUrl = 'https://repo.url', + ...rest + } = overrides; + + return { + directoryPath, + repositoryUrl, + ...rest, + }; +} +*/ + +/** + * 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, + }); +} + +/** + * Builds a package for use in tests, where `directoryPath`, `manifestPath`, and + * `changelogPath` do not have to be provided (they are filled in with + * reasonable defaults), and where some fields in `manifest` is prefilled based + * on `name` and `version`. + * + * TODO: Reuse helper in `helpers.ts`. + * + * @param name - The name of the package. + * @param version - The version of the package, as a version string. + * @param overrides - The properties that will go into the object. + * @returns The mock Package object. + */ +/* +function buildMockPackage( + name: string, + version: string, + overrides: Omit< + Unrequire, + 'manifest' + > & { + manifest: Omit< + Unrequire< + ValidatedManifest, + | packageManifestUtils.ManifestFieldNames.Workspaces + | packageManifestUtils.ManifestDependencyFieldNames + >, + | packageManifestUtils.ManifestFieldNames.Name + | packageManifestUtils.ManifestFieldNames.Version + >; + }, +): Package { + const { + directoryPath = `/path/to/packages/${name}`, + manifest, + manifestPath = path.join(directoryPath, 'package.json'), + changelogPath = path.join(directoryPath, 'CHANGELOG.md'), + ...rest + } = overrides; + + return { + directoryPath, + manifest: buildMockManifest({ + ...manifest, + [packageManifestUtils.ManifestFieldNames.Name]: name, + [packageManifestUtils.ManifestFieldNames.Version]: new SemVer(version), + }), + manifestPath, + changelogPath, + ...rest, + }; +} +*/ + +/** + * Builds a package for use in tests which is designed to be the root package of + * a monorepo. + * + * @param name - The name of the package. + * @param version - The version of the package, as a version string. + * @param overrides - The properties that will go into the object. + * @returns The mock Package object. + */ +function buildMockMonorepoRootPackage( + name = 'root', + version = '2022.1.1', + overrides: Omit, 'manifest'> & { + manifest?: Partial; + } = {}, +) { + const { manifest, ...rest } = overrides; + return buildMockPackage(name, version, { + manifest: { + private: true, + workspaces: ['packages/*'], + ...manifest, + }, + ...rest, + }); +} + +/** + * Builds a manifest object for use in tests, where `workspaces` and + * `*Dependencies` fields do not have to be provided (they are filled in with + * empty collections by default). + * + * TODO: Reuse helper in `helpers.ts`. + * + * @param overrides - The properties that will go into the object. + * @returns The mock ValidatedManifest object. + */ +/* +function buildMockManifest( + overrides: Unrequire< + ValidatedManifest, + | packageManifestUtils.ManifestFieldNames.Workspaces + | packageManifestUtils.ManifestDependencyFieldNames + >, +): ValidatedManifest { + const { + workspaces = [], + dependencies = {}, + devDependencies = {}, + peerDependencies = {}, + bundledDependencies = {}, + optionalDependencies = {}, + ...rest + } = overrides; + + return { + workspaces, + dependencies, + devDependencies, + peerDependencies, + bundledDependencies, + optionalDependencies, + ...rest, + }; +} +*/ + +/** + * 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 releaseSpecificationUtils.generateReleaseSpecificationTemplateForMonorepo + > + >; + waitForUserToEditReleaseSpecification?: UnwrapPromise< + ReturnType< + typeof releaseSpecificationUtils.waitForUserToEditReleaseSpecification + > + >; + validateReleaseSpecification?: UnwrapPromise< + ReturnType + >; + updatePackage?: UnwrapPromise>; + captureChangesInReleaseBranch?: UnwrapPromise< + ReturnType + >; +}) { + jest + .spyOn(editorUtils, 'determineEditor') + .mockResolvedValue(determineEditorValue); + jest.spyOn(envUtils, 'getEnvironmentVariables').mockReturnValue({ + EDITOR: undefined, + TODAY: undefined, + ...getEnvironmentVariablesValue, + }); + const generateReleaseSpecificationTemplateForMonorepoSpy = jest + .spyOn( + releaseSpecificationUtils, + 'generateReleaseSpecificationTemplateForMonorepo', + ) + .mockResolvedValue(generateReleaseSpecificationTemplateForMonorepoValue); + const waitForUserToEditReleaseSpecificationSpy = jest + .spyOn(releaseSpecificationUtils, 'waitForUserToEditReleaseSpecification') + .mockResolvedValue(waitForUserToEditReleaseSpecificationValue); + const validateReleaseSpecificationSpy = jest + .spyOn(releaseSpecificationUtils, 'validateReleaseSpecification') + .mockResolvedValue(validateReleaseSpecificationValue); + const updatePackageSpy = jest + .spyOn(packageUtils, 'updatePackage') + .mockResolvedValue(updatePackageValue); + const captureChangesInReleaseBranchSpy = jest + .spyOn(workflowUtils, 'captureChangesInReleaseBranch') + .mockResolvedValue(captureChangesInReleaseBranchValue); + + return { + generateReleaseSpecificationTemplateForMonorepoSpy, + waitForUserToEditReleaseSpecificationSpy, + validateReleaseSpecificationSpy, + updatePackageSpy, + captureChangesInReleaseBranchSpy, + }; +} diff --git a/src/monorepo-workflow-utils.ts b/src/monorepo-workflow-utils.ts new file mode 100644 index 0000000..359bcca --- /dev/null +++ b/src/monorepo-workflow-utils.ts @@ -0,0 +1,244 @@ +import type { WriteStream } from 'fs'; +import path from 'path'; +import rimraf from 'rimraf'; +import { debug } from './misc-utils'; +import { + ensureDirectoryPathExists, + fileExists, + removeFile, + writeFile, +} from './file-utils'; +import { determineEditor } from './editor-utils'; +import { getEnvironmentVariables } from './env-utils'; +import { updatePackage } from './package-utils'; +import { Project } from './project-utils'; +import { + generateReleaseSpecificationTemplateForMonorepo, + waitForUserToEditReleaseSpecification, + validateReleaseSpecification, + ReleaseSpecification, +} from './release-specification-utils'; +import { semver, SemVer } from './semver-utils'; +import { + captureChangesInReleaseBranch, + PackageReleasePlan, + ReleasePlan, +} from './workflow-utils'; + +/** + * 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: + * + * - The script generates a release spec template, listing the workspace + * packages in the project that have changed since the last release (or all of + * the packages if this would be the first release). + * - The script then presents the template to the user so that they can specify + * the desired versions for each package. It first does this by attempting to + * locate an appropriate code editor on the user's computer (using the + * `EDITOR` environment variable if that is defined, otherwise `code` if it is + * present) and opening the file there, pausing while the user is editing the + * file. If no editor can be found, the script provides the user with the path + * to the template so that they can edit it themselves, then exits. + * - However the user has edited the file, the script will parse and validate + * the information in the file, then apply the desired changes to the + * monorepo. + * - Finally, once it has made the desired changes, the script will create a Git + * commit that includes the changes, then create a branch using the current + * date as the name. + * + * @param options - The options. + * @param options.project - Information about the project. + * @param options.tempDirectoryPath - A directory in which to hold the generated + * release spec file. + * @param options.firstRemovingExistingReleaseSpecification - Sometimes it's + * 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.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. + */ +export async function followMonorepoWorkflow({ + project, + tempDirectoryPath, + firstRemovingExistingReleaseSpecification, + stdout, + stderr, +}: { + project: Project; + tempDirectoryPath: string; + firstRemovingExistingReleaseSpecification: boolean; + stdout: Pick; + stderr: Pick; +}) { + const releaseSpecificationPath = path.join(tempDirectoryPath, 'RELEASE_SPEC'); + + if (firstRemovingExistingReleaseSpecification) { + await new Promise((resolve) => rimraf(releaseSpecificationPath, resolve)); + } + + if (await fileExists(releaseSpecificationPath)) { + stdout.write( + 'Release spec already exists. Picking back up from previous run.\n', + ); + // TODO: If we end up here, then we will probably get an error later when + // attempting to bump versions of packages, as that may have already + // happened — we need to be idempotent + } else { + const editor = await determineEditor(); + + const releaseSpecificationTemplate = + await generateReleaseSpecificationTemplateForMonorepo({ + project, + isEditorAvailable: editor !== undefined, + }); + await ensureDirectoryPathExists(tempDirectoryPath); + await writeFile(releaseSpecificationPath, releaseSpecificationTemplate); + + if (!editor) { + stdout.write( + `${[ + 'A template has been generated that specifies this release. Please open the following file in your editor of choice, then re-run this script:', + `${releaseSpecificationPath}`, + ].join('\n\n')}\n`, + ); + return; + } + + try { + await waitForUserToEditReleaseSpecification( + releaseSpecificationPath, + editor, + ); + } catch (error) { + await removeFile(releaseSpecificationPath); + throw error; + } + } + + const releaseSpecification = await validateReleaseSpecification( + project, + releaseSpecificationPath, + ); + 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(); + // TODO: What if this version already exists? + 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.manifest.version; + const newVersion = + versionSpecifier instanceof SemVer + ? versionSpecifier.toString() + : new SemVer(currentVersion.toString()) + .inc(versionSpecifier) + .toString(); + + const versionDiff = semver.diff(currentVersion.toString(), newVersion); + + if (versionDiff === null) { + throw new Error( + [ + `Could not apply version specifier "${versionSpecifier}" to package "${packageName}" because the current and new versions would end up being the same.`, + `The release spec file has been retained for you to make the necessary fixes. Once you've done this, re-run this script.`, + releaseSpecificationPath, + ].join('\n\n'), + ); + } + + return { + package: pkg, + newVersion, + 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. + */ +async function applyUpdatesToMonorepo( + project: Project, + releasePlan: ReleasePlan, + stderr: Pick, +) { + await Promise.all( + releasePlan.packages.map(async (workspaceReleasePlan) => { + debug( + `Updating package ${workspaceReleasePlan.package.manifest.name}...`, + ); + await updatePackage({ + project, + packageReleasePlan: workspaceReleasePlan, + stderr, + }); + }), + ); +} diff --git a/src/package-manifest-utils.test.ts b/src/package-manifest-utils.test.ts new file mode 100644 index 0000000..28d051b --- /dev/null +++ b/src/package-manifest-utils.test.ts @@ -0,0 +1,352 @@ +import fs from 'fs'; +import path from 'path'; +import { SemVer } from 'semver'; +import { withSandbox } from '../tests/unit/helpers'; +import { readManifest } from './package-manifest-utils'; + +describe('package-manifest-utils', () => { + describe('readManifest', () => { + it('reads a minimal package manifest, expanding it by filling in values for optional fields', async () => { + await withSandbox(async (sandbox) => { + const manifestPath = path.join(sandbox.directoryPath, 'package.json'); + await fs.promises.writeFile( + manifestPath, + JSON.stringify({ + name: 'foo', + version: '1.2.3', + }), + ); + + expect(await readManifest(manifestPath)).toStrictEqual({ + name: 'foo', + version: new SemVer('1.2.3'), + workspaces: [], + private: false, + bundledDependencies: {}, + dependencies: {}, + devDependencies: {}, + optionalDependencies: {}, + peerDependencies: {}, + }); + }); + }); + + it('reads a package manifest where optional fields are fully provided', async () => { + await withSandbox(async (sandbox) => { + const manifestPath = path.join(sandbox.directoryPath, 'package.json'); + await fs.promises.writeFile( + manifestPath, + JSON.stringify({ + name: 'foo', + version: '1.2.3', + workspaces: ['packages/*'], + private: true, + bundledDependencies: { + foo: 'bar', + }, + dependencies: { + foo: 'bar', + }, + devDependencies: { + foo: 'bar', + }, + optionalDependencies: { + foo: 'bar', + }, + peerDependencies: { + foo: 'bar', + }, + }), + ); + + expect(await readManifest(manifestPath)).toStrictEqual({ + name: 'foo', + version: new SemVer('1.2.3'), + workspaces: ['packages/*'], + private: true, + bundledDependencies: { + foo: 'bar', + }, + dependencies: { + foo: 'bar', + }, + devDependencies: { + foo: 'bar', + }, + optionalDependencies: { + foo: 'bar', + }, + peerDependencies: { + foo: 'bar', + }, + }); + }); + }); + + it('reads a package manifest where dependencies fields are provided but empty', async () => { + await withSandbox(async (sandbox) => { + const manifestPath = path.join(sandbox.directoryPath, 'package.json'); + await fs.promises.writeFile( + manifestPath, + JSON.stringify({ + name: 'foo', + version: '1.2.3', + private: true, + bundledDependencies: {}, + dependencies: {}, + devDependencies: {}, + optionalDependencies: {}, + peerDependencies: {}, + }), + ); + + expect(await readManifest(manifestPath)).toStrictEqual({ + name: 'foo', + version: new SemVer('1.2.3'), + workspaces: [], + private: true, + bundledDependencies: {}, + dependencies: {}, + devDependencies: {}, + optionalDependencies: {}, + peerDependencies: {}, + }); + }); + }); + + it('reads a package manifest where the "workspaces" field is provided but empty', async () => { + await withSandbox(async (sandbox) => { + const manifestPath = path.join(sandbox.directoryPath, 'package.json'); + await fs.promises.writeFile( + manifestPath, + JSON.stringify({ + name: 'foo', + version: '1.2.3', + workspaces: [], + }), + ); + + expect(await readManifest(manifestPath)).toStrictEqual({ + name: 'foo', + version: new SemVer('1.2.3'), + workspaces: [], + private: false, + bundledDependencies: {}, + dependencies: {}, + devDependencies: {}, + optionalDependencies: {}, + peerDependencies: {}, + }); + }); + }); + + it('throws if "name" is not provided', async () => { + await withSandbox(async (sandbox) => { + const manifestPath = path.join(sandbox.directoryPath, 'package.json'); + await fs.promises.writeFile( + manifestPath, + JSON.stringify({ + version: '1.2.3', + }), + ); + + await expect(readManifest(manifestPath)).rejects.toThrow( + `The value of "name" in the manifest located at "${sandbox.directoryPath}" must be a non-empty string`, + ); + }); + }); + + it('throws if "name" is an empty string', async () => { + await withSandbox(async (sandbox) => { + const manifestPath = path.join(sandbox.directoryPath, 'package.json'); + await fs.promises.writeFile( + manifestPath, + JSON.stringify({ + name: '', + version: '1.2.3', + }), + ); + + await expect(readManifest(manifestPath)).rejects.toThrow( + `The value of "name" in the manifest located at "${sandbox.directoryPath}" must be a non-empty string`, + ); + }); + }); + + it('throws if "name" is not a string', async () => { + await withSandbox(async (sandbox) => { + const manifestPath = path.join(sandbox.directoryPath, 'package.json'); + await fs.promises.writeFile( + manifestPath, + JSON.stringify({ + name: 12345, + version: '1.2.3', + }), + ); + + await expect(readManifest(manifestPath)).rejects.toThrow( + `The value of "name" in the manifest located at "${sandbox.directoryPath}" must be a non-empty string`, + ); + }); + }); + + it('throws if "version" is not provided', async () => { + await withSandbox(async (sandbox) => { + const manifestPath = path.join(sandbox.directoryPath, 'package.json'); + await fs.promises.writeFile( + manifestPath, + JSON.stringify({ + name: 'foo', + }), + ); + + await expect(readManifest(manifestPath)).rejects.toThrow( + 'The value of "version" in the manifest for "foo" must be a valid SemVer version string', + ); + }); + }); + + it('throws if "version" is not a SemVer-compatible version string', async () => { + await withSandbox(async (sandbox) => { + const manifestPath = path.join(sandbox.directoryPath, 'package.json'); + await fs.promises.writeFile( + manifestPath, + JSON.stringify({ + name: 'foo', + version: 12345, + }), + ); + + await expect(readManifest(manifestPath)).rejects.toThrow( + 'The value of "version" in the manifest for "foo" must be a valid SemVer version string', + ); + }); + }); + + it('throws if "workspaces" is not an array of strings', async () => { + await withSandbox(async (sandbox) => { + const manifestPath = path.join(sandbox.directoryPath, 'package.json'); + await fs.promises.writeFile( + manifestPath, + JSON.stringify({ + name: 'foo', + version: '1.2.3', + workspaces: 12345, + }), + ); + + await expect(readManifest(manifestPath)).rejects.toThrow( + 'The value of "workspaces" in the manifest for "foo" must be an array of non-empty strings (if present)', + ); + }); + }); + + it('throws if "private" is not a boolean', async () => { + await withSandbox(async (sandbox) => { + const manifestPath = path.join(sandbox.directoryPath, 'package.json'); + await fs.promises.writeFile( + manifestPath, + JSON.stringify({ + name: 'foo', + version: '1.2.3', + private: 12345, + }), + ); + + await expect(readManifest(manifestPath)).rejects.toThrow( + 'The value of "private" in the manifest for "foo" must be true or false (if present)', + ); + }); + }); + + it('throws if "bundledDependencies" is not an object with string keys and string values', async () => { + await withSandbox(async (sandbox) => { + const manifestPath = path.join(sandbox.directoryPath, 'package.json'); + await fs.promises.writeFile( + manifestPath, + JSON.stringify({ + name: 'foo', + version: '1.2.3', + bundledDependencies: 12345, + }), + ); + + await expect(readManifest(manifestPath)).rejects.toThrow( + 'The value of "bundledDependencies" in the manifest for "foo" must be an object with non-empty string keys and non-empty string values', + ); + }); + }); + + it('throws if "dependencies" is not an object with string keys and string values', async () => { + await withSandbox(async (sandbox) => { + const manifestPath = path.join(sandbox.directoryPath, 'package.json'); + await fs.promises.writeFile( + manifestPath, + JSON.stringify({ + name: 'foo', + version: '1.2.3', + dependencies: 12345, + }), + ); + + await expect(readManifest(manifestPath)).rejects.toThrow( + 'The value of "dependencies" in the manifest for "foo" must be an object with non-empty string keys and non-empty string values', + ); + }); + }); + + it('throws if "devDependencies" is not an object with string keys and string values', async () => { + await withSandbox(async (sandbox) => { + const manifestPath = path.join(sandbox.directoryPath, 'package.json'); + await fs.promises.writeFile( + manifestPath, + JSON.stringify({ + name: 'foo', + version: '1.2.3', + devDependencies: 12345, + }), + ); + + await expect(readManifest(manifestPath)).rejects.toThrow( + 'The value of "devDependencies" in the manifest for "foo" must be an object with non-empty string keys and non-empty string values', + ); + }); + }); + + it('throws if "optionalDependencies" is not an object with string keys and string values', async () => { + await withSandbox(async (sandbox) => { + const manifestPath = path.join(sandbox.directoryPath, 'package.json'); + await fs.promises.writeFile( + manifestPath, + JSON.stringify({ + name: 'foo', + version: '1.2.3', + optionalDependencies: 12345, + }), + ); + + await expect(readManifest(manifestPath)).rejects.toThrow( + 'The value of "optionalDependencies" in the manifest for "foo" must be an object with non-empty string keys and non-empty string values', + ); + }); + }); + + it('throws if "peerDependencies" is not an object with string keys and string values', async () => { + await withSandbox(async (sandbox) => { + const manifestPath = path.join(sandbox.directoryPath, 'package.json'); + await fs.promises.writeFile( + manifestPath, + JSON.stringify({ + name: 'foo', + version: '1.2.3', + peerDependencies: 12345, + }), + ); + + await expect(readManifest(manifestPath)).rejects.toThrow( + 'The value of "peerDependencies" in the manifest for "foo" must be an object with non-empty string keys and non-empty string values', + ); + }); + }); + }); +}); diff --git a/src/package-manifest-utils.ts b/src/package-manifest-utils.ts new file mode 100644 index 0000000..d342aa9 --- /dev/null +++ b/src/package-manifest-utils.ts @@ -0,0 +1,348 @@ +import path from 'path'; +import { + ManifestFieldNames, + ManifestDependencyFieldNames, +} from '@metamask/action-utils'; +import { readJsonObjectFile } from './file-utils'; +import { isTruthyString, isObject, Require } from './misc-utils'; +import { isValidSemver, SemVer } from './semver-utils'; + +export { ManifestFieldNames, ManifestDependencyFieldNames }; + +/** + * An unverified representation of the data in a package's `package.json`. + * (We know which properties could be present but haven't checked their types + * yet.) + * + * TODO: Move this to action-utils. + */ +type UnvalidatedManifest = Readonly>> & + Readonly>>; + +/** + * A type-checked representation of the data in a package's `package.json`. + * + * TODO: Move this to action-utils. + */ +export type ValidatedManifest = { + readonly [ManifestFieldNames.Name]: string; + readonly [ManifestFieldNames.Version]: SemVer; + readonly [ManifestFieldNames.Private]: boolean; + readonly [ManifestFieldNames.Workspaces]: string[]; +} & Readonly< + Partial>> +>; + +/** + * Represents options to `readManifestField`. + * + * @template T - The expected type of the field value (should include + * `undefined` if not expected to present). + * @template U - The return type of this function, as determined via + * `defaultValue` (if present) or `transform` (if present). + * @property manifest - The manifest object. + * @property parentDirectory - The directory in which the manifest lives. + * @property fieldName - The name of the field. + * @property validation - The validation object. + * @property validation.check - A function to test whether the value for the + * field is valid. + * @property validation.failureReason - A snippet of the message that will be + * produced if the validation fails which explains the requirements of the field + * or merely says that it is invalid, with no explanation. + * @property defaultValue - A value to return in place of the field value if + * the field is not present. + * @property transform - A function to call with the value after it has been + * validated. + */ +interface ReadManifestFieldOptions { + manifest: UnvalidatedManifest; + parentDirectory: string; + fieldName: keyof UnvalidatedManifest; + validation: { + check: (value: any) => value is T; + failureReason: string; + }; + defaultValue?: U; + transform?: (value: T) => U; +} + +const validationForManifestNameField = { + check: isTruthyString, + failureReason: 'must be a non-empty string', +}; + +const validationForManifestVersionField = { + check: isValidManifestVersionField, + failureReason: 'must be a valid SemVer version string', +}; + +const validationForManifestWorkspacesField = { + check: isValidManifestWorkspacesField, + failureReason: 'must be an array of non-empty strings (if present)', +}; + +const validationForManifestPrivateField = { + check: isValidManifestPrivateField, + failureReason: 'must be true or false (if present)', +}; + +const validationForManifestDependenciesField = { + check: isValidManifestDependenciesField, + failureReason: + 'must be an object with non-empty string keys and non-empty string values', +}; + +/** + * Type guard to ensure that the given "version" field of a manifest is valid. + * + * TODO: Move this to action-utils. + * + * @param version - The value to check. + * @returns Whether the value is valid. + */ +function isValidManifestVersionField(version: any): version is string { + return isTruthyString(version) && isValidSemver(version); +} + +/** + * Type guard to ensure that the given "workspaces" field of a manifest is + * valid. + * + * TODO: Move this to action-utils. + * + * @param workspaces - The value to check. + * @returns Whether the value is valid. + */ +function isValidManifestWorkspacesField( + workspaces: any, +): workspaces is string[] | undefined { + return ( + workspaces === undefined || + (Array.isArray(workspaces) && + workspaces.every((workspace) => isTruthyString(workspace))) + ); +} + +/** + * Type guard to ensure that the given "private" field of a manifest is valid. + * + * TODO: Move this to action-utils. + * + * @param privateValue - The value to check. + * @returns Whether the value is valid. + */ +function isValidManifestPrivateField( + privateValue: any, +): privateValue is boolean | undefined { + return ( + privateValue === undefined || + privateValue === true || + privateValue === false + ); +} + +/** + * Type guard to ensure that the given dependencies field of a manifest is valid. + * + * TODO: Move this to action-utils. + * + * @param dependencies - The value to check. + * @returns Whether the value is valid. + */ +function isValidManifestDependenciesField( + dependencies: any, +): dependencies is Record { + return ( + dependencies === undefined || + (isObject(dependencies) && + Object.keys(dependencies).every(isTruthyString) && + Object.values(dependencies).every(isTruthyString)) + ); +} + +/** + * Constructs a message for a manifest file validation error. + * + * TODO: Remove when other functions are moved to action-utils. + * + * @param args - The arguments. + * @param args.manifest - The manifest data that's invalid. + * @param args.parentDirectory - The directory of the package to which the manifest + * belongs. + * @param args.invalidFieldName - The name of the invalid field. + * @param args.verbPhrase - Either the fact that the field is invalid or an + * explanation for why it is invalid. + * @returns The error message. + */ +function buildManifestFieldValidationErrorMessage({ + manifest, + parentDirectory, + invalidFieldName, + verbPhrase, +}: { + manifest: UnvalidatedManifest; + parentDirectory: string; + invalidFieldName: keyof UnvalidatedManifest; + verbPhrase: string; +}) { + const subject = isTruthyString(manifest[ManifestFieldNames.Name]) + ? `The value of "${invalidFieldName}" in the manifest for "${ + manifest[ManifestFieldNames.Name] + }"` + : `The value of "${invalidFieldName}" in the manifest located at "${parentDirectory}"`; + return `${subject} ${verbPhrase}`; +} + +/** + * Retrieves and validates a field within a package manifest object, throwing if + * validation fails. + * + * TODO: Move this to action-utils. + * + * @template T - The expected type of the field value (should include + * `undefined` if not expected to present). + * @template U - The return type of this function, as determined via + * `defaultValue` (if present) or `transform` (if present). + * @param args - The arguments. + * @param args.manifest - The manifest object. + * @param args.parentDirectory - The directory in which the manifest lives. + * @param args.fieldName - The name of the field. + * @param args.validation - The validation object. + * @param args.validation.check - A function to test whether the value for the + * field is valid. + * @param args.validation.failureReason - A snippet of the message that will be + * produced if the validation fails which explains the requirements of the field + * or merely says that it is invalid, with no explanation. + * @param args.defaultValue - A value to return in place of the field value if + * the field is not present. + * @param args.transform - A function to call with the value after it has been + * validated. + * @returns The value of the field, or the default value. + */ +function readManifestField( + options: Omit, 'transform' | 'defaultValue'>, +): T; +function readManifestField( + options: Require, 'transform'>, +): U; +function readManifestField( + options: Require, 'defaultValue'>, +): T extends undefined ? U : T; +/* eslint-disable-next-line jsdoc/require-jsdoc */ +function readManifestField({ + manifest, + parentDirectory, + fieldName, + validation, + defaultValue, + transform, +}: ReadManifestFieldOptions): T | U { + const value = manifest[fieldName]; + + if (!validation.check(value)) { + throw new Error( + buildManifestFieldValidationErrorMessage({ + manifest, + parentDirectory, + invalidFieldName: fieldName, + verbPhrase: validation.failureReason, + }), + ); + } + + if (defaultValue === undefined || value !== undefined) { + if (transform === undefined) { + return value; + } + + return transform(value); + } + + return defaultValue; +} + +/** + * Retrieves and checks the dependency fields of a package manifest object, + * throwing if any of them is not present or is not the correct type. + * + * TODO: Move this to action-utils. + * + * @param manifest - The manifest data to validate. + * @param parentDirectory - The directory of the package to which the manifest + * belongs. + * @returns The extracted dependency fields and their values. + */ +function readManifestDependencyFields( + manifest: UnvalidatedManifest, + parentDirectory: string, +) { + return Object.values(ManifestDependencyFieldNames).reduce( + (obj, fieldName) => { + const dependencies = readManifestField({ + manifest, + parentDirectory, + fieldName, + validation: validationForManifestDependenciesField, + defaultValue: {}, + }); + return { ...obj, [fieldName]: dependencies }; + }, + {} as Record>, + ); +} + +/** + * Reads the package manifest at the given path, verifying key data within the + * manifest and throwing if that data is incomplete. + * + * TODO: Move this to action-utils. + * + * @param manifestPath - The path of the manifest file. + * @returns Information about a correctly typed version of the manifest for a + * package. + */ +export async function readManifest( + manifestPath: string, +): Promise { + const unvalidatedManifest = await readJsonObjectFile(manifestPath); + const parentDirectory = path.dirname(manifestPath); + const name = readManifestField({ + manifest: unvalidatedManifest, + parentDirectory, + fieldName: ManifestFieldNames.Name, + validation: validationForManifestNameField, + }); + const version = readManifestField({ + manifest: unvalidatedManifest, + parentDirectory, + fieldName: ManifestFieldNames.Version, + validation: validationForManifestVersionField, + transform: (value: string) => new SemVer(value), + }); + const workspaces = readManifestField({ + manifest: unvalidatedManifest, + parentDirectory, + fieldName: ManifestFieldNames.Workspaces, + validation: validationForManifestWorkspacesField, + defaultValue: [], + }); + const privateValue = readManifestField({ + manifest: unvalidatedManifest, + parentDirectory, + fieldName: ManifestFieldNames.Private, + validation: validationForManifestPrivateField, + defaultValue: false, + }); + const dependencyFields = readManifestDependencyFields( + unvalidatedManifest, + parentDirectory, + ); + + return { + [ManifestFieldNames.Name]: name, + [ManifestFieldNames.Version]: version, + [ManifestFieldNames.Workspaces]: workspaces, + [ManifestFieldNames.Private]: privateValue, + ...dependencyFields, + }; +} diff --git a/src/package-utils.test.ts b/src/package-utils.test.ts new file mode 100644 index 0000000..9ca481f --- /dev/null +++ b/src/package-utils.test.ts @@ -0,0 +1,223 @@ +import fs from 'fs'; +import path from 'path'; +import { when } from 'jest-when'; +import * as autoChangelog from '@metamask/auto-changelog'; +import { + buildMockProject, + buildMockManifest, + withSandbox, +} from '../tests/unit/helpers'; +import * as fileUtils from './file-utils'; +import { readPackage, updatePackage } from './package-utils'; +import * as packageManifestUtils from './package-manifest-utils'; + +jest.mock('@metamask/auto-changelog'); +jest.mock('./package-manifest-utils'); + +describe('package-utils', () => { + describe('readPackage', () => { + it('reads information about the package located at the given directory', async () => { + const packageDirectoryPath = '/path/to/package'; + jest + .spyOn(packageManifestUtils, 'readManifest') + .mockResolvedValue(buildMockManifest()); + + const pkg = await readPackage(packageDirectoryPath); + + expect(pkg).toStrictEqual({ + directoryPath: packageDirectoryPath, + manifestPath: path.join(packageDirectoryPath, 'package.json'), + manifest: buildMockManifest(), + changelogPath: path.join(packageDirectoryPath, 'CHANGELOG.md'), + }); + }); + }); + + describe('updatePackage', () => { + it('writes the planned version to the planned package', async () => { + await withSandbox(async (sandbox) => { + const project = { + directoryPath: '/path/to/project', + repositoryUrl: 'https://repo.url', + }; + const manifestPath = path.join(sandbox.directoryPath, 'package.json'); + const packageReleasePlan = { + package: { + directoryPath: sandbox.directoryPath, + manifestPath, + manifest: buildMockManifest(), + changelogPath: path.join(sandbox.directoryPath, 'CHANGELOG.md'), + }, + newVersion: '2.0.0', + shouldUpdateChangelog: false, + }; + + await updatePackage({ project, packageReleasePlan }); + + const newManifest = JSON.parse( + await fs.promises.readFile(manifestPath, 'utf8'), + ); + expect(newManifest).toMatchObject({ + version: '2.0.0', + }); + }); + }); + + it('updates the changelog of the package if requested to do so and if the package has one', async () => { + await withSandbox(async (sandbox) => { + const project = buildMockProject({ + repositoryUrl: 'https://repo.url', + }); + const changelogPath = path.join(sandbox.directoryPath, 'CHANGELOG.md'); + const packageReleasePlan = { + package: { + directoryPath: sandbox.directoryPath, + manifestPath: path.join(sandbox.directoryPath, 'package.json'), + manifest: buildMockManifest(), + changelogPath, + }, + newVersion: '2.0.0', + shouldUpdateChangelog: true, + }; + when(jest.spyOn(autoChangelog, 'updateChangelog')) + .calledWith({ + changelogContent: 'existing changelog', + currentVersion: '2.0.0', + isReleaseCandidate: true, + projectRootDirectory: sandbox.directoryPath, + repoUrl: 'https://repo.url', + }) + .mockResolvedValue('new changelog'); + await fs.promises.writeFile(changelogPath, 'existing changelog'); + + await updatePackage({ project, packageReleasePlan }); + + const newChangelogContent = await fs.promises.readFile( + changelogPath, + 'utf8', + ); + expect(newChangelogContent).toStrictEqual('new changelog'); + }); + }); + + it("throws if reading the package's changelog fails in an unexpected way", async () => { + await withSandbox(async (sandbox) => { + const project = buildMockProject(); + const changelogPath = path.join(sandbox.directoryPath, 'CHANGELOG.md'); + const packageReleasePlan = { + package: { + directoryPath: sandbox.directoryPath, + manifestPath: path.join(sandbox.directoryPath, 'package.json'), + manifest: buildMockManifest(), + changelogPath, + }, + newVersion: '2.0.0', + shouldUpdateChangelog: true, + }; + jest.spyOn(fileUtils, 'readFile').mockRejectedValue(new Error('oops')); + + await expect( + updatePackage({ project, packageReleasePlan }), + ).rejects.toThrow('oops'); + }); + }); + + it('does not throw but merely prints a warning if the package does not have a changelog', async () => { + await withSandbox(async (sandbox) => { + const project = buildMockProject(); + const changelogPath = path.join(sandbox.directoryPath, 'CHANGELOG.md'); + const packageReleasePlan = { + package: { + directoryPath: sandbox.directoryPath, + manifestPath: path.join(sandbox.directoryPath, 'package.json'), + manifest: buildMockManifest(), + changelogPath, + }, + newVersion: '2.0.0', + shouldUpdateChangelog: true, + }; + jest + .spyOn(autoChangelog, 'updateChangelog') + .mockResolvedValue('new changelog'); + + const result = await updatePackage({ project, packageReleasePlan }); + + expect(result).toBeUndefined(); + }); + }); + + it('does not update the changelog if updateChangelog returns nothing', async () => { + await withSandbox(async (sandbox) => { + const project = buildMockProject({ + repositoryUrl: 'https://repo.url', + }); + const changelogPath = path.join(sandbox.directoryPath, 'CHANGELOG.md'); + const packageReleasePlan = { + package: { + directoryPath: sandbox.directoryPath, + manifestPath: path.join(sandbox.directoryPath, 'package.json'), + manifest: buildMockManifest(), + changelogPath, + }, + newVersion: '2.0.0', + shouldUpdateChangelog: true, + }; + when(jest.spyOn(autoChangelog, 'updateChangelog')) + .calledWith({ + changelogContent: 'existing changelog', + currentVersion: '2.0.0', + isReleaseCandidate: true, + projectRootDirectory: sandbox.directoryPath, + repoUrl: 'https://repo.url', + }) + .mockResolvedValue(undefined); + await fs.promises.writeFile(changelogPath, 'existing changelog'); + + await updatePackage({ project, packageReleasePlan }); + + const newChangelogContent = await fs.promises.readFile( + changelogPath, + 'utf8', + ); + expect(newChangelogContent).toStrictEqual('existing changelog'); + }); + }); + + it('does not update the changelog if not requested to do so', async () => { + await withSandbox(async (sandbox) => { + const project = buildMockProject({ + repositoryUrl: 'https://repo.url', + }); + const changelogPath = path.join(sandbox.directoryPath, 'CHANGELOG.md'); + const packageReleasePlan = { + package: { + directoryPath: sandbox.directoryPath, + manifestPath: path.join(sandbox.directoryPath, 'package.json'), + manifest: buildMockManifest(), + changelogPath, + }, + newVersion: '2.0.0', + shouldUpdateChangelog: false, + }; + when(jest.spyOn(autoChangelog, 'updateChangelog')) + .calledWith({ + changelogContent: 'existing changelog', + currentVersion: '2.0.0', + isReleaseCandidate: true, + projectRootDirectory: sandbox.directoryPath, + repoUrl: 'https://repo.url', + }) + .mockResolvedValue('new changelog'); + await fs.promises.writeFile(changelogPath, 'existing changelog'); + + await updatePackage({ project, packageReleasePlan }); + + const newChangelogContent = await fs.promises.readFile( + changelogPath, + 'utf8', + ); + expect(newChangelogContent).toStrictEqual('existing changelog'); + }); + }); + }); +}); diff --git a/src/package-utils.ts b/src/package-utils.ts new file mode 100644 index 0000000..1e9505b --- /dev/null +++ b/src/package-utils.ts @@ -0,0 +1,139 @@ +import fs, { WriteStream } from 'fs'; +import path from 'path'; +import { updateChangelog } from '@metamask/auto-changelog'; +import { isErrorWithCode, isErrorWithMessage } from './misc-utils'; +import { readFile, writeFile, writeJsonFile } from './file-utils'; +import { Project } from './project-utils'; +import { PackageReleasePlan } from './workflow-utils'; +import { readManifest, ValidatedManifest } from './package-manifest-utils'; + +const MANIFEST_FILE_NAME = 'package.json'; +const CHANGELOG_FILE_NAME = 'CHANGELOG.md'; + +/** + * Information about a package within a project. + * + * @property directoryPath - The path to the directory where the package is + * located. + * @property manifestPath - The path to the manifest file. + * @property manifest - The data extracted from the manifest. + * @property changelogPath - The path to the changelog file (which may or may + * not exist). + */ +export interface Package { + directoryPath: string; + manifestPath: string; + manifest: ValidatedManifest; + changelogPath: string; +} + +/** + * Collects information about a package. + * + * @param packageDirectoryPath - The path to a package within a project. + * @returns Information about the package. + */ +export async function readPackage( + packageDirectoryPath: string, +): Promise { + const manifestPath = path.join(packageDirectoryPath, MANIFEST_FILE_NAME); + const changelogPath = path.join(packageDirectoryPath, CHANGELOG_FILE_NAME); + const validatedManifest = await readManifest(manifestPath); + + return { + directoryPath: packageDirectoryPath, + manifestPath, + manifest: validatedManifest, + changelogPath, + }; +} + +/** + * Updates the changelog file of the given package using + * `@metamask/auto-changelog`. Assumes that the changelog file is located at the + * package root directory and named "CHANGELOG.md". + * + * @param args - The arguments. + * @param args.project - The project. + * @param args.packageReleasePlan - The release plan for a particular package in + * the project. + * @param args.stderr - A stream that can be used to write to standard error. + * @returns The result of writing to the changelog. + */ +async function updatePackageChangelog({ + project: { repositoryUrl }, + packageReleasePlan: { package: pkg, newVersion }, + stderr, +}: { + project: Pick; + packageReleasePlan: PackageReleasePlan; + stderr: Pick; +}): Promise { + let changelogContent; + + try { + changelogContent = await readFile(pkg.changelogPath); + } catch (error) { + if (isErrorWithCode(error) && error.code === 'ENOENT') { + stderr.write( + `${pkg.manifest.name} does not seem to have a changelog. Skipping.\n`, + ); + return; + } + + throw error; + } + + const newChangelogContent = await updateChangelog({ + changelogContent, + currentVersion: newVersion, + isReleaseCandidate: true, + projectRootDirectory: pkg.directoryPath, + repoUrl: repositoryUrl, + }); + + if (newChangelogContent) { + await writeFile(pkg.changelogPath, newChangelogContent); + } else { + stderr.write( + `Changelog for ${pkg.manifest.name} was not updated as there were no updates to make.`, + ); + } +} + +/** + * Updates the package as per the instructions in the given release plan by + * replacing the `version` field in the manifest and adding a new section to the + * changelog for the new version of the package. + * + * @param args - The project. + * @param args.project - The project. + * @param args.packageReleasePlan - The release plan for a particular package in the + * project. + * @param args.stderr - A stream that can be used to write to standard error. + * Defaults to /dev/null. + */ +export async function updatePackage({ + project, + packageReleasePlan, + stderr = fs.createWriteStream('/dev/null'), +}: { + project: Pick; + packageReleasePlan: PackageReleasePlan; + stderr?: Pick; +}): Promise { + const { + package: pkg, + newVersion, + shouldUpdateChangelog, + } = packageReleasePlan; + + await writeJsonFile(pkg.manifestPath, { + ...pkg.manifest, + version: newVersion, + }); + + if (shouldUpdateChangelog) { + await updatePackageChangelog({ project, packageReleasePlan, stderr }); + } +} diff --git a/src/project-utils.test.ts b/src/project-utils.test.ts new file mode 100644 index 0000000..7c62424 --- /dev/null +++ b/src/project-utils.test.ts @@ -0,0 +1,76 @@ +import fs from 'fs'; +import path from 'path'; +import { when } from 'jest-when'; +import { + buildMockManifest, + buildMockPackage, + withSandbox, +} from '../tests/unit/helpers'; +import * as gitUtils from './git-utils'; +import * as packageUtils from './package-utils'; +import { readProject } from './project-utils'; + +jest.mock('./git-utils'); +jest.mock('./package-utils'); + +describe('project-utils', () => { + describe('readProject', () => { + it('collects information about the repository URL as well as the root and workspace packages within the project', async () => { + await withSandbox(async (sandbox) => { + const projectDirectoryPath = sandbox.directoryPath; + const projectRepositoryUrl = 'https://github.com/some-org/some-repo'; + const rootPackage = buildMockPackage('root', { + directoryPath: projectDirectoryPath, + manifest: buildMockManifest({ + workspaces: ['packages/a', 'packages/subpackages/*'], + }), + }); + const workspacePackages = { + a: buildMockPackage('a', { + directoryPath: path.join(projectDirectoryPath, 'packages', 'a'), + manifest: buildMockManifest(), + }), + b: buildMockPackage('b', { + directoryPath: path.join( + projectDirectoryPath, + 'packages', + 'subpackages', + 'b', + ), + manifest: buildMockManifest(), + }), + }; + when(jest.spyOn(gitUtils, 'getRepositoryHttpsUrl')) + .calledWith(projectDirectoryPath) + .mockResolvedValue(projectRepositoryUrl); + when(jest.spyOn(packageUtils, 'readPackage')) + .calledWith(projectDirectoryPath) + .mockResolvedValue(rootPackage) + .calledWith(path.join(projectDirectoryPath, 'packages', 'a')) + .mockResolvedValue(workspacePackages.a) + .calledWith( + path.join(projectDirectoryPath, 'packages', 'subpackages', 'b'), + ) + .mockResolvedValue(workspacePackages.b); + await fs.promises.mkdir(path.join(projectDirectoryPath, 'packages')); + await fs.promises.mkdir( + path.join(projectDirectoryPath, 'packages', 'a'), + ); + await fs.promises.mkdir( + path.join(projectDirectoryPath, 'packages', 'subpackages'), + ); + await fs.promises.mkdir( + path.join(projectDirectoryPath, 'packages', 'subpackages', 'b'), + ); + + expect(await readProject(projectDirectoryPath)).toStrictEqual({ + directoryPath: projectDirectoryPath, + repositoryUrl: projectRepositoryUrl, + rootPackage, + workspacePackages, + isMonorepo: true, + }); + }); + }); + }); +}); diff --git a/src/project-utils.ts b/src/project-utils.ts new file mode 100644 index 0000000..f2dd70c --- /dev/null +++ b/src/project-utils.ts @@ -0,0 +1,77 @@ +import util from 'util'; +import glob from 'glob'; +import { getRepositoryHttpsUrl } from './git-utils'; +import { Package, readPackage } from './package-utils'; +import { ManifestFieldNames } from './package-manifest-utils'; + +/** + * Represents the entire codebase on which this tool is operating. + * + * @property directoryPath - The directory in which the project lives. + * @property repositoryUrl - The public URL of the Git repository where the + * codebase for the project lives. + * @property rootPackage - Information about the root package (assuming that the + * project is a monorepo). + * @property workspacePackages - Information about packages that are referenced + * via workspaces (assuming that the project is a monorepo). + */ +export interface Project { + directoryPath: string; + repositoryUrl: string; + rootPackage: Package; + workspacePackages: Record; + isMonorepo: boolean; +} + +const promisifiedGlob = util.promisify(glob); + +/** + * Collects information about a project. For a polyrepo, this information will + * only cover the project's `package.json` file; for a monorepo, it will cover + * `package.json` files for any workspaces that the monorepo defines. + * + * @param projectDirectoryPath - The path to the project. + * @returns An object that represents information about the project. + * @throws if the project does not contain a root `package.json` (polyrepo and + * monorepo) or if any of the workspaces specified in the root `package.json` do + * not have `package.json`s (monorepo only). + */ +export async function readProject( + projectDirectoryPath: string, +): Promise { + const repositoryUrl = await getRepositoryHttpsUrl(projectDirectoryPath); + const rootPackage = await readPackage(projectDirectoryPath); + + const workspaceDirectories = ( + await Promise.all( + rootPackage.manifest[ManifestFieldNames.Workspaces].map( + async (workspacePattern) => { + return await promisifiedGlob(workspacePattern, { + cwd: projectDirectoryPath, + absolute: true, + }); + }, + ), + ) + ).flat(); + + const workspacePackages = ( + await Promise.all( + workspaceDirectories.map(async (directory) => { + return await readPackage(directory); + }), + ) + ).reduce((obj, pkg) => { + return { ...obj, [pkg.manifest.name]: pkg }; + }, {} as Record); + + const isMonorepo = Object.keys(workspacePackages).length > 0; + + return { + directoryPath: projectDirectoryPath, + repositoryUrl, + rootPackage, + workspacePackages, + isMonorepo, + }; +} diff --git a/src/release-specification-utils.test.ts b/src/release-specification-utils.test.ts new file mode 100644 index 0000000..ba19cb3 --- /dev/null +++ b/src/release-specification-utils.test.ts @@ -0,0 +1,417 @@ +import fs from 'fs'; +import path from 'path'; +import { when } from 'jest-when'; +import { MockWritable } from 'stdio-mock'; +import YAML from 'yaml'; +import { SemVer } from 'semver'; +import { + withSandbox, + buildMockProject, + buildMockPackage, +} from '../tests/unit/helpers'; +import { + generateReleaseSpecificationTemplateForMonorepo, + waitForUserToEditReleaseSpecification, + validateReleaseSpecification, +} from './release-specification-utils'; +import * as miscUtils from './misc-utils'; + +jest.mock('./misc-utils', () => { + return { + ...jest.requireActual('./misc-utils'), + runCommand: jest.fn(), + }; +}); + +describe('release-specification-utils', () => { + describe('generateReleaseSpecificationTemplateForMonorepo', () => { + it('returns a YAML-encoded string which has a list of all workspace packages in the project', async () => { + const project = buildMockProject({ + rootPackage: buildMockPackage('monorepo'), + workspacePackages: { + a: buildMockPackage('a'), + b: buildMockPackage('b'), + }, + }); + + const template = await generateReleaseSpecificationTemplateForMonorepo({ + project, + isEditorAvailable: true, + }); + + expect(template).toStrictEqual( + ` +# The following is a list of packages in monorepo. +# Please indicate the packages for which you want to create a new release +# by updating "null" (which does nothing) to one of the following: +# +# - "major" (if you want to bump the major part of the package's version) +# - "minor" (if you want to bump the minor part of the package's version) +# - "patch" (if you want to bump the patch part of the package's version) +# - an exact version with major, minor, and patch parts (e.g. "1.2.3") +# - null (to skip the package entirely) +# +# When you're finished making your selections, save this file and the script +# will continue automatically. + +packages: + a: null + b: null +`.slice(1), + ); + }); + + it('adjusts the instructions slightly if an editor is not available', async () => { + const project = buildMockProject({ + rootPackage: buildMockPackage('monorepo'), + workspacePackages: { + a: buildMockPackage('a'), + b: buildMockPackage('b'), + }, + }); + + const template = await generateReleaseSpecificationTemplateForMonorepo({ + project, + isEditorAvailable: false, + }); + + expect(template).toStrictEqual( + ` +# The following is a list of packages in monorepo. +# Please indicate the packages for which you want to create a new release +# by updating "null" (which does nothing) to one of the following: +# +# - "major" (if you want to bump the major part of the package's version) +# - "minor" (if you want to bump the minor part of the package's version) +# - "patch" (if you want to bump the patch part of the package's version) +# - an exact version with major, minor, and patch parts (e.g. "1.2.3") +# - null (to skip the package entirely) +# +# When you're finished making your selections, save this file and then re-run +# the script that generated this file. + +packages: + a: null + b: null +`.slice(1), + ); + }); + }); + + describe('waitForUserToEditReleaseSpecification', () => { + it('waits for the given editor command to complete successfully', async () => { + const releaseSpecificationPath = '/path/to/release-spec'; + const editor = { + path: '/path/to/editor', + args: ['arg1', 'arg2'], + }; + when(jest.spyOn(miscUtils, 'runCommand')) + .calledWith( + '/path/to/editor', + ['arg1', 'arg2', releaseSpecificationPath], + { + stdio: 'inherit', + shell: true, + }, + ) + .mockResolvedValue(); + + expect( + await waitForUserToEditReleaseSpecification( + releaseSpecificationPath, + editor, + ), + ).toBeUndefined(); + }); + + it('prints a message to standard out, but then removes it, if the editor command succeeds', async () => { + const releaseSpecificationPath = '/path/to/release-spec'; + const editor = { path: '/path/to/editor', args: [] }; + const stdout = new MockWritable(); + when(jest.spyOn(miscUtils, 'runCommand')).mockResolvedValue(); + + await waitForUserToEditReleaseSpecification( + releaseSpecificationPath, + editor, + stdout, + ); + + expect(stdout.data()).toStrictEqual([ + 'Waiting for the release spec to be edited...', + '\r\u001B[K', + ]); + }); + + it('still removes the message printed to standard out when the editor command fails', async () => { + const releaseSpecificationPath = '/path/to/release-spec'; + const editor = { + path: '/path/to/editor', + args: ['arg1', 'arg2'], + }; + const stdout = new MockWritable(); + when(jest.spyOn(miscUtils, 'runCommand')) + .calledWith( + '/path/to/editor', + ['arg1', 'arg2', releaseSpecificationPath], + { + stdio: 'inherit', + shell: true, + }, + ) + .mockRejectedValue(new Error('oops')); + + try { + await waitForUserToEditReleaseSpecification( + releaseSpecificationPath, + editor, + stdout, + ); + } catch { + // ignore any error that occurs + } + + expect(stdout.data()).toStrictEqual([ + 'Waiting for the release spec to be edited...', + '\r\u001B[K', + ]); + }); + + it('throws if the given editor command fails', async () => { + const releaseSpecificationPath = '/path/to/release-spec'; + const editor = { + path: '/path/to/editor', + args: ['arg1', 'arg2'], + }; + when(jest.spyOn(miscUtils, 'runCommand')) + .calledWith( + '/path/to/editor', + ['arg1', 'arg2', releaseSpecificationPath], + { + stdio: 'inherit', + shell: true, + }, + ) + .mockRejectedValue(new Error('oops')); + + await expect( + waitForUserToEditReleaseSpecification(releaseSpecificationPath, editor), + ).rejects.toThrow( + 'Encountered an error while waiting for the release spec to be edited: oops', + ); + }); + }); + + describe('validateReleaseSpecification', () => { + it('reads the release spec file and returns an expanded, typed version of its contents', async () => { + await withSandbox(async (sandbox) => { + const project = buildMockProject({ + workspacePackages: { + a: buildMockPackage('a'), + b: buildMockPackage('b'), + c: buildMockPackage('c'), + d: buildMockPackage('d'), + }, + }); + const releaseSpecificationPath = path.join( + sandbox.directoryPath, + 'release-spec', + ); + await fs.promises.writeFile( + releaseSpecificationPath, + YAML.stringify({ + packages: { + a: 'major', + b: 'minor', + c: 'patch', + d: '1.2.3', + }, + }), + ); + + const releaseSpecification = await validateReleaseSpecification( + project, + releaseSpecificationPath, + ); + + expect(releaseSpecification).toStrictEqual({ + packages: { + a: 'major', + b: 'minor', + c: 'patch', + d: new SemVer('1.2.3'), + }, + }); + }); + }); + + it('removes packages which have "null" as their version specifier', async () => { + await withSandbox(async (sandbox) => { + const project = buildMockProject({ + workspacePackages: { + a: buildMockPackage('a'), + b: buildMockPackage('b'), + c: buildMockPackage('c'), + }, + }); + const releaseSpecificationPath = path.join( + sandbox.directoryPath, + 'release-spec', + ); + await fs.promises.writeFile( + releaseSpecificationPath, + YAML.stringify({ + packages: { + a: 'major', + b: null, + c: 'patch', + }, + }), + ); + + const releaseSpecification = await validateReleaseSpecification( + project, + releaseSpecificationPath, + ); + + expect(releaseSpecification).toStrictEqual({ + packages: { + a: 'major', + c: 'patch', + }, + }); + }); + }); + + it('throws if the release spec cannot be parsed as valid YAML', async () => { + await withSandbox(async (sandbox) => { + const project = buildMockProject(); + const releaseSpecificationPath = path.join( + sandbox.directoryPath, + 'release-spec', + ); + await fs.promises.writeFile(releaseSpecificationPath, 'foo: "bar'); + + await expect( + validateReleaseSpecification(project, releaseSpecificationPath), + ).rejects.toThrow( + /^Failed to parse release spec:\n\nMissing closing "quote at line 1/u, + ); + }); + }); + + it('throws if the release spec does not hold an object', async () => { + await withSandbox(async (sandbox) => { + const project = buildMockProject(); + const releaseSpecificationPath = path.join( + sandbox.directoryPath, + 'release-spec', + ); + await fs.promises.writeFile( + releaseSpecificationPath, + YAML.stringify(12345), + ); + + await expect( + validateReleaseSpecification(project, releaseSpecificationPath), + ).rejects.toThrow( + /^Your release spec could not be processed because it needs to be an object/u, + ); + }); + }); + + it('throws if the release spec holds an object but it does not have a "packages" property', async () => { + await withSandbox(async (sandbox) => { + const project = buildMockProject(); + const releaseSpecificationPath = path.join( + sandbox.directoryPath, + 'release-spec', + ); + await fs.promises.writeFile( + releaseSpecificationPath, + YAML.stringify({ foo: 'bar' }), + ); + + await expect( + validateReleaseSpecification(project, releaseSpecificationPath), + ).rejects.toThrow( + /^Your release spec could not be processed because it needs to be an object/u, + ); + }); + }); + + it('throws if any of the keys in the "packages" property do not match the names of any workspace packages', async () => { + await withSandbox(async (sandbox) => { + const project = buildMockProject({ + workspacePackages: { + a: buildMockPackage('a'), + }, + }); + const releaseSpecificationPath = path.join( + sandbox.directoryPath, + 'release-spec', + ); + await fs.promises.writeFile( + releaseSpecificationPath, + YAML.stringify({ + packages: { + foo: 'major', + bar: 'minor', + }, + }), + ); + + await expect( + validateReleaseSpecification(project, releaseSpecificationPath), + ).rejects.toThrow( + new RegExp( + [ + '^Your release spec could not be processed due to the following issues:\n', + '- Line 2: "foo" is not a package in the project', + '- Line 3: "bar" is not a package in the project', + ].join('\n'), + 'u', + ), + ); + }); + }); + + it('throws if any of the values in the "packages" property are not valid version specifiers', async () => { + await withSandbox(async (sandbox) => { + const project = buildMockProject({ + workspacePackages: { + a: buildMockPackage('a'), + b: buildMockPackage('b'), + }, + }); + const releaseSpecificationPath = path.join( + sandbox.directoryPath, + 'release-spec', + ); + await fs.promises.writeFile( + releaseSpecificationPath, + YAML.stringify({ + packages: { + a: 'asdflksdaf', + b: '1.2...3.', + }, + }), + ); + + await expect( + validateReleaseSpecification(project, releaseSpecificationPath), + ).rejects.toThrow( + new RegExp( + [ + '^Your release spec could not be processed due to the following issues:\n', + '- Line 2: "asdflksdaf" is not a valid version specifier for package "a"', + ' \\(must be "major", "minor", or "patch"; or a version string with major, minor, and patch parts, such as "1\\.2\\.3"\\)', + '- Line 3: "1.2\\.\\.\\.3\\." is not a valid version specifier for package "b"', + ' \\(must be "major", "minor", or "patch"; or a version string with major, minor, and patch parts, such as "1\\.2\\.3"\\)', + ].join('\n'), + 'u', + ), + ); + }); + }); + }); +}); diff --git a/src/release-specification-utils.ts b/src/release-specification-utils.ts new file mode 100644 index 0000000..d1e8151 --- /dev/null +++ b/src/release-specification-utils.ts @@ -0,0 +1,298 @@ +import fs, { WriteStream } from 'fs'; +import YAML from 'yaml'; +import { Editor } from './editor-utils'; +import { readFile } from './file-utils'; +import { + debug, + hasProperty, + isErrorWithMessage, + wrapError, + isObject, + runCommand, +} from './misc-utils'; +import { Project } from './project-utils'; +import { isValidSemver, semver, SemVer } from './semver-utils'; + +/** + * The SemVer-compatible parts of a version string that can be bumped by this + * tool. + */ +export enum IncrementableVersionParts { + major = 'major', + minor = 'minor', + patch = 'patch', +} + +/** + * Describes how to update the version for a package, either by bumping a part + * of the version or by setting that version exactly. + */ +type VersionSpecifier = IncrementableVersionParts | SemVer; + +/** + * User-provided instructions for how to update this project in order to prepare + * it for a new release. + * + * @property packages - A mapping of package names to version specifiers. + */ +export interface ReleaseSpecification { + packages: Record; +} + +/** + * Generates a skeleton for a release specification, which describes how a + * project should be updated. + * + * @param args - The set of arguments to this function. + * @param args.project - Information about the project. + * @param args.isEditorAvailable - Whether or not an executable can be found on + * the user's computer to edit the release spec once it is generated. + * @returns The release specification template. + */ +export async function generateReleaseSpecificationTemplateForMonorepo({ + project: { rootPackage, workspacePackages }, + isEditorAvailable, +}: { + project: Project; + isEditorAvailable: boolean; +}) { + const afterEditingInstructions = isEditorAvailable + ? ` +# When you're finished making your selections, save this file and the script +# will continue automatically.`.trim() + : ` +# When you're finished making your selections, save this file and then re-run +# the script that generated this file.`.trim(); + + const instructions = ` +# The following is a list of packages in ${rootPackage.manifest.name}. +# Please indicate the packages for which you want to create a new release +# by updating "null" (which does nothing) to one of the following: +# +# - "major" (if you want to bump the major part of the package's version) +# - "minor" (if you want to bump the minor part of the package's version) +# - "patch" (if you want to bump the patch part of the package's version) +# - an exact version with major, minor, and patch parts (e.g. "1.2.3") +# - null (to skip the package entirely) +# +${afterEditingInstructions} + `.trim(); + + // TODO: List only changed files + const packages = Object.values(workspacePackages).reduce((obj, pkg) => { + return { ...obj, [pkg.manifest.name]: null }; + }, {}); + + return [instructions, YAML.stringify({ packages })].join('\n\n'); +} + +/** + * Launches the given editor to allow the user to update the release spec + * file. + * + * @param releaseSpecificationPath - The path to the release spec file. + * @param editor - Information about the editor. + * @param stdout - A stream that can be used to write to standard out. Defaults + * to /dev/null. + * @returns A promise that resolves when the user has completed editing the + * file, i.e. when the editor process completes. + */ +export async function waitForUserToEditReleaseSpecification( + releaseSpecificationPath: string, + editor: Editor, + stdout: Pick = fs.createWriteStream('/dev/null'), +) { + let caughtError: unknown; + + debug( + `Opening release spec file ${releaseSpecificationPath} with editor located at ${editor.path}...`, + ); + + const promiseForEditorCommand = runCommand( + editor.path, + [...editor.args, releaseSpecificationPath], + { + stdio: 'inherit', + shell: true, + }, + ); + + stdout.write('Waiting for the release spec to be edited...'); + + try { + await promiseForEditorCommand; + } catch (error) { + caughtError = error; + } + + // Clear the previous line + stdout.write('\r\u001B[K'); + + if (caughtError) { + throw wrapError( + caughtError, + ({ message }) => + `Encountered an error while waiting for the release spec to be edited: ${message}`, + ); + } +} + +/** + * Looks over the release spec that the user has edited to ensure that: + * + * 1. the names of all packages match those within the project; and + * 2. the version specifiers for each package are valid. + * + * @param project - Information about the whole project (e.g., names of packages + * and where they can found). + * @param releaseSpecificationPath - The path to the release spec file. + * @returns The validated release spec. + * @throws If there are any issues with the file. + */ +export async function validateReleaseSpecification( + project: Project, + releaseSpecificationPath: string, +): Promise { + const workspacePackageNames = Object.values(project.workspacePackages).map( + (pkg) => pkg.manifest.name, + ); + const releaseSpecificationContents = await readFile(releaseSpecificationPath); + const indexOfFirstUsableLine = releaseSpecificationContents + .split('\n') + .findIndex((line) => !/^#|[ ]+/u.test(line)); + + let unvalidatedReleaseSpecification: { + packages: Record; + }; + + try { + unvalidatedReleaseSpecification = YAML.parse(releaseSpecificationContents); + } catch (error) { + throw wrapError(error, ({ message }) => + [ + 'Failed to parse release spec:', + message, + "The file has been retained for you to make the necessary fixes. Once you've done this, re-run this script.", + releaseSpecificationPath, + ].join('\n\n'), + ); + } + + const postludeForAllErrorMessages = [ + "The release spec file has been retained for you to make the necessary fixes. Once you've done this, re-run this script.", + releaseSpecificationPath, + ].join('\n\n'); + + if ( + !isObject(unvalidatedReleaseSpecification) || + unvalidatedReleaseSpecification.packages === undefined + ) { + const message = [ + `Your release spec could not be processed because it needs to be an object with a \`packages\` property. The value of \`packages\` must itself be an object, where each key is a workspace package in the project and each value is a version specifier ("major", "minor", or "patch"; or a version string with major, minor, and patch parts, such as "1.2.3").`, + `Here is the parsed version of the file you provided:`, + JSON.stringify(unvalidatedReleaseSpecification, null, 2), + postludeForAllErrorMessages, + ].join('\n\n'); + throw new Error(message); + } + + // TODO: Check that no packages that have not been changed have been added + // TODO: Check that the list of packages is not empty + const errors: { message: string | string[]; lineNumber: number }[] = []; + Object.keys(unvalidatedReleaseSpecification.packages).forEach( + (packageName, index) => { + const versionSpecifier = + unvalidatedReleaseSpecification.packages[packageName]; + const lineNumber = indexOfFirstUsableLine + index + 2; + + if (!workspacePackageNames.includes(packageName)) { + errors.push({ + message: `${JSON.stringify( + packageName, + )} is not a package in the project`, + lineNumber, + }); + } + + if ( + versionSpecifier !== null && + !hasProperty(IncrementableVersionParts, versionSpecifier) && + !isValidSemver(versionSpecifier) + ) { + errors.push({ + message: [ + `${JSON.stringify( + versionSpecifier, + )} is not a valid version specifier for package "${packageName}"`, + `(must be "major", "minor", or "patch"; or a version string with major, minor, and patch parts, such as "1.2.3")`, + ], + lineNumber, + }); + } + }, + ); + + if (errors.length > 0) { + const message = [ + 'Your release spec could not be processed due to the following issues:', + errors + .flatMap((error) => { + const itemPrefix = '- '; + const lineNumberPrefix = `Line ${error.lineNumber}: `; + + if (Array.isArray(error.message)) { + return [ + `${itemPrefix}${lineNumberPrefix}${error.message[0]}`, + ...error.message.slice(1).map((line) => { + const spaces = []; + + for ( + let i = 0; + i < itemPrefix.length + lineNumberPrefix.length; + i += 1 + ) { + spaces[i] = ' '; + } + + const indentation = spaces.join(''); + return `${indentation}${line}`; + }), + ]; + } + + return `${itemPrefix}${lineNumberPrefix}${error.message}`; + }) + .join('\n'), + postludeForAllErrorMessages, + ].join('\n\n'); + throw new Error(message); + } + + const packages = Object.keys(unvalidatedReleaseSpecification.packages).reduce( + (obj, packageName) => { + const versionSpecifier = + unvalidatedReleaseSpecification.packages[packageName]; + + if (versionSpecifier) { + switch (versionSpecifier) { + // TODO: Any way to avoid this? + case IncrementableVersionParts.major: + case IncrementableVersionParts.minor: + case IncrementableVersionParts.patch: + return { ...obj, [packageName]: versionSpecifier }; + default: + return { + ...obj, + // Typecast: We know that this will safely parse. + [packageName]: semver.parse(versionSpecifier) as SemVer, + }; + } + } + + return obj; + }, + {} as ReleaseSpecification['packages'], + ); + + return { packages }; +} diff --git a/src/semver-utils.ts b/src/semver-utils.ts new file mode 100644 index 0000000..468162a --- /dev/null +++ b/src/semver-utils.ts @@ -0,0 +1,2 @@ +export { default as semver, SemVer } from 'semver'; +export { isValidSemver } from '@metamask/action-utils'; diff --git a/src/workflow-utils.test.ts b/src/workflow-utils.test.ts new file mode 100644 index 0000000..0841eae --- /dev/null +++ b/src/workflow-utils.test.ts @@ -0,0 +1,38 @@ +import { buildMockProject } from '../tests/unit/helpers'; +import { captureChangesInReleaseBranch } from './workflow-utils'; +import * as gitUtils from './git-utils'; + +describe('workflow-utils', () => { + 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(gitUtils, '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-utils.ts b/src/workflow-utils.ts new file mode 100644 index 0000000..d8e018c --- /dev/null +++ b/src/workflow-utils.ts @@ -0,0 +1,74 @@ +import { Package } from './package-utils'; +import { Project } from './project-utils'; +import { getStdoutFromGitCommandWithin } from './git-utils'; + +/** + * 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, +) { + // TODO: What if the index was dirty before this script was run? Or what if + // you're in the middle of a rebase? Might want to check that up front before + // changes are even made. + // TODO: What if this branch already exists? Append the build number? + 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/unit/helpers.ts b/tests/unit/helpers.ts new file mode 100644 index 0000000..be7e960 --- /dev/null +++ b/tests/unit/helpers.ts @@ -0,0 +1,169 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { SemVer } from 'semver'; +import { nanoid } from 'nanoid'; +import type { Package } from '../../src/package-utils'; +import { + ManifestFieldNames, + ManifestDependencyFieldNames, +} from '../../src/package-manifest-utils'; +import type { Project } from '../../src/project-utils'; +import type { ValidatedManifest } from '../../src/package-manifest-utils'; + +/** + * Returns a version of the given record type where optionality is added to + * the designated keys. + */ +type Unrequire = Omit & { + [P in K]+?: T[P]; +}; + +/** + * Information about the sandbox provided to tests that need access to the + * filesystem. + */ +interface Sandbox { + directoryPath: string; +} + +/** + * The temporary directory that acts as a filesystem sandbox for tests. + */ +const TEMP_DIRECTORY_PATH = path.join( + os.tmpdir(), + 'create-release-branch-tests', +); + +/** + * Creates a temporary directory to hold files that a test could write, runs the + * given function, then ensures that the directory is removed afterward. + * + * @param fn - The function to call. + * @throws If the temporary directory already exists for some reason. This would + * indicate a bug in how the names of the directory is determined. + */ +export async function withSandbox(fn: (sandbox: Sandbox) => any) { + const directoryPath = path.join(TEMP_DIRECTORY_PATH, nanoid()); + let stats; + + try { + stats = await fs.promises.stat(directoryPath); + + if (stats.isDirectory()) { + throw new Error( + `Directory ${directoryPath} already exists, cannot continue`, + ); + } + } catch (error: any) { + if (error.code !== 'ENOENT') { + throw error; + } + } + + await fs.promises.mkdir(directoryPath, { recursive: true }); + + try { + await fn({ directoryPath }); + } finally { + await fs.promises.rmdir(directoryPath, { recursive: true }); + } +} + +/** + * Builds a project object for use in tests. All properties have default + * values, so you can specify only the properties you care about. + * + * @param overrides - The properties that will go into the object. + * @returns The mock Project. + */ +export function buildMockProject(overrides: Partial = {}): Project { + return { + directoryPath: '/path/to/project', + repositoryUrl: 'https://repo.url', + rootPackage: buildMockPackage('root'), + workspacePackages: {}, + isMonorepo: false, + ...overrides, + }; +} + +type MockPackageOverrides = Omit< + Unrequire, + 'manifest' +> & { + manifest?: Omit< + Partial, + ManifestFieldNames.Name | ManifestFieldNames.Version + >; +}; + +/** + * Builds a package object for use in tests. All properties have default + * values, so you can specify only the properties you care about. + * + * @param name - The name of the package. + * @param args - Either the version of the package and the properties that will + * go into the object, or just the properties. + * @returns The mock Package object. + */ +export function buildMockPackage( + name: string, + ...args: [string | SemVer, MockPackageOverrides] | [MockPackageOverrides] | [] +): Package { + let version, overrides; + + if (args.length === 0) { + version = '1.0.0'; + overrides = {}; + } else if (args.length === 1) { + version = '1.0.0'; + overrides = args[0]; + } else { + version = args[0]; + overrides = args[1]; + } + + const { + manifest = {}, + directoryPath = `/path/to/packages/${name}`, + manifestPath = path.join(directoryPath, 'package.json'), + changelogPath = path.join(directoryPath, 'CHANGELOG.md'), + } = overrides; + + return { + directoryPath, + manifest: buildMockManifest({ + ...manifest, + [ManifestFieldNames.Name]: name, + [ManifestFieldNames.Version]: + version instanceof SemVer ? version : new SemVer(version), + }), + manifestPath, + changelogPath, + }; +} + +/** + * Builds a manifest object for use in tests. All properties have default + * values, so you can specify only the properties you care about. + * + * @param overrides - The properties to override in the manifest. + * @returns The mock ValidatedManifest. + */ +export function buildMockManifest( + overrides: Partial = {}, +): ValidatedManifest { + return { + [ManifestFieldNames.Name]: 'foo', + [ManifestFieldNames.Version]: new SemVer('1.2.3'), + [ManifestFieldNames.Private]: false, + [ManifestFieldNames.Workspaces]: [], + [ManifestDependencyFieldNames.Bundled]: {}, + [ManifestDependencyFieldNames.Production]: {}, + [ManifestDependencyFieldNames.Development]: {}, + [ManifestDependencyFieldNames.Optional]: {}, + [ManifestDependencyFieldNames.Peer]: {}, + ...overrides, + }; +} diff --git a/tsconfig.json b/tsconfig.json index 02eb0aa..7502900 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,12 +2,17 @@ "compilerOptions": { "esModuleInterop": true, "forceConsistentCasingInFileNames": true, - "lib": ["ES2020"], + "lib": [ + "ES2020" + ], "module": "CommonJS", "moduleResolution": "node", "noEmit": true, + "noErrorTruncation": true, "strict": true, "target": "es2017" }, - "exclude": ["./dist/**/*"] + "exclude": [ + "./dist/**/*" + ] } diff --git a/yarn.lock b/yarn.lock index c1e5680..076b0cf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -835,6 +835,17 @@ __metadata: languageName: node linkType: hard +"@metamask/action-utils@npm:^0.0.2": + version: 0.0.2 + resolution: "@metamask/action-utils@npm:0.0.2" + dependencies: + "@types/semver": ^7.3.6 + glob: ^7.1.7 + semver: ^7.3.5 + checksum: 4d3552d77a329791e2b1da0ca5023e9e04c1920c61f06ef070e8b4f4f072dd1f632124003964d47cdebf314a621a12d7209fbdd6871db37cfa1330a6ed679a11 + languageName: node + linkType: hard + "@metamask/auto-changelog@npm:^2.3.0": version: 2.6.1 resolution: "@metamask/auto-changelog@npm:2.6.1" @@ -854,15 +865,24 @@ __metadata: resolution: "@metamask/create-release-branch@workspace:." dependencies: "@lavamoat/allow-scripts": ^2.0.3 + "@metamask/action-utils": ^0.0.2 "@metamask/auto-changelog": ^2.3.0 "@metamask/eslint-config": ^9.0.0 "@metamask/eslint-config-jest": ^9.0.0 "@metamask/eslint-config-nodejs": ^9.0.0 "@metamask/eslint-config-typescript": ^9.0.1 + "@metamask/utils": ^2.0.0 + "@types/debug": ^4.1.7 "@types/jest": ^28.1.4 + "@types/jest-when": ^3.5.2 "@types/node": ^17.0.23 + "@types/rimraf": ^3.0.2 + "@types/which": ^2.0.1 + "@types/yargs": ^17.0.10 "@typescript-eslint/eslint-plugin": ^4.21.0 "@typescript-eslint/parser": ^4.21.0 + debug: ^4.3.4 + deepmerge: ^4.2.2 eslint: ^7.23.0 eslint-config-prettier: ^8.1.0 eslint-plugin-import: ^2.22.1 @@ -870,14 +890,23 @@ __metadata: eslint-plugin-jsdoc: ^36.1.0 eslint-plugin-node: ^11.1.0 eslint-plugin-prettier: ^3.3.1 + execa: ^5.0.0 + glob: ^8.0.3 jest: ^28.0.0 jest-it-up: ^2.0.2 + jest-when: ^3.5.1 + nanoid: ^3.3.4 prettier: ^2.2.1 prettier-plugin-packagejson: ^2.2.17 rimraf: ^3.0.2 + semver: ^7.3.7 + stdio-mock: ^1.2.0 ts-jest: ^28.0.0 ts-node: ^10.7.0 typescript: ^4.2.4 + which: ^2.0.2 + yaml: ^2.1.1 + yargs: ^17.5.1 languageName: unknown linkType: soft @@ -930,6 +959,15 @@ __metadata: languageName: node linkType: hard +"@metamask/utils@npm:^2.0.0": + version: 2.0.0 + resolution: "@metamask/utils@npm:2.0.0" + dependencies: + fast-deep-equal: ^3.1.3 + checksum: 517afc6724e58aee889b9962fcedc0345cb264ed8232756cd16e2d47e22b5501af276986a3d84a9ab903075d20802bf38ff4f6a70c58a158666f18cb69ff458d + languageName: node + linkType: hard + "@nodelib/fs.scandir@npm:2.1.5": version: 2.1.5 resolution: "@nodelib/fs.scandir@npm:2.1.5" @@ -1106,7 +1144,16 @@ __metadata: languageName: node linkType: hard -"@types/glob@npm:^7.1.1": +"@types/debug@npm:^4.1.7": + version: 4.1.7 + resolution: "@types/debug@npm:4.1.7" + dependencies: + "@types/ms": "*" + checksum: 0a7b89d8ed72526858f0b61c6fd81f477853e8c4415bb97f48b1b5545248d2ae389931680b94b393b993a7cfe893537a200647d93defe6d87159b96812305adc + languageName: node + linkType: hard + +"@types/glob@npm:*, @types/glob@npm:^7.1.1": version: 7.2.0 resolution: "@types/glob@npm:7.2.0" dependencies: @@ -1150,7 +1197,16 @@ __metadata: languageName: node linkType: hard -"@types/jest@npm:^28.1.4": +"@types/jest-when@npm:^3.5.2": + version: 3.5.2 + resolution: "@types/jest-when@npm:3.5.2" + dependencies: + "@types/jest": "*" + checksum: 106230dd71ee266bbd7620ab339a7305054e56ba7638f0eff9f222e67a959df0ad68a7f0108156c50f7005881bae59cd5c38b3760c101d060cdb3dac9cd77ee2 + languageName: node + linkType: hard + +"@types/jest@npm:*, @types/jest@npm:^28.1.4": version: 28.1.4 resolution: "@types/jest@npm:28.1.4" dependencies: @@ -1181,6 +1237,13 @@ __metadata: languageName: node linkType: hard +"@types/ms@npm:*": + version: 0.7.31 + resolution: "@types/ms@npm:0.7.31" + checksum: daadd354aedde024cce6f5aa873fefe7b71b22cd0e28632a69e8b677aeb48ae8caa1c60e5919bb781df040d116b01cb4316335167a3fc0ef6a63fa3614c0f6da + languageName: node + linkType: hard + "@types/node@npm:*": version: 18.0.3 resolution: "@types/node@npm:18.0.3" @@ -1202,6 +1265,23 @@ __metadata: languageName: node linkType: hard +"@types/rimraf@npm:^3.0.2": + version: 3.0.2 + resolution: "@types/rimraf@npm:3.0.2" + dependencies: + "@types/glob": "*" + "@types/node": "*" + checksum: b47fa302f46434cba704d20465861ad250df79467d3d289f9d6490d3aeeb41e8cb32dd80bd1a8fd833d1e185ac719fbf9be12e05ad9ce9be094d8ee8f1405347 + languageName: node + linkType: hard + +"@types/semver@npm:^7.3.6": + version: 7.3.10 + resolution: "@types/semver@npm:7.3.10" + checksum: 7047c2822b1759b2b950f39cfcf261f2b9dca47b4b55bdebba0905a8553631f1531eb0f59264ffe4834d1198c8331c8e0010a4cd742f4e0b60abbf399d134364 + languageName: node + linkType: hard + "@types/stack-utils@npm:^2.0.0": version: 2.0.1 resolution: "@types/stack-utils@npm:2.0.1" @@ -1209,6 +1289,13 @@ __metadata: languageName: node linkType: hard +"@types/which@npm:^2.0.1": + version: 2.0.1 + resolution: "@types/which@npm:2.0.1" + checksum: 14e963f2ffaa79caaa13044e977456085a1024cb519478f0f5f9dc8a4f33a0ac47f0a255ccbd008efde063c75b98e846b9258b5999bac6f595e06d5518920cb7 + languageName: node + linkType: hard + "@types/yargs-parser@npm:*": version: 21.0.0 resolution: "@types/yargs-parser@npm:21.0.0" @@ -1216,7 +1303,7 @@ __metadata: languageName: node linkType: hard -"@types/yargs@npm:^17.0.8": +"@types/yargs@npm:^17.0.10, @types/yargs@npm:^17.0.8": version: 17.0.10 resolution: "@types/yargs@npm:17.0.10" dependencies: @@ -2071,7 +2158,7 @@ __metadata: languageName: node linkType: hard -"debug@npm:4, debug@npm:^4.0.1, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.3": +"debug@npm:4, debug@npm:^4.0.1, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.3, debug@npm:^4.3.4": version: 4.3.4 resolution: "debug@npm:4.3.4" dependencies: @@ -3014,7 +3101,7 @@ __metadata: languageName: node linkType: hard -"glob@npm:^7.1.3, glob@npm:^7.1.4": +"glob@npm:^7.1.3, glob@npm:^7.1.4, glob@npm:^7.1.7": version: 7.2.3 resolution: "glob@npm:7.2.3" dependencies: @@ -3028,7 +3115,7 @@ __metadata: languageName: node linkType: hard -"glob@npm:^8.0.1": +"glob@npm:^8.0.1, glob@npm:^8.0.3": version: 8.0.3 resolution: "glob@npm:8.0.3" dependencies: @@ -4018,6 +4105,15 @@ __metadata: languageName: node linkType: hard +"jest-when@npm:^3.5.1": + version: 3.5.1 + resolution: "jest-when@npm:3.5.1" + peerDependencies: + jest: ">= 25" + checksum: 1efb9f497f7c846fe8b0f4125d5f449c4a4d78d5d0afa910d134b301ae4c119ea52c9465db38d2146269d42808afe8f3a4328d1d656878a9a69458ee653f6499 + languageName: node + linkType: hard + "jest-worker@npm:^28.1.1": version: 28.1.1 resolution: "jest-worker@npm:28.1.1" @@ -4493,6 +4589,15 @@ __metadata: languageName: node linkType: hard +"nanoid@npm:^3.3.4": + version: 3.3.4 + resolution: "nanoid@npm:3.3.4" + bin: + nanoid: bin/nanoid.cjs + checksum: 2fddd6dee994b7676f008d3ffa4ab16035a754f4bb586c61df5a22cf8c8c94017aadd360368f47d653829e0569a92b129979152ff97af23a558331e47e37cd9c + languageName: node + linkType: hard + "natural-compare@npm:^1.4.0": version: 1.4.0 resolution: "natural-compare@npm:1.4.0" @@ -5213,7 +5318,7 @@ __metadata: languageName: node linkType: hard -"semver@npm:7.x, semver@npm:^7.2.1, semver@npm:^7.3.2, semver@npm:^7.3.5": +"semver@npm:7.x, semver@npm:^7.2.1, semver@npm:^7.3.2, semver@npm:^7.3.5, semver@npm:^7.3.7": version: 7.3.7 resolution: "semver@npm:7.3.7" dependencies: @@ -5437,6 +5542,13 @@ __metadata: languageName: node linkType: hard +"stdio-mock@npm:^1.2.0": + version: 1.2.0 + resolution: "stdio-mock@npm:1.2.0" + checksum: 5c8739e11fc5a18cd5ef0b2e8d900cd1cd4e851f69915d3a829ebdaefd5292ece17f29981516636f9b9acb3026d8c802de6f138280a293cc484160a6c67e65ef + languageName: node + linkType: hard + "string-length@npm:^4.0.1": version: 4.0.2 resolution: "string-length@npm:4.0.2" @@ -6054,6 +6166,13 @@ __metadata: languageName: node linkType: hard +"yaml@npm:^2.1.1": + version: 2.1.1 + resolution: "yaml@npm:2.1.1" + checksum: f48bb209918aa57cfaf78ef6448d1a1f8187f45c746f933268b7023dc59e5456004611879126c9bb5ea55b0a2b1c2b392dfde436931ece0c703a3d754562bb96 + languageName: node + linkType: hard + "yargs-parser@npm:^20.2.2": version: 20.2.9 resolution: "yargs-parser@npm:20.2.9" @@ -6083,7 +6202,7 @@ __metadata: languageName: node linkType: hard -"yargs@npm:^17.0.1, yargs@npm:^17.3.1": +"yargs@npm:^17.0.1, yargs@npm:^17.3.1, yargs@npm:^17.5.1": version: 17.5.1 resolution: "yargs@npm:17.5.1" dependencies: From 10d62bc96f17880d5e9fe15d3b3d45d3d574bcda Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Tue, 19 Jul 2022 09:21:13 -0600 Subject: [PATCH 03/41] Fix lint issues / roll back some changes --- .eslintrc.js | 8 +++++--- jest.config.js | 6 ++++-- package.json | 22 +++++++++++----------- src/package-utils.ts | 2 +- src/release-specification-utils.ts | 1 - tsconfig.json | 9 ++------- 6 files changed, 23 insertions(+), 25 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index 33d3734..14c9375 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -29,9 +29,6 @@ module.exports = { next: 'multiline-block-like', }, ], - // This prevents using bulleted/numbered lists in JSDoc blocks. - // See: - 'jsdoc/check-indentation': 'off', // It's common for scripts to access `process.env` 'node/no-process-env': 'off', // It's common for scripts to exit explicitly @@ -42,6 +39,11 @@ module.exports = { { files: ['*.ts'], extends: ['@metamask/eslint-config-typescript'], + rules: { + // This prevents using bulleted/numbered lists in JSDoc blocks. + // See: + 'jsdoc/check-indentation': 'off', + }, }, { diff --git a/jest.config.js b/jest.config.js index 220d289..dac08ad 100644 --- a/jest.config.js +++ b/jest.config.js @@ -112,7 +112,7 @@ module.exports = { resetMocks: true, // Reset the module registry before running each individual test - resetModules: true, + // resetModules: false, // A path to a custom resolver // resolver: undefined, @@ -161,7 +161,9 @@ module.exports = { // ], // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped - testPathIgnorePatterns: ['/node_modules/', '/src/old/'], + // testPathIgnorePatterns: [ + // "/node_modules/" + // ], // The regexp pattern or array of patterns that Jest uses to detect test files // testRegex: [], diff --git a/package.json b/package.json index ae6f0d2..b0c1dbc 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,17 @@ "test": "jest && jest-it-up", "test:watch": "jest --watch" }, + "dependencies": { + "@metamask/action-utils": "^0.0.2", + "@metamask/utils": "^2.0.0", + "debug": "^4.3.4", + "execa": "^5.0.0", + "glob": "^8.0.3", + "semver": "^7.3.7", + "which": "^2.0.2", + "yaml": "^2.1.1", + "yargs": "^17.5.1" + }, "devDependencies": { "@lavamoat/allow-scripts": "^2.0.3", "@metamask/auto-changelog": "^2.3.0", @@ -70,16 +81,5 @@ "allowScripts": { "@lavamoat/preinstall-always-fail": false } - }, - "dependencies": { - "@metamask/action-utils": "^0.0.2", - "@metamask/utils": "^2.0.0", - "debug": "^4.3.4", - "execa": "^5.0.0", - "glob": "^8.0.3", - "semver": "^7.3.7", - "which": "^2.0.2", - "yaml": "^2.1.1", - "yargs": "^17.5.1" } } diff --git a/src/package-utils.ts b/src/package-utils.ts index 1e9505b..c987b7d 100644 --- a/src/package-utils.ts +++ b/src/package-utils.ts @@ -1,7 +1,7 @@ import fs, { WriteStream } from 'fs'; import path from 'path'; import { updateChangelog } from '@metamask/auto-changelog'; -import { isErrorWithCode, isErrorWithMessage } from './misc-utils'; +import { isErrorWithCode } from './misc-utils'; import { readFile, writeFile, writeJsonFile } from './file-utils'; import { Project } from './project-utils'; import { PackageReleasePlan } from './workflow-utils'; diff --git a/src/release-specification-utils.ts b/src/release-specification-utils.ts index d1e8151..c7581d3 100644 --- a/src/release-specification-utils.ts +++ b/src/release-specification-utils.ts @@ -5,7 +5,6 @@ import { readFile } from './file-utils'; import { debug, hasProperty, - isErrorWithMessage, wrapError, isObject, runCommand, diff --git a/tsconfig.json b/tsconfig.json index 7502900..02eb0aa 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,17 +2,12 @@ "compilerOptions": { "esModuleInterop": true, "forceConsistentCasingInFileNames": true, - "lib": [ - "ES2020" - ], + "lib": ["ES2020"], "module": "CommonJS", "moduleResolution": "node", "noEmit": true, - "noErrorTruncation": true, "strict": true, "target": "es2017" }, - "exclude": [ - "./dist/**/*" - ] + "exclude": ["./dist/**/*"] } From cc6958841223d7030390171105c93f43ff528ee6 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Tue, 19 Jul 2022 09:54:35 -0600 Subject: [PATCH 04/41] Fix test failure on Node 16 --- src/file-utils.test.ts | 2 +- tests/unit/helpers.ts | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/file-utils.test.ts b/src/file-utils.test.ts index 009809c..e426bc2 100644 --- a/src/file-utils.test.ts +++ b/src/file-utils.test.ts @@ -64,7 +64,7 @@ describe('file-utils', () => { }); }); - it('re-throws any error that occurs, assigning it the same code, a wrapped message, and a new stack', async () => { + it.only('re-throws any error that occurs, assigning it the same code, a wrapped message, and a new stack', async () => { await withSandbox(async (sandbox) => { await promisifiedRimraf(sandbox.directoryPath); const filePath = path.join(sandbox.directoryPath, 'test'); diff --git a/tests/unit/helpers.ts b/tests/unit/helpers.ts index be7e960..0d4c5e6 100644 --- a/tests/unit/helpers.ts +++ b/tests/unit/helpers.ts @@ -1,6 +1,8 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; +import util from 'util'; +import rimraf from 'rimraf'; import { SemVer } from 'semver'; import { nanoid } from 'nanoid'; import type { Package } from '../../src/package-utils'; @@ -27,6 +29,8 @@ interface Sandbox { directoryPath: string; } +const promisifiedRimraf = util.promisify(rimraf); + /** * The temporary directory that acts as a filesystem sandbox for tests. */ @@ -66,7 +70,7 @@ export async function withSandbox(fn: (sandbox: Sandbox) => any) { try { await fn({ directoryPath }); } finally { - await fs.promises.rmdir(directoryPath, { recursive: true }); + await promisifiedRimraf(directoryPath); } } From 557b5c12c89dbdd30753398b229380bd63d5fbff Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Tue, 19 Jul 2022 09:59:40 -0600 Subject: [PATCH 05/41] Re-enable this test --- src/file-utils.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/file-utils.test.ts b/src/file-utils.test.ts index e426bc2..009809c 100644 --- a/src/file-utils.test.ts +++ b/src/file-utils.test.ts @@ -64,7 +64,7 @@ describe('file-utils', () => { }); }); - it.only('re-throws any error that occurs, assigning it the same code, a wrapped message, and a new stack', async () => { + it('re-throws any error that occurs, assigning it the same code, a wrapped message, and a new stack', async () => { await withSandbox(async (sandbox) => { await promisifiedRimraf(sandbox.directoryPath); const filePath = path.join(sandbox.directoryPath, 'test'); From 9b2d02fdb810641de4e67d94d3175f88ef038418 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Tue, 19 Jul 2022 16:01:06 -0600 Subject: [PATCH 06/41] Clean up a couple of things --- src/monorepo-workflow-utils.test.ts | 119 ---------------------------- src/release-specification-utils.ts | 28 ++++--- 2 files changed, 16 insertions(+), 131 deletions(-) diff --git a/src/monorepo-workflow-utils.test.ts b/src/monorepo-workflow-utils.test.ts index 9ec281d..cfdbbb6 100644 --- a/src/monorepo-workflow-utils.test.ts +++ b/src/monorepo-workflow-utils.test.ts @@ -1542,31 +1542,6 @@ describe('monorepo-workflow-utils', () => { }); }); -/** - * Builds a project for use in tests, where `directoryPath` and `repositoryUrl` - * do not have to be provided (they are filled in with reasonable defaults). - * - * @param overrides - The properties that will go into the object. - * @returns The mock Project object. - */ -/* -function buildMockProject( - overrides: Unrequire, -): Project { - const { - directoryPath = '/path/to/project', - repositoryUrl = 'https://repo.url', - ...rest - } = overrides; - - return { - directoryPath, - repositoryUrl, - ...rest, - }; -} -*/ - /** * Builds a project for use in tests which represents a monorepo. * @@ -1581,60 +1556,6 @@ function buildMockMonorepoProject(overrides: Partial = {}) { }); } -/** - * Builds a package for use in tests, where `directoryPath`, `manifestPath`, and - * `changelogPath` do not have to be provided (they are filled in with - * reasonable defaults), and where some fields in `manifest` is prefilled based - * on `name` and `version`. - * - * TODO: Reuse helper in `helpers.ts`. - * - * @param name - The name of the package. - * @param version - The version of the package, as a version string. - * @param overrides - The properties that will go into the object. - * @returns The mock Package object. - */ -/* -function buildMockPackage( - name: string, - version: string, - overrides: Omit< - Unrequire, - 'manifest' - > & { - manifest: Omit< - Unrequire< - ValidatedManifest, - | packageManifestUtils.ManifestFieldNames.Workspaces - | packageManifestUtils.ManifestDependencyFieldNames - >, - | packageManifestUtils.ManifestFieldNames.Name - | packageManifestUtils.ManifestFieldNames.Version - >; - }, -): Package { - const { - directoryPath = `/path/to/packages/${name}`, - manifest, - manifestPath = path.join(directoryPath, 'package.json'), - changelogPath = path.join(directoryPath, 'CHANGELOG.md'), - ...rest - } = overrides; - - return { - directoryPath, - manifest: buildMockManifest({ - ...manifest, - [packageManifestUtils.ManifestFieldNames.Name]: name, - [packageManifestUtils.ManifestFieldNames.Version]: new SemVer(version), - }), - manifestPath, - changelogPath, - ...rest, - }; -} -*/ - /** * Builds a package for use in tests which is designed to be the root package of * a monorepo. @@ -1662,46 +1583,6 @@ function buildMockMonorepoRootPackage( }); } -/** - * Builds a manifest object for use in tests, where `workspaces` and - * `*Dependencies` fields do not have to be provided (they are filled in with - * empty collections by default). - * - * TODO: Reuse helper in `helpers.ts`. - * - * @param overrides - The properties that will go into the object. - * @returns The mock ValidatedManifest object. - */ -/* -function buildMockManifest( - overrides: Unrequire< - ValidatedManifest, - | packageManifestUtils.ManifestFieldNames.Workspaces - | packageManifestUtils.ManifestDependencyFieldNames - >, -): ValidatedManifest { - const { - workspaces = [], - dependencies = {}, - devDependencies = {}, - peerDependencies = {}, - bundledDependencies = {}, - optionalDependencies = {}, - ...rest - } = overrides; - - return { - workspaces, - dependencies, - devDependencies, - peerDependencies, - bundledDependencies, - optionalDependencies, - ...rest, - }; -} -*/ - /** * Mocks dependencies that `followMonorepoWorkflow` uses internally. * diff --git a/src/release-specification-utils.ts b/src/release-specification-utils.ts index c7581d3..d29322d 100644 --- a/src/release-specification-utils.ts +++ b/src/release-specification-utils.ts @@ -273,19 +273,23 @@ export async function validateReleaseSpecification( unvalidatedReleaseSpecification.packages[packageName]; if (versionSpecifier) { - switch (versionSpecifier) { - // TODO: Any way to avoid this? - case IncrementableVersionParts.major: - case IncrementableVersionParts.minor: - case IncrementableVersionParts.patch: - return { ...obj, [packageName]: versionSpecifier }; - default: - return { - ...obj, - // Typecast: We know that this will safely parse. - [packageName]: semver.parse(versionSpecifier) as SemVer, - }; + if ( + Object.values(IncrementableVersionParts).includes( + versionSpecifier as any, + ) + ) { + return { + ...obj, + // Typecast: We know what this is as we've checked it above. + [packageName]: versionSpecifier as IncrementableVersionParts, + }; } + + return { + ...obj, + // Typecast: We know that this will safely parse. + [packageName]: semver.parse(versionSpecifier) as SemVer, + }; } return obj; From c622b3ad09cbf954da43627944c5a76301c0c347 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Thu, 21 Jul 2022 14:25:34 -0600 Subject: [PATCH 07/41] Remove TODOs --- src/main.ts | 1 - src/monorepo-workflow-utils.ts | 4 ---- src/package-manifest-utils.ts | 20 -------------------- src/release-specification-utils.ts | 3 --- src/workflow-utils.ts | 4 ---- 5 files changed, 32 deletions(-) diff --git a/src/main.ts b/src/main.ts index 2fd500a..27d5484 100644 --- a/src/main.ts +++ b/src/main.ts @@ -40,6 +40,5 @@ export async function main({ stdout.write( 'Project does not appear to have any workspaces. Following polyrepo workflow.\n', ); - // TODO } } diff --git a/src/monorepo-workflow-utils.ts b/src/monorepo-workflow-utils.ts index 359bcca..ccdd9a7 100644 --- a/src/monorepo-workflow-utils.ts +++ b/src/monorepo-workflow-utils.ts @@ -96,9 +96,6 @@ export async function followMonorepoWorkflow({ stdout.write( 'Release spec already exists. Picking back up from previous run.\n', ); - // TODO: If we end up here, then we will probably get an error later when - // attempting to bump versions of packages, as that may have already - // happened — we need to be idempotent } else { const editor = await determineEditor(); @@ -162,7 +159,6 @@ async function planRelease( releaseSpecificationPath: string, ): Promise { const today = getToday(); - // TODO: What if this version already exists? const newReleaseName = today.toISOString().replace(/T.+$/u, ''); const newRootVersion = [ today.getUTCFullYear(), diff --git a/src/package-manifest-utils.ts b/src/package-manifest-utils.ts index d342aa9..591351e 100644 --- a/src/package-manifest-utils.ts +++ b/src/package-manifest-utils.ts @@ -13,16 +13,12 @@ export { ManifestFieldNames, ManifestDependencyFieldNames }; * An unverified representation of the data in a package's `package.json`. * (We know which properties could be present but haven't checked their types * yet.) - * - * TODO: Move this to action-utils. */ type UnvalidatedManifest = Readonly>> & Readonly>>; /** * A type-checked representation of the data in a package's `package.json`. - * - * TODO: Move this to action-utils. */ export type ValidatedManifest = { readonly [ManifestFieldNames.Name]: string; @@ -95,8 +91,6 @@ const validationForManifestDependenciesField = { /** * Type guard to ensure that the given "version" field of a manifest is valid. * - * TODO: Move this to action-utils. - * * @param version - The value to check. * @returns Whether the value is valid. */ @@ -108,8 +102,6 @@ function isValidManifestVersionField(version: any): version is string { * Type guard to ensure that the given "workspaces" field of a manifest is * valid. * - * TODO: Move this to action-utils. - * * @param workspaces - The value to check. * @returns Whether the value is valid. */ @@ -126,8 +118,6 @@ function isValidManifestWorkspacesField( /** * Type guard to ensure that the given "private" field of a manifest is valid. * - * TODO: Move this to action-utils. - * * @param privateValue - The value to check. * @returns Whether the value is valid. */ @@ -144,8 +134,6 @@ function isValidManifestPrivateField( /** * Type guard to ensure that the given dependencies field of a manifest is valid. * - * TODO: Move this to action-utils. - * * @param dependencies - The value to check. * @returns Whether the value is valid. */ @@ -163,8 +151,6 @@ function isValidManifestDependenciesField( /** * Constructs a message for a manifest file validation error. * - * TODO: Remove when other functions are moved to action-utils. - * * @param args - The arguments. * @param args.manifest - The manifest data that's invalid. * @param args.parentDirectory - The directory of the package to which the manifest @@ -197,8 +183,6 @@ function buildManifestFieldValidationErrorMessage({ * Retrieves and validates a field within a package manifest object, throwing if * validation fails. * - * TODO: Move this to action-utils. - * * @template T - The expected type of the field value (should include * `undefined` if not expected to present). * @template U - The return type of this function, as determined via @@ -265,8 +249,6 @@ function readManifestField({ * Retrieves and checks the dependency fields of a package manifest object, * throwing if any of them is not present or is not the correct type. * - * TODO: Move this to action-utils. - * * @param manifest - The manifest data to validate. * @param parentDirectory - The directory of the package to which the manifest * belongs. @@ -295,8 +277,6 @@ function readManifestDependencyFields( * Reads the package manifest at the given path, verifying key data within the * manifest and throwing if that data is incomplete. * - * TODO: Move this to action-utils. - * * @param manifestPath - The path of the manifest file. * @returns Information about a correctly typed version of the manifest for a * package. diff --git a/src/release-specification-utils.ts b/src/release-specification-utils.ts index d29322d..78ce2b6 100644 --- a/src/release-specification-utils.ts +++ b/src/release-specification-utils.ts @@ -77,7 +77,6 @@ export async function generateReleaseSpecificationTemplateForMonorepo({ ${afterEditingInstructions} `.trim(); - // TODO: List only changed files const packages = Object.values(workspacePackages).reduce((obj, pkg) => { return { ...obj, [pkg.manifest.name]: null }; }, {}); @@ -195,8 +194,6 @@ export async function validateReleaseSpecification( throw new Error(message); } - // TODO: Check that no packages that have not been changed have been added - // TODO: Check that the list of packages is not empty const errors: { message: string | string[]; lineNumber: number }[] = []; Object.keys(unvalidatedReleaseSpecification.packages).forEach( (packageName, index) => { diff --git a/src/workflow-utils.ts b/src/workflow-utils.ts index d8e018c..79ea495 100644 --- a/src/workflow-utils.ts +++ b/src/workflow-utils.ts @@ -56,10 +56,6 @@ export async function captureChangesInReleaseBranch( project: Project, releasePlan: ReleasePlan, ) { - // TODO: What if the index was dirty before this script was run? Or what if - // you're in the middle of a rebase? Might want to check that up front before - // changes are even made. - // TODO: What if this branch already exists? Append the build number? await getStdoutFromGitCommandWithin(project.directoryPath, [ 'checkout', '-b', From f7c1538fbe31cbd0943a5fcbc0d000878c9b8fb8 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Wed, 27 Jul 2022 12:16:46 -0600 Subject: [PATCH 08/41] Fix name of test in `git-utils` Co-authored-by: Mark Stacey --- src/git-utils.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/git-utils.test.ts b/src/git-utils.test.ts index 4201879..b10cc77 100644 --- a/src/git-utils.test.ts +++ b/src/git-utils.test.ts @@ -37,7 +37,7 @@ describe('git-utils', () => { ); }); - it('converts a private GitHub repo URL into a public one', async () => { + it('converts an SSH GitHub repo URL into an HTTPS URL', async () => { const projectDirectoryPath = '/path/to/project'; when(jest.spyOn(miscUtils, 'getStdoutFromCommand')) .calledWith('git', ['config', '--get', 'remote.origin.url'], { From 137c0d9d3bc14bddd1c304a96f23980b35826af9 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Wed, 27 Jul 2022 12:18:04 -0600 Subject: [PATCH 09/41] Respond to feedback --- .eslintrc.js | 7 ------- jest.config.js | 2 +- src/{index.ts => cli.ts} | 8 ++++---- src/misc-utils.ts | 8 -------- src/monorepo-workflow-utils.ts | 23 +++++++++++------------ src/workflow-utils.ts | 4 ++-- 6 files changed, 18 insertions(+), 34 deletions(-) rename src/{index.ts => cli.ts} (67%) diff --git a/.eslintrc.js b/.eslintrc.js index 14c9375..778d9c3 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -31,19 +31,12 @@ module.exports = { ], // It's common for scripts to access `process.env` 'node/no-process-env': 'off', - // It's common for scripts to exit explicitly - 'node/no-process-exit': 'off', }, overrides: [ { files: ['*.ts'], extends: ['@metamask/eslint-config-typescript'], - rules: { - // This prevents using bulleted/numbered lists in JSDoc blocks. - // See: - 'jsdoc/check-indentation': 'off', - }, }, { diff --git a/jest.config.js b/jest.config.js index dac08ad..cd9a6f2 100644 --- a/jest.config.js +++ b/jest.config.js @@ -30,7 +30,7 @@ module.exports = { // An array of regexp pattern strings used to skip coverage collection coveragePathIgnorePatterns: [ '/node_modules/', - '/src/index.ts', + '/src/cli.ts', '/src/inputs-utils.ts', ], diff --git a/src/index.ts b/src/cli.ts similarity index 67% rename from src/index.ts rename to src/cli.ts index e97044b..65f6dcf 100644 --- a/src/index.ts +++ b/src/cli.ts @@ -3,7 +3,7 @@ import { main } from './main'; /** * The entrypoint to this script. */ -async function index() { +async function cli() { await main({ argv: process.argv, cwd: process.cwd(), @@ -12,7 +12,7 @@ async function index() { }); } -index().catch((error) => { - console.error(error.stack); - process.exit(1); +cli().catch((error) => { + console.error(error); + process.exitCode = 1; }); diff --git a/src/misc-utils.ts b/src/misc-utils.ts index a3cfe59..6497a5b 100644 --- a/src/misc-utils.ts +++ b/src/misc-utils.ts @@ -18,14 +18,6 @@ export const debug = createDebug('create-release-branch:impl'); */ 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. - */ -export type Unrequire = Omit & { - [P in K]+?: T[P]; -}; - /** * 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 diff --git a/src/monorepo-workflow-utils.ts b/src/monorepo-workflow-utils.ts index ccdd9a7..d965984 100644 --- a/src/monorepo-workflow-utils.ts +++ b/src/monorepo-workflow-utils.ts @@ -46,21 +46,20 @@ function getToday() { * For a monorepo, the process works like this: * * - The script generates a release spec template, listing the workspace - * packages in the project that have changed since the last release (or all of - * the packages if this would be the first release). + * packages in the project that have changed since the last release (or all of + * the packages if this would be the first release). * - The script then presents the template to the user so that they can specify - * the desired versions for each package. It first does this by attempting to - * locate an appropriate code editor on the user's computer (using the - * `EDITOR` environment variable if that is defined, otherwise `code` if it is - * present) and opening the file there, pausing while the user is editing the - * file. If no editor can be found, the script provides the user with the path - * to the template so that they can edit it themselves, then exits. + * the desired versions for each package. It first does this by attempting to + * locate an appropriate code editor on the user's computer (using the `EDITOR` + * environment variable if that is defined, otherwise `code` if it is present) + * and opening the file there, pausing while the user is editing the file. If no + * editor can be found, the script provides the user with the path to the + * template so that they can edit it themselves, then exits. * - However the user has edited the file, the script will parse and validate - * the information in the file, then apply the desired changes to the - * monorepo. + * the information in the file, then apply the desired changes to the monorepo. * - Finally, once it has made the desired changes, the script will create a Git - * commit that includes the changes, then create a branch using the current - * date as the name. + * commit that includes the changes, then create a branch using the current date + * as the name. * * @param options - The options. * @param options.project - Information about the project. diff --git a/src/workflow-utils.ts b/src/workflow-utils.ts index 79ea495..2016756 100644 --- a/src/workflow-utils.ts +++ b/src/workflow-utils.ts @@ -42,9 +42,9 @@ export interface PackageReleasePlan { * 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. + * 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). + * of the new release). * 3. Switches to that branch. * * @param project - Information about the whole project (e.g., names of packages From 29545e51b3b42454a80d7c14a58cbaea09089b6d Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Wed, 27 Jul 2022 12:43:19 -0600 Subject: [PATCH 10/41] Fix docs for resolveExecutable --- src/misc-utils.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/misc-utils.ts b/src/misc-utils.ts index 6497a5b..6362107 100644 --- a/src/misc-utils.ts +++ b/src/misc-utils.ts @@ -117,10 +117,11 @@ export function knownKeysOf( } /** - * Tests the given path to determine whether it represents an executable. + * Retrieves the real path of an executable via `which`. * * @param executablePath - The path to an executable. - * @returns A promise for true or false, depending on the result. + * @returns The resolved path to the executable. + * @throws what `which` throws if it is not a "not found" error. */ export async function resolveExecutable( executablePath: string, From 7a9af7e0f52670bf0325ba7019ba5311423eaa7e Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Wed, 27 Jul 2022 12:45:07 -0600 Subject: [PATCH 11/41] Remove deepmerge --- package.json | 1 - yarn.lock | 1 - 2 files changed, 2 deletions(-) diff --git a/package.json b/package.json index b0c1dbc..3f710c3 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,6 @@ "@types/yargs": "^17.0.10", "@typescript-eslint/eslint-plugin": "^4.21.0", "@typescript-eslint/parser": "^4.21.0", - "deepmerge": "^4.2.2", "eslint": "^7.23.0", "eslint-config-prettier": "^8.1.0", "eslint-plugin-import": "^2.22.1", diff --git a/yarn.lock b/yarn.lock index 076b0cf..4a6b21e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -882,7 +882,6 @@ __metadata: "@typescript-eslint/eslint-plugin": ^4.21.0 "@typescript-eslint/parser": ^4.21.0 debug: ^4.3.4 - deepmerge: ^4.2.2 eslint: ^7.23.0 eslint-config-prettier: ^8.1.0 eslint-plugin-import: ^2.22.1 From c7d33f599a4c26c0a9551debf8c6a73209a6dc05 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Wed, 27 Jul 2022 15:10:54 -0600 Subject: [PATCH 12/41] Improve test for `getEnvironmentVariables` Co-authored-by: Mark Stacey --- src/env-utils.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/env-utils.test.ts b/src/env-utils.test.ts index 701e3c8..c291275 100644 --- a/src/env-utils.test.ts +++ b/src/env-utils.test.ts @@ -17,6 +17,7 @@ describe('env-utils', () => { it('returns only the environment variables from process.env that we use in this tool', () => { process.env.EDITOR = 'editor'; process.env.TODAY = 'today'; + process.env.EXTRA = 'extra'; expect(getEnvironmentVariables()).toStrictEqual({ EDITOR: 'editor', From 8f4864d61bd93afc9a3306e299ab87f8ba3cb5e3 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Wed, 27 Jul 2022 15:45:31 -0600 Subject: [PATCH 13/41] Clean up assertions in tests for ensureDirectoryPathExists --- jest.config.js | 2 +- src/file-utils.test.ts | 21 +++++------ tests/setupAfterEnv.ts | 80 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 14 deletions(-) create mode 100644 tests/setupAfterEnv.ts diff --git a/jest.config.js b/jest.config.js index cd9a6f2..24debcf 100644 --- a/jest.config.js +++ b/jest.config.js @@ -137,7 +137,7 @@ module.exports = { // setupFiles: [], // A list of paths to modules that run some code to configure or set up the testing framework before each test - // setupFilesAfterEnv: [], + setupFilesAfterEnv: ['./tests/setupAfterEnv.ts'], // The number of seconds after which a test is considered as slow and reported as such in the results. // slowTestThreshold: 5, diff --git a/src/file-utils.test.ts b/src/file-utils.test.ts index 009809c..971a618 100644 --- a/src/file-utils.test.ts +++ b/src/file-utils.test.ts @@ -203,24 +203,21 @@ describe('file-utils', () => { await ensureDirectoryPathExists(directoryPath); - // We don't really need this expectations, but it is here to satisfy - // ESLint - const results = await Promise.all([ + await expect( fs.promises.readdir(path.join(sandbox.directoryPath, 'foo')), + ).toResolve(); + await expect( fs.promises.readdir(path.join(sandbox.directoryPath, 'foo', 'bar')), + ).toResolve(); + await expect( fs.promises.readdir( path.join(sandbox.directoryPath, 'foo', 'bar', 'baz'), ), - ]); - expect(JSON.parse(JSON.stringify(results))).toStrictEqual([ - ['bar'], - ['baz'], - [], - ]); + ).toResolve(); }); }); - it('does nothing if the given directory already exists', async () => { + it('does not throw an error, returning undefined, if the given directory already exists', async () => { await withSandbox(async (sandbox) => { const directoryPath = path.join( sandbox.directoryPath, @@ -234,9 +231,7 @@ describe('file-utils', () => { path.join(sandbox.directoryPath, 'foo', 'bar', 'baz'), ); - // We don't really need this expectations, but it is here to satisfy - // ESLint - expect(await ensureDirectoryPathExists(directoryPath)).toBeUndefined(); + await expect(ensureDirectoryPathExists(directoryPath)).toResolve(); }); }); diff --git a/tests/setupAfterEnv.ts b/tests/setupAfterEnv.ts new file mode 100644 index 0000000..42ea374 --- /dev/null +++ b/tests/setupAfterEnv.ts @@ -0,0 +1,80 @@ +declare global { + // Using `namespace` here is okay because this is how the Jest types are + // defined. + /* eslint-disable-next-line @typescript-eslint/no-namespace */ + namespace jest { + interface Matchers { + toResolve(): Promise; + } + } +} + +// Export something so that TypeScript thinks that we are performing type +// augmentation +export {}; + +const UNRESOLVED = Symbol('timedOut'); +// Store this in case it gets stubbed later +const originalSetTimeout = global.setTimeout; +const TIME_TO_WAIT_UNTIL_UNRESOLVED = 100; + +/** + * Produces a sort of dummy promise which can be used in conjunction with a + * "real" promise to determine whether the "real" promise was ever resolved. If + * the promise that is produced by this function resolves first, then the other + * one must be unresolved. + * + * @param duration - How long to wait before resolving the promise returned by + * this function. + * @returns A promise that resolves to a symbol. + */ +const treatUnresolvedAfter = (duration: number): Promise => { + return new Promise((resolve) => { + originalSetTimeout(resolve, duration, UNRESOLVED); + }); +}; + +expect.extend({ + /** + * Tests that the given promise is resolved within a certain amount of time + * (which defaults to the time that Jest tests wait before timing out as + * configured in the Jest configuration file). + * + * Inspired by . + * + * @param promise - The promise to test. + * @returns The result of the matcher. + */ + async toResolve(promise: Promise) { + if (this.isNot) { + throw new Error('Using `.not.toResolve(...)` is not supported.'); + } + + let resolutionValue: any; + let rejectionValue: any; + + try { + resolutionValue = await Promise.race([ + promise, + treatUnresolvedAfter(TIME_TO_WAIT_UNTIL_UNRESOLVED), + ]); + } catch (e) { + rejectionValue = e; + } + + return rejectionValue !== undefined || resolutionValue === UNRESOLVED + ? { + message: () => { + return `Expected promise to resolve after ${TIME_TO_WAIT_UNTIL_UNRESOLVED}ms, but it ${ + rejectionValue === undefined ? 'did not' : 'was rejected' + }.`; + }, + pass: false, + } + : { + message: () => + `This message should never get produced because .isNot is disallowed.`, + pass: true, + }; + }, +}); From 7ad7e752cebda2aa69dc83efda8be821f3b22167 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Wed, 27 Jul 2022 16:50:32 -0600 Subject: [PATCH 14/41] Remove knownKeysOf --- src/misc-utils.test.ts | 12 ------------ src/misc-utils.ts | 20 -------------------- 2 files changed, 32 deletions(-) diff --git a/src/misc-utils.test.ts b/src/misc-utils.test.ts index fe3d200..0559be7 100644 --- a/src/misc-utils.test.ts +++ b/src/misc-utils.test.ts @@ -5,7 +5,6 @@ import { isErrorWithMessage, isErrorWithStack, wrapError, - knownKeysOf, resolveExecutable, getStdoutFromCommand, runCommand, @@ -119,17 +118,6 @@ describe('misc-utils', () => { }); }); - describe('knownKeysOf', () => { - it('returns the keys of an object', () => { - const object = { - foo: 'bar', - baz: 'qux', - fizz: 'buzz', - }; - expect(knownKeysOf(object)).toStrictEqual(['foo', 'baz', 'fizz']); - }); - }); - describe('resolveExecutable', () => { it('returns the fullpath of the given executable as returned by "which"', async () => { jest diff --git a/src/misc-utils.ts b/src/misc-utils.ts index 6362107..567d983 100644 --- a/src/misc-utils.ts +++ b/src/misc-utils.ts @@ -96,26 +96,6 @@ export function wrapError( return errorWithStack; } -/** - * `Object.keys()` is intentionally generic: it returns the keys of an object, - * but it cannot make guarantees about the contents of that object, so the type - * of the keys is merely `string[]`. While this is technically accurate, it is - * also unnecessary if we have an object that we own and whose contents are - * known exactly. - * - * Note: This function will not work when given an object where any of the keys - * are optional. - * - * @param object - The object. - * @returns The keys of an object, typed according to the type of the object - * itself. - */ -export function knownKeysOf( - object: Record, -) { - return Object.keys(object) as K[]; -} - /** * Retrieves the real path of an executable via `which`. * From a9b3d8c592dd2d088cd45b56cfadb87b57a03faa Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Wed, 27 Jul 2022 17:30:01 -0600 Subject: [PATCH 15/41] Add a polyrepo test for readProject --- src/project-utils.test.ts | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/src/project-utils.test.ts b/src/project-utils.test.ts index 7c62424..688e214 100644 --- a/src/project-utils.test.ts +++ b/src/project-utils.test.ts @@ -15,7 +15,7 @@ jest.mock('./package-utils'); describe('project-utils', () => { describe('readProject', () => { - it('collects information about the repository URL as well as the root and workspace packages within the project', async () => { + it('collects information about a monorepo project', async () => { await withSandbox(async (sandbox) => { const projectDirectoryPath = sandbox.directoryPath; const projectRepositoryUrl = 'https://github.com/some-org/some-repo'; @@ -72,5 +72,29 @@ describe('project-utils', () => { }); }); }); + + it('collects information about a polyrepo project', async () => { + await withSandbox(async (sandbox) => { + const projectDirectoryPath = sandbox.directoryPath; + const projectRepositoryUrl = 'https://github.com/some-org/some-repo'; + const rootPackage = buildMockPackage('root', { + directoryPath: projectDirectoryPath, + }); + when(jest.spyOn(gitUtils, 'getRepositoryHttpsUrl')) + .calledWith(projectDirectoryPath) + .mockResolvedValue(projectRepositoryUrl); + when(jest.spyOn(packageUtils, 'readPackage')) + .calledWith(projectDirectoryPath) + .mockResolvedValue(rootPackage); + + expect(await readProject(projectDirectoryPath)).toStrictEqual({ + directoryPath: projectDirectoryPath, + repositoryUrl: projectRepositoryUrl, + rootPackage, + workspacePackages: {}, + isMonorepo: false, + }); + }); + }); }); }); From 8ceda73428c91817e9c00818a5c89d70e783af8a Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Wed, 27 Jul 2022 17:39:37 -0600 Subject: [PATCH 16/41] Add 'bin' entry --- package.json | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 3f710c3..aeed48e 100644 --- a/package.json +++ b/package.json @@ -6,8 +6,7 @@ "type": "git", "url": "https://github.com/MetaMask/create-release-branch.git" }, - "main": "dist/index.js", - "types": "dist/index.d.ts", + "bin": "dist/cli.js", "files": [ "dist/" ], @@ -18,7 +17,7 @@ "lint:eslint": "eslint . --cache --ext js,ts", "lint:fix": "yarn lint:eslint --fix && yarn lint:misc --write", "lint:misc": "prettier '**/*.json' '**/*.md' '!CHANGELOG.md' '**/*.yml' '!.yarnrc.yml' --ignore-path .gitignore --no-error-on-unmatched-pattern", - "prepack": "yarn build", + "prepack": "yarn build:clean && chmod +x dist/cli.js && yarn lint && yarn test", "test": "jest && jest-it-up", "test:watch": "jest --watch" }, From b8521a177f1172760eb1b5c6f5007c27f2092fcb Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Wed, 27 Jul 2022 17:43:21 -0600 Subject: [PATCH 17/41] Update yarn.lock --- yarn.lock | 2 ++ 1 file changed, 2 insertions(+) diff --git a/yarn.lock b/yarn.lock index 4a6b21e..0d5c5e0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -906,6 +906,8 @@ __metadata: which: ^2.0.2 yaml: ^2.1.1 yargs: ^17.5.1 + bin: + create-release-branch: dist/cli.js languageName: unknown linkType: soft From 3ab9e278dbb864a32ce9137c6e3d3869d75626b4 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Thu, 28 Jul 2022 10:12:04 -0600 Subject: [PATCH 18/41] Use "initial parameters" instead of "information we need to proceed" --- src/initialization-utils.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/initialization-utils.ts b/src/initialization-utils.ts index 74ecee4..d086f81 100644 --- a/src/initialization-utils.ts +++ b/src/initialization-utils.ts @@ -3,18 +3,24 @@ import path from 'path'; import { readProject, Project } from './project-utils'; import { readInputs } from './inputs-utils'; +interface InitialParameters { + project: Project; + tempDirectoryPath: string; + reset: boolean; +} + /** - * Reads the inputs given to this script via `process.argv` and uses them to + * Reads the inputs given to this tool via `process.argv` and uses them to * gather data we can use to proceed. * * @param argv - The arguments to this script. * @param cwd - The directory in which this script was executed. - * @returns Information we need to proceed with the script. + * @returns The initial parameters. */ export async function initialize( argv: string[], cwd: string, -): Promise<{ project: Project; tempDirectoryPath: string; reset: boolean }> { +): Promise { const inputs = await readInputs(argv); const projectDirectoryPath = path.resolve(cwd, inputs.projectDirectory); const project = await readProject(projectDirectoryPath); From 9fbf13c35a89c75647e085f809975b88e9f91a1d Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Thu, 28 Jul 2022 10:18:31 -0600 Subject: [PATCH 19/41] Bump dev version of Node to v16 --- .nvmrc | 2 +- README.md | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.nvmrc b/.nvmrc index 958b5a3..6f7f377 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v14 +v16 diff --git a/README.md b/README.md index 2075a8b..15883d4 100644 --- a/README.md +++ b/README.md @@ -24,8 +24,9 @@ For more on how to use this tool, please see the [docs](./docs). ### Setup -- Install [Node.js](https://nodejs.org) version 12 +- Install [Node.js](https://nodejs.org) version 16 - If you are using [nvm](https://github.com/creationix/nvm#installation) (recommended) running `nvm use` will automatically choose the right node version for you. + - Note that the version of Node used for development (in `.nvmrc`) is intentionally higher than version used for consumption (as `engines` in `package.json`), as we have not fully phased out legacy versions of Node from our products yet. - Install [Yarn v3](https://yarnpkg.com/getting-started/install) - Run `yarn install` to install dependencies and run any required post-install scripts From 0931959d3f2a84352ec845a60ad357ee6e612681 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Thu, 28 Jul 2022 10:24:21 -0600 Subject: [PATCH 20/41] Simplify how lines are indented in validateReleaseSpecification --- src/release-specification-utils.ts | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/src/release-specification-utils.ts b/src/release-specification-utils.ts index 78ce2b6..1ade3c6 100644 --- a/src/release-specification-utils.ts +++ b/src/release-specification-utils.ts @@ -240,18 +240,9 @@ export async function validateReleaseSpecification( return [ `${itemPrefix}${lineNumberPrefix}${error.message[0]}`, ...error.message.slice(1).map((line) => { - const spaces = []; - - for ( - let i = 0; - i < itemPrefix.length + lineNumberPrefix.length; - i += 1 - ) { - spaces[i] = ' '; - } - - const indentation = spaces.join(''); - return `${indentation}${line}`; + const indentationLength = + itemPrefix.length + lineNumberPrefix.length + line.length; + return line.padStart(indentationLength, ' '); }), ]; } From 05f66e02bb09e5c3aa16d5bf8125b57343dfd4fd Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Fri, 29 Jul 2022 10:07:13 -0600 Subject: [PATCH 21/41] indentationLength -> indentedLineLength --- src/release-specification-utils.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/release-specification-utils.ts b/src/release-specification-utils.ts index 1ade3c6..fddb0c3 100644 --- a/src/release-specification-utils.ts +++ b/src/release-specification-utils.ts @@ -240,9 +240,9 @@ export async function validateReleaseSpecification( return [ `${itemPrefix}${lineNumberPrefix}${error.message[0]}`, ...error.message.slice(1).map((line) => { - const indentationLength = + const indentedLineLength = itemPrefix.length + lineNumberPrefix.length + line.length; - return line.padStart(indentationLength, ' '); + return line.padStart(indentedLineLength, ' '); }), ]; } From 20bca5405694a2d6e5176a54538a6ff9d495c47c Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Fri, 29 Jul 2022 10:51:53 -0600 Subject: [PATCH 22/41] Drop -utils suffix from most files --- src/cli.ts | 2 +- ...uts-utils.ts => command-line-arguments.ts} | 14 ++- src/{editor-utils.test.ts => editor.test.ts} | 24 ++--- src/{editor-utils.ts => editor.ts} | 2 +- src/{env-utils.test.ts => env.test.ts} | 4 +- src/{env-utils.ts => env.ts} | 0 src/{file-utils.test.ts => fs.test.ts} | 4 +- src/{file-utils.ts => fs.ts} | 9 +- src/initial-parameters.test.ts | 70 ++++++++++++++ ...ization-utils.ts => initial-parameters.ts} | 14 +-- src/initialization-utils.test.ts | 62 ------------- src/main.test.ts | 36 ++++---- src/main.ts | 17 ++-- ...s => monorepo-workflow-operations.test.ts} | 92 +++++++++---------- ...ils.ts => monorepo-workflow-operations.ts} | 38 ++++---- ...utils.test.ts => package-manifest.test.ts} | 4 +- ...-manifest-utils.ts => package-manifest.ts} | 4 +- ...{package-utils.test.ts => package.test.ts} | 14 +-- src/{package-utils.ts => package.ts} | 8 +- ...{project-utils.test.ts => project.test.ts} | 20 ++-- src/{project-utils.ts => project.ts} | 9 +- ....test.ts => release-specification.test.ts} | 10 +- ...tion-utils.ts => release-specification.ts} | 18 ++-- src/{git-utils.test.ts => repo.test.ts} | 51 +++++----- src/{git-utils.ts => repo.ts} | 16 ++-- src/{semver-utils.ts => semver.ts} | 0 ...ls.test.ts => workflow-operations.test.ts} | 10 +- ...rkflow-utils.ts => workflow-operations.ts} | 6 +- tests/unit/helpers.ts | 8 +- 29 files changed, 300 insertions(+), 266 deletions(-) rename src/{inputs-utils.ts => command-line-arguments.ts} (66%) rename src/{editor-utils.test.ts => editor.test.ts} (86%) rename src/{editor-utils.ts => editor.ts} (96%) rename src/{env-utils.test.ts => env.test.ts} (88%) rename src/{env-utils.ts => env.ts} (100%) rename src/{file-utils.test.ts => fs.test.ts} (99%) rename src/{file-utils.ts => fs.ts} (89%) create mode 100644 src/initial-parameters.test.ts rename src/{initialization-utils.ts => initial-parameters.ts} (66%) delete mode 100644 src/initialization-utils.test.ts rename src/{monorepo-workflow-utils.test.ts => monorepo-workflow-operations.test.ts} (95%) rename src/{monorepo-workflow-utils.ts => monorepo-workflow-operations.ts} (86%) rename src/{package-manifest-utils.test.ts => package-manifest.test.ts} (99%) rename src/{package-manifest-utils.ts => package-manifest.ts} (98%) rename src/{package-utils.test.ts => package.test.ts} (95%) rename src/{package-utils.ts => package.ts} (95%) rename src/{project-utils.test.ts => project.test.ts} (87%) rename src/{project-utils.ts => project.ts} (92%) rename src/{release-specification-utils.test.ts => release-specification.test.ts} (98%) rename src/{release-specification-utils.ts => release-specification.ts} (95%) rename src/{git-utils.test.ts => repo.test.ts} (65%) rename src/{git-utils.ts => repo.ts} (84%) rename src/{semver-utils.ts => semver.ts} (100%) rename src/{workflow-utils.test.ts => workflow-operations.test.ts} (82%) rename src/{workflow-utils.ts => workflow-operations.ts} (94%) diff --git a/src/cli.ts b/src/cli.ts index 65f6dcf..f01666a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,7 +1,7 @@ import { main } from './main'; /** - * The entrypoint to this script. + * The entrypoint to this tool. */ async function cli() { await main({ diff --git a/src/inputs-utils.ts b/src/command-line-arguments.ts similarity index 66% rename from src/inputs-utils.ts rename to src/command-line-arguments.ts index 91c149d..86105bd 100644 --- a/src/inputs-utils.ts +++ b/src/command-line-arguments.ts @@ -1,22 +1,26 @@ import yargs from 'yargs/yargs'; import { hideBin } from 'yargs/helpers'; -export interface Inputs { +export interface CommandLineArguments { projectDirectory: string; tempDirectory: string | undefined; reset: boolean; } /** - * Parse the arguments provided on the command line. + * Parses the arguments provided on the command line using `yargs`. * - * @param argv - The name of this script and its arguments (as obtained via + * @param argv - The name of this executable and its arguments (as obtained via * `process.argv`). * @returns A promise for the `yargs` arguments object. */ -export async function readInputs(argv: string[]): Promise { +export async function readCommandLineArguments( + argv: string[], +): Promise { return await yargs(hideBin(argv)) - .usage('This script generates a release PR.') + .usage( + 'This tool prepares your project for a new release by bumping versions and updating changelogs.', + ) .option('project-directory', { alias: 'd', describe: 'The directory that holds your project.', diff --git a/src/editor-utils.test.ts b/src/editor.test.ts similarity index 86% rename from src/editor-utils.test.ts rename to src/editor.test.ts index d434a56..db5fda3 100644 --- a/src/editor-utils.test.ts +++ b/src/editor.test.ts @@ -1,16 +1,16 @@ import { when } from 'jest-when'; -import { determineEditor } from './editor-utils'; -import * as envUtils from './env-utils'; +import { determineEditor } from './editor'; +import * as envModule from './env'; import * as miscUtils from './misc-utils'; -jest.mock('./env-utils'); +jest.mock('./env'); jest.mock('./misc-utils'); -describe('editor-utils', () => { +describe('editor', () => { describe('determineEditor', () => { it('returns information about the editor from EDITOR if it resolves to an executable', async () => { jest - .spyOn(envUtils, 'getEnvironmentVariables') + .spyOn(envModule, 'getEnvironmentVariables') .mockReturnValue({ EDITOR: 'editor', TODAY: undefined }); when(jest.spyOn(miscUtils, 'resolveExecutable')) .calledWith('editor') @@ -24,7 +24,7 @@ describe('editor-utils', () => { it('falls back to VSCode if it exists and if EDITOR does not point to an executable', async () => { jest - .spyOn(envUtils, 'getEnvironmentVariables') + .spyOn(envModule, 'getEnvironmentVariables') .mockReturnValue({ EDITOR: 'editor', TODAY: undefined }); when(jest.spyOn(miscUtils, 'resolveExecutable')) .calledWith('editor') @@ -40,7 +40,7 @@ describe('editor-utils', () => { it('returns null if resolving EDITOR returns null and resolving VSCode returns null', async () => { jest - .spyOn(envUtils, 'getEnvironmentVariables') + .spyOn(envModule, 'getEnvironmentVariables') .mockReturnValue({ EDITOR: 'editor', TODAY: undefined }); when(jest.spyOn(miscUtils, 'resolveExecutable')) .calledWith('editor') @@ -53,7 +53,7 @@ describe('editor-utils', () => { it('returns null if resolving EDITOR returns null and resolving VSCode throws', async () => { jest - .spyOn(envUtils, 'getEnvironmentVariables') + .spyOn(envModule, 'getEnvironmentVariables') .mockReturnValue({ EDITOR: 'editor', TODAY: undefined }); when(jest.spyOn(miscUtils, 'resolveExecutable')) .calledWith('editor') @@ -66,7 +66,7 @@ describe('editor-utils', () => { it('returns null if resolving EDITOR throws and resolving VSCode returns null', async () => { jest - .spyOn(envUtils, 'getEnvironmentVariables') + .spyOn(envModule, 'getEnvironmentVariables') .mockReturnValue({ EDITOR: 'editor', TODAY: undefined }); when(jest.spyOn(miscUtils, 'resolveExecutable')) .calledWith('editor') @@ -79,7 +79,7 @@ describe('editor-utils', () => { it('returns null if resolving EDITOR throws and resolving VSCode throws', async () => { jest - .spyOn(envUtils, 'getEnvironmentVariables') + .spyOn(envModule, 'getEnvironmentVariables') .mockReturnValue({ EDITOR: 'editor', TODAY: undefined }); when(jest.spyOn(miscUtils, 'resolveExecutable')) .calledWith('editor') @@ -92,7 +92,7 @@ describe('editor-utils', () => { it('returns null if EDITOR is unset and resolving VSCode returns null', async () => { jest - .spyOn(envUtils, 'getEnvironmentVariables') + .spyOn(envModule, 'getEnvironmentVariables') .mockReturnValue({ EDITOR: undefined, TODAY: undefined }); when(jest.spyOn(miscUtils, 'resolveExecutable')) .calledWith('code') @@ -103,7 +103,7 @@ describe('editor-utils', () => { it('returns null if EDITOR is unset and resolving VSCode throws', async () => { jest - .spyOn(envUtils, 'getEnvironmentVariables') + .spyOn(envModule, 'getEnvironmentVariables') .mockReturnValue({ EDITOR: undefined, TODAY: undefined }); when(jest.spyOn(miscUtils, 'resolveExecutable')) .calledWith('code') diff --git a/src/editor-utils.ts b/src/editor.ts similarity index 96% rename from src/editor-utils.ts rename to src/editor.ts index e18e222..63b56f8 100644 --- a/src/editor-utils.ts +++ b/src/editor.ts @@ -1,4 +1,4 @@ -import { getEnvironmentVariables } from './env-utils'; +import { getEnvironmentVariables } from './env'; import { debug, resolveExecutable } from './misc-utils'; /** diff --git a/src/env-utils.test.ts b/src/env.test.ts similarity index 88% rename from src/env-utils.test.ts rename to src/env.test.ts index c291275..c40dbc6 100644 --- a/src/env-utils.test.ts +++ b/src/env.test.ts @@ -1,6 +1,6 @@ -import { getEnvironmentVariables } from './env-utils'; +import { getEnvironmentVariables } from './env'; -describe('env-utils', () => { +describe('env', () => { describe('getEnvironmentVariables', () => { let existingProcessEnv: NodeJS.ProcessEnv; diff --git a/src/env-utils.ts b/src/env.ts similarity index 100% rename from src/env-utils.ts rename to src/env.ts diff --git a/src/file-utils.test.ts b/src/fs.test.ts similarity index 99% rename from src/file-utils.test.ts rename to src/fs.test.ts index 971a618..e25a0f5 100644 --- a/src/file-utils.test.ts +++ b/src/fs.test.ts @@ -13,13 +13,13 @@ import { fileExists, ensureDirectoryPathExists, removeFile, -} from './file-utils'; +} from './fs'; jest.mock('@metamask/action-utils'); const promisifiedRimraf = util.promisify(rimraf); -describe('file-utils', () => { +describe('fs', () => { describe('readFile', () => { it('reads the contents of the given file as a UTF-8-encoded string', async () => { await withSandbox(async (sandbox) => { diff --git a/src/file-utils.ts b/src/fs.ts similarity index 89% rename from src/file-utils.ts rename to src/fs.ts index 4e70459..5503596 100644 --- a/src/file-utils.ts +++ b/src/fs.ts @@ -10,7 +10,7 @@ import { wrapError, isErrorWithCode } from './misc-utils'; * * @param filePath - The path to the file. * @returns The content of the file. - * @throws If reading fails in any way. + * @throws An error with a stack trace if reading fails in any way. */ export async function readFile(filePath: string): Promise { try { @@ -28,7 +28,7 @@ export async function readFile(filePath: string): Promise { * * @param filePath - The path to the file. * @param content - The new content of the file. - * @throws If writing fails in any way. + * @throws An error with a stack trace if writing fails in any way. */ export async function writeFile( filePath: string, @@ -54,6 +54,7 @@ export async function writeFile( * @param filePath - The path segments pointing to the JSON file. Will be passed * to path.join(). * @returns The object corresponding to the parsed JSON file. + * @throws An error with a stack trace if reading fails in any way. */ export async function readJsonObjectFile( filePath: string, @@ -76,6 +77,7 @@ export async function readJsonObjectFile( * itself. * @param jsonValue - The JSON-like value to write to the file. Make sure that * JSON.stringify can handle it. + * @throws An error with a stack trace if writing fails in any way. */ export async function writeJsonFile( filePath: string, @@ -96,6 +98,7 @@ export async function writeJsonFile( * * @param entryPath - The path to a file (or directory) on the filesystem. * @returns A promise for true if the file exists or false otherwise. + * @throws An error with a stack trace if reading fails in any way. */ export async function fileExists(entryPath: string): Promise { try { @@ -120,6 +123,7 @@ export async function fileExists(entryPath: string): Promise { * * @param directoryPath - The path to the desired directory. * @returns What `fs.promises.mkdir` returns. + * @throws An error with a stack trace if reading fails in any way. */ export async function ensureDirectoryPathExists( directoryPath: string, @@ -140,6 +144,7 @@ export async function ensureDirectoryPathExists( * * @param filePath - The path to the file. * @returns What `fs.promises.rm` returns. + * @throws An error with a stack trace if removal fails in any way. */ export async function removeFile(filePath: string): Promise { try { diff --git a/src/initial-parameters.test.ts b/src/initial-parameters.test.ts new file mode 100644 index 0000000..0420dc3 --- /dev/null +++ b/src/initial-parameters.test.ts @@ -0,0 +1,70 @@ +import os from 'os'; +import path from 'path'; +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 projectModule from './project'; + +jest.mock('./command-line-arguments'); +jest.mock('./project'); + +describe('initial-parameters', () => { + describe('determineInitialParameters', () => { + it('returns an object that contains data necessary to run the workflow', async () => { + const project = buildMockProject(); + when(jest.spyOn(commandLineArgumentsModule, 'readCommandLineArguments')) + .calledWith(['arg1', 'arg2']) + .mockResolvedValue({ + projectDirectory: '/path/to/project', + tempDirectory: '/path/to/temp', + reset: true, + }); + when(jest.spyOn(projectModule, 'readProject')) + .calledWith('/path/to/project') + .mockResolvedValue(project); + + const config = await determineInitialParameters( + ['arg1', 'arg2'], + '/path/to/somewhere', + ); + + expect(config).toStrictEqual({ + project, + tempDirectoryPath: '/path/to/temp', + reset: true, + }); + }); + + it('uses a default temporary directory based on the name of the package if no such directory was passed as an input', async () => { + const project = buildMockProject({ + rootPackage: buildMockPackage('@foo/bar'), + }); + when(jest.spyOn(commandLineArgumentsModule, 'readCommandLineArguments')) + .calledWith(['arg1', 'arg2']) + .mockResolvedValue({ + projectDirectory: '/path/to/project', + tempDirectory: undefined, + reset: true, + }); + when(jest.spyOn(projectModule, 'readProject')) + .calledWith('/path/to/project') + .mockResolvedValue(project); + + const config = await determineInitialParameters( + ['arg1', 'arg2'], + '/path/to/somewhere', + ); + + expect(config).toStrictEqual({ + project, + tempDirectoryPath: path.join( + os.tmpdir(), + 'create-release-branch', + '@foo__bar', + ), + reset: true, + }); + }); + }); +}); diff --git a/src/initialization-utils.ts b/src/initial-parameters.ts similarity index 66% rename from src/initialization-utils.ts rename to src/initial-parameters.ts index d086f81..5ccfd85 100644 --- a/src/initialization-utils.ts +++ b/src/initial-parameters.ts @@ -1,7 +1,7 @@ import os from 'os'; import path from 'path'; -import { readProject, Project } from './project-utils'; -import { readInputs } from './inputs-utils'; +import { readCommandLineArguments } from './command-line-arguments'; +import { readProject, Project } from './project'; interface InitialParameters { project: Project; @@ -11,17 +11,17 @@ interface InitialParameters { /** * Reads the inputs given to this tool via `process.argv` and uses them to - * gather data we can use to proceed. + * gather information about the project the tool can use to run. * - * @param argv - The arguments to this script. - * @param cwd - The directory in which this script was executed. + * @param argv - The arguments to this executable. + * @param cwd - The directory in which this executable was run. * @returns The initial parameters. */ -export async function initialize( +export async function determineInitialParameters( argv: string[], cwd: string, ): Promise { - const inputs = await readInputs(argv); + const inputs = await readCommandLineArguments(argv); const projectDirectoryPath = path.resolve(cwd, inputs.projectDirectory); const project = await readProject(projectDirectoryPath); const tempDirectoryPath = diff --git a/src/initialization-utils.test.ts b/src/initialization-utils.test.ts deleted file mode 100644 index 2722922..0000000 --- a/src/initialization-utils.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import os from 'os'; -import path from 'path'; -import { when } from 'jest-when'; -import { buildMockProject, buildMockPackage } from '../tests/unit/helpers'; -import { initialize } from './initialization-utils'; -import * as inputsUtils from './inputs-utils'; -import * as projectUtils from './project-utils'; - -jest.mock('./inputs-utils'); -jest.mock('./project-utils'); - -describe('initialize', () => { - it('returns an object that contains data necessary to run the workflow', async () => { - const project = buildMockProject(); - when(jest.spyOn(inputsUtils, 'readInputs')) - .calledWith(['arg1', 'arg2']) - .mockResolvedValue({ - projectDirectory: '/path/to/project', - tempDirectory: '/path/to/temp', - reset: true, - }); - when(jest.spyOn(projectUtils, 'readProject')) - .calledWith('/path/to/project') - .mockResolvedValue(project); - - const config = await initialize(['arg1', 'arg2'], '/path/to/somewhere'); - - expect(config).toStrictEqual({ - project, - tempDirectoryPath: '/path/to/temp', - reset: true, - }); - }); - - it('uses a default temporary directory based on the name of the package if no such directory was passed as an input', async () => { - const project = buildMockProject({ - rootPackage: buildMockPackage('@foo/bar'), - }); - when(jest.spyOn(inputsUtils, 'readInputs')) - .calledWith(['arg1', 'arg2']) - .mockResolvedValue({ - projectDirectory: '/path/to/project', - tempDirectory: undefined, - reset: true, - }); - when(jest.spyOn(projectUtils, 'readProject')) - .calledWith('/path/to/project') - .mockResolvedValue(project); - - const config = await initialize(['arg1', 'arg2'], '/path/to/somewhere'); - - expect(config).toStrictEqual({ - project, - tempDirectoryPath: path.join( - os.tmpdir(), - 'create-release-branch', - '@foo__bar', - ), - reset: true, - }); - }); -}); diff --git a/src/main.test.ts b/src/main.test.ts index ead89bd..539275c 100644 --- a/src/main.test.ts +++ b/src/main.test.ts @@ -1,24 +1,26 @@ import fs from 'fs'; import { buildMockProject } from '../tests/unit/helpers'; -import * as initializationUtils from './initialization-utils'; -import * as monorepoWorkflowUtils from './monorepo-workflow-utils'; import { main } from './main'; +import * as initialParametersModule from './initial-parameters'; +import * as monorepoWorkflowOperations from './monorepo-workflow-operations'; -jest.mock('./initialization-utils'); -jest.mock('./monorepo-workflow-utils'); +jest.mock('./initial-parameters'); +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 stdout = fs.createWriteStream('/dev/null'); const stderr = fs.createWriteStream('/dev/null'); - jest.spyOn(initializationUtils, 'initialize').mockResolvedValue({ - project, - tempDirectoryPath: '/path/to/temp/directory', - reset: false, - }); + jest + .spyOn(initialParametersModule, 'determineInitialParameters') + .mockResolvedValue({ + project, + tempDirectoryPath: '/path/to/temp/directory', + reset: false, + }); const followMonorepoWorkflowSpy = jest - .spyOn(monorepoWorkflowUtils, 'followMonorepoWorkflow') + .spyOn(monorepoWorkflowOperations, 'followMonorepoWorkflow') .mockResolvedValue(); await main({ @@ -41,13 +43,15 @@ describe('main', () => { const project = buildMockProject({ isMonorepo: false }); const stdout = fs.createWriteStream('/dev/null'); const stderr = fs.createWriteStream('/dev/null'); - jest.spyOn(initializationUtils, 'initialize').mockResolvedValue({ - project, - tempDirectoryPath: '/path/to/temp/directory', - reset: false, - }); + jest + .spyOn(initialParametersModule, 'determineInitialParameters') + .mockResolvedValue({ + project, + tempDirectoryPath: '/path/to/temp/directory', + reset: false, + }); const followMonorepoWorkflowSpy = jest - .spyOn(monorepoWorkflowUtils, 'followMonorepoWorkflow') + .spyOn(monorepoWorkflowOperations, 'followMonorepoWorkflow') .mockResolvedValue(); await main({ diff --git a/src/main.ts b/src/main.ts index 27d5484..66a0fe7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,14 +1,16 @@ import type { WriteStream } from 'fs'; -import { initialize } from './initialization-utils'; -import { followMonorepoWorkflow } from './monorepo-workflow-utils'; +import { determineInitialParameters } from './initial-parameters'; +import { followMonorepoWorkflow } from './monorepo-workflow-operations'; /** - * The main function for this script. + * The main function for this tool. Designed to not access `process.argv`, + * `process.env`, `process.cwd()`, `process.stdout`, or `process.stderr` + * directly so as to be more easily testable. * * @param args - The arguments. - * @param args.argv - The name of this script and its arguments (as obtained via - * `process.argv`). - * @param args.cwd - The directory in which this script was executed. + * @param args.argv - The name of this executable and its arguments (as obtained + * via `process.argv`). + * @param args.cwd - The directory in which this executable was run. * @param args.stdout - A stream that can be used to write to standard out. * @param args.stderr - A stream that can be used to write to standard error. */ @@ -23,7 +25,8 @@ export async function main({ stdout: Pick; stderr: Pick; }) { - const { project, tempDirectoryPath, reset } = await initialize(argv, cwd); + const { project, tempDirectoryPath, reset } = + await determineInitialParameters(argv, cwd); if (project.isMonorepo) { stdout.write( diff --git a/src/monorepo-workflow-utils.test.ts b/src/monorepo-workflow-operations.test.ts similarity index 95% rename from src/monorepo-workflow-utils.test.ts rename to src/monorepo-workflow-operations.test.ts index cfdbbb6..d20f288 100644 --- a/src/monorepo-workflow-utils.test.ts +++ b/src/monorepo-workflow-operations.test.ts @@ -6,28 +6,28 @@ import { buildMockPackage, buildMockProject, } from '../tests/unit/helpers'; -import { followMonorepoWorkflow } from './monorepo-workflow-utils'; -import * as editorUtils from './editor-utils'; -import * as envUtils from './env-utils'; -import * as packageUtils from './package-utils'; -import type { Package } from './package-utils'; -import type { ValidatedManifest } from './package-manifest-utils'; -import type { Project } from './project-utils'; -import * as releaseSpecificationUtils from './release-specification-utils'; -import * as workflowUtils from './workflow-utils'; - -jest.mock('./editor-utils'); -jest.mock('./env-utils'); -jest.mock('./package-utils'); -jest.mock('./release-specification-utils'); -jest.mock('./workflow-utils'); +import { followMonorepoWorkflow } from './monorepo-workflow-operations'; +import * as editorModule from './editor'; +import * as envModule from './env'; +import * as packageModule from './package'; +import type { Package } from './package'; +import type { ValidatedManifest } from './package-manifest'; +import type { Project } from './project'; +import * as releaseSpecificationModule from './release-specification'; +import * as workflowOperations from './workflow-operations'; + +jest.mock('./editor'); +jest.mock('./env'); +jest.mock('./package'); +jest.mock('./release-specification'); +jest.mock('./workflow-operations'); /** * Given a Promise type, returns the type inside. */ type UnwrapPromise = T extends Promise ? U : never; -describe('monorepo-workflow-utils', () => { +describe('monorepo-workflow-operations', () => { describe('followMonorepoWorkflow', () => { describe('when firstRemovingExistingReleaseSpecification is true', () => { describe('when a release spec file does not already exist', () => { @@ -80,11 +80,11 @@ describe('monorepo-workflow-utils', () => { }, validateReleaseSpecification: { packages: { - a: releaseSpecificationUtils.IncrementableVersionParts + a: releaseSpecificationModule.IncrementableVersionParts .major, - b: releaseSpecificationUtils.IncrementableVersionParts + b: releaseSpecificationModule.IncrementableVersionParts .minor, - c: releaseSpecificationUtils.IncrementableVersionParts + c: releaseSpecificationModule.IncrementableVersionParts .patch, d: new SemVer('1.2.3'), }, @@ -179,7 +179,7 @@ describe('monorepo-workflow-utils', () => { }, validateReleaseSpecification: { packages: { - a: releaseSpecificationUtils.IncrementableVersionParts + a: releaseSpecificationModule.IncrementableVersionParts .major, }, }, @@ -363,7 +363,7 @@ describe('monorepo-workflow-utils', () => { }); jest .spyOn( - releaseSpecificationUtils, + releaseSpecificationModule, 'waitForUserToEditReleaseSpecification', ) .mockRejectedValue(new Error('oops')); @@ -464,7 +464,7 @@ describe('monorepo-workflow-utils', () => { }, validateReleaseSpecification: { packages: { - a: releaseSpecificationUtils.IncrementableVersionParts + a: releaseSpecificationModule.IncrementableVersionParts .major, }, }, @@ -536,7 +536,7 @@ describe('monorepo-workflow-utils', () => { }, validateReleaseSpecification: { packages: { - a: releaseSpecificationUtils.IncrementableVersionParts + a: releaseSpecificationModule.IncrementableVersionParts .major, }, }, @@ -732,7 +732,7 @@ describe('monorepo-workflow-utils', () => { }); jest .spyOn( - releaseSpecificationUtils, + releaseSpecificationModule, 'waitForUserToEditReleaseSpecification', ) .mockRejectedValue(new Error('oops')); @@ -857,11 +857,11 @@ describe('monorepo-workflow-utils', () => { }, validateReleaseSpecification: { packages: { - a: releaseSpecificationUtils.IncrementableVersionParts + a: releaseSpecificationModule.IncrementableVersionParts .major, - b: releaseSpecificationUtils.IncrementableVersionParts + b: releaseSpecificationModule.IncrementableVersionParts .minor, - c: releaseSpecificationUtils.IncrementableVersionParts + c: releaseSpecificationModule.IncrementableVersionParts .patch, d: new SemVer('1.2.3'), }, @@ -956,7 +956,7 @@ describe('monorepo-workflow-utils', () => { }, validateReleaseSpecification: { packages: { - a: releaseSpecificationUtils.IncrementableVersionParts + a: releaseSpecificationModule.IncrementableVersionParts .major, }, }, @@ -1137,7 +1137,7 @@ describe('monorepo-workflow-utils', () => { }); jest .spyOn( - releaseSpecificationUtils, + releaseSpecificationModule, 'waitForUserToEditReleaseSpecification', ) .mockRejectedValue(new Error('oops')); @@ -1238,7 +1238,7 @@ describe('monorepo-workflow-utils', () => { }, validateReleaseSpecification: { packages: { - a: releaseSpecificationUtils.IncrementableVersionParts + a: releaseSpecificationModule.IncrementableVersionParts .major, }, }, @@ -1313,7 +1313,7 @@ describe('monorepo-workflow-utils', () => { }, validateReleaseSpecification: { packages: { - a: releaseSpecificationUtils.IncrementableVersionParts + a: releaseSpecificationModule.IncrementableVersionParts .major, }, }, @@ -1509,7 +1509,7 @@ describe('monorepo-workflow-utils', () => { }); jest .spyOn( - releaseSpecificationUtils, + releaseSpecificationModule, 'waitForUserToEditReleaseSpecification', ) .mockRejectedValue(new Error('oops')); @@ -1615,54 +1615,54 @@ function mockDependencies({ captureChangesInReleaseBranch: captureChangesInReleaseBranchValue = undefined, }: { determineEditor?: UnwrapPromise< - ReturnType + ReturnType >; getEnvironmentVariables?: Partial< - ReturnType + ReturnType >; generateReleaseSpecificationTemplateForMonorepo?: UnwrapPromise< ReturnType< - typeof releaseSpecificationUtils.generateReleaseSpecificationTemplateForMonorepo + typeof releaseSpecificationModule.generateReleaseSpecificationTemplateForMonorepo > >; waitForUserToEditReleaseSpecification?: UnwrapPromise< ReturnType< - typeof releaseSpecificationUtils.waitForUserToEditReleaseSpecification + typeof releaseSpecificationModule.waitForUserToEditReleaseSpecification > >; validateReleaseSpecification?: UnwrapPromise< - ReturnType + ReturnType >; - updatePackage?: UnwrapPromise>; + updatePackage?: UnwrapPromise>; captureChangesInReleaseBranch?: UnwrapPromise< - ReturnType + ReturnType >; }) { jest - .spyOn(editorUtils, 'determineEditor') + .spyOn(editorModule, 'determineEditor') .mockResolvedValue(determineEditorValue); - jest.spyOn(envUtils, 'getEnvironmentVariables').mockReturnValue({ + jest.spyOn(envModule, 'getEnvironmentVariables').mockReturnValue({ EDITOR: undefined, TODAY: undefined, ...getEnvironmentVariablesValue, }); const generateReleaseSpecificationTemplateForMonorepoSpy = jest .spyOn( - releaseSpecificationUtils, + releaseSpecificationModule, 'generateReleaseSpecificationTemplateForMonorepo', ) .mockResolvedValue(generateReleaseSpecificationTemplateForMonorepoValue); const waitForUserToEditReleaseSpecificationSpy = jest - .spyOn(releaseSpecificationUtils, 'waitForUserToEditReleaseSpecification') + .spyOn(releaseSpecificationModule, 'waitForUserToEditReleaseSpecification') .mockResolvedValue(waitForUserToEditReleaseSpecificationValue); const validateReleaseSpecificationSpy = jest - .spyOn(releaseSpecificationUtils, 'validateReleaseSpecification') + .spyOn(releaseSpecificationModule, 'validateReleaseSpecification') .mockResolvedValue(validateReleaseSpecificationValue); const updatePackageSpy = jest - .spyOn(packageUtils, 'updatePackage') + .spyOn(packageModule, 'updatePackage') .mockResolvedValue(updatePackageValue); const captureChangesInReleaseBranchSpy = jest - .spyOn(workflowUtils, 'captureChangesInReleaseBranch') + .spyOn(workflowOperations, 'captureChangesInReleaseBranch') .mockResolvedValue(captureChangesInReleaseBranchValue); return { diff --git a/src/monorepo-workflow-utils.ts b/src/monorepo-workflow-operations.ts similarity index 86% rename from src/monorepo-workflow-utils.ts rename to src/monorepo-workflow-operations.ts index d965984..e62b00d 100644 --- a/src/monorepo-workflow-utils.ts +++ b/src/monorepo-workflow-operations.ts @@ -7,23 +7,23 @@ import { fileExists, removeFile, writeFile, -} from './file-utils'; -import { determineEditor } from './editor-utils'; -import { getEnvironmentVariables } from './env-utils'; -import { updatePackage } from './package-utils'; -import { Project } from './project-utils'; +} from './fs'; +import { determineEditor } from './editor'; +import { getEnvironmentVariables } from './env'; +import { updatePackage } from './package'; +import { Project } from './project'; import { generateReleaseSpecificationTemplateForMonorepo, waitForUserToEditReleaseSpecification, validateReleaseSpecification, ReleaseSpecification, -} from './release-specification-utils'; -import { semver, SemVer } from './semver-utils'; +} from './release-specification'; +import { semver, SemVer } from './semver'; import { captureChangesInReleaseBranch, PackageReleasePlan, ReleasePlan, -} from './workflow-utils'; +} from './workflow-operations'; /** * Creates a date from the value of the `TODAY` environment variable, falling @@ -45,19 +45,19 @@ function getToday() { /** * For a monorepo, the process works like this: * - * - The script generates a release spec template, listing the workspace - * packages in the project that have changed since the last release (or all of - * the packages if this would be the first release). - * - The script then presents the template to the user so that they can specify + * - The tool generates a release spec template, listing the workspace packages + * in the project that have changed since the last release (or all of the + * packages if this would be the first release). + * - The tool then presents the template to the user so that they can specify * the desired versions for each package. It first does this by attempting to * locate an appropriate code editor on the user's computer (using the `EDITOR` * environment variable if that is defined, otherwise `code` if it is present) * and opening the file there, pausing while the user is editing the file. If no - * editor can be found, the script provides the user with the path to the - * template so that they can edit it themselves, then exits. - * - However the user has edited the file, the script will parse and validate - * the information in the file, then apply the desired changes to the monorepo. - * - Finally, once it has made the desired changes, the script will create a Git + * editor can be found, the tool provides the user with the path to the template + * so that they can edit it themselves, then exits. + * - However the user has edited the file, the tool will parse and validate the + * information in the file, then apply the desired changes to the monorepo. + * - Finally, once it has made the desired changes, the tool will create a Git * commit that includes the changes, then create a branch using the current date * as the name. * @@ -109,7 +109,7 @@ export async function followMonorepoWorkflow({ if (!editor) { stdout.write( `${[ - 'A template has been generated that specifies this release. Please open the following file in your editor of choice, then re-run this script:', + 'A template has been generated that specifies this release. Please open the following file in your editor of choice, then re-run this tool:', `${releaseSpecificationPath}`, ].join('\n\n')}\n`, ); @@ -190,7 +190,7 @@ async function planRelease( throw new Error( [ `Could not apply version specifier "${versionSpecifier}" to package "${packageName}" because the current and new versions would end up being the same.`, - `The release spec file has been retained for you to make the necessary fixes. Once you've done this, re-run this script.`, + `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'), ); diff --git a/src/package-manifest-utils.test.ts b/src/package-manifest.test.ts similarity index 99% rename from src/package-manifest-utils.test.ts rename to src/package-manifest.test.ts index 28d051b..e89d7f6 100644 --- a/src/package-manifest-utils.test.ts +++ b/src/package-manifest.test.ts @@ -2,9 +2,9 @@ import fs from 'fs'; import path from 'path'; import { SemVer } from 'semver'; import { withSandbox } from '../tests/unit/helpers'; -import { readManifest } from './package-manifest-utils'; +import { readManifest } from './package-manifest'; -describe('package-manifest-utils', () => { +describe('package-manifest', () => { describe('readManifest', () => { it('reads a minimal package manifest, expanding it by filling in values for optional fields', async () => { await withSandbox(async (sandbox) => { diff --git a/src/package-manifest-utils.ts b/src/package-manifest.ts similarity index 98% rename from src/package-manifest-utils.ts rename to src/package-manifest.ts index 591351e..b9fa9c5 100644 --- a/src/package-manifest-utils.ts +++ b/src/package-manifest.ts @@ -3,9 +3,9 @@ import { ManifestFieldNames, ManifestDependencyFieldNames, } from '@metamask/action-utils'; -import { readJsonObjectFile } from './file-utils'; +import { readJsonObjectFile } from './fs'; import { isTruthyString, isObject, Require } from './misc-utils'; -import { isValidSemver, SemVer } from './semver-utils'; +import { isValidSemver, SemVer } from './semver'; export { ManifestFieldNames, ManifestDependencyFieldNames }; diff --git a/src/package-utils.test.ts b/src/package.test.ts similarity index 95% rename from src/package-utils.test.ts rename to src/package.test.ts index 9ca481f..46bcac5 100644 --- a/src/package-utils.test.ts +++ b/src/package.test.ts @@ -7,19 +7,19 @@ import { buildMockManifest, withSandbox, } from '../tests/unit/helpers'; -import * as fileUtils from './file-utils'; -import { readPackage, updatePackage } from './package-utils'; -import * as packageManifestUtils from './package-manifest-utils'; +import { readPackage, updatePackage } from './package'; +import * as fsModule from './fs'; +import * as packageManifestModule from './package-manifest'; jest.mock('@metamask/auto-changelog'); -jest.mock('./package-manifest-utils'); +jest.mock('./package-manifest'); -describe('package-utils', () => { +describe('package', () => { describe('readPackage', () => { it('reads information about the package located at the given directory', async () => { const packageDirectoryPath = '/path/to/package'; jest - .spyOn(packageManifestUtils, 'readManifest') + .spyOn(packageManifestModule, 'readManifest') .mockResolvedValue(buildMockManifest()); const pkg = await readPackage(packageDirectoryPath); @@ -114,7 +114,7 @@ describe('package-utils', () => { newVersion: '2.0.0', shouldUpdateChangelog: true, }; - jest.spyOn(fileUtils, 'readFile').mockRejectedValue(new Error('oops')); + jest.spyOn(fsModule, 'readFile').mockRejectedValue(new Error('oops')); await expect( updatePackage({ project, packageReleasePlan }), diff --git a/src/package-utils.ts b/src/package.ts similarity index 95% rename from src/package-utils.ts rename to src/package.ts index c987b7d..3c87c47 100644 --- a/src/package-utils.ts +++ b/src/package.ts @@ -2,10 +2,10 @@ import fs, { WriteStream } from 'fs'; import path from 'path'; import { updateChangelog } from '@metamask/auto-changelog'; import { isErrorWithCode } from './misc-utils'; -import { readFile, writeFile, writeJsonFile } from './file-utils'; -import { Project } from './project-utils'; -import { PackageReleasePlan } from './workflow-utils'; -import { readManifest, ValidatedManifest } from './package-manifest-utils'; +import { readFile, writeFile, writeJsonFile } from './fs'; +import { readManifest, ValidatedManifest } from './package-manifest'; +import { Project } from './project'; +import { PackageReleasePlan } from './workflow-operations'; const MANIFEST_FILE_NAME = 'package.json'; const CHANGELOG_FILE_NAME = 'CHANGELOG.md'; diff --git a/src/project-utils.test.ts b/src/project.test.ts similarity index 87% rename from src/project-utils.test.ts rename to src/project.test.ts index 688e214..95d7d7a 100644 --- a/src/project-utils.test.ts +++ b/src/project.test.ts @@ -6,14 +6,14 @@ import { buildMockPackage, withSandbox, } from '../tests/unit/helpers'; -import * as gitUtils from './git-utils'; -import * as packageUtils from './package-utils'; -import { readProject } from './project-utils'; +import { readProject } from './project'; +import * as packageModule from './package'; +import * as repoModule from './repo'; -jest.mock('./git-utils'); -jest.mock('./package-utils'); +jest.mock('./package'); +jest.mock('./repo'); -describe('project-utils', () => { +describe('project', () => { describe('readProject', () => { it('collects information about a monorepo project', async () => { await withSandbox(async (sandbox) => { @@ -40,10 +40,10 @@ describe('project-utils', () => { manifest: buildMockManifest(), }), }; - when(jest.spyOn(gitUtils, 'getRepositoryHttpsUrl')) + when(jest.spyOn(repoModule, 'getRepositoryHttpsUrl')) .calledWith(projectDirectoryPath) .mockResolvedValue(projectRepositoryUrl); - when(jest.spyOn(packageUtils, 'readPackage')) + when(jest.spyOn(packageModule, 'readPackage')) .calledWith(projectDirectoryPath) .mockResolvedValue(rootPackage) .calledWith(path.join(projectDirectoryPath, 'packages', 'a')) @@ -80,10 +80,10 @@ describe('project-utils', () => { const rootPackage = buildMockPackage('root', { directoryPath: projectDirectoryPath, }); - when(jest.spyOn(gitUtils, 'getRepositoryHttpsUrl')) + when(jest.spyOn(repoModule, 'getRepositoryHttpsUrl')) .calledWith(projectDirectoryPath) .mockResolvedValue(projectRepositoryUrl); - when(jest.spyOn(packageUtils, 'readPackage')) + when(jest.spyOn(packageModule, 'readPackage')) .calledWith(projectDirectoryPath) .mockResolvedValue(rootPackage); diff --git a/src/project-utils.ts b/src/project.ts similarity index 92% rename from src/project-utils.ts rename to src/project.ts index f2dd70c..af1be8e 100644 --- a/src/project-utils.ts +++ b/src/project.ts @@ -1,8 +1,8 @@ import util from 'util'; import glob from 'glob'; -import { getRepositoryHttpsUrl } from './git-utils'; -import { Package, readPackage } from './package-utils'; -import { ManifestFieldNames } from './package-manifest-utils'; +import { Package, readPackage } from './package'; +import { ManifestFieldNames } from './package-manifest'; +import { getRepositoryHttpsUrl } from './repo'; /** * Represents the entire codebase on which this tool is operating. @@ -23,6 +23,9 @@ export interface Project { isMonorepo: boolean; } +/** + * A promisified version of `glob`. + */ const promisifiedGlob = util.promisify(glob); /** diff --git a/src/release-specification-utils.test.ts b/src/release-specification.test.ts similarity index 98% rename from src/release-specification-utils.test.ts rename to src/release-specification.test.ts index ba19cb3..ca7663d 100644 --- a/src/release-specification-utils.test.ts +++ b/src/release-specification.test.ts @@ -13,7 +13,7 @@ import { generateReleaseSpecificationTemplateForMonorepo, waitForUserToEditReleaseSpecification, validateReleaseSpecification, -} from './release-specification-utils'; +} from './release-specification'; import * as miscUtils from './misc-utils'; jest.mock('./misc-utils', () => { @@ -23,7 +23,7 @@ jest.mock('./misc-utils', () => { }; }); -describe('release-specification-utils', () => { +describe('release-specification', () => { describe('generateReleaseSpecificationTemplateForMonorepo', () => { it('returns a YAML-encoded string which has a list of all workspace packages in the project', async () => { const project = buildMockProject({ @@ -51,8 +51,8 @@ describe('release-specification-utils', () => { # - an exact version with major, minor, and patch parts (e.g. "1.2.3") # - null (to skip the package entirely) # -# When you're finished making your selections, save this file and the script -# will continue automatically. +# When you're finished making your selections, save this file and +# create-release-branch will continue automatically. packages: a: null @@ -88,7 +88,7 @@ packages: # - null (to skip the package entirely) # # When you're finished making your selections, save this file and then re-run -# the script that generated this file. +# create-release-branch. packages: a: null diff --git a/src/release-specification-utils.ts b/src/release-specification.ts similarity index 95% rename from src/release-specification-utils.ts rename to src/release-specification.ts index fddb0c3..21e1f4c 100644 --- a/src/release-specification-utils.ts +++ b/src/release-specification.ts @@ -1,7 +1,7 @@ import fs, { WriteStream } from 'fs'; import YAML from 'yaml'; -import { Editor } from './editor-utils'; -import { readFile } from './file-utils'; +import { Editor } from './editor'; +import { readFile } from './fs'; import { debug, hasProperty, @@ -9,8 +9,8 @@ import { isObject, runCommand, } from './misc-utils'; -import { Project } from './project-utils'; -import { isValidSemver, semver, SemVer } from './semver-utils'; +import { Project } from './project'; +import { isValidSemver, semver, SemVer } from './semver'; /** * The SemVer-compatible parts of a version string that can be bumped by this @@ -57,11 +57,11 @@ export async function generateReleaseSpecificationTemplateForMonorepo({ }) { const afterEditingInstructions = isEditorAvailable ? ` -# When you're finished making your selections, save this file and the script -# will continue automatically.`.trim() +# When you're finished making your selections, save this file and +# create-release-branch will continue automatically.`.trim() : ` # When you're finished making your selections, save this file and then re-run -# the script that generated this file.`.trim(); +# create-release-branch.`.trim(); const instructions = ` # The following is a list of packages in ${rootPackage.manifest.name}. @@ -170,14 +170,14 @@ export async function validateReleaseSpecification( [ 'Failed to parse release spec:', message, - "The file has been retained for you to make the necessary fixes. Once you've done this, re-run this script.", + "The file has been retained for you to make the necessary fixes. Once you've done this, re-run this tool.", releaseSpecificationPath, ].join('\n\n'), ); } const postludeForAllErrorMessages = [ - "The release spec file has been retained for you to make the necessary fixes. Once you've done this, re-run this script.", + "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'); diff --git a/src/git-utils.test.ts b/src/repo.test.ts similarity index 65% rename from src/git-utils.test.ts rename to src/repo.test.ts index b10cc77..63b8f12 100644 --- a/src/git-utils.test.ts +++ b/src/repo.test.ts @@ -1,9 +1,6 @@ import { when } from 'jest-when'; +import { getStdoutFromGitCommandWithin, getRepositoryHttpsUrl } from './repo'; import * as miscUtils from './misc-utils'; -import { - getStdoutFromGitCommandWithin, - getRepositoryHttpsUrl, -} from './git-utils'; jest.mock('./misc-utils'); @@ -25,36 +22,36 @@ describe('git-utils', () => { describe('getRepositoryHttpsUrl', () => { it('returns the URL of the "origin" remote of the given repo if it looks like a HTTPS public GitHub repo URL', async () => { - const projectDirectoryPath = '/path/to/project'; + const repositoryDirectoryPath = '/path/to/project'; when(jest.spyOn(miscUtils, 'getStdoutFromCommand')) .calledWith('git', ['config', '--get', 'remote.origin.url'], { - cwd: projectDirectoryPath, + cwd: repositoryDirectoryPath, }) .mockResolvedValue('https://github.com/foo'); - expect(await getRepositoryHttpsUrl(projectDirectoryPath)).toStrictEqual( - 'https://github.com/foo', - ); + expect( + await getRepositoryHttpsUrl(repositoryDirectoryPath), + ).toStrictEqual('https://github.com/foo'); }); it('converts an SSH GitHub repo URL into an HTTPS URL', async () => { - const projectDirectoryPath = '/path/to/project'; + const repositoryDirectoryPath = '/path/to/project'; when(jest.spyOn(miscUtils, 'getStdoutFromCommand')) .calledWith('git', ['config', '--get', 'remote.origin.url'], { - cwd: projectDirectoryPath, + cwd: repositoryDirectoryPath, }) .mockResolvedValue('git@github.com:Foo/Bar.git'); - expect(await getRepositoryHttpsUrl(projectDirectoryPath)).toStrictEqual( - 'https://github.com/Foo/Bar', - ); + expect( + await getRepositoryHttpsUrl(repositoryDirectoryPath), + ).toStrictEqual('https://github.com/Foo/Bar'); }); it('throws if the URL of the "origin" remote is in an invalid format', async () => { - const projectDirectoryPath = '/path/to/project'; + const repositoryDirectoryPath = '/path/to/project'; when(jest.spyOn(miscUtils, 'getStdoutFromCommand')) .calledWith('git', ['config', '--get', 'remote.origin.url'], { - cwd: projectDirectoryPath, + cwd: repositoryDirectoryPath, }) .mockResolvedValueOnce('foo') .mockResolvedValueOnce('http://github.com/Foo/Bar') @@ -62,19 +59,27 @@ describe('git-utils', () => { .mockResolvedValueOnce('git@gitbar.foo:Foo/Bar.git') .mockResolvedValueOnce('git@github.com:Foo/Bar.foo'); - await expect(getRepositoryHttpsUrl(projectDirectoryPath)).rejects.toThrow( - 'Unrecognized URL for git remote "origin": foo', - ); - await expect(getRepositoryHttpsUrl(projectDirectoryPath)).rejects.toThrow( + await expect( + getRepositoryHttpsUrl(repositoryDirectoryPath), + ).rejects.toThrow('Unrecognized URL for git remote "origin": foo'); + await expect( + getRepositoryHttpsUrl(repositoryDirectoryPath), + ).rejects.toThrow( 'Unrecognized URL for git remote "origin": http://github.com/Foo/Bar', ); - await expect(getRepositoryHttpsUrl(projectDirectoryPath)).rejects.toThrow( + await expect( + getRepositoryHttpsUrl(repositoryDirectoryPath), + ).rejects.toThrow( 'Unrecognized URL for git remote "origin": https://gitbar.foo/Foo/Bar', ); - await expect(getRepositoryHttpsUrl(projectDirectoryPath)).rejects.toThrow( + await expect( + getRepositoryHttpsUrl(repositoryDirectoryPath), + ).rejects.toThrow( 'Unrecognized URL for git remote "origin": git@gitbar.foo:Foo/Bar.git', ); - await expect(getRepositoryHttpsUrl(projectDirectoryPath)).rejects.toThrow( + await expect( + getRepositoryHttpsUrl(repositoryDirectoryPath), + ).rejects.toThrow( 'Unrecognized URL for git remote "origin": git@github.com:Foo/Bar.foo', ); }); diff --git a/src/git-utils.ts b/src/repo.ts similarity index 84% rename from src/git-utils.ts rename to src/repo.ts index 4128966..188f6f2 100644 --- a/src/git-utils.ts +++ b/src/repo.ts @@ -18,23 +18,23 @@ async function getStdoutFromCommandWithin( } /** - * Runs a Git command within the given directory, obtaining the immediate + * Runs a Git command within the given repository, obtaining the immediate * output. * - * @param repoDirectory - The directory of the repository. + * @param repositoryDirectoryPath - The directory of the repository. * @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. */ export async function getStdoutFromGitCommandWithin( - repoDirectory: string, + repositoryDirectoryPath: string, args: readonly string[], ) { - return await getStdoutFromCommandWithin(repoDirectory, 'git', args); + return await getStdoutFromCommandWithin(repositoryDirectoryPath, 'git', args); } /** - * Gets the HTTPS URL of the primary remote with which the given project has + * Gets the HTTPS URL of the primary remote with which the given repository has * been configured. Assumes that the git config `remote.origin.url` string * matches one of: * @@ -44,18 +44,18 @@ export async function getStdoutFromGitCommandWithin( * If the URL of the "origin" remote matches neither pattern, an error is * thrown. * - * @param projectDirectoryPath - The path to the project directory. + * @param repositoryDirectoryPath - The path to the project directory. * @returns The HTTPS URL of the repository, e.g. * `https://github.com/OrganizationName/RepositoryName`. */ export async function getRepositoryHttpsUrl( - projectDirectoryPath: string, + repositoryDirectoryPath: string, ): Promise { const httpsPrefix = 'https://github.com'; const sshPrefixRegex = /^git@github\.com:/u; const sshPostfixRegex = /\.git$/u; const gitConfigUrl = await getStdoutFromCommandWithin( - projectDirectoryPath, + repositoryDirectoryPath, 'git', ['config', '--get', 'remote.origin.url'], ); diff --git a/src/semver-utils.ts b/src/semver.ts similarity index 100% rename from src/semver-utils.ts rename to src/semver.ts diff --git a/src/workflow-utils.test.ts b/src/workflow-operations.test.ts similarity index 82% rename from src/workflow-utils.test.ts rename to src/workflow-operations.test.ts index 0841eae..635d807 100644 --- a/src/workflow-utils.test.ts +++ b/src/workflow-operations.test.ts @@ -1,8 +1,10 @@ import { buildMockProject } from '../tests/unit/helpers'; -import { captureChangesInReleaseBranch } from './workflow-utils'; -import * as gitUtils from './git-utils'; +import { captureChangesInReleaseBranch } from './workflow-operations'; +import * as repoModule from './repo'; -describe('workflow-utils', () => { +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({ @@ -13,7 +15,7 @@ describe('workflow-utils', () => { packages: [], }; const getStdoutFromGitCommandWithinSpy = jest - .spyOn(gitUtils, 'getStdoutFromGitCommandWithin') + .spyOn(repoModule, 'getStdoutFromGitCommandWithin') .mockResolvedValue('the output'); await captureChangesInReleaseBranch(project, releasePlan); diff --git a/src/workflow-utils.ts b/src/workflow-operations.ts similarity index 94% rename from src/workflow-utils.ts rename to src/workflow-operations.ts index 2016756..0120a2f 100644 --- a/src/workflow-utils.ts +++ b/src/workflow-operations.ts @@ -1,6 +1,6 @@ -import { Package } from './package-utils'; -import { Project } from './project-utils'; -import { getStdoutFromGitCommandWithin } from './git-utils'; +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 diff --git a/tests/unit/helpers.ts b/tests/unit/helpers.ts index 0d4c5e6..5f16e31 100644 --- a/tests/unit/helpers.ts +++ b/tests/unit/helpers.ts @@ -5,13 +5,13 @@ import util from 'util'; import rimraf from 'rimraf'; import { SemVer } from 'semver'; import { nanoid } from 'nanoid'; -import type { Package } from '../../src/package-utils'; +import type { Package } from '../../src/package'; import { ManifestFieldNames, ManifestDependencyFieldNames, -} from '../../src/package-manifest-utils'; -import type { Project } from '../../src/project-utils'; -import type { ValidatedManifest } from '../../src/package-manifest-utils'; +} from '../../src/package-manifest'; +import type { ValidatedManifest } from '../../src/package-manifest'; +import type { Project } from '../../src/project'; /** * Returns a version of the given record type where optionality is added to From 006fe54aa006af9e1445ae4556be4c08443f991b Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Fri, 29 Jul 2022 10:54:20 -0600 Subject: [PATCH 23/41] Skip command-line-arguments from tests --- jest.config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jest.config.js b/jest.config.js index 24debcf..be55ba2 100644 --- a/jest.config.js +++ b/jest.config.js @@ -31,7 +31,7 @@ module.exports = { coveragePathIgnorePatterns: [ '/node_modules/', '/src/cli.ts', - '/src/inputs-utils.ts', + '/src/command-line-arguments.ts', ], // Indicates which provider should be used to instrument code for coverage From cbdf61abed7d72b460f11b908f6b8d6c7b0c88da Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Fri, 29 Jul 2022 11:16:27 -0600 Subject: [PATCH 24/41] Make args to readManifestDependencyFields consistent with readManifestField --- src/package-manifest.ts | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/src/package-manifest.ts b/src/package-manifest.ts index b9fa9c5..2bea961 100644 --- a/src/package-manifest.ts +++ b/src/package-manifest.ts @@ -249,15 +249,19 @@ function readManifestField({ * Retrieves and checks the dependency fields of a package manifest object, * throwing if any of them is not present or is not the correct type. * - * @param manifest - The manifest data to validate. - * @param parentDirectory - The directory of the package to which the manifest - * belongs. + * @param args - The arguments. + * @param args.manifest - The manifest data to validate. + * @param args.parentDirectory - The directory of the package to which the + * manifest belongs. * @returns The extracted dependency fields and their values. */ -function readManifestDependencyFields( - manifest: UnvalidatedManifest, - parentDirectory: string, -) { +function readManifestDependencyFields({ + manifest, + parentDirectory, +}: { + manifest: UnvalidatedManifest; + parentDirectory: string; +}) { return Object.values(ManifestDependencyFieldNames).reduce( (obj, fieldName) => { const dependencies = readManifestField({ @@ -313,10 +317,10 @@ export async function readManifest( validation: validationForManifestPrivateField, defaultValue: false, }); - const dependencyFields = readManifestDependencyFields( - unvalidatedManifest, + const dependencyFields = readManifestDependencyFields({ + manifest: unvalidatedManifest, parentDirectory, - ); + }); return { [ManifestFieldNames.Name]: name, From 6a3887a5f3816686f1da66eec35a7a4ee2411dd3 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Fri, 29 Jul 2022 14:33:12 -0600 Subject: [PATCH 25/41] wip --- src/package-manifest.test.ts | 142 ++++++++++------------------------- 1 file changed, 39 insertions(+), 103 deletions(-) diff --git a/src/package-manifest.test.ts b/src/package-manifest.test.ts index e89d7f6..12f1c0a 100644 --- a/src/package-manifest.test.ts +++ b/src/package-manifest.test.ts @@ -241,111 +241,47 @@ describe('package-manifest', () => { }); }); - it('throws if "private" is not a boolean', async () => { - await withSandbox(async (sandbox) => { - const manifestPath = path.join(sandbox.directoryPath, 'package.json'); - await fs.promises.writeFile( - manifestPath, - JSON.stringify({ - name: 'foo', - version: '1.2.3', - private: 12345, - }), - ); - - await expect(readManifest(manifestPath)).rejects.toThrow( - 'The value of "private" in the manifest for "foo" must be true or false (if present)', - ); - }); - }); - - it('throws if "bundledDependencies" is not an object with string keys and string values', async () => { - await withSandbox(async (sandbox) => { - const manifestPath = path.join(sandbox.directoryPath, 'package.json'); - await fs.promises.writeFile( - manifestPath, - JSON.stringify({ - name: 'foo', - version: '1.2.3', - bundledDependencies: 12345, - }), - ); - - await expect(readManifest(manifestPath)).rejects.toThrow( - 'The value of "bundledDependencies" in the manifest for "foo" must be an object with non-empty string keys and non-empty string values', - ); - }); - }); - - it('throws if "dependencies" is not an object with string keys and string values', async () => { - await withSandbox(async (sandbox) => { - const manifestPath = path.join(sandbox.directoryPath, 'package.json'); - await fs.promises.writeFile( - manifestPath, - JSON.stringify({ - name: 'foo', - version: '1.2.3', - dependencies: 12345, - }), - ); - - await expect(readManifest(manifestPath)).rejects.toThrow( - 'The value of "dependencies" in the manifest for "foo" must be an object with non-empty string keys and non-empty string values', - ); - }); - }); - - it('throws if "devDependencies" is not an object with string keys and string values', async () => { - await withSandbox(async (sandbox) => { - const manifestPath = path.join(sandbox.directoryPath, 'package.json'); - await fs.promises.writeFile( - manifestPath, - JSON.stringify({ - name: 'foo', - version: '1.2.3', - devDependencies: 12345, - }), - ); - - await expect(readManifest(manifestPath)).rejects.toThrow( - 'The value of "devDependencies" in the manifest for "foo" must be an object with non-empty string keys and non-empty string values', - ); - }); - }); - - it('throws if "optionalDependencies" is not an object with string keys and string values', async () => { - await withSandbox(async (sandbox) => { - const manifestPath = path.join(sandbox.directoryPath, 'package.json'); - await fs.promises.writeFile( - manifestPath, - JSON.stringify({ - name: 'foo', - version: '1.2.3', - optionalDependencies: 12345, - }), - ); - - await expect(readManifest(manifestPath)).rejects.toThrow( - 'The value of "optionalDependencies" in the manifest for "foo" must be an object with non-empty string keys and non-empty string values', - ); + [ + 'bundledDependencies', + 'dependencies', + 'devDependencies', + 'optionalDependencies', + 'peerDependencies', + ].forEach((fieldName) => { + it(`throws if "${fieldName}" is not an object`, async () => { + await withSandbox(async (sandbox) => { + const manifestPath = path.join(sandbox.directoryPath, 'package.json'); + await fs.promises.writeFile( + manifestPath, + JSON.stringify({ + name: 'foo', + version: '1.2.3', + [fieldName]: 12345, + }), + ); + + await expect(readManifest(manifestPath)).rejects.toThrow( + `The value of "${fieldName}" in the manifest for "foo" must be an object with non-empty string keys and non-empty string values`, + ); + }); }); - }); - it('throws if "peerDependencies" is not an object with string keys and string values', async () => { - await withSandbox(async (sandbox) => { - const manifestPath = path.join(sandbox.directoryPath, 'package.json'); - await fs.promises.writeFile( - manifestPath, - JSON.stringify({ - name: 'foo', - version: '1.2.3', - peerDependencies: 12345, - }), - ); - - await expect(readManifest(manifestPath)).rejects.toThrow( - 'The value of "peerDependencies" in the manifest for "foo" must be an object with non-empty string keys and non-empty string values', - ); + it(`throws if "${fieldName}" is not an object with string values`, async () => { + await withSandbox(async (sandbox) => { + const manifestPath = path.join(sandbox.directoryPath, 'package.json'); + await fs.promises.writeFile( + manifestPath, + JSON.stringify({ + name: 'foo', + version: '1.2.3', + [fieldName]: { foo: 12345 }, + }), + ); + + await expect(readManifest(manifestPath)).rejects.toThrow( + `The value of "${fieldName}" in the manifest for "foo" must be an object with non-empty string keys and non-empty string values`, + ); + }); }); }); }); From ecbba2bce661cdb3490b43b64bc5569ecd344911 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Fri, 29 Jul 2022 14:51:44 -0600 Subject: [PATCH 26/41] Simplify UnvalidatedManifest and add more docs to package manifest stuff --- src/package-manifest.ts | 68 +++++++++++++++++++++++++++++++---------- 1 file changed, 52 insertions(+), 16 deletions(-) diff --git a/src/package-manifest.ts b/src/package-manifest.ts index 2bea961..e841b20 100644 --- a/src/package-manifest.ts +++ b/src/package-manifest.ts @@ -11,14 +11,27 @@ export { ManifestFieldNames, ManifestDependencyFieldNames }; /** * An unverified representation of the data in a package's `package.json`. - * (We know which properties could be present but haven't checked their types - * yet.) */ -type UnvalidatedManifest = Readonly>> & - Readonly>>; +export type UnvalidatedManifest = Readonly>; /** * A type-checked representation of the data in a package's `package.json`. + * + * @property name - The name of the package. + * @property version - The version of the package. + * @property private - Whether the package is private. + * @property workspaces - Paths to subpackages within the package. + * @property bundledDependencies - The set of packages that are expected to be + * bundled when publishing the package. + * @property dependencies - The set of packages, and their versions, that the + * published version of the package needs to run effectively. + * @property devDependencies - The set of packages, and their versions, that the + * the package relies upon for development purposes (such as tests or + * locally-run scripts). + * @property optionalDependencies - The set of packages, and their versions, + * that the package may need but is not required for use. + * @property peerDependencies - The set of packages, and their versions, that + * the package may need but is not required for use. Intended for plugins. */ export type ValidatedManifest = { readonly [ManifestFieldNames.Name]: string; @@ -62,26 +75,46 @@ interface ReadManifestFieldOptions { transform?: (value: T) => U; } +/** + * Object that includes a check for validating the "name" field of a manifest + * along with an error message if that validation fails. + */ const validationForManifestNameField = { check: isTruthyString, failureReason: 'must be a non-empty string', }; +/** + * Object that includes a check for validating the "version" field of a manifest + * along with an error message if that validation fails. + */ const validationForManifestVersionField = { check: isValidManifestVersionField, failureReason: 'must be a valid SemVer version string', }; +/** + * Object that includes a check for validating the "workspaces" field of a + * manifest along with an error message if that validation fails. + */ const validationForManifestWorkspacesField = { check: isValidManifestWorkspacesField, failureReason: 'must be an array of non-empty strings (if present)', }; +/** + * Object that includes a check for validating the "private" field of a manifest + * along with an error message if that validation fails. + */ const validationForManifestPrivateField = { check: isValidManifestPrivateField, failureReason: 'must be true or false (if present)', }; +/** + * Object that includes a check for validating any of the "dependencies" fields + * of a manifest along with an error message if that validation fails. + */ const validationForManifestDependenciesField = { check: isValidManifestDependenciesField, failureReason: @@ -143,7 +176,6 @@ function isValidManifestDependenciesField( return ( dependencies === undefined || (isObject(dependencies) && - Object.keys(dependencies).every(isTruthyString) && Object.values(dependencies).every(isTruthyString)) ); } @@ -180,8 +212,7 @@ function buildManifestFieldValidationErrorMessage({ } /** - * Retrieves and validates a field within a package manifest object, throwing if - * validation fails. + * Retrieves and validates a field within a package manifest object. * * @template T - The expected type of the field value (should include * `undefined` if not expected to present). @@ -201,7 +232,9 @@ function buildManifestFieldValidationErrorMessage({ * the field is not present. * @param args.transform - A function to call with the value after it has been * validated. - * @returns The value of the field, or the default value. + * @returns The value of the field, or the default value if the field is not + * present. + * @throws If the validation on the field fails. */ function readManifestField( options: Omit, 'transform' | 'defaultValue'>, @@ -246,16 +279,18 @@ function readManifestField({ } /** - * Retrieves and checks the dependency fields of a package manifest object, - * throwing if any of them is not present or is not the correct type. + * Retrieves and validates the "dependencies" fields of a package manifest + * object. * * @param args - The arguments. * @param args.manifest - The manifest data to validate. * @param args.parentDirectory - The directory of the package to which the * manifest belongs. - * @returns The extracted dependency fields and their values. + * @returns All of the possible "dependencies" fields and their values (if any + * one does not exist, it defaults to `{}`). + * @throws If the validation on any of the dependencies fields fails. */ -function readManifestDependencyFields({ +function readManifestDependenciesFields({ manifest, parentDirectory, }: { @@ -279,11 +314,12 @@ function readManifestDependencyFields({ /** * Reads the package manifest at the given path, verifying key data within the - * manifest and throwing if that data is incomplete. + * manifest. * * @param manifestPath - The path of the manifest file. - * @returns Information about a correctly typed version of the manifest for a - * package. + * @returns The correctly typed version of the manifest. + * @throws If key data within the manifest is missing (currently `name` and + * `version`). */ export async function readManifest( manifestPath: string, @@ -317,7 +353,7 @@ export async function readManifest( validation: validationForManifestPrivateField, defaultValue: false, }); - const dependencyFields = readManifestDependencyFields({ + const dependencyFields = readManifestDependenciesFields({ manifest: unvalidatedManifest, parentDirectory, }); From fec220a7ccfa996b65dca37199f0de678e4db113 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Fri, 29 Jul 2022 14:59:18 -0600 Subject: [PATCH 27/41] Rename 'Manifest' types to 'PackageManifest' --- src/monorepo-workflow-operations.test.ts | 4 +- src/package-manifest.test.ts | 80 +++++++++--- src/package-manifest.ts | 152 ++++++++++++----------- src/package.test.ts | 2 +- src/package.ts | 9 +- src/project.ts | 4 +- tests/unit/helpers.ts | 38 +++--- 7 files changed, 174 insertions(+), 115 deletions(-) diff --git a/src/monorepo-workflow-operations.test.ts b/src/monorepo-workflow-operations.test.ts index d20f288..6cb73ad 100644 --- a/src/monorepo-workflow-operations.test.ts +++ b/src/monorepo-workflow-operations.test.ts @@ -11,7 +11,7 @@ import * as editorModule from './editor'; import * as envModule from './env'; import * as packageModule from './package'; import type { Package } from './package'; -import type { ValidatedManifest } from './package-manifest'; +import type { ValidatedPackageManifest } from './package-manifest'; import type { Project } from './project'; import * as releaseSpecificationModule from './release-specification'; import * as workflowOperations from './workflow-operations'; @@ -1569,7 +1569,7 @@ function buildMockMonorepoRootPackage( name = 'root', version = '2022.1.1', overrides: Omit, 'manifest'> & { - manifest?: Partial; + manifest?: Partial; } = {}, ) { const { manifest, ...rest } = overrides; diff --git a/src/package-manifest.test.ts b/src/package-manifest.test.ts index 12f1c0a..99799fb 100644 --- a/src/package-manifest.test.ts +++ b/src/package-manifest.test.ts @@ -2,10 +2,10 @@ import fs from 'fs'; import path from 'path'; import { SemVer } from 'semver'; import { withSandbox } from '../tests/unit/helpers'; -import { readManifest } from './package-manifest'; +import { readPackageManifest } from './package-manifest'; describe('package-manifest', () => { - describe('readManifest', () => { + describe('readPackageManifest', () => { it('reads a minimal package manifest, expanding it by filling in values for optional fields', async () => { await withSandbox(async (sandbox) => { const manifestPath = path.join(sandbox.directoryPath, 'package.json'); @@ -17,7 +17,59 @@ describe('package-manifest', () => { }), ); - expect(await readManifest(manifestPath)).toStrictEqual({ + expect(await readPackageManifest(manifestPath)).toStrictEqual({ + name: 'foo', + version: new SemVer('1.2.3'), + workspaces: [], + private: false, + bundledDependencies: {}, + dependencies: {}, + devDependencies: {}, + optionalDependencies: {}, + peerDependencies: {}, + }); + }); + }); + + it('reads a package manifest where "private" is true', async () => { + await withSandbox(async (sandbox) => { + const manifestPath = path.join(sandbox.directoryPath, 'package.json'); + await fs.promises.writeFile( + manifestPath, + JSON.stringify({ + name: 'foo', + version: '1.2.3', + private: true, + }), + ); + + expect(await readPackageManifest(manifestPath)).toStrictEqual({ + name: 'foo', + version: new SemVer('1.2.3'), + workspaces: [], + private: true, + bundledDependencies: {}, + dependencies: {}, + devDependencies: {}, + optionalDependencies: {}, + peerDependencies: {}, + }); + }); + }); + + it('reads a package manifest where "private" is false', async () => { + await withSandbox(async (sandbox) => { + const manifestPath = path.join(sandbox.directoryPath, 'package.json'); + await fs.promises.writeFile( + manifestPath, + JSON.stringify({ + name: 'foo', + version: '1.2.3', + private: false, + }), + ); + + expect(await readPackageManifest(manifestPath)).toStrictEqual({ name: 'foo', version: new SemVer('1.2.3'), workspaces: [], @@ -59,7 +111,7 @@ describe('package-manifest', () => { }), ); - expect(await readManifest(manifestPath)).toStrictEqual({ + expect(await readPackageManifest(manifestPath)).toStrictEqual({ name: 'foo', version: new SemVer('1.2.3'), workspaces: ['packages/*'], @@ -100,7 +152,7 @@ describe('package-manifest', () => { }), ); - expect(await readManifest(manifestPath)).toStrictEqual({ + expect(await readPackageManifest(manifestPath)).toStrictEqual({ name: 'foo', version: new SemVer('1.2.3'), workspaces: [], @@ -126,7 +178,7 @@ describe('package-manifest', () => { }), ); - expect(await readManifest(manifestPath)).toStrictEqual({ + expect(await readPackageManifest(manifestPath)).toStrictEqual({ name: 'foo', version: new SemVer('1.2.3'), workspaces: [], @@ -150,7 +202,7 @@ describe('package-manifest', () => { }), ); - await expect(readManifest(manifestPath)).rejects.toThrow( + await expect(readPackageManifest(manifestPath)).rejects.toThrow( `The value of "name" in the manifest located at "${sandbox.directoryPath}" must be a non-empty string`, ); }); @@ -167,7 +219,7 @@ describe('package-manifest', () => { }), ); - await expect(readManifest(manifestPath)).rejects.toThrow( + await expect(readPackageManifest(manifestPath)).rejects.toThrow( `The value of "name" in the manifest located at "${sandbox.directoryPath}" must be a non-empty string`, ); }); @@ -184,7 +236,7 @@ describe('package-manifest', () => { }), ); - await expect(readManifest(manifestPath)).rejects.toThrow( + await expect(readPackageManifest(manifestPath)).rejects.toThrow( `The value of "name" in the manifest located at "${sandbox.directoryPath}" must be a non-empty string`, ); }); @@ -200,7 +252,7 @@ describe('package-manifest', () => { }), ); - await expect(readManifest(manifestPath)).rejects.toThrow( + await expect(readPackageManifest(manifestPath)).rejects.toThrow( 'The value of "version" in the manifest for "foo" must be a valid SemVer version string', ); }); @@ -217,7 +269,7 @@ describe('package-manifest', () => { }), ); - await expect(readManifest(manifestPath)).rejects.toThrow( + await expect(readPackageManifest(manifestPath)).rejects.toThrow( 'The value of "version" in the manifest for "foo" must be a valid SemVer version string', ); }); @@ -235,7 +287,7 @@ describe('package-manifest', () => { }), ); - await expect(readManifest(manifestPath)).rejects.toThrow( + await expect(readPackageManifest(manifestPath)).rejects.toThrow( 'The value of "workspaces" in the manifest for "foo" must be an array of non-empty strings (if present)', ); }); @@ -260,7 +312,7 @@ describe('package-manifest', () => { }), ); - await expect(readManifest(manifestPath)).rejects.toThrow( + await expect(readPackageManifest(manifestPath)).rejects.toThrow( `The value of "${fieldName}" in the manifest for "foo" must be an object with non-empty string keys and non-empty string values`, ); }); @@ -278,7 +330,7 @@ describe('package-manifest', () => { }), ); - await expect(readManifest(manifestPath)).rejects.toThrow( + await expect(readPackageManifest(manifestPath)).rejects.toThrow( `The value of "${fieldName}" in the manifest for "foo" must be an object with non-empty string keys and non-empty string values`, ); }); diff --git a/src/package-manifest.ts b/src/package-manifest.ts index e841b20..6bd2b04 100644 --- a/src/package-manifest.ts +++ b/src/package-manifest.ts @@ -1,18 +1,18 @@ import path from 'path'; import { - ManifestFieldNames, - ManifestDependencyFieldNames, + ManifestFieldNames as PackageManifestFieldNames, + ManifestDependencyFieldNames as PackageManifestDependenciesFieldNames, } from '@metamask/action-utils'; import { readJsonObjectFile } from './fs'; import { isTruthyString, isObject, Require } from './misc-utils'; import { isValidSemver, SemVer } from './semver'; -export { ManifestFieldNames, ManifestDependencyFieldNames }; +export { PackageManifestFieldNames, PackageManifestDependenciesFieldNames }; /** * An unverified representation of the data in a package's `package.json`. */ -export type UnvalidatedManifest = Readonly>; +export type UnvalidatedPackageManifest = Readonly>; /** * A type-checked representation of the data in a package's `package.json`. @@ -33,17 +33,17 @@ export type UnvalidatedManifest = Readonly>; * @property peerDependencies - The set of packages, and their versions, that * the package may need but is not required for use. Intended for plugins. */ -export type ValidatedManifest = { - readonly [ManifestFieldNames.Name]: string; - readonly [ManifestFieldNames.Version]: SemVer; - readonly [ManifestFieldNames.Private]: boolean; - readonly [ManifestFieldNames.Workspaces]: string[]; +export type ValidatedPackageManifest = { + readonly [PackageManifestFieldNames.Name]: string; + readonly [PackageManifestFieldNames.Version]: SemVer; + readonly [PackageManifestFieldNames.Private]: boolean; + readonly [PackageManifestFieldNames.Workspaces]: string[]; } & Readonly< - Partial>> + Partial>> >; /** - * Represents options to `readManifestField`. + * Represents options to `readPackageManifestField`. * * @template T - The expected type of the field value (should include * `undefined` if not expected to present). @@ -63,10 +63,10 @@ export type ValidatedManifest = { * @property transform - A function to call with the value after it has been * validated. */ -interface ReadManifestFieldOptions { - manifest: UnvalidatedManifest; +interface ReadPackageManifestFieldOptions { + manifest: UnvalidatedPackageManifest; parentDirectory: string; - fieldName: keyof UnvalidatedManifest; + fieldName: keyof UnvalidatedPackageManifest; validation: { check: (value: any) => value is T; failureReason: string; @@ -79,7 +79,7 @@ interface ReadManifestFieldOptions { * Object that includes a check for validating the "name" field of a manifest * along with an error message if that validation fails. */ -const validationForManifestNameField = { +const validationForPackageManifestNameField = { check: isTruthyString, failureReason: 'must be a non-empty string', }; @@ -88,8 +88,8 @@ const validationForManifestNameField = { * Object that includes a check for validating the "version" field of a manifest * along with an error message if that validation fails. */ -const validationForManifestVersionField = { - check: isValidManifestVersionField, +const validationForPackageManifestVersionField = { + check: isValidPackageManifestVersionField, failureReason: 'must be a valid SemVer version string', }; @@ -97,8 +97,8 @@ const validationForManifestVersionField = { * Object that includes a check for validating the "workspaces" field of a * manifest along with an error message if that validation fails. */ -const validationForManifestWorkspacesField = { - check: isValidManifestWorkspacesField, +const validationForPackageManifestWorkspacesField = { + check: isValidPackageManifestWorkspacesField, failureReason: 'must be an array of non-empty strings (if present)', }; @@ -106,8 +106,8 @@ const validationForManifestWorkspacesField = { * Object that includes a check for validating the "private" field of a manifest * along with an error message if that validation fails. */ -const validationForManifestPrivateField = { - check: isValidManifestPrivateField, +const validationForPackageManifestPrivateField = { + check: isValidPackageManifestPrivateField, failureReason: 'must be true or false (if present)', }; @@ -115,8 +115,8 @@ const validationForManifestPrivateField = { * Object that includes a check for validating any of the "dependencies" fields * of a manifest along with an error message if that validation fails. */ -const validationForManifestDependenciesField = { - check: isValidManifestDependenciesField, +const validationForPackageManifestDependenciesField = { + check: isValidPackageManifestDependenciesField, failureReason: 'must be an object with non-empty string keys and non-empty string values', }; @@ -127,7 +127,7 @@ const validationForManifestDependenciesField = { * @param version - The value to check. * @returns Whether the value is valid. */ -function isValidManifestVersionField(version: any): version is string { +function isValidPackageManifestVersionField(version: any): version is string { return isTruthyString(version) && isValidSemver(version); } @@ -138,7 +138,7 @@ function isValidManifestVersionField(version: any): version is string { * @param workspaces - The value to check. * @returns Whether the value is valid. */ -function isValidManifestWorkspacesField( +function isValidPackageManifestWorkspacesField( workspaces: any, ): workspaces is string[] | undefined { return ( @@ -154,7 +154,7 @@ function isValidManifestWorkspacesField( * @param privateValue - The value to check. * @returns Whether the value is valid. */ -function isValidManifestPrivateField( +function isValidPackageManifestPrivateField( privateValue: any, ): privateValue is boolean | undefined { return ( @@ -165,12 +165,13 @@ function isValidManifestPrivateField( } /** - * Type guard to ensure that the given dependencies field of a manifest is valid. + * Type guard to ensure that the given dependencies field of a manifest is + * valid. * * @param dependencies - The value to check. * @returns Whether the value is valid. */ -function isValidManifestDependenciesField( +function isValidPackageManifestDependenciesField( dependencies: any, ): dependencies is Record { return ( @@ -192,20 +193,20 @@ function isValidManifestDependenciesField( * explanation for why it is invalid. * @returns The error message. */ -function buildManifestFieldValidationErrorMessage({ +function buildPackageManifestFieldValidationErrorMessage({ manifest, parentDirectory, invalidFieldName, verbPhrase, }: { - manifest: UnvalidatedManifest; + manifest: UnvalidatedPackageManifest; parentDirectory: string; - invalidFieldName: keyof UnvalidatedManifest; + invalidFieldName: keyof UnvalidatedPackageManifest; verbPhrase: string; }) { - const subject = isTruthyString(manifest[ManifestFieldNames.Name]) + const subject = isTruthyString(manifest[PackageManifestFieldNames.Name]) ? `The value of "${invalidFieldName}" in the manifest for "${ - manifest[ManifestFieldNames.Name] + manifest[PackageManifestFieldNames.Name] }"` : `The value of "${invalidFieldName}" in the manifest located at "${parentDirectory}"`; return `${subject} ${verbPhrase}`; @@ -236,29 +237,32 @@ function buildManifestFieldValidationErrorMessage({ * present. * @throws If the validation on the field fails. */ -function readManifestField( - options: Omit, 'transform' | 'defaultValue'>, +function readPackageManifestField( + options: Omit< + ReadPackageManifestFieldOptions, + 'transform' | 'defaultValue' + >, ): T; -function readManifestField( - options: Require, 'transform'>, +function readPackageManifestField( + options: Require, 'transform'>, ): U; -function readManifestField( - options: Require, 'defaultValue'>, +function readPackageManifestField( + options: Require, 'defaultValue'>, ): T extends undefined ? U : T; /* eslint-disable-next-line jsdoc/require-jsdoc */ -function readManifestField({ +function readPackageManifestField({ manifest, parentDirectory, fieldName, validation, defaultValue, transform, -}: ReadManifestFieldOptions): T | U { +}: ReadPackageManifestFieldOptions): T | U { const value = manifest[fieldName]; if (!validation.check(value)) { throw new Error( - buildManifestFieldValidationErrorMessage({ + buildPackageManifestFieldValidationErrorMessage({ manifest, parentDirectory, invalidFieldName: fieldName, @@ -290,25 +294,25 @@ function readManifestField({ * one does not exist, it defaults to `{}`). * @throws If the validation on any of the dependencies fields fails. */ -function readManifestDependenciesFields({ +function readPackageManifestDependenciesFields({ manifest, parentDirectory, }: { - manifest: UnvalidatedManifest; + manifest: UnvalidatedPackageManifest; parentDirectory: string; }) { - return Object.values(ManifestDependencyFieldNames).reduce( + return Object.values(PackageManifestDependenciesFieldNames).reduce( (obj, fieldName) => { - const dependencies = readManifestField({ + const dependencies = readPackageManifestField({ manifest, parentDirectory, fieldName, - validation: validationForManifestDependenciesField, + validation: validationForPackageManifestDependenciesField, defaultValue: {}, }); return { ...obj, [fieldName]: dependencies }; }, - {} as Record>, + {} as Record>, ); } @@ -321,48 +325,48 @@ function readManifestDependenciesFields({ * @throws If key data within the manifest is missing (currently `name` and * `version`). */ -export async function readManifest( +export async function readPackageManifest( manifestPath: string, -): Promise { - const unvalidatedManifest = await readJsonObjectFile(manifestPath); +): Promise { + const unvalidatedPackageManifest = await readJsonObjectFile(manifestPath); const parentDirectory = path.dirname(manifestPath); - const name = readManifestField({ - manifest: unvalidatedManifest, + const name = readPackageManifestField({ + manifest: unvalidatedPackageManifest, parentDirectory, - fieldName: ManifestFieldNames.Name, - validation: validationForManifestNameField, + fieldName: PackageManifestFieldNames.Name, + validation: validationForPackageManifestNameField, }); - const version = readManifestField({ - manifest: unvalidatedManifest, + const version = readPackageManifestField({ + manifest: unvalidatedPackageManifest, parentDirectory, - fieldName: ManifestFieldNames.Version, - validation: validationForManifestVersionField, + fieldName: PackageManifestFieldNames.Version, + validation: validationForPackageManifestVersionField, transform: (value: string) => new SemVer(value), }); - const workspaces = readManifestField({ - manifest: unvalidatedManifest, + const workspaces = readPackageManifestField({ + manifest: unvalidatedPackageManifest, parentDirectory, - fieldName: ManifestFieldNames.Workspaces, - validation: validationForManifestWorkspacesField, + fieldName: PackageManifestFieldNames.Workspaces, + validation: validationForPackageManifestWorkspacesField, defaultValue: [], }); - const privateValue = readManifestField({ - manifest: unvalidatedManifest, + const privateValue = readPackageManifestField({ + manifest: unvalidatedPackageManifest, parentDirectory, - fieldName: ManifestFieldNames.Private, - validation: validationForManifestPrivateField, + fieldName: PackageManifestFieldNames.Private, + validation: validationForPackageManifestPrivateField, defaultValue: false, }); - const dependencyFields = readManifestDependenciesFields({ - manifest: unvalidatedManifest, + const dependenciesFields = readPackageManifestDependenciesFields({ + manifest: unvalidatedPackageManifest, parentDirectory, }); return { - [ManifestFieldNames.Name]: name, - [ManifestFieldNames.Version]: version, - [ManifestFieldNames.Workspaces]: workspaces, - [ManifestFieldNames.Private]: privateValue, - ...dependencyFields, + [PackageManifestFieldNames.Name]: name, + [PackageManifestFieldNames.Version]: version, + [PackageManifestFieldNames.Workspaces]: workspaces, + [PackageManifestFieldNames.Private]: privateValue, + ...dependenciesFields, }; } diff --git a/src/package.test.ts b/src/package.test.ts index 46bcac5..c694de0 100644 --- a/src/package.test.ts +++ b/src/package.test.ts @@ -19,7 +19,7 @@ describe('package', () => { it('reads information about the package located at the given directory', async () => { const packageDirectoryPath = '/path/to/package'; jest - .spyOn(packageManifestModule, 'readManifest') + .spyOn(packageManifestModule, 'readPackageManifest') .mockResolvedValue(buildMockManifest()); const pkg = await readPackage(packageDirectoryPath); diff --git a/src/package.ts b/src/package.ts index 3c87c47..26efc85 100644 --- a/src/package.ts +++ b/src/package.ts @@ -3,7 +3,10 @@ import path from 'path'; import { updateChangelog } from '@metamask/auto-changelog'; import { isErrorWithCode } from './misc-utils'; import { readFile, writeFile, writeJsonFile } from './fs'; -import { readManifest, ValidatedManifest } from './package-manifest'; +import { + readPackageManifest, + ValidatedPackageManifest, +} from './package-manifest'; import { Project } from './project'; import { PackageReleasePlan } from './workflow-operations'; @@ -23,7 +26,7 @@ const CHANGELOG_FILE_NAME = 'CHANGELOG.md'; export interface Package { directoryPath: string; manifestPath: string; - manifest: ValidatedManifest; + manifest: ValidatedPackageManifest; changelogPath: string; } @@ -38,7 +41,7 @@ export async function readPackage( ): Promise { const manifestPath = path.join(packageDirectoryPath, MANIFEST_FILE_NAME); const changelogPath = path.join(packageDirectoryPath, CHANGELOG_FILE_NAME); - const validatedManifest = await readManifest(manifestPath); + const validatedManifest = await readPackageManifest(manifestPath); return { directoryPath: packageDirectoryPath, diff --git a/src/project.ts b/src/project.ts index af1be8e..5976130 100644 --- a/src/project.ts +++ b/src/project.ts @@ -1,7 +1,7 @@ import util from 'util'; import glob from 'glob'; import { Package, readPackage } from './package'; -import { ManifestFieldNames } from './package-manifest'; +import { PackageManifestFieldNames } from './package-manifest'; import { getRepositoryHttpsUrl } from './repo'; /** @@ -47,7 +47,7 @@ export async function readProject( const workspaceDirectories = ( await Promise.all( - rootPackage.manifest[ManifestFieldNames.Workspaces].map( + rootPackage.manifest[PackageManifestFieldNames.Workspaces].map( async (workspacePattern) => { return await promisifiedGlob(workspacePattern, { cwd: projectDirectoryPath, diff --git a/tests/unit/helpers.ts b/tests/unit/helpers.ts index 5f16e31..401b2d2 100644 --- a/tests/unit/helpers.ts +++ b/tests/unit/helpers.ts @@ -7,10 +7,10 @@ import { SemVer } from 'semver'; import { nanoid } from 'nanoid'; import type { Package } from '../../src/package'; import { - ManifestFieldNames, - ManifestDependencyFieldNames, + PackageManifestFieldNames, + PackageManifestDependenciesFieldNames, } from '../../src/package-manifest'; -import type { ValidatedManifest } from '../../src/package-manifest'; +import type { ValidatedPackageManifest } from '../../src/package-manifest'; import type { Project } from '../../src/project'; /** @@ -97,8 +97,8 @@ type MockPackageOverrides = Omit< 'manifest' > & { manifest?: Omit< - Partial, - ManifestFieldNames.Name | ManifestFieldNames.Version + Partial, + PackageManifestFieldNames.Name | PackageManifestFieldNames.Version >; }; @@ -139,8 +139,8 @@ export function buildMockPackage( directoryPath, manifest: buildMockManifest({ ...manifest, - [ManifestFieldNames.Name]: name, - [ManifestFieldNames.Version]: + [PackageManifestFieldNames.Name]: name, + [PackageManifestFieldNames.Version]: version instanceof SemVer ? version : new SemVer(version), }), manifestPath, @@ -153,21 +153,21 @@ export function buildMockPackage( * values, so you can specify only the properties you care about. * * @param overrides - The properties to override in the manifest. - * @returns The mock ValidatedManifest. + * @returns The mock ValidatedPackageManifest. */ export function buildMockManifest( - overrides: Partial = {}, -): ValidatedManifest { + overrides: Partial = {}, +): ValidatedPackageManifest { return { - [ManifestFieldNames.Name]: 'foo', - [ManifestFieldNames.Version]: new SemVer('1.2.3'), - [ManifestFieldNames.Private]: false, - [ManifestFieldNames.Workspaces]: [], - [ManifestDependencyFieldNames.Bundled]: {}, - [ManifestDependencyFieldNames.Production]: {}, - [ManifestDependencyFieldNames.Development]: {}, - [ManifestDependencyFieldNames.Optional]: {}, - [ManifestDependencyFieldNames.Peer]: {}, + [PackageManifestFieldNames.Name]: 'foo', + [PackageManifestFieldNames.Version]: new SemVer('1.2.3'), + [PackageManifestFieldNames.Private]: false, + [PackageManifestFieldNames.Workspaces]: [], + [PackageManifestDependenciesFieldNames.Bundled]: {}, + [PackageManifestDependenciesFieldNames.Production]: {}, + [PackageManifestDependenciesFieldNames.Development]: {}, + [PackageManifestDependenciesFieldNames.Optional]: {}, + [PackageManifestDependenciesFieldNames.Peer]: {}, ...overrides, }; } From ad02957bb76d9bff22c156a1df141f67d5d63071 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Mon, 1 Aug 2022 09:54:21 -0600 Subject: [PATCH 28/41] Use .strict() when parsing command-line args Co-authored-by: Mark Stacey --- src/command-line-arguments.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/command-line-arguments.ts b/src/command-line-arguments.ts index 86105bd..753a162 100644 --- a/src/command-line-arguments.ts +++ b/src/command-line-arguments.ts @@ -38,5 +38,6 @@ export async function readCommandLineArguments( default: false, }) .help() + .strict() .parse(); } From eb391cfa895e76ff2855ecfce41c162b66606b29 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Mon, 1 Aug 2022 09:57:19 -0600 Subject: [PATCH 29/41] Update withSandbox to throw if entry exists, not just directory Co-authored-by: Mark Stacey --- tests/unit/helpers.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/tests/unit/helpers.ts b/tests/unit/helpers.ts index 401b2d2..34d5e72 100644 --- a/tests/unit/helpers.ts +++ b/tests/unit/helpers.ts @@ -52,13 +52,10 @@ export async function withSandbox(fn: (sandbox: Sandbox) => any) { let stats; try { - stats = await fs.promises.stat(directoryPath); - - if (stats.isDirectory()) { - throw new Error( - `Directory ${directoryPath} already exists, cannot continue`, - ); - } + await fs.promises.access(directoryPath); + throw new Error( + `Directory ${directoryPath} already exists, cannot continue`, + ); } catch (error: any) { if (error.code !== 'ENOENT') { throw error; From 58dcdf1bcc8415d0818fb3e76b101b861f05d05e Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Mon, 1 Aug 2022 10:06:16 -0600 Subject: [PATCH 30/41] Extract directory check in withSandbox --- tests/unit/helpers.ts | 36 +++++++++++++++++++++++------------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/tests/unit/helpers.ts b/tests/unit/helpers.ts index 34d5e72..ae26cec 100644 --- a/tests/unit/helpers.ts +++ b/tests/unit/helpers.ts @@ -40,28 +40,38 @@ const TEMP_DIRECTORY_PATH = path.join( ); /** - * Creates a temporary directory to hold files that a test could write, runs the - * given function, then ensures that the directory is removed afterward. + * Each test gets its own randomly generated directory in a temporary directory + * where it can perform filesystem operations. There is a miniscule chance + * that more than one test will receive the same name for its directory. If this + * happens, then all bets are off, and we should stop running tests, because + * the state that we expect to be isolated to a single test has now bled into + * another test. * - * @param fn - The function to call. - * @throws If the temporary directory already exists for some reason. This would - * indicate a bug in how the names of the directory is determined. + * @param entryPath - The path to the directory. + * @throws If the directory already exists (or a file exists in its place). */ -export async function withSandbox(fn: (sandbox: Sandbox) => any) { - const directoryPath = path.join(TEMP_DIRECTORY_PATH, nanoid()); - let stats; - +async function ensureFileEntryDoesNotExist(entryPath: string): Promise { try { - await fs.promises.access(directoryPath); - throw new Error( - `Directory ${directoryPath} already exists, cannot continue`, - ); + await fs.promises.access(entryPath); + throw new Error(`${entryPath} already exists, cannot continue`); } catch (error: any) { if (error.code !== 'ENOENT') { throw error; } } +} +/** + * Creates a temporary directory to hold files that a test could write to, runs + * the given function, then ensures that the directory is removed afterward. + * + * @param fn - The function to call. + * @throws If the temporary directory already exists for some reason. This would + * indicate a bug in how the names of the directory is determined. + */ +export async function withSandbox(fn: (sandbox: Sandbox) => any) { + const directoryPath = path.join(TEMP_DIRECTORY_PATH, nanoid()); + await ensureFileEntryDoesNotExist(directoryPath); await fs.promises.mkdir(directoryPath, { recursive: true }); try { From 6c52cc78d21831ed43add4314ce2f2d581d4ac3a Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Mon, 1 Aug 2022 15:37:26 -0600 Subject: [PATCH 31/41] Fix typo in package-manifest Co-authored-by: Mark Stacey --- src/package-manifest.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/package-manifest.ts b/src/package-manifest.ts index 6bd2b04..eb77e78 100644 --- a/src/package-manifest.ts +++ b/src/package-manifest.ts @@ -216,7 +216,7 @@ function buildPackageManifestFieldValidationErrorMessage({ * Retrieves and validates a field within a package manifest object. * * @template T - The expected type of the field value (should include - * `undefined` if not expected to present). + * `undefined` if not expected to be present). * @template U - The return type of this function, as determined via * `defaultValue` (if present) or `transform` (if present). * @param args - The arguments. From 7565f34f93b805efc1f4a20e07310250376709e3 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Mon, 1 Aug 2022 15:37:49 -0600 Subject: [PATCH 32/41] Fix another typo --- src/package-manifest.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/package-manifest.ts b/src/package-manifest.ts index eb77e78..17d9396 100644 --- a/src/package-manifest.ts +++ b/src/package-manifest.ts @@ -46,7 +46,7 @@ export type ValidatedPackageManifest = { * Represents options to `readPackageManifestField`. * * @template T - The expected type of the field value (should include - * `undefined` if not expected to present). + * `undefined` if not expected to be present). * @template U - The return type of this function, as determined via * `defaultValue` (if present) or `transform` (if present). * @property manifest - The manifest object. From fd2c32239c04d35ee683764111a869daea1b2393 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Tue, 2 Aug 2022 10:45:03 -0600 Subject: [PATCH 33/41] Remove generics from package-manifest --- src/misc-utils.ts | 6 - src/package-manifest.test.ts | 18 ++ src/package-manifest.ts | 446 +++++++++++++++++------------------ 3 files changed, 241 insertions(+), 229 deletions(-) diff --git a/src/misc-utils.ts b/src/misc-utils.ts index 567d983..d90b79f 100644 --- a/src/misc-utils.ts +++ b/src/misc-utils.ts @@ -12,12 +12,6 @@ export { hasProperty, isNullOrUndefined, isObject } from '@metamask/utils'; */ export const debug = createDebug('create-release-branch:impl'); -/** - * 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] }; - /** * 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 diff --git a/src/package-manifest.test.ts b/src/package-manifest.test.ts index 99799fb..a6796bd 100644 --- a/src/package-manifest.test.ts +++ b/src/package-manifest.test.ts @@ -293,6 +293,24 @@ describe('package-manifest', () => { }); }); + it('throws if "private" is not a boolean', async () => { + await withSandbox(async (sandbox) => { + const manifestPath = path.join(sandbox.directoryPath, 'package.json'); + await fs.promises.writeFile( + manifestPath, + JSON.stringify({ + name: 'foo', + version: '1.2.3', + private: 'whatever', + }), + ); + + await expect(readPackageManifest(manifestPath)).rejects.toThrow( + 'The value of "private" in the manifest for "foo" must be true or false (if present)', + ); + }); + }); + [ 'bundledDependencies', 'dependencies', diff --git a/src/package-manifest.ts b/src/package-manifest.ts index 17d9396..2a01cbc 100644 --- a/src/package-manifest.ts +++ b/src/package-manifest.ts @@ -4,7 +4,7 @@ import { ManifestDependencyFieldNames as PackageManifestDependenciesFieldNames, } from '@metamask/action-utils'; import { readJsonObjectFile } from './fs'; -import { isTruthyString, isObject, Require } from './misc-utils'; +import { isTruthyString, isObject } from './misc-utils'; import { isValidSemver, SemVer } from './semver'; export { PackageManifestFieldNames, PackageManifestDependenciesFieldNames }; @@ -43,103 +43,147 @@ export type ValidatedPackageManifest = { >; /** - * Represents options to `readPackageManifestField`. + * Constructs a validation error message for a field within the manifest. * - * @template T - The expected type of the field value (should include - * `undefined` if not expected to be present). - * @template U - The return type of this function, as determined via - * `defaultValue` (if present) or `transform` (if present). - * @property manifest - The manifest object. - * @property parentDirectory - The directory in which the manifest lives. - * @property fieldName - The name of the field. - * @property validation - The validation object. - * @property validation.check - A function to test whether the value for the - * field is valid. - * @property validation.failureReason - A snippet of the message that will be - * produced if the validation fails which explains the requirements of the field - * or merely says that it is invalid, with no explanation. - * @property defaultValue - A value to return in place of the field value if - * the field is not present. - * @property transform - A function to call with the value after it has been - * validated. + * @param args - The arguments. + * @param args.manifest - The manifest data that's invalid. + * @param args.parentDirectory - The directory of the package to which the + * manifest belongs. + * @param args.fieldName - The name of the field in the manifest. + * @param args.verbPhrase - Either the fact that the field is invalid or an + * explanation for why it is invalid. + * @returns The error message. */ -interface ReadPackageManifestFieldOptions { +function buildPackageManifestFieldValidationErrorMessage({ + manifest, + parentDirectory, + fieldName, + verbPhrase, +}: { manifest: UnvalidatedPackageManifest; parentDirectory: string; fieldName: keyof UnvalidatedPackageManifest; - validation: { - check: (value: any) => value is T; - failureReason: string; - }; - defaultValue?: U; - transform?: (value: T) => U; + verbPhrase: string; +}) { + const subject = isTruthyString(manifest[PackageManifestFieldNames.Name]) + ? `The value of "${fieldName}" in the manifest for "${ + manifest[PackageManifestFieldNames.Name] + }"` + : `The value of "${fieldName}" in the manifest located at "${parentDirectory}"`; + return `${subject} ${verbPhrase}`; } /** - * Object that includes a check for validating the "name" field of a manifest - * along with an error message if that validation fails. + * Object that includes checks for validating fields within a manifest + * along with error messages if those validations fail. */ -const validationForPackageManifestNameField = { - check: isTruthyString, - failureReason: 'must be a non-empty string', +const schemata = { + [PackageManifestFieldNames.Name]: { + validate: isTruthyString, + errorMessage: 'must be a non-empty string', + }, + [PackageManifestFieldNames.Version]: { + validate: isValidPackageManifestVersionField, + errorMessage: 'must be a valid SemVer version string', + }, + [PackageManifestFieldNames.Workspaces]: { + validate: isValidPackageManifestWorkspacesField, + errorMessage: 'must be an array of non-empty strings (if present)', + }, + [PackageManifestFieldNames.Private]: { + validate: isValidPackageManifestPrivateField, + errorMessage: 'must be true or false (if present)', + }, + dependencies: { + validate: isValidPackageManifestDependenciesField, + errorMessage: + 'must be an object with non-empty string keys and non-empty string values', + }, }; /** - * Object that includes a check for validating the "version" field of a manifest - * along with an error message if that validation fails. + * Retrieves and validates the "name" field within the package manifest object. + * + * @param manifest - The manifest object. + * @param parentDirectory - The directory in which the manifest lives. + * @returns The value of the "name" field. + * @throws If the value of the field is not a truthy string. */ -const validationForPackageManifestVersionField = { - check: isValidPackageManifestVersionField, - failureReason: 'must be a valid SemVer version string', -}; +export function readPackageManifestNameField( + manifest: UnvalidatedPackageManifest, + parentDirectory: string, +): string { + const fieldName = PackageManifestFieldNames.Name; + const value = manifest[fieldName]; + const schema = schemata[fieldName]; -/** - * Object that includes a check for validating the "workspaces" field of a - * manifest along with an error message if that validation fails. - */ -const validationForPackageManifestWorkspacesField = { - check: isValidPackageManifestWorkspacesField, - failureReason: 'must be an array of non-empty strings (if present)', -}; + if (!schema.validate(value)) { + throw new Error( + buildPackageManifestFieldValidationErrorMessage({ + manifest, + parentDirectory, + fieldName: PackageManifestFieldNames.Name, + verbPhrase: schema.errorMessage, + }), + ); + } -/** - * Object that includes a check for validating the "private" field of a manifest - * along with an error message if that validation fails. - */ -const validationForPackageManifestPrivateField = { - check: isValidPackageManifestPrivateField, - failureReason: 'must be true or false (if present)', -}; + return value; +} /** - * Object that includes a check for validating any of the "dependencies" fields - * of a manifest along with an error message if that validation fails. + * Type guard to ensure that the value of the "version" field of a manifest is + * valid. + * + * @param version - The value to check. + * @returns Whether the version is a valid SemVer version string. */ -const validationForPackageManifestDependenciesField = { - check: isValidPackageManifestDependenciesField, - failureReason: - 'must be an object with non-empty string keys and non-empty string values', -}; +function isValidPackageManifestVersionField( + version: unknown, +): version is string { + return isTruthyString(version) && isValidSemver(version); +} /** - * Type guard to ensure that the given "version" field of a manifest is valid. + * Retrieves and validates the "version" field within the package manifest + * object. * - * @param version - The value to check. - * @returns Whether the value is valid. + * @param manifest - The manifest object. + * @param parentDirectory - The directory in which the manifest lives. + * @returns The value of the "version" field wrapped in a SemVer object. + * @throws If the value of the field is not a valid SemVer version string. */ -function isValidPackageManifestVersionField(version: any): version is string { - return isTruthyString(version) && isValidSemver(version); +export function readPackageManifestVersionField( + manifest: UnvalidatedPackageManifest, + parentDirectory: string, +): SemVer { + const fieldName = PackageManifestFieldNames.Version; + const value = manifest[fieldName]; + const schema = schemata[fieldName]; + + if (!schema.validate(value)) { + throw new Error( + buildPackageManifestFieldValidationErrorMessage({ + manifest, + parentDirectory, + fieldName: PackageManifestFieldNames.Version, + verbPhrase: schema.errorMessage, + }), + ); + } + + return new SemVer(value); } /** - * Type guard to ensure that the given "workspaces" field of a manifest is - * valid. + * Type guard to ensure that the value of the "workspaces" field of a manifest + * is valid. * * @param workspaces - The value to check. - * @returns Whether the value is valid. + * @returns Whether the value is an array of truthy strings. */ function isValidPackageManifestWorkspacesField( - workspaces: any, + workspaces: unknown, ): workspaces is string[] | undefined { return ( workspaces === undefined || @@ -149,168 +193,135 @@ function isValidPackageManifestWorkspacesField( } /** - * Type guard to ensure that the given "private" field of a manifest is valid. + * Retrieves and validates the "workspaces" field within the package manifest + * object. * - * @param privateValue - The value to check. - * @returns Whether the value is valid. + * @param manifest - The manifest object. + * @param parentDirectory - The directory in which the manifest lives. + * @returns The value of the "workspaces" field, or an empty array if no such + * field exists. + * @throws If the value of the field is not an array of truthy strings. */ -function isValidPackageManifestPrivateField( - privateValue: any, -): privateValue is boolean | undefined { - return ( - privateValue === undefined || - privateValue === true || - privateValue === false - ); +export function readPackageManifestWorkspacesField( + manifest: UnvalidatedPackageManifest, + parentDirectory: string, +): string[] { + const fieldName = PackageManifestFieldNames.Workspaces; + const value = manifest[fieldName]; + const schema = schemata[fieldName]; + + if (!schema.validate(value)) { + throw new Error( + buildPackageManifestFieldValidationErrorMessage({ + manifest, + parentDirectory, + fieldName, + verbPhrase: schema.errorMessage, + }), + ); + } + + return value ?? []; } /** - * Type guard to ensure that the given dependencies field of a manifest is + * Type guard to ensure that the value of the "private" field of a manifest is * valid. * - * @param dependencies - The value to check. - * @returns Whether the value is valid. + * @param privateValue - The value to check. + * @returns Whether the value is undefined, true, or false. */ -function isValidPackageManifestDependenciesField( - dependencies: any, -): dependencies is Record { +function isValidPackageManifestPrivateField( + privateValue: unknown, +): privateValue is boolean | undefined { return ( - dependencies === undefined || - (isObject(dependencies) && - Object.values(dependencies).every(isTruthyString)) + privateValue === undefined || + privateValue === true || + privateValue === false ); } /** - * Constructs a message for a manifest file validation error. + * Retrieves and validates the "private" field within the package manifest + * object. * - * @param args - The arguments. - * @param args.manifest - The manifest data that's invalid. - * @param args.parentDirectory - The directory of the package to which the manifest - * belongs. - * @param args.invalidFieldName - The name of the invalid field. - * @param args.verbPhrase - Either the fact that the field is invalid or an - * explanation for why it is invalid. - * @returns The error message. + * @param manifest - The manifest object. + * @param parentDirectory - The directory in which the manifest lives. + * @returns The value of the "private" field, or false if no such field exists. + * @throws If the value of the field is not true or false. */ -function buildPackageManifestFieldValidationErrorMessage({ - manifest, - parentDirectory, - invalidFieldName, - verbPhrase, -}: { - manifest: UnvalidatedPackageManifest; - parentDirectory: string; - invalidFieldName: keyof UnvalidatedPackageManifest; - verbPhrase: string; -}) { - const subject = isTruthyString(manifest[PackageManifestFieldNames.Name]) - ? `The value of "${invalidFieldName}" in the manifest for "${ - manifest[PackageManifestFieldNames.Name] - }"` - : `The value of "${invalidFieldName}" in the manifest located at "${parentDirectory}"`; - return `${subject} ${verbPhrase}`; -} - -/** - * Retrieves and validates a field within a package manifest object. - * - * @template T - The expected type of the field value (should include - * `undefined` if not expected to be present). - * @template U - The return type of this function, as determined via - * `defaultValue` (if present) or `transform` (if present). - * @param args - The arguments. - * @param args.manifest - The manifest object. - * @param args.parentDirectory - The directory in which the manifest lives. - * @param args.fieldName - The name of the field. - * @param args.validation - The validation object. - * @param args.validation.check - A function to test whether the value for the - * field is valid. - * @param args.validation.failureReason - A snippet of the message that will be - * produced if the validation fails which explains the requirements of the field - * or merely says that it is invalid, with no explanation. - * @param args.defaultValue - A value to return in place of the field value if - * the field is not present. - * @param args.transform - A function to call with the value after it has been - * validated. - * @returns The value of the field, or the default value if the field is not - * present. - * @throws If the validation on the field fails. - */ -function readPackageManifestField( - options: Omit< - ReadPackageManifestFieldOptions, - 'transform' | 'defaultValue' - >, -): T; -function readPackageManifestField( - options: Require, 'transform'>, -): U; -function readPackageManifestField( - options: Require, 'defaultValue'>, -): T extends undefined ? U : T; -/* eslint-disable-next-line jsdoc/require-jsdoc */ -function readPackageManifestField({ - manifest, - parentDirectory, - fieldName, - validation, - defaultValue, - transform, -}: ReadPackageManifestFieldOptions): T | U { +export function readPackageManifestPrivateField( + manifest: UnvalidatedPackageManifest, + parentDirectory: string, +): boolean { + const fieldName = PackageManifestFieldNames.Private; const value = manifest[fieldName]; + const schema = schemata[fieldName]; - if (!validation.check(value)) { + if (!schema.validate(value)) { throw new Error( buildPackageManifestFieldValidationErrorMessage({ manifest, parentDirectory, - invalidFieldName: fieldName, - verbPhrase: validation.failureReason, + fieldName, + verbPhrase: schema.errorMessage, }), ); } - if (defaultValue === undefined || value !== undefined) { - if (transform === undefined) { - return value; - } - - return transform(value); - } + return value ?? false; +} - return defaultValue; +/** + * Type guard to ensure that the value of the dependencies field of a manifest + * is valid. + * + * @param dependencies - The value to check. + * @returns Whether the value is undefined or an object with truthy strings. + */ +function isValidPackageManifestDependenciesField( + dependencies: unknown, +): dependencies is Record { + return ( + dependencies === undefined || + (isObject(dependencies) && + Object.values(dependencies).every(isTruthyString)) + ); } /** - * Retrieves and validates the "dependencies" fields of a package manifest + * Retrieves and validates the dependencies fields of a package manifest * object. * - * @param args - The arguments. - * @param args.manifest - The manifest data to validate. - * @param args.parentDirectory - The directory of the package to which the + * @param manifest - The manifest data to validate. + * @param parentDirectory - The directory of the package to which the * manifest belongs. - * @returns All of the possible "dependencies" fields and their values (if any - * one does not exist, it defaults to `{}`). - * @throws If the validation on any of the dependencies fields fails. + * @returns All of the possible dependencies fields and their values (if any one + * does not exist, it defaults to `{}`). + * @throws If any one of the dependencies fields is not an object whose values + * are truthy strings. */ -function readPackageManifestDependenciesFields({ - manifest, - parentDirectory, -}: { - manifest: UnvalidatedPackageManifest; - parentDirectory: string; -}) { +function readPackageManifestDependenciesFields( + manifest: UnvalidatedPackageManifest, + parentDirectory: string, +): Record> { return Object.values(PackageManifestDependenciesFieldNames).reduce( (obj, fieldName) => { - const dependencies = readPackageManifestField({ - manifest, - parentDirectory, - fieldName, - validation: validationForPackageManifestDependenciesField, - defaultValue: {}, - }); - return { ...obj, [fieldName]: dependencies }; + const value = manifest[fieldName]; + const schema = schemata.dependencies; + + if (!schema.validate(value)) { + throw new Error( + buildPackageManifestFieldValidationErrorMessage({ + manifest, + parentDirectory, + fieldName, + verbPhrase: schema.errorMessage, + }), + ); + } + + return { ...obj, [fieldName]: value ?? {} }; }, {} as Record>, ); @@ -323,44 +334,33 @@ function readPackageManifestDependenciesFields({ * @param manifestPath - The path of the manifest file. * @returns The correctly typed version of the manifest. * @throws If key data within the manifest is missing (currently `name` and - * `version`). + * `version`) or the value of any other fields is unexpected. */ export async function readPackageManifest( manifestPath: string, ): Promise { const unvalidatedPackageManifest = await readJsonObjectFile(manifestPath); const parentDirectory = path.dirname(manifestPath); - const name = readPackageManifestField({ - manifest: unvalidatedPackageManifest, + const name = readPackageManifestNameField( + unvalidatedPackageManifest, parentDirectory, - fieldName: PackageManifestFieldNames.Name, - validation: validationForPackageManifestNameField, - }); - const version = readPackageManifestField({ - manifest: unvalidatedPackageManifest, + ); + const version = readPackageManifestVersionField( + unvalidatedPackageManifest, parentDirectory, - fieldName: PackageManifestFieldNames.Version, - validation: validationForPackageManifestVersionField, - transform: (value: string) => new SemVer(value), - }); - const workspaces = readPackageManifestField({ - manifest: unvalidatedPackageManifest, + ); + const workspaces = readPackageManifestWorkspacesField( + unvalidatedPackageManifest, parentDirectory, - fieldName: PackageManifestFieldNames.Workspaces, - validation: validationForPackageManifestWorkspacesField, - defaultValue: [], - }); - const privateValue = readPackageManifestField({ - manifest: unvalidatedPackageManifest, + ); + const privateValue = readPackageManifestPrivateField( + unvalidatedPackageManifest, parentDirectory, - fieldName: PackageManifestFieldNames.Private, - validation: validationForPackageManifestPrivateField, - defaultValue: false, - }); - const dependenciesFields = readPackageManifestDependenciesFields({ - manifest: unvalidatedPackageManifest, + ); + const dependenciesFields = readPackageManifestDependenciesFields( + unvalidatedPackageManifest, parentDirectory, - }); + ); return { [PackageManifestFieldNames.Name]: name, From d8f9c78fdf99c95c5bd949e9dfe7b977a24f8a1c Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Tue, 2 Aug 2022 10:47:58 -0600 Subject: [PATCH 34/41] Remove dependencies fields --- src/package-manifest.test.ts | 125 ----------------------------------- src/package-manifest.ts | 76 +-------------------- 2 files changed, 1 insertion(+), 200 deletions(-) diff --git a/src/package-manifest.test.ts b/src/package-manifest.test.ts index a6796bd..1414c00 100644 --- a/src/package-manifest.test.ts +++ b/src/package-manifest.test.ts @@ -22,11 +22,6 @@ describe('package-manifest', () => { version: new SemVer('1.2.3'), workspaces: [], private: false, - bundledDependencies: {}, - dependencies: {}, - devDependencies: {}, - optionalDependencies: {}, - peerDependencies: {}, }); }); }); @@ -48,11 +43,6 @@ describe('package-manifest', () => { version: new SemVer('1.2.3'), workspaces: [], private: true, - bundledDependencies: {}, - dependencies: {}, - devDependencies: {}, - optionalDependencies: {}, - peerDependencies: {}, }); }); }); @@ -74,11 +64,6 @@ describe('package-manifest', () => { version: new SemVer('1.2.3'), workspaces: [], private: false, - bundledDependencies: {}, - dependencies: {}, - devDependencies: {}, - optionalDependencies: {}, - peerDependencies: {}, }); }); }); @@ -93,21 +78,6 @@ describe('package-manifest', () => { version: '1.2.3', workspaces: ['packages/*'], private: true, - bundledDependencies: { - foo: 'bar', - }, - dependencies: { - foo: 'bar', - }, - devDependencies: { - foo: 'bar', - }, - optionalDependencies: { - foo: 'bar', - }, - peerDependencies: { - foo: 'bar', - }, }), ); @@ -116,52 +86,6 @@ describe('package-manifest', () => { version: new SemVer('1.2.3'), workspaces: ['packages/*'], private: true, - bundledDependencies: { - foo: 'bar', - }, - dependencies: { - foo: 'bar', - }, - devDependencies: { - foo: 'bar', - }, - optionalDependencies: { - foo: 'bar', - }, - peerDependencies: { - foo: 'bar', - }, - }); - }); - }); - - it('reads a package manifest where dependencies fields are provided but empty', async () => { - await withSandbox(async (sandbox) => { - const manifestPath = path.join(sandbox.directoryPath, 'package.json'); - await fs.promises.writeFile( - manifestPath, - JSON.stringify({ - name: 'foo', - version: '1.2.3', - private: true, - bundledDependencies: {}, - dependencies: {}, - devDependencies: {}, - optionalDependencies: {}, - peerDependencies: {}, - }), - ); - - expect(await readPackageManifest(manifestPath)).toStrictEqual({ - name: 'foo', - version: new SemVer('1.2.3'), - workspaces: [], - private: true, - bundledDependencies: {}, - dependencies: {}, - devDependencies: {}, - optionalDependencies: {}, - peerDependencies: {}, }); }); }); @@ -183,11 +107,6 @@ describe('package-manifest', () => { version: new SemVer('1.2.3'), workspaces: [], private: false, - bundledDependencies: {}, - dependencies: {}, - devDependencies: {}, - optionalDependencies: {}, - peerDependencies: {}, }); }); }); @@ -310,49 +229,5 @@ describe('package-manifest', () => { ); }); }); - - [ - 'bundledDependencies', - 'dependencies', - 'devDependencies', - 'optionalDependencies', - 'peerDependencies', - ].forEach((fieldName) => { - it(`throws if "${fieldName}" is not an object`, async () => { - await withSandbox(async (sandbox) => { - const manifestPath = path.join(sandbox.directoryPath, 'package.json'); - await fs.promises.writeFile( - manifestPath, - JSON.stringify({ - name: 'foo', - version: '1.2.3', - [fieldName]: 12345, - }), - ); - - await expect(readPackageManifest(manifestPath)).rejects.toThrow( - `The value of "${fieldName}" in the manifest for "foo" must be an object with non-empty string keys and non-empty string values`, - ); - }); - }); - - it(`throws if "${fieldName}" is not an object with string values`, async () => { - await withSandbox(async (sandbox) => { - const manifestPath = path.join(sandbox.directoryPath, 'package.json'); - await fs.promises.writeFile( - manifestPath, - JSON.stringify({ - name: 'foo', - version: '1.2.3', - [fieldName]: { foo: 12345 }, - }), - ); - - await expect(readPackageManifest(manifestPath)).rejects.toThrow( - `The value of "${fieldName}" in the manifest for "foo" must be an object with non-empty string keys and non-empty string values`, - ); - }); - }); - }); }); }); diff --git a/src/package-manifest.ts b/src/package-manifest.ts index 2a01cbc..d25e858 100644 --- a/src/package-manifest.ts +++ b/src/package-manifest.ts @@ -4,7 +4,7 @@ import { ManifestDependencyFieldNames as PackageManifestDependenciesFieldNames, } from '@metamask/action-utils'; import { readJsonObjectFile } from './fs'; -import { isTruthyString, isObject } from './misc-utils'; +import { isTruthyString } from './misc-utils'; import { isValidSemver, SemVer } from './semver'; export { PackageManifestFieldNames, PackageManifestDependenciesFieldNames }; @@ -23,15 +23,6 @@ export type UnvalidatedPackageManifest = Readonly>; * @property workspaces - Paths to subpackages within the package. * @property bundledDependencies - The set of packages that are expected to be * bundled when publishing the package. - * @property dependencies - The set of packages, and their versions, that the - * published version of the package needs to run effectively. - * @property devDependencies - The set of packages, and their versions, that the - * the package relies upon for development purposes (such as tests or - * locally-run scripts). - * @property optionalDependencies - The set of packages, and their versions, - * that the package may need but is not required for use. - * @property peerDependencies - The set of packages, and their versions, that - * the package may need but is not required for use. Intended for plugins. */ export type ValidatedPackageManifest = { readonly [PackageManifestFieldNames.Name]: string; @@ -94,11 +85,6 @@ const schemata = { validate: isValidPackageManifestPrivateField, errorMessage: 'must be true or false (if present)', }, - dependencies: { - validate: isValidPackageManifestDependenciesField, - errorMessage: - 'must be an object with non-empty string keys and non-empty string values', - }, }; /** @@ -272,61 +258,6 @@ export function readPackageManifestPrivateField( return value ?? false; } -/** - * Type guard to ensure that the value of the dependencies field of a manifest - * is valid. - * - * @param dependencies - The value to check. - * @returns Whether the value is undefined or an object with truthy strings. - */ -function isValidPackageManifestDependenciesField( - dependencies: unknown, -): dependencies is Record { - return ( - dependencies === undefined || - (isObject(dependencies) && - Object.values(dependencies).every(isTruthyString)) - ); -} - -/** - * Retrieves and validates the dependencies fields of a package manifest - * object. - * - * @param manifest - The manifest data to validate. - * @param parentDirectory - The directory of the package to which the - * manifest belongs. - * @returns All of the possible dependencies fields and their values (if any one - * does not exist, it defaults to `{}`). - * @throws If any one of the dependencies fields is not an object whose values - * are truthy strings. - */ -function readPackageManifestDependenciesFields( - manifest: UnvalidatedPackageManifest, - parentDirectory: string, -): Record> { - return Object.values(PackageManifestDependenciesFieldNames).reduce( - (obj, fieldName) => { - const value = manifest[fieldName]; - const schema = schemata.dependencies; - - if (!schema.validate(value)) { - throw new Error( - buildPackageManifestFieldValidationErrorMessage({ - manifest, - parentDirectory, - fieldName, - verbPhrase: schema.errorMessage, - }), - ); - } - - return { ...obj, [fieldName]: value ?? {} }; - }, - {} as Record>, - ); -} - /** * Reads the package manifest at the given path, verifying key data within the * manifest. @@ -357,16 +288,11 @@ export async function readPackageManifest( unvalidatedPackageManifest, parentDirectory, ); - const dependenciesFields = readPackageManifestDependenciesFields( - unvalidatedPackageManifest, - parentDirectory, - ); return { [PackageManifestFieldNames.Name]: name, [PackageManifestFieldNames.Version]: version, [PackageManifestFieldNames.Workspaces]: workspaces, [PackageManifestFieldNames.Private]: privateValue, - ...dependenciesFields, }; } From fa6b380506f116b2dca0069bea3b630684cbfd18 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Tue, 2 Aug 2022 10:55:11 -0600 Subject: [PATCH 35/41] Remove extra 'describe' within 'when a release spec file already exists' --- src/monorepo-workflow-operations.test.ts | 553 +++++++++++------------ 1 file changed, 253 insertions(+), 300 deletions(-) diff --git a/src/monorepo-workflow-operations.test.ts b/src/monorepo-workflow-operations.test.ts index 6cb73ad..1573c87 100644 --- a/src/monorepo-workflow-operations.test.ts +++ b/src/monorepo-workflow-operations.test.ts @@ -377,7 +377,7 @@ describe('monorepo-workflow-operations', () => { stderr, }); } catch { - // ignore the error above + // ignore the error } await expect( @@ -751,7 +751,7 @@ describe('monorepo-workflow-operations', () => { stderr, }); } catch { - // ignore the error above + // ignore the error } await expect( @@ -1151,7 +1151,7 @@ describe('monorepo-workflow-operations', () => { stderr, }); } catch { - // ignore the error above + // ignore the error } await expect( @@ -1204,337 +1204,290 @@ describe('monorepo-workflow-operations', () => { }); describe('when a release spec file already exists', () => { - describe('when the editor command completes successfully', () => { - 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', { + 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', { + manifest: { + private: true, + workspaces: ['packages/*'], + }, + }), + workspacePackages: { + a: buildMockPackage('a', '1.0.0', { manifest: { - private: true, - workspaces: ['packages/*'], + private: false, }, }), - workspacePackages: { - a: buildMockPackage('a', '1.0.0', { - manifest: { - private: false, - }, - }), - }, - }); - 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, - }, + }, + }); + 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, }, - }); - const releaseSpecPath = path.join( - sandbox.directoryPath, - 'RELEASE_SPEC', - ); - await fs.promises.writeFile(releaseSpecPath, 'release spec'); - - await followMonorepoWorkflow({ - project, - tempDirectoryPath: sandbox.directoryPath, - firstRemovingExistingReleaseSpecification: false, - stdout, - stderr, - }); + }, + }); + const releaseSpecPath = path.join( + sandbox.directoryPath, + 'RELEASE_SPEC', + ); + await fs.promises.writeFile(releaseSpecPath, 'release spec'); + + await followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: false, + stdout, + stderr, + }); - expect( - generateReleaseSpecificationTemplateForMonorepoSpy, - ).not.toHaveBeenCalled(); - expect( - waitForUserToEditReleaseSpecificationSpy, - ).not.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( + generateReleaseSpecificationTemplateForMonorepoSpy, + ).not.toHaveBeenCalled(); + expect( + waitForUserToEditReleaseSpecificationSpy, + ).not.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('creates a new branch named after the generated release version', async () => { - await withSandbox(async (sandbox) => { - const project = buildMockMonorepoProject({ - rootPackage: buildMockPackage('root', '2022.1.1', { + 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', { + manifest: { + private: true, + workspaces: ['packages/*'], + }, + }), + workspacePackages: { + a: buildMockPackage('a', '1.0.0', { manifest: { - private: true, - workspaces: ['packages/*'], + private: false, }, }), - workspacePackages: { - a: buildMockPackage('a', '1.0.0', { - manifest: { - 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 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'); + }, + }); + const releaseSpecPath = path.join( + sandbox.directoryPath, + 'RELEASE_SPEC', + ); + await fs.promises.writeFile(releaseSpecPath, 'release spec'); + + await followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: false, + stdout, + stderr, + }); - 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, + }, + ], + }, + ); + }); + }); - 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, - }, - ], - }, - ); + 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: false, + stdout, + stderr, }); + + await expect( + fs.promises.readFile(releaseSpecPath, 'utf8'), + ).rejects.toThrow(/^ENOENT: no such file or directory/u); }); + }); - it('removes the release spec file at the end', async () => { - await withSandbox(async (sandbox) => { - const project = buildMockMonorepoProject(); - const stdout = fs.createWriteStream('/dev/null'); - const stderr = fs.createWriteStream('/dev/null'); - mockDependencies({ - determineEditor: { - path: '/some/editor', - args: [], + it("throws 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', { + manifest: { + private: true, + workspaces: ['packages/*'], }, - }); - const releaseSpecPath = path.join( - sandbox.directoryPath, - 'RELEASE_SPEC', - ); - await fs.promises.writeFile(releaseSpecPath, 'release spec'); - - await followMonorepoWorkflow({ + }), + workspacePackages: { + a: buildMockPackage('a', '1.0.0', { + manifest: { + private: false, + }, + }), + }, + }); + const stdout = fs.createWriteStream('/dev/null'); + const stderr = fs.createWriteStream('/dev/null'); + mockDependencies({ + determineEditor: { + path: '/some/editor', + args: [], + }, + getEnvironmentVariables: { + TODAY: '2022-06-12', + }, + validateReleaseSpecification: { + packages: { + a: new SemVer('1.0.0'), + }, + }, + }); + const releaseSpecPath = path.join( + sandbox.directoryPath, + 'RELEASE_SPEC', + ); + await fs.promises.writeFile(releaseSpecPath, 'release spec'); + + await expect( + followMonorepoWorkflow({ project, tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: false, stdout, stderr, - }); - - await expect( - fs.promises.readFile(releaseSpecPath, 'utf8'), - ).rejects.toThrow(/^ENOENT: no such file or directory/u); - }); + }), + ).rejects.toThrow( + /^Could not apply version specifier "1.0.0" to package "a" because the current and new versions would end up being the same./u, + ); }); + }); - 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', { - manifest: { - private: true, - workspaces: ['packages/*'], - }, - }), - workspacePackages: { - a: buildMockPackage('a', '1.0.0', { - manifest: { - 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', + 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', { + manifest: { + private: true, + workspaces: ['packages/*'], }, - 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 apply version specifier "1.0.0" to package "a" because the current and new versions would end up being the same./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', { + }), + workspacePackages: { + a: buildMockPackage('a', '1.0.0', { manifest: { - private: true, - workspaces: ['packages/*'], + private: false, }, }), - workspacePackages: { - a: buildMockPackage('a', '1.0.0', { - manifest: { - 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 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'); - - try { - await followMonorepoWorkflow({ - project, - tempDirectoryPath: sandbox.directoryPath, - firstRemovingExistingReleaseSpecification: false, - stdout, - stderr, - }); - } catch { - // ignore the error - } - - expect(await fs.promises.stat(releaseSpecPath)).toStrictEqual( - expect.anything(), - ); + }, }); - }); - }); + const releaseSpecPath = path.join( + sandbox.directoryPath, + 'RELEASE_SPEC', + ); + await fs.promises.writeFile(releaseSpecPath, 'release spec'); - 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: [], - }, + try { + await followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: false, + stdout, + stderr, }); - jest - .spyOn( - releaseSpecificationModule, - 'waitForUserToEditReleaseSpecification', - ) - .mockRejectedValue(new Error('oops')); - const releaseSpecPath = path.join( - sandbox.directoryPath, - 'RELEASE_SPEC', - ); - await fs.promises.writeFile(releaseSpecPath, 'release spec'); + } catch { + // ignore the error + } - try { - await followMonorepoWorkflow({ - project, - tempDirectoryPath: sandbox.directoryPath, - firstRemovingExistingReleaseSpecification: false, - stdout, - stderr, - }); - } catch { - // ignore the error above - } - - await expect( - fs.promises.readFile(releaseSpecPath, 'utf8'), - ).rejects.toThrow(/^ENOENT: no such file or directory/u); - }); + expect(await fs.promises.stat(releaseSpecPath)).toStrictEqual( + expect.anything(), + ); }); }); }); From 1e5a8d6c51fdbdfd63c883fdf61a14648ebe06a6 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Tue, 2 Aug 2022 11:04:34 -0600 Subject: [PATCH 36/41] Promisify rimraf --- src/monorepo-workflow-operations.ts | 8 +++++++- tests/unit/helpers.ts | 3 +++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/monorepo-workflow-operations.ts b/src/monorepo-workflow-operations.ts index e62b00d..76c181e 100644 --- a/src/monorepo-workflow-operations.ts +++ b/src/monorepo-workflow-operations.ts @@ -1,5 +1,6 @@ import type { WriteStream } from 'fs'; import path from 'path'; +import util from 'util'; import rimraf from 'rimraf'; import { debug } from './misc-utils'; import { @@ -25,6 +26,11 @@ import { 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 @@ -88,7 +94,7 @@ export async function followMonorepoWorkflow({ const releaseSpecificationPath = path.join(tempDirectoryPath, 'RELEASE_SPEC'); if (firstRemovingExistingReleaseSpecification) { - await new Promise((resolve) => rimraf(releaseSpecificationPath, resolve)); + await promisifiedRimraf(releaseSpecificationPath); } if (await fileExists(releaseSpecificationPath)) { diff --git a/tests/unit/helpers.ts b/tests/unit/helpers.ts index ae26cec..4748aa1 100644 --- a/tests/unit/helpers.ts +++ b/tests/unit/helpers.ts @@ -29,6 +29,9 @@ interface Sandbox { directoryPath: string; } +/** + * A promisified version of `rimraf`. + */ const promisifiedRimraf = util.promisify(rimraf); /** From fc30d387fa7dfbe63f73529d4319f6dcd1b2d56a Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Tue, 2 Aug 2022 11:40:04 -0600 Subject: [PATCH 37/41] Throw if the new version of a package is less than its current version --- src/monorepo-workflow-operations.test.ts | 210 ++++++++++++++++++++++- src/monorepo-workflow-operations.ts | 41 +++-- 2 files changed, 230 insertions(+), 21 deletions(-) diff --git a/src/monorepo-workflow-operations.test.ts b/src/monorepo-workflow-operations.test.ts index 1573c87..926e7fd 100644 --- a/src/monorepo-workflow-operations.test.ts +++ b/src/monorepo-workflow-operations.test.ts @@ -286,7 +286,55 @@ describe('monorepo-workflow-operations', () => { stderr, }), ).rejects.toThrow( - /^Could not apply version specifier "1.0.0" to package "a" because the current and new versions would end up being the same./u, + /^Could not update package "a" to "1.0.0" as that is already the current version./u, + ); + }); + }); + + 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', { + manifest: { + private: true, + workspaces: ['packages/*'], + }, + }), + workspacePackages: { + a: buildMockPackage('a', '1.0.3', { + manifest: { + 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, ); }); }); @@ -655,7 +703,60 @@ describe('monorepo-workflow-operations', () => { stderr, }), ).rejects.toThrow( - /^Could not apply version specifier "1.0.0" to package "a" because the current and new versions would end up being the same./u, + /^Could not update package "a" to "1.0.0" as that is already the current version./u, + ); + }); + }); + + 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', { + manifest: { + private: true, + workspaces: ['packages/*'], + }, + }), + workspacePackages: { + a: buildMockPackage('a', '1.0.3', { + manifest: { + 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, ); }); }); @@ -1063,7 +1164,55 @@ describe('monorepo-workflow-operations', () => { stderr, }), ).rejects.toThrow( - /^Could not apply version specifier "1.0.0" to package "a" because the current and new versions would end up being the same./u, + /^Could not update package "a" to "1.0.0" as that is already the current version./u, + ); + }); + }); + + 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', { + manifest: { + private: true, + workspaces: ['packages/*'], + }, + }), + workspacePackages: { + a: buildMockPackage('a', '1.0.3', { + manifest: { + 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, ); }); }); @@ -1429,7 +1578,60 @@ describe('monorepo-workflow-operations', () => { stderr, }), ).rejects.toThrow( - /^Could not apply version specifier "1.0.0" to package "a" because the current and new versions would end up being the same./u, + /^Could not update package "a" to "1.0.0" as that is already the current version./u, + ); + }); + }); + + 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', { + manifest: { + private: true, + workspaces: ['packages/*'], + }, + }), + workspacePackages: { + a: buildMockPackage('a', '1.0.3', { + manifest: { + 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: 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, ); }); }); diff --git a/src/monorepo-workflow-operations.ts b/src/monorepo-workflow-operations.ts index 76c181e..078eec1 100644 --- a/src/monorepo-workflow-operations.ts +++ b/src/monorepo-workflow-operations.ts @@ -19,7 +19,7 @@ import { validateReleaseSpecification, ReleaseSpecification, } from './release-specification'; -import { semver, SemVer } from './semver'; +import { SemVer } from './semver'; import { captureChangesInReleaseBranch, PackageReleasePlan, @@ -185,26 +185,33 @@ async function planRelease( const currentVersion = pkg.manifest.version; const newVersion = versionSpecifier instanceof SemVer - ? versionSpecifier.toString() - : new SemVer(currentVersion.toString()) - .inc(versionSpecifier) - .toString(); - - const versionDiff = semver.diff(currentVersion.toString(), newVersion); - - if (versionDiff === null) { - throw new Error( - [ - `Could not apply version specifier "${versionSpecifier}" to package "${packageName}" because the current and new versions would end up being the same.`, - `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'), - ); + ? versionSpecifier + : new SemVer(currentVersion.toString()).inc(versionSpecifier); + const comparison = newVersion.compare(currentVersion); + + if (versionSpecifier instanceof SemVer) { + 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'), + ); + } } return { package: pkg, - newVersion, + newVersion: newVersion.toString(), shouldUpdateChangelog: true, }; }); From e367ac1896ae691d83e1eef2f45f3379e76581f8 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Tue, 2 Aug 2022 11:45:15 -0600 Subject: [PATCH 38/41] Stop ignoring errors --- src/monorepo-workflow-operations.test.ts | 70 ++++++++++-------------- 1 file changed, 28 insertions(+), 42 deletions(-) diff --git a/src/monorepo-workflow-operations.test.ts b/src/monorepo-workflow-operations.test.ts index 926e7fd..2d225cf 100644 --- a/src/monorepo-workflow-operations.test.ts +++ b/src/monorepo-workflow-operations.test.ts @@ -378,17 +378,15 @@ describe('monorepo-workflow-operations', () => { ); await fs.promises.writeFile(releaseSpecPath, 'release spec'); - try { - await followMonorepoWorkflow({ + await expect( + followMonorepoWorkflow({ project, tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: true, stdout, stderr, - }); - } catch { - // ignore the error - } + }), + ).rejects.toThrow(expect.anything()); expect(await fs.promises.stat(releaseSpecPath)).toStrictEqual( expect.anything(), @@ -416,17 +414,15 @@ describe('monorepo-workflow-operations', () => { ) .mockRejectedValue(new Error('oops')); - try { - await followMonorepoWorkflow({ + await expect( + followMonorepoWorkflow({ project, tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: true, stdout, stderr, - }); - } catch { - // ignore the error - } + }), + ).rejects.toThrow(expect.anything()); await expect( fs.promises.readFile( @@ -800,17 +796,15 @@ describe('monorepo-workflow-operations', () => { ); await fs.promises.writeFile(releaseSpecPath, 'release spec'); - try { - await followMonorepoWorkflow({ + await expect( + followMonorepoWorkflow({ project, tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: true, stdout, stderr, - }); - } catch { - // ignore the error - } + }), + ).rejects.toThrow(expect.anything()); expect(await fs.promises.stat(releaseSpecPath)).toStrictEqual( expect.anything(), @@ -843,17 +837,15 @@ describe('monorepo-workflow-operations', () => { ); await fs.promises.writeFile(releaseSpecPath, 'release spec'); - try { - await followMonorepoWorkflow({ + await expect( + followMonorepoWorkflow({ project, tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: true, stdout, stderr, - }); - } catch { - // ignore the error - } + }), + ).rejects.toThrow(expect.anything()); await expect( fs.promises.readFile(releaseSpecPath, 'utf8'), @@ -1251,17 +1243,15 @@ describe('monorepo-workflow-operations', () => { }, }); - try { - await followMonorepoWorkflow({ + await expect( + followMonorepoWorkflow({ project, tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: false, stdout, stderr, - }); - } catch { - // ignore the error - } + }), + ).rejects.toThrow(expect.anything()); expect( await fs.promises.stat( @@ -1291,17 +1281,15 @@ describe('monorepo-workflow-operations', () => { ) .mockRejectedValue(new Error('oops')); - try { - await followMonorepoWorkflow({ + await expect( + followMonorepoWorkflow({ project, tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: false, stdout, stderr, - }); - } catch { - // ignore the error - } + }), + ).rejects.toThrow(expect.anything()); await expect( fs.promises.readFile( @@ -1675,17 +1663,15 @@ describe('monorepo-workflow-operations', () => { ); await fs.promises.writeFile(releaseSpecPath, 'release spec'); - try { - await followMonorepoWorkflow({ + await expect( + followMonorepoWorkflow({ project, tempDirectoryPath: sandbox.directoryPath, firstRemovingExistingReleaseSpecification: false, stdout, stderr, - }); - } catch { - // ignore the error - } + }), + ).rejects.toThrow(expect.anything()); expect(await fs.promises.stat(releaseSpecPath)).toStrictEqual( expect.anything(), From 761377f510018a55a13a6515db5b1b2af2f8b55a Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Tue, 2 Aug 2022 15:01:56 -0600 Subject: [PATCH 39/41] Use error causes to wrap errors --- package.json | 1 + src/fs.test.ts | 88 ++++++++++++++----------------- src/fs.ts | 37 ++++--------- src/misc-utils.test.ts | 45 ++++++---------- src/misc-utils.ts | 60 +++++++++++---------- src/release-specification.test.ts | 18 +++++-- src/release-specification.ts | 30 +++++------ yarn.lock | 8 +++ 8 files changed, 137 insertions(+), 150 deletions(-) diff --git a/package.json b/package.json index aeed48e..75aedf5 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "debug": "^4.3.4", "execa": "^5.0.0", "glob": "^8.0.3", + "pony-cause": "^2.1.0", "semver": "^7.3.7", "which": "^2.0.2", "yaml": "^2.1.1", diff --git a/src/fs.test.ts b/src/fs.test.ts index e25a0f5..f92f792 100644 --- a/src/fs.test.ts +++ b/src/fs.test.ts @@ -31,20 +31,16 @@ describe('fs', () => { }); }); - it('re-throws any error that occurs, assigning it the same code, a wrapped message, and a new stack', async () => { + it('re-throws any error that occurs as a new error that points to the original', async () => { await withSandbox(async (sandbox) => { const filePath = path.join(sandbox.directoryPath, 'nonexistent'); await expect(readFile(filePath)).rejects.toThrow( expect.objectContaining({ - message: expect.stringMatching( - new RegExp( - `^Could not read file '${filePath}': ENOENT: no such file or directory, open '${filePath}'`, - 'u', - ), - ), - code: 'ENOENT', - stack: expect.anything(), + message: `Could not read file '${filePath}'`, + cause: expect.objectContaining({ + message: `ENOENT: no such file or directory, open '${filePath}'`, + }), }), ); }); @@ -64,21 +60,17 @@ describe('fs', () => { }); }); - it('re-throws any error that occurs, assigning it the same code, a wrapped message, and a new stack', async () => { + it('re-throws any error that occurs as a new error that points to the original', async () => { await withSandbox(async (sandbox) => { await promisifiedRimraf(sandbox.directoryPath); const filePath = path.join(sandbox.directoryPath, 'test'); await expect(writeFile(filePath, 'some content 😄')).rejects.toThrow( expect.objectContaining({ - message: expect.stringMatching( - new RegExp( - `^Could not write file '${filePath}': ENOENT: no such file or directory, open '${filePath}'`, - 'u', - ), - ), - code: 'ENOENT', - stack: expect.anything(), + message: `Could not write file '${filePath}'`, + cause: expect.objectContaining({ + message: `ENOENT: no such file or directory, open '${filePath}'`, + }), }), ); }); @@ -97,20 +89,17 @@ describe('fs', () => { }); }); - it('re-throws any error that occurs, assigning it the same code, a wrapped message, and a new stack', async () => { + it('re-throws any error that occurs as a new error that points to the original', async () => { const filePath = '/some/file'; - const error: any = new Error('oops'); - error.code = 'ESOMETHING'; - error.stack = 'some stack'; + const error = new Error('oops'); when(jest.spyOn(actionUtils, 'readJsonObjectFile')) .calledWith(filePath) .mockRejectedValue(error); await expect(readJsonObjectFile(filePath)).rejects.toThrow( expect.objectContaining({ - message: `Could not read JSON file '${filePath}': oops`, - code: 'ESOMETHING', - stack: 'some stack', + message: `Could not read JSON file '${filePath}'`, + cause: error, }), ); }); @@ -126,20 +115,17 @@ describe('fs', () => { expect(await writeJsonFile(filePath, { some: 'object' })).toBeUndefined(); }); - it('re-throws any error that occurs, assigning it the same code, a wrapped message, and a new stack', async () => { + it('re-throws any error that occurs as a new error that points to the original', async () => { const filePath = '/some/file'; - const error: any = new Error('oops'); - error.code = 'ESOMETHING'; - error.stack = 'some stack'; + const error = new Error('oops'); when(jest.spyOn(actionUtils, 'writeJsonFile')) .calledWith(filePath, { some: 'object' }) .mockRejectedValue(error); await expect(writeJsonFile(filePath, { some: 'object' })).rejects.toThrow( expect.objectContaining({ - message: `Could not write JSON file '${filePath}': oops`, - code: 'ESOMETHING', - stack: 'some stack', + message: `Could not write JSON file '${filePath}'`, + cause: error, }), ); }); @@ -183,9 +169,23 @@ describe('fs', () => { await expect(fileExists(entryPath)).rejects.toThrow( expect.objectContaining({ - message: `Could not determine if file exists '${entryPath}': oops`, - code: 'ESOMETHING', - stack: 'some stack', + message: `Could not determine if file exists '${entryPath}'`, + cause: error, + }), + ); + }); + + it('re-throws any error that occurs as a new error that points to the original', async () => { + const entryPath = '/some/file'; + const error = new Error('oops'); + when(jest.spyOn(fs.promises, 'stat')) + .calledWith(entryPath) + .mockRejectedValue(error); + + await expect(fileExists(entryPath)).rejects.toThrow( + expect.objectContaining({ + message: `Could not determine if file exists '${entryPath}'`, + cause: error, }), ); }); @@ -237,18 +237,15 @@ describe('fs', () => { it('re-throws any error that occurs, assigning it the same code, a wrapped message, and a new stack', async () => { const directoryPath = '/some/directory'; - const error: any = new Error('oops'); - error.code = 'ESOMETHING'; - error.stack = 'some stack'; + const error = new Error('oops'); when(jest.spyOn(fs.promises, 'mkdir')) .calledWith(directoryPath, { recursive: true }) .mockRejectedValue(error); await expect(ensureDirectoryPathExists(directoryPath)).rejects.toThrow( expect.objectContaining({ - message: `Could not create directory path '${directoryPath}': oops`, - code: 'ESOMETHING', - stack: 'some stack', + message: `Could not create directory path '${directoryPath}'`, + cause: error, }), ); }); @@ -273,18 +270,15 @@ describe('fs', () => { it('re-throws any error that occurs, assigning it the same code, a wrapped message, and a new stack', async () => { const filePath = '/some/file'; - const error: any = new Error('oops'); - error.code = 'ESOMETHING'; - error.stack = 'some stack'; + const error = new Error('oops'); when(jest.spyOn(fs.promises, 'rm')) .calledWith(filePath, { force: true }) .mockRejectedValue(error); await expect(removeFile(filePath)).rejects.toThrow( expect.objectContaining({ - message: `Could not remove file '${filePath}': oops`, - code: 'ESOMETHING', - stack: 'some stack', + message: `Could not remove file '${filePath}'`, + cause: error, }), ); }); diff --git a/src/fs.ts b/src/fs.ts index 5503596..b1e66d5 100644 --- a/src/fs.ts +++ b/src/fs.ts @@ -3,7 +3,7 @@ import { readJsonObjectFile as underlyingReadJsonObjectFile, writeJsonFile as underlyingWriteJsonFile, } from '@metamask/action-utils'; -import { wrapError, isErrorWithCode } from './misc-utils'; +import { coverError, isErrorWithCode } from './misc-utils'; /** * Reads the file at the given path, assuming its content is encoded as UTF-8. @@ -16,10 +16,7 @@ export async function readFile(filePath: string): Promise { try { return await fs.promises.readFile(filePath, 'utf8'); } catch (error) { - throw wrapError( - error, - ({ message }) => `Could not read file '${filePath}': ${message}`, - ); + throw coverError(`Could not read file '${filePath}'`, error); } } @@ -37,10 +34,7 @@ export async function writeFile( try { await fs.promises.writeFile(filePath, content); } catch (error) { - throw wrapError( - error, - ({ message }) => `Could not write file '${filePath}': ${message}`, - ); + throw coverError(`Could not write file '${filePath}'`, error); } } @@ -62,10 +56,7 @@ export async function readJsonObjectFile( try { return await underlyingReadJsonObjectFile(filePath); } catch (error) { - throw wrapError( - error, - ({ message }) => `Could not read JSON file '${filePath}': ${message}`, - ); + throw coverError(`Could not read JSON file '${filePath}'`, error); } } @@ -86,10 +77,7 @@ export async function writeJsonFile( try { await underlyingWriteJsonFile(filePath, jsonValue); } catch (error) { - throw wrapError( - error, - ({ message }) => `Could not write JSON file '${filePath}': ${message}`, - ); + throw coverError(`Could not write JSON file '${filePath}'`, error); } } @@ -109,10 +97,9 @@ export async function fileExists(entryPath: string): Promise { return false; } - throw wrapError( + throw coverError( + `Could not determine if file exists '${entryPath}'`, error, - ({ message }) => - `Could not determine if file exists '${entryPath}': ${message}`, ); } } @@ -131,10 +118,9 @@ export async function ensureDirectoryPathExists( try { return await fs.promises.mkdir(directoryPath, { recursive: true }); } catch (error) { - throw wrapError( + throw coverError( + `Could not create directory path '${directoryPath}'`, error, - ({ message }) => - `Could not create directory path '${directoryPath}': ${message}`, ); } } @@ -150,9 +136,6 @@ export async function removeFile(filePath: string): Promise { try { return await fs.promises.rm(filePath, { force: true }); } catch (error) { - throw wrapError( - error, - ({ message }) => `Could not remove file '${filePath}': ${message}`, - ); + throw coverError(`Could not remove file '${filePath}'`, error); } } diff --git a/src/misc-utils.test.ts b/src/misc-utils.test.ts index 0559be7..6724d9f 100644 --- a/src/misc-utils.test.ts +++ b/src/misc-utils.test.ts @@ -4,7 +4,7 @@ import { isErrorWithCode, isErrorWithMessage, isErrorWithStack, - wrapError, + coverError, resolveExecutable, getStdoutFromCommand, runCommand, @@ -80,41 +80,28 @@ describe('misc-utils', () => { }); }); - describe('wrapError', () => { - it('wraps the given error object by prepending the given prefix to its message', () => { - const error = new Error('Some message'); + describe('coverError', () => { + it('returns a new Error that links to the given Error', () => { + const originalError = new Error('oops'); + const newError = coverError('Some message', originalError); - expect( - wrapError(error, ({ message }) => `Some prefix: ${message}`), - ).toMatchObject({ - message: 'Some prefix: Some message', - }); + expect(newError.message).toStrictEqual('Some message'); + expect(newError.cause).toBe(originalError); }); - it('returns a new error object that retains the "code" property of the original error object', () => { - const error: any = new Error('foo'); - error.code = 'ESOMETHING'; + it('copies over any "code" property that exists on the given Error', () => { + const originalError: any = new Error('oops'); + originalError.code = 'CODE'; + const newError: any = coverError('Some message', originalError); - expect(wrapError(error)).toMatchObject({ - code: 'ESOMETHING', - }); + expect(newError.code).toStrictEqual('CODE'); }); - it('returns a new error object that retains the "stack" property of the original error object', () => { - const error: any = new Error('foo'); - error.stack = 'some stack'; - - expect(wrapError(error)).toMatchObject({ - stack: 'some stack', - }); - }); + it('returns a new Error which prefixes the given message', () => { + const newError = coverError('Some message', 'Some original message'); - it('wraps the given string by prepending the given prefix to it', () => { - expect( - wrapError('Some message', ({ message }) => `Some prefix: ${message}`), - ).toMatchObject({ - message: 'Some prefix: Some message', - }); + expect(newError.message).toBe('Some message: Some original message'); + expect(newError.cause).toBeUndefined(); }); }); diff --git a/src/misc-utils.ts b/src/misc-utils.ts index d90b79f..4d57c26 100644 --- a/src/misc-utils.ts +++ b/src/misc-utils.ts @@ -1,9 +1,12 @@ import which from 'which'; import execa from 'execa'; import createDebug from 'debug'; +import { ErrorWithCause } from 'pony-cause'; +import { isObject } from '@metamask/utils'; export { isTruthyString } from '@metamask/action-utils'; -export { hasProperty, isNullOrUndefined, isObject } from '@metamask/utils'; +export { hasProperty, isNullOrUndefined } from '@metamask/utils'; +export { isObject }; /** * A logger object for the implementation part of this project. @@ -12,6 +15,21 @@ export { hasProperty, isNullOrUndefined, isObject } from '@metamask/utils'; */ export const debug = createDebug('create-release-branch:impl'); +/** + * Type guard for determining whether the given value is an instance of Error. + * For errors generated via `fs.promises`, `error instanceof Error` won't work, + * so we have to come up with another way of testing. + * + * @param error - The object to check. + * @returns True or false, depending on the result. + */ +function isError(error: unknown): error is Error { + return ( + error instanceof Error || + (isObject(error) && error.constructor.name === 'Error') + ); +} + /** * 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 @@ -49,45 +67,31 @@ export function isErrorWithStack(error: unknown): error is { stack: string } { } /** - * Builds a new error object by optionally prepending a prefix or appending a - * suffix to the error's message (or the error itself, it is a string). Retains - * the `code` and `stack` of the original error object if they exist. + * Builds a new error object, linking to the original error via the `cause` + * property if it is an Error. * * This function is useful to reframe error messages in general, but is * _critical_ when interacting with any of Node's filesystem functions as * provided via `fs.promises`, because these do not produce stack traces in the * case of an I/O error (see ). * - * @param errorLike - Any value that can be thrown. - * @param buildMessage - A function that can be used to build the message of the - * new error object. It's passed an object that has a `message` property, and - * returns that `message` by default. + * @param message - The desired message of the new error. + * @param originalError - The error that you want to cover (either an Error or + * something throwable). * @returns A new error object. */ -export function wrapError( - errorLike: unknown, - buildMessage: (props: { message: string }) => string = (props) => - props.message, -) { - const message = isErrorWithMessage(errorLike) - ? errorLike.message - : String(errorLike); - const code = isErrorWithCode(errorLike) ? errorLike.code : undefined; - const stack = isErrorWithStack(errorLike) ? errorLike.stack : undefined; - const errorWithStack: Error & { - code?: string | undefined; - stack?: string | undefined; - } = new Error(buildMessage({ message })); +export function coverError(message: string, originalError: unknown) { + if (isError(originalError)) { + const error: any = new ErrorWithCause(message, { cause: originalError }); - if (code !== undefined) { - errorWithStack.code = code; - } + if (isErrorWithCode(originalError)) { + error.code = originalError.code; + } - if (stack !== undefined) { - errorWithStack.stack = stack; + return error; } - return errorWithStack; + return new Error(`${message}: ${originalError}`); } /** diff --git a/src/release-specification.test.ts b/src/release-specification.test.ts index ca7663d..bbf3bad 100644 --- a/src/release-specification.test.ts +++ b/src/release-specification.test.ts @@ -182,6 +182,7 @@ packages: path: '/path/to/editor', args: ['arg1', 'arg2'], }; + const error = new Error('oops'); when(jest.spyOn(miscUtils, 'runCommand')) .calledWith( '/path/to/editor', @@ -191,12 +192,16 @@ packages: shell: true, }, ) - .mockRejectedValue(new Error('oops')); + .mockRejectedValue(error); await expect( waitForUserToEditReleaseSpecification(releaseSpecificationPath, editor), ).rejects.toThrow( - 'Encountered an error while waiting for the release spec to be edited: oops', + expect.objectContaining({ + message: + 'Encountered an error while waiting for the release spec to be edited.', + cause: error, + }), ); }); }); @@ -294,7 +299,14 @@ packages: await expect( validateReleaseSpecification(project, releaseSpecificationPath), ).rejects.toThrow( - /^Failed to parse release spec:\n\nMissing closing "quote at line 1/u, + expect.objectContaining({ + message: expect.stringMatching( + /^Your release spec does not appear to be valid YAML\.\n/u, + ), + cause: expect.objectContaining({ + message: expect.stringMatching(/^Missing closing "quote/u), + }), + }), ); }); }); diff --git a/src/release-specification.ts b/src/release-specification.ts index 21e1f4c..6a73860 100644 --- a/src/release-specification.ts +++ b/src/release-specification.ts @@ -5,7 +5,7 @@ import { readFile } from './fs'; import { debug, hasProperty, - wrapError, + coverError, isObject, runCommand, } from './misc-utils'; @@ -127,10 +127,9 @@ export async function waitForUserToEditReleaseSpecification( stdout.write('\r\u001B[K'); if (caughtError) { - throw wrapError( + throw coverError( + 'Encountered an error while waiting for the release spec to be edited.', caughtError, - ({ message }) => - `Encountered an error while waiting for the release spec to be edited: ${message}`, ); } } @@ -163,24 +162,23 @@ export async function validateReleaseSpecification( packages: Record; }; + const afterwordForAllErrorMessages = [ + "The release spec file has been retained for you to edit again and make the necessary fixes. Once you've done this, re-run this tool.", + releaseSpecificationPath, + ].join('\n\n'); + try { unvalidatedReleaseSpecification = YAML.parse(releaseSpecificationContents); } catch (error) { - throw wrapError(error, ({ message }) => + throw coverError( [ - 'Failed to parse release spec:', - message, - "The file has been retained for you to make the necessary fixes. Once you've done this, re-run this tool.", - releaseSpecificationPath, + 'Your release spec does not appear to be valid YAML.', + afterwordForAllErrorMessages, ].join('\n\n'), + error, ); } - const postludeForAllErrorMessages = [ - "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'); - if ( !isObject(unvalidatedReleaseSpecification) || unvalidatedReleaseSpecification.packages === undefined @@ -189,7 +187,7 @@ export async function validateReleaseSpecification( `Your release spec could not be processed because it needs to be an object with a \`packages\` property. The value of \`packages\` must itself be an object, where each key is a workspace package in the project and each value is a version specifier ("major", "minor", or "patch"; or a version string with major, minor, and patch parts, such as "1.2.3").`, `Here is the parsed version of the file you provided:`, JSON.stringify(unvalidatedReleaseSpecification, null, 2), - postludeForAllErrorMessages, + afterwordForAllErrorMessages, ].join('\n\n'); throw new Error(message); } @@ -250,7 +248,7 @@ export async function validateReleaseSpecification( return `${itemPrefix}${lineNumberPrefix}${error.message}`; }) .join('\n'), - postludeForAllErrorMessages, + afterwordForAllErrorMessages, ].join('\n\n'); throw new Error(message); } diff --git a/yarn.lock b/yarn.lock index 0d5c5e0..c25fcd4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -895,6 +895,7 @@ __metadata: jest-it-up: ^2.0.2 jest-when: ^3.5.1 nanoid: ^3.3.4 + pony-cause: ^2.1.0 prettier: ^2.2.1 prettier-plugin-packagejson: ^2.2.17 rimraf: ^3.0.2 @@ -4974,6 +4975,13 @@ __metadata: languageName: node linkType: hard +"pony-cause@npm:^2.1.0": + version: 2.1.0 + resolution: "pony-cause@npm:2.1.0" + checksum: ae1df5d97da0cfeac3d5a16abb66f7e5dffe675f8fc0811f143a46b0a3409803def538b862e4dd9825635e8268157f532b6c8e5bcdf8f99e20d482bfba93c042 + languageName: node + linkType: hard + "prelude-ls@npm:^1.2.1": version: 1.2.1 resolution: "prelude-ls@npm:1.2.1" From 35f5085c64854d7c11609e54eeaea1ca50944eca Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Tue, 2 Aug 2022 15:14:29 -0600 Subject: [PATCH 40/41] Clarify the version comparison check --- src/monorepo-workflow-operations.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/monorepo-workflow-operations.ts b/src/monorepo-workflow-operations.ts index 078eec1..ef2f0d5 100644 --- a/src/monorepo-workflow-operations.ts +++ b/src/monorepo-workflow-operations.ts @@ -183,13 +183,11 @@ async function planRelease( const pkg = project.workspacePackages[packageName]; const versionSpecifier = releaseSpecification.packages[packageName]; const currentVersion = pkg.manifest.version; - const newVersion = - versionSpecifier instanceof SemVer - ? versionSpecifier - : new SemVer(currentVersion.toString()).inc(versionSpecifier); - const comparison = newVersion.compare(currentVersion); + let newVersion: SemVer; if (versionSpecifier instanceof SemVer) { + const comparison = versionSpecifier.compare(currentVersion); + if (comparison === 0) { throw new Error( [ @@ -207,6 +205,10 @@ async function planRelease( ].join('\n\n'), ); } + + newVersion = versionSpecifier; + } else { + newVersion = new SemVer(currentVersion.toString()).inc(versionSpecifier); } return { From b4d820dbe19ee07035aa87f81dc6192722cbaf4c Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Tue, 2 Aug 2022 16:03:14 -0600 Subject: [PATCH 41/41] Rename coverError to wrapError --- src/fs.ts | 19 ++++++++----------- src/misc-utils.test.ts | 10 +++++----- src/misc-utils.ts | 2 +- src/release-specification.ts | 6 +++--- 4 files changed, 17 insertions(+), 20 deletions(-) diff --git a/src/fs.ts b/src/fs.ts index b1e66d5..c369507 100644 --- a/src/fs.ts +++ b/src/fs.ts @@ -3,7 +3,7 @@ import { readJsonObjectFile as underlyingReadJsonObjectFile, writeJsonFile as underlyingWriteJsonFile, } from '@metamask/action-utils'; -import { coverError, isErrorWithCode } from './misc-utils'; +import { wrapError, isErrorWithCode } from './misc-utils'; /** * Reads the file at the given path, assuming its content is encoded as UTF-8. @@ -16,7 +16,7 @@ export async function readFile(filePath: string): Promise { try { return await fs.promises.readFile(filePath, 'utf8'); } catch (error) { - throw coverError(`Could not read file '${filePath}'`, error); + throw wrapError(`Could not read file '${filePath}'`, error); } } @@ -34,7 +34,7 @@ export async function writeFile( try { await fs.promises.writeFile(filePath, content); } catch (error) { - throw coverError(`Could not write file '${filePath}'`, error); + throw wrapError(`Could not write file '${filePath}'`, error); } } @@ -56,7 +56,7 @@ export async function readJsonObjectFile( try { return await underlyingReadJsonObjectFile(filePath); } catch (error) { - throw coverError(`Could not read JSON file '${filePath}'`, error); + throw wrapError(`Could not read JSON file '${filePath}'`, error); } } @@ -77,7 +77,7 @@ export async function writeJsonFile( try { await underlyingWriteJsonFile(filePath, jsonValue); } catch (error) { - throw coverError(`Could not write JSON file '${filePath}'`, error); + throw wrapError(`Could not write JSON file '${filePath}'`, error); } } @@ -97,10 +97,7 @@ export async function fileExists(entryPath: string): Promise { return false; } - throw coverError( - `Could not determine if file exists '${entryPath}'`, - error, - ); + throw wrapError(`Could not determine if file exists '${entryPath}'`, error); } } @@ -118,7 +115,7 @@ export async function ensureDirectoryPathExists( try { return await fs.promises.mkdir(directoryPath, { recursive: true }); } catch (error) { - throw coverError( + throw wrapError( `Could not create directory path '${directoryPath}'`, error, ); @@ -136,6 +133,6 @@ export async function removeFile(filePath: string): Promise { try { return await fs.promises.rm(filePath, { force: true }); } catch (error) { - throw coverError(`Could not remove file '${filePath}'`, error); + throw wrapError(`Could not remove file '${filePath}'`, error); } } diff --git a/src/misc-utils.test.ts b/src/misc-utils.test.ts index 6724d9f..88effc1 100644 --- a/src/misc-utils.test.ts +++ b/src/misc-utils.test.ts @@ -4,7 +4,7 @@ import { isErrorWithCode, isErrorWithMessage, isErrorWithStack, - coverError, + wrapError, resolveExecutable, getStdoutFromCommand, runCommand, @@ -80,10 +80,10 @@ describe('misc-utils', () => { }); }); - describe('coverError', () => { + describe('wrapError', () => { it('returns a new Error that links to the given Error', () => { const originalError = new Error('oops'); - const newError = coverError('Some message', originalError); + const newError = wrapError('Some message', originalError); expect(newError.message).toStrictEqual('Some message'); expect(newError.cause).toBe(originalError); @@ -92,13 +92,13 @@ describe('misc-utils', () => { it('copies over any "code" property that exists on the given Error', () => { const originalError: any = new Error('oops'); originalError.code = 'CODE'; - const newError: any = coverError('Some message', originalError); + const newError: any = wrapError('Some message', originalError); expect(newError.code).toStrictEqual('CODE'); }); it('returns a new Error which prefixes the given message', () => { - const newError = coverError('Some message', 'Some original message'); + const newError = wrapError('Some message', 'Some original message'); expect(newError.message).toBe('Some message: Some original message'); expect(newError.cause).toBeUndefined(); diff --git a/src/misc-utils.ts b/src/misc-utils.ts index 4d57c26..779ea05 100644 --- a/src/misc-utils.ts +++ b/src/misc-utils.ts @@ -80,7 +80,7 @@ export function isErrorWithStack(error: unknown): error is { stack: string } { * something throwable). * @returns A new error object. */ -export function coverError(message: string, originalError: unknown) { +export function wrapError(message: string, originalError: unknown) { if (isError(originalError)) { const error: any = new ErrorWithCause(message, { cause: originalError }); diff --git a/src/release-specification.ts b/src/release-specification.ts index 6a73860..ef51ff0 100644 --- a/src/release-specification.ts +++ b/src/release-specification.ts @@ -5,7 +5,7 @@ import { readFile } from './fs'; import { debug, hasProperty, - coverError, + wrapError, isObject, runCommand, } from './misc-utils'; @@ -127,7 +127,7 @@ export async function waitForUserToEditReleaseSpecification( stdout.write('\r\u001B[K'); if (caughtError) { - throw coverError( + throw wrapError( 'Encountered an error while waiting for the release spec to be edited.', caughtError, ); @@ -170,7 +170,7 @@ export async function validateReleaseSpecification( try { unvalidatedReleaseSpecification = YAML.parse(releaseSpecificationContents); } catch (error) { - throw coverError( + throw wrapError( [ 'Your release spec does not appear to be valid YAML.', afterwordForAllErrorMessages,