test(v2): add vitest and cargo tests for sdk-messages, agent-tree, session, ctx

Frontend (vitest):
- sdk-messages.test.ts: adaptSDKMessage() for all 9 message types
- agent-tree.test.ts: buildAgentTree(), countTreeNodes(), subtreeCost()
- vite.config.ts: vitest test config (src/**/*.test.ts)
- package.json: vitest ^4.0.18 dev dep, "test" script

Backend (cargo):
- session.rs: SessionDb CRUD tests (sessions, SSH, settings, layout) with tempfile
- ctx.rs: CtxDb error handling tests with missing database
- Cargo.toml: tempfile 3 dev dependency
This commit is contained in:
Hibryda 2026-03-06 15:10:12 +01:00
parent 7e6e777713
commit 35a515db25
9 changed files with 1482 additions and 3 deletions

336
v2/package-lock.json generated
View file

@ -23,7 +23,8 @@
"svelte": "^5.45.2",
"svelte-check": "^4.3.4",
"typescript": "~5.9.3",
"vite": "^7.3.1"
"vite": "^7.3.1",
"vitest": "^4.0.18"
}
},
"node_modules/@esbuild/aix-ppc64": {
@ -968,6 +969,13 @@
"integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==",
"license": "MIT"
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"dev": true,
"license": "MIT"
},
"node_modules/@sveltejs/acorn-typescript": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz",
@ -1043,6 +1051,24 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/deep-eql": "*",
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@ -1097,6 +1123,117 @@
"integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==",
"license": "ISC"
},
"node_modules/@vitest/expect": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz",
"integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.0.18",
"@vitest/utils": "4.0.18",
"chai": "^6.2.1",
"tinyrainbow": "^3.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/mocker": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz",
"integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.0.18",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^6.0.0 || ^7.0.0-0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz",
"integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyrainbow": "^3.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz",
"integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.0.18",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz",
"integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.0.18",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/spy": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz",
"integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz",
"integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.0.18",
"tinyrainbow": "^3.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@xterm/addon-canvas": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-canvas/-/addon-canvas-0.7.0.tgz",
@ -1144,6 +1281,16 @@
"node": ">= 0.4"
}
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/axobject-query": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
@ -1164,6 +1311,16 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/character-entities-html4": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz",
@ -1259,6 +1416,13 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/es-module-lexer": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
"dev": true,
"license": "MIT"
},
"node_modules/esbuild": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
@ -1318,6 +1482,26 @@
"@jridgewell/sourcemap-codec": "^1.4.15"
}
},
"node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/expect-type": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
"integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@ -1603,6 +1787,13 @@
"regex-recursion": "^6.0.2"
}
},
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true,
"license": "MIT"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@ -1777,6 +1968,13 @@
"node": ">=20"
}
},
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
"dev": true,
"license": "ISC"
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@ -1797,6 +1995,20 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
"dev": true,
"license": "MIT"
},
"node_modules/std-env": {
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
"dev": true,
"license": "MIT"
},
"node_modules/stringify-entities": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
@ -1863,6 +2075,23 @@
"typescript": ">=5.0.0"
}
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
"dev": true,
"license": "MIT"
},
"node_modules/tinyexec": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
"integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@ -1880,6 +2109,16 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tinyrainbow": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz",
"integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/trim-lines": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
@ -2102,6 +2341,101 @@
}
}
},
"node_modules/vitest": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz",
"integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.0.18",
"@vitest/mocker": "4.0.18",
"@vitest/pretty-format": "4.0.18",
"@vitest/runner": "4.0.18",
"@vitest/snapshot": "4.0.18",
"@vitest/spy": "4.0.18",
"@vitest/utils": "4.0.18",
"es-module-lexer": "^1.7.0",
"expect-type": "^1.2.2",
"magic-string": "^0.30.21",
"obug": "^2.1.1",
"pathe": "^2.0.3",
"picomatch": "^4.0.3",
"std-env": "^3.10.0",
"tinybench": "^2.9.0",
"tinyexec": "^1.0.2",
"tinyglobby": "^0.2.15",
"tinyrainbow": "^3.0.3",
"vite": "^6.0.0 || ^7.0.0",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.0.18",
"@vitest/browser-preview": "4.0.18",
"@vitest/browser-webdriverio": "4.0.18",
"@vitest/ui": "4.0.18",
"happy-dom": "*",
"jsdom": "*"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@opentelemetry/api": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser-playwright": {
"optional": true
},
"@vitest/browser-preview": {
"optional": true
},
"@vitest/browser-webdriverio": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
}
}
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"siginfo": "^2.0.0",
"stackback": "0.0.2"
},
"bin": {
"why-is-node-running": "cli.js"
},
"engines": {
"node": ">=8"
}
},
"node_modules/zimmerframe": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",

