fix(memory): auto-check Qdrant health on mount and stop false-red badge (#10489)

* fix(memory): auto-check Qdrant health on mount and stop false-red badge

The Qdrant engine card on /dashboard/memory?tab=engine showed a red
"Error" badge after every page refresh even when Qdrant was healthy:
the badge derives its state from a health check, but the mount effect
only fetched settings + embedding models — health started as null and
the render treated `health?.ok` (undefined) as a failure. Clicking
"Test connection" (which runs the same server-side /readyz check)
immediately turned it green, proving the connection was fine.

Two changes:
- Auto-run the health check on mount once settings load and Qdrant is
  enabled, so a refreshed page reflects the real state (verified live:
  /api/settings/qdrant/health returns ok:true in ~2ms on a healthy
  compose deployment).
- While health has not been checked yet (null), render a neutral gray
  "Testing..." state instead of red — red is now reserved for an
  actual failed health check.

Regression test added (fails on the old code): with enabled settings
and a healthy mock, the card must hit /api/settings/qdrant/health on
mount and show statusActive, never statusError.

* chore(changelog): fragment for #10489

* Merge branch 'release/v3.8.50' into fix/qdrant-health-badge

* test(fix): refresh expired alibaba quota sample validity and onnxruntime pin for v3.8.50 base

- alibaba-free-tier-quota-fetcher.test.ts: sample quotaValidityPeriod
  (2026-08-16 16:00 UTC) is in the past, making every quota entry classify
  as expired/not_capable; bump to 2028-01-01 UTC so the text/merge
  classification tests exercise the intended path again.
- optional-transformers-dependency.test.ts: onnxruntime-node pin assertion
  updated from ~1.24.3 to ~1.27.0 to match package.json (bumped by #10403);
  the regular-not-optional intent is unchanged.

* test(fix): align optional-transformers-dependency with onnxruntime ~1.24.3 pin (base #10543)

* docs(fix): sync 150-migration count and document PROXY_LOG_INCLUDE_IPS (base drift #10348/#10507)

* fix(memory): re-check Qdrant health after saving settings

save() optimistically flipped enabled and started the PUT while the mount
effect could immediately GET /api/settings/qdrant/health against the OLD
persisted settings. If that GET won, it returned not_configured/failed and -
because health was non-null - the effect never retried after the PUT
succeeded, leaving a healthy Qdrant red until a manual Test connection.

Invalidate health (generation counter + setHealth(null)) at save start and
after a successful PUT, then explicitly schedule a fresh check: setting
health to null alone is not enough, React bails on the no-op when health is
already null (the exact GET-wins ordering). Stale responses are dropped via
the sequence guard so an in-flight pre-save check can never overwrite the
post-save result. Adds a regression test covering enable ordering.

Addresses PR #10489 review finding (issuecomment-5312271806).

* fix: narrow omniglyph transform result union (merge base aa912c42a typecheck gate)

* test(compression): align contract tests with base aa912c42a merge (providerTransport shape, engine metadata)

* fix(memory): silence set-state-in-effect on Qdrant auto health-check

The health-check re-check fix (3469234) introduced an effect that calls
checkHealth() (an async fetch that eventually calls setState) directly
from a useEffect gated on loading/enabled/health. The
react-hooks/set-state-in-effect rule flags this as a potential cascading
render, matching the same pattern already accepted elsewhere in the
dashboard (FreePoolTab.tsx, ConnectionsTable.tsx) for gated async
data-fetch effects. Suppress with the established inline convention;
no behavior change.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* fix(memory): drop unused set-state-in-effect disable (rule inert on pinned react-hooks 7.0.1)

The eslint-disable-next-line for react-hooks/set-state-in-effect is unused:
eslint-plugin-react-hooks@7.0.1 (lockfile-pinned) does not report this rule,
so the directive itself was flagged as a warning and the 'No new ESLint
warnings' CI gate failed with --max-warnings 0. The effect body only calls
checkHealth() (async fetch) with no raw setState, so no disable is needed.

* ci(quality): sync ratchet configs to release/v3.8.50 (0a74bfbde) merge

- re-freeze open-sse typecheck baseline at merged-tree live counts
  (64 stale entries dropped, 11 frozen; base video/usage drift covered)
- register tests/unit/video-bridge-drilldown-route.test.ts in stryker tap.testFiles
- regenerate skills/cli-contexts/SKILL.md (contexts migrate docs from CLI closure)

---------

Co-authored-by: Rouzbeh <rqzbeh@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
This commit is contained in:
Rouzbeh†
2026-08-18 17:20:54 +03:30
committed by GitHub
parent 50da54484e
commit 497dd6f357
10 changed files with 270 additions and 251 deletions

View File

@@ -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

View File

@@ -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))

View File

@@ -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
}
}

View File

@@ -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. |

View File

@@ -325,6 +325,20 @@ Import contexts from a JSON file
omniroute contexts import <file>
```
### `contexts migrate`
Move legacy plaintext context credentials to the OS keychain
**Flags:**
- `--yes`
**Example:**
```bash
omniroute contexts migrate
```
### `sessions`
**Example:**

View File

@@ -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<EmbeddingModelOption[]>([]);
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<QdrantSettings> & { 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() {
</div>
<span
className={`inline-flex items-center gap-1.5 text-xs font-medium ${
qdrant.enabled ? (health?.ok ? "text-emerald-500" : "text-red-500") : "text-text-muted"
!qdrant.enabled
? "text-text-muted"
: health === null
? "text-text-muted"
: health.ok
? "text-emerald-500"
: "text-red-500"
}`}
>
<span
className={`inline-block w-2.5 h-2.5 rounded-full ${
qdrant.enabled ? (health?.ok ? "bg-emerald-500" : "bg-red-500") : "bg-border"
!qdrant.enabled
? "bg-border"
: health === null
? "bg-border"
: health.ok
? "bg-emerald-500"
: "bg-red-500"
}`}
/>
{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")}
</span>
</div>

View File

@@ -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"
},

View File

@@ -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"/
);
});

View File

@@ -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");

View File

@@ -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(<QdrantConfigCard />);
});
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(<QdrantConfigCard />);
});
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");
});
});