fix(targets): close all remaining multi-target droid edge cases

This commit is contained in:
Tam Nhu Tran committed 2026-02-17 03:22:45 +07:00
1 parent 3191a4ab38
commit 0431adf306
21 files changed
+1257 -265

No files matched your search

@@ -0,0 +1,56 @@
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import {
registerSession,
unregisterSession,
getProxyStatus,
} from '../../../src/cliproxy/session-tracker';
describe('session-tracker target metadata', () => {
let tmpDir: string;
let originalCcsHome: string | undefined;
const port = 28317;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-session-target-test-'));
originalCcsHome = process.env.CCS_HOME;
process.env.CCS_HOME = tmpDir;
});
afterEach(() => {
if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome;
} else {
delete process.env.CCS_HOME;
}
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('returns single target when all sessions share same target', () => {
const s1 = registerSession(port, process.pid, undefined, undefined, 'droid');
const s2 = registerSession(port, process.pid, undefined, undefined, 'droid');
const status = getProxyStatus(port);
expect(status.running).toBe(true);
expect(status.target).toBe('droid');
expect(status.sessionCount).toBe(2);
unregisterSession(s1, port);
unregisterSession(s2, port);
});
it('returns mixed when active sessions use different targets', () => {
const s1 = registerSession(port, process.pid, undefined, undefined, 'claude');
const s2 = registerSession(port, process.pid, undefined, undefined, 'droid');
const status = getProxyStatus(port);
expect(status.running).toBe(true);
expect(status.target).toBe('mixed');
expect(status.sessionCount).toBe(2);
unregisterSession(s1, port);
unregisterSession(s2, port);
});
});
+36
View File
@@ -237,6 +237,42 @@ describe('aggregateSessionUsage', () => {
expect(result[1].sessionId).toBe('old-session');
});
test('propagates target metadata into session usage', () => {
const entries: RawUsageEntry[] = [
createEntry({ sessionId: 'session-A', target: 'droid' }),
createEntry({ sessionId: 'session-B' }),
];
const result = aggregateSessionUsage(entries);
const sessionA = result.find((s) => s.sessionId === 'session-A');
const sessionB = result.find((s) => s.sessionId === 'session-B');
expect(sessionA?.target).toBe('droid');
expect(sessionB?.target).toBeUndefined();
});
test('uses latest timestamp target when a session has mixed targets', () => {
const entries: RawUsageEntry[] = [
createEntry({
sessionId: 'session-A',
timestamp: '2025-12-09T10:00:00.000Z',
target: 'claude',
}),
createEntry({
sessionId: 'session-A',
timestamp: '2025-12-09T12:00:00.000Z',
target: 'droid',
}),
createEntry({
sessionId: 'session-A',
timestamp: '2025-12-09T11:00:00.000Z',
}),
];
const result = aggregateSessionUsage(entries);
expect(result[0].target).toBe('droid');
});
test('returns empty array for no entries', () => {
const result = aggregateSessionUsage([]);
expect(result.length).toBe(0);
+20
View File
@@ -147,6 +147,26 @@ describe('parseUsageEntry', () => {
const result = parseUsageEntry(VALID_ASSISTANT_ENTRY, '/custom/project/path');
expect(result!.projectPath).toBe('/custom/project/path');
});
test('parses string target when present', () => {
const withTarget = JSON.stringify({
...JSON.parse(VALID_ASSISTANT_ENTRY),
target: 'droid',
});
const result = parseUsageEntry(withTarget, '/test');
expect(result).not.toBeNull();
expect(result!.target).toBe('droid');
});
test('ignores non-string target values', () => {
const withNumericTarget = JSON.stringify({
...JSON.parse(VALID_ASSISTANT_ENTRY),
target: 123,
});
const result = parseUsageEntry(withNumericTarget, '/test');
expect(result).not.toBeNull();
expect(result!.target).toBeUndefined();
});
});
// ============================================================================
@@ -109,6 +109,40 @@ describe('droid-config-manager', () => {
expect(settings.customModels[1].displayName).toBe('CCS gemini');
});
it('should preserve user entries with unknown provider strings', async () => {
const factoryDir = path.join(tmpDir, '.factory');
fs.mkdirSync(factoryDir, { recursive: true });
fs.writeFileSync(
path.join(factoryDir, 'settings.json'),
JSON.stringify({
customModels: [
{
model: 'user-model',
displayName: 'My Custom Provider',
baseUrl: 'https://example.invalid',
apiKey: 'user-key',
provider: 'custom-provider',
},
],
})
);
await upsertCcsModel('gemini', {
model: 'claude-opus-4-6',
displayName: 'CCS gemini',
baseUrl: 'http://localhost:8317',
apiKey: 'dummy',
provider: 'anthropic',
});
const settings = JSON.parse(
fs.readFileSync(path.join(factoryDir, 'settings.json'), 'utf8')
);
expect(settings.customModels).toHaveLength(2);
expect(settings.customModels[0].provider).toBe('custom-provider');
expect(settings.customModels[1].displayName).toBe('CCS gemini');
});
it('should write with restricted permissions', async () => {
await upsertCcsModel('test', {
model: 'test-model',
@@ -124,6 +158,23 @@ describe('droid-config-manager', () => {
const otherPerms = stat.mode & 0o077;
expect(otherPerms).toBe(0);
});
it('should reject symlinked temp file path', async () => {
const factoryDir = path.join(tmpDir, '.factory');
fs.mkdirSync(factoryDir, { recursive: true });
fs.writeFileSync(path.join(factoryDir, 'settings.json'), JSON.stringify({ customModels: [] }));
fs.symlinkSync('/tmp', path.join(factoryDir, 'settings.json.tmp'));
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\.tmp is a symlink/);
});
});
describe('removeCcsModel', () => {
@@ -192,6 +243,58 @@ describe('droid-config-manager', () => {
const models = await listCcsModels();
expect(models.size).toBe(0);
});
it('should normalize legacy object-map customModels', async () => {
const factoryDir = path.join(tmpDir, '.factory');
fs.mkdirSync(factoryDir, { recursive: true });
fs.writeFileSync(
path.join(factoryDir, 'settings.json'),
JSON.stringify({
customModels: {
gemini: {
model: 'opus',
displayName: 'CCS gemini',
baseUrl: 'http://localhost:8317',
apiKey: 'dummy',
provider: 'anthropic',
},
invalid: {
model: 'x',
baseUrl: 'x',
},
},
})
);
const models = await listCcsModels();
expect(models.size).toBe(1);
expect(models.has('gemini')).toBe(true);
});
it('should ignore malformed customModels entries', async () => {
const factoryDir = path.join(tmpDir, '.factory');
fs.mkdirSync(factoryDir, { recursive: true });
fs.writeFileSync(
path.join(factoryDir, 'settings.json'),
JSON.stringify({
customModels: [null, 123, 'bad', { displayName: 'CCS ok', model: 'x', baseUrl: 'x', apiKey: 'y', provider: 'anthropic' }],
})
);
const models = await listCcsModels();
expect(models.size).toBe(1);
expect(models.has('ok')).toBe(true);
});
it('should reject symlinked settings file on read', async () => {
const factoryDir = path.join(tmpDir, '.factory');
fs.mkdirSync(factoryDir, { recursive: true });
const target = path.join(factoryDir, 'real-settings.json');
fs.writeFileSync(target, JSON.stringify({ customModels: [] }));
fs.symlinkSync(target, path.join(factoryDir, 'settings.json'));
await expect(listCcsModels()).rejects.toThrow(/settings\.json is a symlink/);
});
});
describe('pruneOrphanedModels', () => {
+53
View File
@@ -0,0 +1,53 @@
/**
* Unit tests for Droid detector edge cases
*/
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { detectDroidCli, checkDroidVersion } from '../../../src/targets/droid-detector';
describe('droid-detector', () => {
let tmpDir: string;
let originalPath: string | undefined;
let originalDroidPath: string | undefined;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-droid-detector-test-'));
originalPath = process.env.PATH;
originalDroidPath = process.env.CCS_DROID_PATH;
process.env.PATH = '';
});
afterEach(() => {
if (originalPath !== undefined) process.env.PATH = originalPath;
else delete process.env.PATH;
if (originalDroidPath !== undefined) process.env.CCS_DROID_PATH = originalDroidPath;
else delete process.env.CCS_DROID_PATH;
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('should prefer CCS_DROID_PATH when it points to a file', () => {
const fakeDroid = path.join(tmpDir, 'droid');
fs.writeFileSync(fakeDroid, '#!/bin/sh\necho droid\n');
process.env.CCS_DROID_PATH = fakeDroid;
expect(detectDroidCli()).toBe(fakeDroid);
});
it('should fall back (null) when CCS_DROID_PATH points to directory', () => {
process.env.CCS_DROID_PATH = tmpDir;
expect(detectDroidCli()).toBeNull();
});
it('should fall back (null) when CCS_DROID_PATH does not exist', () => {
process.env.CCS_DROID_PATH = path.join(tmpDir, 'missing-droid');
expect(detectDroidCli()).toBeNull();
});
it('checkDroidVersion should be non-throwing for invalid binaries', () => {
expect(() => checkDroidVersion(path.join(tmpDir, 'missing-droid'))).not.toThrow();
});
});
+49 -3
View File
@@ -2,6 +2,9 @@
* Unit tests for target registry and adapters
*/
import { describe, it, expect, beforeEach } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import {
registerTarget,
getTarget,
@@ -112,11 +115,14 @@ describe('DroidAdapter', () => {
expect(adapter.supportsProfileType('account')).toBe(false);
});
it('should support non-account profile types', () => {
it('should support settings and default profile types', () => {
expect(adapter.supportsProfileType('settings')).toBe(true);
expect(adapter.supportsProfileType('cliproxy')).toBe(true);
expect(adapter.supportsProfileType('default')).toBe(true);
expect(adapter.supportsProfileType('copilot')).toBe(true);
});
it('should NOT support cliproxy and copilot profile types', () => {
expect(adapter.supportsProfileType('cliproxy')).toBe(false);
expect(adapter.supportsProfileType('copilot')).toBe(false);
});
it('should build args with -m custom:ccs- prefix', () => {
@@ -137,4 +143,44 @@ describe('DroidAdapter', () => {
expect(env['ANTHROPIC_BASE_URL']).toBeUndefined();
expect(env['ANTHROPIC_AUTH_TOKEN']).toBeUndefined();
});
it('prepareCredentials should reject missing required credentials', async () => {
await expect(
adapter.prepareCredentials({
profile: 'gemini',
baseUrl: '',
apiKey: 'dummy',
})
).rejects.toThrow(/ANTHROPIC_BASE_URL/);
await expect(
adapter.prepareCredentials({
profile: 'gemini',
baseUrl: 'http://localhost:8317',
apiKey: '',
})
).rejects.toThrow(/ANTHROPIC_AUTH_TOKEN/);
});
it('prepareCredentials should persist valid credentials', async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-droid-adapter-test-'));
const originalCcsHome = process.env.CCS_HOME;
process.env.CCS_HOME = tmpDir;
try {
await adapter.prepareCredentials({
profile: 'gemini',
baseUrl: 'http://localhost:8317',
apiKey: 'dummy-key',
model: 'claude-opus-4-6',
});
const settingsPath = path.join(tmpDir, '.factory', 'settings.json');
expect(fs.existsSync(settingsPath)).toBe(true);
} finally {
if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome;
else delete process.env.CCS_HOME;
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
});
+70 -4
View File
@@ -51,11 +51,19 @@ describe('resolveTargetType', () => {
expect(resolveTargetType([])).toBe('droid');
});
it('should not match ccsd with .exe extension', () => {
// .exe is not stripped — ccsd.exe won't match 'ccsd' in the map
// This is intentional — npm creates .cmd shims, not .exe
it('should strip .ps1 extension on Windows argv[0]', () => {
process.argv = ['node', 'ccsd.ps1'];
expect(resolveTargetType([])).toBe('droid');
});
it('should strip .exe extension on Windows argv[0]', () => {
process.argv = ['node', 'ccsd.exe'];
expect(resolveTargetType([])).toBe('claude');
expect(resolveTargetType([])).toBe('droid');
});
it('should handle full path argv[0]', () => {
process.argv = ['node', '/usr/local/bin/ccsd'];
expect(resolveTargetType([])).toBe('droid');
});
it('should prioritize --target over argv[0]', () => {
@@ -72,6 +80,31 @@ describe('resolveTargetType', () => {
process.argv = ['node', 'ccs'];
expect(() => resolveTargetType(['--target', 'invalid'])).toThrow(/Unknown target "invalid"/);
});
it('should support --target=<value> form', () => {
process.argv = ['node', 'ccs'];
expect(resolveTargetType(['--target=droid'])).toBe('droid');
});
it('should throw when --target is missing value', () => {
process.argv = ['node', 'ccs'];
expect(() => resolveTargetType(['--target'])).toThrow(/--target requires a value/);
});
it('should throw when --target value is another flag', () => {
process.argv = ['node', 'ccs'];
expect(() => resolveTargetType(['--target', '--help'])).toThrow(/--target requires a value/);
});
it('should use last --target flag when repeated', () => {
process.argv = ['node', 'ccs'];
expect(resolveTargetType(['--target', 'droid', '--target=claude'])).toBe('claude');
});
it('should ignore --target after option terminator', () => {
process.argv = ['node', 'ccs'];
expect(resolveTargetType(['--', '--target', 'droid'])).toBe('claude');
});
});
describe('stripTargetFlag', () => {
@@ -88,9 +121,42 @@ describe('stripTargetFlag', () => {
expect(stripTargetFlag(args)).toEqual(['gemini', '-p', 'hello']);
});
it('should remove --target=<value> form', () => {
expect(stripTargetFlag(['gemini', '--target=droid', '--verbose'])).toEqual([
'gemini',
'--verbose',
]);
});
it('should remove repeated --target flags', () => {
expect(stripTargetFlag(['--target', 'droid', 'gemini', '--target=claude', '--verbose'])).toEqual(
['gemini', '--verbose']
);
});
it('should throw when --target has no value', () => {
expect(() => stripTargetFlag(['gemini', '--target'])).toThrow(/--target requires a value/);
});
it('should throw when --target value is another flag', () => {
expect(() => stripTargetFlag(['gemini', '--target', '--help'])).toThrow(
/--target requires a value/
);
});
it('should not modify the original array', () => {
const args = ['--target', 'droid', 'gemini'];
stripTargetFlag(args);
expect(args).toEqual(['--target', 'droid', 'gemini']);
});
it('should preserve args after option terminator', () => {
expect(stripTargetFlag(['glm', '--', '--target', 'droid', '-p'])).toEqual([
'glm',
'--',
'--target',
'droid',
'-p',
]);
});
});