mirror of
https://github.com/tiennm99/ccs.git
synced 2026-08-06 04:22:40 +00:00
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)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
+2
-2
@@ -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",
|
||||
|
||||
+10
-5
@@ -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
|
||||
|
||||
+15
-10
@@ -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
|
||||
- ✅ Increased coverage: From 41 to 177 tests
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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', () => {
|
||||
|
||||
@@ -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('<reasoning_content>'));
|
||||
});
|
||||
|
||||
/**
|
||||
* 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, '<reasoning_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'));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+103
-137
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user