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
130 changes: 130 additions & 0 deletions src/tui/__tests__/App.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1174,6 +1174,136 @@ describe('App while an install or update runs', () => {
});
});

describe('App when Claude settings.json breaks while the TUI is open', () => {
let sandbox: AppSandbox;
let claudeSettingsPath: string;

beforeEach(() => {
sandbox = setUpAppSandbox();
claudeSettingsPath = claudeSettings.getClaudeSettingsPath();
fs.mkdirSync(path.dirname(claudeSettingsPath), { recursive: true });
});

afterEach(() => {
sandbox.restore();
});

const managedHooks = {
UserPromptSubmit: [
{
_tag: 'ccstatusline-managed',
hooks: [{ type: 'command', command: 'ccstatusline --hook' }]
}
]
};

it.each([
{
name: 'settings.json no longer parses',
// A hand edit in another window leaves a trailing comma
content: '{ "statusLine": { "type": "command", "command": "ccstatusline", "padding": 0 }, }',
mode: 0o644
},
{
name: 'a read-only settings.json has lost its statusLine but kept the managed hooks',
content: JSON.stringify({ hooks: managedHooks }),
mode: 0o444
}
])('fails the uninstall and keeps the npm global package when $name', async ({ content, mode }) => {
const installation: InstallationMetadata = { method: 'pinned', installedVersion: getPackageVersion() };
fs.writeFileSync(sandbox.settingsPath, JSON.stringify({ ...DEFAULT_SETTINGS, installation }));
fs.writeFileSync(claudeSettingsPath, JSON.stringify({
statusLine: { type: 'command', command: 'ccstatusline', padding: 0 },
hooks: managedHooks
}));
vi.spyOn(globalPackageManager, 'inspectActiveGlobalCommand').mockReturnValue({
packageManager: 'npm',
resolvedPath: '/usr/local/bin/ccstatusline',
resolvedPaths: ['/usr/local/bin/ccstatusline'],
binDir: '/usr/local/bin',
version: null,
warning: null
});
vi.spyOn(globalPackageManager, 'inspectGlobalPackageInstallations').mockReturnValue([
{ packageManager: 'npm', available: true, installed: true, binDir: '/usr/local/bin' },
{ packageManager: 'bun', available: false, installed: false, binDir: null }
]);
// Stands in for `npm uninstall -g ccstatusline`
const globalUninstall = vi.spyOn(globalPackageManager, 'runGlobalPackageUninstall').mockResolvedValue(undefined);
const rendered = renderApp();

try {
await waitFor(() => {
expect(rendered.getFrame()).toContain('🧰 Manage Installation');
});
fs.writeFileSync(claudeSettingsPath, content);
fs.chmodSync(claudeSettingsPath, mode);

await pressKey(rendered, KEYS.up, '▶ ⭐ Like ccstatusline?');
await pressKey(rendered, KEYS.up, '▶ 🚪 Exit');
await pressKey(rendered, KEYS.up, '▶ 🧰 Manage Installation');
await pressKey(rendered, KEYS.enter, '▶ 🔄 Check for Updates');
await pressKey(rendered, KEYS.down, '▶ 🔌 Uninstall');
await pressKey(rendered, KEYS.enter, 'Remove from Claude Code settings only');
await pressKey(rendered, KEYS.down, '▶ Remove Claude settings and npm global package');
await pressKey(rendered, KEYS.enter, 'npm uninstall -g ccstatusline');
await pressKey(rendered, KEYS.enter, '✗ Uninstall failed');

expect(globalUninstall).not.toHaveBeenCalled();
expect(fs.readFileSync(claudeSettingsPath, 'utf-8')).toBe(content);
const saved = JSON.parse(fs.readFileSync(sandbox.settingsPath, 'utf-8')) as { installation?: InstallationMetadata };
expect(saved.installation).toEqual(installation);

await pressKey(rendered, KEYS.escape, '▶ 🔄 Check for Updates');
await pressKey(rendered, KEYS.escape, 'Main Menu');
expect(rendered.getFrame()).toContain('🧰 Manage Installation');
expect(rendered.getFrame()).not.toContain('📦 Install to Claude Code');
} finally {
rendered.cleanup();
}
});

const installedStatusLine = { type: 'command', command: 'npx -y ccstatusline@latest', padding: 0, refreshInterval: 10 };

it.each([
{
name: 'no longer parses',
content: `{ "statusLine": ${JSON.stringify(installedStatusLine)}, }`
},
{
name: 'has lost its statusLine',
content: JSON.stringify({ effortLevel: 'high' })
}
])('keeps the refresh interval and reports the failed save when settings.json $name', async ({ content }) => {
fs.writeFileSync(claudeSettingsPath, JSON.stringify({ statusLine: installedStatusLine }));
vi.spyOn(claudeSettings, 'isClaudeCodeVersionAtLeast').mockReturnValue(true);
const rendered = renderApp();

try {
await waitFor(() => {
expect(rendered.getFrame()).toContain('🔌 Uninstall from Claude Code');
});
await pressKey(rendered, KEYS.down, '▶ 🎨 Edit Colors');
await pressKey(rendered, KEYS.down, '▶ ⚡ Powerline Setup');
await pressKey(rendered, KEYS.down, '▶ 💻 Terminal Options');
await pressKey(rendered, KEYS.down, '▶ 🌐 Global Overrides');
await pressKey(rendered, KEYS.down, '▶ 🔧 Configure Status Line');
await pressKey(rendered, KEYS.enter, '🔄 Refresh Interval (10s)');
fs.writeFileSync(claudeSettingsPath, content);

await pressKey(rendered, KEYS.enter, 'Enter refresh interval in seconds (1-60): 10s');
await pressKey(rendered, '\x7f', 'Enter refresh interval in seconds (1-60): 1s');
await pressKey(rendered, '5', 'Enter refresh interval in seconds (1-60): 15s');
await pressKey(rendered, KEYS.enter, '✗ Failed to save refresh interval');
expect(fs.readFileSync(claudeSettingsPath, 'utf-8')).toBe(content);

await pressKey(rendered, KEYS.enter, '🔄 Refresh Interval (10s)');
} finally {
rendered.cleanup();
}
});
});