View file

@ -10,7 +10,8 @@
"check": "svelte-check --tsconfig ./tsconfig.app.json && tsc -p tsconfig.node.json",
"tauri": "cargo tauri",
"tauri:dev": "cargo tauri dev",
"tauri:build": "cargo tauri build"
"tauri:build": "cargo tauri build",
"test": "vitest run"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^6.2.1",
@ -19,7 +20,8 @@
"svelte": "^5.45.2",
"svelte-check": "^4.3.4",
"typescript": "~5.9.3",
"vite": "^7.3.1"
"vite": "^7.3.1",
"vitest": "^4.0.18"
},
"dependencies": {
"@tauri-apps/api": "^2.10.1",

View file

@ -253,6 +253,7 @@ dependencies = [
"tauri-build",
"tauri-plugin-log",
"tauri-plugin-updater",
"tempfile",
"uuid",
]

View file

@ -28,3 +28,6 @@ rusqlite = { version = "0.31", features = ["bundled"] }
dirs = "5"
notify = { version = "6", features = ["macos_fsevent"] }
tauri-plugin-updater = "2.10.0"
[dev-dependencies]
tempfile = "3"

View file

@ -170,3 +170,68 @@ impl CtxDb {
Ok(entries)
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Create a CtxDb with conn set to None, simulating a missing database.
fn make_missing_db() -> CtxDb {
CtxDb { conn: Mutex::new(None) }
}
#[test]
fn test_new_does_not_panic() {
// CtxDb::new() should never panic even if ~/.claude-context/context.db
// doesn't exist — it just stores None for the connection.
let _db = CtxDb::new();
}
#[test]
fn test_list_projects_missing_db_returns_error() {
let db = make_missing_db();
let result = db.list_projects();
assert!(result.is_err());
assert_eq!(result.unwrap_err(), "ctx database not found");
}
#[test]
fn test_get_context_missing_db_returns_error() {
let db = make_missing_db();
let result = db.get_context("any-project");
assert!(result.is_err());
assert_eq!(result.unwrap_err(), "ctx database not found");
}
#[test]
fn test_get_shared_missing_db_returns_error() {
let db = make_missing_db();
let result = db.get_shared();
assert!(result.is_err());
assert_eq!(result.unwrap_err(), "ctx database not found");
}
#[test]
fn test_get_summaries_missing_db_returns_error() {
let db = make_missing_db();
let result = db.get_summaries("any-project", 10);
assert!(result.is_err());
assert_eq!(result.unwrap_err(), "ctx database not found");
}
#[test]
fn test_search_missing_db_returns_error() {
let db = make_missing_db();
let result = db.search("anything");
assert!(result.is_err());
assert_eq!(result.unwrap_err(), "ctx database not found");
}
#[test]
fn test_search_empty_query_missing_db_returns_error() {
let db = make_missing_db();
let result = db.search("");
assert!(result.is_err());
assert_eq!(result.unwrap_err(), "ctx database not found");
}
}

View file

@ -316,3 +316,331 @@ impl SessionDb {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_db() -> SessionDb {
let dir = tempfile::tempdir().unwrap();
SessionDb::open(&dir.path().to_path_buf()).unwrap()
}
fn make_session(id: &str, title: &str) -> Session {
Session {
id: id.to_string(),
session_type: "terminal".to_string(),
title: title.to_string(),
shell: Some("/bin/bash".to_string()),
cwd: Some("/home/user".to_string()),
args: Some(vec!["--login".to_string()]),
created_at: 1000,
last_used_at: 2000,
}
}
fn make_ssh_session(id: &str, name: &str) -> SshSession {
SshSession {
id: id.to_string(),
name: name.to_string(),
host: "example.com".to_string(),
port: 22,
username: "admin".to_string(),
key_file: "/home/user/.ssh/id_rsa".to_string(),
folder: "/srv".to_string(),
color: "#89b4fa".to_string(),
created_at: 1000,
last_used_at: 2000,
}
}
// --- Session CRUD ---
#[test]
fn test_list_sessions_empty() {
let db = make_db();
let sessions = db.list_sessions().unwrap();
assert!(sessions.is_empty());
}
#[test]
fn test_save_and_list_session() {
let db = make_db();
let s = make_session("s1", "My Terminal");
db.save_session(&s).unwrap();
let sessions = db.list_sessions().unwrap();
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0].id, "s1");
assert_eq!(sessions[0].title, "My Terminal");
assert_eq!(sessions[0].session_type, "terminal");
assert_eq!(sessions[0].shell, Some("/bin/bash".to_string()));
assert_eq!(sessions[0].cwd, Some("/home/user".to_string()));
assert_eq!(sessions[0].args, Some(vec!["--login".to_string()]));
assert_eq!(sessions[0].created_at, 1000);
assert_eq!(sessions[0].last_used_at, 2000);
}
#[test]
fn test_save_session_upsert() {
let db = make_db();
let mut s = make_session("s1", "First");
db.save_session(&s).unwrap();
s.title = "Updated".to_string();
db.save_session(&s).unwrap();
let sessions = db.list_sessions().unwrap();
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0].title, "Updated");
}
#[test]
fn test_delete_session() {
let db = make_db();
db.save_session(&make_session("s1", "A")).unwrap();
db.save_session(&make_session("s2", "B")).unwrap();
assert_eq!(db.list_sessions().unwrap().len(), 2);
db.delete_session("s1").unwrap();
let sessions = db.list_sessions().unwrap();
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0].id, "s2");
}
#[test]
fn test_delete_nonexistent_session_no_error() {
let db = make_db();
// Should not error when deleting a session that doesn't exist
db.delete_session("nonexistent").unwrap();
}
#[test]
fn test_update_title() {
let db = make_db();
db.save_session(&make_session("s1", "Old")).unwrap();
db.update_title("s1", "New Title").unwrap();
let sessions = db.list_sessions().unwrap();
assert_eq!(sessions[0].title, "New Title");
}
#[test]
fn test_touch_session() {
let db = make_db();
db.save_session(&make_session("s1", "A")).unwrap();
let before = db.list_sessions().unwrap()[0].last_used_at;
db.touch_session("s1").unwrap();
let after = db.list_sessions().unwrap()[0].last_used_at;
// touch_session sets last_used_at to current time (epoch seconds),
// which should be greater than our test fixture value of 2000
assert!(after > before);
}
#[test]
fn test_session_with_no_optional_fields() {
let db = make_db();
let s = Session {
id: "s1".to_string(),
session_type: "agent".to_string(),
title: "Agent".to_string(),
shell: None,
cwd: None,
args: None,
created_at: 1000,
last_used_at: 2000,
};
db.save_session(&s).unwrap();
let sessions = db.list_sessions().unwrap();
assert_eq!(sessions.len(), 1);
assert!(sessions[0].shell.is_none());
assert!(sessions[0].cwd.is_none());
assert!(sessions[0].args.is_none());
}
// --- Layout ---
#[test]
fn test_load_default_layout() {
let db = make_db();
let layout = db.load_layout().unwrap();
assert_eq!(layout.preset, "1-col");
assert!(layout.pane_ids.is_empty());
}
#[test]
fn test_save_and_load_layout() {
let db = make_db();
let layout = LayoutState {
preset: "2-col".to_string(),
pane_ids: vec!["p1".to_string(), "p2".to_string()],
};
db.save_layout(&layout).unwrap();
let loaded = db.load_layout().unwrap();
assert_eq!(loaded.preset, "2-col");
assert_eq!(loaded.pane_ids, vec!["p1", "p2"]);
}
#[test]
fn test_save_layout_overwrites() {
let db = make_db();
let layout1 = LayoutState {
preset: "2-col".to_string(),
pane_ids: vec!["p1".to_string()],
};
db.save_layout(&layout1).unwrap();
let layout2 = LayoutState {
preset: "3-col".to_string(),
pane_ids: vec!["a".to_string(), "b".to_string(), "c".to_string()],
};
db.save_layout(&layout2).unwrap();
let loaded = db.load_layout().unwrap();
assert_eq!(loaded.preset, "3-col");
assert_eq!(loaded.pane_ids.len(), 3);
}
// --- Settings ---
#[test]
fn test_get_setting_missing_returns_none() {
let db = make_db();
let val = db.get_setting("nonexistent").unwrap();
assert!(val.is_none());
}
#[test]
fn test_set_and_get_setting() {
let db = make_db();
db.set_setting("theme", "mocha").unwrap();
let val = db.get_setting("theme").unwrap();
assert_eq!(val, Some("mocha".to_string()));
}
#[test]
fn test_set_setting_overwrites() {
let db = make_db();
db.set_setting("font_size", "12").unwrap();
db.set_setting("font_size", "14").unwrap();
assert_eq!(db.get_setting("font_size").unwrap(), Some("14".to_string()));
}
#[test]
fn test_get_all_settings() {
let db = make_db();
db.set_setting("b_key", "val_b").unwrap();
db.set_setting("a_key", "val_a").unwrap();
let all = db.get_all_settings().unwrap();
assert_eq!(all.len(), 2);
// Should be ordered by key
assert_eq!(all[0].0, "a_key");
assert_eq!(all[1].0, "b_key");
}
#[test]
fn test_get_all_settings_empty() {
let db = make_db();
let all = db.get_all_settings().unwrap();
assert!(all.is_empty());
}
// --- SSH Sessions ---
#[test]
fn test_list_ssh_sessions_empty() {
let db = make_db();
let sessions = db.list_ssh_sessions().unwrap();
assert!(sessions.is_empty());
}
#[test]
fn test_save_and_list_ssh_session() {
let db = make_db();
let s = make_ssh_session("ssh1", "Prod Server");
db.save_ssh_session(&s).unwrap();
let sessions = db.list_ssh_sessions().unwrap();
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0].id, "ssh1");
assert_eq!(sessions[0].name, "Prod Server");
assert_eq!(sessions[0].host, "example.com");
assert_eq!(sessions[0].port, 22);
assert_eq!(sessions[0].username, "admin");
}
#[test]
fn test_delete_ssh_session() {
let db = make_db();
db.save_ssh_session(&make_ssh_session("ssh1", "A")).unwrap();
db.save_ssh_session(&make_ssh_session("ssh2", "B")).unwrap();
db.delete_ssh_session("ssh1").unwrap();
let sessions = db.list_ssh_sessions().unwrap();
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0].id, "ssh2");
}
#[test]
fn test_update_ssh_session() {
let db = make_db();
let mut s = make_ssh_session("ssh1", "Old Name");
db.save_ssh_session(&s).unwrap();
s.name = "New Name".to_string();
s.host = "new.example.com".to_string();
s.port = 2222;
s.last_used_at = 9999;
db.update_ssh_session(&s).unwrap();
let sessions = db.list_ssh_sessions().unwrap();
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0].name, "New Name");
assert_eq!(sessions[0].host, "new.example.com");
assert_eq!(sessions[0].port, 2222);
assert_eq!(sessions[0].last_used_at, 9999);
// created_at should be unchanged (UPDATE doesn't touch it)
assert_eq!(sessions[0].created_at, 1000);
}
#[test]
fn test_ssh_session_upsert() {
let db = make_db();
let mut s = make_ssh_session("ssh1", "First");
db.save_ssh_session(&s).unwrap();
s.name = "Second".to_string();
db.save_ssh_session(&s).unwrap();
let sessions = db.list_ssh_sessions().unwrap();
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0].name, "Second");
}
// --- Multiple sessions ordering ---
#[test]
fn test_sessions_ordered_by_last_used_desc() {
let db = make_db();
let mut s1 = make_session("s1", "Older");
s1.last_used_at = 1000;
let mut s2 = make_session("s2", "Newer");
s2.last_used_at = 3000;
let mut s3 = make_session("s3", "Middle");
s3.last_used_at = 2000;
db.save_session(&s1).unwrap();
db.save_session(&s2).unwrap();
db.save_session(&s3).unwrap();
let sessions = db.list_sessions().unwrap();
assert_eq!(sessions[0].id, "s2");
assert_eq!(sessions[1].id, "s3");
assert_eq!(sessions[2].id, "s1");
}
}

