Files
OmniRoute/tests/unit/ui/runtime-page-client.test.tsx
Diego Rodrigues de Sa e Souza bf8b56b29f Release v3.8.20 (#3547)
* chore(release): open v3.8.20 development cycle

* fix(images): prefer bare combos over image aliases (#3527)

Integrated into release/v3.8.20

* fix(translator): map Codex local_shell tool (#3534)

Integrated into release/v3.8.20

* fix(usage): make opencode-go quota fetcher fail-open instead of throwing 500 (#3522)

Integrated into release/v3.8.20

* Fix Runtime page breaker state rendering (#3533)

Integrated into release/v3.8.20

* Expose provider breaker degradation threshold setting (#3535)

Integrated into release/v3.8.20

* fix(executor): strip provider prefix from versioned built-in tool model field (#3532)

Integrated into release/v3.8.20

* feat(providers): add Claude Fable 5 support (#3524)

Integrated into release/v3.8.20

* feat(resilience): add global provider cooldown tracking to prevent combo re-walking (#3556)

Integrated into release/v3.8.20 (default OFF, opt-in)

* fix(translator): scope thoughtSignature bypass to Antigravity/CLI only (#3560)

Integrated into release/v3.8.20. Co-authored-by: Six7Day <six7day@gmail.com>

* fix(routing): normalize thinking:disabled for combo-substituted models that reject it (#3554) (#3563)

Integrated into release/v3.8.20

* fix(usage): accept 0/empty budget limits so the dashboard can save and clear (#3537) (#3564)

Integrated into release/v3.8.20

* docs(changelog): credit @Six7Day for #3560 thoughtSignature fix (#3414)

The #3560 squash co-author trailer landed inline (unparsed by GitHub), so add
an explicit CHANGELOG credit ensuring @Six7Day (original #3414) and @oyi77 are
on the public record for the Gemini thoughtSignature fix.

* fix(gamification): dedup badge unlock via user_badges so events don't re-fire every request (#3472) (#3565)

Integrated into release/v3.8.20

* fix(routing): pass through 'auto' keyword on codex /v1/responses instead of rewriting to codex/auto (#3509) (#3566)

Integrated into release/v3.8.20

* fix(cli-tools): normalize apiKey null in guide-settings schema so cloud-mode config saves (#3552) (#3567)

Integrated into release/v3.8.20

* fix(catalog): reclassify PublicAI from keyless to one-time-initial (requires API key) (#3558) (#3568)

Integrated into release/v3.8.20

* fix(gemini-web): surface missing Playwright browser as actionable 503 + cooldown hint, not a retryable 500 loop (#3516) (#3570)

Integrated into release/v3.8.20

* fix(security): sanitize raw err.message in web executors + embeddings/search response bodies (Rule #12) (#3494, #3495) (#3573)

Integrated into release/v3.8.20

* fix(dashboard): point CustomHostsManager + FeatureFlagsGrid at real routes (#3486, #3487) (#3574)

Integrated into release/v3.8.20

* chore(providers): remove dead krutrim entry (#3483) + docs(api): fix agent-bridge per-agent state route (#3489) (#3575)

Integrated into release/v3.8.20

* docs(api): correct API_REFERENCE.md paths for skills/plugins/admin/cache/acp/system-info (#3497) (#3577)

Integrated into release/v3.8.20

* fix(proxy): drive SOCKS5 UI option from runtime ENABLE_SOCKS5_PROXY, not build-time NEXT_PUBLIC (#3508) (#3579)

Integrated into release/v3.8.20

* fix(playground): filter playground models by node prefix so custom-endpoint models appear (#3505) (#3581)

Integrated into release/v3.8.20

* fix(usage): show an informative message instead of a blank Kiro quota card when no usage breakdown (#3506) (#3582)

Integrated into release/v3.8.20

* docs(changelog): add the #3506 Kiro quota entry (missed in #3582 due to a stale-base CHANGELOG anchor) (#3583)

Integrated into release/v3.8.20

* fix(auto-update): use stable PROJECT_ROOT walker, not frozen process.cwd() (#3561)

Integrated into release/v3.8.20. Auto-update PROJECT_ROOT now uses a stable __dirname-anchored upward walker instead of the no-op process.cwd() resolver.

* fix: address PR #3518 review comments (lifecycle hooks, regex, indentation, route params) (#3562)

Integrated into release/v3.8.20. Addresses #3518 review: regex literals, logs/[id] route params (Next 16), indentation, and wires plugin lifecycle hooks (onInstall/onActivate/onDeactivate/onUninstall) in the loader so manager.ts can register them. Adds Rule #18 regression test.

* docs(changelog): credit @ViFigueiredo (#3423) for PROJECT_ROOT + log #3561/#3562 (v3.8.20)

* fix: openai to gemini incorrectly translates historical tool calls into text (#3569)

Integrated into release/v3.8.20. Standard Gemini direct path now maps historical tool calls to native functionCall/functionResponse parts (signaturelessToolCallMode: native) instead of inert text — validated against the real Gemini API (gemini-2.5-flash returns 200 for signatureless native functionCall, even with tools+thinking; Hard Rule #18). Eliminates the text-serialization leak. Antigravity/CLI sentinel path (#3560) untouched.

* docs(changelog)+test: reconcile standard-Gemini native mode (#3569) — update round-2 rationale comment + log VPS validation

* docs(changelog): reconcile v3.8.20 — add 9 missing bullets + move [Unreleased] to versioned section

* docs(changelog): complete v3.8.20 reconciliation — 27 bullets, 11 contributors

---------

Co-authored-by: Alexander Averyanov <alex@averyan.ru>
Co-authored-by: Hakan Kurşun <bykamaka@gmail.com>
Co-authored-by: Wilson <pedbookmed@gmail.com>
Co-authored-by: Randi <55005611+rdself@users.noreply.github.com>
Co-authored-by: Giorgos Giakoumettis <giorgos@yiakoumettis.gr>
Co-authored-by: PizzaV <103120356+pizzav-xyz@users.noreply.github.com>
Co-authored-by: Paijo <14921983+oyi77@users.noreply.github.com>
Co-authored-by: Markus Hartung <mail@hartmark.se>
2026-06-10 13:49:08 -03:00

193 lines
6.2 KiB
TypeScript

// @vitest-environment jsdom
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import RuntimePageClient from "../../../src/app/(dashboard)/dashboard/runtime/RuntimePageClient";
function jsonResponse(data: unknown) {
return {
ok: true,
json: async () => data,
} as Response;
}
function getRequestPath(input: RequestInfo | URL) {
return typeof input === "string" ? input : input instanceof URL ? input.pathname : input.url;
}
const cleanupCallbacks: Array<() => void> = [];
async function waitForText(text: string, timeoutMs = 3000) {
const startedAt = Date.now();
while (!document.body.textContent?.includes(text)) {
if (Date.now() - startedAt > timeoutMs) {
throw new Error(`Timed out waiting for text: ${text}`);
}
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 20));
});
}
}
async function renderRuntimePage() {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
await act(async () => {
root.render(<RuntimePageClient />);
});
cleanupCallbacks.push(() => {
act(() => {
root.unmount();
});
container.remove();
});
}
vi.mock("next/link", () => ({
default: ({ href, children, ...props }: React.AnchorHTMLAttributes<HTMLAnchorElement>) => (
<a href={String(href)} {...props}>
{children}
</a>
),
}));
vi.mock("@/shared/components/Card", () => ({
default: ({ children }: { children: React.ReactNode }) => <section>{children}</section>,
}));
vi.mock("@/shared/components/ProviderIcon", () => ({
default: ({ providerId }: { providerId: string }) => (
<span data-testid={`provider-icon-${providerId}`} />
),
}));
vi.mock("next-intl", () => ({
useTranslations: () => {
const messages: Record<string, string> = {
title: "Runtime",
description: "Realtime observability",
pause: "Pause",
resume: "Resume",
refreshNow: "Refresh now",
kpiSessions: "Sessions",
kpiCircuits: "Circuits",
kpiCooldowns: "Cooldowns",
kpiLockouts: "Lockouts",
hintStickyBound: "{count} sticky-bound",
hintRecovering: "{count} recovering",
hintAllHealthy: "all healthy",
hintOpen: "open",
hintConnsCooling: "connections cooling",
hintModelsBlocked: "models blocked",
resilienceTitle: "3-Layer Resilience",
resilienceSubtitle: "Mirrors the documented resilience model",
providersHealthy: "{percent}% providers healthy",
layer: "Layer {n}",
layer1Title: "Provider Circuit Breakers",
layer1Desc: "Stop traffic to providers failing at the upstream level",
layer2Title: "Connection Cooldowns",
layer2Desc: "Skip one bad account/key",
layer3Title: "Model Lockouts",
layer3Desc: "Per-model rate-limit locks",
badgeAffectedOf: "{affected} of {total} affected",
badgeCooling: "{count} cooling",
badgeLocked: "{count} locked",
emptyCircuits: "No circuit breakers active yet",
emptyCooldowns: "No connection cooldowns active",
emptyLockouts: "No model lockouts",
feedTitle: "Live Feed",
feedSubtitle: "Last {count} events",
feedFilterAll: "All",
feedFilterCircuits: "Circuits",
feedFilterCooldowns: "Cooldowns",
feedFilterLockouts: "Lockouts",
feedFilterSessions: "Sessions",
feedFilterQuotas: "Quotas",
feedClear: "Clear",
feedEmptyWaiting: "Waiting for events...",
feedEmptyFiltered: "No events match this filter",
sessionsTitle: "Active Sessions",
sessionsSubtitle: "Sticky-bound request fingerprints",
sessionsActive: "{count} active",
sessionsEmptyTitle: "No active sessions",
sessionsEmptyHint: "Sessions appear as requests flow through the proxy",
tblSession: "Session",
tblAge: "Age",
tblIdle: "Idle",
tblReqs: "Reqs",
tblBoundTo: "Bound to",
topApiKeys: "Top API keys",
quotaMonitorsTitle: "Quota Monitors",
quotaMonitorsSubtitle: "Live quota state per account window",
openQuota: "Open Quota",
allQuotasHealthy: "All quotas healthy",
moreSuffix: "+{count} more",
};
return (key: string, values?: Record<string, unknown>) => {
let message = messages[key] ?? key;
if (values) {
for (const [name, value] of Object.entries(values)) {
message = message.replace(`{${name}}`, String(value));
}
}
return message;
};
},
}));
describe("RuntimePageClient", () => {
const fetchMock = vi.fn();
beforeEach(() => {
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
fetchMock.mockReset();
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
while (cleanupCallbacks.length > 0) {
cleanupCallbacks.pop()?.();
}
document.body.innerHTML = "";
vi.unstubAllGlobals();
});
it("renders degraded and unknown provider breaker states without crashing", async () => {
fetchMock.mockImplementation((input: RequestInfo | URL) => {
const path = getRequestPath(input);
if (path === "/api/monitoring/health") {
return Promise.resolve(
jsonResponse({
providerBreakers: [
{ provider: "free3", state: "DEGRADED", failureCount: 7, retryAfterMs: 0 },
{ provider: "future-provider", state: "SUSPENDED", failureCount: 1, retryAfterMs: 0 },
],
lockouts: {},
sessions: { activeCount: 0, stickyBoundCount: 0, byApiKey: {}, top: [] },
quotaMonitor: { active: 0, alerting: 0, exhausted: 0, errors: 0, monitors: [] },
})
);
}
if (path === "/api/providers/client") {
return Promise.resolve(jsonResponse({ connections: [] }));
}
return Promise.resolve(jsonResponse({}));
});
await renderRuntimePage();
await waitForText("free3");
await waitForText("future-provider");
await waitForText("DEG");
await waitForText("UNK");
expect(document.body.textContent).not.toContain("Internal Server Error");
});
});