/** * bmad-module skill — parseSource / semver-lite / channel-resolver unit tests. * * Covers the skill's pure (no-network, no-filesystem) install plumbing so the * @ref / deep-path-URL / subdir parsing, the registry-free semver math, and the * channel resolver stay at parity with the canonical installer * (tools/installer/modules/custom-module-manager.js + channel-resolver.js, whose * own behavior is pinned by test/test-parse-source-urls.js and * test/test-installer-channels.js). * * Usage: node test/test-bmad-module-source.mjs */ import { parseSource } from '../src/core-skills/bmad-module/scripts/lib/source.mjs'; import { valid, prerelease, compare, rcompare, validRange } from '../src/core-skills/bmad-module/scripts/lib/semver-lite.mjs'; import { parseGitHubRepo, normalizeStableTag } from '../src/core-skills/bmad-module/scripts/lib/channel-resolver.mjs'; const colors = { reset: '', green: '', red: '', cyan: '', dim: '' }; let passed = 0; let failed = 0; function assert(cond, name, detail = '') { if (cond) { console.log(`${colors.green}✓${colors.reset} ${name}`); passed++; } else { console.log(`${colors.red}✗${colors.reset} ${name}`); if (detail) console.log(` ${colors.dim}${detail}${colors.reset}`); failed++; } } const eq = (got, want, name) => assert(JSON.stringify(got) === JSON.stringify(want), name, `got ${JSON.stringify(got)} want ${JSON.stringify(want)}`); function throws(fn, name) { try { fn(); assert(false, name, 'expected a throw'); } catch { assert(true, name); } } // ─── parseSource ──────────────────────────────────────────────────────────── console.log(`\n${colors.cyan}parseSource${colors.reset}\n`); { const r = parseSource('owner/repo'); eq( [r.kind, r.url, r.ref, r.subdir, r.cacheKey], ['git', 'https://github.com/owner/repo', null, null, 'github.com/owner/repo'], 'owner/repo shorthand → GitHub HTTPS', ); } { const r = parseSource('acme/devlog@v1.2.3'); eq([r.url, r.ref], ['https://github.com/acme/devlog', 'v1.2.3'], 'owner/repo@ref strips suffix'); } { const r = parseSource('https://github.com/owner/repo/tree/main/pkg/foo'); eq([r.url, r.ref, r.subdir], ['https://github.com/owner/repo', 'main', 'pkg/foo'], 'GitHub /tree//'); } { const r = parseSource('https://github.com/owner/repo/tree/main'); eq([r.url, r.ref, r.subdir], ['https://github.com/owner/repo', 'main', null], 'GitHub /tree/ without subdir strips ref'); } { const r = parseSource('https://github.com/owner/repo/blob/v2.0.0/src'); eq([r.url, r.ref, r.subdir], ['https://github.com/owner/repo', 'v2.0.0', 'src'], 'GitHub /blob//'); } { const r = parseSource('https://gitlab.com/group/subgroup/repo/-/tree/main/src/module'); eq([r.url, r.ref, r.subdir], ['https://gitlab.com/group/subgroup/repo', 'main', 'src/module'], 'GitLab nested-group /-/tree'); } { const r = parseSource('https://gitea.example.com/owner/repo/src/branch/main'); eq([r.url, r.subdir], ['https://gitea.example.com/owner/repo', null], 'Gitea /src/branch/ without subdir strips ref'); } { const r = parseSource('https://dev.azure.com/myorg/MyProject/_git/my-module?path=/src/skills'); eq( [r.url, r.subdir, r.cacheKey], ['https://dev.azure.com/myorg/MyProject/_git/my-module', 'src/skills', 'dev.azure.com/myorg/MyProject/_git/my-module'], 'Azure DevOps ?path= + full-path cacheKey', ); } { const r = parseSource('https://dev.azure.com/myorg/MyProject/_git/my-module.git'); eq(r.url, 'https://dev.azure.com/myorg/MyProject/_git/my-module', 'trailing .git stripped from cloneUrl'); } { const r = parseSource('git@github.com:owner/repo.git'); eq( [r.kind, r.url, r.cacheKey, r.displayName], ['git', 'git@github.com:owner/repo.git', 'github.com/owner/repo', 'owner/repo'], 'SSH URL preserved, @ not consumed', ); } { const r = parseSource('https://git.example.com/owner/my.repo.name'); eq([r.url, r.displayName], ['https://git.example.com/owner/my.repo.name', 'owner/my.repo.name'], 'dotted repo name preserved'); } { const r = parseSource('https://git.example.com/myorg/MyProject/_git/my-module'); eq( [r.cacheKey, r.displayName], ['git.example.com/myorg/MyProject/_git/my-module', '_git/my-module'], 'nested path cacheKey + last-two-segment displayName', ); } throws(() => parseSource('./local@v1'), 'local path + @ref throws'); throws(() => parseSource(' '), 'empty source throws'); throws(() => parseSource('not a source'), 'garbage source throws'); // ─── semver-lite ────────────────────────────────────────────────────────────── console.log(`\n${colors.cyan}semver-lite${colors.reset}\n`); eq(valid('v1.2.3'), '1.2.3', 'valid() strips leading v'); eq(valid('1.2'), null, 'valid() rejects partial'); eq(prerelease('1.0.0-rc.1'), ['rc', 1], 'prerelease() parses identifiers'); eq(prerelease('1.0.0'), null, 'prerelease() null for release'); eq(compare('1.0.0-alpha', '1.0.0'), -1, 'prerelease < release'); eq(compare('1.0.0-alpha.1', '1.0.0-alpha.beta'), -1, 'numeric id < alphanumeric id'); eq(compare('1.2.0', '1.10.0'), -1, 'numeric (not lexical) field compare'); eq(compare('2.0.0', '2.0.0'), 0, 'equal versions'); eq(compare('bad', '1.0.0'), null, 'compare() null on invalid'); { const tags = [ { tag: 'v1.0.0', version: '1.0.0' }, { tag: 'v1.7.0', version: '1.7.0' }, { tag: 'v1.2.0', version: '1.2.0' }, ]; tags.sort((a, b) => rcompare(a.version, b.version)); eq( tags.map((t) => t.tag), ['v1.7.0', 'v1.2.0', 'v1.0.0'], 'rcompare() sorts newest-first', ); } assert(validRange('>=6.0.0') !== null, 'validRange() accepts a real range'); // ─── channel-resolver (pure helpers) ────────────────────────────────────────── console.log(`\n${colors.cyan}channel-resolver${colors.reset}\n`); eq(parseGitHubRepo('https://github.com/o/r/tree/main'), { owner: 'o', repo: 'r' }, 'parseGitHubRepo from deep URL'); eq(parseGitHubRepo('git@github.com:o/r'), { owner: 'o', repo: 'r' }, 'parseGitHubRepo from SSH'); eq(parseGitHubRepo('https://gitlab.com/o/r'), null, 'parseGitHubRepo null for non-GitHub'); eq( [normalizeStableTag('v1.7.0'), normalizeStableTag('1.0.0-rc.1'), normalizeStableTag('nope')], ['1.7.0', null, null], 'normalizeStableTag excludes prereleases/invalid', ); // ─── Summary ────────────────────────────────────────────────────────────────── console.log(`\n${colors.cyan}Results: ${passed} passed, ${failed} failed${colors.reset}\n`); process.exit(failed > 0 ? 1 : 0);