fix(targets): harden adapter lifecycle and droid model edge cases

This commit is contained in:
Tam Nhu Tran committed 2026-02-17 04:09:48 +07:00
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');
});
});
+184 -14
View File
@@ -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
);
});
});
+148
View File
@@ -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();
}
});
});