View file

@ -0,0 +1,446 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { adaptSDKMessage } from './sdk-messages';
import type { InitContent, TextContent, ThinkingContent, ToolCallContent, ToolResultContent, StatusContent, CostContent, ErrorContent } from './sdk-messages';
// Mock crypto.randomUUID for deterministic IDs when uuid is missing
beforeEach(() => {
vi.stubGlobal('crypto', {
randomUUID: () => 'fallback-uuid',
});
});
describe('adaptSDKMessage', () => {
describe('system/init messages', () => {
it('adapts a system init message', () => {
const raw = {
type: 'system',
subtype: 'init',
uuid: 'sys-001',
session_id: 'sess-abc',
model: 'claude-sonnet-4-20250514',
cwd: '/home/user/project',
tools: ['Read', 'Write', 'Bash'],
};
const result = adaptSDKMessage(raw);
expect(result).toHaveLength(1);
expect(result[0].id).toBe('sys-001');
expect(result[0].type).toBe('init');
const content = result[0].content as InitContent;
expect(content.sessionId).toBe('sess-abc');
expect(content.model).toBe('claude-sonnet-4-20250514');
expect(content.cwd).toBe('/home/user/project');
expect(content.tools).toEqual(['Read', 'Write', 'Bash']);
});
it('defaults tools to empty array when missing', () => {
const raw = {
type: 'system',
subtype: 'init',
uuid: 'sys-002',
session_id: 'sess-abc',
model: 'claude-sonnet-4-20250514',
cwd: '/tmp',
};
const result = adaptSDKMessage(raw);
const content = result[0].content as InitContent;
expect(content.tools).toEqual([]);
});
});
describe('system/status messages (non-init subtypes)', () => {
it('adapts a system status message', () => {
const raw = {
type: 'system',
subtype: 'api_key_check',
uuid: 'sys-003',
status: 'API key is valid',
};
const result = adaptSDKMessage(raw);
expect(result).toHaveLength(1);
expect(result[0].type).toBe('status');
const content = result[0].content as StatusContent;
expect(content.subtype).toBe('api_key_check');
expect(content.message).toBe('API key is valid');
});
it('handles missing status field', () => {
const raw = {
type: 'system',
subtype: 'some_event',
uuid: 'sys-004',
};
const result = adaptSDKMessage(raw);
const content = result[0].content as StatusContent;
expect(content.subtype).toBe('some_event');
expect(content.message).toBeUndefined();
});
});
describe('assistant/text messages', () => {
it('adapts a single text block', () => {
const raw = {
type: 'assistant',
uuid: 'asst-001',
message: {
content: [{ type: 'text', text: 'Hello, world!' }],
},
};
const result = adaptSDKMessage(raw);
expect(result).toHaveLength(1);
expect(result[0].type).toBe('text');
expect(result[0].id).toBe('asst-001-text-0');
const content = result[0].content as TextContent;
expect(content.text).toBe('Hello, world!');
});
it('preserves parentId on assistant messages', () => {
const raw = {
type: 'assistant',
uuid: 'asst-002',
parent_tool_use_id: 'tool-parent-123',
message: {
content: [{ type: 'text', text: 'subagent response' }],
},
};
const result = adaptSDKMessage(raw);
expect(result[0].parentId).toBe('tool-parent-123');
});
});
describe('assistant/thinking messages', () => {
it('adapts a thinking block with thinking field', () => {
const raw = {
type: 'assistant',
uuid: 'asst-003',
message: {
content: [{ type: 'thinking', thinking: 'Let me consider...', text: 'fallback' }],
},
};
const result = adaptSDKMessage(raw);
expect(result).toHaveLength(1);
expect(result[0].type).toBe('thinking');
expect(result[0].id).toBe('asst-003-think-0');
const content = result[0].content as ThinkingContent;
expect(content.text).toBe('Let me consider...');
});
it('falls back to text field when thinking is absent', () => {
const raw = {
type: 'assistant',
uuid: 'asst-004',
message: {
content: [{ type: 'thinking', text: 'Thinking via text field' }],
},
};
const result = adaptSDKMessage(raw);
const content = result[0].content as ThinkingContent;
expect(content.text).toBe('Thinking via text field');
});
});
describe('assistant/tool_use messages', () => {
it('adapts a tool_use block', () => {
const raw = {
type: 'assistant',
uuid: 'asst-005',
message: {
content: [{
type: 'tool_use',
id: 'toolu_abc123',
name: 'Read',
input: { file_path: '/src/main.ts' },
}],
},
};
const result = adaptSDKMessage(raw);
expect(result).toHaveLength(1);
expect(result[0].type).toBe('tool_call');
expect(result[0].id).toBe('asst-005-tool-0');
const content = result[0].content as ToolCallContent;
expect(content.toolUseId).toBe('toolu_abc123');
expect(content.name).toBe('Read');
expect(content.input).toEqual({ file_path: '/src/main.ts' });
});
});
describe('assistant messages with multiple content blocks', () => {
it('produces one AgentMessage per content block', () => {
const raw = {
type: 'assistant',
uuid: 'asst-multi',
message: {
content: [
{ type: 'thinking', thinking: 'Hmm...' },
{ type: 'text', text: 'Here is the answer.' },
{ type: 'tool_use', id: 'toolu_xyz', name: 'Bash', input: { command: 'ls' } },
],
},
};
const result = adaptSDKMessage(raw);
expect(result).toHaveLength(3);
expect(result[0].type).toBe('thinking');
expect(result[0].id).toBe('asst-multi-think-0');
expect(result[1].type).toBe('text');
expect(result[1].id).toBe('asst-multi-text-1');
expect(result[2].type).toBe('tool_call');
expect(result[2].id).toBe('asst-multi-tool-2');
});
it('skips unknown content block types silently', () => {
const raw = {
type: 'assistant',
uuid: 'asst-unk-block',
message: {
content: [
{ type: 'text', text: 'Hello' },
{ type: 'image', data: 'base64...' },
],
},
};
const result = adaptSDKMessage(raw);
expect(result).toHaveLength(1);
expect(result[0].type).toBe('text');
});
});
describe('user/tool_result messages', () => {
it('adapts a tool_result block', () => {
const raw = {
type: 'user',
uuid: 'user-001',
message: {
content: [{
type: 'tool_result',
tool_use_id: 'toolu_abc123',
content: 'file contents here',
}],
},
};
const result = adaptSDKMessage(raw);
expect(result).toHaveLength(1);
expect(result[0].type).toBe('tool_result');
expect(result[0].id).toBe('user-001-result-0');
const content = result[0].content as ToolResultContent;
expect(content.toolUseId).toBe('toolu_abc123');
expect(content.output).toBe('file contents here');
});
it('falls back to tool_use_result when block content is missing', () => {
const raw = {
type: 'user',
uuid: 'user-002',
tool_use_result: { status: 'success', output: 'done' },
message: {
content: [{
type: 'tool_result',
tool_use_id: 'toolu_def456',
// no content field
}],
},
};
const result = adaptSDKMessage(raw);
const content = result[0].content as ToolResultContent;
expect(content.output).toEqual({ status: 'success', output: 'done' });
});
it('preserves parentId on user messages', () => {
const raw = {
type: 'user',
uuid: 'user-003',
parent_tool_use_id: 'parent-tool-id',
message: {
content: [{
type: 'tool_result',
tool_use_id: 'toolu_ghi',
content: 'ok',
}],
},
};
const result = adaptSDKMessage(raw);
expect(result[0].parentId).toBe('parent-tool-id');
});
});
describe('result/cost messages', () => {
it('adapts a full result message', () => {
const raw = {
type: 'result',
uuid: 'res-001',
total_cost_usd: 0.0125,
duration_ms: 4500,
usage: { input_tokens: 1000, output_tokens: 500 },
num_turns: 3,
is_error: false,
result: 'Task completed successfully.',
};
const result = adaptSDKMessage(raw);
expect(result).toHaveLength(1);
expect(result[0].type).toBe('cost');
expect(result[0].id).toBe('res-001');
const content = result[0].content as CostContent;
expect(content.totalCostUsd).toBe(0.0125);
expect(content.durationMs).toBe(4500);
expect(content.inputTokens).toBe(1000);
expect(content.outputTokens).toBe(500);
expect(content.numTurns).toBe(3);
expect(content.isError).toBe(false);
expect(content.result).toBe('Task completed successfully.');
expect(content.errors).toBeUndefined();
});
it('defaults numeric fields to 0 when missing', () => {
const raw = {
type: 'result',
uuid: 'res-002',
};
const result = adaptSDKMessage(raw);
const content = result[0].content as CostContent;
expect(content.totalCostUsd).toBe(0);
expect(content.durationMs).toBe(0);
expect(content.inputTokens).toBe(0);
expect(content.outputTokens).toBe(0);
expect(content.numTurns).toBe(0);
expect(content.isError).toBe(false);
});
it('includes errors array when present', () => {
const raw = {
type: 'result',
uuid: 'res-003',
is_error: true,
errors: ['Rate limit exceeded', 'Retry failed'],
};
const result = adaptSDKMessage(raw);
const content = result[0].content as CostContent;
expect(content.isError).toBe(true);
expect(content.errors).toEqual(['Rate limit exceeded', 'Retry failed']);
});
});
describe('edge cases', () => {
it('returns unknown type for unrecognized message types', () => {
const raw = {
type: 'something_new',
uuid: 'unk-001',
data: 'arbitrary',
};
const result = adaptSDKMessage(raw);
expect(result).toHaveLength(1);
expect(result[0].type).toBe('unknown');
expect(result[0].id).toBe('unk-001');
expect(result[0].content).toBe(raw);
});
it('uses crypto.randomUUID when uuid is missing', () => {
const raw = {
type: 'result',
total_cost_usd: 0.001,
};
const result = adaptSDKMessage(raw);
expect(result[0].id).toBe('fallback-uuid');
});
it('returns empty array when assistant message has no message field', () => {
const raw = {
type: 'assistant',
uuid: 'asst-empty',
};
const result = adaptSDKMessage(raw);
expect(result).toHaveLength(0);
});
it('returns empty array when assistant message.content is not an array', () => {
const raw = {
type: 'assistant',
uuid: 'asst-bad-content',
message: { content: 'not-an-array' },
};
const result = adaptSDKMessage(raw);
expect(result).toHaveLength(0);
});
it('returns empty array when user message has no message field', () => {
const raw = {
type: 'user',
uuid: 'user-empty',
};
const result = adaptSDKMessage(raw);
expect(result).toHaveLength(0);
});
it('returns empty array when user message.content is not an array', () => {
const raw = {
type: 'user',
uuid: 'user-bad',
message: { content: 'string' },
};
const result = adaptSDKMessage(raw);
expect(result).toHaveLength(0);
});
it('ignores non-tool_result blocks in user messages', () => {
const raw = {
type: 'user',
uuid: 'user-text',
message: {
content: [
{ type: 'text', text: 'User typed something' },
{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'ok' },
],
},
};
const result = adaptSDKMessage(raw);
expect(result).toHaveLength(1);
expect(result[0].type).toBe('tool_result');
});
it('sets timestamp on every message', () => {
const before = Date.now();
const result = adaptSDKMessage({
type: 'system',
subtype: 'init',
uuid: 'ts-test',
session_id: 's',
model: 'm',
cwd: '/',
});
const after = Date.now();
expect(result[0].timestamp).toBeGreaterThanOrEqual(before);
expect(result[0].timestamp).toBeLessThanOrEqual(after);
});
});
});

