BMAD-METHOD/test/test-installer-channels.js

349 lines
17 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Installer Channel Resolution Tests
*
* Unit tests for the pure planning/resolution modules:
* - tools/installer/modules/channel-plan.js
* - tools/installer/modules/channel-resolver.js
*
* Neither module does I/O outside of GitHub tag lookups (which we don't
* exercise here) and semver math. All tests are deterministic.
*
* Usage: node test/test-installer-channels.js
*/
const {
parseChannelOptions,
decideChannelForModule,
buildPlan,
orphanPinWarnings,
bundledTargetWarnings,
parsePinSpec,
} = require('../tools/installer/modules/channel-plan');
const { parseGitHubRepo, normalizeStableTag, classifyUpgrade, releaseNotesUrl } = require('../tools/installer/modules/channel-resolver');
const colors = {
reset: '',
green: '',
red: '',
yellow: '',
cyan: '',
dim: '',
};
let passed = 0;
let failed = 0;
function assert(condition, testName, errorMessage = '') {
if (condition) {
console.log(`${colors.green}${colors.reset} ${testName}`);
passed++;
} else {
console.log(`${colors.red}${colors.reset} ${testName}`);
if (errorMessage) {
console.log(` ${colors.dim}${errorMessage}${colors.reset}`);
}
failed++;
}
}
function assertEqual(actual, expected, testName) {
const ok = actual === expected;
assert(ok, testName, ok ? '' : `expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
}
function section(title) {
console.log(`\n${colors.cyan}── ${title} ──${colors.reset}`);
}
function runTests() {
// ─────────────────────────────────────────────────────────────────────────
// channel-plan.js :: parsePinSpec
// ─────────────────────────────────────────────────────────────────────────
section('channel-plan :: parsePinSpec');
{
const r = parsePinSpec('bmb=v1.2.3');
assert(r && r.code === 'bmb' && r.tag === 'v1.2.3', 'valid CODE=TAG');
}
{
const r = parsePinSpec(' cis = v0.1.0 ');
assert(r && r.code === 'cis' && r.tag === 'v0.1.0', 'trims whitespace around code and tag');
}
assert(parsePinSpec('') === null, 'empty string returns null');
assert(parsePinSpec('bmb') === null, 'missing = returns null');
assert(parsePinSpec('=v1.0.0') === null, 'leading = returns null');
assert(parsePinSpec('bmb=') === null, 'trailing = returns null');
assert(parsePinSpec(null) === null, 'null input returns null');
let undef;
assert(parsePinSpec(undef) === null, 'undefined input returns null');
assert(parsePinSpec(42) === null, 'non-string input returns null');
// ─────────────────────────────────────────────────────────────────────────
// channel-plan.js :: parseChannelOptions
// ─────────────────────────────────────────────────────────────────────────
section('channel-plan :: parseChannelOptions');
{
const r = parseChannelOptions({});
assert(r.global === null, 'empty: global is null');
assert(r.nextSet instanceof Set && r.nextSet.size === 0, 'empty: nextSet is empty Set');
assert(r.pins instanceof Map && r.pins.size === 0, 'empty: pins is empty Map');
assert(Array.isArray(r.warnings) && r.warnings.length === 0, 'empty: no warnings');
assert(r.acceptBypass === false, 'empty: acceptBypass false by default');
}
{
const r = parseChannelOptions({ channel: 'stable' });
assertEqual(r.global, 'stable', '--channel=stable sets global');
}
{
const r = parseChannelOptions({ channel: 'NEXT' });
assertEqual(r.global, 'next', '--channel is case-insensitive');
}
{
const r = parseChannelOptions({ allStable: true });
assertEqual(r.global, 'stable', '--all-stable sets global stable');
}
{
const r = parseChannelOptions({ allNext: true });
assertEqual(r.global, 'next', '--all-next sets global next');
}
{
const r = parseChannelOptions({ channel: 'bogus' });
assert(r.global === null, 'invalid --channel value is rejected (global stays null)');
assert(
r.warnings.some((w) => w.includes("Ignoring invalid --channel value 'bogus'")),
'invalid --channel produces a warning',
);
}
{
// --all-stable and --all-next conflict → warning, first-wins
const r = parseChannelOptions({ allStable: true, allNext: true });
assertEqual(r.global, 'stable', 'conflict: first flag (--all-stable) wins');
assert(
r.warnings.some((w) => w.includes('Conflicting channel flags')),
'conflict produces warning',
);
}
{
const r = parseChannelOptions({ next: ['bmb', 'cis', ' '] });
assert(r.nextSet.has('bmb') && r.nextSet.has('cis'), '--next=CODE adds to nextSet');
assert(!r.nextSet.has(''), 'blank --next entries are skipped');
}
{
const r = parseChannelOptions({ pin: ['bmb=v1.0.0', 'cis=v2.0.0'] });
assertEqual(r.pins.get('bmb'), 'v1.0.0', '--pin bmb=v1.0.0 recorded');
assertEqual(r.pins.get('cis'), 'v2.0.0', '--pin cis=v2.0.0 recorded');
}
{
const r = parseChannelOptions({ pin: ['bmb=v1.0.0', 'bmb=v1.1.0'] });
assertEqual(r.pins.get('bmb'), 'v1.1.0', 'duplicate --pin: last wins');
assert(
r.warnings.some((w) => w.includes('--pin specified multiple times')),
'duplicate --pin produces warning',
);
}
{
const r = parseChannelOptions({ pin: ['malformed-no-equals'] });
assert(r.pins.size === 0, 'malformed --pin is ignored');
assert(
r.warnings.some((w) => w.includes('malformed --pin')),
'malformed --pin warns',
);
}
{
const r = parseChannelOptions({ yes: true });
assertEqual(r.acceptBypass, true, '--yes sets acceptBypass so curator-bypass prompt is auto-confirmed');
}
{
const r = parseChannelOptions({ acceptBypass: true });
assertEqual(r.acceptBypass, true, 'explicit acceptBypass: true honored');
}
// ─────────────────────────────────────────────────────────────────────────
// channel-plan.js :: decideChannelForModule (precedence)
// ─────────────────────────────────────────────────────────────────────────
section('channel-plan :: decideChannelForModule (precedence)');
const emptyOpts = parseChannelOptions({});
{
const r = decideChannelForModule({ code: 'bmb', channelOptions: emptyOpts });
assertEqual(r.channel, 'stable', 'no signal → stable default');
assertEqual(r.source, 'default', 'source: default');
}
{
const r = decideChannelForModule({ code: 'bmb', channelOptions: emptyOpts, registryDefault: 'next' });
assertEqual(r.channel, 'next', 'registry default applied when no flags');
assertEqual(r.source, 'registry', 'source: registry');
}
{
const r = decideChannelForModule({ code: 'bmb', channelOptions: emptyOpts, registryDefault: 'bogus' });
assertEqual(r.channel, 'stable', 'invalid registry default ignored, falls to stable');
}
{
const opts = parseChannelOptions({ channel: 'next' });
const r = decideChannelForModule({ code: 'bmb', channelOptions: opts, registryDefault: 'stable' });
assertEqual(r.channel, 'next', 'global --channel beats registry default');
assertEqual(r.source, 'flag:--channel', 'source reflects --channel origin');
}
{
const opts = parseChannelOptions({ channel: 'stable', next: ['bmb'] });
const r = decideChannelForModule({ code: 'bmb', channelOptions: opts });
assertEqual(r.channel, 'next', '--next=bmb beats --channel=stable for bmb');
assertEqual(r.source, 'flag:--next', 'source: flag:--next');
}
{
const opts = parseChannelOptions({ channel: 'next', pin: ['bmb=v1.0.0'] });
const r = decideChannelForModule({ code: 'bmb', channelOptions: opts });
assertEqual(r.channel, 'pinned', '--pin beats --channel');
assertEqual(r.pin, 'v1.0.0', 'pin value carried through');
assertEqual(r.source, 'flag:--pin', 'source: flag:--pin');
}
{
const opts = parseChannelOptions({ next: ['bmb'], pin: ['bmb=v1.0.0'] });
const r = decideChannelForModule({ code: 'bmb', channelOptions: opts });
assertEqual(r.channel, 'pinned', '--pin beats --next for same code');
}
// ─────────────────────────────────────────────────────────────────────────
// channel-plan.js :: buildPlan, orphanPinWarnings, bundledTargetWarnings
// ─────────────────────────────────────────────────────────────────────────
section('channel-plan :: buildPlan / warnings');
{
const opts = parseChannelOptions({ allStable: true, pin: ['bmb=v1.0.0'] });
const plan = buildPlan({
modules: [
{ code: 'bmb', defaultChannel: 'stable' },
{ code: 'cis', defaultChannel: 'stable' },
],
channelOptions: opts,
});
assertEqual(plan.get('bmb').channel, 'pinned', 'buildPlan: bmb pinned');
assertEqual(plan.get('cis').channel, 'stable', 'buildPlan: cis stable via global');
}
{
const opts = parseChannelOptions({ pin: ['ghost=v1.0.0', 'bmb=v1.0.0'], next: ['gds'] });
const warnings = orphanPinWarnings(opts, ['bmb']);
assert(
warnings.some((w) => w.includes("--pin for 'ghost'")),
'orphanPinWarnings: flags pin for unselected module',
);
assert(
warnings.some((w) => w.includes("--next for 'gds'")),
'orphanPinWarnings: flags --next for unselected module',
);
assert(!warnings.some((w) => w.includes("'bmb'")), 'orphanPinWarnings: no warning for selected module');
}
{
const opts = parseChannelOptions({ pin: ['bmm=v1.0.0'], next: ['core'] });
const warnings = bundledTargetWarnings(opts, ['core', 'bmm']);
assert(
warnings.some((w) => w.includes('bundled module')),
'bundledTargetWarnings: warns bundled pin',
);
assert(warnings.length === 2, 'bundledTargetWarnings: both pin and next warned');
}
// ─────────────────────────────────────────────────────────────────────────
// channel-resolver.js :: parseGitHubRepo
// ─────────────────────────────────────────────────────────────────────────
section('channel-resolver :: parseGitHubRepo');
{
const r = parseGitHubRepo('https://github.com/bmad-code-org/BMAD-METHOD');
assert(r && r.owner === 'bmad-code-org' && r.repo === 'BMAD-METHOD', 'https URL basic');
}
{
const r = parseGitHubRepo('https://github.com/bmad-code-org/BMAD-METHOD.git');
assert(r && r.repo === 'BMAD-METHOD', '.git suffix stripped');
}
{
const r = parseGitHubRepo('https://github.com/bmad-code-org/BMAD-METHOD/');
assert(r && r.repo === 'BMAD-METHOD', 'trailing slash stripped');
}
{
const r = parseGitHubRepo('https://github.com/org/repo/tree/main/subdir');
assert(r && r.owner === 'org' && r.repo === 'repo', 'deep path yields owner/repo');
}
{
const r = parseGitHubRepo('git@github.com:org/repo.git');
assert(r && r.owner === 'org' && r.repo === 'repo', 'SSH URL parsed');
}
assert(parseGitHubRepo('https://gitlab.com/foo/bar') === null, 'non-github URL returns null');
assert(parseGitHubRepo('') === null, 'empty string returns null');
assert(parseGitHubRepo(null) === null, 'null input returns null');
assert(parseGitHubRepo(123) === null, 'non-string input returns null');
// ─────────────────────────────────────────────────────────────────────────
// channel-resolver.js :: normalizeStableTag
// ─────────────────────────────────────────────────────────────────────────
section('channel-resolver :: normalizeStableTag');
assertEqual(normalizeStableTag('v1.2.3'), '1.2.3', 'strips leading v');
assertEqual(normalizeStableTag('1.2.3'), '1.2.3', 'bare semver accepted');
assertEqual(normalizeStableTag('v1.2.3-alpha.1'), null, 'prerelease -alpha excluded');
assertEqual(normalizeStableTag('v1.2.3-beta'), null, 'prerelease -beta excluded');
assertEqual(normalizeStableTag('v1.2.3-rc.1'), null, 'prerelease -rc excluded');
assertEqual(normalizeStableTag('not-a-version'), null, 'invalid string returns null');
assertEqual(normalizeStableTag('v1.2'), null, 'incomplete semver returns null');
assertEqual(normalizeStableTag(null), null, 'null returns null');
assertEqual(normalizeStableTag(123), null, 'non-string returns null');
// ─────────────────────────────────────────────────────────────────────────
// channel-resolver.js :: classifyUpgrade
// ─────────────────────────────────────────────────────────────────────────
section('channel-resolver :: classifyUpgrade');
assertEqual(classifyUpgrade('v1.2.3', 'v1.2.3'), 'none', 'equal versions → none');
assertEqual(classifyUpgrade('v1.2.3', 'v1.2.2'), 'none', 'downgrade → none');
assertEqual(classifyUpgrade('v1.2.3', 'v1.2.4'), 'patch', 'patch bump');
assertEqual(classifyUpgrade('v1.2.3', 'v1.3.0'), 'minor', 'minor bump');
assertEqual(classifyUpgrade('v1.2.3', 'v2.0.0'), 'major', 'major bump');
assertEqual(classifyUpgrade('1.2.3', '1.2.4'), 'patch', 'unprefixed versions work');
assertEqual(classifyUpgrade('main', 'v1.2.3'), 'unknown', 'non-semver current → unknown');
assertEqual(classifyUpgrade('v1.2.3', 'main'), 'unknown', 'non-semver next → unknown');
assertEqual(classifyUpgrade('', ''), 'unknown', 'both empty → unknown');
// ─────────────────────────────────────────────────────────────────────────
// channel-resolver.js :: releaseNotesUrl
// ─────────────────────────────────────────────────────────────────────────
section('channel-resolver :: releaseNotesUrl');
assertEqual(
releaseNotesUrl('https://github.com/bmad-code-org/BMAD-METHOD', 'v1.2.3'),
'https://github.com/bmad-code-org/BMAD-METHOD/releases/tag/v1.2.3',
'builds standard release URL',
);
assertEqual(releaseNotesUrl('https://gitlab.com/foo/bar', 'v1.0.0'), null, 'non-github repo → null');
assertEqual(releaseNotesUrl('https://github.com/foo/bar', null), null, 'null tag → null');
assertEqual(releaseNotesUrl('', 'v1.0.0'), null, 'empty URL → null');
// ─────────────────────────────────────────────────────────────────────────
// Summary
// ─────────────────────────────────────────────────────────────────────────
console.log('');
console.log(`${colors.cyan}========================================`);
console.log('Test Results:');
console.log(` Passed: ${colors.green}${passed}${colors.reset}`);
console.log(` Failed: ${colors.red}${failed}${colors.reset}`);
console.log(`========================================${colors.reset}\n`);
if (failed === 0) {
console.log(`${colors.green}✨ All channel resolution tests passed!${colors.reset}\n`);
process.exit(0);
} else {
console.log(`${colors.red}❌ Some channel resolution tests failed${colors.reset}\n`);
process.exit(1);
}
}
try {
runTests();
} catch (error) {
console.error(`${colors.red}Test runner failed:${colors.reset}`, error.message);
console.error(error.stack);
process.exit(1);
}