describe('App Check for Updates', () => {
let sandbox: AppSandbox;

Expand Down
146 changes: 129 additions & 17 deletions src/utils/__tests__/claude-settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -582,10 +582,10 @@ describe('refreshInterval', () => {
expect(settings.statusLine?.refreshInterval).toBeUndefined();
});

it('setRefreshInterval should do nothing when no statusLine exists', async () => {
it('setRefreshInterval should reject without writing when no statusLine exists', async () => {
await saveClaudeSettings({});

await setRefreshInterval(10);
await expect(setRefreshInterval(10)).rejects.toThrow(getClaudeSettingsPath());

const settings = await loadClaudeSettings();
expect(settings.statusLine).toBeUndefined();
Expand All @@ -599,13 +599,13 @@ describe('refreshInterval', () => {
expect(consoleErrorSpy).not.toHaveBeenCalled();
});

it('setRefreshInterval should leave unreadable settings untouched', async () => {
it('setRefreshInterval should reject and leave unreadable settings untouched', async () => {
writeRawClaudeSettings('{ invalid json');
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
const settingsPath = getClaudeSettingsPath();

await setRefreshInterval(10);
await expect(setRefreshInterval(10)).rejects.toThrow(settingsPath);

const settingsPath = getClaudeSettingsPath();
expect(fs.readFileSync(settingsPath, 'utf-8')).toBe('{ invalid json');
expect(fs.existsSync(`${settingsPath}.bak`)).toBe(false);
expect(consoleErrorSpy).not.toHaveBeenCalled();
Expand Down Expand Up @@ -715,21 +715,20 @@ describe('backup and error handling behavior', () => {
expect(fs.existsSync(`${settingsPath}.orig`)).toBe(false);
});

it('uninstallStatusLine should warn and return without modifying invalid settings', async () => {
it('uninstallStatusLine should abort without touching settings that cannot be parsed', async () => {
writeRawClaudeSettings('{ invalid json');
const settingsPath = getClaudeSettingsPath();
const installation = { method: 'pinned', installedVersion: '2.2.13' };
fs.writeFileSync(config.getConfigPath(), JSON.stringify({ ...DEFAULT_SETTINGS, installation }), 'utf-8');
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
try {
await uninstallStatusLine();

const settingsPath = getClaudeSettingsPath();
expect(fs.readFileSync(settingsPath, 'utf-8')).toBe('{ invalid json');
expect(fs.existsSync(`${settingsPath}.bak`)).toBe(false);
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Warning: Could not read existing Claude settings.'
);
} finally {
consoleErrorSpy.mockRestore();
}
await expect(uninstallStatusLine()).rejects.toThrow(settingsPath);

expect(fs.readFileSync(settingsPath, 'utf-8')).toBe('{ invalid json');
expect(fs.existsSync(`${settingsPath}.bak`)).toBe(false);
const savedSettings = JSON.parse(fs.readFileSync(config.getConfigPath(), 'utf-8')) as { installation?: unknown };
expect(savedSettings.installation).toEqual(installation);
expect(consoleErrorSpy).not.toHaveBeenCalled();
});

it('uninstallStatusLine should remove all managed hooks', async () => {
Expand Down Expand Up @@ -778,6 +777,119 @@ describe('backup and error handling behavior', () => {
});
});

it('uninstallStatusLine should abort when managed hooks are left in settings that cannot be written', async () => {
const content = JSON.stringify({
hooks: {
UserPromptSubmit: [
{
_tag: 'ccstatusline-managed',
hooks: [{ type: 'command', command: `${CCSTATUSLINE_COMMANDS.NPM} --hook` }]
}
]
}
});
writeRawClaudeSettings(content);
const settingsPath = getClaudeSettingsPath();
fs.chmodSync(settingsPath, 0o444);
const installation = { method: 'pinned', installedVersion: '2.2.13' };
fs.writeFileSync(config.getConfigPath(), JSON.stringify({ ...DEFAULT_SETTINGS, installation }), 'utf-8');

await expect(uninstallStatusLine()).rejects.toMatchObject({ code: 'EACCES' });

expect(fs.readFileSync(settingsPath, 'utf-8')).toBe(content);
const savedSettings = JSON.parse(fs.readFileSync(config.getConfigPath(), 'utf-8')) as { installation?: unknown };
expect(savedSettings.installation).toEqual(installation);
});

it('uninstallStatusLine should leave settings without the status line or managed hooks unwritten', async () => {
const content = JSON.stringify({
effortLevel: 'high',
hooks: {
PreToolUse: [
{
matcher: 'Other',
hooks: [{ type: 'command', command: 'keep-me' }]
}
]
}
});
writeRawClaudeSettings(content);
const settingsPath = getClaudeSettingsPath();
fs.chmodSync(settingsPath, 0o444);
const installation = { method: 'pinned', installedVersion: '2.2.13' };
fs.writeFileSync(config.getConfigPath(), JSON.stringify({ ...DEFAULT_SETTINGS, installation }), 'utf-8');

await uninstallStatusLine();

expect(fs.readFileSync(settingsPath, 'utf-8')).toBe(content);
expect(fs.existsSync(`${settingsPath}.bak`)).toBe(false);
const savedSettings = JSON.parse(fs.readFileSync(config.getConfigPath(), 'utf-8')) as { installation?: unknown };
expect(savedSettings.installation).toBeUndefined();
});

it('uninstallStatusLine should keep hook entries it does not recognize', async () => {
const otherHooks = {
Stop: { type: 'command', command: 'not-a-list' },
PreToolUse: [
null,
'not-an-entry',
{ matcher: 'Other', hooks: 'not-a-list' },
{ matcher: 'Mixed', hooks: [null, { type: 'command', command: 'keep-me' }] }
]
};
writeRawClaudeSettings(JSON.stringify({
statusLine: {
type: 'command',
command: CCSTATUSLINE_COMMANDS.NPM,
padding: 0
},
hooks: {
...otherHooks,
PreToolUse: [
...otherHooks.PreToolUse,
{
_tag: 'ccstatusline-managed',
hooks: [{ type: 'command', command: `${CCSTATUSLINE_COMMANDS.NPM} --hook` }]
}
]
}
}));

await uninstallStatusLine();

const updated = JSON.parse(fs.readFileSync(getClaudeSettingsPath(), 'utf-8')) as {
statusLine?: unknown;
hooks?: unknown;
};
expect(updated.statusLine).toBeUndefined();
expect(updated.hooks).toEqual(otherHooks);
});

it('uninstallStatusLine should back up the settings as they were before the uninstall', async () => {
const content = JSON.stringify({
statusLine: {
type: 'command',
command: CCSTATUSLINE_COMMANDS.NPM,
padding: 0
},
hooks: {
UserPromptSubmit: [
{
_tag: 'ccstatusline-managed',
hooks: [{ type: 'command', command: `${CCSTATUSLINE_COMMANDS.NPM} --hook` }]
}
]
}
});
writeRawClaudeSettings(content);

await uninstallStatusLine();

const settingsPath = getClaudeSettingsPath();
expect(JSON.parse(fs.readFileSync(settingsPath, 'utf-8'))).toEqual({});
expect(fs.readFileSync(`${settingsPath}.bak`, 'utf-8')).toBe(content);
});

it('getExistingStatusLine should return null when settings cannot be loaded', async () => {
writeRawClaudeSettings('{ invalid json');
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
Expand Down
Loading
Loading