diff --git a/packages/cli/test/add.test.ts b/packages/cli/test/add.test.ts new file mode 100644 index 00000000..02ecb594 --- /dev/null +++ b/packages/cli/test/add.test.ts @@ -0,0 +1,960 @@ +import { vi, describe, expect, it, beforeEach } from "vitest"; +import { add } from "../src/commands/add.js"; + +vi.mock("../src/utils/detect.js", () => ({ + detectProject: vi.fn(), +})); + +vi.mock("prompts", () => ({ + default: vi.fn(), +})); + +vi.mock("../src/utils/spinner.js", () => ({ + spinner: vi.fn(), +})); + +vi.mock("../src/utils/logger.js", () => ({ + logger: { + log: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + success: vi.fn(), + break: vi.fn(), + }, +})); + +vi.mock("../src/utils/install.js", () => ({ + getPackagesToInstall: vi.fn(), + getPackagesToUninstall: vi.fn(), + installPackages: vi.fn(), + uninstallPackages: vi.fn(), +})); + +vi.mock("../src/utils/transform.js", () => ({ + previewTransform: vi.fn(), + applyTransform: vi.fn(), + previewPackageJsonTransform: vi.fn(), + applyPackageJsonTransform: vi.fn(), + previewAgentRemoval: vi.fn(), + previewPackageJsonAgentRemoval: vi.fn(), +})); + +vi.mock("../src/utils/diff.js", () => ({ + printDiff: vi.fn(), +})); + +vi.mock("../src/utils/handle-error.js", () => ({ + handleError: vi.fn(), +})); + +import { detectProject } from "../src/utils/detect.js"; +import prompts from "prompts"; +import { spinner } from "../src/utils/spinner.js"; +import { logger } from "../src/utils/logger.js"; +import { + getPackagesToInstall, + getPackagesToUninstall, + installPackages, + uninstallPackages, +} from "../src/utils/install.js"; +import { + previewTransform, + applyTransform, + previewPackageJsonTransform, + applyPackageJsonTransform, + previewAgentRemoval, + previewPackageJsonAgentRemoval, +} from "../src/utils/transform.js"; +import { printDiff } from "../src/utils/diff.js"; +import { handleError } from "../src/utils/handle-error.js"; + +const mockDetectProject = vi.mocked(detectProject); +const mockPrompts = vi.mocked(prompts); +const mockSpinner = vi.mocked(spinner); +const mockGetPackagesToInstall = vi.mocked(getPackagesToInstall); +const mockGetPackagesToUninstall = vi.mocked(getPackagesToUninstall); +const mockInstallPackages = vi.mocked(installPackages); +const mockUninstallPackages = vi.mocked(uninstallPackages); +const mockPreviewTransform = vi.mocked(previewTransform); +const mockApplyTransform = vi.mocked(applyTransform); +const mockPreviewPackageJsonTransform = vi.mocked(previewPackageJsonTransform); +const mockApplyPackageJsonTransform = vi.mocked(applyPackageJsonTransform); +const mockPreviewAgentRemoval = vi.mocked(previewAgentRemoval); +const mockPreviewPackageJsonAgentRemoval = vi.mocked( + previewPackageJsonAgentRemoval, +); +const mockPrintDiff = vi.mocked(printDiff); +const mockHandleError = vi.mocked(handleError); + +const createMockSpinner = () => { + const mockInstance = { + start: vi.fn().mockReturnThis(), + succeed: vi.fn().mockReturnThis(), + fail: vi.fn().mockReturnThis(), + info: vi.fn().mockReturnThis(), + }; + return mockInstance; +}; + +const createMockProjectInfo = (overrides = {}) => ({ + packageManager: "npm" as const, + framework: "next" as const, + nextRouterType: "app" as const, + isMonorepo: false, + projectRoot: "/test/project", + hasReactGrab: true, + installedAgents: [] as string[], + unsupportedFramework: null, + ...overrides, +}); + +describe("add command", () => { + let consoleLogSpy: ReturnType; + let processExitSpy: ReturnType; + + beforeEach(() => { + vi.resetAllMocks(); + consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + processExitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit called"); + }); + + const spinnerInstance = createMockSpinner(); + mockSpinner.mockReturnValue(spinnerInstance); + }); + + describe("preflight checks", () => { + it("should fail when React Grab is not installed", async () => { + mockDetectProject.mockResolvedValue( + createMockProjectInfo({ hasReactGrab: false }), + ); + + await add.parseAsync(["cursor"], { from: "user" }); + + expect(mockDetectProject).toHaveBeenCalledWith(process.cwd()); + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining("react-grab init"), + ); + expect(processExitSpy).toHaveBeenCalledWith(1); + }); + + it("should succeed when React Grab is installed", async () => { + mockDetectProject.mockResolvedValue( + createMockProjectInfo({ + hasReactGrab: true, + installedAgents: ["cursor"], + }), + ); + mockPrompts.mockResolvedValueOnce({ agent: "opencode" }); + mockPrompts.mockResolvedValueOnce({ action: "cancel" }); + + try { + await add.parseAsync([], { from: "user" }); + } catch (error) { + // Expected: process.exit throws + } + + expect(processExitSpy).toHaveBeenCalledWith(0); + }); + }); + + describe("agent availability", () => { + it("should exit when all agents are already installed", async () => { + mockDetectProject.mockResolvedValue( + createMockProjectInfo({ + installedAgents: [ + "claude-code", + "cursor", + "opencode", + "codex", + "gemini", + "amp", + "visual-edit", + ], + }), + ); + + try { + await add.parseAsync([], { from: "user" }); + } catch (error) { + // Expected: process.exit throws + } + + expect(logger.success).toHaveBeenCalledWith( + "All agent integrations are already installed.", + ); + expect(processExitSpy).toHaveBeenCalledWith(0); + }); + + it("should filter out already installed agents from selection", async () => { + mockDetectProject.mockResolvedValue( + createMockProjectInfo({ + installedAgents: ["cursor", "opencode"], + }), + ); + mockPrompts.mockResolvedValueOnce({ + agent: "claude-code", + }); + mockPrompts.mockResolvedValueOnce({ action: "cancel" }); + + try { + await add.parseAsync([], { from: "user" }); + } catch (error) { + // Expected: process.exit throws + } + + expect(mockPrompts).toHaveBeenCalledWith( + expect.objectContaining({ + choices: expect.arrayContaining([ + expect.objectContaining({ value: "claude-code" }), + expect.objectContaining({ value: "codex" }), + ]), + }), + ); + }); + }); + + describe("agent argument validation", () => { + it("should error on invalid agent argument", async () => { + mockDetectProject.mockResolvedValue(createMockProjectInfo()); + + try { + await add.parseAsync(["invalid-agent"], { from: "user" }); + } catch (error) { + // Expected: process.exit throws + } + + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining("Invalid agent: invalid-agent"), + ); + expect(processExitSpy).toHaveBeenCalledWith(1); + }); + + it("should warn when agent is already installed", async () => { + mockDetectProject.mockResolvedValue( + createMockProjectInfo({ + installedAgents: ["cursor"], + }), + ); + + try { + await add.parseAsync(["cursor"], { from: "user" }); + } catch (error) { + // Expected: process.exit throws + } + + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining("Cursor is already installed"), + ); + expect(processExitSpy).toHaveBeenCalledWith(0); + }); + + it("should accept valid agent argument", async () => { + mockDetectProject.mockResolvedValue( + createMockProjectInfo({ + installedAgents: [], + }), + ); + mockPreviewTransform.mockReturnValue({ + success: true, + filePath: "/test/layout.tsx", + message: "Success", + noChanges: true, + }); + mockPreviewPackageJsonTransform.mockReturnValue({ + success: true, + filePath: "/test/package.json", + message: "Success", + noChanges: true, + }); + mockGetPackagesToInstall.mockReturnValue([]); + + await add.parseAsync(["cursor", "--yes"], { + from: "user", + }); + + expect(mockPreviewTransform).toHaveBeenCalledWith( + "/test/project", + "next", + "app", + "cursor", + true, + ); + }); + }); + + describe("interactive agent selection", () => { + it("should prompt for agent selection when no argument provided", async () => { + mockDetectProject.mockResolvedValue( + createMockProjectInfo({ + installedAgents: [], + }), + ); + mockPrompts.mockResolvedValueOnce({ agent: "cursor" }); + mockPrompts.mockResolvedValueOnce({ action: "cancel" }); + + try { + await add.parseAsync([], { from: "user" }); + } catch (error) { + // Expected: process.exit throws + } + + expect(mockPrompts).toHaveBeenCalledWith( + expect.objectContaining({ + type: "select", + name: "agent", + message: expect.stringContaining("agent integration"), + }), + ); + }); + + it("should exit when agent selection is cancelled", async () => { + mockDetectProject.mockResolvedValue( + createMockProjectInfo({ + installedAgents: [], + }), + ); + mockPrompts.mockResolvedValueOnce({ agent: undefined }); + + try { + await add.parseAsync([], { from: "user" }); + } catch (error) { + // Expected: process.exit throws + } + + expect(processExitSpy).toHaveBeenCalledWith(1); + }); + }); + + describe("non-interactive mode", () => { + it("should error when no agent argument provided in non-interactive mode", async () => { + mockDetectProject.mockResolvedValue( + createMockProjectInfo({ + installedAgents: [], + }), + ); + + try { + await add.parseAsync(["--yes"], { from: "user" }); + } catch (error) { + // Expected: process.exit throws + } + + expect(logger.error).toHaveBeenCalledWith( + "Please specify an agent to add.", + ); + expect(processExitSpy).toHaveBeenCalledWith(1); + }); + + it("should proceed without prompts in non-interactive mode with agent argument", async () => { + mockDetectProject.mockResolvedValue( + createMockProjectInfo({ + installedAgents: [], + }), + ); + mockPreviewTransform.mockReturnValue({ + success: true, + filePath: "/test/layout.tsx", + message: "Success", + noChanges: true, + }); + mockPreviewPackageJsonTransform.mockReturnValue({ + success: true, + filePath: "/test/package.json", + message: "Success", + noChanges: true, + }); + mockGetPackagesToInstall.mockReturnValue([]); + + await add.parseAsync(["cursor", "--yes"], { + from: "user", + }); + + expect(mockPrompts).not.toHaveBeenCalled(); + expect(logger.log).toHaveBeenCalledWith( + expect.stringContaining("Cursor has been added"), + ); + }); + }); + + describe("agent conflict resolution", () => { + it("should prompt to replace or add alongside existing agent", async () => { + mockDetectProject.mockResolvedValue( + createMockProjectInfo({ + installedAgents: ["opencode"], + }), + ); + mockPrompts.mockResolvedValueOnce({ action: "cancel" }); + + try { + await add.parseAsync(["cursor"], { from: "user" }); + } catch (error) { + // Expected: process.exit throws + } + + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining("OpenCode is already installed"), + ); + expect(mockPrompts).toHaveBeenCalledWith( + expect.objectContaining({ + type: "select", + name: "action", + message: "How would you like to proceed?", + choices: expect.arrayContaining([ + expect.objectContaining({ value: "replace" }), + expect.objectContaining({ value: "add" }), + expect.objectContaining({ value: "cancel" }), + ]), + }), + ); + }); + + it("should cancel when user selects cancel", async () => { + mockDetectProject.mockResolvedValue( + createMockProjectInfo({ + installedAgents: ["opencode"], + }), + ); + mockPrompts.mockResolvedValueOnce({ action: "cancel" }); + + try { + await add.parseAsync(["cursor"], { from: "user" }); + } catch (error) { + // Expected: process.exit throws + } + + expect(logger.log).toHaveBeenCalledWith("Changes cancelled."); + expect(processExitSpy).toHaveBeenCalledWith(0); + }); + + it("should remove existing agents when replace is selected", async () => { + mockDetectProject.mockResolvedValue( + createMockProjectInfo({ + installedAgents: ["opencode"], + }), + ); + mockPrompts.mockResolvedValueOnce({ action: "replace" }); + mockGetPackagesToUninstall.mockReturnValue(["@react-grab/opencode"]); + mockPreviewAgentRemoval.mockReturnValue({ + success: true, + filePath: "/test/layout.tsx", + message: "Removed", + originalContent: "old", + newContent: "new", + }); + mockPreviewPackageJsonAgentRemoval.mockReturnValue({ + success: true, + filePath: "/test/package.json", + message: "Removed", + originalContent: "old", + newContent: "new", + }); + mockApplyTransform.mockReturnValue({ success: true }); + mockApplyPackageJsonTransform.mockReturnValue({ success: true }); + mockPreviewTransform.mockReturnValue({ + success: true, + filePath: "/test/layout.tsx", + message: "Success", + noChanges: true, + }); + mockPreviewPackageJsonTransform.mockReturnValue({ + success: true, + filePath: "/test/package.json", + message: "Success", + noChanges: true, + }); + mockGetPackagesToInstall.mockReturnValue([]); + + await add.parseAsync(["cursor"], { from: "user" }); + + expect(mockGetPackagesToUninstall).toHaveBeenCalledWith("opencode"); + expect(mockUninstallPackages).toHaveBeenCalledWith( + ["@react-grab/opencode"], + "npm", + "/test/project", + ); + expect(mockPreviewAgentRemoval).toHaveBeenCalledWith( + "/test/project", + "next", + "app", + "opencode", + ); + }); + + it("should add alongside when add action is selected", async () => { + mockDetectProject.mockResolvedValue( + createMockProjectInfo({ + installedAgents: ["opencode"], + }), + ); + mockPrompts.mockResolvedValueOnce({ action: "add" }); + mockPrompts.mockResolvedValueOnce({ proceed: true }); + mockPreviewTransform.mockReturnValue({ + success: true, + filePath: "/test/layout.tsx", + message: "Success", + originalContent: "before", + newContent: "after", + }); + mockPreviewPackageJsonTransform.mockReturnValue({ + success: true, + filePath: "/test/package.json", + message: "Success", + originalContent: "before", + newContent: "after", + }); + mockGetPackagesToInstall.mockReturnValue(["@react-grab/cursor"]); + mockApplyTransform.mockReturnValue({ success: true }); + mockApplyPackageJsonTransform.mockReturnValue({ success: true }); + + await add.parseAsync(["cursor"], { from: "user" }); + + expect(mockGetPackagesToUninstall).not.toHaveBeenCalled(); + expect(mockUninstallPackages).not.toHaveBeenCalled(); + expect(mockPreviewAgentRemoval).not.toHaveBeenCalled(); + expect(mockInstallPackages).toHaveBeenCalledWith( + ["@react-grab/cursor"], + "npm", + "/test/project", + ); + }); + }); + + describe("package management", () => { + it("should install agent packages when adding agent", async () => { + mockDetectProject.mockResolvedValue( + createMockProjectInfo({ + installedAgents: [], + }), + ); + mockPreviewTransform.mockReturnValue({ + success: true, + filePath: "/test/layout.tsx", + message: "Success", + noChanges: true, + }); + mockPreviewPackageJsonTransform.mockReturnValue({ + success: true, + filePath: "/test/package.json", + message: "Success", + noChanges: true, + }); + mockGetPackagesToInstall.mockReturnValue(["@react-grab/cursor"]); + + await add.parseAsync(["cursor", "--yes"], { + from: "user", + }); + + expect(mockGetPackagesToInstall).toHaveBeenCalledWith("cursor", false); + expect(mockInstallPackages).toHaveBeenCalledWith( + ["@react-grab/cursor"], + "npm", + "/test/project", + ); + }); + + it("should handle package installation failure", async () => { + mockDetectProject.mockResolvedValue( + createMockProjectInfo({ + installedAgents: [], + }), + ); + mockPreviewTransform.mockReturnValue({ + success: true, + filePath: "/test/layout.tsx", + message: "Success", + noChanges: true, + }); + mockPreviewPackageJsonTransform.mockReturnValue({ + success: true, + filePath: "/test/package.json", + message: "Success", + noChanges: true, + }); + mockGetPackagesToInstall.mockReturnValue(["@react-grab/cursor"]); + const installError = new Error("Network error"); + mockInstallPackages.mockImplementation(() => { + throw installError; + }); + + await add.parseAsync(["cursor", "--yes"], { + from: "user", + }); + + expect(mockHandleError).toHaveBeenCalledWith(installError); + }); + }); + + describe("file transformations", () => { + it("should preview and apply layout transformations", async () => { + mockDetectProject.mockResolvedValue( + createMockProjectInfo({ + installedAgents: [], + }), + ); + mockPreviewTransform.mockReturnValue({ + success: true, + filePath: "/test/layout.tsx", + message: "Success", + originalContent: "before", + newContent: "after", + }); + mockPreviewPackageJsonTransform.mockReturnValue({ + success: true, + filePath: "/test/package.json", + message: "Success", + noChanges: true, + }); + mockApplyTransform.mockReturnValue({ success: true }); + mockGetPackagesToInstall.mockReturnValue([]); + + await add.parseAsync(["cursor", "--yes"], { + from: "user", + }); + + expect(mockPreviewTransform).toHaveBeenCalledWith( + "/test/project", + "next", + "app", + "cursor", + true, + ); + expect(mockApplyTransform).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + filePath: "/test/layout.tsx", + newContent: "after", + }), + ); + }); + + it("should preview and apply package.json transformations", async () => { + mockDetectProject.mockResolvedValue( + createMockProjectInfo({ + installedAgents: [], + }), + ); + mockPreviewTransform.mockReturnValue({ + success: true, + filePath: "/test/layout.tsx", + message: "Success", + noChanges: true, + }); + mockPreviewPackageJsonTransform.mockReturnValue({ + success: true, + filePath: "/test/package.json", + message: "Success", + originalContent: "before", + newContent: "after", + }); + mockApplyPackageJsonTransform.mockReturnValue({ success: true }); + mockGetPackagesToInstall.mockReturnValue([]); + + await add.parseAsync(["cursor", "--yes"], { + from: "user", + }); + + expect(mockPreviewPackageJsonTransform).toHaveBeenCalledWith( + "/test/project", + "cursor", + [], + "npm", + ); + expect(mockApplyPackageJsonTransform).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + filePath: "/test/package.json", + newContent: "after", + }), + ); + }); + + it("should exit when transform preview fails", async () => { + mockDetectProject.mockResolvedValue( + createMockProjectInfo({ + installedAgents: [], + }), + ); + mockPreviewTransform.mockReturnValue({ + success: false, + filePath: "", + message: "Layout file not found", + }); + + try { + await add.parseAsync(["cursor", "--yes"], { from: "user" }); + } catch (error) { + // Expected: process.exit throws + } + + expect(logger.error).toHaveBeenCalledWith("Layout file not found"); + expect(processExitSpy).toHaveBeenCalledWith(1); + }); + + it("should exit when apply transform fails", async () => { + mockDetectProject.mockResolvedValue( + createMockProjectInfo({ + installedAgents: [], + }), + ); + mockPreviewTransform.mockReturnValue({ + success: true, + filePath: "/test/layout.tsx", + message: "Success", + originalContent: "before", + newContent: "after", + }); + mockPreviewPackageJsonTransform.mockReturnValue({ + success: true, + filePath: "/test/package.json", + message: "Success", + noChanges: true, + }); + mockApplyTransform.mockReturnValue({ + success: false, + error: "Write permission denied", + }); + mockGetPackagesToInstall.mockReturnValue([]); + + try { + await add.parseAsync(["cursor", "--yes"], { from: "user" }); + } catch (error) { + // Expected: process.exit throws + } + + expect(logger.error).toHaveBeenCalledWith("Write permission denied"); + expect(processExitSpy).toHaveBeenCalledWith(1); + }); + }); + + describe("user confirmation", () => { + it("should show diff and prompt for confirmation when changes exist", async () => { + mockDetectProject.mockResolvedValue( + createMockProjectInfo({ + installedAgents: [], + }), + ); + mockPreviewTransform.mockReturnValue({ + success: true, + filePath: "/test/layout.tsx", + message: "Success", + originalContent: "before", + newContent: "after", + }); + mockPreviewPackageJsonTransform.mockReturnValue({ + success: true, + filePath: "/test/package.json", + message: "Success", + originalContent: "pkg before", + newContent: "pkg after", + }); + mockPrompts.mockResolvedValueOnce({ proceed: true }); + mockApplyTransform.mockReturnValue({ success: true }); + mockApplyPackageJsonTransform.mockReturnValue({ success: true }); + mockGetPackagesToInstall.mockReturnValue([]); + + await add.parseAsync(["cursor"], { from: "user" }); + + expect(mockPrintDiff).toHaveBeenCalledWith( + "/test/layout.tsx", + "before", + "after", + ); + expect(mockPrintDiff).toHaveBeenCalledWith( + "/test/package.json", + "pkg before", + "pkg after", + ); + expect(mockPrompts).toHaveBeenCalledWith( + expect.objectContaining({ + type: "confirm", + name: "proceed", + message: "Apply these changes?", + }), + ); + }); + + it("should cancel when user declines confirmation", async () => { + mockDetectProject.mockResolvedValue( + createMockProjectInfo({ + installedAgents: [], + }), + ); + mockPreviewTransform.mockReturnValue({ + success: true, + filePath: "/test/layout.tsx", + message: "Success", + originalContent: "before", + newContent: "after", + }); + mockPreviewPackageJsonTransform.mockReturnValue({ + success: true, + filePath: "/test/package.json", + message: "Success", + noChanges: true, + }); + mockPrompts.mockResolvedValueOnce({ proceed: false }); + + try { + await add.parseAsync(["cursor"], { from: "user" }); + } catch (error) { + // Expected: process.exit throws + } + + expect(logger.log).toHaveBeenCalledWith("Changes cancelled."); + expect(processExitSpy).toHaveBeenCalledWith(0); + }); + + it("should skip confirmation in non-interactive mode", async () => { + mockDetectProject.mockResolvedValue( + createMockProjectInfo({ + installedAgents: [], + }), + ); + mockPreviewTransform.mockReturnValue({ + success: true, + filePath: "/test/layout.tsx", + message: "Success", + originalContent: "before", + newContent: "after", + }); + mockPreviewPackageJsonTransform.mockReturnValue({ + success: true, + filePath: "/test/package.json", + message: "Success", + noChanges: true, + }); + mockApplyTransform.mockReturnValue({ success: true }); + mockGetPackagesToInstall.mockReturnValue([]); + + await add.parseAsync(["cursor", "--yes"], { + from: "user", + }); + + expect(mockPrompts).not.toHaveBeenCalledWith( + expect.objectContaining({ + name: "proceed", + }), + ); + }); + }); + + describe("complete success workflow", () => { + it("should successfully add agent with all steps", async () => { + mockDetectProject.mockResolvedValue( + createMockProjectInfo({ + installedAgents: [], + }), + ); + mockPreviewTransform.mockReturnValue({ + success: true, + filePath: "/test/layout.tsx", + message: "Success", + originalContent: "before", + newContent: "after", + }); + mockPreviewPackageJsonTransform.mockReturnValue({ + success: true, + filePath: "/test/package.json", + message: "Success", + originalContent: "pkg before", + newContent: "pkg after", + }); + mockGetPackagesToInstall.mockReturnValue(["@react-grab/cursor"]); + mockApplyTransform.mockReturnValue({ success: true }); + mockApplyPackageJsonTransform.mockReturnValue({ success: true }); + + await add.parseAsync(["cursor", "--yes"], { + from: "user", + }); + + expect(mockInstallPackages).toHaveBeenCalledWith( + ["@react-grab/cursor"], + "npm", + "/test/project", + ); + expect(mockApplyTransform).toHaveBeenCalled(); + expect(mockApplyPackageJsonTransform).toHaveBeenCalled(); + expect(logger.log).toHaveBeenCalledWith( + expect.stringContaining("Cursor has been added"), + ); + }); + + it("should display warning when package.json transform has warning", async () => { + mockDetectProject.mockResolvedValue( + createMockProjectInfo({ + installedAgents: [], + }), + ); + mockPreviewTransform.mockReturnValue({ + success: true, + filePath: "/test/layout.tsx", + message: "Success", + noChanges: true, + }); + mockPreviewPackageJsonTransform.mockReturnValue({ + success: true, + filePath: "/test/package.json", + message: "Success", + noChanges: true, + warning: "No dev script found. Run manually with: npx @react-grab/cursor", + }); + mockGetPackagesToInstall.mockReturnValue([]); + + await add.parseAsync(["cursor", "--yes"], { + from: "user", + }); + + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining("No dev script found"), + ); + }); + }); + + describe("custom working directory", () => { + it("should use custom cwd option", async () => { + const customCwd = "/custom/path"; + mockDetectProject.mockResolvedValue( + createMockProjectInfo({ + projectRoot: customCwd, + installedAgents: [], + }), + ); + mockPreviewTransform.mockReturnValue({ + success: true, + filePath: "/test/layout.tsx", + message: "Success", + noChanges: true, + }); + mockPreviewPackageJsonTransform.mockReturnValue({ + success: true, + filePath: "/test/package.json", + message: "Success", + noChanges: true, + }); + mockGetPackagesToInstall.mockReturnValue([]); + + await add.parseAsync( + ["cursor", "--yes", "--cwd", customCwd], + { + from: "user", + }, + ); + + expect(mockDetectProject).toHaveBeenCalledWith(customCwd); + }); + }); + + describe("error handling", () => { + it("should handle unexpected errors", async () => { + const testError = new Error("Unexpected error"); + mockDetectProject.mockRejectedValue(testError); + + await add.parseAsync(["cursor", "--yes"], { + from: "user", + }); + + expect(mockHandleError).toHaveBeenCalledWith(testError); + }); + }); +});