diff --git a/justfile b/justfile index 6b833ed..63620ff 100644 --- a/justfile +++ b/justfile @@ -142,10 +142,15 @@ deploy-rules-wsl: # ========================================================= # Run fast parallel unit tests via cargo-nextest (and type-check UI) -test: check-ui +test: check-ui test-js @Write-Host "Running fast parallel tests via cargo-nextest..." -ForegroundColor Cyan $env:MCP_ALLOW_TMP_FALLBACK='1'; cargo nextest run --workspace +# Run JavaScript UI unit tests using Bun +test-js: build-ui + @Write-Host "Running dashboard JavaScript unit tests..." -ForegroundColor Cyan + bun test server/tests/dashboard.test.ts + # Run dashboard UI endpoint and integrity unit tests test-ui: @Write-Host "Running dashboard UI endpoint and integrity unit tests..." -ForegroundColor Cyan diff --git a/server/src/config.rs b/server/src/config.rs index 3e56af4..cd9abd6 100644 --- a/server/src/config.rs +++ b/server/src/config.rs @@ -37,10 +37,15 @@ pub fn load_mcp_config_env() { } pub fn get_env(key: &str) -> Result { + // Process environment variables must always take precedence over the config file + // to allow testing frameworks to override paths (like MCP_MEMORY_STORE_DIR). + if let Ok(val) = env::var(key) { + return Ok(val); + } if let Some(map) = CONFIG_ENV.get() { if let Some(val) = map.get(key) { return Ok(val.clone()); } } - env::var(key) + Err(env::VarError::NotPresent) } diff --git a/server/src/dashboard.js b/server/src/dashboard.js index db785dd..768e648 100644 --- a/server/src/dashboard.js +++ b/server/src/dashboard.js @@ -811,6 +811,9 @@ function handleIncomingActivity(payload) { } else if (category === "MEMO" || category === "MILESTONE" || category === "HANDOFF_MEMO") { requestDomainRefresh("memos"); } + if (method === "notifications/resources/updated") { + return; + } const feed = document.getElementById("activity-feed"); if (feed) { if (feed.querySelector(".feed-entry") === null) { @@ -861,8 +864,7 @@ function setupWS() { return; } if (data.type === "activity" || data.type === "gate" || data.type === "task" || data.type === "tech_debt" || data.type === "nvim_telemetry" || data.method === "notifications/activity" || data.method === "notifications/task/completed" || data.method === "notifications/resources/updated") { - const payload = data.params || data.data || data; - handleIncomingActivity(payload); + handleIncomingActivity(data); } } catch (e) { console.error("WebSocket message parse error", e); diff --git a/server/src/dashboard.ts b/server/src/dashboard.ts index 9f45825..34a360b 100644 --- a/server/src/dashboard.ts +++ b/server/src/dashboard.ts @@ -1083,6 +1083,11 @@ function handleIncomingActivity(payload: any): void { ) { requestDomainRefresh("memos"); } + + if (method === "notifications/resources/updated") { + return; + } + const feed = document.getElementById("activity-feed"); if (feed) { if (feed.querySelector(".feed-entry") === null) { @@ -1144,8 +1149,7 @@ function setupWS(): void { data.method === "notifications/task/completed" || data.method === "notifications/resources/updated" ) { - const payload = data.params || data.data || data; - handleIncomingActivity(payload); + handleIncomingActivity(data); } } catch (e) { console.error("WebSocket message parse error", e); diff --git a/server/src/router.rs b/server/src/router.rs index 877ab94..1e10097 100644 --- a/server/src/router.rs +++ b/server/src/router.rs @@ -1121,6 +1121,65 @@ pub fn format_tool_activity_description(name: &str, args: &serde_json::Value) -> .to_string(), ) } + "omni_search" => { + let query = args.get("query").and_then(|v| v.as_str()).unwrap_or(""); + ("OmniSearch", query.to_string()) + } + "create_relations" => { + let names = args + .get("relations") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|r| { + let source = r.get("source").or_else(|| r.get("from")).and_then(|v| v.as_str()).unwrap_or("?"); + let target = r.get("target").or_else(|| r.get("to")).and_then(|v| v.as_str()).unwrap_or("?"); + let rel = r.get("relationType").or_else(|| r.get("type")).or_else(|| r.get("relation_type")).and_then(|v| v.as_str()).unwrap_or("?"); + Some(format!("{} -[{}]-> {}", source, rel, target)) + }) + .collect::>() + .join(", ") + }) + .unwrap_or_default(); + ("Create Relations", names) + } + "log_code_change" => { + let file = args.get("file_path").and_then(|v| v.as_str()).unwrap_or(""); + let desc = args.get("description").and_then(|v| v.as_str()).unwrap_or(""); + let detail = if !file.is_empty() && !desc.is_empty() { + format!("{} ({})", desc, file) + } else if !file.is_empty() { + file.to_string() + } else { + desc.to_string() + }; + ("Log Code Change", detail) + } + "log_error_fix" => { + let sig = args.get("signature").and_then(|v| v.as_str()).unwrap_or(""); + let sol = args.get("solution").and_then(|v| v.as_str()).unwrap_or(""); + let detail = if !sig.is_empty() && !sol.is_empty() { + format!("{}: {}", sig, sol) + } else if !sig.is_empty() { + sig.to_string() + } else { + sol.to_string() + }; + ("Log Error Fix", detail) + } + "open_nodes" => { + let names = args + .get("names") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|e| e.as_str()) + .collect::>() + .join(", ") + }) + .unwrap_or_default(); + ("Open Nodes", names) + } _ => return name.to_string(), }; if detail.is_empty() { diff --git a/server/tests/dashboard.test.ts b/server/tests/dashboard.test.ts new file mode 100644 index 0000000..95c57fd --- /dev/null +++ b/server/tests/dashboard.test.ts @@ -0,0 +1,62 @@ +import { test, expect } from "bun:test"; +import { readFileSync } from "fs"; + +test("dashboard parses and renders activity payload", () => { + // 1. Read dashboard.js (the transpiled JavaScript) + let code = readFileSync("server/src/dashboard.js", "utf8"); + + // Remove the trailing initialization calls that cause side-effects + code = code.replace("loadVersion();", ""); + code = code.replace("loadActivityHistory();", ""); + code = code.replace("setupWS();", ""); + + // 2. Mock necessary DOM and Browser globals + const context = { + window: {}, + document: { + documentElement: {}, + getElementById: () => null, + createElement: () => ({ classList: { add: () => {} }, prepend: () => {}, removeChild: () => {}, children: [] }), + addEventListener: () => {}, + }, + location: { protocol: "http:" }, + MutationObserver: class { observe() {} }, + vis: { DataSet: class {}, Network: class {} }, + console: console, + fetch: () => Promise.resolve({ json: () => Promise.resolve({}) }), + WebSocket: class { addEventListener() {} }, + setTimeout: setTimeout, + Date: Date, + localStorage: { getItem: () => null, setItem: () => {} }, + }; + + // 3. Create a function that executes the code + const extractFn = new Function(...Object.keys(context), code + ` + return { + parseActivityPayload, + handleIncomingActivity, + requestDomainRefresh + }; + `); + + const exports = extractFn(...Object.values(context)); + + // 4. Test parseActivityPayload normal case + const payload = { + category: "CODE_CHANGE", + message: "Refactored live activity UI", + }; + + const html = exports.parseActivityPayload(payload); + expect(html).toContain("CODE_CHANGE"); + expect(html).toContain("Refactored live activity UI"); + + // 5. Test parseActivityPayload with resources/updated + // It should handle fallback correctly if we pass it directly + const resPayload = { + method: "notifications/resources/updated", + params: { uri: "memory://tasks/active" } + }; + const htmlRes = exports.parseActivityPayload(resPayload); + expect(htmlRes).toContain("memory://tasks/active"); +});