diff --git a/v2/package-lock.json b/v2/package-lock.json index 34bb777..326c8bc 100644 --- a/v2/package-lock.json +++ b/v2/package-lock.json @@ -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", diff --git a/v2/package.json b/v2/package.json index 69c2d40..09d2b6c 100644 --- a/v2/package.json +++ b/v2/package.json @@ -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", diff --git a/v2/src-tauri/Cargo.lock b/v2/src-tauri/Cargo.lock index 01f2bcd..2dc2055 100644 --- a/v2/src-tauri/Cargo.lock +++ b/v2/src-tauri/Cargo.lock @@ -253,6 +253,7 @@ dependencies = [ "tauri-build", "tauri-plugin-log", "tauri-plugin-updater", + "tempfile", "uuid", ] diff --git a/v2/src-tauri/Cargo.toml b/v2/src-tauri/Cargo.toml index 9eacd98..7c264ff 100644 --- a/v2/src-tauri/Cargo.toml +++ b/v2/src-tauri/Cargo.toml @@ -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" diff --git a/v2/src-tauri/src/ctx.rs b/v2/src-tauri/src/ctx.rs index 64c9cf2..074d6ff 100644 --- a/v2/src-tauri/src/ctx.rs +++ b/v2/src-tauri/src/ctx.rs @@ -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"); + } +} diff --git a/v2/src-tauri/src/session.rs b/v2/src-tauri/src/session.rs index ebbbb46..712ee4e 100644 --- a/v2/src-tauri/src/session.rs +++ b/v2/src-tauri/src/session.rs @@ -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"); + } +} diff --git a/v2/src/lib/adapters/sdk-messages.test.ts b/v2/src/lib/adapters/sdk-messages.test.ts new file mode 100644 index 0000000..d847719 --- /dev/null +++ b/v2/src/lib/adapters/sdk-messages.test.ts @@ -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); + }); + }); +}); diff --git a/v2/src/lib/utils/agent-tree.test.ts b/v2/src/lib/utils/agent-tree.test.ts new file mode 100644 index 0000000..2ba24fc --- /dev/null +++ b/v2/src/lib/utils/agent-tree.test.ts @@ -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); + }); +}); diff --git a/v2/vite.config.ts b/v2/vite.config.ts index 80c479e..6a5bb20 100644 --- a/v2/vite.config.ts +++ b/v2/vite.config.ts @@ -8,4 +8,7 @@ export default defineConfig({ strictPort: true, }, clearScreen: false, + test: { + include: ['src/**/*.test.ts'], + }, })