/** * Tests for scripts/lib/statusline-render.js * * Run with: node tests/lib/statusline-render.test.js */ const assert = require('assert'); const fs = require('fs'); const os = require('os'); const path = require('path'); const { C, G, pctColor, buildBar, formatCountdown, formatMs, computeCacheStats, buildUsageLine, buildSessionLine, buildEccLine, readInstalledPlugins, getHooksSummary, } = require('../../scripts/lib/statusline-render'); function test(name, fn) { try { fn(); console.log(` \u2713 ${name}`); return true; } catch (err) { console.log(` \u2717 ${name}`); console.log(` Error: ${err.message}`); return false; } } function runTests() { console.log('\n=== Testing statusline-render.js ===\n'); let passed = 0; let failed = 0; const t = (name, fn) => (test(name, fn) ? passed++ : failed++); console.log('buildBar:'); t('0% renders all empty blocks', () => { assert.strictEqual(buildBar(0, 8), G.empty.repeat(8)); }); t('100% renders all full blocks', () => { assert.strictEqual(buildBar(100, 8), G.full.repeat(8)); }); t('50% renders half full', () => { assert.strictEqual(buildBar(50, 8), G.full.repeat(4) + G.empty.repeat(4)); }); t('clamps values beyond 100', () => { assert.strictEqual(buildBar(250, 4), G.full.repeat(4)); }); t('clamps negative values', () => { assert.strictEqual(buildBar(-10, 4), G.empty.repeat(4)); }); console.log('\nglyphSet:'); t('non-UTF-8 locale falls back to ASCII glyphs', () => { const { glyphSet, ASCII_GLYPHS } = require('../../scripts/lib/statusline-render'); assert.strictEqual(glyphSet({ LANG: 'C' }), ASCII_GLYPHS); assert.strictEqual(glyphSet({}), ASCII_GLYPHS); }); t('UTF-8 locale uses unicode glyphs', () => { const { glyphSet, UNICODE_GLYPHS } = require('../../scripts/lib/statusline-render'); assert.strictEqual(glyphSet({ LANG: 'en_US.UTF-8' }), UNICODE_GLYPHS); assert.strictEqual(glyphSet({ LC_ALL: 'C.utf8' }), UNICODE_GLYPHS); }); t('ECC_BAR_GLYPHS forces either set', () => { const { glyphSet, ASCII_GLYPHS, UNICODE_GLYPHS } = require('../../scripts/lib/statusline-render'); assert.strictEqual(glyphSet({ LANG: 'en_US.UTF-8', ECC_BAR_GLYPHS: 'ascii' }), ASCII_GLYPHS); assert.strictEqual(glyphSet({ LANG: 'C', ECC_BAR_GLYPHS: 'unicode' }), UNICODE_GLYPHS); }); console.log('\npctColor:'); t('below 60 uses brand color', () => { assert.strictEqual(pctColor(59, C.amber), C.amber); }); t('60-79 is yellow', () => { assert.strictEqual(pctColor(60), C.yellow); }); t('80-89 is orange', () => { assert.strictEqual(pctColor(80), C.orange); }); t('90+ is red', () => { assert.strictEqual(pctColor(95), C.red); }); console.log('\nformatCountdown:'); t('past timestamp returns empty', () => { assert.strictEqual(formatCountdown(Date.now() / 1000 - 60), ''); }); t('invalid input returns empty', () => { assert.strictEqual(formatCountdown(null), ''); assert.strictEqual(formatCountdown('soon'), ''); }); t('minutes-scale countdown ends with m', () => { const result = formatCountdown(Date.now() / 1000 + 42 * 60); assert.ok(/^\d+m$/.test(result), `got: ${result}`); }); t('hours-scale countdown contains h', () => { const result = formatCountdown(Date.now() / 1000 + 90 * 60); assert.ok(result.includes('h'), `got: ${result}`); }); t('days-scale countdown ends with d', () => { const result = formatCountdown(Date.now() / 1000 + 3 * 24 * 3600 + 60); assert.ok(/^\d+d$/.test(result), `got: ${result}`); }); console.log('\nformatMs:'); t('zero returns empty', () => { assert.strictEqual(formatMs(0), ''); }); t('seconds scale', () => { assert.strictEqual(formatMs(30 * 1000), '30s'); }); t('minutes scale', () => { assert.strictEqual(formatMs(5 * 60 * 1000), '5m'); }); t('hours scale with remainder', () => { assert.strictEqual(formatMs((3 * 60 + 11) * 60 * 1000), '3h11m'); }); console.log('\ncomputeCacheStats:'); t('null usage returns null', () => { assert.strictEqual(computeCacheStats(null), null); }); t('zero tokens returns null', () => { assert.strictEqual(computeCacheStats({ input_tokens: 0 }), null); }); t('computes hit percentage from cache reads', () => { const stats = computeCacheStats({ input_tokens: 10, cache_creation_input_tokens: 10, cache_read_input_tokens: 80, }); assert.strictEqual(stats.hitPct, 80); }); console.log('\nbuildUsageLine:'); t('no rate limits returns empty string', () => { assert.strictEqual(buildUsageLine(undefined, { hitPct: 90 }), ''); assert.strictEqual(buildUsageLine({}, null), ''); }); t('renders both windows with percentages', () => { const line = buildUsageLine({ five_hour: { used_percentage: 71, resets_at: Date.now() / 1000 + 3600 }, seven_day: { used_percentage: 23, resets_at: Date.now() / 1000 + 86400 * 3 }, }, null); assert.ok(line.includes('5h'), 'expected 5h label'); assert.ok(line.includes('7d'), 'expected 7d label'); assert.ok(line.includes('71%'), 'expected 5h percentage'); assert.ok(line.includes('23%'), 'expected 7d percentage'); assert.ok(line.includes(G.reset), 'expected reset countdown'); }); t('renders a single window when only one exists', () => { const line = buildUsageLine({ five_hour: { used_percentage: 5 } }, null); assert.ok(line.includes('5h'), 'expected 5h label'); assert.ok(!line.includes('7d'), 'no 7d label expected'); }); t('appends cache hit rate when bars exist', () => { const line = buildUsageLine({ five_hour: { used_percentage: 5 } }, { hitPct: 98 }); assert.ok(line.includes('cache'), 'expected cache segment'); assert.ok(line.includes('98%'), 'expected cache percentage'); }); console.log('\nbuildSessionLine:'); t('shows model with effort badge', () => { const line = buildSessionLine({ model: 'Opus 5', effort: 'xhigh' }); assert.ok(line.includes('Opus 5'), 'expected model name'); assert.ok(line.includes('xhigh'), 'expected effort badge'); }); t('default effort high shows no badge', () => { const line = buildSessionLine({ model: 'Opus 5', effort: 'high' }); assert.ok(!line.includes('high'), 'no badge expected for default effort'); }); t('shows cost, diff, and task', () => { const line = buildSessionLine({ model: 'Opus 5', costUsd: 1.234, linesAdded: 11, linesRemoved: 4, durationMs: 60 * 1000, task: 'Fixing auth bug', }); assert.ok(line.includes('$1.23'), 'expected cost'); assert.ok(line.includes('+11/-4'), 'expected diff stats'); assert.ok(line.includes('1m'), 'expected duration'); assert.ok(line.includes('Fixing auth bug'), 'expected task'); }); t('shows 1M context window size', () => { const line = buildSessionLine({ model: 'Opus 5', ctxBar: ' bar 10%', ctxWindowSize: 1000000 }); assert.ok(line.includes('1M'), 'expected 1M badge'); }); console.log('\nbuildEccLine:'); t('shows version, hooks profile, plugins, dirname', () => { const line = buildEccLine({ eccVersion: '2.2.0', hooks: { enabled: true, profile: 'standard', disabledCount: 0 }, plugins: [{ name: 'ecc', version: '2.2.0' }, { name: 'swift-lsp', version: '1.0.0' }], dirname: 'myproject', }); assert.ok(line.includes('ECC 2.2.0'), 'expected ECC version'); assert.ok(line.includes('hooks standard'), 'expected hooks profile'); assert.ok(line.includes('swift-lsp'), 'expected plugin name'); assert.ok(line.includes('1.0.0'), 'expected plugin version'); assert.ok(line.includes('myproject'), 'expected dirname'); }); t('disabled hooks render hooks off', () => { const line = buildEccLine({ hooks: { enabled: false, profile: 'standard', disabledCount: 0 } }); assert.ok(line.includes('hooks off'), 'expected hooks off'); }); t('disabled hook count is shown', () => { const line = buildEccLine({ hooks: { enabled: true, profile: 'minimal', disabledCount: 2 } }); assert.ok(line.includes('(2 off)'), 'expected disabled count'); }); t('caps plugin list at 4 with overflow count', () => { const plugins = ['a', 'b', 'c', 'd', 'e', 'f'].map(name => ({ name, version: '1.0.0' })); const line = buildEccLine({ plugins }); assert.ok(line.includes('+2'), 'expected +2 overflow'); }); console.log('\nreadInstalledPlugins:'); t('reads v2 plugin file and filters disabled plugins, ecc first', () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-render-test-')); try { fs.mkdirSync(path.join(tmpDir, 'plugins'), { recursive: true }); fs.writeFileSync(path.join(tmpDir, 'plugins', 'installed_plugins.json'), JSON.stringify({ version: 2, plugins: { 'zeta@market': [{ version: '0.5.0', scope: 'user' }], 'ecc@ecc': [{ version: '2.2.0', scope: 'user' }], 'off-plugin@market': [{ version: '9.9.9', scope: 'user' }], }, })); fs.writeFileSync(path.join(tmpDir, 'settings.json'), JSON.stringify({ enabledPlugins: { 'zeta@market': true, 'ecc@ecc': true, 'off-plugin@market': false }, })); const plugins = readInstalledPlugins(tmpDir); assert.strictEqual(plugins.length, 2); assert.strictEqual(plugins[0].name, 'ecc'); assert.strictEqual(plugins[0].version, '2.2.0'); assert.strictEqual(plugins[1].name, 'zeta'); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); } }); t('missing plugin file returns empty array', () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-render-test-')); try { assert.deepStrictEqual(readInstalledPlugins(tmpDir), []); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); } }); console.log('\ngetHooksSummary:'); t('defaults to enabled standard profile', () => { const summary = getHooksSummary({}); assert.strictEqual(summary.enabled, true); assert.strictEqual(summary.profile, 'standard'); assert.strictEqual(summary.disabledCount, 0); }); t('respects ECC_HOOKS_ENABLED=false', () => { assert.strictEqual(getHooksSummary({ ECC_HOOKS_ENABLED: 'false' }).enabled, false); }); t('reports profile and disabled hook count', () => { const summary = getHooksSummary({ ECC_HOOK_PROFILE: 'minimal', ECC_DISABLED_HOOKS: 'a,b', }); assert.strictEqual(summary.profile, 'minimal'); assert.strictEqual(summary.disabledCount, 2); }); console.log(`\nResults: Passed: ${passed}, Failed: ${failed}\n`); return { passed, failed }; } const { failed } = runTests(); process.exit(failed > 0 ? 1 : 0);