Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
233 changes: 233 additions & 0 deletions src/__tests__/add-design-comments.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof fs.pathExists>;
const mockExeca = execa as jest.MockedFunction<typeof execa>;
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<typeof execa>
>);
});

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<ReturnType<typeof execa>>;
});

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<ReturnType<typeof execa>>;
});
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<ReturnType<typeof execa>>;
});

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<ReturnType<typeof execa>>;
});

await expect(runAddDesignComments({ cwd })).rejects.toThrow('design-comments init failed');
expect(mockExeca).toHaveBeenCalledWith(
'npm',
['install', '@patternfly/design-comments'],
{ cwd, stdio: 'inherit' },
);
});
});
72 changes: 72 additions & 0 deletions src/add-design-comments.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<void> {
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',
);
}
23 changes: 23 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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')
Expand Down
Loading