fix: resolve review findings for community/custom module support

- Remove redundant CommunityModuleManager instantiation in UI display
- Remove dead customModulesMeta field from Config (never populated)
- Add 35 unit tests for CustomModuleManager and CommunityModuleManager
  pure functions: URL validation, normalization, search, featured, categories
This commit is contained in:
Brian Madison 2026-04-08 00:20:55 -05:00
parent 7c58c0bab1
commit cccc8218fb
3 changed files with 167 additions and 5 deletions

View File

@ -1723,6 +1723,171 @@ async function runTests() {
console.log(''); console.log('');
// ============================================================
// Test Suite 33: Community & Custom Module Managers
// ============================================================
console.log(`${colors.yellow}Test Suite 33: Community & Custom Module Managers${colors.reset}\n`);
// --- CustomModuleManager.validateGitHubUrl ---
{
const { CustomModuleManager } = require('../tools/installer/modules/custom-module-manager');
const mgr = new CustomModuleManager();
const https1 = mgr.validateGitHubUrl('https://github.com/owner/repo');
assert(https1.isValid === true, 'validateGitHubUrl accepts HTTPS URL');
assert(https1.owner === 'owner' && https1.repo === 'repo', 'validateGitHubUrl extracts owner/repo from HTTPS');
const https2 = mgr.validateGitHubUrl('https://github.com/owner/repo.git');
assert(https2.isValid === true, 'validateGitHubUrl accepts HTTPS URL with .git');
assert(https2.repo === 'repo', 'validateGitHubUrl strips .git suffix');
const ssh1 = mgr.validateGitHubUrl('git@github.com:owner/repo.git');
assert(ssh1.isValid === true, 'validateGitHubUrl accepts SSH URL');
assert(ssh1.owner === 'owner' && ssh1.repo === 'repo', 'validateGitHubUrl extracts owner/repo from SSH');
const bad1 = mgr.validateGitHubUrl('https://gitlab.com/owner/repo');
assert(bad1.isValid === false, 'validateGitHubUrl rejects non-GitHub URL');
const bad2 = mgr.validateGitHubUrl('');
assert(bad2.isValid === false, 'validateGitHubUrl rejects empty string');
const bad3 = mgr.validateGitHubUrl(null);
assert(bad3.isValid === false, 'validateGitHubUrl rejects null');
const bad4 = mgr.validateGitHubUrl('https://github.com/owner');
assert(bad4.isValid === false, 'validateGitHubUrl rejects URL without repo');
}
// --- CustomModuleManager._normalizeCustomModule ---
{
const { CustomModuleManager } = require('../tools/installer/modules/custom-module-manager');
const mgr = new CustomModuleManager();
const plugin = { name: 'test-plugin', description: 'A test', version: '1.0.0', author: 'tester', source: './src' };
const data = { owner: 'Fallback Owner' };
const result = mgr._normalizeCustomModule(plugin, 'https://github.com/o/r', data);
assert(result.code === 'test-plugin', 'normalizeCustomModule sets code from plugin name');
assert(result.type === 'custom', 'normalizeCustomModule sets type to custom');
assert(result.trustTier === 'unverified', 'normalizeCustomModule sets trustTier to unverified');
assert(result.version === '1.0.0', 'normalizeCustomModule preserves version');
assert(result.author === 'tester', 'normalizeCustomModule uses plugin author over data.owner');
const pluginNoAuthor = { name: 'x', description: '', version: null };
const result2 = mgr._normalizeCustomModule(pluginNoAuthor, 'https://github.com/o/r', data);
assert(result2.author === 'Fallback Owner', 'normalizeCustomModule falls back to data.owner');
}
// --- CommunityModuleManager._normalizeCommunityModule ---
{
const { CommunityModuleManager } = require('../tools/installer/modules/community-manager');
const mgr = new CommunityModuleManager();
const mod = {
name: 'test-mod',
display_name: 'Test Module',
code: 'tm',
description: 'desc',
repository: 'https://github.com/o/r',
module_definition: 'src/module.yaml',
category: 'software-development',
subcategory: 'dev-tools',
trust_tier: 'bmad-certified',
version: '2.0.0',
approved_sha: 'abc123',
promoted: true,
promoted_rank: 1,
keywords: ['test', 'module'],
};
const result = mgr._normalizeCommunityModule(mod);
assert(result.code === 'tm', 'normalizeCommunityModule sets code');
assert(result.displayName === 'Test Module', 'normalizeCommunityModule sets displayName from display_name');
assert(result.type === 'community', 'normalizeCommunityModule sets type to community');
assert(result.category === 'software-development', 'normalizeCommunityModule preserves category');
assert(result.trustTier === 'bmad-certified', 'normalizeCommunityModule maps trust_tier');
assert(result.approvedSha === 'abc123', 'normalizeCommunityModule maps approved_sha');
assert(result.promoted === true, 'normalizeCommunityModule maps promoted');
assert(result.promotedRank === 1, 'normalizeCommunityModule maps promoted_rank');
assert(result.builtIn === false, 'normalizeCommunityModule sets builtIn false');
}
// --- CommunityModuleManager.searchByKeyword (with injected cache) ---
{
const { CommunityModuleManager } = require('../tools/installer/modules/community-manager');
const mgr = new CommunityModuleManager();
// Inject cached index to avoid network call
mgr._cachedIndex = {
modules: [
{ name: 'mod-a', display_name: 'Alpha', code: 'a', description: 'testing tools', category: 'dev', keywords: ['test'] },
{ name: 'mod-b', display_name: 'Beta', code: 'b', description: 'design suite', category: 'design', keywords: ['ux'] },
{ name: 'mod-c', display_name: 'Gamma', code: 'c', description: 'game engine', category: 'game', keywords: ['unity'] },
],
};
const r1 = await mgr.searchByKeyword('test');
assert(r1.length === 1 && r1[0].code === 'a', 'searchByKeyword matches keyword');
const r2 = await mgr.searchByKeyword('design');
assert(r2.length === 1 && r2[0].code === 'b', 'searchByKeyword matches description');
const r3 = await mgr.searchByKeyword('alpha');
assert(r3.length === 1 && r3[0].code === 'a', 'searchByKeyword matches display name');
const r4 = await mgr.searchByKeyword('xyz');
assert(r4.length === 0, 'searchByKeyword returns empty for no match');
const r5 = await mgr.searchByKeyword('UNITY');
assert(r5.length === 1 && r5[0].code === 'c', 'searchByKeyword is case-insensitive');
}
// --- CommunityModuleManager.listFeatured (with injected cache) ---
{
const { CommunityModuleManager } = require('../tools/installer/modules/community-manager');
const mgr = new CommunityModuleManager();
mgr._cachedIndex = {
modules: [
{ name: 'a', code: 'a', promoted: true, promoted_rank: 3 },
{ name: 'b', code: 'b', promoted: false },
{ name: 'c', code: 'c', promoted: true, promoted_rank: 1 },
],
};
const featured = await mgr.listFeatured();
assert(featured.length === 2, 'listFeatured returns only promoted modules');
assert(featured[0].code === 'c' && featured[1].code === 'a', 'listFeatured sorts by promoted_rank ascending');
}
// --- CommunityModuleManager.getCategoryList (with injected cache) ---
{
const { CommunityModuleManager } = require('../tools/installer/modules/community-manager');
const mgr = new CommunityModuleManager();
mgr._cachedIndex = {
modules: [
{ name: 'a', code: 'a', category: 'software-development' },
{ name: 'b', code: 'b', category: 'design-and-creative' },
{ name: 'c', code: 'c', category: 'software-development' },
],
};
mgr._cachedCategories = {
categories: {
'software-development': { name: 'Software Development' },
'design-and-creative': { name: 'Design & Creative' },
},
};
const cats = await mgr.getCategoryList();
assert(cats.length === 2, 'getCategoryList returns categories with modules');
const swDev = cats.find((c) => c.slug === 'software-development');
assert(swDev && swDev.moduleCount === 2, 'getCategoryList counts modules per category');
assert(cats[0].name === 'Design & Creative', 'getCategoryList sorts alphabetically');
}
console.log('');
// ============================================================ // ============================================================
// Summary // Summary
// ============================================================ // ============================================================

View File

@ -3,7 +3,7 @@
* User input comes from either UI answers or headless CLI flags. * User input comes from either UI answers or headless CLI flags.
*/ */
class Config { class Config {
constructor({ directory, modules, ides, skipPrompts, verbose, actionType, coreConfig, moduleConfigs, quickUpdate, customModulesMeta }) { constructor({ directory, modules, ides, skipPrompts, verbose, actionType, coreConfig, moduleConfigs, quickUpdate }) {
this.directory = directory; this.directory = directory;
this.modules = Object.freeze([...modules]); this.modules = Object.freeze([...modules]);
this.ides = Object.freeze([...ides]); this.ides = Object.freeze([...ides]);
@ -13,7 +13,6 @@ class Config {
this.coreConfig = coreConfig; this.coreConfig = coreConfig;
this.moduleConfigs = moduleConfigs; this.moduleConfigs = moduleConfigs;
this._quickUpdate = quickUpdate; this._quickUpdate = quickUpdate;
this.customModulesMeta = Object.freeze(customModulesMeta || []);
Object.freeze(this); Object.freeze(this);
} }
@ -38,7 +37,6 @@ class Config {
coreConfig: userInput.coreConfig || {}, coreConfig: userInput.coreConfig || {},
moduleConfigs: userInput.moduleConfigs || null, moduleConfigs: userInput.moduleConfigs || null,
quickUpdate: userInput._quickUpdate || false, quickUpdate: userInput._quickUpdate || false,
customModulesMeta: userInput.customModulesMeta || [],
}); });
} }

View File

@ -786,10 +786,9 @@ class UI {
} }
if (selectedCodes.size > 0) { if (selectedCodes.size > 0) {
const communityMgrForDisplay = new (require('./modules/community-manager').CommunityModuleManager)();
const moduleLines = []; const moduleLines = [];
for (const code of selectedCodes) { for (const code of selectedCodes) {
const mod = await communityMgrForDisplay.getModuleByCode(code); const mod = await communityMgr.getModuleByCode(code);
moduleLines.push(` \u2022 ${mod?.displayName || code}`); moduleLines.push(` \u2022 ${mod?.displayName || code}`);
} }
await prompts.log.message('Selected community modules:\n' + moduleLines.join('\n')); await prompts.log.message('Selected community modules:\n' + moduleLines.join('\n'));