Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions src/utils/__tests__/ansi.test.ts
Original file line number Diff line number Diff line change
@@ -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...');
});
});
133 changes: 133 additions & 0 deletions src/utils/__tests__/claude-settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,15 @@ import {
getClaudeSettingsPath,
getExistingStatusLine,
getRefreshInterval,
getRemoteControlStatus,
getSandboxConfig,
getVoiceConfig,
installStatusLine,
isClaudeCodeVersionAtLeast,
isInstalled,
isKnownCommand,
loadClaudeSettings,
loadClaudeSettingsSync,
saveClaudeSettings,
setRefreshInterval,
uninstallStatusLine
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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();
});
});
41 changes: 41 additions & 0 deletions src/utils/__tests__/renderer-dim.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [
{
Expand Down
7 changes: 7 additions & 0 deletions src/utils/__tests__/renderer-flex-width.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
53 changes: 53 additions & 0 deletions src/utils/__tests__/renderer-padding-side.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number, string>, 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}`);
});
});
});
Loading
Loading