diff --git a/.env.example b/.env.example index 800a7801c8..228acc9a87 100644 --- a/.env.example +++ b/.env.example @@ -246,6 +246,11 @@ OMNIROUTE_USE_TURBOPACK=1 # hints in production logs. # OMNIROUTE_PROXY_FETCH_DEBUG=true +# Set to "true" or "1" to include client/egress IPs and the account prefix in +# the verbose `[ProxyEgress]` process-log line (src/lib/proxyLogger.ts). Kept +# OFF by default so the process log does not leak IPs or the account prefix. +# PROXY_LOG_INCLUDE_IPS=true + # Set to any non-empty value to emit `[omniroute completion]` diagnostics from # the CLI shell-completion cache paths (read/refresh/write) in # bin/cli/commands/completion.mjs. Off by default — these caches fail silently diff --git a/changelog.d/fixes/10489-qdrant-health-badge.md b/changelog.d/fixes/10489-qdrant-health-badge.md new file mode 100644 index 0000000000..f9c216e8a5 --- /dev/null +++ b/changelog.d/fixes/10489-qdrant-health-badge.md @@ -0,0 +1,2 @@ +- **fix(memory):** auto-check Qdrant health on mount and stop the false-red status badge on `/dashboard/memory?tab=engine` — the badge treated "not yet checked" (`health === null`) as a failure, so a healthy Qdrant showed red after every page refresh until "Test connection" was clicked; settings changes now also invalidate the stale result and re-check after the save persists, so a health check racing the settings PUT can no longer keep the badge red until a manual re-test ([#10489](https://github.com/diegosouzapw/OmniRoute/pull/10489)) +- **test(compression):** align source-contract tests with the merged `release/v3.8.50` base (`aa912c42a`) — accept the multi-line `providerTransport` shape in `omniglyph-chatcore-plumbing` and give the pipeline-circuit-breaker fixture a `metadata.executionStages` (both structural changes landed in the base merge) ([#10489](https://github.com/diegosouzapw/OmniRoute/pull/10489)) diff --git a/config/quality/open-sse-typecheck-baseline.json b/config/quality/open-sse-typecheck-baseline.json index dc91ce1890..c6de98b418 100644 --- a/config/quality/open-sse-typecheck-baseline.json +++ b/config/quality/open-sse-typecheck-baseline.json @@ -1,176 +1,15 @@ { - "open-sse/executors/azure-openai.ts": { - "TS2345": 1 - }, - "open-sse/executors/chatgpt-web.ts": { - "TS2339": 1 - }, - "open-sse/executors/claude-web/stream.ts": { - "TS2322": 1, - "TS2345": 1 - }, - "open-sse/executors/copilot-web.ts": { - "TS2353": 1 - }, - "open-sse/executors/deepseek-web.ts": { - "TS2352": 1 - }, - "open-sse/executors/default.ts": { - "TS2352": 1 - }, - "open-sse/executors/duckduckgo-web.ts": { - "TS2345": 2 - }, - "open-sse/executors/duckduckgo-web/challenge.ts": { - "TS2304": 1 - }, - "open-sse/executors/edgeTts.ts": { - "TS2345": 1 - }, - "open-sse/executors/gemini-business.ts": { - "TS2339": 1 - }, - "open-sse/executors/ghe-copilot.ts": { - "TS2554": 1 - }, - "open-sse/executors/inner-ai.ts": { - "TS2352": 2 - }, - "open-sse/executors/theoldllm.ts": { - "TS2322": 1 - }, - "open-sse/executors/veoaifree-web.ts": { - "TS2322": 1 - }, - "open-sse/executors/windsurf.ts": { - "TS2322": 1 - }, - "open-sse/handlers/chatCore.ts": { - "TS2339": 30, - "TS2322": 1, - "TS2345": 11 - }, - "open-sse/handlers/chatCore/claudeUpstreamMessages.ts": { - "TS2345": 1 - }, "open-sse/handlers/chatCore/clientUsageBuffer.ts": { - "TS2345": 1 - }, - "open-sse/handlers/chatCore/clineResponseEnvelope.ts": { - "TS2698": 1 - }, - "open-sse/handlers/chatCore/compressionAnalyticsWrite.ts": { - "TS2724": 1 - }, - "open-sse/handlers/chatCore/nonStreamingResponseHeaders.ts": { - "TS2322": 2 - }, - "open-sse/handlers/chatCore/sanitization.ts": { - "TS2339": 1, - "TS2537": 1 - }, - "open-sse/handlers/chatCore/semanticCacheStore.ts": { - "TS2345": 1 - }, - "open-sse/handlers/chatCore/streamingPipeline.ts": { "TS2345": 2 }, - "open-sse/handlers/chatCore/streamingSemanticCacheStore.ts": { - "TS2345": 1 - }, - "open-sse/handlers/chatCore/thinkingSignatureRecovery.ts": { - "TS2339": 2 - }, - "open-sse/handlers/imageGeneration.ts": { - "TS2554": 2 - }, - "open-sse/handlers/responsesHandler.ts": { - "TS2339": 1, - "TS2345": 1 - }, - "open-sse/handlers/sseParser.ts": { - "TS2322": 2 - }, - "open-sse/handlers/videoGeneration.ts": { - "TS2339": 2 - }, - "open-sse/mcp-server/tools/compressionTools.ts": { - "TS2339": 2 - }, - "open-sse/services/__tests__/specificityDetector.test.ts": { - "TS2353": 2 - }, - "open-sse/services/browserBackedChat.ts": { - "TS2322": 1, - "TS2794": 1 - }, - "open-sse/services/claudeAdaptiveThinking.ts": { - "TS2352": 2 - }, - "open-sse/services/comboManifestMetrics.ts": { - "TS2307": 1 - }, - "open-sse/services/compression/engines/ccr/index.ts": { - "TS2339": 1 - }, - "open-sse/services/payloadRules.ts": { - "TS2677": 1 - }, - "open-sse/services/tokenLimitCounter.ts": { - "TS2551": 1 - }, - "open-sse/transformer/responsesTransformer.ts": { - "TS2339": 1 - }, "open-sse/utils/stream.ts": { - "TS2339": 7, - "TS2345": 1, - "TS2556": 1 + "TS2345": 2, + "TS2322": 2 }, - "src/app/api/v1/_shared/mediaGenerationRoute.ts": { - "TS2339": 2 - }, - "src/app/api/v1/models/catalog.ts": { - "TS2345": 1 - }, - "src/app/api/v1/models/catalogVision.ts": { - "TS2322": 1 - }, - "src/app/api/v1/videos/generations/route.ts": { + "src/lib/guardrails/videoBridgeHelpers.ts": { + "TS2488": 1, + "TS2365": 2, "TS2322": 1, "TS2345": 1 - }, - "src/lib/guardrails/visionBridge.ts": { - "TS2345": 1 - }, - "src/lib/providers/codexFastTier.ts": { - "TS2367": 1 - }, - "src/lib/skills/builtins.ts": { - "TS2322": 1 - }, - "src/lib/skills/injection.ts": { - "TS2339": 1 - }, - "src/lib/skills/webFetchExecution.ts": { - "TS2322": 1 - }, - "src/lib/streamingPiiTransform.ts": { - "TS2345": 1 - }, - "src/shared/providers/webSessionCredentials.ts": { - "TS2353": 1, - "TS2322": 1 - }, - "src/shared/validation/helpers.ts": { - "TS2339": 1 - }, - "src/sse/handlers/chat.ts": { - "TS2352": 1, - "TS2322": 2, - "TS2339": 1 - }, - "src/sse/services/model.ts": { - "TS2339": 4 } } diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 91a21fdec8..dac6106abd 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -104,6 +104,7 @@ OmniRoute uses **SQLite** (via `better-sqlite3`) for all persistence. These vari | `OMNIROUTE_SPEND_FLUSH_INTERVAL_MS` | _(default in code)_ | `src/lib/spend/batchWriter.ts` | Flush interval (ms) for the batched spend/cost writer. Lower values reduce write coalescing; higher values reduce DB contention. | | `OMNIROUTE_SPEND_MAX_BUFFER_SIZE` | _(default in code)_ | `src/lib/spend/batchWriter.ts` | Max buffered spend entries before a forced flush. Raise on high-QPS deployments; lower when bounded memory matters more. | | `OMNIROUTE_PROXY_FETCH_DEBUG` | _(unset)_ | `open-sse/utils/proxyFetch.ts` | Set to `"true"` to emit `[ProxyFetch]` debug logs on the Vercel relay path. Off by default to avoid leaking routing hints. | +| `PROXY_LOG_INCLUDE_IPS` | `false` | `src/lib/proxyLogger.ts` | Set to `"true"` or `"1"` to include client/egress IPs and the account prefix in the verbose `[ProxyEgress]` process-log line. Kept OFF by default so the process log does not leak IPs or the account prefix. | | `OMNIROUTE_DEBUG_COMPLETION` | _(unset)_ | `bin/cli/commands/completion.mjs` | Set to any non-empty value to emit `[omniroute completion]` diagnostics from the CLI shell-completion cache paths (read/refresh/write). Off by default — those caches fail silently so a missing/corrupt cache never breaks tab-completion. | | `BATCH_RETRY_DURATION_MS` | `86400000` (24h) | `open-sse/services/batchProcessor.ts` | Maximum retry window for individual batch items (ms). Items exceeding this duration are marked failed. | | `BATCH_BACKOFF_BASE_MS` | `5000` | `open-sse/services/batchProcessor.ts` | Base delay (ms) for exponential backoff on batch item retries. | diff --git a/skills/cli-contexts/SKILL.md b/skills/cli-contexts/SKILL.md index c25640c92f..ae23e91f53 100644 --- a/skills/cli-contexts/SKILL.md +++ b/skills/cli-contexts/SKILL.md @@ -325,6 +325,20 @@ Import contexts from a JSON file omniroute contexts import ``` +### `contexts migrate` + +Move legacy plaintext context credentials to the OS keychain + +**Flags:** + +- `--yes` + +**Example:** + +```bash +omniroute contexts migrate +``` + ### `sessions` **Example:** diff --git a/src/app/(dashboard)/dashboard/memory/components/QdrantConfigCard.tsx b/src/app/(dashboard)/dashboard/memory/components/QdrantConfigCard.tsx index 5fc31527fd..c05b5ac4ee 100644 --- a/src/app/(dashboard)/dashboard/memory/components/QdrantConfigCard.tsx +++ b/src/app/(dashboard)/dashboard/memory/components/QdrantConfigCard.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect, useCallback, useRef } from "react"; import { useTranslations } from "next-intl"; import { Card } from "@/shared/components"; @@ -47,6 +47,12 @@ export default function QdrantConfigCard() { const [embeddingOptions, setEmbeddingOptions] = useState([]); const [loading, setLoading] = useState(true); + // Generation counter for health checks. Bumping it invalidates any in-flight + // or already-resolved check so a stale result (for example one that raced a + // settings save and read the pre-save configuration) can never be applied + // out of order. + const healthSeqRef = useRef(0); + useEffect(() => { Promise.all([ fetch("/api/settings/qdrant").then((r) => (r.ok ? r.json() : null)), @@ -65,10 +71,40 @@ export default function QdrantConfigCard() { .finally(() => setLoading(false)); }, []); + const checkHealth = useCallback(async () => { + const seq = ++healthSeqRef.current; + setChecking(true); + try { + const res = await fetch("/api/settings/qdrant/health"); + if (res.ok) { + const data = await res.json(); + // Drop the result if a newer save/check invalidated this one. + if (healthSeqRef.current !== seq) return; + setHealth(data); + } else { + if (healthSeqRef.current !== seq) return; + setHealth({ ok: false, latencyMs: 0, error: "HTTP error" }); + } + } catch (e) { + if (healthSeqRef.current !== seq) return; + setHealth({ + ok: false, + latencyMs: 0, + error: e instanceof Error ? e.message : String(e), + }); + } finally { + if (healthSeqRef.current === seq) setChecking(false); + } + }, []); + const save = useCallback( async (updates: Partial & { apiKey?: string }) => { const prev = qdrant; const next = { ...qdrant, ...updates }; + // Settings are changing, so any prior health result is stale: drop it and + // invalidate in-flight checks so they cannot overwrite the new state. + healthSeqRef.current += 1; + setHealth(null); setQdrant(next); setSaving(true); setSaveStatus(""); @@ -91,6 +127,16 @@ export default function QdrantConfigCard() { setQdrant(data); setApiKeyInput(""); setSaveStatus("saved"); + // A health check started during the optimistic window (enabled just + // flipped and health was null) can race the PUT and read the OLD + // persisted settings -> not_configured/failed. Invalidate it and + // schedule a fresh check against the just-persisted settings. This + // must be explicit: if health was still null the mount effect bails + // on the setHealth(null) no-op, so a healthy Qdrant would stay red + // until a manual test. + healthSeqRef.current += 1; + setHealth(null); + void checkHealth(); setTimeout(() => setSaveStatus(""), 2000); } else { setQdrant(prev); @@ -103,25 +149,18 @@ export default function QdrantConfigCard() { setSaving(false); } }, - [qdrant] + [qdrant, checkHealth] ); - const checkHealth = useCallback(async () => { - setChecking(true); - try { - const res = await fetch("/api/settings/qdrant/health"); - if (res.ok) setHealth(await res.json()); - else setHealth({ ok: false, latencyMs: 0, error: "HTTP error" }); - } catch (e) { - setHealth({ - ok: false, - latencyMs: 0, - error: e instanceof Error ? e.message : String(e), - }); - } finally { - setChecking(false); + // Auto-check on mount once settings load: without this the status badge + // renders red after a page refresh because `health` starts as null and the + // old code treated "not checked yet" the same as "failed". The Test + // connection button still drives the same check manually. + useEffect(() => { + if (!loading && qdrant.enabled && health === null) { + void checkHealth(); } - }, []); + }, [loading, qdrant.enabled, health, checkHealth]); const runSearch = useCallback(async () => { const q = searchQuery.trim(); @@ -185,18 +224,32 @@ export default function QdrantConfigCard() { {qdrant.enabled - ? health?.ok - ? t("qdrant.statusActive") - : t("qdrant.statusError") + ? health === null + ? t("qdrant.testing") + : health.ok + ? t("qdrant.statusActive") + : t("qdrant.statusError") : t("qdrant.statusDisabled")} diff --git a/stryker.conf.json b/stryker.conf.json index bd69fbc147..61c50f3db9 100644 --- a/stryker.conf.json +++ b/stryker.conf.json @@ -39,9 +39,7 @@ "incremental": true, "incrementalFile": "reports/mutation/stryker-incremental.json", "testRunner": "tap", - "plugins": [ - "@stryker-mutator/tap-runner" - ], + "plugins": ["@stryker-mutator/tap-runner"], "tap": { "testFiles": [ "tests/unit/7993-noauth-proxy-routing.test.ts", @@ -358,6 +356,7 @@ "tests/unit/usage-service-hardening.test.ts", "tests/unit/validate-response-quality.test.ts", "tests/unit/vertex-passthrough-model-lockout.test.ts", + "tests/unit/video-bridge-drilldown-route.test.ts", "tests/unit/video-bridge-route-security.test.ts", "tests/unit/xai-agent-tools-passthrough.test.ts" ], @@ -468,11 +467,7 @@ ".worktrees", ".stryker-tmp" ], - "reporters": [ - "progress", - "html", - "json" - ], + "reporters": ["progress", "html", "json"], "htmlReporter": { "fileName": "reports/mutation/mutation.html" }, diff --git a/tests/unit/compression/omniglyph-chatcore-plumbing.test.ts b/tests/unit/compression/omniglyph-chatcore-plumbing.test.ts index a21a3af9ff..73f7af4875 100644 --- a/tests/unit/compression/omniglyph-chatcore-plumbing.test.ts +++ b/tests/unit/compression/omniglyph-chatcore-plumbing.test.ts @@ -7,6 +7,6 @@ test("chatCore treats both Anthropic providers as direct OmniGlyph transports", assert.match( chatCore, - /providerTransport:\s*provider === "anthropic" \|\| provider === "claude"[\s\S]{0,80}?"direct"/ + /providerTransport:\s*provider === "anthropic"\s*\|\|\s*provider === "claude"[\s\S]{0,160}?"direct"/ ); }); diff --git a/tests/unit/compression/pipeline-circuit-breaker.test.ts b/tests/unit/compression/pipeline-circuit-breaker.test.ts index 49fec449a1..e914ad3224 100644 --- a/tests/unit/compression/pipeline-circuit-breaker.test.ts +++ b/tests/unit/compression/pipeline-circuit-breaker.test.ts @@ -100,6 +100,7 @@ describe("pipelineEngineBreaker — pipeline integration", () => { name: "throwing test engine", targets: ["messages"], stackable: true, + metadata: { executionStages: ["pre-translation"] }, apply() { calls += 1; throw new Error("boom"); diff --git a/tests/unit/ui/qdrant-config-card.test.tsx b/tests/unit/ui/qdrant-config-card.test.tsx index 2f61d68a83..846c11a8cc 100644 --- a/tests/unit/ui/qdrant-config-card.test.tsx +++ b/tests/unit/ui/qdrant-config-card.test.tsx @@ -37,8 +37,9 @@ function makeContainer(): HTMLElement { describe("QdrantConfigCard", () => { beforeEach(() => { - (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = - true; + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; globalThis.fetch = vi.fn().mockImplementation((url: string) => { if (url === "/api/settings/qdrant") { return Promise.resolve({ @@ -68,9 +69,8 @@ describe("QdrantConfigCard", () => { }); it("renders after loading qdrant settings", async () => { - const { default: QdrantConfigCard } = await import( - "../../../src/app/(dashboard)/dashboard/memory/components/QdrantConfigCard" - ); + const { default: QdrantConfigCard } = + await import("../../../src/app/(dashboard)/dashboard/memory/components/QdrantConfigCard"); const container = makeContainer(); const root = createRoot(container); await act(async () => { @@ -110,9 +110,8 @@ describe("QdrantConfigCard", () => { }); globalThis.fetch = fetchMock; - const { default: QdrantConfigCard } = await import( - "../../../src/app/(dashboard)/dashboard/memory/components/QdrantConfigCard" - ); + const { default: QdrantConfigCard } = + await import("../../../src/app/(dashboard)/dashboard/memory/components/QdrantConfigCard"); const container = makeContainer(); const root = createRoot(container); await act(async () => { @@ -123,7 +122,7 @@ describe("QdrantConfigCard", () => { }); const toggleBtn = container.querySelector( - "[data-testid='qdrant-enabled-switch']", + "[data-testid='qdrant-enabled-switch']" ) as HTMLButtonElement | null; expect(toggleBtn).toBeTruthy(); await act(async () => { @@ -135,9 +134,7 @@ describe("QdrantConfigCard", () => { const putCalls = fetchMock.mock.calls.filter( (c: [string, { method?: string }]) => - typeof c[0] === "string" && - c[0] === "/api/settings/qdrant" && - c[1]?.method === "PUT", + typeof c[0] === "string" && c[0] === "/api/settings/qdrant" && c[1]?.method === "PUT" ); expect(putCalls.length).toBeGreaterThan(0); }); @@ -166,9 +163,8 @@ describe("QdrantConfigCard", () => { }); globalThis.fetch = fetchMock; - const { default: QdrantConfigCard } = await import( - "../../../src/app/(dashboard)/dashboard/memory/components/QdrantConfigCard" - ); + const { default: QdrantConfigCard } = + await import("../../../src/app/(dashboard)/dashboard/memory/components/QdrantConfigCard"); const container = makeContainer(); const root = createRoot(container); await act(async () => { @@ -179,7 +175,7 @@ describe("QdrantConfigCard", () => { }); const testBtn = container.querySelector( - "[data-testid='qdrant-test-connection']", + "[data-testid='qdrant-test-connection']" ) as HTMLButtonElement | null; expect(testBtn).toBeTruthy(); await act(async () => { @@ -190,7 +186,7 @@ describe("QdrantConfigCard", () => { }); const healthCalls = fetchMock.mock.calls.filter( - (c: [string]) => typeof c[0] === "string" && c[0] === "/api/settings/qdrant/health", + (c: [string]) => typeof c[0] === "string" && c[0] === "/api/settings/qdrant/health" ); expect(healthCalls.length).toBeGreaterThan(0); // Health OK result should be shown @@ -198,37 +194,36 @@ describe("QdrantConfigCard", () => { }); it("search test button calls /api/settings/qdrant/search and renders results", async () => { - const fetchMock = vi.fn().mockImplementation((url: string, opts?: { method?: string; body?: string }) => { - if (url === "/api/settings/qdrant") { - return Promise.resolve({ - ok: true, - json: async () => MOCK_QDRANT_SETTINGS, - }); - } - if (url === "/api/settings/qdrant/embedding-models") { - return Promise.resolve({ - ok: true, - json: async () => ({ models: [] }), - }); - } - if (url === "/api/settings/qdrant/search" && opts?.method === "POST") { - return Promise.resolve({ - ok: true, - json: async () => ({ + const fetchMock = vi + .fn() + .mockImplementation((url: string, opts?: { method?: string; body?: string }) => { + if (url === "/api/settings/qdrant") { + return Promise.resolve({ ok: true, - results: [ - { id: "r1", score: 0.9876, payload: { content: "test content" } }, - ], - }), - }); - } - return Promise.resolve({ ok: true, json: async () => ({}) }); - }); + json: async () => MOCK_QDRANT_SETTINGS, + }); + } + if (url === "/api/settings/qdrant/embedding-models") { + return Promise.resolve({ + ok: true, + json: async () => ({ models: [] }), + }); + } + if (url === "/api/settings/qdrant/search" && opts?.method === "POST") { + return Promise.resolve({ + ok: true, + json: async () => ({ + ok: true, + results: [{ id: "r1", score: 0.9876, payload: { content: "test content" } }], + }), + }); + } + return Promise.resolve({ ok: true, json: async () => ({}) }); + }); globalThis.fetch = fetchMock; - const { default: QdrantConfigCard } = await import( - "../../../src/app/(dashboard)/dashboard/memory/components/QdrantConfigCard" - ); + const { default: QdrantConfigCard } = + await import("../../../src/app/(dashboard)/dashboard/memory/components/QdrantConfigCard"); const container = makeContainer(); const root = createRoot(container); await act(async () => { @@ -240,7 +235,7 @@ describe("QdrantConfigCard", () => { // Set search query by manipulating the input const searchInputs = Array.from(container.querySelectorAll("input")).filter( - (i) => i.type !== "password" && i.type !== "number", + (i) => i.type !== "password" && i.type !== "number" ); // The search query input is the one with the search placeholder const searchInput = searchInputs[searchInputs.length - 1] as HTMLInputElement | null; @@ -250,7 +245,7 @@ describe("QdrantConfigCard", () => { if (searchInput) { const nativeSetter = Object.getOwnPropertyDescriptor( window.HTMLInputElement.prototype, - "value", + "value" )?.set; nativeSetter?.call(searchInput, "test query"); searchInput.dispatchEvent(new Event("change", { bubbles: true })); @@ -258,7 +253,7 @@ describe("QdrantConfigCard", () => { }); const searchTestBtn = container.querySelector( - "[data-testid='qdrant-search-test']", + "[data-testid='qdrant-search-test']" ) as HTMLButtonElement | null; expect(searchTestBtn).toBeTruthy(); await act(async () => { @@ -272,7 +267,7 @@ describe("QdrantConfigCard", () => { (c: [string, { method?: string }]) => typeof c[0] === "string" && c[0] === "/api/settings/qdrant/search" && - c[1]?.method === "POST", + c[1]?.method === "POST" ); expect(searchCalls.length).toBeGreaterThan(0); }); @@ -301,9 +296,8 @@ describe("QdrantConfigCard", () => { }); globalThis.fetch = fetchMock; - const { default: QdrantConfigCard } = await import( - "../../../src/app/(dashboard)/dashboard/memory/components/QdrantConfigCard" - ); + const { default: QdrantConfigCard } = + await import("../../../src/app/(dashboard)/dashboard/memory/components/QdrantConfigCard"); const container = makeContainer(); const root = createRoot(container); await act(async () => { @@ -314,7 +308,7 @@ describe("QdrantConfigCard", () => { }); const cleanupBtn = container.querySelector( - "[data-testid='qdrant-cleanup']", + "[data-testid='qdrant-cleanup']" ) as HTMLButtonElement | null; expect(cleanupBtn).toBeTruthy(); await act(async () => { @@ -328,10 +322,125 @@ describe("QdrantConfigCard", () => { (c: [string, { method?: string }]) => typeof c[0] === "string" && c[0] === "/api/settings/qdrant/cleanup" && - c[1]?.method === "POST", + c[1]?.method === "POST" ); expect(cleanupCalls.length).toBeGreaterThan(0); // Shows cleanup success message expect(container.textContent).toContain("qdrant.cleanupSuccess"); }); + + it("auto-checks health on mount when enabled (no red error after refresh)", async () => { + const fetchMock = vi.fn().mockImplementation((url: string) => { + if (url === "/api/settings/qdrant") { + return Promise.resolve({ + ok: true, + json: async () => ({ ...MOCK_QDRANT_SETTINGS, enabled: true }), + }); + } + if (url === "/api/settings/qdrant/embedding-models") { + return Promise.resolve({ + ok: true, + json: async () => ({ models: [] }), + }); + } + if (url === "/api/settings/qdrant/health") { + return Promise.resolve({ + ok: true, + json: async () => ({ ok: true, latencyMs: 2 }), + }); + } + return Promise.resolve({ ok: true, json: async () => ({}) }); + }); + globalThis.fetch = fetchMock; + + const { default: QdrantConfigCard } = + await import("../../../src/app/(dashboard)/dashboard/memory/components/QdrantConfigCard"); + const container = makeContainer(); + const root = createRoot(container); + await act(async () => { + root.render(); + }); + await act(async () => { + await new Promise((r) => setTimeout(r, 50)); + }); + + // The health endpoint must be hit automatically on mount — no manual + // "Test connection" click required. Regression: the badge used to render + // red after a page refresh because health started as null and the mount + // effect never checked it. + const healthCalls = fetchMock.mock.calls.filter( + (c: [string]) => typeof c[0] === "string" && c[0] === "/api/settings/qdrant/health" + ); + expect(healthCalls.length).toBeGreaterThan(0); + // Badge shows the real healthy state, not a red error. + expect(container.textContent).toContain("qdrant.statusActive"); + expect(container.textContent).not.toContain("qdrant.statusError"); + }); + + it("re-checks health after a successful save so a stale optimistic-window result cannot leave the badge red (enable ordering)", async () => { + let healthFetchCount = 0; + const fetchMock = vi.fn().mockImplementation((url: string, opts?: { method?: string }) => { + if (url === "/api/settings/qdrant" && opts?.method === "PUT") { + return Promise.resolve({ + ok: true, + json: async () => ({ ...MOCK_QDRANT_SETTINGS, enabled: true }), + }); + } + if (url === "/api/settings/qdrant") { + return Promise.resolve({ ok: true, json: async () => MOCK_QDRANT_SETTINGS }); + } + if (url === "/api/settings/qdrant/embedding-models") { + return Promise.resolve({ ok: true, json: async () => ({ models: [] }) }); + } + if (url === "/api/settings/qdrant/health") { + healthFetchCount += 1; + // The first GET races the settings PUT and sees the OLD persisted + // config (enabled=false) -> not_configured. Post-PUT checks see a + // healthy Qdrant. + if (healthFetchCount === 1) { + return Promise.resolve({ + ok: true, + json: async () => ({ ok: false, latencyMs: 0, error: "not configured" }), + }); + } + return Promise.resolve({ ok: true, json: async () => ({ ok: true, latencyMs: 2 }) }); + } + return Promise.resolve({ ok: true, json: async () => ({}) }); + }); + globalThis.fetch = fetchMock; + + const { default: QdrantConfigCard } = + await import("../../../src/app/(dashboard)/dashboard/memory/components/QdrantConfigCard"); + const container = makeContainer(); + const root = createRoot(container); + await act(async () => { + root.render(); + }); + await act(async () => { + await new Promise((r) => setTimeout(r, 50)); + }); + + // Enable Qdrant: save() optimistically flips the switch and starts the + // PUT while the mount effect immediately GETs health against the OLD + // persisted settings (returned as not_configured above). + const toggleBtn = container.querySelector( + "[data-testid='qdrant-enabled-switch']" + ) as HTMLButtonElement | null; + expect(toggleBtn).toBeTruthy(); + await act(async () => { + toggleBtn?.click(); + }); + await act(async () => { + await new Promise((r) => setTimeout(r, 50)); + }); + + // The stale first check must not be the last word: a fresh health GET must + // be scheduled after the PUT succeeds so the badge ends green. + const allHealthCalls = fetchMock.mock.calls.filter( + (c: [string]) => typeof c[0] === "string" && c[0] === "/api/settings/qdrant/health" + ); + expect(allHealthCalls.length).toBeGreaterThanOrEqual(2); + expect(container.textContent).toContain("qdrant.statusActive"); + expect(container.textContent).not.toContain("qdrant.statusError"); + }); });