From 65bc3b1a61e3ca3622918b6685055664c183a2f7 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Thu, 11 Dec 2025 16:09:00 -0700 Subject: [PATCH] Reorganize src/ It's hard to tell how the UI code is using the code that was originally added for the CLI. This commit splits out common code into a `core/` directory while keeping the code unique to the CLI and UI in separate `cli/` and `ui/` directories. --- bin/create-release-branch.js | 17 +-- eslint.config.mjs | 2 +- jest.config.cjs | 2 +- package.json | 2 +- src/cli.ts | 18 --- src/{ => cli}/command-line-arguments.ts | 0 src/{ => cli}/env.test.ts | 0 src/{ => cli}/env.ts | 0 src/{ => cli}/initial-parameters.test.ts | 6 +- src/{ => cli}/initial-parameters.ts | 16 +-- .../monorepo-workflow-operations.test.ts | 39 +++--- src/{ => cli}/monorepo-workflow-operations.ts | 22 ++-- src/{main.ts => cli/run.ts} | 12 +- src/{main.test.ts => cli/start.test.ts} | 26 ++-- src/core/editor.test.ts | 80 ++++++++++++ src/{ => core}/editor.ts | 17 +-- src/{ => core}/fs.test.ts | 2 +- src/{ => core}/fs.ts | 0 src/core/get-root-directory-path.ts | 16 +++ src/{ => core}/misc-utils.test.ts | 0 src/{ => core}/misc-utils.ts | 0 src/{ => core}/package-manifest.test.ts | 2 +- src/{ => core}/package-manifest.ts | 0 src/{ => core}/package.test.ts | 4 +- src/{ => core}/package.ts | 0 src/{ => core}/project.test.ts | 4 +- src/{ => core}/project.ts | 0 src/{ => core}/release-plan.test.ts | 5 +- src/{ => core}/release-plan.ts | 0 src/{ => core}/release-specification.test.ts | 7 +- src/{ => core}/release-specification.ts | 0 src/{ => core}/repo.test.ts | 0 src/{ => core}/repo.ts | 0 src/{ => core}/semver.ts | 0 src/core/types.ts | 10 ++ src/{ => core}/workflow-operations.test.ts | 2 +- src/{ => core}/workflow-operations.ts | 2 +- src/{ => core}/yarn-commands.test.ts | 0 src/{ => core}/yarn-commands.ts | 0 src/dirname.ts | 11 -- src/editor.test.ts | 116 ------------------ src/scripts/cli.ts | 11 ++ src/ui/{ => app}/App.tsx | 0 src/ui/{ => app}/DependencyErrorSection.tsx | 0 src/ui/{ => app}/ErrorMessage.tsx | 0 src/ui/{ => app}/Markdown.tsx | 0 src/ui/{ => app}/PackageItem.tsx | 0 src/ui/{ => app}/VersionSelector.tsx | 0 src/ui/{ => app}/favicon.svg | 0 src/ui/{ => app}/index.html | 0 src/ui/{ => app}/style.css | 0 src/ui/{ => app}/types.ts | 0 src/{ui.ts => ui/start.ts} | 28 ++--- tests/functional/helpers/constants.ts | 9 +- tests/unit/helpers.ts | 8 +- vite.config.mjs | 4 +- yarn.lock | 19 ++- 57 files changed, 257 insertions(+), 262 deletions(-) delete mode 100644 src/cli.ts rename src/{ => cli}/command-line-arguments.ts (100%) rename src/{ => cli}/env.test.ts (100%) rename src/{ => cli}/env.ts (100%) rename src/{ => cli}/initial-parameters.test.ts (98%) rename src/{ => cli}/initial-parameters.ts (75%) rename src/{ => cli}/monorepo-workflow-operations.test.ts (98%) rename src/{ => cli}/monorepo-workflow-operations.ts (89%) rename src/{main.ts => cli/run.ts} (85%) rename src/{main.test.ts => cli/start.test.ts} (86%) create mode 100644 src/core/editor.test.ts rename src/{ => core}/editor.ts (72%) rename src/{ => core}/fs.test.ts (99%) rename src/{ => core}/fs.ts (100%) create mode 100644 src/core/get-root-directory-path.ts rename src/{ => core}/misc-utils.test.ts (100%) rename src/{ => core}/misc-utils.ts (100%) rename src/{ => core}/package-manifest.test.ts (99%) rename src/{ => core}/package-manifest.ts (100%) rename src/{ => core}/package.test.ts (99%) rename src/{ => core}/package.ts (100%) rename src/{ => core}/project.test.ts (99%) rename src/{ => core}/project.ts (100%) rename src/{ => core}/release-plan.test.ts (98%) rename src/{ => core}/release-plan.ts (100%) rename src/{ => core}/release-specification.test.ts (99%) rename src/{ => core}/release-specification.ts (100%) rename src/{ => core}/repo.test.ts (100%) rename src/{ => core}/repo.ts (100%) rename src/{ => core}/semver.ts (100%) create mode 100644 src/core/types.ts rename src/{ => core}/workflow-operations.test.ts (98%) rename src/{ => core}/workflow-operations.ts (97%) rename src/{ => core}/yarn-commands.test.ts (100%) rename src/{ => core}/yarn-commands.ts (100%) delete mode 100644 src/dirname.ts delete mode 100644 src/editor.test.ts create mode 100644 src/scripts/cli.ts rename src/ui/{ => app}/App.tsx (100%) rename src/ui/{ => app}/DependencyErrorSection.tsx (100%) rename src/ui/{ => app}/ErrorMessage.tsx (100%) rename src/ui/{ => app}/Markdown.tsx (100%) rename src/ui/{ => app}/PackageItem.tsx (100%) rename src/ui/{ => app}/VersionSelector.tsx (100%) rename src/ui/{ => app}/favicon.svg (100%) rename src/ui/{ => app}/index.html (100%) rename src/ui/{ => app}/style.css (100%) rename src/ui/{ => app}/types.ts (100%) rename src/{ui.ts => ui/start.ts} (92%) diff --git a/bin/create-release-branch.js b/bin/create-release-branch.js index 0e568f87..064b4dee 100755 --- a/bin/create-release-branch.js +++ b/bin/create-release-branch.js @@ -1,10 +1,11 @@ #!/usr/bin/env node -// Three things: -// - This file doesn't export anything, as it's a script. -// - We are using a `.js` extension because that's what appears in `dist/`. -// - This file will only exist after running `yarn build`. We don't want -// developers or CI to receive a lint error if the script has not been run. -// (A warning will appear if the script *has* been run, but that is okay.) -// eslint-disable-next-line import-x/no-unassigned-import, import-x/extensions, import-x/no-unresolved -import '../dist/cli.js'; +// This file will only exist after running `yarn build`, and it will get a `.js` +// extension. +// +// We don't want developers or CI to receive a lint error if the script has not +// been run. (A warning will appear if the script *has* been run, but that is +// okay.) +// +// eslint-disable-next-line import-x/extensions, import-x/no-unassigned-import, import-x/no-unresolved +import '../dist/scripts/cli.js'; diff --git a/eslint.config.mjs b/eslint.config.mjs index 1fe55bc8..43e22148 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -106,7 +106,7 @@ const config = createConfig([ { files: ['**/*.js', '**/*.cjs', '**/*.ts', '**/*.test.ts', '**/*.test.js'], - ignores: ['src/ui/**'], + ignores: ['src/ui/app/**'], extends: nodejs, }, diff --git a/jest.config.cjs b/jest.config.cjs index 52890d22..23a5704a 100644 --- a/jest.config.cjs +++ b/jest.config.cjs @@ -61,7 +61,7 @@ module.exports = { '/src/command-line-arguments.ts', '/src/ui.ts', '/src/ui/types.ts', - '/src/dirname.ts', + '/src/get-source-directory-path.ts', ], // Indicates which provider should be used to instrument code for coverage diff --git a/package.json b/package.json index 7f36fea5..b1193aab 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "@types/express": "^5.0.0", "@types/jest": "^29.5.10", "@types/jest-when": "^3.5.2", - "@types/node": "^17.0.23", + "@types/node": "^22.0.0", "@types/prettier": "^2.7.3", "@types/react": "^19.0.8", "@types/react-dom": "^19.0.3", diff --git a/src/cli.ts b/src/cli.ts deleted file mode 100644 index b23333f3..00000000 --- a/src/cli.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { main } from './main.js'; - -/** - * The entrypoint to this tool. - */ -async function cli(): Promise { - await main({ - argv: process.argv, - cwd: process.cwd(), - stdout: process.stdout, - stderr: process.stderr, - }); -} - -cli().catch((error) => { - console.error(error); - process.exitCode = 1; -}); diff --git a/src/command-line-arguments.ts b/src/cli/command-line-arguments.ts similarity index 100% rename from src/command-line-arguments.ts rename to src/cli/command-line-arguments.ts diff --git a/src/env.test.ts b/src/cli/env.test.ts similarity index 100% rename from src/env.test.ts rename to src/cli/env.test.ts diff --git a/src/env.ts b/src/cli/env.ts similarity index 100% rename from src/env.ts rename to src/cli/env.ts diff --git a/src/initial-parameters.test.ts b/src/cli/initial-parameters.test.ts similarity index 98% rename from src/initial-parameters.test.ts rename to src/cli/initial-parameters.test.ts index bb499ed3..23688fcf 100644 --- a/src/initial-parameters.test.ts +++ b/src/cli/initial-parameters.test.ts @@ -5,16 +5,16 @@ import path from 'path'; import * as commandLineArgumentsModule from './command-line-arguments.js'; import * as envModule from './env.js'; import { determineInitialParameters } from './initial-parameters.js'; -import * as projectModule from './project.js'; import { buildMockProject, buildMockPackage, createNoopWriteStream, -} from '../tests/unit/helpers.js'; +} from '../../tests/unit/helpers.js'; +import * as projectModule from '../core/project.js'; +jest.mock('../core/project'); jest.mock('./command-line-arguments'); jest.mock('./env'); -jest.mock('./project'); describe('initial-parameters', () => { describe('determineInitialParameters', () => { diff --git a/src/initial-parameters.ts b/src/cli/initial-parameters.ts similarity index 75% rename from src/initial-parameters.ts rename to src/cli/initial-parameters.ts index acd82087..bdd84d1f 100644 --- a/src/initial-parameters.ts +++ b/src/cli/initial-parameters.ts @@ -2,19 +2,9 @@ import os from 'os'; import path from 'path'; import { readCommandLineArguments } from './command-line-arguments.js'; -import { WriteStreamLike } from './fs.js'; -import { readProject, Project } from './project.js'; - -/** - * The type of release being created as determined by the parent release. - * - * - An *ordinary* release includes features or fixes applied against the latest - * release and is designated by bumping the first part of that release's - * version string. - * - A *backport* release includes fixes applied against a previous release and - * is designated by bumping the second part of that release's version string. - */ -export type ReleaseType = 'ordinary' | 'backport'; +import { WriteStreamLike } from '../core/fs.js'; +import { readProject, Project } from '../core/project.js'; +import { ReleaseType } from '../core/types.js'; /** * Various pieces of information that the tool uses to run, derived from diff --git a/src/monorepo-workflow-operations.test.ts b/src/cli/monorepo-workflow-operations.test.ts similarity index 98% rename from src/monorepo-workflow-operations.test.ts rename to src/cli/monorepo-workflow-operations.test.ts index a5a68e2d..b79d7ebb 100644 --- a/src/monorepo-workflow-operations.test.ts +++ b/src/cli/monorepo-workflow-operations.test.ts @@ -3,33 +3,35 @@ import { when } from 'jest-when'; import path from 'path'; import { MockWritable } from 'stdio-mock'; -import { determineEditor } from './editor.js'; -import type { Editor } from './editor.js'; +import { getEnvironmentVariables } from './env.js'; import { followMonorepoWorkflow } from './monorepo-workflow-operations.js'; -import { Project } from './project.js'; -import { executeReleasePlan, planRelease } from './release-plan.js'; -import type { ReleasePlan } from './release-plan.js'; +import { withSandbox, Sandbox, isErrorWithCode } from '../../tests/helpers.js'; +import { buildMockProject, Require } from '../../tests/unit/helpers.js'; +import { determineEditor } from '../core/editor.js'; +import type { Editor } from '../core/editor.js'; +import { Project } from '../core/project.js'; +import { executeReleasePlan, planRelease } from '../core/release-plan.js'; +import type { ReleasePlan } from '../core/release-plan.js'; import { generateReleaseSpecificationTemplateForMonorepo, waitForUserToEditReleaseSpecification, validateReleaseSpecification, -} from './release-specification.js'; -import type { ReleaseSpecification } from './release-specification.js'; -import { commitAllChanges } from './repo.js'; -import * as workflowOperationsModule from './workflow-operations.js'; +} from '../core/release-specification.js'; +import type { ReleaseSpecification } from '../core/release-specification.js'; +import { commitAllChanges } from '../core/repo.js'; +import * as workflowOperationsModule from '../core/workflow-operations.js'; import { deduplicateDependencies, fixConstraints, updateYarnLockfile, -} from './yarn-commands.js'; -import { withSandbox, Sandbox, isErrorWithCode } from '../tests/helpers.js'; -import { buildMockProject, Require } from '../tests/unit/helpers.js'; +} from '../core/yarn-commands.js'; -jest.mock('./editor'); -jest.mock('./release-plan'); -jest.mock('./release-specification'); -jest.mock('./repo'); -jest.mock('./yarn-commands.js'); +jest.mock('../core/editor'); +jest.mock('../core/release-plan'); +jest.mock('../core/release-specification'); +jest.mock('../core/repo'); +jest.mock('../core/yarn-commands.js'); +jest.mock('./env'); const determineEditorMock = jest.mocked(determineEditor); const generateReleaseSpecificationTemplateForMonorepoMock = jest.mocked( @@ -47,6 +49,7 @@ const commitAllChangesMock = jest.mocked(commitAllChanges); const fixConstraintsMock = jest.mocked(fixConstraints); const updateYarnLockfileMock = jest.mocked(updateYarnLockfile); const deduplicateDependenciesMock = jest.mocked(deduplicateDependencies); +const getEnvironmentVariablesMock = jest.mocked(getEnvironmentVariables); /** * Tests the given path to determine whether it represents a file. @@ -249,6 +252,8 @@ async function setupFollowMonorepoWorkflow({ .calledWith(projectDirectoryPath, '') .mockResolvedValue(); + getEnvironmentVariablesMock.mockReturnValue({}); + if (doesReleaseSpecFileExist) { await fs.promises.writeFile( releaseSpecificationPath, diff --git a/src/monorepo-workflow-operations.ts b/src/cli/monorepo-workflow-operations.ts similarity index 89% rename from src/monorepo-workflow-operations.ts rename to src/cli/monorepo-workflow-operations.ts index f9bc28a1..af073d5d 100644 --- a/src/monorepo-workflow-operations.ts +++ b/src/cli/monorepo-workflow-operations.ts @@ -1,32 +1,33 @@ import type { WriteStream } from 'fs'; import path from 'path'; -import { determineEditor } from './editor.js'; +import { getEnvironmentVariables } from './env.js'; +import { determineEditor } from '../core/editor.js'; import { ensureDirectoryPathExists, fileExists, removeFile, writeFile, -} from './fs.js'; -import { ReleaseType } from './initial-parameters.js'; +} from '../core/fs.js'; import { Project, updateChangelogsForChangedPackages, restoreChangelogsForSkippedPackages, -} from './project.js'; -import { planRelease, executeReleasePlan } from './release-plan.js'; +} from '../core/project.js'; +import { planRelease, executeReleasePlan } from '../core/release-plan.js'; import { generateReleaseSpecificationTemplateForMonorepo, waitForUserToEditReleaseSpecification, validateReleaseSpecification, -} from './release-specification.js'; -import { commitAllChanges } from './repo.js'; -import { createReleaseBranch } from './workflow-operations.js'; +} from '../core/release-specification.js'; +import { commitAllChanges } from '../core/repo.js'; +import { ReleaseType } from '../core/types.js'; +import { createReleaseBranch } from '../core/workflow-operations.js'; import { deduplicateDependencies, fixConstraints, updateYarnLockfile, -} from './yarn-commands.js'; +} from '../core/yarn-commands.js'; /** * For a monorepo, the process works like this: @@ -104,7 +105,8 @@ export async function followMonorepoWorkflow({ 'Release spec already exists. Picking back up from previous run.\n', ); } else { - const editor = await determineEditor(); + const { EDITOR } = getEnvironmentVariables(); + const editor = await determineEditor(EDITOR); const releaseSpecificationTemplate = await generateReleaseSpecificationTemplateForMonorepo({ diff --git a/src/main.ts b/src/cli/run.ts similarity index 85% rename from src/main.ts rename to src/cli/run.ts index 184bb909..297c8326 100644 --- a/src/main.ts +++ b/src/cli/run.ts @@ -2,12 +2,14 @@ import type { WriteStream } from 'fs'; import { determineInitialParameters } from './initial-parameters.js'; import { followMonorepoWorkflow } from './monorepo-workflow-operations.js'; -import { startUI } from './ui.js'; +import { start as startUI } from '../ui/start.js'; /** - * 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. + * The entrypoint 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 executable and its arguments (as obtained @@ -16,7 +18,7 @@ import { startUI } from './ui.js'; * @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({ +export async function run({ argv, cwd, stdout, diff --git a/src/main.test.ts b/src/cli/start.test.ts similarity index 86% rename from src/main.test.ts rename to src/cli/start.test.ts index a7cbdf93..5cda73d1 100644 --- a/src/main.test.ts +++ b/src/cli/start.test.ts @@ -1,24 +1,24 @@ import fs from 'fs'; import * as initialParametersModule from './initial-parameters.js'; -import { main } from './main.js'; import * as monorepoWorkflowOperations from './monorepo-workflow-operations.js'; -import * as ui from './ui.js'; -import { buildMockProject } from '../tests/unit/helpers.js'; +import { run } from './run.js'; +import { buildMockProject } from '../../tests/unit/helpers.js'; +import * as ui from '../ui/start.js'; +jest.mock('../core/get-root-directory-path', () => ({ + getRootDirectoryPath: jest.fn().mockReturnValue('/path/to/somewhere'), +})); +jest.mock('../ui/start'); jest.mock('./initial-parameters'); jest.mock('./monorepo-workflow-operations'); -jest.mock('./ui'); -jest.mock('./dirname', () => ({ - getCurrentDirectoryPath: jest.fn().mockReturnValue('/path/to/somewhere'), -})); jest.mock('open', () => ({ apps: { browser: jest.fn(), }, })); -describe('main', () => { +describe('start', () => { it('executes the CLI monorepo workflow if the project is a monorepo and interactive is false', async () => { const project = buildMockProject({ isMonorepo: true }); const stdout = fs.createWriteStream('/dev/null'); @@ -38,7 +38,7 @@ describe('main', () => { .spyOn(monorepoWorkflowOperations, 'followMonorepoWorkflow') .mockResolvedValue(); - await main({ + await run({ argv: [], cwd: '/path/to/somewhere', stdout, @@ -71,16 +71,16 @@ describe('main', () => { interactive: true, port: 3000, }); - const startUISpy = jest.spyOn(ui, 'startUI').mockResolvedValue(); + const startSpy = jest.spyOn(ui, 'start').mockResolvedValue(); - await main({ + await run({ argv: [], cwd: '/path/to/somewhere', stdout, stderr, }); - expect(startUISpy).toHaveBeenCalledWith({ + expect(startSpy).toHaveBeenCalledWith({ project, releaseType: 'backport', defaultBranch: 'main', @@ -109,7 +109,7 @@ describe('main', () => { .spyOn(monorepoWorkflowOperations, 'followMonorepoWorkflow') .mockResolvedValue(); - await main({ + await run({ argv: [], cwd: '/path/to/somewhere', stdout, diff --git a/src/core/editor.test.ts b/src/core/editor.test.ts new file mode 100644 index 00000000..eb4b8fed --- /dev/null +++ b/src/core/editor.test.ts @@ -0,0 +1,80 @@ +import { when } from 'jest-when'; + +import { determineEditor } from './editor.js'; +import * as miscUtils from './misc-utils.js'; + +jest.mock('./misc-utils'); + +describe('editor', () => { + describe('determineEditor', () => { + it('returns information about the path if it resolves to an executable', async () => { + when(jest.spyOn(miscUtils, 'resolveExecutable')) + .calledWith('editor') + .mockResolvedValue('/path/to/resolved-editor'); + + expect(await determineEditor('editor')).toStrictEqual({ + path: '/path/to/resolved-editor', + args: [], + }); + }); + + it('defaults to VSCode if the given path does not resolve to an executable', async () => { + when(jest.spyOn(miscUtils, 'resolveExecutable')) + .calledWith('editor') + .mockResolvedValue(null) + .calledWith('code') + .mockResolvedValue('/path/to/code'); + + expect(await determineEditor('editor')).toStrictEqual({ + path: '/path/to/code', + args: ['--wait'], + }); + }); + + it('returns null if the given path cannot be resolved and VSCode cannot be found', async () => { + when(jest.spyOn(miscUtils, 'resolveExecutable')) + .calledWith('editor') + .mockResolvedValue(null) + .calledWith('code') + .mockResolvedValue(null); + + expect(await determineEditor('editor')).toBeNull(); + }); + + it('returns null if the given path cannot be resolved and attempting to find VSCode fails', async () => { + when(jest.spyOn(miscUtils, 'resolveExecutable')) + .calledWith('editor') + .mockResolvedValue(null) + .calledWith('code') + .mockRejectedValue(new Error('some error')); + + expect(await determineEditor('editor')).toBeNull(); + }); + + it('returns null if resolving the given path fails', async () => { + when(jest.spyOn(miscUtils, 'resolveExecutable')) + .calledWith('editor') + .mockRejectedValue(new Error('some error')) + .calledWith('code') + .mockResolvedValue(null); + + expect(await determineEditor('editor')).toBeNull(); + }); + + it('returns null no path is given and VSCode cannot be found', async () => { + when(jest.spyOn(miscUtils, 'resolveExecutable')) + .calledWith('code') + .mockResolvedValue(null); + + expect(await determineEditor(undefined)).toBeNull(); + }); + + it('returns null no path is given and attempting to find VSCode fails', async () => { + when(jest.spyOn(miscUtils, 'resolveExecutable')) + .calledWith('code') + .mockRejectedValue(new Error('some error')); + + expect(await determineEditor(undefined)).toBeNull(); + }); + }); +}); diff --git a/src/editor.ts b/src/core/editor.ts similarity index 72% rename from src/editor.ts rename to src/core/editor.ts index 37d3b4d5..f19458a6 100644 --- a/src/editor.ts +++ b/src/core/editor.ts @@ -1,6 +1,5 @@ import { getErrorMessage } from '@metamask/utils'; -import { getEnvironmentVariables } from './env.js'; import { debug, resolveExecutable } from './misc-utils.js'; /** @@ -18,23 +17,25 @@ export type Editor = { /** * 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`). + * the given file path first, falling back to the executable that represents + * VSCode (`code`). * + * @param possiblePath - A file path to test for existence. * @returns A promise that contains information about the found editor (path and * arguments), or null otherwise. */ -export async function determineEditor(): Promise { +export async function determineEditor( + possiblePath: string | undefined, +): Promise { let executablePath: string | null = null; const executableArgs: string[] = []; - const { EDITOR } = getEnvironmentVariables(); - if (EDITOR !== undefined) { + if (possiblePath !== undefined) { try { - executablePath = await resolveExecutable(EDITOR); + executablePath = await resolveExecutable(possiblePath); } catch (error) { debug( - `Could not resolve executable ${EDITOR} (${getErrorMessage(error)}), falling back to VSCode`, + `Could not resolve executable ${possiblePath} (${getErrorMessage(error)}), falling back to VSCode`, ); } } diff --git a/src/fs.test.ts b/src/core/fs.test.ts similarity index 99% rename from src/fs.test.ts rename to src/core/fs.test.ts index ab712e0d..d8a943c7 100644 --- a/src/fs.test.ts +++ b/src/core/fs.test.ts @@ -13,7 +13,7 @@ import { ensureDirectoryPathExists, removeFile, } from './fs.js'; -import { withSandbox } from '../tests/helpers.js'; +import { withSandbox } from '../../tests/helpers.js'; jest.mock('@metamask/action-utils'); diff --git a/src/fs.ts b/src/core/fs.ts similarity index 100% rename from src/fs.ts rename to src/core/fs.ts diff --git a/src/core/get-root-directory-path.ts b/src/core/get-root-directory-path.ts new file mode 100644 index 00000000..dbb9300b --- /dev/null +++ b/src/core/get-root-directory-path.ts @@ -0,0 +1,16 @@ +import path from 'path'; +import { fileURLToPath } from 'url'; + +/** + * Get the absolute path of either the `src/` directory in development or the + * `dist/` directory in production. + * + * This function exists so that we can mock it out in unit tests. (We cannot use + * `import.meta` in Jest because we can't configured it to understand ESM, and + * doing so would involve more work than we want to spend right now.) + * + * @returns The absolute path of either `src/` or `dest/`. + */ +export function getRootDirectoryPath(): string { + return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +} diff --git a/src/misc-utils.test.ts b/src/core/misc-utils.test.ts similarity index 100% rename from src/misc-utils.test.ts rename to src/core/misc-utils.test.ts diff --git a/src/misc-utils.ts b/src/core/misc-utils.ts similarity index 100% rename from src/misc-utils.ts rename to src/core/misc-utils.ts diff --git a/src/package-manifest.test.ts b/src/core/package-manifest.test.ts similarity index 99% rename from src/package-manifest.test.ts rename to src/core/package-manifest.test.ts index 3c0afd8c..3636dd2b 100644 --- a/src/package-manifest.test.ts +++ b/src/core/package-manifest.test.ts @@ -3,7 +3,7 @@ import path from 'path'; import { SemVer } from 'semver'; import { readPackageManifest } from './package-manifest.js'; -import { withSandbox } from '../tests/helpers.js'; +import { withSandbox } from '../../tests/helpers.js'; describe('package-manifest', () => { describe('readPackageManifest', () => { diff --git a/src/package-manifest.ts b/src/core/package-manifest.ts similarity index 100% rename from src/package-manifest.ts rename to src/core/package-manifest.ts diff --git a/src/package.test.ts b/src/core/package.test.ts similarity index 99% rename from src/package.test.ts rename to src/core/package.test.ts index c097ec3f..834b2cf3 100644 --- a/src/package.test.ts +++ b/src/core/package.test.ts @@ -15,13 +15,13 @@ import { updatePackageChangelog, } from './package.js'; import * as repoModule from './repo.js'; -import { buildChangelog, withSandbox } from '../tests/helpers.js'; +import { buildChangelog, withSandbox } from '../../tests/helpers.js'; import { buildMockPackage, buildMockProject, buildMockManifest, createNoopWriteStream, -} from '../tests/unit/helpers.js'; +} from '../../tests/unit/helpers.js'; jest.mock('./package-manifest'); jest.mock('./repo'); diff --git a/src/package.ts b/src/core/package.ts similarity index 100% rename from src/package.ts rename to src/core/package.ts diff --git a/src/project.test.ts b/src/core/project.test.ts similarity index 99% rename from src/project.test.ts rename to src/core/project.test.ts index 6f79551f..ab2bb9af 100644 --- a/src/project.test.ts +++ b/src/core/project.test.ts @@ -15,12 +15,12 @@ import { } from './project.js'; import { IncrementableVersionParts } from './release-specification.js'; import * as repoModule from './repo.js'; -import { withProtectedProcessEnv, withSandbox } from '../tests/helpers.js'; +import { withProtectedProcessEnv, withSandbox } from '../../tests/helpers.js'; import { buildMockPackage, buildMockProject, createNoopWriteStream, -} from '../tests/unit/helpers.js'; +} from '../../tests/unit/helpers.js'; jest.mock('./package'); jest.mock('./repo'); diff --git a/src/project.ts b/src/core/project.ts similarity index 100% rename from src/project.ts rename to src/core/project.ts diff --git a/src/release-plan.test.ts b/src/core/release-plan.test.ts similarity index 98% rename from src/release-plan.test.ts rename to src/core/release-plan.test.ts index 18a3b4e8..5d9ee678 100644 --- a/src/release-plan.test.ts +++ b/src/core/release-plan.test.ts @@ -4,7 +4,10 @@ import { SemVer } from 'semver'; import * as packageUtils from './package.js'; import { planRelease, executeReleasePlan } from './release-plan.js'; import { IncrementableVersionParts } from './release-specification.js'; -import { buildMockProject, buildMockPackage } from '../tests/unit/helpers.js'; +import { + buildMockProject, + buildMockPackage, +} from '../../tests/unit/helpers.js'; jest.mock('./package'); diff --git a/src/release-plan.ts b/src/core/release-plan.ts similarity index 100% rename from src/release-plan.ts rename to src/core/release-plan.ts diff --git a/src/release-specification.test.ts b/src/core/release-specification.test.ts similarity index 99% rename from src/release-specification.test.ts rename to src/core/release-specification.test.ts index 1cf9e907..3194d4aa 100644 --- a/src/release-specification.test.ts +++ b/src/core/release-specification.test.ts @@ -11,8 +11,11 @@ import { waitForUserToEditReleaseSpecification, validateReleaseSpecification, } from './release-specification.js'; -import { withSandbox } from '../tests/helpers.js'; -import { buildMockProject, buildMockPackage } from '../tests/unit/helpers.js'; +import { withSandbox } from '../../tests/helpers.js'; +import { + buildMockProject, + buildMockPackage, +} from '../../tests/unit/helpers.js'; jest.mock('./misc-utils', () => { return { diff --git a/src/release-specification.ts b/src/core/release-specification.ts similarity index 100% rename from src/release-specification.ts rename to src/core/release-specification.ts diff --git a/src/repo.test.ts b/src/core/repo.test.ts similarity index 100% rename from src/repo.test.ts rename to src/core/repo.test.ts diff --git a/src/repo.ts b/src/core/repo.ts similarity index 100% rename from src/repo.ts rename to src/core/repo.ts diff --git a/src/semver.ts b/src/core/semver.ts similarity index 100% rename from src/semver.ts rename to src/core/semver.ts diff --git a/src/core/types.ts b/src/core/types.ts new file mode 100644 index 00000000..d828e33c --- /dev/null +++ b/src/core/types.ts @@ -0,0 +1,10 @@ +/** + * The type of release being created as determined by the parent release. + * + * - An *ordinary* release includes features or fixes applied against the latest + * release and is designated by bumping the first part of that release's + * version string. + * - A *backport* release includes fixes applied against a previous release and + * is designated by bumping the second part of that release's version string. + */ +export type ReleaseType = 'ordinary' | 'backport'; diff --git a/src/workflow-operations.test.ts b/src/core/workflow-operations.test.ts similarity index 98% rename from src/workflow-operations.test.ts rename to src/core/workflow-operations.test.ts index 94e7e5cb..1a9ea963 100644 --- a/src/workflow-operations.test.ts +++ b/src/core/workflow-operations.test.ts @@ -2,7 +2,7 @@ import { when } from 'jest-when'; import * as repoModule from './repo.js'; import { createReleaseBranch } from './workflow-operations.js'; -import { buildMockProject } from '../tests/unit/helpers'; +import { buildMockProject } from '../../tests/unit/helpers'; jest.mock('./repo'); diff --git a/src/workflow-operations.ts b/src/core/workflow-operations.ts similarity index 97% rename from src/workflow-operations.ts rename to src/core/workflow-operations.ts index 96bbd9dc..9cb8980f 100644 --- a/src/workflow-operations.ts +++ b/src/core/workflow-operations.ts @@ -1,4 +1,3 @@ -import { ReleaseType } from './initial-parameters.js'; import { debug } from './misc-utils.js'; import { Project } from './project.js'; import { @@ -6,6 +5,7 @@ import { getCurrentBranchName, runGitCommandWithin, } from './repo.js'; +import { ReleaseType } from './types.js'; /** * Creates a new release branch in the given project repository based on the specified release type. diff --git a/src/yarn-commands.test.ts b/src/core/yarn-commands.test.ts similarity index 100% rename from src/yarn-commands.test.ts rename to src/core/yarn-commands.test.ts diff --git a/src/yarn-commands.ts b/src/core/yarn-commands.ts similarity index 100% rename from src/yarn-commands.ts rename to src/core/yarn-commands.ts diff --git a/src/dirname.ts b/src/dirname.ts deleted file mode 100644 index efdae826..00000000 --- a/src/dirname.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { dirname } from 'path'; -import { fileURLToPath } from 'url'; - -/** - * Get the current directory path. - * - * @returns The current directory path. - */ -export function getCurrentDirectoryPath(): string { - return dirname(fileURLToPath(import.meta.url)); -} diff --git a/src/editor.test.ts b/src/editor.test.ts deleted file mode 100644 index 177d352b..00000000 --- a/src/editor.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { when } from 'jest-when'; - -import { determineEditor } from './editor.js'; -import * as envModule from './env.js'; -import * as miscUtils from './misc-utils.js'; - -jest.mock('./env'); -jest.mock('./misc-utils'); - -describe('editor', () => { - describe('determineEditor', () => { - it('returns information about the editor from EDITOR if it resolves to an executable', async () => { - jest - .spyOn(envModule, 'getEnvironmentVariables') - .mockReturnValue({ EDITOR: 'editor' }); - 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(envModule, 'getEnvironmentVariables') - .mockReturnValue({ EDITOR: 'editor' }); - 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(envModule, 'getEnvironmentVariables') - .mockReturnValue({ EDITOR: 'editor' }); - 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(envModule, 'getEnvironmentVariables') - .mockReturnValue({ EDITOR: 'editor' }); - 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(envModule, 'getEnvironmentVariables') - .mockReturnValue({ EDITOR: 'editor' }); - 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(envModule, 'getEnvironmentVariables') - .mockReturnValue({ EDITOR: 'editor' }); - 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(envModule, 'getEnvironmentVariables') - .mockReturnValue({ EDITOR: 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(envModule, 'getEnvironmentVariables') - .mockReturnValue({ EDITOR: undefined }); - when(jest.spyOn(miscUtils, 'resolveExecutable')) - .calledWith('code') - .mockRejectedValue(new Error('some error')); - - expect(await determineEditor()).toBeNull(); - }); - }); -}); diff --git a/src/scripts/cli.ts b/src/scripts/cli.ts new file mode 100644 index 00000000..5ea40ef1 --- /dev/null +++ b/src/scripts/cli.ts @@ -0,0 +1,11 @@ +import { run } from '../cli/run.js'; + +run({ + argv: process.argv, + cwd: process.cwd(), + stdout: process.stdout, + stderr: process.stderr, +}).catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/src/ui/App.tsx b/src/ui/app/App.tsx similarity index 100% rename from src/ui/App.tsx rename to src/ui/app/App.tsx diff --git a/src/ui/DependencyErrorSection.tsx b/src/ui/app/DependencyErrorSection.tsx similarity index 100% rename from src/ui/DependencyErrorSection.tsx rename to src/ui/app/DependencyErrorSection.tsx diff --git a/src/ui/ErrorMessage.tsx b/src/ui/app/ErrorMessage.tsx similarity index 100% rename from src/ui/ErrorMessage.tsx rename to src/ui/app/ErrorMessage.tsx diff --git a/src/ui/Markdown.tsx b/src/ui/app/Markdown.tsx similarity index 100% rename from src/ui/Markdown.tsx rename to src/ui/app/Markdown.tsx diff --git a/src/ui/PackageItem.tsx b/src/ui/app/PackageItem.tsx similarity index 100% rename from src/ui/PackageItem.tsx rename to src/ui/app/PackageItem.tsx diff --git a/src/ui/VersionSelector.tsx b/src/ui/app/VersionSelector.tsx similarity index 100% rename from src/ui/VersionSelector.tsx rename to src/ui/app/VersionSelector.tsx diff --git a/src/ui/favicon.svg b/src/ui/app/favicon.svg similarity index 100% rename from src/ui/favicon.svg rename to src/ui/app/favicon.svg diff --git a/src/ui/index.html b/src/ui/app/index.html similarity index 100% rename from src/ui/index.html rename to src/ui/app/index.html diff --git a/src/ui/style.css b/src/ui/app/style.css similarity index 100% rename from src/ui/style.css rename to src/ui/app/style.css diff --git a/src/ui/types.ts b/src/ui/app/types.ts similarity index 100% rename from src/ui/types.ts rename to src/ui/app/types.ts diff --git a/src/ui.ts b/src/ui/start.ts similarity index 92% rename from src/ui.ts rename to src/ui/start.ts index 7ba71f7e..e82c5440 100644 --- a/src/ui.ts +++ b/src/ui/start.ts @@ -2,17 +2,17 @@ import { getErrorMessage } from '@metamask/utils'; import express, { static as expressStatic, json as expressJson } from 'express'; import type { WriteStream } from 'fs'; import open from 'open'; -import { join } from 'path'; +import path from 'path'; -import { getCurrentDirectoryPath } from './dirname.js'; -import { readFile } from './fs.js'; -import { Package } from './package.js'; +import { readFile } from '../core/fs.js'; +import { getRootDirectoryPath } from '../core/get-root-directory-path.js'; +import { Package } from '../core/package.js'; import { restoreChangelogsForSkippedPackages, updateChangelogsForChangedPackages, -} from './project.js'; -import type { Project } from './project.js'; -import { executeReleasePlan, planRelease } from './release-plan.js'; +} from '../core/project.js'; +import type { Project } from '../core/project.js'; +import { executeReleasePlan, planRelease } from '../core/release-plan.js'; import { findAllWorkspacePackagesThatDependOnPackage, findMissingUnreleasedDependenciesForRelease, @@ -20,17 +20,17 @@ import { IncrementableVersionParts, ReleaseSpecification, validateAllPackageEntries, -} from './release-specification.js'; -import { commitAllChanges } from './repo.js'; -import { SemVer, semver } from './semver.js'; -import { createReleaseBranch } from './workflow-operations.js'; +} from '../core/release-specification.js'; +import { commitAllChanges } from '../core/repo.js'; +import { SemVer, semver } from '../core/semver.js'; +import { createReleaseBranch } from '../core/workflow-operations.js'; import { deduplicateDependencies, fixConstraints, updateYarnLockfile, -} from './yarn-commands.js'; +} from '../core/yarn-commands.js'; -const UI_BUILD_DIR = join(getCurrentDirectoryPath(), 'ui'); +const UI_BUILD_DIR = path.join(getRootDirectoryPath(), 'ui', 'build'); /** * The set of options that can be used to start the UI. @@ -55,7 +55,7 @@ type UIOptions = { * @param options.stdout - The stdout stream. * @param options.stderr - The stderr stream. */ -export async function startUI({ +export async function start({ project, releaseType, defaultBranch, diff --git a/tests/functional/helpers/constants.ts b/tests/functional/helpers/constants.ts index c82e4c18..2d8fe017 100644 --- a/tests/functional/helpers/constants.ts +++ b/tests/functional/helpers/constants.ts @@ -4,8 +4,15 @@ const ROOT_DIR = path.resolve(__dirname, '../../..'); /** * The path to the entrypoint of the tool, locally. + * + * This needs to match `bin/create-release-branch.js`. */ -export const TOOL_EXECUTABLE_PATH = path.join(ROOT_DIR, 'src', 'cli.ts'); +export const TOOL_EXECUTABLE_PATH = path.join( + ROOT_DIR, + 'src', + 'scripts', + 'cli.ts', +); /** * The path to `tsx`, locally. diff --git a/tests/unit/helpers.ts b/tests/unit/helpers.ts index 9303d5eb..03e7f312 100644 --- a/tests/unit/helpers.ts +++ b/tests/unit/helpers.ts @@ -6,13 +6,13 @@ import { SemVer } from 'semver'; import { PackageManifestDependenciesFieldNames, PackageManifestFieldNames, -} from '../../src/package-manifest.js'; +} from '../../src/core/package-manifest.js'; import type { UnvalidatedPackageManifest, ValidatedPackageManifest, -} from '../../src/package-manifest.js'; -import type { Package } from '../../src/package.js'; -import type { Project } from '../../src/project.js'; +} from '../../src/core/package-manifest.js'; +import type { Package } from '../../src/core/package.js'; +import type { Project } from '../../src/core/project.js'; /** * Returns a version of the given record type where optionality is removed from diff --git a/vite.config.mjs b/vite.config.mjs index c40258ac..45a77963 100644 --- a/vite.config.mjs +++ b/vite.config.mjs @@ -4,9 +4,9 @@ import { defineConfig } from 'vite'; export default defineConfig({ plugins: [react(), tailwindcss()], - root: 'src/ui', + root: 'src/ui/app', build: { - outDir: '../../dist/ui', + outDir: '../../../dist/ui/build', emptyOutDir: true, }, }); diff --git a/yarn.lock b/yarn.lock index 3db3c4e6..3bc3a72d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2587,7 +2587,7 @@ __metadata: "@types/express": ^5.0.0 "@types/jest": ^29.5.10 "@types/jest-when": ^3.5.2 - "@types/node": ^17.0.23 + "@types/node": ^22.0.0 "@types/prettier": ^2.7.3 "@types/react": ^19.0.8 "@types/react-dom": ^19.0.3 @@ -3516,10 +3516,12 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:^17.0.23": - version: 17.0.45 - resolution: "@types/node@npm:17.0.45" - checksum: aa04366b9103b7d6cfd6b2ef64182e0eaa7d4462c3f817618486ea0422984c51fc69fd0d436eae6c9e696ddfdbec9ccaa27a917f7c2e8c75c5d57827fe3d95e8 +"@types/node@npm:^22.0.0": + version: 22.19.2 + resolution: "@types/node@npm:22.19.2" + dependencies: + undici-types: ~6.21.0 + checksum: 06e329d4a3c40ef8e675f92bd6b92a766f3fd66501cc23e021d9537a8a8c7ab5be987de2ae6b48e9405c5f9d58a58faedf1a280b1f8f73a2326a7cb85ffd39fd languageName: node linkType: hard @@ -10229,6 +10231,13 @@ __metadata: languageName: node linkType: hard +"undici-types@npm:~6.21.0": + version: 6.21.0 + resolution: "undici-types@npm:6.21.0" + checksum: 46331c7d6016bf85b3e8f20c159d62f5ae471aba1eb3dc52fff35a0259d58dcc7d592d4cc4f00c5f9243fa738a11cfa48bd20203040d4a9e6bc25e807fab7ab3 + languageName: node + linkType: hard + "unicode-canonical-property-names-ecmascript@npm:^2.0.0": version: 2.0.0 resolution: "unicode-canonical-property-names-ecmascript@npm:2.0.0"