mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-18 21:22:28 +03:00
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 baseaa912c42atypecheck gate) * test(compression): align contract tests with baseaa912c42amerge (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:
@@ -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
|
||||
|
||||
2
changelog.d/fixes/10489-qdrant-health-badge.md
Normal file
2
changelog.d/fixes/10489-qdrant-health-badge.md
Normal 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))
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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:**
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"/
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user