mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-15 06:16:03 +00:00
fix(targets): close all remaining multi-target droid edge cases
This commit is contained in:
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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user