mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 16:14:31 +00:00
fix(targets): harden adapter lifecycle and droid model edge cases
This commit is contained in:
1 parent
02af8d5737
commit
3da3407f9a
12 files changed
+556
-101
No files matched your search
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Integration-style test for Node argv alias behavior.
|
||||
*
|
||||
* This validates the runtime assumption used by target-resolver:
|
||||
* when invoked via a `ccsd` symlink, Node preserves the invoked
|
||||
* symlink path in process.argv[1].
|
||||
*/
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { spawnSync } from 'child_process';
|
||||
|
||||
function probeArgvPath(aliasBasename: string): string {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-ccsd-alias-'));
|
||||
const scriptPath = path.join(tmpDir, 'probe.js');
|
||||
const aliasPath = path.join(tmpDir, aliasBasename);
|
||||
|
||||
try {
|
||||
fs.writeFileSync(scriptPath, 'console.log(process.argv[1]);\n', { encoding: 'utf8' });
|
||||
fs.symlinkSync(scriptPath, aliasPath);
|
||||
|
||||
const result = spawnSync('node', [aliasPath], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
return result.stdout.trim();
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function probeArgvPathDirect(filename: string): string {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-ccsd-direct-'));
|
||||
const scriptPath = path.join(tmpDir, filename);
|
||||
|
||||
try {
|
||||
fs.writeFileSync(scriptPath, 'console.log(process.argv[1]);\n', { encoding: 'utf8' });
|
||||
|
||||
const result = spawnSync('node', [scriptPath], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
return result.stdout.trim();
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
describe('ccsd alias integration', () => {
|
||||
it('should preserve ccsd symlink basename in argv[1] under node', () => {
|
||||
if (process.platform === 'win32') {
|
||||
// Windows symlink creation requires elevated privileges/Developer Mode.
|
||||
return;
|
||||
}
|
||||
|
||||
const argvPath = probeArgvPath('ccsd');
|
||||
expect(path.basename(argvPath)).toBe('ccsd');
|
||||
});
|
||||
|
||||
it('should preserve extension-style alias basenames for wrapper compatibility', () => {
|
||||
const cmdArgvPath = probeArgvPathDirect('ccsd.cmd');
|
||||
const ps1ArgvPath = probeArgvPathDirect('ccsd.ps1');
|
||||
|
||||
expect(path.basename(cmdArgvPath)).toBe('ccsd.cmd');
|
||||
expect(path.basename(ps1ArgvPath)).toBe('ccsd.ps1');
|
||||
});
|
||||
});
|
||||
@@ -175,6 +175,60 @@ describe('droid-config-manager', () => {
|
||||
})
|
||||
).rejects.toThrow(/settings\.json\.tmp is a symlink/);
|
||||
});
|
||||
|
||||
it('should update legacy ccs- alias entry on upsert', async () => {
|
||||
const factoryDir = path.join(tmpDir, '.factory');
|
||||
fs.mkdirSync(factoryDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(factoryDir, 'settings.json'),
|
||||
JSON.stringify({
|
||||
customModels: [
|
||||
{
|
||||
model: 'claude-opus-4-6',
|
||||
displayName: 'ccs-gemini',
|
||||
baseUrl: 'http://localhost:8317',
|
||||
apiKey: 'old-key',
|
||||
provider: 'anthropic',
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
await upsertCcsModel('gemini', {
|
||||
model: 'claude-opus-4-6',
|
||||
displayName: 'CCS gemini',
|
||||
baseUrl: 'http://localhost:8318',
|
||||
apiKey: 'new-key',
|
||||
provider: 'anthropic',
|
||||
});
|
||||
|
||||
const settings = JSON.parse(
|
||||
fs.readFileSync(path.join(factoryDir, 'settings.json'), 'utf8')
|
||||
);
|
||||
expect(settings.customModels).toHaveLength(1);
|
||||
expect(settings.customModels[0].displayName).toBe('CCS gemini');
|
||||
expect(settings.customModels[0].apiKey).toBe('new-key');
|
||||
expect(settings.customModels[0].baseUrl).toBe('http://localhost:8318');
|
||||
});
|
||||
|
||||
it('should reject symlinked settings file on write', async () => {
|
||||
const factoryDir = path.join(tmpDir, '.factory');
|
||||
fs.mkdirSync(factoryDir, { recursive: true });
|
||||
|
||||
const realSettings = path.join(factoryDir, 'real-settings.json');
|
||||
fs.writeFileSync(realSettings, JSON.stringify({ customModels: [] }));
|
||||
fs.symlinkSync(realSettings, path.join(factoryDir, 'settings.json'));
|
||||
|
||||
await expect(
|
||||
upsertCcsModel('gemini', {
|
||||
model: 'claude-opus-4-6',
|
||||
displayName: 'CCS gemini',
|
||||
baseUrl: 'http://localhost:8317',
|
||||
apiKey: 'dummy-key',
|
||||
provider: 'anthropic',
|
||||
})
|
||||
).rejects.toThrow(/settings\.json is a symlink/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeCcsModel', () => {
|
||||
@@ -213,6 +267,26 @@ describe('droid-config-manager', () => {
|
||||
expect(settings.customModels).toHaveLength(1);
|
||||
expect(settings.customModels[0].displayName).toBe('My GPT');
|
||||
});
|
||||
|
||||
it('should remove legacy ccs- alias entries', async () => {
|
||||
const factoryDir = path.join(tmpDir, '.factory');
|
||||
fs.mkdirSync(factoryDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(factoryDir, 'settings.json'),
|
||||
JSON.stringify({
|
||||
customModels: [
|
||||
{ model: 'opus', displayName: 'ccs-gemini', baseUrl: 'x', apiKey: 'y', provider: 'anthropic' },
|
||||
{ model: 'gpt-4o', displayName: 'My GPT', baseUrl: 'x', apiKey: 'y', provider: 'openai' },
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
await removeCcsModel('gemini');
|
||||
|
||||
const settings = JSON.parse(fs.readFileSync(path.join(factoryDir, 'settings.json'), 'utf8'));
|
||||
expect(settings.customModels).toHaveLength(1);
|
||||
expect(settings.customModels[0].displayName).toBe('My GPT');
|
||||
});
|
||||
});
|
||||
|
||||
describe('listCcsModels', () => {
|
||||
@@ -295,6 +369,60 @@ describe('droid-config-manager', () => {
|
||||
|
||||
await expect(listCcsModels()).rejects.toThrow(/settings\.json is a symlink/);
|
||||
});
|
||||
|
||||
it('should include legacy ccs- alias entries', async () => {
|
||||
const factoryDir = path.join(tmpDir, '.factory');
|
||||
fs.mkdirSync(factoryDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(factoryDir, 'settings.json'),
|
||||
JSON.stringify({
|
||||
customModels: [
|
||||
{
|
||||
model: 'opus',
|
||||
displayName: 'ccs-gemini',
|
||||
baseUrl: 'http://localhost:8317',
|
||||
apiKey: 'dummy',
|
||||
provider: 'anthropic',
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
const models = await listCcsModels();
|
||||
expect(models.size).toBe(1);
|
||||
expect(models.has('gemini')).toBe(true);
|
||||
});
|
||||
|
||||
it('should ignore malformed managed display names', async () => {
|
||||
const factoryDir = path.join(tmpDir, '.factory');
|
||||
fs.mkdirSync(factoryDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(factoryDir, 'settings.json'),
|
||||
JSON.stringify({
|
||||
customModels: [
|
||||
{ model: 'x', displayName: 'CCS ', baseUrl: 'x', apiKey: 'y', provider: 'anthropic' },
|
||||
{ model: 'x', displayName: 'ccs-', baseUrl: 'x', apiKey: 'y', provider: 'anthropic' },
|
||||
{ model: 'x', displayName: 'CCS ok', baseUrl: 'x', apiKey: 'y', provider: 'anthropic' },
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
const models = await listCcsModels();
|
||||
expect(models.size).toBe(1);
|
||||
expect(models.has('ok')).toBe(true);
|
||||
});
|
||||
|
||||
it('should recover from corrupted JSON by backing up and returning empty models', async () => {
|
||||
const factoryDir = path.join(tmpDir, '.factory');
|
||||
fs.mkdirSync(factoryDir, { recursive: true });
|
||||
const settingsPath = path.join(factoryDir, 'settings.json');
|
||||
|
||||
fs.writeFileSync(settingsPath, '{"customModels":[', 'utf8');
|
||||
|
||||
const models = await listCcsModels();
|
||||
expect(models.size).toBe(0);
|
||||
expect(fs.existsSync(`${settingsPath}.bak`)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pruneOrphanedModels', () => {
|
||||
@@ -343,18 +471,59 @@ describe('droid-config-manager', () => {
|
||||
expect(settings.customModels).toHaveLength(1);
|
||||
expect(settings.customModels[0].displayName).toBe('My GPT');
|
||||
});
|
||||
|
||||
it('should prune orphaned legacy ccs- alias entries', async () => {
|
||||
const factoryDir = path.join(tmpDir, '.factory');
|
||||
fs.mkdirSync(factoryDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(factoryDir, 'settings.json'),
|
||||
JSON.stringify({
|
||||
customModels: [
|
||||
{ model: 'opus', displayName: 'ccs-gemini', baseUrl: 'x', apiKey: 'y', provider: 'anthropic' },
|
||||
{ model: 'sonnet', displayName: 'ccs-codex', baseUrl: 'x', apiKey: 'y', provider: 'anthropic' },
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
const removed = await pruneOrphanedModels(['gemini']);
|
||||
expect(removed).toBe(1);
|
||||
|
||||
const models = await listCcsModels();
|
||||
expect(models.size).toBe(1);
|
||||
expect(models.has('gemini')).toBe(true);
|
||||
});
|
||||
|
||||
it('should prune malformed managed entries while preserving user models', async () => {
|
||||
const factoryDir = path.join(tmpDir, '.factory');
|
||||
fs.mkdirSync(factoryDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(factoryDir, 'settings.json'),
|
||||
JSON.stringify({
|
||||
customModels: [
|
||||
{ model: 'x', displayName: 'CCS ', baseUrl: 'x', apiKey: 'y', provider: 'anthropic' },
|
||||
{ model: 'x', displayName: 'ccs-', baseUrl: 'x', apiKey: 'y', provider: 'anthropic' },
|
||||
{ model: 'gpt-4o', displayName: 'My GPT', baseUrl: 'x', apiKey: 'y', provider: 'openai' },
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
const removed = await pruneOrphanedModels([]);
|
||||
expect(removed).toBe(2);
|
||||
|
||||
const settings = JSON.parse(fs.readFileSync(path.join(factoryDir, 'settings.json'), 'utf8'));
|
||||
expect(settings.customModels).toHaveLength(1);
|
||||
expect(settings.customModels[0].displayName).toBe('My GPT');
|
||||
});
|
||||
});
|
||||
|
||||
describe('concurrent writes', () => {
|
||||
it('should handle concurrent upserts without data loss', async () => {
|
||||
// Write in batches of 3 to simulate realistic concurrency
|
||||
// (10 simultaneous locks exceeds retry budget)
|
||||
const profiles = Array.from({ length: 9 }, (_, i) => `profile-${i}`);
|
||||
it(
|
||||
'should handle concurrent upserts without data loss',
|
||||
async () => {
|
||||
const profiles = Array.from({ length: 10 }, (_, i) => `profile-${i}`);
|
||||
|
||||
for (let i = 0; i < profiles.length; i += 3) {
|
||||
const batch = profiles.slice(i, i + 3);
|
||||
await Promise.all(
|
||||
batch.map((p) =>
|
||||
profiles.map((p) =>
|
||||
upsertCcsModel(p, {
|
||||
model: 'test-model',
|
||||
displayName: `CCS ${p}`,
|
||||
@@ -364,14 +533,15 @@ describe('droid-config-manager', () => {
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const models = await listCcsModels();
|
||||
expect(models.size).toBe(9);
|
||||
const models = await listCcsModels();
|
||||
expect(models.size).toBe(10);
|
||||
|
||||
for (const p of profiles) {
|
||||
expect(models.has(p)).toBe(true);
|
||||
}
|
||||
});
|
||||
for (const p of profiles) {
|
||||
expect(models.has(p)).toBe(true);
|
||||
}
|
||||
},
|
||||
15000
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import { describe, it, expect, jest } from 'bun:test';
|
||||
import { EventEmitter } from 'events';
|
||||
import type { ChildProcess } from 'child_process';
|
||||
import { forwardSignals, wireChildProcessSignals } from '../../../src/utils/signal-forwarder';
|
||||
|
||||
type MockChildProcess = EventEmitter & {
|
||||
killed: boolean;
|
||||
kill: ChildProcess['kill'];
|
||||
};
|
||||
|
||||
function createMockChildProcess(): ChildProcess {
|
||||
const child = new EventEmitter() as MockChildProcess;
|
||||
child.killed = false;
|
||||
child.kill = jest.fn(() => true) as ChildProcess['kill'];
|
||||
return child as ChildProcess;
|
||||
}
|
||||
|
||||
function getSignalListenerCounts(): Record<'SIGINT' | 'SIGTERM' | 'SIGHUP', number> {
|
||||
return {
|
||||
SIGINT: process.listenerCount('SIGINT'),
|
||||
SIGTERM: process.listenerCount('SIGTERM'),
|
||||
SIGHUP: process.listenerCount('SIGHUP'),
|
||||
};
|
||||
}
|
||||
|
||||
describe('signal-forwarder', () => {
|
||||
it('forwardSignals should register and cleanup listeners', () => {
|
||||
const child = createMockChildProcess();
|
||||
const before = getSignalListenerCounts();
|
||||
|
||||
const cleanup = forwardSignals(child);
|
||||
|
||||
expect(process.listenerCount('SIGINT')).toBe(before.SIGINT + 1);
|
||||
expect(process.listenerCount('SIGTERM')).toBe(before.SIGTERM + 1);
|
||||
expect(process.listenerCount('SIGHUP')).toBe(before.SIGHUP + 1);
|
||||
|
||||
cleanup();
|
||||
|
||||
expect(process.listenerCount('SIGINT')).toBe(before.SIGINT);
|
||||
expect(process.listenerCount('SIGTERM')).toBe(before.SIGTERM);
|
||||
expect(process.listenerCount('SIGHUP')).toBe(before.SIGHUP);
|
||||
});
|
||||
|
||||
it('wireChildProcessSignals should use default exit behavior for exit code', () => {
|
||||
const child = createMockChildProcess();
|
||||
const before = getSignalListenerCounts();
|
||||
|
||||
const exitSpy = jest
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation((() => undefined as never) as typeof process.exit);
|
||||
const killSpy = jest
|
||||
.spyOn(process, 'kill')
|
||||
.mockImplementation((() => true) as typeof process.kill);
|
||||
|
||||
try {
|
||||
wireChildProcessSignals(child, () => {});
|
||||
child.emit('exit', 7, null);
|
||||
|
||||
expect(exitSpy).toHaveBeenCalledWith(7);
|
||||
expect(killSpy).not.toHaveBeenCalled();
|
||||
expect(process.listenerCount('SIGINT')).toBe(before.SIGINT);
|
||||
expect(process.listenerCount('SIGTERM')).toBe(before.SIGTERM);
|
||||
expect(process.listenerCount('SIGHUP')).toBe(before.SIGHUP);
|
||||
} finally {
|
||||
exitSpy.mockRestore();
|
||||
killSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('wireChildProcessSignals should use default exit behavior for signal', () => {
|
||||
const child = createMockChildProcess();
|
||||
const before = getSignalListenerCounts();
|
||||
|
||||
const exitSpy = jest
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation((() => undefined as never) as typeof process.exit);
|
||||
const killSpy = jest
|
||||
.spyOn(process, 'kill')
|
||||
.mockImplementation((() => true) as typeof process.kill);
|
||||
|
||||
try {
|
||||
wireChildProcessSignals(child, () => {});
|
||||
child.emit('exit', null, 'SIGTERM');
|
||||
|
||||
expect(killSpy).toHaveBeenCalledWith(process.pid, 'SIGTERM');
|
||||
expect(exitSpy).not.toHaveBeenCalled();
|
||||
expect(process.listenerCount('SIGINT')).toBe(before.SIGINT);
|
||||
expect(process.listenerCount('SIGTERM')).toBe(before.SIGTERM);
|
||||
expect(process.listenerCount('SIGHUP')).toBe(before.SIGHUP);
|
||||
} finally {
|
||||
exitSpy.mockRestore();
|
||||
killSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('wireChildProcessSignals should invoke onError and cleanup listeners', async () => {
|
||||
const child = createMockChildProcess();
|
||||
const before = getSignalListenerCounts();
|
||||
const onError = jest.fn(async () => {});
|
||||
const err = Object.assign(new Error('spawn failed'), { code: 'ENOENT' }) as NodeJS.ErrnoException;
|
||||
|
||||
wireChildProcessSignals(child, onError);
|
||||
child.emit('error', err);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(onError).toHaveBeenCalledWith(err);
|
||||
expect(process.listenerCount('SIGINT')).toBe(before.SIGINT);
|
||||
expect(process.listenerCount('SIGTERM')).toBe(before.SIGTERM);
|
||||
expect(process.listenerCount('SIGHUP')).toBe(before.SIGHUP);
|
||||
});
|
||||
|
||||
it('wireChildProcessSignals should run only one terminal callback when error is followed by exit', async () => {
|
||||
const child = createMockChildProcess();
|
||||
const onError = jest.fn(async () => {});
|
||||
const onExit = jest.fn();
|
||||
const err = Object.assign(new Error('spawn failed'), { code: 'ENOENT' }) as NodeJS.ErrnoException;
|
||||
|
||||
wireChildProcessSignals(child, onError, onExit);
|
||||
child.emit('error', err);
|
||||
child.emit('exit', 1, null);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(onError).toHaveBeenCalledTimes(1);
|
||||
expect(onExit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('wireChildProcessSignals should exit with code 1 when onError throws', async () => {
|
||||
const child = createMockChildProcess();
|
||||
const onError = jest.fn(async () => {
|
||||
throw new Error('handler exploded');
|
||||
});
|
||||
const exitSpy = jest
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation((() => undefined as never) as typeof process.exit);
|
||||
|
||||
try {
|
||||
const err = Object.assign(new Error('spawn failed'), { code: 'ENOENT' }) as NodeJS.ErrnoException;
|
||||
wireChildProcessSignals(child, onError);
|
||||
child.emit('error', err);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(onError).toHaveBeenCalledTimes(1);
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
} finally {
|
||||
exitSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user