From 88c1d0942111e21c1193aaf5c10cd2b208fdf8ee Mon Sep 17 00:00:00 2001 From: Eric Engberg Date: Sun, 4 Oct 2026 22:45:55 -0500 Subject: [PATCH] test: cover renderer separators, ANSI edge cases and settings errors Powerline separator colors (same, single and missing backgrounds, both normal and inverted), caps on widgets without a background, bold resets at flex gaps and end caps, preserved command colors, and auto-align padding for merged groups had no tests, and neither did inherited and override colors on standard-mode separators and padding. These paths decide whether a status line keeps a continuous color band or shows gaps, wrong arrow colors and leaked bold. The ANSI parser gets direct tests for 8-bit C1 CSI/OSC, unterminated sequences, two-character escapes, flags, ZWJ emoji sequences and hyperlink closing on truncation, so widths and truncation stay right for custom command output. Claude settings gain tests for loadClaudeSettingsSync, refresh-interval reads and writes against unreadable settings, a failed .orig backup not blocking install, invalid ccstatusline configs not syncing hooks, and the getRemoteControlStatus manifest lookup. --- src/utils/__tests__/ansi.test.ts | 112 ++++++++ src/utils/__tests__/claude-settings.test.ts | 133 +++++++++ src/utils/__tests__/renderer-dim.test.ts | 41 +++ .../__tests__/renderer-flex-width.test.ts | 7 + .../__tests__/renderer-padding-side.test.ts | 53 ++++ .../renderer-powerline-separators.test.ts | 252 ++++++++++++++++++ .../renderer-separator-collapse.test.ts | 23 +- .../renderer-separator-colors.test.ts | 141 ++++++++++ 8 files changed, 761 insertions(+), 1 deletion(-) create mode 100644 src/utils/__tests__/ansi.test.ts create mode 100644 src/utils/__tests__/renderer-powerline-separators.test.ts create mode 100644 src/utils/__tests__/renderer-separator-colors.test.ts diff --git a/src/utils/__tests__/ansi.test.ts b/src/utils/__tests__/ansi.test.ts new file mode 100644 index 000000000..3b7c9f5e3 --- /dev/null +++ b/src/utils/__tests__/ansi.test.ts @@ -0,0 +1,112 @@ +import { + describe, + expect, + it +} from 'vitest'; + +import { + getVisibleText, + getVisibleWidth, + stripOscCodes, + truncateStyledText +} from '../ansi'; + +const OSC8_OPEN = '\x1b]8;;https://example.com\x1b\\'; +const OSC8_CLOSE = '\x1b]8;;\x1b\\'; +// 8-bit C1 forms: CSI (0x9b), OSC (0x9d) and String Terminator (0x9c). +const C1_OSC8_OPEN = '\x9d8;;https://example.com\x9c'; +const C1_OSC8_CLOSE = '\x9d8;;\x9c'; + +describe('escape sequence parsing', () => { + it('hides 8-bit C1 CSI and OSC sequences', () => { + expect(getVisibleText('\x9b31mred\x9b39m')).toBe('red'); + expect(getVisibleText(`${C1_OSC8_OPEN}link${C1_OSC8_CLOSE}`)).toBe('link'); + expect(getVisibleWidth(`\x9b1m${C1_OSC8_OPEN}link${C1_OSC8_CLOSE}\x9b22m`)).toBe(4); + }); + + it('strips 8-bit OSC sequences but keeps 8-bit CSI styling', () => { + expect(stripOscCodes(`\x9b31m${C1_OSC8_OPEN}link${C1_OSC8_CLOSE}`)).toBe('\x9b31mlink'); + }); + + it('strips non-hyperlink OSC sequences such as window titles', () => { + expect(stripOscCodes('\x1b]0;my title\x07\x1b[1mbold\x1b[22m')).toBe('\x1b[1mbold\x1b[22m'); + }); + + it('swallows a CSI sequence left unterminated at the end of the text', () => { + expect(getVisibleText('abc\x1b[38;5')).toBe('abc'); + expect(getVisibleWidth('abc\x9b38;5')).toBe(3); + }); + + it('swallows an OSC sequence left unterminated at the end of the text', () => { + expect(getVisibleText('abc\x1b]8;;https://example.com')).toBe('abc'); + expect(stripOscCodes('\x1b[1mabc\x1b]8;;https://example.com')).toBe('\x1b[1mabc'); + }); + + it('treats ESC followed by one other character as a two-character escape', () => { + // ESC 7 / ESC 8 are save/restore cursor. + expect(getVisibleText('a\x1b7b\x1b8c')).toBe('abc'); + expect(getVisibleWidth('a\x1b7b\x1b8c')).toBe(3); + }); + + it('swallows a lone ESC at the end of the text', () => { + expect(getVisibleText('ab\x1b')).toBe('ab'); + expect(getVisibleWidth('ab\x1b')).toBe(2); + }); +}); + +describe('display width of grapheme clusters', () => { + it('counts each flag (a regional indicator pair) as one two-column cluster', () => { + expect(getVisibleWidth('πŸ‡ΊπŸ‡Έ')).toBe(2); + expect(getVisibleWidth('πŸ‡ΊπŸ‡ΈπŸ‡«πŸ‡·')).toBe(4); + }); + + it('counts a ZWJ emoji sequence as one two-column cluster', () => { + expect(getVisibleWidth('πŸ‘¨\u200dπŸ‘©\u200dπŸ‘§')).toBe(2); + expect(getVisibleWidth('πŸ‘¨\u200dπŸ‘©\u200dπŸ‘§ ok')).toBe(5); + }); + + it('tolerates a ZWJ dangling at the end of the text', () => { + expect(getVisibleWidth('ok\u200d')).toBe(2); + }); + + it('gives zero width to combining marks and variation selectors with no base character', () => { + expect(getVisibleWidth('\u0301abc')).toBe(3); + expect(getVisibleWidth('\ufe0f')).toBe(0); + expect(getVisibleWidth('\u200dabc')).toBe(3); + }); +}); + +describe('truncateStyledText', () => { + it('returns an empty string when there is no room at all', () => { + expect(truncateStyledText('abc', 0)).toBe(''); + expect(truncateStyledText('abc', -1)).toBe(''); + }); + + it('never splits a flag in half', () => { + expect(truncateStyledText('πŸ‡ΊπŸ‡ΈπŸ‡«πŸ‡·', 3, { ellipsis: false })).toBe('πŸ‡ΊπŸ‡Έ'); + }); + + it('never splits a ZWJ emoji sequence', () => { + expect(truncateStyledText('πŸ‘¨\u200dπŸ‘©\u200dπŸ‘§ family', 4, { ellipsis: false })).toBe('πŸ‘¨\u200dπŸ‘©\u200dπŸ‘§ f'); + expect(truncateStyledText('aπŸ‘¨\u200dπŸ‘©\u200dπŸ‘§', 2, { ellipsis: false })).toBe('a'); + }); + + it('does not add a hyperlink close when the link already closed before the cut', () => { + const text = `${OSC8_OPEN}ab${OSC8_CLOSE}cdefgh`; + + expect(truncateStyledText(text, 5)).toBe(`${OSC8_OPEN}ab${OSC8_CLOSE}...`); + }); + + it('closes a hyperlink opened with a C1 string terminator', () => { + const text = '\x1b]8;;https://example.com\x9clong-link-text'; + + expect(truncateStyledText(text, 6)).toBe(`\x1b]8;;https://example.com\x9clon${OSC8_CLOSE}...`); + }); + + it('only treats well-formed OSC 8 sequences as open hyperlinks', () => { + // An OSC 8 with no URL field, and a window-title OSC, are passed + // through as-is and must not trigger an extra hyperlink close. + expect(truncateStyledText('\x1b]8;\x07abcdefgh', 5)).toBe('\x1b]8;\x07ab...'); + expect(truncateStyledText('\x1b]0;title\x07abcdefgh', 5)).toBe('\x1b]0;title\x07ab...'); + }); +}); diff --git a/src/utils/__tests__/claude-settings.test.ts b/src/utils/__tests__/claude-settings.test.ts index 0630ba2ab..9eda67c6f 100644 --- a/src/utils/__tests__/claude-settings.test.ts +++ b/src/utils/__tests__/claude-settings.test.ts @@ -22,6 +22,7 @@ import { getClaudeSettingsPath, getExistingStatusLine, getRefreshInterval, + getRemoteControlStatus, getSandboxConfig, getVoiceConfig, installStatusLine, @@ -29,6 +30,7 @@ import { isInstalled, isKnownCommand, loadClaudeSettings, + loadClaudeSettingsSync, saveClaudeSettings, setRefreshInterval, uninstallStatusLine @@ -420,6 +422,26 @@ describe('refreshInterval', () => { const settings = await loadClaudeSettings(); expect(settings.statusLine).toBeUndefined(); }); + + it('getRefreshInterval should return null without logging when settings cannot be loaded', async () => { + writeRawClaudeSettings('{ invalid json'); + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + await expect(getRefreshInterval()).resolves.toBeNull(); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + }); + + it('setRefreshInterval should leave unreadable settings untouched', async () => { + writeRawClaudeSettings('{ invalid json'); + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + await setRefreshInterval(10); + + const settingsPath = getClaudeSettingsPath(); + expect(fs.readFileSync(settingsPath, 'utf-8')).toBe('{ invalid json'); + expect(fs.existsSync(`${settingsPath}.bak`)).toBe(false); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + }); }); describe('backup and error handling behavior', () => { @@ -612,6 +634,68 @@ describe('backup and error handling behavior', () => { await expect(isInstalled()).resolves.toBe(true); }); + + it('installStatusLine should log and still install when the .orig backup cannot be written', async () => { + writeRawClaudeSettings(JSON.stringify({ effortLevel: 'high' })); + const settingsPath = getClaudeSettingsPath(); + // A directory where the backup file should go makes the backup write fail. + fs.mkdirSync(`${settingsPath}.orig`); + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + await installStatusLine({ commandMode: 'auto-npx' }); + + const installed = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { effortLevel?: string; statusLine?: { command?: string } }; + expect(installed.statusLine?.command).toBe(buildStatusLineCommand('auto-npx')); + expect(installed.effortLevel).toBe('high'); + expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to backup Claude settings:', expect.anything()); + }); + + it.each([ + { + name: 'fails validation', + content: JSON.stringify({ flexMode: 'not-a-mode', lines: [[{ id: 'skills-1', type: 'skills' }]] }) + }, + { + name: 'is not valid JSON', + content: '{ "lines": [[{ "id": "skills-1", "type": "skills" }]]' + } + ])('installStatusLine should install without syncing hooks when the ccstatusline config $name', async ({ content }) => { + fs.writeFileSync(config.getConfigPath(), content, 'utf-8'); + + await installStatusLine({ commandMode: 'auto-npx' }); + + const claudeSettings = await loadClaudeSettings(); + expect(claudeSettings.statusLine?.command).toBe(buildStatusLineCommand('auto-npx')); + expect(claudeSettings.hooks).toBeUndefined(); + }); +}); + +describe('loadClaudeSettingsSync', () => { + it('should return an empty object when the settings file is missing', () => { + expect(loadClaudeSettingsSync()).toEqual({}); + }); + + it('should parse the settings file', () => { + writeRawClaudeSettings(JSON.stringify({ statusLine: { type: 'command', command: 'my-command', padding: 0 } })); + + expect(loadClaudeSettingsSync()).toEqual({ statusLine: { type: 'command', command: 'my-command', padding: 0 } }); + }); + + it('should log and throw when the settings file is invalid JSON', () => { + writeRawClaudeSettings('{ invalid json'); + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + expect(() => loadClaudeSettingsSync()).toThrow(SyntaxError); + expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to load Claude settings:', expect.anything()); + }); + + it('should throw without logging when logErrors is false', () => { + writeRawClaudeSettings('{ invalid json'); + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + expect(() => loadClaudeSettingsSync({ logErrors: false })).toThrow(SyntaxError); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + }); }); describe('getClaudeCodeVersion', () => { @@ -923,3 +1007,52 @@ describe('getSandboxConfig', () => { expect(getSandboxConfig(testSandboxProjectDir)).toEqual({ enabled: true }); }); }); + +describe('getRemoteControlStatus', () => { + function writeSessionFile(name: string, content: string): void { + const sessionsDir = path.join(testClaudeConfigDir, 'sessions'); + fs.mkdirSync(sessionsDir, { recursive: true }); + fs.writeFileSync(path.join(sessionsDir, name), content, 'utf-8'); + } + + it('returns null without a session id', () => { + writeSessionFile('1.json', JSON.stringify({ sessionId: '', bridgeSessionId: 'bridge-1' })); + + expect(getRemoteControlStatus(undefined)).toBeNull(); + expect(getRemoteControlStatus('')).toBeNull(); + }); + + it('returns null when the sessions directory does not exist', () => { + expect(getRemoteControlStatus('session-a')).toBeNull(); + }); + + it('returns { enabled: true } when the matching session has a bridge attached', () => { + writeSessionFile('100.json', JSON.stringify({ sessionId: 'session-b', bridgeSessionId: 'bridge-b' })); + writeSessionFile('200.json', JSON.stringify({ sessionId: 'session-a', bridgeSessionId: 'bridge-a' })); + + expect(getRemoteControlStatus('session-a')).toEqual({ enabled: true }); + }); + + it.each([ + { name: 'null', manifest: { sessionId: 'session-a', bridgeSessionId: null } }, + { name: 'empty', manifest: { sessionId: 'session-a', bridgeSessionId: '' } }, + { name: 'missing', manifest: { sessionId: 'session-a' } } + ])('returns { enabled: false } when the matching session\'s bridge id is $name', ({ manifest }) => { + writeSessionFile('100.json', JSON.stringify(manifest)); + + expect(getRemoteControlStatus('session-a')).toEqual({ enabled: false }); + }); + + it('returns null when no readable, well-formed manifest matches the session', () => { + const matching = JSON.stringify({ sessionId: 'session-a', bridgeSessionId: 'bridge-a' }); + // Only .json files are manifests. + writeSessionFile('notes.txt', matching); + // Unreadable as a file. + fs.mkdirSync(path.join(testClaudeConfigDir, 'sessions', 'dir.json')); + writeSessionFile('broken.json', '{ "sessionId": "session-a"'); + writeSessionFile('wrong-shape.json', JSON.stringify({ sessionId: 'session-a', bridgeSessionId: 7 })); + writeSessionFile('other.json', JSON.stringify({ sessionId: 'session-b', bridgeSessionId: 'bridge-b' })); + + expect(getRemoteControlStatus('session-a')).toBeNull(); + }); +}); diff --git a/src/utils/__tests__/renderer-dim.test.ts b/src/utils/__tests__/renderer-dim.test.ts index 57293f5a9..e1cec6ba5 100644 --- a/src/utils/__tests__/renderer-dim.test.ts +++ b/src/utils/__tests__/renderer-dim.test.ts @@ -258,6 +258,47 @@ describe('renderer dim styling', () => { expect(resetAfterMidIndex).toBeLessThan(tailIndex); }); + it('ends dim but keeps bold through the powerline separator after a bold, dimmed widget', () => { + // Hex colors produce raw codes, independent of chalk's color detection. + const fg = '\x1b[38;2;236;239;244m'; + const colorReset = '\x1b[49m\x1b[39m'; + const widgets: WidgetItem[] = [ + { + id: 'w1', + type: 'custom-text', + customText: 'head', + color: 'hex:ECEFF4', + bold: true, + dim: true + }, + { + id: 'w2', + type: 'custom-text', + customText: 'tail', + color: 'hex:ECEFF4' + } + ]; + + const line = renderLine(widgets, { + terminalWidth: 200, + settings: { + colorLevel: 3, + powerline: { + ...DEFAULT_SETTINGS.powerline, + enabled: true, + separators: ['\uE0B0'], + separatorInvertBackground: [false] + } + } + }); + + expect(line).toBe( + `${BOLD}${DIM}${fg}head${colorReset}${INTENSITY_RESET_BOLD}` + + `\uE0B0${INTENSITY_RESET}` + + `${fg}tail${colorReset}` + ); + }); + it('dims parens spans in powerline mode', () => { const widgets: WidgetItem[] = [ { diff --git a/src/utils/__tests__/renderer-flex-width.test.ts b/src/utils/__tests__/renderer-flex-width.test.ts index 25d949151..0b25a686f 100644 --- a/src/utils/__tests__/renderer-flex-width.test.ts +++ b/src/utils/__tests__/renderer-flex-width.test.ts @@ -100,6 +100,13 @@ describe('renderer flex width behavior', () => { expect(line.endsWith('...')).toBe(false); }); + it('uses full-minus-40 width in preview mode, matching the real render', () => { + const line = renderLine([longTextWidget], { flexMode: 'full-minus-40' }, { isPreview: true }); + + expect(getVisibleWidth(line)).toBe(10); + expect(line.endsWith('...')).toBe(true); + }); + it('applies the same width behavior in powerline mode', () => { const line = renderLine([{ ...longTextWidget, diff --git a/src/utils/__tests__/renderer-padding-side.test.ts b/src/utils/__tests__/renderer-padding-side.test.ts index 4367bb605..ee81120c3 100644 --- a/src/utils/__tests__/renderer-padding-side.test.ts +++ b/src/utils/__tests__/renderer-padding-side.test.ts @@ -174,5 +174,58 @@ describe('defaultPaddingSide', () => { // 'AB' (2) + 0 leading + 2 trailing = 4 expect(calculateMaxWidthsFromPreRendered(lines, settings)).toEqual([4]); }); + + it('drops the padding between no-padding merged widgets, as rendering does', () => { + const lines = [[pre('A', { merge: 'no-padding' }), pre('B')]]; + const settings = createSettings(); + // '.' + 'A' + 'B' + '.' = 4: the merged pair renders as '.AB.' + expect(calculateMaxWidthsFromPreRendered(lines, settings)).toEqual([4]); + expect(render([text('a', { merge: 'no-padding' }), text('b')], { 0: 'A', 1: 'B' }, settings)).toBe('.AB.'); + }); + }); + + describe('padding on widgets with a background', () => { + // Hex colors produce raw codes, independent of chalk's color detection. + const BG = 'hex:112233'; + const FG = 'hex:AABBCC'; + const BG_CODE = '\x1b[48;2;17;34;51m'; + const FG_CODE = '\x1b[38;2;170;187;204m'; + const PAD = `${BG_CODE} \x1b[49m`; + const styled = (content: string): string => `${BG_CODE}${FG_CODE}${content}\x1b[39m\x1b[49m`; + + function renderStyled(widgets: WidgetItem[], contentByIndex: Record, settings: Settings): string { + const context: RenderContext = { isPreview: false, terminalWidth: 200 }; + const preRenderedWidgets = widgets.map((widget, i) => { + const content = contentByIndex[i] ?? ''; + return { content, plainLength: content.length, widget }; + }); + return renderStatusLine(widgets, settings, context, preRenderedWidgets, []); + } + + it.each([ + { side: 'both' as const, expected: `${PAD}${styled('A')}${PAD}` }, + { side: 'left' as const, expected: `${PAD}${styled('A')}` }, + { side: 'right' as const, expected: `${styled('A')}${PAD}` } + ])('colors the $side padding with the widget background', ({ side, expected }) => { + const settings = createSettings({ colorLevel: 3, defaultPadding: ' ', defaultPaddingSide: side }); + + expect(renderStyled([text('a', { color: FG, backgroundColor: BG })], { 0: 'A' }, settings)).toBe(expected); + }); + + it('colors padding with the global background override', () => { + const settings = createSettings({ colorLevel: 3, defaultPadding: ' ', overrideBackgroundColor: BG }); + + expect(renderStyled([text('a', { color: FG })], { 0: 'A' }, settings)).toBe(`${PAD}${styled('A')}${PAD}`); + }); + + it('leaves out the colored padding between no-padding merged widgets', () => { + const settings = createSettings({ colorLevel: 3, defaultPadding: ' ' }); + const widgets = [ + text('a', { color: FG, backgroundColor: BG, merge: 'no-padding' }), + text('b', { color: FG, backgroundColor: BG }) + ]; + + expect(renderStyled(widgets, { 0: 'A', 1: 'B' }, settings)).toBe(`${PAD}${styled('A')}${styled('B')}${PAD}`); + }); }); }); diff --git a/src/utils/__tests__/renderer-powerline-separators.test.ts b/src/utils/__tests__/renderer-powerline-separators.test.ts new file mode 100644 index 000000000..2ca5a6432 --- /dev/null +++ b/src/utils/__tests__/renderer-powerline-separators.test.ts @@ -0,0 +1,252 @@ +import { + describe, + expect, + it +} from 'vitest'; + +import type { RenderContext } from '../../types/RenderContext'; +import { + DEFAULT_SETTINGS, + type Settings +} from '../../types/Settings'; +import type { WidgetItem } from '../../types/Widget'; +import { + calculateMaxWidthsFromPreRendered, + renderStatusLine, + type PreRenderedWidget +} from '../renderer'; + +// Hex colors produce raw truecolor codes, so expected strings do not depend +// on chalk's ambient color-support detection. +const A_FG = 'hex:AABBCC'; +const A_BG = 'hex:112233'; +const B_FG = 'hex:DDEEFF'; +const B_BG = 'hex:445566'; + +const A_FG_CODE = '\x1b[38;2;170;187;204m'; +const A_BG_CODE = '\x1b[48;2;17;34;51m'; +const A_BG_AS_FG_CODE = '\x1b[38;2;17;34;51m'; +const B_FG_CODE = '\x1b[38;2;221;238;255m'; +const B_BG_CODE = '\x1b[48;2;68;85;102m'; +const B_BG_AS_FG_CODE = '\x1b[38;2;68;85;102m'; + +const A_BACKGROUND = { color: A_BG, code: A_BG_CODE }; +const B_BACKGROUND = { color: B_BG, code: B_BG_CODE }; +const NO_BACKGROUND = { color: undefined, code: '' }; + +const BOLD = '\x1b[1m'; +const INTENSITY_RESET = '\x1b[22m'; +// Emitted after each Powerline widget's text. +const COLOR_RESET = '\x1b[49m\x1b[39m'; + +type PowerlineOverrides = Partial; + +function text(id: string, extra: Partial = {}): WidgetItem { + return { id, type: 'custom-text', ...extra }; +} + +const FLEX: WidgetItem = { id: 'flex', type: 'flex-separator' }; + +function powerlineSettings(powerline: PowerlineOverrides = {}): Settings { + return { + ...DEFAULT_SETTINGS, + colorLevel: 3, + powerline: { + ...DEFAULT_SETTINGS.powerline, + enabled: true, + separators: ['>'], + separatorInvertBackground: [false], + ...powerline + } + }; +} + +function preRender(widgets: WidgetItem[], contentByIndex: Record): PreRenderedWidget[] { + return widgets.map((widget, i) => { + const content = contentByIndex[i] ?? ''; + return { content, plainLength: content.length, widget }; + }); +} + +// terminalWidth 0 means "width unknown": no truncation, and each flex +// separator collapses to a single space. +function render( + widgets: WidgetItem[], + contentByIndex: Record, + powerline: PowerlineOverrides = {}, + maxWidths: number[] = [] +): string { + const context: RenderContext = { isPreview: false, terminalWidth: 0 }; + return renderStatusLine(widgets, powerlineSettings(powerline), context, preRender(widgets, contentByIndex), maxWidths); +} + +describe('powerline separator colors', () => { + it.each([ + { + name: 'different backgrounds', + invert: false, + aBg: A_BACKGROUND, + bBg: B_BACKGROUND, + separator: `${A_BG_AS_FG_CODE}${B_BG_CODE}>\x1b[39m\x1b[49m` + }, + { + name: 'the same background (arrow takes the left widget\'s text color)', + invert: false, + aBg: A_BACKGROUND, + bBg: A_BACKGROUND, + separator: `${A_FG_CODE}${A_BG_CODE}>\x1b[39m\x1b[49m` + }, + { + name: 'only the left widget has a background', + invert: false, + aBg: A_BACKGROUND, + bBg: NO_BACKGROUND, + separator: `${A_BG_AS_FG_CODE}>\x1b[39m` + }, + { + name: 'only the right widget has a background', + invert: false, + aBg: NO_BACKGROUND, + bBg: B_BACKGROUND, + separator: `${B_BG_AS_FG_CODE}>\x1b[39m` + }, + { + name: 'no backgrounds', + invert: false, + aBg: NO_BACKGROUND, + bBg: NO_BACKGROUND, + separator: '>' + }, + { + name: 'different backgrounds, inverted', + invert: true, + aBg: A_BACKGROUND, + bBg: B_BACKGROUND, + separator: `${B_BG_AS_FG_CODE}${A_BG_CODE}>\x1b[39m\x1b[49m` + }, + { + name: 'the same background, inverted (arrow takes the right widget\'s text color)', + invert: true, + aBg: A_BACKGROUND, + bBg: A_BACKGROUND, + separator: `${B_FG_CODE}${A_BG_CODE}>\x1b[39m\x1b[49m` + }, + { + name: 'only the left widget has a background, inverted', + invert: true, + aBg: A_BACKGROUND, + bBg: NO_BACKGROUND, + separator: `${A_BG_AS_FG_CODE}>\x1b[39m` + }, + { + name: 'only the right widget has a background, inverted', + invert: true, + aBg: NO_BACKGROUND, + bBg: B_BACKGROUND, + separator: `${B_BG_AS_FG_CODE}>\x1b[39m` + }, + { + name: 'no backgrounds, inverted', + invert: true, + aBg: NO_BACKGROUND, + bBg: NO_BACKGROUND, + separator: '>' + } + ])('colors the arrow between widgets with $name', ({ invert, aBg, bBg, separator }) => { + const widgets = [ + text('a', { color: A_FG, backgroundColor: aBg.color }), + text('b', { color: B_FG, backgroundColor: bBg.color }) + ]; + + const line = render(widgets, { 0: 'A', 1: 'B' }, { separatorInvertBackground: [invert] }); + + expect(line).toBe( + `${A_FG_CODE}${aBg.code}A${COLOR_RESET}` + + separator + + `${B_FG_CODE}${bBg.code}B${COLOR_RESET}` + ); + }); +}); + +describe('powerline caps on widgets without a background', () => { + it('emits start, flex-boundary and end caps uncolored', () => { + const widgets = [text('a', { color: A_FG }), FLEX, text('b', { color: B_FG })]; + + const line = render(widgets, { 0: 'A', 2: 'B' }, { startCaps: ['['], endCaps: [']'] }); + + expect(line).toBe(`[${A_FG_CODE}A${COLOR_RESET}] [${B_FG_CODE}B${COLOR_RESET}]`); + }); +}); + +describe('powerline bold resets at segment boundaries', () => { + it('ends bold before a flex gap so it cannot leak into the next segment', () => { + const widgets = [text('a', { color: A_FG, bold: true }), FLEX, text('b', { color: B_FG })]; + + const line = render(widgets, { 0: 'A', 2: 'B' }); + + expect(line).toBe(`${BOLD}${A_FG_CODE}A${COLOR_RESET}${INTENSITY_RESET} ${B_FG_CODE}B${COLOR_RESET}`); + }); + + it('keeps the end cap bold, then ends bold after it', () => { + const line = render([text('a', { color: A_FG, bold: true })], { 0: 'A' }, { endCaps: [']'] }); + + expect(line).toBe(`${BOLD}${A_FG_CODE}A${COLOR_RESET}]${INTENSITY_RESET}`); + }); +}); + +describe('powerline widgets that preserve their own colors', () => { + it('keeps the command\'s colors, applies the background, and fully resets after', () => { + const widgets: WidgetItem[] = [{ + id: 'cmd', + type: 'custom-command', + preserveColors: true, + color: B_FG, + backgroundColor: A_BG + }]; + + const line = render(widgets, { 0: '\x1b[31mred\x1b[39m' }); + + expect(line).toBe(`${A_BG_CODE}\x1b[31mred\x1b[39m\x1b[0m`); + }); +}); + +describe('powerline merged widgets', () => { + it('pads a merged group as a unit, after its last widget, when auto-aligning', () => { + const line1 = [text('a', { color: A_FG, merge: true }), text('b', { color: B_FG }), text('c', { color: A_FG })]; + const line2 = [text('wide', { color: A_FG }), text('d', { color: B_FG })]; + const settings = powerlineSettings({ autoAlign: true }); + const maxWidths = calculateMaxWidthsFromPreRendered([ + preRender(line1, { 0: 'A', 1: 'B', 2: 'C' }), + preRender(line2, { 0: 'WIDE5', 1: 'D' }) + ], settings); + + expect(maxWidths).toEqual([5, 1]); + + const line = render(line1, { 0: 'A', 1: 'B', 2: 'C' }, { autoAlign: true }, maxWidths); + + expect(line).toBe( + `${A_FG_CODE}A${COLOR_RESET}` + + `${B_FG_CODE}B ${COLOR_RESET}` + + '>' + + `${A_FG_CODE}C${COLOR_RESET}` + ); + }); + + it('merges across a widget that rendered empty', () => { + const widgets = [text('a', { color: A_FG, merge: true }), text('empty'), text('b', { color: B_FG })]; + const contents = { 0: 'A', 2: 'B' }; + + expect(calculateMaxWidthsFromPreRendered([preRender(widgets, contents)], powerlineSettings())).toEqual([2]); + expect(render(widgets, contents)).toBe(`${A_FG_CODE}A${COLOR_RESET}${B_FG_CODE}B${COLOR_RESET}`); + }); + + it('keeps the trailing padding of a no-padding merge when nothing after it rendered', () => { + const widgets = [text('a', { color: A_FG, merge: 'no-padding' }), text('empty')]; + const settings = { ...powerlineSettings(), defaultPadding: ' ' }; + const context: RenderContext = { isPreview: false, terminalWidth: 0 }; + const preRendered = preRender(widgets, { 0: 'A' }); + + expect(calculateMaxWidthsFromPreRendered([preRendered], settings)).toEqual([3]); + expect(renderStatusLine(widgets, settings, context, preRendered, [])).toBe(`${A_FG_CODE} A ${COLOR_RESET}`); + }); +}); diff --git a/src/utils/__tests__/renderer-separator-collapse.test.ts b/src/utils/__tests__/renderer-separator-collapse.test.ts index 8fd7d2c73..83b50abfa 100644 --- a/src/utils/__tests__/renderer-separator-collapse.test.ts +++ b/src/utils/__tests__/renderer-separator-collapse.test.ts @@ -11,7 +11,10 @@ import { } from '../../types/Settings'; import type { WidgetItem } from '../../types/Widget'; import { stripSgrCodes } from '../ansi'; -import { renderStatusLine } from '../renderer'; +import { + preRenderAllWidgets, + renderStatusLine +} from '../renderer'; interface PreRenderedWidget { content: string; @@ -96,6 +99,24 @@ describe('renderer separator collapse around empty widgets', () => { expect((out.match(/\|/g) ?? []).length).toBe(1); }); + it('treats a widget type this version does not know as empty, keeping later widgets aligned', () => { + // e.g. a settings file written by a newer ccstatusline + const widgets: WidgetItem[] = [ + { id: 'a', type: 'custom-text', customText: 'A' }, + SEP, + { id: 'future', type: 'widget-from-a-newer-version' }, + SEP, + { id: 'c', type: 'custom-text', customText: 'C' } + ]; + const settings = createSettings({ colorLevel: 0 }); + const context: RenderContext = { isPreview: false, terminalWidth: 200 }; + + const preRendered = preRenderAllWidgets([widgets], settings, context)[0] ?? []; + + expect(preRendered.map(entry => entry.content)).toEqual(['A', '', '', '', 'C']); + expect(stripSgrCodes(renderStatusLine(widgets, settings, context, preRendered, []))).toBe('A | C'); + }); + it('suppresses a leading separator when no prior widget has rendered (existing behavior)', () => { const widgets = [T('a'), SEP, T('b')]; // 'a' renders empty; the separator after it should not emit diff --git a/src/utils/__tests__/renderer-separator-colors.test.ts b/src/utils/__tests__/renderer-separator-colors.test.ts new file mode 100644 index 000000000..49e07ac91 --- /dev/null +++ b/src/utils/__tests__/renderer-separator-colors.test.ts @@ -0,0 +1,141 @@ +import chalk from 'chalk'; +import { + afterEach, + beforeEach, + describe, + expect, + it +} from 'vitest'; + +import type { RenderContext } from '../../types/RenderContext'; +import { + DEFAULT_SETTINGS, + type Settings +} from '../../types/Settings'; +import type { WidgetItem } from '../../types/Widget'; +import { stripSgrCodes } from '../ansi'; +import { updateColorMap } from '../colors'; +import { + renderStatusLine, + type PreRenderedWidget +} from '../renderer'; + +const A_FG = 'hex:AABBCC'; +const A_BG = 'hex:112233'; +const B_FG = 'hex:DDEEFF'; + +const A_FG_CODE = '\x1b[38;2;170;187;204m'; +const A_BG_CODE = '\x1b[48;2;17;34;51m'; +const B_FG_CODE = '\x1b[38;2;221;238;255m'; +// Custom Text's default color, 'white', at truecolor (#d3d7cf). +const WHITE_CODE = '\x1b[38;2;211;215;207m'; + +const BOLD = '\x1b[1m'; +const FG_RESET = '\x1b[39m'; +const BG_RESET = '\x1b[49m'; +const INTENSITY_RESET = '\x1b[22m'; + +const SEP: WidgetItem = { id: 'sep', type: 'separator' }; + +function text(id: string, extra: Partial = {}): WidgetItem { + return { id, type: 'custom-text', ...extra }; +} + +function render( + widgets: WidgetItem[], + contentByIndex: Record, + overrides: Partial = {} +): string { + const settings: Settings = { ...DEFAULT_SETTINGS, colorLevel: 3, ...overrides }; + const context: RenderContext = { isPreview: false, terminalWidth: 200 }; + const preRenderedWidgets: PreRenderedWidget[] = widgets.map((widget, i) => { + const content = contentByIndex[i] ?? ''; + return { content, plainLength: content.length, widget }; + }); + return renderStatusLine(widgets, settings, context, preRenderedWidgets, []); +} + +describe('separator colors in standard mode', () => { + // Named colors (a widget's default color) go through chalk, so pin its + // level to keep the expected codes independent of the environment. + const originalLevel = chalk.level; + + beforeEach(() => { + chalk.level = 3; + updateColorMap(); + }); + + afterEach(() => { + chalk.level = originalLevel; + updateColorMap(); + }); + + describe('separator widgets with inheritSeparatorColors', () => { + it('take the color, background and bold of the widget before them', () => { + const widgets = [text('a', { color: A_FG, backgroundColor: A_BG, bold: true }), SEP, text('b', { color: B_FG })]; + + const line = render(widgets, { 0: 'A', 2: 'B' }, { inheritSeparatorColors: true }); + + expect(line).toBe( + `${BOLD}${A_BG_CODE}${A_FG_CODE}A${FG_RESET}${BG_RESET}${INTENSITY_RESET}` + + `${BOLD}${A_BG_CODE}${A_FG_CODE} | ${FG_RESET}${BG_RESET}${INTENSITY_RESET}` + + `${B_FG_CODE}B${FG_RESET}` + ); + }); + + it('fall back to the previous widget\'s default color when it has none', () => { + const widgets = [text('a'), SEP, text('b', { color: B_FG })]; + + const line = render(widgets, { 0: 'A', 2: 'B' }, { inheritSeparatorColors: true }); + + expect(line).toBe( + `${WHITE_CODE}A${FG_RESET}` + + `${WHITE_CODE} | ${FG_RESET}` + + `${B_FG_CODE}B${FG_RESET}` + ); + }); + }); + + describe('the automatic defaultSeparator', () => { + it('takes each previous widget\'s colors with inheritSeparatorColors, including default colors', () => { + const widgets = [text('a', { color: A_FG, backgroundColor: A_BG }), text('b'), text('c', { color: B_FG })]; + + const line = render(widgets, { 0: 'A', 1: 'B', 2: 'C' }, { + defaultSeparator: '|', + inheritSeparatorColors: true + }); + + expect(line).toBe( + `${A_BG_CODE}${A_FG_CODE}A${FG_RESET}${BG_RESET}` + + `${A_BG_CODE}${A_FG_CODE} | ${FG_RESET}${BG_RESET}` + + `${WHITE_CODE}B${FG_RESET}` + + `${WHITE_CODE} | ${FG_RESET}` + + `${B_FG_CODE}C${FG_RESET}` + ); + }); + + it('takes the global background override when not inheriting', () => { + const widgets = [text('a', { color: A_FG }), text('b', { color: B_FG })]; + + const line = render(widgets, { 0: 'A', 1: 'B' }, { + defaultSeparator: '|', + overrideBackgroundColor: A_BG + }); + + expect(line).toBe( + `${A_BG_CODE}${A_FG_CODE}A${FG_RESET}${BG_RESET}` + + `${A_BG_CODE} | ${BG_RESET}` + + `${A_BG_CODE}${B_FG_CODE}B${FG_RESET}${BG_RESET}` + ); + }); + + it.each([ + { separator: ',', expected: 'A, B' }, + { separator: '-', expected: 'A - B' } + ])('spaces a "$separator" separator as "$expected"', ({ separator, expected }) => { + const line = render([text('a'), text('b')], { 0: 'A', 1: 'B' }, { defaultSeparator: separator }); + + expect(stripSgrCodes(line)).toBe(expected); + }); + }); +});