diff --git a/src/__tests__/add-design-comments.test.ts b/src/__tests__/add-design-comments.test.ts new file mode 100644 index 0000000..36e2eac --- /dev/null +++ b/src/__tests__/add-design-comments.test.ts @@ -0,0 +1,233 @@ +jest.mock('fs-extra', () => ({ + __esModule: true, + default: { + pathExists: jest.fn(), + }, +})); + +jest.mock('execa', () => ({ + __esModule: true, + execa: jest.fn(), +})); + +jest.mock('../git-user-config.js', () => ({ + promptAndSetLocalGitUser: jest.fn(), +})); + +import path from 'path'; +import fs from 'fs-extra'; +import { execa } from 'execa'; +import { promptAndSetLocalGitUser } from '../git-user-config.js'; +import { runAddDesignComments } from '../add-design-comments.js'; + +const mockPathExists = fs.pathExists as jest.MockedFunction; +const mockExeca = execa as jest.MockedFunction; +const mockPromptAndSetLocalGitUser = promptAndSetLocalGitUser as jest.MockedFunction< + typeof promptAndSetLocalGitUser +>; + +const cwd = '/tmp/design-comments-project'; + +function mockPathExistsForProject(options: { hasPackageJson?: boolean; hasGit?: boolean; lockFile?: 'yarn' | 'pnpm' | 'none' }): void { + const { hasPackageJson = true, hasGit = true, lockFile = 'none' } = options; + mockPathExists.mockImplementation(async (p: string) => { + if (p === path.join(cwd, 'package.json')) return hasPackageJson; + if (p === path.join(cwd, '.git')) return hasGit; + if (p === path.join(cwd, 'yarn.lock')) return lockFile === 'yarn'; + if (p === path.join(cwd, 'pnpm-lock.yaml')) return lockFile === 'pnpm'; + return false; + }); +} + +describe('runAddDesignComments', () => { + const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + + beforeEach(() => { + jest.clearAllMocks(); + mockExeca.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 } as Awaited< + ReturnType + >); + }); + + afterAll(() => { + consoleLogSpy.mockRestore(); + }); + + it('throws when package.json is missing', async () => { + mockPathExistsForProject({ hasPackageJson: false }); + + await expect(runAddDesignComments({ cwd })).rejects.toThrow(/No package\.json found/); + expect(mockExeca).not.toHaveBeenCalled(); + }); + + it('initializes git when .git is missing', async () => { + mockPathExistsForProject({ hasGit: false }); + + await runAddDesignComments({ cwd }); + + expect(mockExeca).toHaveBeenCalledWith('git', ['init'], { stdio: 'inherit', cwd }); + expect(consoleLogSpy).toHaveBeenCalledWith('✅ Git repository initialized.\n'); + }); + + it('skips git init when .git already exists', async () => { + mockPathExistsForProject({ hasGit: true }); + + await runAddDesignComments({ cwd }); + + expect(mockExeca).not.toHaveBeenCalledWith('git', ['init'], expect.anything()); + }); + + it('installs with npm and runs design-comments init by default', async () => { + mockPathExistsForProject({ lockFile: 'none' }); + + await runAddDesignComments({ cwd }); + + expect(mockExeca).toHaveBeenCalledWith( + 'npm', + ['install', '@patternfly/design-comments'], + { cwd, stdio: 'inherit' }, + ); + expect(mockExeca).toHaveBeenCalledWith('npx', ['design-comments', 'init'], { + cwd, + stdio: 'inherit', + }); + }); + + it('uses yarn when yarn.lock is present', async () => { + mockPathExistsForProject({ lockFile: 'yarn' }); + + await runAddDesignComments({ cwd }); + + expect(mockExeca).toHaveBeenCalledWith( + 'yarn', + ['add', '@patternfly/design-comments'], + { cwd, stdio: 'inherit' }, + ); + }); + + it('uses pnpm when pnpm-lock.yaml is present', async () => { + mockPathExistsForProject({ lockFile: 'pnpm' }); + + await runAddDesignComments({ cwd }); + + expect(mockExeca).toHaveBeenCalledWith( + 'pnpm', + ['add', '@patternfly/design-comments'], + { cwd, stdio: 'inherit' }, + ); + }); + + it('prompts for local git user config when gitInit is true', async () => { + mockPathExistsForProject({}); + + await runAddDesignComments({ cwd, gitInit: true }); + + expect(mockPromptAndSetLocalGitUser).toHaveBeenCalledWith(cwd); + }); + + it('does not prompt for local git user config by default', async () => { + mockPathExistsForProject({}); + + await runAddDesignComments({ cwd }); + + expect(mockPromptAndSetLocalGitUser).not.toHaveBeenCalled(); + }); + + it('runs git init, install, and design-comments init in order', async () => { + mockPathExistsForProject({ hasGit: false, lockFile: 'none' }); + const callOrder: string[] = []; + mockExeca.mockImplementation(async (command, args) => { + if (command === 'git' && args?.[0] === 'init') callOrder.push('git-init'); + if (command === 'npm') callOrder.push('install'); + if (command === 'npx') callOrder.push('design-comments-init'); + return { stdout: '', stderr: '', exitCode: 0 } as Awaited>; + }); + + await runAddDesignComments({ cwd }); + + expect(callOrder).toEqual(['git-init', 'install', 'design-comments-init']); + }); + + it('runs git user config after git init when gitInit is true', async () => { + mockPathExistsForProject({ hasGit: false }); + const callOrder: string[] = []; + mockExeca.mockImplementation(async (command, args) => { + if (command === 'git' && args?.[0] === 'init') callOrder.push('git-init'); + if (command === 'npm') callOrder.push('install'); + return { stdout: '', stderr: '', exitCode: 0 } as Awaited>; + }); + mockPromptAndSetLocalGitUser.mockImplementation(async () => { + callOrder.push('git-user-config'); + }); + + await runAddDesignComments({ cwd, gitInit: true }); + + expect(callOrder).toEqual(['git-init', 'git-user-config', 'install']); + }); + + it('runs npx design-comments init after yarn install', async () => { + mockPathExistsForProject({ lockFile: 'yarn' }); + + await runAddDesignComments({ cwd }); + + const yarnIdx = mockExeca.mock.calls.findIndex(([cmd]) => cmd === 'yarn'); + const npxIdx = mockExeca.mock.calls.findIndex(([cmd]) => cmd === 'npx'); + expect(yarnIdx).toBeGreaterThan(-1); + expect(npxIdx).toBeGreaterThan(yarnIdx); + expect(mockExeca).toHaveBeenCalledWith('npx', ['design-comments', 'init'], { + cwd, + stdio: 'inherit', + }); + }); + + it('runs npx design-comments init after pnpm install', async () => { + mockPathExistsForProject({ lockFile: 'pnpm' }); + + await runAddDesignComments({ cwd }); + + const pnpmIdx = mockExeca.mock.calls.findIndex(([cmd]) => cmd === 'pnpm'); + const npxIdx = mockExeca.mock.calls.findIndex(([cmd]) => cmd === 'npx'); + expect(pnpmIdx).toBeGreaterThan(-1); + expect(npxIdx).toBeGreaterThan(pnpmIdx); + }); + + it('logs install and success messages', async () => { + mockPathExistsForProject({}); + + await runAddDesignComments({ cwd }); + + expect(consoleLogSpy).toHaveBeenCalledWith('📦 Installing @patternfly/design-comments...\n'); + expect(consoleLogSpy).toHaveBeenCalledWith('\n🔧 Running design-comments setup...\n'); + expect(consoleLogSpy).toHaveBeenCalledWith( + '\n✨ design-comments installed and integrated. Start your dev server to add comments on your UI.\n', + ); + }); + + it('propagates errors from package install', async () => { + mockPathExistsForProject({}); + const installError = new Error('npm install failed'); + mockExeca.mockImplementation(async (command) => { + if (command === 'npm') throw installError; + return { stdout: '', stderr: '', exitCode: 0 } as Awaited>; + }); + + await expect(runAddDesignComments({ cwd })).rejects.toThrow('npm install failed'); + expect(mockExeca).not.toHaveBeenCalledWith('npx', ['design-comments', 'init'], expect.anything()); + }); + + it('propagates errors from design-comments init', async () => { + mockPathExistsForProject({}); + const initError = new Error('design-comments init failed'); + mockExeca.mockImplementation(async (command) => { + if (command === 'npx') throw initError; + return { stdout: '', stderr: '', exitCode: 0 } as Awaited>; + }); + + await expect(runAddDesignComments({ cwd })).rejects.toThrow('design-comments init failed'); + expect(mockExeca).toHaveBeenCalledWith( + 'npm', + ['install', '@patternfly/design-comments'], + { cwd, stdio: 'inherit' }, + ); + }); +}); diff --git a/src/add-design-comments.ts b/src/add-design-comments.ts new file mode 100644 index 0000000..a7d50d4 --- /dev/null +++ b/src/add-design-comments.ts @@ -0,0 +1,72 @@ +import path from 'node:path'; +import { execa } from 'execa'; +import fs from 'fs-extra'; +import { promptAndSetLocalGitUser } from './git-user-config.js'; + +const DESIGN_COMMENTS_PACKAGE = '@patternfly/design-comments'; + +export type RunAddDesignCommentsOptions = { + /** Project root to install into. */ + cwd: string; + /** When true, prompt for git user.name and user.email and store them locally. */ + gitInit?: boolean; +}; + +async function getPackageManager(cwd: string): Promise<'yarn' | 'pnpm' | 'npm'> { + if (await fs.pathExists(path.join(cwd, 'yarn.lock'))) return 'yarn'; + if (await fs.pathExists(path.join(cwd, 'pnpm-lock.yaml'))) return 'pnpm'; + return 'npm'; +} + +function getInstallArgs(packageManager: 'yarn' | 'pnpm' | 'npm'): string[] { + switch (packageManager) { + case 'yarn': + return ['add', DESIGN_COMMENTS_PACKAGE]; + case 'pnpm': + return ['add', DESIGN_COMMENTS_PACKAGE]; + default: + return ['install', DESIGN_COMMENTS_PACKAGE]; + } +} + +async function ensureGitRepository(cwd: string): Promise { + const gitDir = path.join(cwd, '.git'); + if (!(await fs.pathExists(gitDir))) { + await execa('git', ['init'], { stdio: 'inherit', cwd }); + console.log('✅ Git repository initialized.\n'); + } +} + +/** + * Install @patternfly/design-comments and run its integration script so users can pin comments on UI elements. + */ +export async function runAddDesignComments(options: RunAddDesignCommentsOptions): Promise { + const { cwd, gitInit = false } = options; + + const pkgJsonPath = path.join(cwd, 'package.json'); + if (!(await fs.pathExists(pkgJsonPath))) { + throw new Error( + `No package.json found in ${cwd}.\n` + + 'Run this command from a Node.js project root (or create one with "patternfly-cli create").', + ); + } + + await ensureGitRepository(cwd); + + if (gitInit) { + await promptAndSetLocalGitUser(cwd); + } + + const packageManager = await getPackageManager(cwd); + const installArgs = getInstallArgs(packageManager); + + console.log(`📦 Installing ${DESIGN_COMMENTS_PACKAGE}...\n`); + await execa(packageManager, installArgs, { cwd, stdio: 'inherit' }); + + console.log('\n🔧 Running design-comments setup...\n'); + await execa('npx', ['design-comments', 'init'], { cwd, stdio: 'inherit' }); + + console.log( + '\n✨ design-comments installed and integrated. Start your dev server to add comments on your UI.\n', + ); +} diff --git a/src/cli.ts b/src/cli.ts index cd42bce..9ca6c51 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -13,6 +13,7 @@ import { runSave } from './save.js'; import { runLoad } from './load.js'; import { runDeployToGitHubPages } from './gh-pages.js'; import { runAddAiContext } from './add-ai-context.js'; +import { runAddDesignComments } from './add-design-comments.js'; import { readPackageVersion } from './read-package-version.js'; import { promptAndSetLocalGitUser } from './git-user-config.js'; import { runBumpPrerelease } from './bump-prerelease.js'; @@ -148,6 +149,28 @@ program console.log('\n✨ All updates completed successfully! ✨'); }); +/** Command to install and integrate @patternfly/design-comments */ +program + .command('add-design-comments') + .description( + 'Install @patternfly/design-comments and integrate the commenting overlay into a React project', + ) + .argument('[path]', 'Path to the project (defaults to current directory)') + .option('--git-init', 'Prompt for git user.name and user.email and store them locally for this repository') + .action(async (projectPath, options) => { + const cwd = projectPath ? path.resolve(projectPath) : process.cwd(); + try { + await runAddDesignComments({ cwd, gitInit: Boolean(options.gitInit) }); + } catch (error) { + if (error instanceof Error) { + console.error(`\n❌ ${error.message}\n`); + } else { + console.error(error); + } + process.exit(1); + } + }); + /** Command to run the PatternFly context-for-ai codemod (semantic data-* attributes) */ program .command('add-ai-context')