Alexander ef5f5ba0e6
fix(cli): install all 7 skills via uipro init, not just the orchestrator (#362) (#387)
* fix(cli): install all 7 skills via uipro init, not just the orchestrator

`uipro init` rendered only the orchestrator (ui-ux-pro-max) and never
delivered the 6 sibling skills (banner-design, brand, design,
design-system, slides, ui-styling), so users got 1 of 7 skills (#362).

- sync-assets.mjs: bundle the 6 sub-skills into cli/assets/skills/ as
  static copies (source of truth: .claude/skills/), with sync + check
  coverage. Excludes ui-styling/canvas-fonts (~5.8MB of TTF) and
  __pycache__/.pyc cruft — a skill registers from its SKILL.md, not its
  fonts — so the bundle adds ~0.9MB, not ~6.6MB.
- template.ts: after rendering the orchestrator, install each bundled
  sub-skill as a sibling. The skills parent is derived from the
  platform's skillPath (skills/ for most, prompts/ for copilot,
  steering/ for kiro) rather than hardcoded.
- uninstall.ts: remove the sub-skills too.

Verified: check:assets in sync, tsc passes, and a per-platform install
harness delivers all 7 skills to the correct parent dir with no fonts.

Closes #362

* fix(cli): filter excluded files from target side of check:assets

check:assets filtered sourceFiles with isExcludedAssetFile but not
targetFiles, so a stray cli/assets/scripts/__pycache__/*.pyc (generated
by a local Python run) was reported as an "extra asset file" and failed
the gate. Apply the same predicate to targetFiles in both the
dirsToSync and sub-skill loops.

Verified: check:assets now passes with __pycache__/*.pyc present in the
target tree; typecheck passes.

* fix(cli): uninstall from each platform's real skills dir, not hardcoded skills/

removeSkillDir() hardcoded <folder>/skills/<name>, but the installer
places skills under each platform config's skillPath parent — copilot in
.github/prompts/, kiro in .kiro/steering/. So uninstall left those
platforms' skills (orchestrator + sub-skills) behind.

Derive the install parent from loadPlatformConfig(aiType).folderStructure
(same source the installer uses), and keep the legacy <folder>/skills/
cleanup (incl. .shared/) for older installs. Deduped via a Set.

Verified: typecheck passes; an install+uninstall harness removes all 7
skills with zero leftovers for claude (.claude/skills), copilot
(.github/prompts) and kiro (.kiro/steering).

* fix(cli): re-sync bundled sub-skills after #385 stripped ckm- names

#385 merged to main and removed the ckm- prefix from the six
.claude/skills/*/SKILL.md name fields. This branch's bundled copies
under cli/assets/skills/ still carried the old ckm- names, so after the
PR merges with main the source no longer matched the bundle and the
check-asset-sync CI gate failed (stale asset file: skills/*/SKILL.md).

Merge main and regenerate the bundle so cli/assets/skills matches the
current .claude/skills source of truth. check:assets and typecheck pass.
2026-06-25 17:33:23 +07:00

267 lines
9.7 KiB
Python

"""Tests for shadcn_add.py"""
import json
import subprocess
from pathlib import Path
from unittest.mock import MagicMock, mock_open, patch
import pytest
# Add parent directory to path for imports
import sys
sys.path.insert(0, str(Path(__file__).parent.parent))
from shadcn_add import ShadcnInstaller
class TestShadcnInstaller:
"""Test ShadcnInstaller class."""
@pytest.fixture
def temp_project(self, tmp_path):
"""Create temporary project structure."""
project_root = tmp_path / "test-project"
project_root.mkdir()
# Create components.json
components_json = project_root / "components.json"
components_json.write_text(
json.dumps({
"style": "new-york",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"
}
})
)
# Create components directory
ui_dir = project_root / "components" / "ui"
ui_dir.mkdir(parents=True)
return project_root
def test_init_default_project_root(self):
"""Test initialization with default project root."""
installer = ShadcnInstaller()
assert installer.project_root == Path.cwd()
assert installer.dry_run is False
def test_init_custom_project_root(self, tmp_path):
"""Test initialization with custom project root."""
installer = ShadcnInstaller(project_root=tmp_path)
assert installer.project_root == tmp_path
def test_init_dry_run(self):
"""Test initialization with dry run mode."""
installer = ShadcnInstaller(dry_run=True)
assert installer.dry_run is True
def test_check_shadcn_config_exists(self, temp_project):
"""Test checking for existing shadcn config."""
installer = ShadcnInstaller(project_root=temp_project)
assert installer.check_shadcn_config() is True
def test_check_shadcn_config_not_exists(self, tmp_path):
"""Test checking for non-existent shadcn config."""
installer = ShadcnInstaller(project_root=tmp_path)
assert installer.check_shadcn_config() is False
def test_get_installed_components_empty(self, temp_project):
"""Test getting installed components when none exist."""
installer = ShadcnInstaller(project_root=temp_project)
installed = installer.get_installed_components()
assert installed == []
def test_get_installed_components_with_files(self, temp_project):
"""Test getting installed components when files exist."""
ui_dir = temp_project / "components" / "ui"
# Create component files
(ui_dir / "button.tsx").write_text("export const Button = () => {}")
(ui_dir / "card.tsx").write_text("export const Card = () => {}")
installer = ShadcnInstaller(project_root=temp_project)
installed = installer.get_installed_components()
assert sorted(installed) == ["button", "card"]
def test_get_installed_components_no_config(self, tmp_path):
"""Test getting installed components without config."""
installer = ShadcnInstaller(project_root=tmp_path)
installed = installer.get_installed_components()
assert installed == []
def test_add_components_no_components(self, temp_project):
"""Test adding components with empty list."""
installer = ShadcnInstaller(project_root=temp_project)
success, message = installer.add_components([])
assert success is False
assert "No components specified" in message
def test_add_components_no_config(self, tmp_path):
"""Test adding components without shadcn config."""
installer = ShadcnInstaller(project_root=tmp_path)
success, message = installer.add_components(["button"])
assert success is False
assert "not initialized" in message
def test_add_components_already_installed(self, temp_project):
"""Test adding components that are already installed."""
ui_dir = temp_project / "components" / "ui"
(ui_dir / "button.tsx").write_text("export const Button = () => {}")
installer = ShadcnInstaller(project_root=temp_project)
success, message = installer.add_components(["button"])
assert success is False
assert "already installed" in message
assert "button" in message
def test_add_components_with_overwrite(self, temp_project):
"""Test adding components with overwrite flag."""
ui_dir = temp_project / "components" / "ui"
(ui_dir / "button.tsx").write_text("export const Button = () => {}")
installer = ShadcnInstaller(project_root=temp_project)
with patch("subprocess.run") as mock_run:
mock_run.return_value = MagicMock(
stdout="Component added successfully",
returncode=0
)
success, message = installer.add_components(["button"], overwrite=True)
assert success is True
assert "Successfully added" in message
mock_run.assert_called_once()
# Verify --overwrite flag was passed
call_args = mock_run.call_args[0][0]
assert "--overwrite" in call_args
def test_add_components_dry_run(self, temp_project):
"""Test adding components in dry run mode."""
installer = ShadcnInstaller(project_root=temp_project, dry_run=True)
success, message = installer.add_components(["button", "card"])
assert success is True
assert "Would run:" in message
assert "button" in message
assert "card" in message
@patch("subprocess.run")
def test_add_components_success(self, mock_run, temp_project):
"""Test successful component addition."""
mock_run.return_value = MagicMock(
stdout="Components added successfully",
stderr="",
returncode=0
)
installer = ShadcnInstaller(project_root=temp_project)
success, message = installer.add_components(["button", "card"])
assert success is True
assert "Successfully added" in message
assert "button" in message
assert "card" in message
# Verify correct command was called
mock_run.assert_called_once()
call_args = mock_run.call_args[0][0]
assert call_args[:3] == ["npx", "shadcn@latest", "add"]
assert "button" in call_args
assert "card" in call_args
@patch("subprocess.run")
def test_add_components_subprocess_error(self, mock_run, temp_project):
"""Test component addition with subprocess error."""
mock_run.side_effect = subprocess.CalledProcessError(
1, "cmd", stderr="Error occurred"
)
installer = ShadcnInstaller(project_root=temp_project)
success, message = installer.add_components(["button"])
assert success is False
assert "Failed to add" in message
@patch("subprocess.run")
def test_add_components_npx_not_found(self, mock_run, temp_project):
"""Test component addition when npx is not found."""
mock_run.side_effect = FileNotFoundError()
installer = ShadcnInstaller(project_root=temp_project)
success, message = installer.add_components(["button"])
assert success is False
assert "npx not found" in message
def test_add_all_components_no_config(self, tmp_path):
"""Test adding all components without config."""
installer = ShadcnInstaller(project_root=tmp_path)
success, message = installer.add_all_components()
assert success is False
assert "not initialized" in message
def test_add_all_components_dry_run(self, temp_project):
"""Test adding all components in dry run mode."""
installer = ShadcnInstaller(project_root=temp_project, dry_run=True)
success, message = installer.add_all_components()
assert success is True
assert "Would run:" in message
assert "--all" in message
@patch("subprocess.run")
def test_add_all_components_success(self, mock_run, temp_project):
"""Test successful addition of all components."""
mock_run.return_value = MagicMock(
stdout="All components added",
returncode=0
)
installer = ShadcnInstaller(project_root=temp_project)
success, message = installer.add_all_components()
assert success is True
assert "Successfully added all" in message
# Verify --all flag was passed
call_args = mock_run.call_args[0][0]
assert "--all" in call_args
def test_list_installed_no_config(self, tmp_path):
"""Test listing installed components without config."""
installer = ShadcnInstaller(project_root=tmp_path)
success, message = installer.list_installed()
assert success is False
assert "not initialized" in message
def test_list_installed_empty(self, temp_project):
"""Test listing installed components when none exist."""
installer = ShadcnInstaller(project_root=temp_project)
success, message = installer.list_installed()
assert success is True
assert "No components installed" in message
def test_list_installed_with_components(self, temp_project):
"""Test listing installed components when they exist."""
ui_dir = temp_project / "components" / "ui"
(ui_dir / "button.tsx").write_text("export const Button = () => {}")
(ui_dir / "card.tsx").write_text("export const Card = () => {}")
installer = ShadcnInstaller(project_root=temp_project)
success, message = installer.list_installed()
assert success is True
assert "button" in message
assert "card" in message