View file

@ -0,0 +1,297 @@
import { describe, it, expect } from 'vitest';
import { buildAgentTree, countTreeNodes, subtreeCost } from './agent-tree';
import type { AgentMessage, ToolCallContent, ToolResultContent } from '../adapters/sdk-messages';
import type { AgentTreeNode } from './agent-tree';
// Helper to create typed AgentMessages
function makeToolCall(
uuid: string,
toolUseId: string,
name: string,
parentId?: string,
): AgentMessage {
return {
id: uuid,
type: 'tool_call',
parentId,
content: { toolUseId, name, input: {} } satisfies ToolCallContent,
timestamp: Date.now(),
};
}
function makeToolResult(uuid: string, toolUseId: string, parentId?: string): AgentMessage {
return {
id: uuid,
type: 'tool_result',
parentId,
content: { toolUseId, output: 'ok' } satisfies ToolResultContent,
timestamp: Date.now(),
};
}
function makeTextMessage(uuid: string, text: string, parentId?: string): AgentMessage {
return {
id: uuid,
type: 'text',
parentId,
content: { text },
timestamp: Date.now(),
};
}
describe('buildAgentTree', () => {
it('creates a root node with no children from empty messages', () => {
const tree = buildAgentTree('session-1', [], 'done', 0.05, 1500);
expect(tree.id).toBe('session-1');
expect(tree.label).toBe('session-');
expect(tree.status).toBe('done');
expect(tree.costUsd).toBe(0.05);
expect(tree.tokens).toBe(1500);
expect(tree.children).toEqual([]);
});
it('maps running/starting status to running', () => {
const tree1 = buildAgentTree('s1', [], 'running', 0, 0);
expect(tree1.status).toBe('running');
const tree2 = buildAgentTree('s2', [], 'starting', 0, 0);
expect(tree2.status).toBe('running');
});
it('maps error status to error', () => {
const tree = buildAgentTree('s3', [], 'error', 0, 0);
expect(tree.status).toBe('error');
});
it('maps other statuses to done', () => {
const tree = buildAgentTree('s4', [], 'completed', 0, 0);
expect(tree.status).toBe('done');
});
it('adds tool_call messages as children of root', () => {
const messages: AgentMessage[] = [
makeToolCall('m1', 'tool-1', 'Read'),
makeToolCall('m2', 'tool-2', 'Write'),
];
const tree = buildAgentTree('sess', messages, 'done', 0, 0);
expect(tree.children).toHaveLength(2);
expect(tree.children[0].id).toBe('tool-1');
expect(tree.children[0].label).toBe('Read');
expect(tree.children[0].toolName).toBe('Read');
expect(tree.children[1].id).toBe('tool-2');
expect(tree.children[1].label).toBe('Write');
});
it('marks tool nodes as running until a result arrives', () => {
const messages: AgentMessage[] = [
makeToolCall('m1', 'tool-1', 'Bash'),
];
const tree = buildAgentTree('sess', messages, 'running', 0, 0);
expect(tree.children[0].status).toBe('running');
});
it('marks tool nodes as done when result arrives', () => {
const messages: AgentMessage[] = [
makeToolCall('m1', 'tool-1', 'Bash'),
makeToolResult('m2', 'tool-1'),
];
const tree = buildAgentTree('sess', messages, 'done', 0, 0);
expect(tree.children[0].status).toBe('done');
});
it('nests subagent tool calls under their parent tool node', () => {
const messages: AgentMessage[] = [
makeToolCall('m1', 'tool-parent', 'Agent'),
makeToolCall('m2', 'tool-child', 'Read', 'tool-parent'),
];
const tree = buildAgentTree('sess', messages, 'done', 0, 0);
expect(tree.children).toHaveLength(1);
const parentNode = tree.children[0];
expect(parentNode.id).toBe('tool-parent');
expect(parentNode.children).toHaveLength(1);
expect(parentNode.children[0].id).toBe('tool-child');
expect(parentNode.children[0].label).toBe('Read');
});
it('handles deeply nested subagents (3 levels)', () => {
const messages: AgentMessage[] = [
makeToolCall('m1', 'level-1', 'Agent'),
makeToolCall('m2', 'level-2', 'SubAgent', 'level-1'),
makeToolCall('m3', 'level-3', 'Read', 'level-2'),
];
const tree = buildAgentTree('sess', messages, 'done', 0, 0);
expect(tree.children).toHaveLength(1);
expect(tree.children[0].children).toHaveLength(1);
expect(tree.children[0].children[0].children).toHaveLength(1);
expect(tree.children[0].children[0].children[0].id).toBe('level-3');
});
it('attaches to root when parentId references a non-existent tool node', () => {
const messages: AgentMessage[] = [
makeToolCall('m1', 'orphan-tool', 'Bash', 'nonexistent-parent'),
];
const tree = buildAgentTree('sess', messages, 'done', 0, 0);
expect(tree.children).toHaveLength(1);
expect(tree.children[0].id).toBe('orphan-tool');
});
it('ignores non-tool messages (text, thinking, etc.)', () => {
const messages: AgentMessage[] = [
makeTextMessage('m1', 'Hello'),
makeToolCall('m2', 'tool-1', 'Read'),
makeTextMessage('m3', 'Done'),
];
const tree = buildAgentTree('sess', messages, 'done', 0, 0);
expect(tree.children).toHaveLength(1);
expect(tree.children[0].id).toBe('tool-1');
});
it('handles tool_result for a non-existent tool gracefully', () => {
const messages: AgentMessage[] = [
makeToolResult('m1', 'nonexistent-tool'),
];
// Should not throw
const tree = buildAgentTree('sess', messages, 'done', 0, 0);
expect(tree.children).toHaveLength(0);
});
it('truncates session ID to 8 chars for label', () => {
const tree = buildAgentTree('abcdefghijklmnop', [], 'done', 0, 0);
expect(tree.label).toBe('abcdefgh');
});
});
describe('countTreeNodes', () => {
it('returns 1 for a leaf node', () => {
const leaf: AgentTreeNode = {
id: 'leaf',
label: 'leaf',
status: 'done',
costUsd: 0,
tokens: 0,
children: [],
};
expect(countTreeNodes(leaf)).toBe(1);
});
it('counts all nodes in a flat tree', () => {
const root: AgentTreeNode = {
id: 'root',
label: 'root',
status: 'done',
costUsd: 0,
tokens: 0,
children: [
{ id: 'a', label: 'a', status: 'done', costUsd: 0, tokens: 0, children: [] },
{ id: 'b', label: 'b', status: 'done', costUsd: 0, tokens: 0, children: [] },
{ id: 'c', label: 'c', status: 'done', costUsd: 0, tokens: 0, children: [] },
],
};
expect(countTreeNodes(root)).toBe(4);
});
it('counts all nodes in a nested tree', () => {
const root: AgentTreeNode = {
id: 'root',
label: 'root',
status: 'done',
costUsd: 0,
tokens: 0,
children: [
{
id: 'a',
label: 'a',
status: 'done',
costUsd: 0,
tokens: 0,
children: [
{ id: 'a1', label: 'a1', status: 'done', costUsd: 0, tokens: 0, children: [] },
{ id: 'a2', label: 'a2', status: 'done', costUsd: 0, tokens: 0, children: [] },
],
},
{ id: 'b', label: 'b', status: 'done', costUsd: 0, tokens: 0, children: [] },
],
};
expect(countTreeNodes(root)).toBe(5);
});
});
describe('subtreeCost', () => {
it('returns own cost for a leaf node', () => {
const leaf: AgentTreeNode = {
id: 'leaf',
label: 'leaf',
status: 'done',
costUsd: 0.05,
tokens: 0,
children: [],
};
expect(subtreeCost(leaf)).toBe(0.05);
});
it('aggregates cost across children', () => {
const root: AgentTreeNode = {
id: 'root',
label: 'root',
status: 'done',
costUsd: 0.10,
tokens: 0,
children: [
{ id: 'a', label: 'a', status: 'done', costUsd: 0.03, tokens: 0, children: [] },
{ id: 'b', label: 'b', status: 'done', costUsd: 0.02, tokens: 0, children: [] },
],
};
expect(subtreeCost(root)).toBeCloseTo(0.15);
});
it('aggregates cost recursively across nested children', () => {
const root: AgentTreeNode = {
id: 'root',
label: 'root',
status: 'done',
costUsd: 1.0,
tokens: 0,
children: [
{
id: 'a',
label: 'a',
status: 'done',
costUsd: 0.5,
tokens: 0,
children: [
{ id: 'a1', label: 'a1', status: 'done', costUsd: 0.25, tokens: 0, children: [] },
],
},
],
};
expect(subtreeCost(root)).toBeCloseTo(1.75);
});
it('returns 0 for a tree with all zero costs', () => {
const root: AgentTreeNode = {
id: 'root',
label: 'root',
status: 'done',
costUsd: 0,
tokens: 0,
children: [
{ id: 'a', label: 'a', status: 'done', costUsd: 0, tokens: 0, children: [] },
],
};
expect(subtreeCost(root)).toBe(0);
});
});

View file

@ -8,4 +8,7 @@ export default defineConfig({
strictPort: true,
},
clearScreen: false,
test: {
include: ['src/**/*.test.ts'],
},
})