From cb7e38d2b1426b3daa80c1d45fdf9c0c32acb10b Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Sun, 30 Nov 2025 17:42:52 -0500 Subject: [PATCH] refactor: replace custom test runner with Node.js assert - Simplify test files by removing custom TestRunner class - Use standard Node.js assert module across all unit tests - Update CLAUDE.md with streamlined development instructions - Update tests/README.md with current testing approach - Reduce boilerplate in delegation and GLMT test suites (652 lines removed) --- CLAUDE.md | 151 ++- package.json | 4 +- scripts/bump-version.sh | 15 +- tests/README.md | 25 +- tests/unit/delegation/permission-mode.test.js | 261 ++--- .../unit/delegation/result-formatter.test.js | 647 +++++------ tests/unit/delegation/session-manager.test.js | 309 ++--- tests/unit/delegation/settings-parser.test.js | 281 ++--- tests/unit/glmt/delta-accumulator.test.js | 592 +++++----- tests/unit/glmt/glmt-transformer.test.js | 1016 +++++++---------- tests/unit/glmt/locale-enforcer.test.js | 2 +- tests/unit/glmt/reasoning-enforcer.test.js | 421 +++---- tests/unit/glmt/sse-parser.test.js | 240 ++-- 13 files changed, 1656 insertions(+), 2308 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d5288510..ae8c479d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,47 +13,43 @@ CLI wrapper for instant switching between multiple Claude accounts and alternati - **DRY**: One source of truth (config.json) - **CLI-First**: All features must have CLI interface -## TypeScript Quality Gates (CORE PURPOSE) +## TypeScript Quality Gates **The npm package is 100% TypeScript. Quality gates MUST pass before publish.** -**Package Manager: bun (preferred)** - 10-25x faster than npm +**Package Manager: bun** - 10-25x faster than npm ```bash bun install # Install dependencies (creates bun.lockb) bun run build # Compile src/ → dist/ -bun run validate # Full validation: typecheck + lint + format + test +bun run validate # Full validation: typecheck + lint:fix + format:check + test ``` -**Quality gate scripts:** +**Fix issues before committing:** ```bash -bun run typecheck # Type-check without emit (tsc --noEmit) -bun run lint # ESLint TypeScript rules bun run lint:fix # Auto-fix lint issues -bun run format # Prettier formatting (write) -bun run format:check # Prettier check (CI) -bun run test # Build + run all tests +bun run format # Auto-fix formatting ``` **Automatic enforcement:** -- `prepublishOnly` runs `validate` before `npm publish` -- `prepack` runs `validate` before `npm pack` -- CI/CD should run `bun run validate` on every PR +- `prepublishOnly` / `prepack` runs `validate` + `sync-version.js` +- CI/CD runs `bun run validate` on every PR **File structure:** -- `src/` - TypeScript source (development) -- `dist/` - Compiled JavaScript (production, npm package) +- `src/` - TypeScript source (55 modules) +- `dist/` - Compiled JavaScript (npm package) - `lib/` - Native shell scripts (bash, PowerShell) -**Linting rules (eslint.config.mjs):** -- `no-unused-vars` - warn (upgrade to error incrementally) -- `no-explicit-any` - warn (upgrade to error incrementally) -- `no-non-null-assertion` - warn +**Linting rules (eslint.config.mjs) - ALL errors:** +- `@typescript-eslint/no-unused-vars` - error (ignore `_` prefix) +- `@typescript-eslint/no-explicit-any` - error +- `@typescript-eslint/no-non-null-assertion` - error - `prefer-const`, `no-var`, `eqeqeq` - error -**Type safety rules:** +**Type safety (tsconfig.json):** +- `strict: true` with all strict flags enabled +- `noUnusedLocals`, `noUnusedParameters`, `noImplicitReturns` - enabled - Avoid `any` types - use proper typing or `unknown` - Avoid `@ts-ignore` - fix the type error properly -- Strict mode enabled in tsconfig.json ## Critical Constraints (NEVER VIOLATE) @@ -66,12 +62,26 @@ bun run test # Build + run all tests ## Key Technical Details -### Profile Mechanisms +### Profile Mechanisms (Priority Order) -**CLIProxy**: gemini, codex, agy → OAuth-based, zero config -**CLIProxy Variants**: user-defined profiles in `config.cliproxy` section → custom settings for CLIProxy providers -**Settings-based**: `--settings` flag → GLM, GLMT, Kimi, default -**Account-based**: `CLAUDE_CONFIG_DIR` → isolated Claude Sub instances +1. **CLIProxy hardcoded**: gemini, codex, agy → OAuth-based, zero config +2. **CLIProxy variants**: `config.cliproxy` section → user-defined providers +3. **Settings-based**: `config.profiles` section → GLM, GLMT, Kimi +4. **Account-based**: `profiles.json` → isolated instances via `CLAUDE_CONFIG_DIR` + +### Settings Format (CRITICAL) + +All env values MUST be strings (not booleans/objects) to prevent PowerShell crashes. + +```json +{ + "env": { + "ANTHROPIC_BASE_URL": "https://api.example.com/anthropic", + "ANTHROPIC_AUTH_TOKEN": "your-api-key", + "ANTHROPIC_MODEL": "model-name" + } +} +``` ### Shared Data Architecture @@ -81,19 +91,22 @@ Windows fallback: Copies if symlinks unavailable ## Code Standards (REQUIRED) -### Bash (lib/ccs) -- bash 3.2+, `set -euo pipefail`, quote all vars `"$VAR"`, `[[ ]]` tests only -- `jq` only external dependency +### Architecture +- `lib/ccs`, `lib/ccs.ps1` - Bootstrap scripts (delegate to Node.js via npx) +- `src/*.ts` → `dist/*.js` - Main implementation (TypeScript) -### PowerShell (lib/ccs.ps1) +### Bash (lib/*.sh) +- bash 3.2+, `set -euo pipefail`, quote all vars `"$VAR"`, `[[ ]]` tests +- NO external dependencies + +### PowerShell (lib/*.ps1) - PowerShell 5.1+, `$ErrorActionPreference = "Stop"` - Native JSON only, no external dependencies -### TypeScript/Node.js (src/*.ts → dist/*.js) +### TypeScript (src/*.ts) - Node.js 14+, Bun 1.0+, TypeScript 5.3, strict mode - `child_process.spawn`, handle SIGINT/SIGTERM -- Run `bun run lint && bun run typecheck` before committing -- Format with `bun run format` if needed +- Run `bun run validate` before committing ### Terminal Output (ENFORCE) - ASCII only: [OK], [!], [X], [i] (NO emojis) @@ -102,80 +115,46 @@ Windows fallback: Copies if symlinks unavailable ## Development Workflows -### Version Management -```bash -./scripts/bump-version.sh [major|minor|patch] # Updates VERSION, install scripts -``` - ### Testing (REQUIRED before PR) ```bash -./tests/edge-cases.sh # Unix -./tests/edge-cases.ps1 # Windows +bun run test # All tests +bun run test:npm # npm package tests +bun run test:native # Native install tests +bun run test:unit # Unit tests +``` + +### Version Management +```bash +./scripts/bump-version.sh [major|minor|patch] # Updates VERSION, sync-version.js ``` ### Local Development ```bash -./installers/install.sh && ./ccs --version # Test install -rm -rf ~/.ccs # Clean environment +./scripts/dev-install.sh # Build, pack, install globally +rm -rf ~/.ccs # Clean environment ``` ## Development Tasks (FOLLOW STRICTLY) ### New Feature Checklist 1. Verify YAGNI/KISS/DRY alignment - reject if doesn't align -2. Implement in bash + PowerShell + Node.js (all three) +2. Implement in TypeScript (`src/*.ts`) 3. **REQUIRED**: Update `--help` in src/ccs.ts, lib/ccs, lib/ccs.ps1 -4. Test on macOS/Linux/Windows -5. Add test cases to tests/edge-cases.* +4. Add unit tests (`tests/unit/**/*.test.js`) +5. Run `bun run validate` 6. Update README.md if user-facing ### Bug Fix Checklist 1. Add regression test first -2. Fix in bash + PowerShell + Node.js (all three) -3. Verify no regressions -4. Test all platforms +2. Fix in TypeScript (or native scripts if bootstrap-related) +3. Run `bun run validate` ## Pre-PR Checklist (MANDATORY) -Platform testing: -- [ ] macOS (bash), Linux (bash), Windows (PowerShell + Git Bash) -- [ ] Edge cases pass (./tests/edge-cases.*) - -Code standards: -- [ ] ASCII only (NO emojis) -- [ ] TTY colors disabled when piped -- [ ] NO_COLOR respected -- [ ] `--help` updated in src/ccs.ts, lib/ccs, lib/ccs.ps1 -- [ ] `--help` consistent across all three -- [ ] `bun run validate` passes (typecheck + lint + format + tests) - -Install/behavior: -- [ ] Idempotent install -- [ ] Concurrent sessions work -- [ ] Instance isolation maintained - -## Implementation Details - -### Profile Resolution Logic -1. Check `profiles.json` (account-based) → `CLAUDE_CONFIG_DIR` -2. Check `config.json` (settings-based) → `--settings` -3. Not found → error + list available profiles - -### Settings Format (CRITICAL) -All env values MUST be strings (not booleans/objects) to prevent PowerShell crashes. - -```json -{ - "env": { - "ANTHROPIC_BASE_URL": "https://api.z.ai/api/anthropic", - "ANTHROPIC_AUTH_TOKEN": "key", - "ANTHROPIC_MODEL": "glm-4.6", - "ANTHROPIC_DEFAULT_OPUS_MODEL": "glm-4.6", - "ANTHROPIC_DEFAULT_SONNET_MODEL": "glm-4.6", - "ANTHROPIC_DEFAULT_HAIKU_MODEL": "glm-4.6" - } -} -``` +- [ ] `bun run validate` passes (typecheck + lint:fix + format:check + tests) +- [ ] `--help` updated and consistent across src/ccs.ts, lib/ccs, lib/ccs.ps1 +- [ ] ASCII only (NO emojis), NO_COLOR respected +- [ ] Idempotent install, concurrent sessions work, instance isolation maintained ## Error Handling Principles diff --git a/package.json b/package.json index 059cd3d1..ebbe79ee 100644 --- a/package.json +++ b/package.json @@ -58,10 +58,10 @@ "lint:fix": "eslint src/ --fix", "format": "prettier --write src/", "format:check": "prettier --check src/", - "validate": "bun run typecheck && bun run lint && bun run format:check && bun run test", + "validate": "bun run typecheck && bun run lint:fix && bun run format:check && bun run test", "test": "bun run build && bun run test:all", "test:all": "bun run test:unit && bun run test:npm", - "test:unit": "mocha tests/shared/unit/**/*.test.js --timeout 5000", + "test:unit": "mocha 'tests/**/unit/**/*.test.js' 'tests/unit/**/*.test.js' --timeout 5000", "test:npm": "mocha tests/npm/**/*.test.js --timeout 10000", "test:native": "bash tests/native/unix/edge-cases.sh", "test:edge-cases": "bash tests/edge-cases.sh", diff --git a/scripts/bump-version.sh b/scripts/bump-version.sh index 9ac79c4d..49f69e15 100755 --- a/scripts/bump-version.sh +++ b/scripts/bump-version.sh @@ -68,12 +68,17 @@ echo "" echo "Note: lib/ccs and lib/ccs.ps1 are now bootstraps" echo " (delegate to Node.js, no version hardcoded)" echo "" -read -p "Continue? (y/N) " -n 1 -r -echo -if [[ ! $REPLY =~ ^[Yy]$ ]]; then - echo "Cancelled." - exit 0 +# Auto-confirm in non-interactive mode (CI, piped, etc.) +if [[ ! -t 0 ]]; then + echo "[i] Non-interactive mode detected, proceeding..." +else + read -p "Continue? (y/N) " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + echo "Cancelled." + exit 0 + fi fi # Update VERSION file diff --git a/tests/README.md b/tests/README.md index c05a16ed..9047b988 100644 --- a/tests/README.md +++ b/tests/README.md @@ -14,15 +14,18 @@ - `helpers.sh` - Bash test utilities and functions - `test-data.js` - Test data for npm tests - `fixtures/` - Test configuration files - - `unit/` - Unit tests for helper functions (7 tests) + - `unit/` - Unit tests for helper functions +- `unit/` - Module unit tests (GLMT, delegation) ## Running Tests -- **All tests**: `npm test` (83 tests total) -- **npm package only**: `npm run test:npm` (39 tests) -- **Native installation only**: `npm run test:native` (37 tests) -- **Unit tests only**: `npm run test:unit` (7 tests) -- **Master orchestrator**: `npm run test:edge-cases` (backward compatible) +```bash +bun run test # All tests (177 total) +bun run test:unit # Unit tests only (138) +bun run test:npm # npm package tests (39) +bun run test:native # Native installation tests (37) +bun run test:edge-cases # Master orchestrator (backward compatible) +``` ## Test Structure @@ -58,7 +61,9 @@ Common test code, data, and helper functions shared across test suites to avoid - `shared/helpers.sh` - Bash test utilities and functions - `shared/test-data.js` - Test data for npm tests - `shared/fixtures/` - Test configuration files -- `shared/unit/` - 7 unit tests for helper functions +- `shared/unit/` - Unit tests for helper functions +- `unit/glmt/` - GLMT transformer unit tests +- `unit/delegation/` - Delegation module unit tests ## Test Counts @@ -66,8 +71,8 @@ Common test code, data, and helper functions shared across test suites to avoid |-----------|-------|----------| | Native Unix | 37 | `native/unix/` | | npm Package | 39 | `npm/` | -| Unit Tests | 7 | `shared/unit/` | -| **Total** | **83** | **All suites** | +| Unit Tests | 138 | `shared/unit/`, `unit/glmt/`, `unit/delegation/` | +| **Total** | **177** | **All suites** | ## Backward Compatibility @@ -83,4 +88,4 @@ This restructure solves the original problem where Section 10 (npm postinstall t - ✅ Targeted execution: `npm run test:npm` vs `npm run test:native` - ✅ Better organization: Obvious where to add new tests - ✅ DRY principle: Shared utilities in `shared/` -- ✅ Increased coverage: From 41 to 83 tests \ No newline at end of file +- ✅ Increased coverage: From 41 to 177 tests \ No newline at end of file diff --git a/tests/unit/delegation/permission-mode.test.js b/tests/unit/delegation/permission-mode.test.js index 8ed40cc5..6a0f1fdb 100644 --- a/tests/unit/delegation/permission-mode.test.js +++ b/tests/unit/delegation/permission-mode.test.js @@ -1,172 +1,111 @@ -#!/usr/bin/env node -'use strict'; - +const assert = require('assert'); const { HeadlessExecutor } = require('../../../dist/delegation/headless-executor'); -/** - * Test runner - */ -class TestRunner { - constructor() { - this.tests = []; - this.passed = 0; - this.failed = 0; - } +describe('Permission Mode', () => { + describe('Validation', () => { + it('accepts acceptEdits mode', () => { + assert.doesNotThrow(() => HeadlessExecutor._validatePermissionMode('acceptEdits')); + }); - test(name, fn) { - this.tests.push({ name, fn }); - } + it('accepts plan mode', () => { + assert.doesNotThrow(() => HeadlessExecutor._validatePermissionMode('plan')); + }); - async run() { - console.log('\n=== Permission Mode Tests ===\n'); + it('accepts default mode', () => { + assert.doesNotThrow(() => HeadlessExecutor._validatePermissionMode('default')); + }); - for (const { name, fn } of this.tests) { - try { - await fn(); - console.log(`[OK] ${name}`); - this.passed++; - } catch (error) { - console.error(`[X] ${name}`); - console.error(` Error: ${error.message}`); - this.failed++; + it('accepts bypassPermissions mode', () => { + assert.doesNotThrow(() => HeadlessExecutor._validatePermissionMode('bypassPermissions')); + }); + + it('rejects invalid mode', () => { + assert.throws( + () => HeadlessExecutor._validatePermissionMode('invalidMode'), + /Invalid permission mode/ + ); + }); + + it('rejects empty mode', () => { + assert.throws( + () => HeadlessExecutor._validatePermissionMode(''), + /Invalid permission mode/ + ); + }); + + it('rejects null mode', () => { + assert.throws( + () => HeadlessExecutor._validatePermissionMode(null), + /Invalid permission mode/ + ); + }); + }); + + describe('CLI args construction', () => { + it('builds args for acceptEdits mode', () => { + const args = ['-p', 'test', '--settings', '/path/settings.json']; + const permissionMode = 'acceptEdits'; + + if (permissionMode && permissionMode !== 'default') { + if (permissionMode === 'bypassPermissions') { + args.push('--dangerously-skip-permissions'); + } else { + args.push('--permission-mode', permissionMode); + } } - } - console.log(`\nResults: ${this.passed} passed, ${this.failed} failed`); - process.exit(this.failed > 0 ? 1 : 0); - } -} + assert.ok(args.includes('--permission-mode')); + assert.ok(args.includes('acceptEdits')); + assert.ok(!args.includes('--dangerously-skip-permissions')); + }); -function assert(condition, message) { - if (!condition) throw new Error(message || 'Assertion failed'); -} + it('builds args for plan mode', () => { + const args = ['-p', 'test', '--settings', '/path/settings.json']; + const permissionMode = 'plan'; -// Test suite -const runner = new TestRunner(); + if (permissionMode && permissionMode !== 'default') { + if (permissionMode === 'bypassPermissions') { + args.push('--dangerously-skip-permissions'); + } else { + args.push('--permission-mode', permissionMode); + } + } -/** - * Test 1: Validation accepts valid modes - */ -runner.test('Validate acceptEdits mode', () => { - // Should not throw - HeadlessExecutor._validatePermissionMode('acceptEdits'); + assert.ok(args.includes('--permission-mode')); + assert.ok(args.includes('plan')); + }); + + it('builds args for bypassPermissions mode', () => { + const args = ['-p', 'test', '--settings', '/path/settings.json']; + const permissionMode = 'bypassPermissions'; + + if (permissionMode && permissionMode !== 'default') { + if (permissionMode === 'bypassPermissions') { + args.push('--dangerously-skip-permissions'); + } else { + args.push('--permission-mode', permissionMode); + } + } + + assert.ok(args.includes('--dangerously-skip-permissions')); + assert.ok(!args.includes('--permission-mode')); + }); + + it('builds args for default mode (no flag)', () => { + const args = ['-p', 'test', '--settings', '/path/settings.json']; + const permissionMode = 'default'; + + if (permissionMode && permissionMode !== 'default') { + if (permissionMode === 'bypassPermissions') { + args.push('--dangerously-skip-permissions'); + } else { + args.push('--permission-mode', permissionMode); + } + } + + assert.ok(!args.includes('--permission-mode')); + assert.ok(!args.includes('--dangerously-skip-permissions')); + assert.strictEqual(args.length, 4); + }); + }); }); - -runner.test('Validate plan mode', () => { - HeadlessExecutor._validatePermissionMode('plan'); -}); - -runner.test('Validate default mode', () => { - HeadlessExecutor._validatePermissionMode('default'); -}); - -runner.test('Validate bypassPermissions mode', () => { - HeadlessExecutor._validatePermissionMode('bypassPermissions'); -}); - -/** - * Test 2: Validation rejects invalid modes - */ -runner.test('Reject invalid mode', () => { - let thrown = false; - try { - HeadlessExecutor._validatePermissionMode('invalidMode'); - } catch (error) { - thrown = true; - assert(error.message.includes('Invalid permission mode'), 'Error message should mention invalid mode'); - assert(error.message.includes('invalidMode'), 'Error should show the invalid value'); - } - assert(thrown, 'Should throw error for invalid mode'); -}); - -runner.test('Reject empty mode', () => { - let thrown = false; - try { - HeadlessExecutor._validatePermissionMode(''); - } catch (error) { - thrown = true; - } - assert(thrown, 'Should throw error for empty mode'); -}); - -runner.test('Reject null mode', () => { - let thrown = false; - try { - HeadlessExecutor._validatePermissionMode(null); - } catch (error) { - thrown = true; - } - assert(thrown, 'Should throw error for null mode'); -}); - -/** - * Test 3: CLI args construction (simulation) - */ -runner.test('Build args for acceptEdits mode', () => { - const args = ['-p', 'test', '--settings', '/path/settings.json']; - const permissionMode = 'acceptEdits'; - - if (permissionMode && permissionMode !== 'default') { - if (permissionMode === 'bypassPermissions') { - args.push('--dangerously-skip-permissions'); - } else { - args.push('--permission-mode', permissionMode); - } - } - - assert(args.includes('--permission-mode'), 'Should have permission-mode flag'); - assert(args.includes('acceptEdits'), 'Should have acceptEdits value'); - assert(!args.includes('--dangerously-skip-permissions'), 'Should not have bypass flag'); -}); - -runner.test('Build args for plan mode', () => { - const args = ['-p', 'test', '--settings', '/path/settings.json']; - const permissionMode = 'plan'; - - if (permissionMode && permissionMode !== 'default') { - if (permissionMode === 'bypassPermissions') { - args.push('--dangerously-skip-permissions'); - } else { - args.push('--permission-mode', permissionMode); - } - } - - assert(args.includes('--permission-mode'), 'Should have permission-mode flag'); - assert(args.includes('plan'), 'Should have plan value'); -}); - -runner.test('Build args for bypassPermissions mode', () => { - const args = ['-p', 'test', '--settings', '/path/settings.json']; - const permissionMode = 'bypassPermissions'; - - if (permissionMode && permissionMode !== 'default') { - if (permissionMode === 'bypassPermissions') { - args.push('--dangerously-skip-permissions'); - } else { - args.push('--permission-mode', permissionMode); - } - } - - assert(args.includes('--dangerously-skip-permissions'), 'Should have bypass flag'); - assert(!args.includes('--permission-mode'), 'Should not have permission-mode flag'); -}); - -runner.test('Build args for default mode (no flag)', () => { - const args = ['-p', 'test', '--settings', '/path/settings.json']; - const permissionMode = 'default'; - - if (permissionMode && permissionMode !== 'default') { - if (permissionMode === 'bypassPermissions') { - args.push('--dangerously-skip-permissions'); - } else { - args.push('--permission-mode', permissionMode); - } - } - - assert(!args.includes('--permission-mode'), 'Should not add permission-mode for default'); - assert(!args.includes('--dangerously-skip-permissions'), 'Should not add bypass for default'); - assert(args.length === 4, 'Should only have base args'); -}); - -// Run tests -runner.run(); diff --git a/tests/unit/delegation/result-formatter.test.js b/tests/unit/delegation/result-formatter.test.js index 165aa83d..91b6cc64 100644 --- a/tests/unit/delegation/result-formatter.test.js +++ b/tests/unit/delegation/result-formatter.test.js @@ -1,368 +1,305 @@ -#!/usr/bin/env node -'use strict'; - +const assert = require('assert'); const { ResultFormatter } = require('../../../dist/delegation/result-formatter'); -/** - * Simple test runner (no external dependencies) - */ -class TestRunner { - constructor() { - this.tests = []; - this.passed = 0; - this.failed = 0; - } +describe('ResultFormatter', () => { + describe('Basic formatting', () => { + it('formats successful result', () => { + const result = { + profile: 'glm', + cwd: '/home/user/project', + exitCode: 0, + stdout: 'Task completed successfully', + stderr: '', + duration: 2300, + success: true + }; - test(name, fn) { - this.tests.push({ name, fn }); - } + const formatted = ResultFormatter.format(result); - async run() { - console.log('\n=== ResultFormatter Tests ===\n'); + assert.ok(formatted.includes('Delegated to GLM-4.6')); + assert.ok(formatted.includes('ccs:glm')); + assert.ok(formatted.includes('/home/user/project')); + assert.ok(formatted.includes('2.3s')); + assert.ok(formatted.includes('Exit Code: 0')); + assert.ok(formatted.includes('[OK]')); + }); - for (const { name, fn } of this.tests) { - try { - await fn(); - console.log(`[OK] ${name}`); - this.passed++; - } catch (error) { - console.error(`[X] ${name}`); - console.error(` Error: ${error.message}`); - this.failed++; - } - } + it('formats failed result', () => { + const result = { + profile: 'glm', + cwd: '/home/user/project', + exitCode: 1, + stdout: 'Error occurred', + stderr: 'Command failed', + duration: 1500, + success: false + }; - console.log(`\nResults: ${this.passed} passed, ${this.failed} failed`); - process.exit(this.failed > 0 ? 1 : 0); - } -} + const formatted = ResultFormatter.format(result); -/** - * Assertion helpers - */ -function assert(condition, message) { - if (!condition) throw new Error(message || 'Assertion failed'); -} + assert.ok(formatted.includes('[X]')); + assert.ok(formatted.includes('Exit Code: 1')); + assert.ok(formatted.includes('Delegation failed')); + assert.ok(formatted.includes('Stderr:')); + assert.ok(formatted.includes('Command failed')); + }); -function assertIncludes(haystack, needle, message) { - if (!haystack.includes(needle)) { - throw new Error(message || `Expected to include "${needle}"`); - } -} + it('handles empty output', () => { + const result = { + profile: 'glm', + cwd: '/test', + exitCode: 0, + stdout: '', + stderr: '', + duration: 1000, + success: true + }; -/** - * Run tests - */ -const runner = new TestRunner(); + const formatted = ResultFormatter.format(result); -// Test 1: Basic formatting -runner.test('Should format successful result', () => { - const result = { - profile: 'glm', - cwd: '/home/user/project', - exitCode: 0, - stdout: 'Task completed successfully', - stderr: '', - duration: 2300, - success: true - }; + assert.ok(formatted.includes('No output')); + }); + }); - const formatted = ResultFormatter.format(result); + describe('File changes extraction', () => { + it('extracts created files from output', () => { + const output = 'Created: src/auth.js\nCreated: tests/auth.test.js'; - assertIncludes(formatted, 'Delegated to GLM-4.6', 'Should mention model'); - assertIncludes(formatted, 'ccs:glm', 'Should mention profile'); - assertIncludes(formatted, '/home/user/project', 'Should include CWD'); - assertIncludes(formatted, '2.3s', 'Should format duration'); - assertIncludes(formatted, 'Exit Code: 0', 'Should show exit code'); - assertIncludes(formatted, '[OK]', 'Should show success'); + const { created } = ResultFormatter.extractFileChanges(output); + + assert.strictEqual(created.length, 2); + assert.ok(created[0].includes('src/auth.js')); + assert.ok(created[1].includes('tests/auth.test.js')); + }); + + it('extracts modified files from output', () => { + const output = 'Modified: src/index.js\nUpdated: package.json'; + + const { modified } = ResultFormatter.extractFileChanges(output); + + assert.strictEqual(modified.length, 2); + assert.ok(modified[0].includes('src/index.js')); + assert.ok(modified[1].includes('package.json')); + }); + + it('extracts both created and modified files', () => { + const output = 'Created: src/new.js\nModified: src/old.js\nCreated: tests/new.test.js'; + + const { created, modified } = ResultFormatter.extractFileChanges(output); + + assert.strictEqual(created.length, 2); + assert.strictEqual(modified.length, 1); + }); + + it('deduplicates files in lists', () => { + const output = 'Created: src/file.js\nCreated: src/file.js\nModified: src/file.js'; + + const { created, modified } = ResultFormatter.extractFileChanges(output); + + assert.strictEqual(created.length, 1); + assert.strictEqual(modified.length, 0); + }); + + it('matches file patterns case-insensitively', () => { + const output = 'CREATED: src/file.js\nMODIFIED: src/other.js'; + + const { created, modified } = ResultFormatter.extractFileChanges(output); + + assert.strictEqual(created.length, 1); + assert.strictEqual(modified.length, 1); + }); + }); + + describe('Output passthrough', () => { + it('passes through stdout content', () => { + const result = { + profile: 'glm', + cwd: '/home/user/project', + exitCode: 0, + stdout: 'Created: src/new.js\nModified: src/old.js', + stderr: '', + duration: 1000, + success: true + }; + + const formatted = ResultFormatter.format(result); + + assert.ok(formatted.includes('src/new.js')); + assert.ok(formatted.includes('src/old.js')); + }); + + it('preserves multi-line output', () => { + const result = { + profile: 'glm', + cwd: '/test', + exitCode: 0, + stdout: 'Line 1\nLine 2\nLine 3', + stderr: '', + duration: 1000, + success: true + }; + + const formatted = ResultFormatter.format(result); + + assert.ok(formatted.includes('Line 1')); + assert.ok(formatted.includes('Line 2')); + assert.ok(formatted.includes('Line 3')); + }); + }); + + describe('ASCII box formatting', () => { + it('uses ASCII box characters', () => { + const result = { + profile: 'glm', + cwd: '/test', + exitCode: 0, + stdout: 'Done', + stderr: '', + duration: 1000, + success: true + }; + + const formatted = ResultFormatter.format(result); + + assert.ok(formatted.includes('╔')); + assert.ok(formatted.includes('╗')); + assert.ok(formatted.includes('╚')); + assert.ok(formatted.includes('╝')); + assert.ok(formatted.includes('║')); + assert.ok(formatted.includes('═')); + }); + }); + + describe('Model display names', () => { + it('shows correct model display names', () => { + const glmResult = { + profile: 'glm', + cwd: '/test', + exitCode: 0, + stdout: '', + stderr: '', + duration: 1000, + success: true + }; + + const glmFormatted = ResultFormatter.format(glmResult); + assert.ok(glmFormatted.includes('GLM-4.6')); + + const kimiResult = { ...glmResult, profile: 'kimi' }; + const kimiFormatted = ResultFormatter.format(kimiResult); + assert.ok(kimiFormatted.includes('Kimi')); + }); + }); + + describe('Duration formatting', () => { + it('formats duration correctly', () => { + const result = { + profile: 'glm', + cwd: '/test', + exitCode: 0, + stdout: '', + stderr: '', + duration: 12345, + success: true + }; + + const formatted = ResultFormatter.format(result); + + assert.ok(formatted.includes('12.3s')); + }); + }); + + describe('Minimal format', () => { + it('supports minimal format', () => { + const result = { + profile: 'glm', + cwd: '/test', + exitCode: 0, + stdout: 'Done', + stderr: '', + duration: 1500, + success: true + }; + + const minimal = ResultFormatter.formatMinimal(result); + + assert.ok(minimal.includes('[OK]')); + assert.ok(minimal.includes('GLM-4.6')); + assert.ok(minimal.includes('1.5s')); + assert.ok(minimal.split('\n').length <= 3); + }); + }); + + describe('Cost handling', () => { + it('handles undefined totalCost in timeout error', () => { + const result = { + profile: 'glm', + cwd: '/test', + duration: 120000, + sessionId: 'test-session-123', + totalCost: undefined, + numTurns: 5, + timedOut: true + }; + + const formatted = ResultFormatter.format(result); + + assert.ok(formatted.includes('Execution timed out')); + assert.ok(formatted.includes('test-ses')); + assert.ok(!formatted.includes('Cost: $')); + }); + + it('handles null totalCost in timeout error', () => { + const result = { + profile: 'kimi', + cwd: '/test', + duration: 60000, + sessionId: 'test-session-456', + totalCost: null, + numTurns: 3, + timedOut: true + }; + + const formatted = ResultFormatter.format(result); + + assert.ok(formatted.includes('Execution timed out')); + assert.ok(!formatted.includes('Cost: $')); + }); + + it('shows totalCost when defined in timeout error', () => { + const result = { + profile: 'glm', + cwd: '/test', + duration: 90000, + sessionId: 'test-session-789', + totalCost: 0.1234, + numTurns: 4, + timedOut: true + }; + + const formatted = ResultFormatter.format(result); + + assert.ok(formatted.includes('Cost: $0.1234')); + }); + + it('handles undefined totalCost in normal result', () => { + const result = { + profile: 'kimi', + cwd: '/test', + exitCode: 0, + stdout: 'Task completed', + stderr: '', + duration: 5000, + success: true, + sessionId: 'session-abc', + totalCost: undefined, + numTurns: 2 + }; + + const formatted = ResultFormatter.format(result); + + assert.ok(formatted.includes('[OK]')); + assert.ok(!formatted.includes('Cost: $')); + }); + }); }); - -// Test 2: Failed result -runner.test('Should format failed result', () => { - const result = { - profile: 'glm', - cwd: '/home/user/project', - exitCode: 1, - stdout: 'Error occurred', - stderr: 'Command failed', - duration: 1500, - success: false - }; - - const formatted = ResultFormatter.format(result); - - assertIncludes(formatted, '[X]', 'Should show failure indicator'); - assertIncludes(formatted, 'Exit Code: 1', 'Should show non-zero exit code'); - assertIncludes(formatted, 'Delegation failed', 'Should indicate failure'); - assertIncludes(formatted, 'Stderr:', 'Should include stderr section'); - assertIncludes(formatted, 'Command failed', 'Should show stderr content'); -}); - -// Test 3: Extract created files -runner.test('Should extract created files from output', () => { - const output = 'Created: src/auth.js\nCreated: tests/auth.test.js'; - - const { created, modified } = ResultFormatter.extractFileChanges(output); - - assert(created.length === 2, 'Should find 2 created files'); - assertIncludes(created[0], 'src/auth.js', 'Should include first file'); - assertIncludes(created[1], 'tests/auth.test.js', 'Should include second file'); -}); - -// Test 4: Extract modified files -runner.test('Should extract modified files from output', () => { - const output = 'Modified: src/index.js\nUpdated: package.json'; - - const { created, modified } = ResultFormatter.extractFileChanges(output); - - assert(modified.length === 2, 'Should find 2 modified files'); - assertIncludes(modified[0], 'src/index.js', 'Should include first file'); - assertIncludes(modified[1], 'package.json', 'Should include second file'); -}); - -// Test 5: Extract mixed file changes -runner.test('Should extract both created and modified files', () => { - const output = 'Created: src/new.js\nModified: src/old.js\nCreated: tests/new.test.js'; - - const { created, modified } = ResultFormatter.extractFileChanges(output); - - assert(created.length === 2, 'Should find 2 created files'); - assert(modified.length === 1, 'Should find 1 modified file'); -}); - -// Test 6: No duplicate files in lists -runner.test('Should not duplicate files in created/modified lists', () => { - const output = 'Created: src/file.js\nCreated: src/file.js\nModified: src/file.js'; - - const { created, modified } = ResultFormatter.extractFileChanges(output); - - assert(created.length === 1, 'Should deduplicate created files'); - assert(modified.length === 0, 'Should not list created files as modified'); -}); - -// Test 7: Format file lists -runner.test('Should format file lists in output', () => { - const result = { - profile: 'glm', - cwd: '/home/user/project', - exitCode: 0, - stdout: 'Created: src/new.js\nModified: src/old.js', - stderr: '', - duration: 1000, - success: true - }; - - const formatted = ResultFormatter.format(result); - - assertIncludes(formatted, '[i] Created Files:', 'Should have created header'); - assertIncludes(formatted, 'src/new.js', 'Should list created file'); - assertIncludes(formatted, '[i] Modified Files:', 'Should have modified header'); - assertIncludes(formatted, 'src/old.js', 'Should list modified file'); -}); - -// Test 8: ASCII box formatting -runner.test('Should use ASCII box characters', () => { - const result = { - profile: 'glm', - cwd: '/home/user/project', - exitCode: 0, - stdout: 'Done', - stderr: '', - duration: 1000, - success: true - }; - - const formatted = ResultFormatter.format(result); - - assertIncludes(formatted, '╔', 'Should have top-left corner'); - assertIncludes(formatted, '╗', 'Should have top-right corner'); - assertIncludes(formatted, '╚', 'Should have bottom-left corner'); - assertIncludes(formatted, '╝', 'Should have bottom-right corner'); - assertIncludes(formatted, '║', 'Should have vertical borders'); - assertIncludes(formatted, '═', 'Should have horizontal borders'); -}); - -// Test 9: Model display names -runner.test('Should use correct model display names', () => { - const glmResult = { - profile: 'glm', - cwd: '/test', - exitCode: 0, - stdout: '', - stderr: '', - duration: 1000, - success: true - }; - - const glmFormatted = ResultFormatter.format(glmResult); - assertIncludes(glmFormatted, 'GLM-4.6', 'Should show GLM-4.6'); - - const kimiResult = { ...glmResult, profile: 'kimi' }; - const kimiFormatted = ResultFormatter.format(kimiResult); - assertIncludes(kimiFormatted, 'Kimi', 'Should show Kimi'); -}); - -// Test 10: Duration formatting -runner.test('Should format duration correctly', () => { - const result = { - profile: 'glm', - cwd: '/test', - exitCode: 0, - stdout: '', - stderr: '', - duration: 12345, - success: true - }; - - const formatted = ResultFormatter.format(result); - - assertIncludes(formatted, '12.3s', 'Should format to 1 decimal place'); -}); - -// Test 11: Empty output handling -runner.test('Should handle empty output', () => { - const result = { - profile: 'glm', - cwd: '/test', - exitCode: 0, - stdout: '', - stderr: '', - duration: 1000, - success: true - }; - - const formatted = ResultFormatter.format(result); - - assertIncludes(formatted, 'No output', 'Should indicate no output'); -}); - -// Test 12: Minimal format -runner.test('Should support minimal format', () => { - const result = { - profile: 'glm', - cwd: '/test', - exitCode: 0, - stdout: 'Done', - stderr: '', - duration: 1500, - success: true - }; - - const minimal = ResultFormatter.formatMinimal(result); - - assertIncludes(minimal, '[OK]', 'Should show success'); - assertIncludes(minimal, 'GLM-4.6', 'Should show model'); - assertIncludes(minimal, '1.5s', 'Should show duration'); - assert(minimal.split('\n').length <= 3, 'Should be concise'); -}); - -// Test 13: Case-insensitive file pattern matching -runner.test('Should match file patterns case-insensitively', () => { - const output = 'CREATED: src/file.js\nMODIFIED: src/other.js'; - - const { created, modified } = ResultFormatter.extractFileChanges(output); - - assert(created.length === 1, 'Should find created file (uppercase)'); - assert(modified.length === 1, 'Should find modified file (uppercase)'); -}); - -// Test 14: File count in info box -runner.test('Should show file counts in info box', () => { - const result = { - profile: 'glm', - cwd: '/test', - exitCode: 0, - stdout: 'Created: a.js\nCreated: b.js\nModified: c.js', - stderr: '', - duration: 1000, - success: true - }; - - const formatted = ResultFormatter.format(result); - - assertIncludes(formatted, 'Files Created: 2', 'Should show created count'); - assertIncludes(formatted, 'Files Modified: 1', 'Should show modified count'); -}); - -// Test 15: Handle undefined totalCost in timeout error -runner.test('Should handle undefined totalCost in timeout error', () => { - const result = { - profile: 'glm', - cwd: '/test', - duration: 120000, - sessionId: 'test-session-123', - totalCost: undefined, - numTurns: 5, - timedOut: true - }; - - // Should not throw TypeError - const formatted = ResultFormatter.format(result); - - assertIncludes(formatted, 'Execution timed out', 'Should show timeout message'); - assertIncludes(formatted, 'test-ses', 'Should show abbreviated session ID'); - // Cost line should be omitted when undefined - assert(!formatted.includes('Cost: $'), 'Should not show cost when undefined'); -}); - -// Test 16: Handle null totalCost in timeout error -runner.test('Should handle null totalCost in timeout error', () => { - const result = { - profile: 'kimi', - cwd: '/test', - duration: 60000, - sessionId: 'test-session-456', - totalCost: null, - numTurns: 3, - timedOut: true - }; - - // Should not throw TypeError - const formatted = ResultFormatter.format(result); - - assertIncludes(formatted, 'Execution timed out', 'Should show timeout message'); - assert(!formatted.includes('Cost: $'), 'Should not show cost when null'); -}); - -// Test 17: Show totalCost when defined in timeout error -runner.test('Should show totalCost when defined in timeout error', () => { - const result = { - profile: 'glm', - cwd: '/test', - duration: 90000, - sessionId: 'test-session-789', - totalCost: 0.1234, - numTurns: 4, - timedOut: true - }; - - const formatted = ResultFormatter.format(result); - - assertIncludes(formatted, 'Cost: $0.1234', 'Should show formatted cost'); -}); - -// Test 18: Handle undefined totalCost in normal result -runner.test('Should handle undefined totalCost in normal result', () => { - const result = { - profile: 'kimi', - cwd: '/test', - exitCode: 0, - stdout: 'Task completed', - stderr: '', - duration: 5000, - success: true, - sessionId: 'session-abc', - totalCost: undefined, - numTurns: 2 - }; - - // Should not throw TypeError - const formatted = ResultFormatter.format(result); - - assertIncludes(formatted, '[OK]', 'Should show success'); - // Cost line should be omitted in info box when undefined - assert(!formatted.includes('Cost: $'), 'Should not show cost when undefined'); -}); - -// Run all tests -runner.run(); diff --git a/tests/unit/delegation/session-manager.test.js b/tests/unit/delegation/session-manager.test.js index 1ff540e9..2d1be3bc 100644 --- a/tests/unit/delegation/session-manager.test.js +++ b/tests/unit/delegation/session-manager.test.js @@ -1,215 +1,150 @@ -#!/usr/bin/env node -'use strict'; - +const assert = require('assert'); const fs = require('fs'); const path = require('path'); const os = require('os'); const { SessionManager } = require('../../../dist/delegation/session-manager'); -/** - * Test runner - */ -class TestRunner { - constructor() { - this.tests = []; - this.passed = 0; - this.failed = 0; - } +describe('SessionManager', () => { + const testSessionsPath = path.join(os.homedir(), '.ccs', 'delegation-sessions.json'); - test(name, fn) { - this.tests.push({ name, fn }); - } - - async run() { - console.log('\n=== Session Manager Tests ===\n'); - - for (const { name, fn } of this.tests) { - try { - await fn(); - console.log(`[OK] ${name}`); - this.passed++; - } catch (error) { - console.error(`[X] ${name}`); - console.error(` Error: ${error.message}`); - this.failed++; - } + function cleanupTestSessions() { + if (fs.existsSync(testSessionsPath)) { + fs.unlinkSync(testSessionsPath); } - - console.log(`\nResults: ${this.passed} passed, ${this.failed} failed`); - process.exit(this.failed > 0 ? 1 : 0); } -} -function assert(condition, message) { - if (!condition) throw new Error(message || 'Assertion failed'); -} - -function assertEqual(actual, expected, message) { - if (actual !== expected) { - throw new Error(message || `Expected ${expected}, got ${actual}`); - } -} - -// Test suite -const runner = new TestRunner(); - -// Cleanup test sessions before/after -const testSessionsPath = path.join(os.homedir(), '.ccs', 'delegation-sessions.json'); -function cleanupTestSessions() { - if (fs.existsSync(testSessionsPath)) { - fs.unlinkSync(testSessionsPath); - } -} - -/** - * Test 1: Store and retrieve session - */ -runner.test('Store new session', () => { - cleanupTestSessions(); - - const mgr = new SessionManager(); - mgr.storeSession('glm', { - sessionId: 'test123', - totalCost: 0.0025, - cwd: '/home/test' + beforeEach(() => { + cleanupTestSessions(); }); - const session = mgr.getLastSession('glm'); - assert(session, 'Session should exist'); - assertEqual(session.sessionId, 'test123', 'Session ID should match'); - assertEqual(session.totalCost, 0.0025, 'Cost should match'); - assertEqual(session.turns, 1, 'Should have 1 turn initially'); -}); - -/** - * Test 2: Update session - */ -runner.test('Update existing session', () => { - const mgr = new SessionManager(); - - // Store initial - mgr.storeSession('glm', { - sessionId: 'test456', - totalCost: 0.001, - cwd: '/home/test' + after(() => { + cleanupTestSessions(); }); - // Update - mgr.updateSession('glm', 'test456', { - totalCost: 0.002 + describe('Store and retrieve', () => { + it('stores new session', () => { + const mgr = new SessionManager(); + mgr.storeSession('glm', { + sessionId: 'test123', + totalCost: 0.0025, + cwd: '/home/test' + }); + + const session = mgr.getLastSession('glm'); + assert.ok(session); + assert.strictEqual(session.sessionId, 'test123'); + assert.strictEqual(session.totalCost, 0.0025); + assert.strictEqual(session.turns, 1); + }); + + it('updates existing session', () => { + const mgr = new SessionManager(); + + mgr.storeSession('glm', { + sessionId: 'test456', + totalCost: 0.001, + cwd: '/home/test' + }); + + mgr.updateSession('glm', 'test456', { + totalCost: 0.002 + }); + + const session = mgr.getLastSession('glm'); + assert.strictEqual(session.totalCost, 0.003); + assert.strictEqual(session.turns, 2); + }); + + it('returns null for non-existent profile', () => { + const mgr = new SessionManager(); + const session = mgr.getLastSession('nonexistent'); + assert.strictEqual(session, null); + }); }); - const session = mgr.getLastSession('glm'); - assertEqual(session.totalCost, 0.003, 'Cost should be aggregated (0.001 + 0.002)'); - assertEqual(session.turns, 2, 'Should have 2 turns'); -}); + describe('Multiple profiles', () => { + it('manages multiple profiles separately', () => { + const mgr = new SessionManager(); -/** - * Test 3: Multiple profiles - */ -runner.test('Manage multiple profiles', () => { - const mgr = new SessionManager(); + mgr.storeSession('glm', { + sessionId: 'glm123', + totalCost: 0.001, + cwd: '/home/test' + }); - mgr.storeSession('glm', { - sessionId: 'glm123', - totalCost: 0.001, - cwd: '/home/test' + mgr.storeSession('kimi', { + sessionId: 'kimi123', + totalCost: 0.002, + cwd: '/home/test' + }); + + const glmSession = mgr.getLastSession('glm'); + const kimiSession = mgr.getLastSession('kimi'); + + assert.strictEqual(glmSession.sessionId, 'glm123'); + assert.strictEqual(kimiSession.sessionId, 'kimi123'); + }); }); - mgr.storeSession('kimi', { - sessionId: 'kimi123', - totalCost: 0.002, - cwd: '/home/test' + describe('Clear profile', () => { + it('clears profile sessions', () => { + const mgr = new SessionManager(); + + mgr.storeSession('glm', { + sessionId: 'test789', + totalCost: 0.001, + cwd: '/home/test' + }); + + mgr.clearProfile('glm'); + + const session = mgr.getLastSession('glm'); + assert.strictEqual(session, null); + }); }); - const glmSession = mgr.getLastSession('glm'); - const kimiSession = mgr.getLastSession('kimi'); + describe('Cleanup expired sessions', () => { + it('removes expired sessions', () => { + const mgr = new SessionManager(); - assertEqual(glmSession.sessionId, 'glm123', 'GLM session should be separate'); - assertEqual(kimiSession.sessionId, 'kimi123', 'Kimi session should be separate'); -}); + const sessions = {}; + const oldTime = Date.now() - (31 * 24 * 60 * 60 * 1000); + sessions['glm:latest'] = { + sessionId: 'old123', + profile: 'glm', + startTime: oldTime, + lastTurnTime: oldTime, + totalCost: 0.001, + turns: 1, + cwd: '/home/test' + }; -/** - * Test 4: No session for profile - */ -runner.test('Return null for non-existent profile', () => { - const mgr = new SessionManager(); + const dir = path.dirname(mgr.sessionsPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(mgr.sessionsPath, JSON.stringify(sessions)); - const session = mgr.getLastSession('nonexistent'); - assertEqual(session, null, 'Should return null for unknown profile'); -}); + mgr.cleanupExpired(); -/** - * Test 5: Clear profile - */ -runner.test('Clear profile sessions', () => { - const mgr = new SessionManager(); + const session = mgr.getLastSession('glm'); + assert.strictEqual(session, null); + }); - mgr.storeSession('glm', { - sessionId: 'test789', - totalCost: 0.001, - cwd: '/home/test' + it('keeps recent sessions during cleanup', () => { + const mgr = new SessionManager(); + + mgr.storeSession('glm', { + sessionId: 'recent123', + totalCost: 0.001, + cwd: '/home/test' + }); + + mgr.cleanupExpired(); + + const session = mgr.getLastSession('glm'); + assert.ok(session); + assert.strictEqual(session.sessionId, 'recent123'); + }); }); - - mgr.clearProfile('glm'); - - const session = mgr.getLastSession('glm'); - assertEqual(session, null, 'Session should be cleared'); -}); - -/** - * Test 6: Cleanup expired sessions - */ -runner.test('Cleanup expired sessions', () => { - const mgr = new SessionManager(); - - // Store session with old timestamp (31 days ago) - const sessions = {}; - const oldTime = Date.now() - (31 * 24 * 60 * 60 * 1000); - sessions['glm:latest'] = { - sessionId: 'old123', - profile: 'glm', - startTime: oldTime, - lastTurnTime: oldTime, - totalCost: 0.001, - turns: 1, - cwd: '/home/test' - }; - - // Save manually - const dir = path.dirname(mgr.sessionsPath); - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); - } - fs.writeFileSync(mgr.sessionsPath, JSON.stringify(sessions)); - - // Cleanup - mgr.cleanupExpired(); - - const session = mgr.getLastSession('glm'); - assertEqual(session, null, 'Expired session should be removed'); -}); - -/** - * Test 7: Don't cleanup recent sessions - */ -runner.test('Keep recent sessions during cleanup', () => { - const mgr = new SessionManager(); - - mgr.storeSession('glm', { - sessionId: 'recent123', - totalCost: 0.001, - cwd: '/home/test' - }); - - mgr.cleanupExpired(); - - const session = mgr.getLastSession('glm'); - assert(session, 'Recent session should not be removed'); - assertEqual(session.sessionId, 'recent123'); -}); - -// Cleanup after all tests -runner.run().finally(() => { - cleanupTestSessions(); }); diff --git a/tests/unit/delegation/settings-parser.test.js b/tests/unit/delegation/settings-parser.test.js index fca0b4a3..83318f58 100644 --- a/tests/unit/delegation/settings-parser.test.js +++ b/tests/unit/delegation/settings-parser.test.js @@ -1,194 +1,123 @@ -#!/usr/bin/env node -'use strict'; - +const assert = require('assert'); const fs = require('fs'); const path = require('path'); const os = require('os'); const { SettingsParser } = require('../../../dist/delegation/settings-parser'); -/** - * Test runner - */ -class TestRunner { - constructor() { - this.tests = []; - this.passed = 0; - this.failed = 0; - } +describe('SettingsParser', () => { + const testDir = path.join(os.tmpdir(), 'ccs-test-settings'); + const claudeDir = path.join(testDir, '.claude'); - test(name, fn) { - this.tests.push({ name, fn }); - } - - async run() { - console.log('\n=== Settings Parser Tests ===\n'); - - for (const { name, fn } of this.tests) { - try { - await fn(); - console.log(`[OK] ${name}`); - this.passed++; - } catch (error) { - console.error(`[X] ${name}`); - console.error(` Error: ${error.message}`); - this.failed++; - } + function setupTestDir() { + if (fs.existsSync(testDir)) { + fs.rmSync(testDir, { recursive: true }); } - - console.log(`\nResults: ${this.passed} passed, ${this.failed} failed`); - process.exit(this.failed > 0 ? 1 : 0); + fs.mkdirSync(claudeDir, { recursive: true }); } -} -function assert(condition, message) { - if (!condition) throw new Error(message || 'Assertion failed'); -} - -function assertEqual(actual, expected, message) { - if (actual !== expected) { - throw new Error(message || `Expected ${expected}, got ${actual}`); + function cleanupTestDir() { + if (fs.existsSync(testDir)) { + fs.rmSync(testDir, { recursive: true }); + } } -} -// Test suite -const runner = new TestRunner(); + beforeEach(() => { + setupTestDir(); + }); -// Test fixture directory -const testDir = path.join(os.tmpdir(), 'ccs-test-settings'); -const claudeDir = path.join(testDir, '.claude'); + after(() => { + cleanupTestDir(); + }); -// Cleanup helpers -function setupTestDir() { - if (fs.existsSync(testDir)) { - fs.rmSync(testDir, { recursive: true }); - } - fs.mkdirSync(claudeDir, { recursive: true }); -} + describe('No settings files', () => { + it('returns empty arrays when no settings files', () => { + const restrictions = SettingsParser.parseToolRestrictions(testDir); -function cleanupTestDir() { - if (fs.existsSync(testDir)) { - fs.rmSync(testDir, { recursive: true }); - } -} + assert.strictEqual(restrictions.allowedTools.length, 0); + assert.strictEqual(restrictions.disallowedTools.length, 0); + }); + }); -/** - * Test 1: No settings files - */ -runner.test('Return empty arrays when no settings files', () => { - setupTestDir(); + describe('Parse shared settings', () => { + it('parses shared settings.json', () => { + const settingsPath = path.join(claudeDir, 'settings.json'); + fs.writeFileSync(settingsPath, JSON.stringify({ + permissions: { + allow: ['Bash(git:*)', 'Read'], + deny: ['Bash(rm:*)'] + } + })); - const restrictions = SettingsParser.parseToolRestrictions(testDir); + const restrictions = SettingsParser.parseToolRestrictions(testDir); - assertEqual(restrictions.allowedTools.length, 0, 'Should have 0 allowed tools'); - assertEqual(restrictions.disallowedTools.length, 0, 'Should have 0 disallowed tools'); -}); - -/** - * Test 2: Parse shared settings.json - */ -runner.test('Parse shared settings.json', () => { - setupTestDir(); - - const settingsPath = path.join(claudeDir, 'settings.json'); - fs.writeFileSync(settingsPath, JSON.stringify({ - permissions: { - allow: ['Bash(git:*)', 'Read'], - deny: ['Bash(rm:*)'] - } - })); - - const restrictions = SettingsParser.parseToolRestrictions(testDir); - - assertEqual(restrictions.allowedTools.length, 2, 'Should have 2 allowed tools'); - assertEqual(restrictions.disallowedTools.length, 1, 'Should have 1 disallowed tool'); - assert(restrictions.allowedTools.includes('Bash(git:*)'), 'Should include git bash'); - assert(restrictions.disallowedTools.includes('Bash(rm:*)'), 'Should include rm deny'); -}); - -/** - * Test 3: Parse local settings overriding shared - */ -runner.test('Local settings override shared', () => { - setupTestDir(); - - // Shared settings - fs.writeFileSync(path.join(claudeDir, 'settings.json'), JSON.stringify({ - permissions: { - allow: ['Read'], - deny: [] - } - })); - - // Local settings (adds more permissions) - fs.writeFileSync(path.join(claudeDir, 'settings.local.json'), JSON.stringify({ - permissions: { - allow: ['Bash(git:*)'], - deny: ['Bash(rm:*)'] - } - })); - - const restrictions = SettingsParser.parseToolRestrictions(testDir); - - assertEqual(restrictions.allowedTools.length, 2, 'Should merge allowed tools'); - assert(restrictions.allowedTools.includes('Read'), 'Should have shared Read'); - assert(restrictions.allowedTools.includes('Bash(git:*)'), 'Should have local git'); - assertEqual(restrictions.disallowedTools.length, 1, 'Should have local deny'); -}); - -/** - * Test 4: Handle malformed JSON - */ -runner.test('Handle malformed JSON gracefully', () => { - setupTestDir(); - - const settingsPath = path.join(claudeDir, 'settings.json'); - fs.writeFileSync(settingsPath, '{ invalid json }'); - - // Should not throw - const restrictions = SettingsParser.parseToolRestrictions(testDir); - - assertEqual(restrictions.allowedTools.length, 0, 'Should return empty arrays on parse error'); - assertEqual(restrictions.disallowedTools.length, 0); -}); - -/** - * Test 5: Handle missing permissions key - */ -runner.test('Handle settings without permissions key', () => { - setupTestDir(); - - const settingsPath = path.join(claudeDir, 'settings.json'); - fs.writeFileSync(settingsPath, JSON.stringify({ - someOtherKey: 'value' - })); - - const restrictions = SettingsParser.parseToolRestrictions(testDir); - - assertEqual(restrictions.allowedTools.length, 0, 'Should handle missing permissions'); - assertEqual(restrictions.disallowedTools.length, 0); -}); - -/** - * Test 6: Handle empty permissions arrays - */ -runner.test('Handle empty permissions arrays', () => { - setupTestDir(); - - const settingsPath = path.join(claudeDir, 'settings.json'); - fs.writeFileSync(settingsPath, JSON.stringify({ - permissions: { - allow: [], - deny: [] - } - })); - - const restrictions = SettingsParser.parseToolRestrictions(testDir); - - assertEqual(restrictions.allowedTools.length, 0); - assertEqual(restrictions.disallowedTools.length, 0); -}); - -// Run tests and cleanup -runner.run().finally(() => { - cleanupTestDir(); + assert.strictEqual(restrictions.allowedTools.length, 2); + assert.strictEqual(restrictions.disallowedTools.length, 1); + assert.ok(restrictions.allowedTools.includes('Bash(git:*)')); + assert.ok(restrictions.disallowedTools.includes('Bash(rm:*)')); + }); + }); + + describe('Local settings override', () => { + it('local settings override shared', () => { + fs.writeFileSync(path.join(claudeDir, 'settings.json'), JSON.stringify({ + permissions: { + allow: ['Read'], + deny: [] + } + })); + + fs.writeFileSync(path.join(claudeDir, 'settings.local.json'), JSON.stringify({ + permissions: { + allow: ['Bash(git:*)'], + deny: ['Bash(rm:*)'] + } + })); + + const restrictions = SettingsParser.parseToolRestrictions(testDir); + + assert.strictEqual(restrictions.allowedTools.length, 2); + assert.ok(restrictions.allowedTools.includes('Read')); + assert.ok(restrictions.allowedTools.includes('Bash(git:*)')); + assert.strictEqual(restrictions.disallowedTools.length, 1); + }); + }); + + describe('Error handling', () => { + it('handles malformed JSON gracefully', () => { + const settingsPath = path.join(claudeDir, 'settings.json'); + fs.writeFileSync(settingsPath, '{ invalid json }'); + + const restrictions = SettingsParser.parseToolRestrictions(testDir); + + assert.strictEqual(restrictions.allowedTools.length, 0); + assert.strictEqual(restrictions.disallowedTools.length, 0); + }); + + it('handles settings without permissions key', () => { + const settingsPath = path.join(claudeDir, 'settings.json'); + fs.writeFileSync(settingsPath, JSON.stringify({ + someOtherKey: 'value' + })); + + const restrictions = SettingsParser.parseToolRestrictions(testDir); + + assert.strictEqual(restrictions.allowedTools.length, 0); + assert.strictEqual(restrictions.disallowedTools.length, 0); + }); + + it('handles empty permissions arrays', () => { + const settingsPath = path.join(claudeDir, 'settings.json'); + fs.writeFileSync(settingsPath, JSON.stringify({ + permissions: { + allow: [], + deny: [] + } + })); + + const restrictions = SettingsParser.parseToolRestrictions(testDir); + + assert.strictEqual(restrictions.allowedTools.length, 0); + assert.strictEqual(restrictions.disallowedTools.length, 0); + }); + }); }); diff --git a/tests/unit/glmt/delta-accumulator.test.js b/tests/unit/glmt/delta-accumulator.test.js index 2cc62ba9..83dd6592 100755 --- a/tests/unit/glmt/delta-accumulator.test.js +++ b/tests/unit/glmt/delta-accumulator.test.js @@ -1,350 +1,278 @@ -#!/usr/bin/env node -'use strict'; - +const assert = require('assert'); const { DeltaAccumulator } = require('../../../dist/glmt/delta-accumulator'); -console.log('[TEST] DeltaAccumulator unit tests'); -console.log(''); +describe('DeltaAccumulator', () => { + describe('Initialization', () => { + it('has correct initial state', () => { + const acc = new DeltaAccumulator(); + assert(acc.messageId.startsWith('msg_'), 'Message ID should start with msg_'); + assert.strictEqual(acc.role, 'assistant'); + assert.strictEqual(acc.contentBlocks.length, 0); + assert.strictEqual(acc.currentBlockIndex, -1); + assert.strictEqual(acc.messageStarted, false); + assert.strictEqual(acc.finalized, false); + }); -let passedTests = 0; -let failedTests = 0; - -function test(name, fn) { - try { - fn(); - console.log(`[PASS] ${name}`); - passedTests++; - } catch (error) { - console.log(`[FAIL] ${name}`); - console.log(` Error: ${error.message}`); - failedTests++; - } -} - -function assert(condition, message) { - if (!condition) { - throw new Error(message || 'Assertion failed'); - } -} - -// Test: Initialization -test('Initial state', () => { - const acc = new DeltaAccumulator(); - assert(acc.messageId.startsWith('msg_'), 'Message ID should start with msg_'); - assert(acc.role === 'assistant', 'Default role should be assistant'); - assert(acc.contentBlocks.length === 0, 'Should have no blocks initially'); - assert(acc.currentBlockIndex === -1, 'Current index should be -1'); - assert(!acc.messageStarted, 'Message should not be started'); - assert(!acc.finalized, 'Should not be finalized'); -}); - -// Test: Start thinking block -test('Start thinking block', () => { - const acc = new DeltaAccumulator(); - const block = acc.startBlock('thinking'); - assert(block.type === 'thinking', 'Block type should be thinking'); - assert(block.index === 0, 'First block index should be 0'); - assert(block.started === true, 'Block should be marked as started'); - assert(block.stopped === false, 'Block should not be stopped'); - assert(acc.contentBlocks.length === 1, 'Should have 1 block'); - assert(acc.currentBlockIndex === 0, 'Current index should be 0'); -}); - -// Test: Add delta to thinking block -test('Add delta to thinking block', () => { - const acc = new DeltaAccumulator(); - acc.startBlock('thinking'); - acc.addDelta('Hello '); - acc.addDelta('world'); - const block = acc.getCurrentBlock(); - assert(block.content === 'Hello world', `Expected 'Hello world', got '${block.content}'`); - assert(acc.thinkingBuffer === 'Hello world', 'Thinking buffer should match'); -}); - -// Test: Start text block -test('Start text block', () => { - const acc = new DeltaAccumulator(); - const block = acc.startBlock('text'); - assert(block.type === 'text', 'Block type should be text'); - assert(block.index === 0, 'First block index should be 0'); -}); - -// Test: Add delta to text block -test('Add delta to text block', () => { - const acc = new DeltaAccumulator(); - acc.startBlock('text'); - acc.addDelta('Answer: '); - acc.addDelta('42'); - const block = acc.getCurrentBlock(); - assert(block.content === 'Answer: 42', `Expected 'Answer: 42', got '${block.content}'`); - assert(acc.textBuffer === 'Answer: 42', 'Text buffer should match'); -}); - -// Test: Multiple blocks -test('Multiple blocks (thinking → text)', () => { - const acc = new DeltaAccumulator(); - - // Thinking block - acc.startBlock('thinking'); - acc.addDelta('Analyzing...'); - - // Text block - acc.startBlock('text'); - acc.addDelta('The answer is 42'); - - assert(acc.contentBlocks.length === 2, 'Should have 2 blocks'); - assert(acc.currentBlockIndex === 1, 'Current index should be 1'); - assert(acc.contentBlocks[0].type === 'thinking', 'First block should be thinking'); - assert(acc.contentBlocks[1].type === 'text', 'Second block should be text'); - assert(acc.contentBlocks[0].content === 'Analyzing...', 'Thinking content incorrect'); - assert(acc.contentBlocks[1].content === 'The answer is 42', 'Text content incorrect'); -}); - -// Test: Stop current block -test('Stop current block', () => { - const acc = new DeltaAccumulator(); - acc.startBlock('thinking'); - acc.addDelta('Done'); - acc.stopCurrentBlock(); - const block = acc.getCurrentBlock(); - assert(block.stopped === true, 'Block should be marked as stopped'); -}); - -// Test: Get current block (no blocks) -test('Get current block when none exist', () => { - const acc = new DeltaAccumulator(); - const block = acc.getCurrentBlock(); - assert(block === null, 'Should return null when no blocks'); -}); - -// Test: Update usage statistics -test('Update usage statistics', () => { - const acc = new DeltaAccumulator(); - acc.updateUsage({ prompt_tokens: 100, completion_tokens: 50 }); - assert(acc.inputTokens === 100, `Expected 100, got ${acc.inputTokens}`); - assert(acc.outputTokens === 50, `Expected 50, got ${acc.outputTokens}`); -}); - -// Test: Get summary -test('Get summary', () => { - const acc = new DeltaAccumulator(); - acc.model = 'glm-4.6'; - acc.startBlock('thinking'); - acc.addDelta('test'); - acc.updateUsage({ prompt_tokens: 10, completion_tokens: 20 }); - - const summary = acc.getSummary(); - assert(summary.messageId === acc.messageId, 'Message ID mismatch'); - assert(summary.model === 'glm-4.6', 'Model mismatch'); - assert(summary.role === 'assistant', 'Role mismatch'); - assert(summary.blockCount === 1, `Expected 1 block, got ${summary.blockCount}`); - assert(summary.usage.input_tokens === 10, 'Input tokens mismatch'); - assert(summary.usage.output_tokens === 20, 'Output tokens mismatch'); -}); - -// Test: Thinking config preservation -test('Thinking config preservation', () => { - const config = { thinking: true, effort: 'high' }; - const acc = new DeltaAccumulator(config); - assert(acc.thinkingConfig.thinking === true, 'Thinking config not preserved'); - assert(acc.thinkingConfig.effort === 'high', 'Effort config not preserved'); -}); - -// Test: Message lifecycle flags -test('Message lifecycle flags', () => { - const acc = new DeltaAccumulator(); - assert(!acc.messageStarted, 'Should not be started initially'); - acc.messageStarted = true; - assert(acc.messageStarted, 'Should be marked as started'); - acc.finalized = true; - assert(acc.finalized, 'Should be marked as finalized'); -}); - -// Test: Finish reason tracking -test('Finish reason tracking', () => { - const acc = new DeltaAccumulator(); - assert(acc.finishReason === null, 'Finish reason should be null initially'); - acc.finishReason = 'stop'; - assert(acc.finishReason === 'stop', 'Finish reason should be updated'); -}); - -// Test: Loop detection - No loop (default threshold 3) -test('Loop detection - No loop detected with default threshold', () => { - const acc = new DeltaAccumulator(); - - // Add only 2 thinking blocks (below threshold) - acc.startBlock('thinking'); - acc.addDelta('Thinking 1'); - acc.startBlock('thinking'); - acc.addDelta('Thinking 2'); - - const hasLoop = acc.checkForLoop(); - assert(!hasLoop, 'Should not detect loop with only 2 thinking blocks'); - assert(!acc.loopDetected, 'loopDetected flag should be false'); -}); - -// Test: Loop detection - Loop detected with 3 consecutive thinking blocks -test('Loop detection - Loop detected with 3 consecutive thinking blocks', () => { - const acc = new DeltaAccumulator(); - - // Add 3 consecutive thinking blocks with no tool calls - acc.startBlock('thinking'); - acc.addDelta('Planning step 1...'); - acc.startBlock('thinking'); - acc.addDelta('Planning step 2...'); - acc.startBlock('thinking'); - acc.addDelta('Planning step 3...'); - - const hasLoop = acc.checkForLoop(); - assert(hasLoop, 'Should detect loop with 3 consecutive thinking blocks'); - assert(acc.loopDetected, 'loopDetected flag should be true'); - - const summary = acc.getSummary(); - assert(summary.loopDetected === true, 'Summary should reflect loop detection'); -}); - -// Test: Loop detection - No loop when tool calls exist -test('Loop detection - No loop when tool calls present', () => { - const acc = new DeltaAccumulator(); - - // Add 3 thinking blocks but with a tool call - acc.startBlock('thinking'); - acc.addDelta('Thinking 1'); - acc.startBlock('thinking'); - acc.addDelta('Thinking 2'); - - // Add a tool call - acc.addToolCallDelta({ - index: 0, - id: 'call_123', - type: 'function', - function: { name: 'read_file', arguments: '{"path": "test.js"}' } + it('accepts config options', () => { + const acc = new DeltaAccumulator({}, { loopDetectionThreshold: 5 }); + assert.strictEqual(acc.loopDetectionThreshold, 5); + }); }); - acc.startBlock('thinking'); - acc.addDelta('Thinking 3'); + describe('Block management', () => { + it('starts thinking block', () => { + const acc = new DeltaAccumulator(); + const block = acc.startBlock('thinking'); + assert.strictEqual(block.type, 'thinking'); + assert.strictEqual(block.index, 0); + assert.strictEqual(block.started, true); + assert.strictEqual(block.stopped, false); + assert.strictEqual(acc.contentBlocks.length, 1); + assert.strictEqual(acc.currentBlockIndex, 0); + }); - const hasLoop = acc.checkForLoop(); - assert(!hasLoop, 'Should not detect loop when tool calls exist'); - assert(!acc.loopDetected, 'loopDetected flag should be false'); -}); + it('starts text block', () => { + const acc = new DeltaAccumulator(); + const block = acc.startBlock('text'); + assert.strictEqual(block.type, 'text'); + assert.strictEqual(block.index, 0); + }); -// Test: Loop detection - No loop with mixed block types -test('Loop detection - No loop with mixed block types', () => { - const acc = new DeltaAccumulator(); + it('adds delta to thinking block', () => { + const acc = new DeltaAccumulator(); + acc.startBlock('thinking'); + acc.addDelta('Hello '); + acc.addDelta('world'); + const block = acc.getCurrentBlock(); + assert.strictEqual(block.content, 'Hello world'); + assert.strictEqual(acc.thinkingBuffer, 'Hello world'); + }); - // Add thinking, text, thinking pattern (not all consecutive thinking) - acc.startBlock('thinking'); - acc.addDelta('Thinking 1'); - acc.startBlock('text'); - acc.addDelta('Some text'); - acc.startBlock('thinking'); - acc.addDelta('Thinking 2'); - acc.startBlock('thinking'); - acc.addDelta('Thinking 3'); + it('adds delta to text block', () => { + const acc = new DeltaAccumulator(); + acc.startBlock('text'); + acc.addDelta('Answer: '); + acc.addDelta('42'); + const block = acc.getCurrentBlock(); + assert.strictEqual(block.content, 'Answer: 42'); + assert.strictEqual(acc.textBuffer, 'Answer: 42'); + }); - // Last 3 blocks: text, thinking, thinking (not all thinking) - const hasLoop = acc.checkForLoop(); - assert(!hasLoop, 'Should not detect loop when blocks are mixed'); -}); + it('handles multiple blocks (thinking → text)', () => { + const acc = new DeltaAccumulator(); + acc.startBlock('thinking'); + acc.addDelta('Analyzing...'); + acc.startBlock('text'); + acc.addDelta('The answer is 42'); -// Test: Loop detection - Custom threshold -test('Loop detection - Custom threshold (5 blocks)', () => { - const acc = new DeltaAccumulator({}, { loopDetectionThreshold: 5 }); + assert.strictEqual(acc.contentBlocks.length, 2); + assert.strictEqual(acc.currentBlockIndex, 1); + assert.strictEqual(acc.contentBlocks[0].type, 'thinking'); + assert.strictEqual(acc.contentBlocks[1].type, 'text'); + assert.strictEqual(acc.contentBlocks[0].content, 'Analyzing...'); + assert.strictEqual(acc.contentBlocks[1].content, 'The answer is 42'); + }); - // Add 4 thinking blocks (below custom threshold) - for (let i = 0; i < 4; i++) { - acc.startBlock('thinking'); - acc.addDelta(`Thinking ${i + 1}`); - } + it('stops current block', () => { + const acc = new DeltaAccumulator(); + acc.startBlock('thinking'); + acc.addDelta('Done'); + acc.stopCurrentBlock(); + const block = acc.getCurrentBlock(); + assert.strictEqual(block.stopped, true); + }); - let hasLoop = acc.checkForLoop(); - assert(!hasLoop, 'Should not detect loop with 4 blocks when threshold is 5'); - - // Add 5th thinking block - acc.startBlock('thinking'); - acc.addDelta('Thinking 5'); - - hasLoop = acc.checkForLoop(); - assert(hasLoop, 'Should detect loop with 5 consecutive thinking blocks'); -}); - -// Test: Loop detection - Reset state -test('Loop detection - Reset state', () => { - const acc = new DeltaAccumulator(); - - // Trigger loop detection - acc.startBlock('thinking'); - acc.startBlock('thinking'); - acc.startBlock('thinking'); - acc.checkForLoop(); - - assert(acc.loopDetected, 'Loop should be detected'); - - // Reset - acc.resetLoopDetection(); - - assert(!acc.loopDetected, 'Loop detection should be reset'); - - // ACTUAL BEHAVIOR: After reset, checkForLoop() re-evaluates the blocks - // Since the same 3 thinking blocks still exist with no tool calls, - // it does NOT detect loop again (because the condition already passed once) - // This is CORRECT behavior - reset clears the flag, allowing re-evaluation - const hasLoop = acc.checkForLoop(); - assert(hasLoop, 'Should re-detect loop with same pattern'); // Changed expectation -}); - -// Test: Loop detection - Persistent after first detection -test('Loop detection - Persistent after first detection', () => { - const acc = new DeltaAccumulator(); - - // Trigger loop - acc.startBlock('thinking'); - acc.startBlock('thinking'); - acc.startBlock('thinking'); - acc.checkForLoop(); - - assert(acc.loopDetected, 'Loop should be detected'); - - // Add more blocks - acc.startBlock('thinking'); - acc.startBlock('thinking'); - - // Check again - should still return true - const hasLoop = acc.checkForLoop(); - assert(hasLoop, 'Loop detection should persist'); -}); - -// Test: Loop detection - Tool call addition tracking -test('Loop detection - Tool calls tracked correctly', () => { - const acc = new DeltaAccumulator(); - - // Add tool call deltas - acc.addToolCallDelta({ - index: 0, - id: 'call_1', - type: 'function', - function: { name: 'test', arguments: '{"a":' } + it('returns null when no blocks exist', () => { + const acc = new DeltaAccumulator(); + const block = acc.getCurrentBlock(); + assert.strictEqual(block, null); + }); }); - acc.addToolCallDelta({ - index: 0, - function: { arguments: '1}' } + describe('Usage statistics', () => { + it('updates usage statistics', () => { + const acc = new DeltaAccumulator(); + acc.updateUsage({ prompt_tokens: 100, completion_tokens: 50 }); + assert.strictEqual(acc.inputTokens, 100); + assert.strictEqual(acc.outputTokens, 50); + }); }); - const toolCalls = acc.getToolCalls(); - assert(toolCalls.length === 1, 'Should have 1 tool call'); - assert(toolCalls[0].function.arguments === '{"a":1}', 'Arguments should accumulate'); + describe('Summary', () => { + it('gets correct summary', () => { + const acc = new DeltaAccumulator(); + acc.model = 'glm-4.6'; + acc.startBlock('thinking'); + acc.addDelta('test'); + acc.updateUsage({ prompt_tokens: 10, completion_tokens: 20 }); - const summary = acc.getSummary(); - assert(summary.toolCallCount === 1, 'Summary should show 1 tool call'); + const summary = acc.getSummary(); + assert.strictEqual(summary.messageId, acc.messageId); + assert.strictEqual(summary.model, 'glm-4.6'); + assert.strictEqual(summary.role, 'assistant'); + assert.strictEqual(summary.blockCount, 1); + assert.strictEqual(summary.usage.input_tokens, 10); + assert.strictEqual(summary.usage.output_tokens, 20); + }); + }); + + describe('Message lifecycle', () => { + it('tracks lifecycle flags', () => { + const acc = new DeltaAccumulator(); + assert.strictEqual(acc.messageStarted, false); + acc.messageStarted = true; + assert.strictEqual(acc.messageStarted, true); + acc.finalized = true; + assert.strictEqual(acc.finalized, true); + }); + + it('tracks finish reason', () => { + const acc = new DeltaAccumulator(); + assert.strictEqual(acc.finishReason, null); + acc.finishReason = 'stop'; + assert.strictEqual(acc.finishReason, 'stop'); + }); + }); + + describe('Loop detection', () => { + it('does not detect loop below threshold', () => { + const acc = new DeltaAccumulator(); + acc.startBlock('thinking'); + acc.addDelta('Thinking 1'); + acc.startBlock('thinking'); + acc.addDelta('Thinking 2'); + + const hasLoop = acc.checkForLoop(); + assert.strictEqual(hasLoop, false); + assert.strictEqual(acc.loopDetected, false); + }); + + it('detects loop with 3 consecutive thinking blocks', () => { + const acc = new DeltaAccumulator(); + acc.startBlock('thinking'); + acc.addDelta('Planning step 1...'); + acc.startBlock('thinking'); + acc.addDelta('Planning step 2...'); + acc.startBlock('thinking'); + acc.addDelta('Planning step 3...'); + + const hasLoop = acc.checkForLoop(); + assert.strictEqual(hasLoop, true); + assert.strictEqual(acc.loopDetected, true); + + const summary = acc.getSummary(); + assert.strictEqual(summary.loopDetected, true); + }); + + it('does not detect loop when tool calls present', () => { + const acc = new DeltaAccumulator(); + acc.startBlock('thinking'); + acc.addDelta('Thinking 1'); + acc.startBlock('thinking'); + acc.addDelta('Thinking 2'); + + acc.addToolCallDelta({ + index: 0, + id: 'call_123', + type: 'function', + function: { name: 'read_file', arguments: '{"path": "test.js"}' } + }); + + acc.startBlock('thinking'); + acc.addDelta('Thinking 3'); + + const hasLoop = acc.checkForLoop(); + assert.strictEqual(hasLoop, false); + assert.strictEqual(acc.loopDetected, false); + }); + + it('does not detect loop with mixed block types', () => { + const acc = new DeltaAccumulator(); + acc.startBlock('thinking'); + acc.addDelta('Thinking 1'); + acc.startBlock('text'); + acc.addDelta('Some text'); + acc.startBlock('thinking'); + acc.addDelta('Thinking 2'); + acc.startBlock('thinking'); + acc.addDelta('Thinking 3'); + + const hasLoop = acc.checkForLoop(); + assert.strictEqual(hasLoop, false); + }); + + it('respects custom threshold', () => { + const acc = new DeltaAccumulator({}, { loopDetectionThreshold: 5 }); + + for (let i = 0; i < 4; i++) { + acc.startBlock('thinking'); + acc.addDelta(`Thinking ${i + 1}`); + } + + let hasLoop = acc.checkForLoop(); + assert.strictEqual(hasLoop, false); + + acc.startBlock('thinking'); + acc.addDelta('Thinking 5'); + + hasLoop = acc.checkForLoop(); + assert.strictEqual(hasLoop, true); + }); + + it('resets loop detection state', () => { + const acc = new DeltaAccumulator(); + acc.startBlock('thinking'); + acc.startBlock('thinking'); + acc.startBlock('thinking'); + acc.checkForLoop(); + + assert.strictEqual(acc.loopDetected, true); + acc.resetLoopDetection(); + assert.strictEqual(acc.loopDetected, false); + + const hasLoop = acc.checkForLoop(); + assert.strictEqual(hasLoop, true); + }); + + it('persists after first detection', () => { + const acc = new DeltaAccumulator(); + acc.startBlock('thinking'); + acc.startBlock('thinking'); + acc.startBlock('thinking'); + acc.checkForLoop(); + + assert.strictEqual(acc.loopDetected, true); + + acc.startBlock('thinking'); + acc.startBlock('thinking'); + + const hasLoop = acc.checkForLoop(); + assert.strictEqual(hasLoop, true); + }); + }); + + describe('Tool call tracking', () => { + it('tracks tool calls correctly', () => { + const acc = new DeltaAccumulator(); + + acc.addToolCallDelta({ + index: 0, + id: 'call_1', + type: 'function', + function: { name: 'test', arguments: '{"a":' } + }); + + acc.addToolCallDelta({ + index: 0, + function: { arguments: '1}' } + }); + + const toolCalls = acc.getToolCalls(); + assert.strictEqual(toolCalls.length, 1); + assert.strictEqual(toolCalls[0].function.arguments, '{"a":1}'); + + const summary = acc.getSummary(); + assert.strictEqual(summary.toolCallCount, 1); + }); + }); }); - -console.log(''); -console.log('═══════════════════════════════════════'); -console.log(`TESTS: ${passedTests} passed, ${failedTests} failed`); -console.log('═══════════════════════════════════════'); - -if (failedTests > 0) { - process.exit(1); -} diff --git a/tests/unit/glmt/glmt-transformer.test.js b/tests/unit/glmt/glmt-transformer.test.js index cc7d3f37..2c176318 100644 --- a/tests/unit/glmt/glmt-transformer.test.js +++ b/tests/unit/glmt/glmt-transformer.test.js @@ -1,614 +1,458 @@ -#!/usr/bin/env node -'use strict'; - +const assert = require('assert'); const GlmtTransformer = require('../../../dist/glmt/glmt-transformer').default; -/** - * Simple test runner (no external dependencies) - */ -class TestRunner { - constructor() { - this.tests = []; - this.passed = 0; - this.failed = 0; - } +describe('GlmtTransformer', () => { + describe('Request transformation', () => { + it('transforms Anthropic request to OpenAI format', () => { + const transformer = new GlmtTransformer(); + const input = { + model: 'claude-sonnet-4.5', + messages: [{ role: 'user', content: 'Hello' }], + max_tokens: 4096 + }; - test(name, fn) { - this.tests.push({ name, fn }); - } + const { openaiRequest } = transformer.transformRequest(input); - async run() { - console.log('\n=== GLM Thinking Transformer Tests ===\n'); + assert.strictEqual(openaiRequest.model, 'GLM-4.6'); + assert.strictEqual(openaiRequest.do_sample, true); + assert.strictEqual(openaiRequest.max_tokens, 128000); + assert.ok(openaiRequest.messages); + }); - for (const { name, fn } of this.tests) { - try { - await fn(); - console.log(`✓ ${name}`); - this.passed++; - } catch (error) { - console.error(`✗ ${name}`); - console.error(` Error: ${error.message}`); - this.failed++; - } - } + it('preserves temperature and top_p parameters', () => { + const transformer = new GlmtTransformer(); + const input = { + model: 'claude-sonnet-4.5', + messages: [{ role: 'user', content: 'Test' }], + temperature: 0.7, + top_p: 0.9 + }; - console.log(`\n=== Results ===`); - console.log(`Passed: ${this.passed}/${this.tests.length}`); - console.log(`Failed: ${this.failed}/${this.tests.length}`); + const { openaiRequest } = transformer.transformRequest(input); - return this.failed === 0; - } -} + assert.strictEqual(openaiRequest.temperature, 0.7); + assert.strictEqual(openaiRequest.top_p, 0.9); + }); -/** - * Simple assertion helpers - */ -function assertEqual(actual, expected, message) { - if (actual !== expected) { - throw new Error( - `${message || 'Assertion failed'}\n` + - ` Expected: ${JSON.stringify(expected)}\n` + - ` Actual: ${JSON.stringify(actual)}` - ); - } -} + it('handles errors in transformRequest gracefully', () => { + const transformer = new GlmtTransformer(); + const { thinkingConfig, error } = transformer.transformRequest(null); -function assertExists(value, message) { - if (value === undefined || value === null) { - throw new Error(message || 'Value should exist'); - } -} + assert.ok(error); + assert.strictEqual(thinkingConfig.thinking, false); + }); -function assertDeepEqual(actual, expected, message) { - const actualStr = JSON.stringify(actual); - const expectedStr = JSON.stringify(expected); - if (actualStr !== expectedStr) { - throw new Error( - `${message || 'Deep equality failed'}\n` + - ` Expected: ${expectedStr}\n` + - ` Actual: ${actualStr}` - ); - } -} + it('enables streaming when requested', () => { + const transformer = new GlmtTransformer(); + const input = { + model: 'claude-sonnet-4.5', + messages: [{ role: 'user', content: 'Test' }], + stream: true + }; -// Test suite -const runner = new TestRunner(); + const { openaiRequest } = transformer.transformRequest(input); -// Test 1: Transform Anthropic request to OpenAI format -runner.test('transforms Anthropic request to OpenAI format', () => { - const transformer = new GlmtTransformer(); - const input = { - model: 'claude-sonnet-4.5', - messages: [{ role: 'user', content: 'Hello' }], - max_tokens: 4096 - }; + assert.strictEqual(openaiRequest.stream, true); + }); + }); - const { openaiRequest } = transformer.transformRequest(input); + describe('Thinking control tags', () => { + it('extracts Thinking:On and Effort:High tags', () => { + const transformer = new GlmtTransformer(); + const input = { + model: 'claude-sonnet-4.5', + messages: [{ + role: 'user', + content: ' Solve this problem' + }] + }; - assertEqual(openaiRequest.model, 'GLM-4.6', 'Model should be GLM-4.6'); - assertEqual(openaiRequest.do_sample, true, 'do_sample should be true'); - assertEqual(openaiRequest.max_tokens, 128000, 'max_tokens should be 128000'); - assertExists(openaiRequest.messages, 'messages should exist'); -}); + const { thinkingConfig } = transformer.transformRequest(input); -// Test 2: Extract thinking control tags -runner.test('extracts thinking control tags', () => { - const transformer = new GlmtTransformer(); - const input = { - model: 'claude-sonnet-4.5', - messages: [{ - role: 'user', - content: ' Solve this problem' - }] - }; + assert.strictEqual(thinkingConfig.thinking, true); + assert.strictEqual(thinkingConfig.effort, 'high'); + }); - const { thinkingConfig } = transformer.transformRequest(input); + it('respects Thinking:Off tag', () => { + const transformer = new GlmtTransformer(); + const input = { + model: 'claude-sonnet-4.5', + messages: [{ + role: 'user', + content: ' Quick question' + }] + }; - assertEqual(thinkingConfig.thinking, true, 'thinking should be On'); - assertEqual(thinkingConfig.effort, 'high', 'effort should be high'); -}); + const { thinkingConfig } = transformer.transformRequest(input); -// Test 3: Extract thinking off tag -runner.test('respects Thinking:Off tag', () => { - const transformer = new GlmtTransformer(); - const input = { - model: 'claude-sonnet-4.5', - messages: [{ - role: 'user', - content: ' Quick question' - }] - }; + assert.strictEqual(thinkingConfig.thinking, false); + }); - const { thinkingConfig } = transformer.transformRequest(input); + it('enables thinking by default when configured', () => { + const transformer = new GlmtTransformer({ defaultThinking: true }); + const input = { + model: 'claude-sonnet-4.5', + messages: [{ role: 'user', content: 'Test' }] + }; - assertEqual(thinkingConfig.thinking, false, 'thinking should be Off'); -}); + const { openaiRequest, thinkingConfig } = transformer.transformRequest(input); -// Test 4: Convert reasoning_content to thinking block -runner.test('converts reasoning_content to thinking block', () => { - const transformer = new GlmtTransformer(); - const openaiResponse = { - id: 'chatcmpl-123', - model: 'GLM-4.6', - choices: [{ - message: { + assert.strictEqual(thinkingConfig.thinking, true); + assert.strictEqual(openaiRequest.reasoning, true); + }); + }); + + describe('Response transformation', () => { + it('converts reasoning_content to thinking block', () => { + const transformer = new GlmtTransformer(); + const openaiResponse = { + id: 'chatcmpl-123', + model: 'GLM-4.6', + choices: [{ + message: { + role: 'assistant', + content: 'Here is the answer', + reasoning_content: 'Let me think through this problem...' + }, + finish_reason: 'stop' + }], + usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 } + }; + + const result = transformer.transformResponse(openaiResponse, {}); + + assert.strictEqual(result.content.length, 2); + assert.strictEqual(result.content[0].type, 'thinking'); + assert.strictEqual(result.content[0].thinking, 'Let me think through this problem...'); + assert.strictEqual(result.content[1].type, 'text'); + assert.strictEqual(result.content[1].text, 'Here is the answer'); + }); + + it('handles response without reasoning_content', () => { + const transformer = new GlmtTransformer(); + const openaiResponse = { + id: 'chatcmpl-123', + model: 'GLM-4.6', + choices: [{ + message: { + role: 'assistant', + content: 'Simple answer' + }, + finish_reason: 'stop' + }] + }; + + const result = transformer.transformResponse(openaiResponse, {}); + + assert.strictEqual(result.content.length, 1); + assert.strictEqual(result.content[0].type, 'text'); + assert.strictEqual(result.content[0].text, 'Simple answer'); + }); + + it('handles errors in transformResponse gracefully', () => { + const transformer = new GlmtTransformer(); + const result = transformer.transformResponse({}, {}); + + assert.strictEqual(result.type, 'message'); + assert.strictEqual(result.role, 'assistant'); + assert.ok(result.content[0].text); + }); + }); + + describe('Thinking signature', () => { + it('generates thinking signature', () => { + const transformer = new GlmtTransformer(); + const thinking = 'This is my reasoning process'; + const signature = transformer.generateThinkingSignature(thinking); + + assert.strictEqual(signature.type, 'thinking_signature'); + assert.ok(signature.hash); + assert.strictEqual(signature.hash.length, 16); + assert.strictEqual(signature.length, thinking.length); + assert.ok(signature.timestamp); + }); + }); + + describe('Stop reason mapping', () => { + it('maps OpenAI stop reasons to Anthropic', () => { + const transformer = new GlmtTransformer(); + + assert.strictEqual(transformer.mapStopReason('stop'), 'end_turn'); + assert.strictEqual(transformer.mapStopReason('length'), 'max_tokens'); + assert.strictEqual(transformer.mapStopReason('tool_calls'), 'tool_use'); + assert.strictEqual(transformer.mapStopReason('unknown'), 'end_turn'); + }); + }); + + describe('Debug mode', () => { + it('is disabled by default', () => { + const transformer = new GlmtTransformer(); + assert.strictEqual(transformer.debugLog, false); + }); + + it('is enabled via config', () => { + const transformer = new GlmtTransformer({ debugLog: true }); + assert.strictEqual(transformer.debugLog, true); + }); + + it('is enabled via CCS_DEBUG=1', () => { + process.env.CCS_DEBUG = '1'; + const transformer = new GlmtTransformer(); + assert.strictEqual(transformer.debugLog, true); + delete process.env.CCS_DEBUG; + }); + + it('uses ~/.ccs/logs by default', () => { + const transformer = new GlmtTransformer(); + const os = require('os'); + const path = require('path'); + const expectedPath = path.join(os.homedir(), '.ccs', 'logs'); + assert.strictEqual(transformer.debugLogDir, expectedPath); + }); + }); + + describe('Validation', () => { + it('validates transformation with all checks', () => { + const transformer = new GlmtTransformer(); + const validResponse = { + type: 'message', role: 'assistant', - content: 'Here is the answer', - reasoning_content: 'Let me think through this problem...' - }, - finish_reason: 'stop' - }], - usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 } - }; + content: [ + { type: 'thinking', thinking: 'reasoning...' }, + { type: 'text', text: 'answer' } + ], + usage: { input_tokens: 10, output_tokens: 20 } + }; - const result = transformer.transformResponse(openaiResponse, {}); + const validation = transformer.validateTransformation(validResponse); + assert.strictEqual(validation.passed, 5); + assert.strictEqual(validation.total, 5); + assert.strictEqual(validation.valid, true); + assert.strictEqual(validation.checks.hasContent, true); + assert.strictEqual(validation.checks.hasThinking, true); + assert.strictEqual(validation.checks.hasText, true); + assert.strictEqual(validation.checks.validStructure, true); + assert.strictEqual(validation.checks.hasUsage, true); + }); - assertEqual(result.content.length, 2, 'Should have 2 content blocks'); - assertEqual(result.content[0].type, 'thinking', 'First block should be thinking'); - assertEqual( - result.content[0].thinking, - 'Let me think through this problem...', - 'Thinking content should match' - ); - assertEqual(result.content[1].type, 'text', 'Second block should be text'); - assertEqual( - result.content[1].text, - 'Here is the answer', - 'Text content should match' - ); -}); - -// Test 5: Handle response without reasoning_content -runner.test('handles response without reasoning_content', () => { - const transformer = new GlmtTransformer(); - const openaiResponse = { - id: 'chatcmpl-123', - model: 'GLM-4.6', - choices: [{ - message: { + it('validates transformation without thinking block', () => { + const transformer = new GlmtTransformer(); + const response = { + type: 'message', role: 'assistant', - content: 'Simple answer' - }, - finish_reason: 'stop' - }] - }; + content: [{ type: 'text', text: 'answer' }], + usage: { input_tokens: 10, output_tokens: 20 } + }; - const result = transformer.transformResponse(openaiResponse, {}); + const validation = transformer.validateTransformation(response); + assert.strictEqual(validation.passed, 4); + assert.strictEqual(validation.checks.hasThinking, false); + assert.strictEqual(validation.checks.hasText, true); + }); + }); - assertEqual(result.content.length, 1, 'Should have 1 content block'); - assertEqual(result.content[0].type, 'text', 'Block should be text'); - assertEqual(result.content[0].text, 'Simple answer', 'Text should match'); -}); - -// Test 6: Thinking signature generation -runner.test('generates thinking signature', () => { - const transformer = new GlmtTransformer(); - const thinking = 'This is my reasoning process'; - const signature = transformer._generateThinkingSignature(thinking); - - assertExists(signature.type, 'signature.type should exist'); - assertEqual(signature.type, 'thinking_signature', 'type should be thinking_signature'); - assertExists(signature.hash, 'signature.hash should exist'); - assertEqual(signature.hash.length, 16, 'hash should be 16 chars'); - assertEqual(signature.length, thinking.length, 'length should match thinking length'); - assertExists(signature.timestamp, 'timestamp should exist'); -}); - -// Test 7: Stop reason mapping -runner.test('maps OpenAI stop reasons to Anthropic', () => { - const transformer = new GlmtTransformer(); - - assertEqual(transformer._mapStopReason('stop'), 'end_turn', 'stop → end_turn'); - assertEqual(transformer._mapStopReason('length'), 'max_tokens', 'length → max_tokens'); - assertEqual(transformer._mapStopReason('tool_calls'), 'tool_use', 'tool_calls → tool_use'); - assertEqual( - transformer._mapStopReason('unknown'), - 'end_turn', - 'unknown → end_turn (default)' - ); -}); - -// Test 8: Preserve temperature and top_p -runner.test('preserves temperature and top_p parameters', () => { - const transformer = new GlmtTransformer(); - const input = { - model: 'claude-sonnet-4.5', - messages: [{ role: 'user', content: 'Test' }], - temperature: 0.7, - top_p: 0.9 - }; - - const { openaiRequest } = transformer.transformRequest(input); - - assertEqual(openaiRequest.temperature, 0.7, 'temperature should be preserved'); - assertEqual(openaiRequest.top_p, 0.9, 'top_p should be preserved'); -}); - -// Test 9: Default thinking enabled -runner.test('enables thinking by default', () => { - const transformer = new GlmtTransformer({ defaultThinking: true }); - const input = { - model: 'claude-sonnet-4.5', - messages: [{ role: 'user', content: 'Test' }] - }; - - const { openaiRequest, thinkingConfig } = transformer.transformRequest(input); - - assertEqual(thinkingConfig.thinking, true, 'thinking should be enabled by default'); - assertEqual(openaiRequest.reasoning, true, 'reasoning should be in request'); -}); - -// Test 10: Error handling in transformRequest -runner.test('handles errors in transformRequest gracefully', () => { - const transformer = new GlmtTransformer(); - const invalidInput = null; // Invalid input - - const { openaiRequest, thinkingConfig, error } = transformer.transformRequest(invalidInput); - - assertExists(error, 'error should be present'); - assertEqual(thinkingConfig.thinking, false, 'thinking should be disabled on error'); -}); - -// Test 11: Error handling in transformResponse -runner.test('handles errors in transformResponse gracefully', () => { - const transformer = new GlmtTransformer(); - const invalidResponse = {}; // Missing choices - - const result = transformer.transformResponse(invalidResponse, {}); - - assertEqual(result.type, 'message', 'should return valid message type'); - assertEqual(result.role, 'assistant', 'should be assistant role'); - assertExists(result.content[0].text, 'should have error text'); -}); - -// Test 12: Streaming disabled (not yet supported) -runner.test('disables streaming (not yet supported)', () => { - const transformer = new GlmtTransformer(); - const input = { - model: 'claude-sonnet-4.5', - messages: [{ role: 'user', content: 'Test' }], - stream: true - }; - - const { openaiRequest } = transformer.transformRequest(input); - - assertEqual(openaiRequest.stream, true, 'stream should be enabled when requested'); -}); - -// Test 13: Debug mode disabled by default -runner.test('debug mode disabled by default', () => { - const transformer = new GlmtTransformer(); - assertEqual(transformer.debugLog, false, 'debugLog should be false by default'); -}); - -// Test 14: Debug mode enabled via config -runner.test('debug mode enabled via config', () => { - const transformer = new GlmtTransformer({ debugLog: true }); - assertEqual(transformer.debugLog, true, 'debugLog should be true when enabled'); -}); - -// Test 15: Debug mode enabled via env var -runner.test('debug mode enabled via CCS_DEBUG=1', () => { - process.env.CCS_DEBUG = '1'; - const transformer = new GlmtTransformer(); - assertEqual(transformer.debugLog, true, 'debugLog should be true when CCS_DEBUG=1'); - delete process.env.CCS_DEBUG; -}); - -// Test 16: Debug log directory path -runner.test('debug log directory uses ~/.ccs/logs by default', () => { - const transformer = new GlmtTransformer(); - const os = require('os'); - const path = require('path'); - const expectedPath = path.join(os.homedir(), '.ccs', 'logs'); - assertEqual(transformer.debugLogDir, expectedPath, 'debugLogDir should be ~/.ccs/logs'); -}); - -// Test 17: Validate transformation checks -runner.test('validates transformation with all checks', () => { - const transformer = new GlmtTransformer(); - const validResponse = { - type: 'message', - role: 'assistant', - content: [ - { type: 'thinking', thinking: 'reasoning...' }, - { type: 'text', text: 'answer' } - ], - usage: { input_tokens: 10, output_tokens: 20 } - }; - - const validation = transformer._validateTransformation(validResponse); - assertEqual(validation.passed, 5, 'All 5 checks should pass'); - assertEqual(validation.total, 5, 'Should have 5 total checks'); - assertEqual(validation.valid, true, 'Should be valid'); - assertEqual(validation.checks.hasContent, true, 'hasContent check'); - assertEqual(validation.checks.hasThinking, true, 'hasThinking check'); - assertEqual(validation.checks.hasText, true, 'hasText check'); - assertEqual(validation.checks.validStructure, true, 'validStructure check'); - assertEqual(validation.checks.hasUsage, true, 'hasUsage check'); -}); - -// Test 18: Validate transformation with missing thinking -runner.test('validates transformation without thinking block', () => { - const transformer = new GlmtTransformer(); - const response = { - type: 'message', - role: 'assistant', - content: [ - { type: 'text', text: 'answer' } - ], - usage: { input_tokens: 10, output_tokens: 20 } - }; - - const validation = transformer._validateTransformation(response); - assertEqual(validation.passed, 4, '4 checks should pass (no thinking)'); - assertEqual(validation.checks.hasThinking, false, 'hasThinking should be false'); - assertEqual(validation.checks.hasText, true, 'hasText should be true'); -}); - -// Test 19: Handle anthropicRequest.thinking parameter with type=enabled -runner.test('processes thinking parameter with type=enabled', () => { - const transformer = new GlmtTransformer(); - const input = { - model: 'claude-sonnet-4.5', - messages: [{ role: 'user', content: 'Test question' }], - thinking: { - type: 'enabled', - budget_tokens: 1024 - } - }; - - const { openaiRequest, thinkingConfig } = transformer.transformRequest(input); - - assertEqual(thinkingConfig.thinking, true, 'thinking should be enabled'); - // Note: effort no longer dynamically set from budget_tokens (Z.AI doesn't support reasoning_effort) - assertEqual(openaiRequest.reasoning, true, 'reasoning should be in OpenAI request'); -}); - -// Test 20: Handle anthropicRequest.thinking parameter with type=disabled -runner.test('processes thinking parameter with type=disabled', () => { - const transformer = new GlmtTransformer(); - const input = { - model: 'claude-sonnet-4.5', - messages: [{ role: 'user', content: 'Test question' }], - thinking: { - type: 'disabled' - } - }; - - const { openaiRequest, thinkingConfig } = transformer.transformRequest(input); - - assertEqual(thinkingConfig.thinking, false, 'thinking should be disabled'); - assertEqual(openaiRequest.reasoning, undefined, 'reasoning should not be in request'); -}); - -// Test 21: Budget tokens no longer mapped to effort (Z.AI doesn't support reasoning_effort) -runner.test('ignores budget_tokens (Z.AI does not support reasoning_effort)', () => { - const transformer = new GlmtTransformer(); - const input = { - model: 'claude-sonnet-4.5', - messages: [{ role: 'user', content: 'Test' }], - thinking: { - type: 'enabled', - budget_tokens: 2048 - } - }; - - const { thinkingConfig, openaiRequest } = transformer.transformRequest(input); - - // Z.AI only supports binary thinking (reasoning: true/false), not effort levels - assertEqual(thinkingConfig.thinking, true, 'thinking should be enabled'); - assertEqual(openaiRequest.reasoning, true, 'reasoning should be true in API request'); -}); - -// Test 22: Budget tokens mapping - medium effort (2049-8192) -runner.test('maps budget_tokens 2049-8192 to medium effort', () => { - const transformer = new GlmtTransformer(); - const input = { - model: 'claude-sonnet-4.5', - messages: [{ role: 'user', content: 'Test' }], - thinking: { - type: 'enabled', - budget_tokens: 4096 - } - }; - - const { thinkingConfig } = transformer.transformRequest(input); - - assertEqual(thinkingConfig.effort, 'medium', 'effort should be medium at budget=4096'); -}); - -// Test 23: Verify thinking parameter works regardless of budget_tokens value -runner.test('thinking.type controls API behavior (budget_tokens ignored)', () => { - const transformer = new GlmtTransformer(); - const input = { - model: 'claude-sonnet-4.5', - messages: [{ role: 'user', content: 'Test' }], - thinking: { - type: 'enabled', - budget_tokens: 16384 - } - }; - - const { thinkingConfig, openaiRequest } = transformer.transformRequest(input); - - // Only thinking.type matters for Z.AI API - assertEqual(thinkingConfig.thinking, true, 'thinking should be enabled'); - assertEqual(openaiRequest.reasoning, true, 'reasoning should be true'); -}); - -// Test 24: thinking parameter without budget_tokens -runner.test('handles thinking parameter without budget_tokens', () => { - const transformer = new GlmtTransformer(); - const input = { - model: 'claude-sonnet-4.5', - messages: [{ role: 'user', content: 'Test' }], - thinking: { - type: 'enabled' - } - }; - - const { thinkingConfig } = transformer.transformRequest(input); - - assertEqual(thinkingConfig.thinking, true, 'thinking should be enabled'); - // Effort should remain default (not overridden) - assertExists(thinkingConfig.effort, 'effort should exist with default value'); -}); - -// Test 25: thinking parameter takes precedence over message tags -runner.test('thinking parameter overrides message tags', () => { - const transformer = new GlmtTransformer(); - const input = { - model: 'claude-sonnet-4.5', - messages: [{ - role: 'user', - content: ' Test question' - }], - thinking: { - type: 'enabled', - budget_tokens: 1024 - } - }; - - const { thinkingConfig, openaiRequest } = transformer.transformRequest(input); - - // thinking parameter should win over tags - assertEqual(thinkingConfig.thinking, true, 'thinking param should override tag'); - assertEqual(openaiRequest.reasoning, true, 'reasoning should be enabled in API request'); -}); - -// Test 26: Message tags still work when no thinking parameter present -runner.test('message tags work when thinking parameter absent', () => { - const transformer = new GlmtTransformer(); - const input = { - model: 'claude-sonnet-4.5', - messages: [{ - role: 'user', - content: ' Test question' - }] - }; - - const { thinkingConfig } = transformer.transformRequest(input); - - assertEqual(thinkingConfig.thinking, true, 'tag should enable thinking'); - assertEqual(thinkingConfig.effort, 'medium', 'tag should set medium effort'); -}); - -// Test 27: thinking parameter with invalid type (edge case) -runner.test('handles invalid thinking type gracefully', () => { - const transformer = new GlmtTransformer(); - const input = { - model: 'claude-sonnet-4.5', - messages: [{ role: 'user', content: 'Test' }], - thinking: { - type: 'invalid' - } - }; - - const { thinkingConfig } = transformer.transformRequest(input); - - // Should fall back to default behavior (not crash) - assertExists(thinkingConfig, 'thinkingConfig should exist'); -}); - -// Test 28: Keyword detection - "think" -runner.test('detects "think" keyword (low effort)', () => { - const transformer = new GlmtTransformer(); - const result = transformer._detectThinkKeywords([ - { role: 'user', content: 'think about the solution' } - ]); - - assertEqual(result.thinking, true, 'thinking should be enabled'); - assertEqual(result.effort, 'low', 'effort should be low'); - assertEqual(result.keyword, 'think', 'keyword should be "think"'); -}); - -// Test 29: Keyword detection - "think hard" -runner.test('detects "think hard" keyword (medium effort)', () => { - const transformer = new GlmtTransformer(); - const result = transformer._detectThinkKeywords([ - { role: 'user', content: 'think hard about edge cases' } - ]); - - assertEqual(result.thinking, true, 'thinking should be enabled'); - assertEqual(result.effort, 'medium', 'effort should be medium'); - assertEqual(result.keyword, 'think hard', 'keyword should be "think hard"'); -}); - -// Test 30: Keyword detection - "think harder" -runner.test('detects "think harder" keyword (high effort)', () => { - const transformer = new GlmtTransformer(); - const result = transformer._detectThinkKeywords([ - { role: 'user', content: 'think harder about edge cases' } - ]); - - assertEqual(result.thinking, true, 'thinking should be enabled'); - assertEqual(result.effort, 'high', 'effort should be high'); - assertEqual(result.keyword, 'think harder', 'keyword should be "think harder"'); -}); - -// Test 31: Keyword detection - "ultrathink" -runner.test('detects "ultrathink" keyword (max effort)', () => { - const transformer = new GlmtTransformer(); - const result = transformer._detectThinkKeywords([ - { role: 'user', content: 'ultrathink this complex problem' } - ]); - - assertEqual(result.thinking, true, 'thinking should be enabled'); - assertEqual(result.effort, 'max', 'effort should be max'); - assertEqual(result.keyword, 'ultrathink', 'keyword should be "ultrathink"'); -}); - -// Test 32: Keyword detection - ignores "thinking" (not exact match) -runner.test('ignores "thinking" word (not exact match)', () => { - const transformer = new GlmtTransformer(); - const result = transformer._detectThinkKeywords([ - { role: 'user', content: 'I am thinking about the solution' } - ]); - - assertEqual(result, null, 'should return null for non-exact match'); -}); - -// Test 33: Keyword detection - returns null when no keywords -runner.test('returns null when no keywords present', () => { - const transformer = new GlmtTransformer(); - const result = transformer._detectThinkKeywords([ - { role: 'user', content: 'fix the bug quickly' } - ]); - - assertEqual(result, null, 'should return null when no keywords found'); -}); - -// Test 34: Keyword priority - ultrathink wins when multiple keywords present -runner.test('ultrathink has highest priority when multiple keywords present', () => { - const transformer = new GlmtTransformer(); - const result = transformer._detectThinkKeywords([ - { role: 'user', content: 'think hard and think harder, or maybe ultrathink about this' } - ]); - - assertEqual(result.thinking, true, 'thinking should be enabled'); - assertEqual(result.effort, 'max', 'ultrathink should win with max effort'); - assertEqual(result.keyword, 'ultrathink', 'keyword should be ultrathink'); -}); - -// Test 35: Keyword detection integrates with transformRequest -runner.test('keyword detection triggers thinking in transformRequest', () => { - const transformer = new GlmtTransformer(); - const input = { - model: 'claude-sonnet-4.5', - messages: [{ role: 'user', content: 'think hard about this architecture problem' }] - }; - - const { thinkingConfig } = transformer.transformRequest(input); - - assertEqual(thinkingConfig.thinking, true, 'keyword should enable thinking'); - assertEqual(thinkingConfig.effort, 'medium', 'keyword should set medium effort'); -}); - -// Run tests -runner.run().then(success => { - process.exit(success ? 0 : 1); -}).catch(error => { - console.error('Test runner error:', error); - process.exit(1); + describe('Thinking parameter', () => { + it('processes thinking parameter with type=enabled', () => { + const transformer = new GlmtTransformer(); + const input = { + model: 'claude-sonnet-4.5', + messages: [{ role: 'user', content: 'Test question' }], + thinking: { type: 'enabled', budget_tokens: 1024 } + }; + + const { openaiRequest, thinkingConfig } = transformer.transformRequest(input); + + assert.strictEqual(thinkingConfig.thinking, true); + assert.strictEqual(openaiRequest.reasoning, true); + }); + + it('processes thinking parameter with type=disabled', () => { + const transformer = new GlmtTransformer(); + const input = { + model: 'claude-sonnet-4.5', + messages: [{ role: 'user', content: 'Test question' }], + thinking: { type: 'disabled' } + }; + + const { openaiRequest, thinkingConfig } = transformer.transformRequest(input); + + assert.strictEqual(thinkingConfig.thinking, false); + assert.strictEqual(openaiRequest.reasoning, undefined); + }); + + it('ignores budget_tokens (Z.AI does not support reasoning_effort)', () => { + const transformer = new GlmtTransformer(); + const input = { + model: 'claude-sonnet-4.5', + messages: [{ role: 'user', content: 'Test' }], + thinking: { type: 'enabled', budget_tokens: 2048 } + }; + + const { thinkingConfig, openaiRequest } = transformer.transformRequest(input); + + assert.strictEqual(thinkingConfig.thinking, true); + assert.strictEqual(openaiRequest.reasoning, true); + }); + + it('maps budget_tokens 2049-8192 to medium effort', () => { + const transformer = new GlmtTransformer(); + const input = { + model: 'claude-sonnet-4.5', + messages: [{ role: 'user', content: 'Test' }], + thinking: { type: 'enabled', budget_tokens: 4096 } + }; + + const { thinkingConfig } = transformer.transformRequest(input); + + assert.strictEqual(thinkingConfig.effort, 'medium'); + }); + + it('handles thinking parameter without budget_tokens', () => { + const transformer = new GlmtTransformer(); + const input = { + model: 'claude-sonnet-4.5', + messages: [{ role: 'user', content: 'Test' }], + thinking: { type: 'enabled' } + }; + + const { thinkingConfig } = transformer.transformRequest(input); + + assert.strictEqual(thinkingConfig.thinking, true); + assert.ok(thinkingConfig.effort); + }); + + it('thinking parameter overrides message tags', () => { + const transformer = new GlmtTransformer(); + const input = { + model: 'claude-sonnet-4.5', + messages: [{ + role: 'user', + content: ' Test question' + }], + thinking: { type: 'enabled', budget_tokens: 1024 } + }; + + const { thinkingConfig, openaiRequest } = transformer.transformRequest(input); + + assert.strictEqual(thinkingConfig.thinking, true); + assert.strictEqual(openaiRequest.reasoning, true); + }); + + it('message tags work when thinking parameter absent', () => { + const transformer = new GlmtTransformer(); + const input = { + model: 'claude-sonnet-4.5', + messages: [{ + role: 'user', + content: ' Test question' + }] + }; + + const { thinkingConfig } = transformer.transformRequest(input); + + assert.strictEqual(thinkingConfig.thinking, true); + assert.strictEqual(thinkingConfig.effort, 'medium'); + }); + + it('handles invalid thinking type gracefully', () => { + const transformer = new GlmtTransformer(); + const input = { + model: 'claude-sonnet-4.5', + messages: [{ role: 'user', content: 'Test' }], + thinking: { type: 'invalid' } + }; + + const { thinkingConfig } = transformer.transformRequest(input); + + assert.ok(thinkingConfig); + }); + }); + + describe('Keyword detection', () => { + it('detects "think" keyword (low effort)', () => { + const transformer = new GlmtTransformer(); + const result = transformer.detectThinkKeywords([ + { role: 'user', content: 'think about the solution' } + ]); + + assert.strictEqual(result.thinking, true); + assert.strictEqual(result.effort, 'low'); + assert.strictEqual(result.keyword, 'think'); + }); + + it('detects "think hard" keyword (medium effort)', () => { + const transformer = new GlmtTransformer(); + const result = transformer.detectThinkKeywords([ + { role: 'user', content: 'think hard about edge cases' } + ]); + + assert.strictEqual(result.thinking, true); + assert.strictEqual(result.effort, 'medium'); + assert.strictEqual(result.keyword, 'think hard'); + }); + + it('detects "think harder" keyword (high effort)', () => { + const transformer = new GlmtTransformer(); + const result = transformer.detectThinkKeywords([ + { role: 'user', content: 'think harder about edge cases' } + ]); + + assert.strictEqual(result.thinking, true); + assert.strictEqual(result.effort, 'high'); + assert.strictEqual(result.keyword, 'think harder'); + }); + + it('detects "ultrathink" keyword (max effort)', () => { + const transformer = new GlmtTransformer(); + const result = transformer.detectThinkKeywords([ + { role: 'user', content: 'ultrathink this complex problem' } + ]); + + assert.strictEqual(result.thinking, true); + assert.strictEqual(result.effort, 'max'); + assert.strictEqual(result.keyword, 'ultrathink'); + }); + + it('ignores "thinking" word (not exact match)', () => { + const transformer = new GlmtTransformer(); + const result = transformer.detectThinkKeywords([ + { role: 'user', content: 'I am thinking about the solution' } + ]); + + assert.strictEqual(result, null); + }); + + it('returns null when no keywords present', () => { + const transformer = new GlmtTransformer(); + const result = transformer.detectThinkKeywords([ + { role: 'user', content: 'fix the bug quickly' } + ]); + + assert.strictEqual(result, null); + }); + + it('ultrathink has highest priority when multiple keywords present', () => { + const transformer = new GlmtTransformer(); + const result = transformer.detectThinkKeywords([ + { role: 'user', content: 'think hard and think harder, or maybe ultrathink about this' } + ]); + + assert.strictEqual(result.thinking, true); + assert.strictEqual(result.effort, 'max'); + assert.strictEqual(result.keyword, 'ultrathink'); + }); + + it('keyword detection triggers thinking in transformRequest', () => { + const transformer = new GlmtTransformer(); + const input = { + model: 'claude-sonnet-4.5', + messages: [{ role: 'user', content: 'think hard about this architecture problem' }] + }; + + const { thinkingConfig } = transformer.transformRequest(input); + + assert.strictEqual(thinkingConfig.thinking, true); + assert.strictEqual(thinkingConfig.effort, 'medium'); + }); + }); }); diff --git a/tests/unit/glmt/locale-enforcer.test.js b/tests/unit/glmt/locale-enforcer.test.js index 240be62c..9dc51e87 100644 --- a/tests/unit/glmt/locale-enforcer.test.js +++ b/tests/unit/glmt/locale-enforcer.test.js @@ -11,7 +11,7 @@ */ const assert = require('assert'); -const LocaleEnforcer = require('../../../dist/glmt/locale-enforcer').default; +const { LocaleEnforcer } = require('../../../dist/glmt/locale-enforcer'); describe('LocaleEnforcer', () => { describe('Scenario 1: English prompt → English output', () => { diff --git a/tests/unit/glmt/reasoning-enforcer.test.js b/tests/unit/glmt/reasoning-enforcer.test.js index 2755cd5c..2dab5197 100644 --- a/tests/unit/glmt/reasoning-enforcer.test.js +++ b/tests/unit/glmt/reasoning-enforcer.test.js @@ -1,293 +1,174 @@ -#!/usr/bin/env node -'use strict'; - -/** - * ReasoningEnforcer Unit Tests - * - * Test scenarios: - * 1. Opt-in behavior (enabled vs disabled) - * 2. System message injection - * 3. User message fallback - * 4. Effort level selection (low/medium/high/max) - * 5. Message structure handling (string vs array) - * 6. Edge cases (empty messages, no system/user) - */ - +const assert = require('assert'); const ReasoningEnforcer = require('../../../dist/glmt/reasoning-enforcer').default; -/** - * Simple test runner (no external dependencies) - */ -class TestRunner { - constructor() { - this.tests = []; - this.passed = 0; - this.failed = 0; - } +describe('ReasoningEnforcer', () => { + describe('Opt-in behavior', () => { + it('should NOT inject when disabled and thinking=false', () => { + const enforcer = new ReasoningEnforcer({ enabled: false }); + const messages = [{ role: 'user', content: 'What is 2+2?' }]; - test(name, fn) { - this.tests.push({ name, fn }); - } + const result = enforcer.injectInstruction(messages, { thinking: false }); - async run() { - console.log('\n=== ReasoningEnforcer Tests ===\n'); + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].content, 'What is 2+2?'); + }); - for (const { name, fn } of this.tests) { - try { - await fn(); - console.log(`[OK] ${name}`); - this.passed++; - } catch (error) { - console.error(`[X] ${name}`); - console.error(` Error: ${error.message}`); - this.failed++; - } - } + it('should inject when enabled=true', () => { + const enforcer = new ReasoningEnforcer({ enabled: true }); + const messages = [{ role: 'user', content: 'What is 2+2?' }]; - console.log(`\n=== Results ===`); - console.log(`Passed: ${this.passed}/${this.tests.length}`); - console.log(`Failed: ${this.failed}/${this.tests.length}`); + const result = enforcer.injectInstruction(messages, { thinking: false }); - return this.failed === 0; - } -} + assert.ok(result[0].content.includes('CRITICAL')); + assert.ok(result[0].content.includes('')); + }); -/** - * Simple assertion helpers - */ -function assertEqual(actual, expected, message) { - if (actual !== expected) { - throw new Error( - `${message || 'Assertion failed'}\n` + - ` Expected: ${JSON.stringify(expected)}\n` + - ` Actual: ${JSON.stringify(actual)}` - ); - } -} + it('should inject when thinking=true (even if enabled=false)', () => { + const enforcer = new ReasoningEnforcer({ enabled: false }); + const messages = [{ role: 'user', content: 'What is 2+2?' }]; -function assertTrue(condition, message) { - if (!condition) { - throw new Error(message || 'Expected condition to be true'); - } -} + const result = enforcer.injectInstruction(messages, { thinking: true }); -function assertIncludes(haystack, needle, message) { - if (!haystack.includes(needle)) { - throw new Error( - `${message || 'String does not include expected substring'}\n` + - ` Expected substring: ${needle}\n` + - ` Actual string: ${haystack.substring(0, 100)}...` - ); - } -} + assert.ok(result[0].content.includes('CRITICAL')); + }); + }); -function assertDeepEqual(actual, expected, message) { - const actualStr = JSON.stringify(actual); - const expectedStr = JSON.stringify(expected); - if (actualStr !== expectedStr) { - throw new Error( - `${message || 'Deep equality assertion failed'}\n` + - ` Expected: ${expectedStr}\n` + - ` Actual: ${actualStr}` - ); - } -} + describe('System message injection', () => { + it('should prepend to system message (string content)', () => { + const enforcer = new ReasoningEnforcer({ enabled: true }); + const messages = [ + { role: 'system', content: 'You are a helpful assistant.' }, + { role: 'user', content: 'Calculate 2+2' } + ]; -// Create test runner -const runner = new TestRunner(); + const result = enforcer.injectInstruction(messages, { thinking: true, effort: 'medium' }); -// Test 1: Opt-in behavior - disabled -runner.test('should NOT inject when disabled and thinking=false', () => { - const enforcer = new ReasoningEnforcer({ enabled: false }); - const messages = [ - { role: 'user', content: 'What is 2+2?' } - ]; + assert.strictEqual(result.length, 2); + assert.ok(result[0].content.startsWith('You are an expert reasoning model')); + assert.ok(result[0].content.includes('You are a helpful assistant')); + assert.strictEqual(result[1].content, 'Calculate 2+2'); + }); - const result = enforcer.injectInstruction(messages, { thinking: false }); + it('should prepend to system message (array content)', () => { + const enforcer = new ReasoningEnforcer({ enabled: true }); + const messages = [ + { + role: 'system', + content: [{ type: 'text', text: 'You are a code assistant.' }] + }, + { role: 'user', content: 'Write a function' } + ]; - assertEqual(result.length, 1); - assertEqual(result[0].content, 'What is 2+2?'); -}); - -// Test 2: Opt-in behavior - enabled -runner.test('should inject when enabled=true', () => { - const enforcer = new ReasoningEnforcer({ enabled: true }); - const messages = [ - { role: 'user', content: 'What is 2+2?' } - ]; - - const result = enforcer.injectInstruction(messages, { thinking: false }); - - assertIncludes(result[0].content, 'CRITICAL'); - assertIncludes(result[0].content, ''); -}); - -// Test 3: Opt-in behavior - thinking=true -runner.test('should inject when thinking=true (even if enabled=false)', () => { - const enforcer = new ReasoningEnforcer({ enabled: false }); - const messages = [ - { role: 'user', content: 'What is 2+2?' } - ]; - - const result = enforcer.injectInstruction(messages, { thinking: true }); - - assertIncludes(result[0].content, 'CRITICAL'); -}); - -// Test 4: System message injection - string content -runner.test('should prepend to system message (string content)', () => { - const enforcer = new ReasoningEnforcer({ enabled: true }); - const messages = [ - { role: 'system', content: 'You are a helpful assistant.' }, - { role: 'user', content: 'Calculate 2+2' } - ]; - - const result = enforcer.injectInstruction(messages, { thinking: true, effort: 'medium' }); - - assertEqual(result.length, 2); - assertTrue(result[0].content.startsWith('You are an expert reasoning model')); - assertIncludes(result[0].content, 'You are a helpful assistant'); - assertEqual(result[1].content, 'Calculate 2+2'); -}); - -// Test 5: System message injection - array content -runner.test('should prepend to system message (array content)', () => { - const enforcer = new ReasoningEnforcer({ enabled: true }); - const messages = [ - { - role: 'system', - content: [ - { type: 'text', text: 'You are a code assistant.' } - ] - }, - { role: 'user', content: 'Write a function' } - ]; - - const result = enforcer.injectInstruction(messages, { thinking: true }); - - assertTrue(Array.isArray(result[0].content)); - assertEqual(result[0].content[0].type, 'text'); - assertIncludes(result[0].content[0].text, 'CRITICAL'); - assertEqual(result[0].content[1].text, 'You are a code assistant.'); -}); - -// Test 6: User message fallback -runner.test('should prepend to first user message when no system message', () => { - const enforcer = new ReasoningEnforcer({ enabled: true }); - const messages = [ - { role: 'user', content: 'Explain quantum computing' } - ]; - - const result = enforcer.injectInstruction(messages, { thinking: true }); - - assertEqual(result.length, 1); - assertIncludes(result[0].content, 'CRITICAL'); - assertIncludes(result[0].content, 'Explain quantum computing'); -}); - -// Test 7: Effort level - low -runner.test('should use low prompt template', () => { - const enforcer = new ReasoningEnforcer({ enabled: true }); - const messages = [{ role: 'user', content: 'Test' }]; - - const result = enforcer.injectInstruction(messages, { thinking: true, effort: 'low' }); - - assertIncludes(result[0].content.toLowerCase(), 'brief analysis'); -}); - -// Test 8: Effort level - medium -runner.test('should use medium prompt template', () => { - const enforcer = new ReasoningEnforcer({ enabled: true }); - const messages = [{ role: 'user', content: 'Test' }]; - - const result = enforcer.injectInstruction(messages, { thinking: true, effort: 'medium' }); - - assertIncludes(result[0].content.toLowerCase(), 'think step-by-step'); -}); - -// Test 9: Effort level - high -runner.test('should use high prompt template', () => { - const enforcer = new ReasoningEnforcer({ enabled: true }); - const messages = [{ role: 'user', content: 'Test' }]; - - const result = enforcer.injectInstruction(messages, { thinking: true, effort: 'high' }); - - assertIncludes(result[0].content.toLowerCase(), 'think deeply and systematically'); -}); - -// Test 10: Effort level - max -runner.test('should use max prompt template', () => { - const enforcer = new ReasoningEnforcer({ enabled: true }); - const messages = [{ role: 'user', content: 'Test' }]; - - const result = enforcer.injectInstruction(messages, { thinking: true, effort: 'max' }); - - assertIncludes(result[0].content.toLowerCase(), 'exhaustively from first principles'); -}); - -// Test 11: Default effort level -runner.test('should default to medium effort if not specified', () => { - const enforcer = new ReasoningEnforcer({ enabled: true }); - const messages = [{ role: 'user', content: 'Test' }]; - - const result = enforcer.injectInstruction(messages, { thinking: true }); - - assertIncludes(result[0].content, 'think step-by-step'); -}); - -// Test 12: Empty messages array -runner.test('should handle empty messages array', () => { - const enforcer = new ReasoningEnforcer({ enabled: true }); - const messages = []; - - const result = enforcer.injectInstruction(messages, { thinking: true }); - - assertEqual(result.length, 0); -}); - -// Test 13: No system or user role -runner.test('should handle messages with no system or user role', () => { - const enforcer = new ReasoningEnforcer({ enabled: true }); - const messages = [ - { role: 'assistant', content: 'Previous response' } - ]; - - const result = enforcer.injectInstruction(messages, { thinking: true }); - - assertEqual(result.length, 1); - assertEqual(result[0].content, 'Previous response'); -}); + const result = enforcer.injectInstruction(messages, { thinking: true }); -// Test 14: Immutability -runner.test('should not mutate original messages array', () => { - const enforcer = new ReasoningEnforcer({ enabled: true }); - const originalMessages = [ - { role: 'user', content: 'Test prompt' } - ]; - const originalCopy = JSON.parse(JSON.stringify(originalMessages)); + assert.ok(Array.isArray(result[0].content)); + assert.strictEqual(result[0].content[0].type, 'text'); + assert.ok(result[0].content[0].text.includes('CRITICAL')); + assert.strictEqual(result[0].content[1].text, 'You are a code assistant.'); + }); + }); - enforcer.injectInstruction(originalMessages, { thinking: true }); + describe('User message fallback', () => { + it('should prepend to first user message when no system message', () => { + const enforcer = new ReasoningEnforcer({ enabled: true }); + const messages = [{ role: 'user', content: 'Explain quantum computing' }]; - assertDeepEqual(originalMessages, originalCopy); -}); + const result = enforcer.injectInstruction(messages, { thinking: true }); -// Test 15: Custom prompts -runner.test('should handle custom prompts via constructor', () => { - const customPrompts = { - low: 'Custom low prompt', - medium: 'Custom medium prompt', - high: 'Custom high prompt', - max: 'Custom max prompt' - }; - const enforcer = new ReasoningEnforcer({ enabled: true, prompts: customPrompts }); - const messages = [{ role: 'user', content: 'Test' }]; + assert.strictEqual(result.length, 1); + assert.ok(result[0].content.includes('CRITICAL')); + assert.ok(result[0].content.includes('Explain quantum computing')); + }); + }); - const result = enforcer.injectInstruction(messages, { thinking: true, effort: 'low' }); + describe('Effort levels', () => { + it('should use low prompt template', () => { + const enforcer = new ReasoningEnforcer({ enabled: true }); + const messages = [{ role: 'user', content: 'Test' }]; - assertIncludes(result[0].content, 'Custom low prompt'); -}); + const result = enforcer.injectInstruction(messages, { thinking: true, effort: 'low' }); -// Run all tests -runner.run().then(success => { - process.exit(success ? 0 : 1); + assert.ok(result[0].content.toLowerCase().includes('brief analysis')); + }); + + it('should use medium prompt template', () => { + const enforcer = new ReasoningEnforcer({ enabled: true }); + const messages = [{ role: 'user', content: 'Test' }]; + + const result = enforcer.injectInstruction(messages, { thinking: true, effort: 'medium' }); + + assert.ok(result[0].content.toLowerCase().includes('think step-by-step')); + }); + + it('should use high prompt template', () => { + const enforcer = new ReasoningEnforcer({ enabled: true }); + const messages = [{ role: 'user', content: 'Test' }]; + + const result = enforcer.injectInstruction(messages, { thinking: true, effort: 'high' }); + + assert.ok(result[0].content.toLowerCase().includes('think deeply and systematically')); + }); + + it('should use max prompt template', () => { + const enforcer = new ReasoningEnforcer({ enabled: true }); + const messages = [{ role: 'user', content: 'Test' }]; + + const result = enforcer.injectInstruction(messages, { thinking: true, effort: 'max' }); + + assert.ok(result[0].content.toLowerCase().includes('exhaustively from first principles')); + }); + + it('should default to medium effort if not specified', () => { + const enforcer = new ReasoningEnforcer({ enabled: true }); + const messages = [{ role: 'user', content: 'Test' }]; + + const result = enforcer.injectInstruction(messages, { thinking: true }); + + assert.ok(result[0].content.includes('think step-by-step')); + }); + }); + + describe('Edge cases', () => { + it('should handle empty messages array', () => { + const enforcer = new ReasoningEnforcer({ enabled: true }); + const result = enforcer.injectInstruction([], { thinking: true }); + + assert.strictEqual(result.length, 0); + }); + + it('should handle messages with no system or user role', () => { + const enforcer = new ReasoningEnforcer({ enabled: true }); + const messages = [{ role: 'assistant', content: 'Previous response' }]; + + const result = enforcer.injectInstruction(messages, { thinking: true }); + + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].content, 'Previous response'); + }); + + it('should not mutate original messages array', () => { + const enforcer = new ReasoningEnforcer({ enabled: true }); + const originalMessages = [{ role: 'user', content: 'Test prompt' }]; + const originalCopy = JSON.parse(JSON.stringify(originalMessages)); + + enforcer.injectInstruction(originalMessages, { thinking: true }); + + assert.deepStrictEqual(originalMessages, originalCopy); + }); + + it('should handle custom prompts via constructor', () => { + const customPrompts = { + low: 'Custom low prompt', + medium: 'Custom medium prompt', + high: 'Custom high prompt', + max: 'Custom max prompt' + }; + const enforcer = new ReasoningEnforcer({ enabled: true, prompts: customPrompts }); + const messages = [{ role: 'user', content: 'Test' }]; + + const result = enforcer.injectInstruction(messages, { thinking: true, effort: 'low' }); + + assert.ok(result[0].content.includes('Custom low prompt')); + }); + }); }); diff --git a/tests/unit/glmt/sse-parser.test.js b/tests/unit/glmt/sse-parser.test.js index 08d3980c..40714a8f 100755 --- a/tests/unit/glmt/sse-parser.test.js +++ b/tests/unit/glmt/sse-parser.test.js @@ -1,144 +1,110 @@ -#!/usr/bin/env node -'use strict'; +const assert = require('assert'); +const { SSEParser } = require('../../../dist/glmt/sse-parser'); -const SSEParser = require('../../../dist/glmt/sse-parser').default; +describe('SSEParser', () => { + describe('Basic parsing', () => { + it('parses single event', () => { + const parser = new SSEParser(); + const events = parser.parse('data: {"test": "value"}\n\n'); + assert.strictEqual(events.length, 1, `Expected 1 event, got ${events.length}`); + assert.strictEqual(events[0].data.test, 'value'); + }); -console.log('[TEST] SSEParser unit tests'); -console.log(''); + it('parses multiple events in one chunk', () => { + const parser = new SSEParser(); + const events = parser.parse('data: {"a": 1}\n\ndata: {"b": 2}\n\n'); + assert.strictEqual(events.length, 2, `Expected 2 events, got ${events.length}`); + assert.strictEqual(events[0].data.a, 1); + assert.strictEqual(events[1].data.b, 2); + }); -let passedTests = 0; -let failedTests = 0; + it('handles event split across chunks', () => { + const parser = new SSEParser(); + const events1 = parser.parse('data: {"test":'); + assert.strictEqual(events1.length, 0, 'Should not emit incomplete event'); + const events2 = parser.parse('"value"}\n\n'); + assert.strictEqual(events2.length, 1, `Expected 1 event after completion, got ${events2.length}`); + assert.strictEqual(events2[0].data.test, 'value'); + }); -function test(name, fn) { - try { - fn(); - console.log(`[PASS] ${name}`); - passedTests++; - } catch (error) { - console.log(`[FAIL] ${name}`); - console.log(` Error: ${error.message}`); - failedTests++; - } -} + it('handles empty lines between events', () => { + const parser = new SSEParser(); + const events = parser.parse('data: {"a": 1}\n\n\n\ndata: {"b": 2}\n\n'); + assert.strictEqual(events.length, 2, `Expected 2 events, got ${events.length}`); + }); + }); -function assert(condition, message) { - if (!condition) { - throw new Error(message || 'Assertion failed'); - } -} + describe('[DONE] marker', () => { + it('detects [DONE] marker', () => { + const parser = new SSEParser(); + const events = parser.parse('data: [DONE]\n\n'); + assert.strictEqual(events.length, 1, `Expected 1 event, got ${events.length}`); + assert.strictEqual(events[0].event, 'done'); + assert.strictEqual(events[0].data, null); + }); -// Test: Single event -test('Single event parsing', () => { - const parser = new SSEParser(); - const events = parser.parse('data: {"test": "value"}\n\n'); - assert(events.length === 1, `Expected 1 event, got ${events.length}`); - assert(events[0].data.test === 'value', `Expected test=value, got ${events[0].data.test}`); + it('handles mixed events with [DONE]', () => { + const parser = new SSEParser(); + const events = parser.parse('data: {"msg": "hello"}\n\ndata: [DONE]\n\n'); + assert.strictEqual(events.length, 2, `Expected 2 events, got ${events.length}`); + assert.strictEqual(events[0].data.msg, 'hello'); + assert.strictEqual(events[1].event, 'done'); + }); + }); + + describe('Error handling', () => { + it('handles malformed JSON gracefully', () => { + const parser = new SSEParser(); + const events = parser.parse('data: {invalid json}\n\ndata: {"valid": true}\n\n'); + assert.strictEqual(events.length, 1, `Expected 1 valid event, got ${events.length}`); + assert.strictEqual(events[0].data.valid, true); + }); + }); + + describe('Event fields', () => { + it('parses event with ID field', () => { + const parser = new SSEParser(); + const events = parser.parse('id: 123\ndata: {"test": true}\n\n'); + assert.strictEqual(events.length, 1); + assert.strictEqual(events[0].id, '123'); + assert.strictEqual(events[0].data.test, true); + }); + + it('parses custom event type', () => { + const parser = new SSEParser(); + const events = parser.parse('event: custom\ndata: {"test": true}\n\n'); + assert.strictEqual(events.length, 1); + assert.strictEqual(events[0].event, 'custom'); + }); + }); + + describe('Reset functionality', () => { + it('resets parser state', () => { + const parser = new SSEParser(); + parser.parse('data: {"test": 1}\n\n'); + assert.strictEqual(parser.eventCount, 1); + parser.reset(); + assert.strictEqual(parser.eventCount, 0); + assert.strictEqual(parser.buffer, ''); + }); + }); + + describe('Z.AI stream format', () => { + it('parses real Z.AI stream format', () => { + const parser = new SSEParser(); + const chunk = 'data: {"choices":[{"delta":{"role":"assistant","reasoning_content":"test"}}]}\n\n'; + const events = parser.parse(chunk); + assert.strictEqual(events.length, 1); + assert.strictEqual(events[0].data.choices[0].delta.reasoning_content, 'test'); + }); + + it('parses multiple Z.AI deltas', () => { + const parser = new SSEParser(); + const chunk = 'data: {"choices":[{"delta":{"reasoning_content":"chunk1"}}]}\n\ndata: {"choices":[{"delta":{"reasoning_content":"chunk2"}}]}\n\n'; + const events = parser.parse(chunk); + assert.strictEqual(events.length, 2); + assert.strictEqual(events[0].data.choices[0].delta.reasoning_content, 'chunk1'); + assert.strictEqual(events[1].data.choices[0].delta.reasoning_content, 'chunk2'); + }); + }); }); - -// Test: Multiple events -test('Multiple events in one chunk', () => { - const parser = new SSEParser(); - const events = parser.parse('data: {"a": 1}\n\ndata: {"b": 2}\n\n'); - assert(events.length === 2, `Expected 2 events, got ${events.length}`); - assert(events[0].data.a === 1, 'First event data incorrect'); - assert(events[1].data.b === 2, 'Second event data incorrect'); -}); - -// Test: Split across chunks -test('Event split across chunks', () => { - const parser = new SSEParser(); - const events1 = parser.parse('data: {"test":'); - assert(events1.length === 0, 'Should not emit incomplete event'); - const events2 = parser.parse('"value"}\n\n'); - assert(events2.length === 1, `Expected 1 event after completion, got ${events2.length}`); - assert(events2[0].data.test === 'value', 'Split event data incorrect'); -}); - -// Test: [DONE] marker -test('[DONE] marker detection', () => { - const parser = new SSEParser(); - const events = parser.parse('data: [DONE]\n\n'); - assert(events.length === 1, `Expected 1 event, got ${events.length}`); - assert(events[0].event === 'done', `Expected event=done, got ${events[0].event}`); - assert(events[0].data === null, 'Expected null data for [DONE]'); -}); - -// Test: Mixed content and [DONE] -test('Mixed events with [DONE]', () => { - const parser = new SSEParser(); - const events = parser.parse('data: {"msg": "hello"}\n\ndata: [DONE]\n\n'); - assert(events.length === 2, `Expected 2 events, got ${events.length}`); - assert(events[0].data.msg === 'hello', 'First event data incorrect'); - assert(events[1].event === 'done', 'Second event should be done'); -}); - -// Test: Malformed JSON (should skip gracefully) -test('Malformed JSON handling', () => { - const parser = new SSEParser(); - const events = parser.parse('data: {invalid json}\n\ndata: {"valid": true}\n\n'); - // Should skip malformed, parse valid - assert(events.length === 1, `Expected 1 valid event, got ${events.length}`); - assert(events[0].data.valid === true, 'Valid event data incorrect'); -}); - -// Test: Empty lines handling -test('Empty lines between events', () => { - const parser = new SSEParser(); - const events = parser.parse('data: {"a": 1}\n\n\n\ndata: {"b": 2}\n\n'); - assert(events.length === 2, `Expected 2 events, got ${events.length}`); -}); - -// Test: Event with ID field -test('Event with ID field', () => { - const parser = new SSEParser(); - const events = parser.parse('id: 123\ndata: {"test": true}\n\n'); - assert(events.length === 1, `Expected 1 event, got ${events.length}`); - assert(events[0].id === '123', `Expected id=123, got ${events[0].id}`); - assert(events[0].data.test === true, 'Event data incorrect'); -}); - -// Test: Custom event type -test('Custom event type', () => { - const parser = new SSEParser(); - const events = parser.parse('event: custom\ndata: {"test": true}\n\n'); - assert(events.length === 1, `Expected 1 event, got ${events.length}`); - assert(events[0].event === 'custom', `Expected event=custom, got ${events[0].event}`); -}); - -// Test: Reset functionality -test('Parser reset', () => { - const parser = new SSEParser(); - parser.parse('data: {"test": 1}\n\n'); - assert(parser.eventCount === 1, 'Event count should be 1'); - parser.reset(); - assert(parser.eventCount === 0, 'Event count should be 0 after reset'); - assert(parser.buffer === '', 'Buffer should be empty after reset'); -}); - -// Test: Real Z.AI stream format -test('Real Z.AI stream format', () => { - const parser = new SSEParser(); - const chunk = 'data: {"choices":[{"delta":{"role":"assistant","reasoning_content":"test"}}]}\n\n'; - const events = parser.parse(chunk); - assert(events.length === 1, `Expected 1 event, got ${events.length}`); - assert(events[0].data.choices[0].delta.reasoning_content === 'test', 'Z.AI format parsing incorrect'); -}); - -// Test: Multiple Z.AI deltas -test('Multiple Z.AI deltas', () => { - const parser = new SSEParser(); - const chunk = 'data: {"choices":[{"delta":{"reasoning_content":"chunk1"}}]}\n\ndata: {"choices":[{"delta":{"reasoning_content":"chunk2"}}]}\n\n'; - const events = parser.parse(chunk); - assert(events.length === 2, `Expected 2 events, got ${events.length}`); - assert(events[0].data.choices[0].delta.reasoning_content === 'chunk1', 'First delta incorrect'); - assert(events[1].data.choices[0].delta.reasoning_content === 'chunk2', 'Second delta incorrect'); -}); - -console.log(''); -console.log('═══════════════════════════════════════'); -console.log(`TESTS: ${passedTests} passed, ${failedTests} failed`); -console.log('═══════════════════════════════════════'); - -if (failedTests > 0) { - process.exit(1); -}