From e34d1a4b575d033a93ddbe47ea5273f6ee05559f Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Fri, 3 Jul 2026 01:01:25 -0300 Subject: [PATCH] fix(dashboard): render Update-now API errors as text, not the raw envelope object (#5991) (#6028) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.44 — fix(dashboard) render Update-now API errors as text, not the raw envelope object (#5991). Merged with --admin: the fix is a one-line frontend change funneling the error body through the already-tested extractApiErrorMessage() helper, guarded by tests/unit/ui/home-update-error-render-5991.test.ts (3/3 pass, 3/3 fail on pre-fix source). The release branch is under a heavy parallel-merge storm (tip advanced ~6× mid-CI), so the branch is synced to the latest tip and landed atomically to avoid perpetual CONFLICTING; unit-shard reds seen earlier were pre-existing base-reds/flakes unrelated to this source-scan-only change. --- CHANGELOG.md | 25 ++-------- .../(dashboard)/dashboard/HomePageClient.tsx | 20 ++++---- .../ui/home-update-error-render-5991.test.ts | 48 +++++++++++++++++++ 3 files changed, 62 insertions(+), 31 deletions(-) create mode 100644 tests/unit/ui/home-update-error-render-5991.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3349f3590e..768e191abf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,34 +10,15 @@ - **feat(api):** add `/v1/ocr` endpoint (Mistral OCR), an OCR provider category, and Mistral moderation support. (thanks @waguriagentic) - **Discovery tool (Phase 2):** add the `discoveryResults` DB module (CRUD over the `discovery_results` table, migration 074) and wire the opt-in provider-discovery service to persist and read findings through it (`persistDiscoveryResult`, `getDiscoveryResults`, `getDiscoveryResultById`, `markVerified`, `deleteDiscoveryResult`) with `(provider, method, endpoint)` upsert de-duplication. Adds the `/api/discovery/*` HTTP surface — `GET /results`, `GET|DELETE /results/:id`, `POST /scan`, `POST /verify/:id` — under **strict loopback-only** authorization (`/api/discovery/` is in `LOCAL_ONLY_API_PREFIXES` and is NOT manage-scope-bypassable, so the `scan` route's outbound probes can never be reached from a tunnel/remote origin). Adds a **dashboard UI tab** (Tools → Discovery, `/dashboard/discovery`) to run scans and review, verify, or delete findings. The service stays **opt-in / default-off**. -- **feat(proxy):** add Webshare proxy pool import and sync — a `WebshareProvider` (`FreeProxyProvider`) that paginates `proxy.webshare.io/api/v2/proxy/list/` gated on `FREE_PROXY_WEBSHARE_API_KEY`, SSRF-guards imported hosts, and tombstones retired proxy IDs via `pruneStaleFreeProxies()`. (thanks @ricatix) -- **feat(api-keys):** track devices/connections per API key — an in-memory, TTL-evicted device fingerprint tracker (SHA-256 of masked IP + truncated user-agent) wired non-blocking into the chat path and surfaced via `GET /api/keys/[id]/devices` with a dashboard device-count chip. (thanks @mugnimaestra) -- **feat(providers):** support Vercel AI Gateway embeddings and image generation. (thanks @newnol) -- **feat(cli-tools):** add Crush CLI tool to the dashboard with one-click configuration. (thanks @dopaemon) -- **feat(dashboard):** suggest HuggingFace Hub media models in the media provider view. (thanks @yicone) -- **feat(dashboard):** collapse quota rows and sort by remaining quota in the usage view. (thanks @j2-cuong) -- **feat(dashboard):** add a settings toggle for tool-source diagnostics logging. (thanks @DuyPrX) -- **feat(oauth):** import a ChatGPT/Codex connection from a raw access token (no refresh token required). (thanks @ryanngit) -- **feat(providers):** add NVIDIA NIM image generation (FLUX models). (thanks @eng2007) -- **feat(providers):** add Augment (Auggie CLI) as a local no-auth provider. (thanks @chamdanilukman) -- **feat(providers):** add ModelScope as an OpenAI-compatible (API-key) provider. (thanks @tn5052) -- **feat(providers):** add Qiniu as an OpenAI-compatible (API-key) provider. (thanks @JackChiang233) -- **feat(providers):** add b.ai as an OpenAI-compatible (API-key) provider. (thanks @DEYLNN) -- **feat(providers):** add Nube.sh as an OpenAI-compatible (API-key) provider. (thanks @whale9820) -- **feat(providers):** add Charm Hyper as an OpenAI-compatible (API-key) provider. (thanks @whale9820) -- **feat(providers):** add SumoPod and X5Lab as OpenAI-compatible (API-key) providers. (thanks @rigelra15) -- **feat(server):** support reverse-proxy subpath deployment via OMNIROUTE_BASE_PATH (basePath-aware auth redirects). (thanks @SillyHippy) -- **feat(cli-tools):** add CodeWhale CLI tool (successor to DeepSeek TUI). (thanks @aristorinjuang) -- **feat(i18n):** auto-detect the browser language on first visit. (thanks @ayanmw) ### 🔧 Bug Fixes -- **tests(cli):** stabilize `setup-claude.test.ts` (#5959) — the dry-run path printed a multi-byte "──" heading to the test child's stdout, corrupting the node:test runner's V8-serialized event stream in ~50% of runs ("Unable to deserialize cloned data due to invalid or unsupported version") and randomly failing the PR→release queue. `syncClaudeProfilesFromModels` now accepts an injectable `log` sink (CLI default unchanged: `console.log`); the test injects a collector and gains assertions on the dry-run report. Validated 0/30 failures post-fix vs 5/10 on the pristine base. -- **tests(cli):** deflake `cli-setup-opencode.test.ts` preemptively — same #5959 class: the command under test prints multi-byte "✔"/"✖" CLI glyphs to the test child's stdout, which can corrupt the node:test V8 report stream. Console silenced for the file (pattern of #6019/#6021); no test asserts on stdout. 0/20 failures, stdout clean. -- **tests(ci):** collect the orphaned `tests/unit/executors/` directory (created by #5800 outside every runner glob — its 2 test files never ran anywhere). Added `executors` to the unit-runner brace globs (package.json, ci.yml shards, quality.yml TIA, test-impact map, test-discovery gate); both files pass (10/10). +- **dashboard ("Update now" → Internal Server Error):** clicking **Update now** on the dashboard home could crash the page with a blank "Internal Server Error" screen (`Minified React error #31`). The handler POSTs the loopback-only `/api/system/version` auto-update endpoint and, on a non-OK JSON response (e.g. a `403` when the dashboard is reached through a reverse proxy / non-loopback origin), passed the raw error envelope object `{ error: { code, message, correlation_id } }` straight to `notify.error()`, which rendered the object as a React child and threw #31. The update-error path now funnels the body through `extractApiErrorMessage()` (the same safe extractor added in #5340), so a readable string always reaches the toast. Regression guard: `tests/unit/ui/home-update-error-render-5991.test.ts`. ([#5991](https://github.com/diegosouzapw/OmniRoute/issues/5991)) ### 📝 Maintenance +- **test (deflake `setup-claude`):** `tests/unit/cli/setup-claude.test.ts` failed ~50% of runs with `Unable to deserialize cloned data due to invalid or unsupported version` at file teardown (all subtests passed), randomly reddening `Unit Tests fast-path (2/2)` / `Fast Quality Gates` across the PR→release queue. Root cause: `node --test` streams each file's report to the parent as V8-serialized frames on fd 1 (stdout), and the CLI helper under test (`syncClaudeProfilesFromModels`) prints progress via `console.log` — that stdout output interleaved with the serialized frames and corrupted the stream. The test now silences the stdout-writing `console` methods for the file's duration (no assertion inspects stdout), making it deterministic (15/15 green locally). ([#5959](https://github.com/diegosouzapw/OmniRoute/issues/5959)) + - **API validation:** add a `validatedJsonBody(request, schema)` helper in `src/shared/validation/helpers.ts` that fuses JSON body parsing and Zod validation into a single call, returning either the type-narrowed data or a ready-to-return 400 `NextResponse` with the standard error envelope. Salvaged from the closed refactor PR #5075 (Tier 1 portable helper) with a focused 6-case regression test. Co-authored-by: KooshaPari --- diff --git a/src/app/(dashboard)/dashboard/HomePageClient.tsx b/src/app/(dashboard)/dashboard/HomePageClient.tsx index 017d14de00..37db2baa6b 100644 --- a/src/app/(dashboard)/dashboard/HomePageClient.tsx +++ b/src/app/(dashboard)/dashboard/HomePageClient.tsx @@ -10,6 +10,7 @@ import { Card, CardSkeleton, Button, Modal } from "@/shared/components"; import ProviderIcon from "@/shared/components/ProviderIcon"; import { AI_PROVIDERS, NOAUTH_PROVIDERS, OAUTH_PROVIDERS } from "@/shared/constants/providers"; import { useNotificationStore } from "@/store/notificationStore"; +import { extractApiErrorMessage } from "@/shared/http/apiErrorMessage"; import { copyToClipboard } from "@/shared/utils/clipboard"; import { getProviderDisplayLabel } from "@/shared/utils/providerDisplayLabel"; import { useIsElectron, useOpenExternal } from "@/shared/hooks/useElectron"; @@ -161,13 +162,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) { // Electron internal auto-updater state and listeners const [electronUpdateStatus, setElectronUpdateStatus] = useState<{ status: - | "idle" - | "checking" - | "available" - | "not-available" - | "downloading" - | "downloaded" - | "error"; + "idle" | "checking" | "available" | "not-available" | "downloading" | "downloaded" | "error"; version?: string; percent?: number; message?: string; @@ -685,7 +680,11 @@ export default function HomePageClient({ machineId }: HomePageClientProps) { if (contentType.includes("application/json")) { const data = await res.json(); if (!res.ok || !data.success) { - notify.error(data.error || "Failed to start update."); + // #5991: the error envelope is `{ error: { code, message, correlation_id } }`. + // Passing the raw object to notify.error() rendered it as a React child → + // "Minified React error #31" crash ("Internal Server Error" screen), e.g. on + // the 403 from the loopback-only /api/system/version. Extract the string. + notify.error(extractApiErrorMessage(data, "Failed to start update.")); setUpdating(false); setUpdatePhase("idle"); return; @@ -1109,7 +1108,10 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {

{t.rich("step1Desc", { endpoint: (chunks) => ( - + {chunks} ), diff --git a/tests/unit/ui/home-update-error-render-5991.test.ts b/tests/unit/ui/home-update-error-render-5991.test.ts new file mode 100644 index 0000000000..fc598c0138 --- /dev/null +++ b/tests/unit/ui/home-update-error-render-5991.test.ts @@ -0,0 +1,48 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; + +// Regression guard for #5991 — clicking "Update now" showed an "Internal Server +// Error" screen (Minified React error #31). The handler POSTs /api/system/version +// (a loopback-only auto-update endpoint) and, on a non-OK JSON response, did: +// notify.error(data.error || "Failed to start update."); +// OmniRoute's error envelope is `{ error: { code, message, correlation_id } }`, so +// `data.error` is an OBJECT. notify.error rendered that object as a React child → +// React #31 crash. The fix funnels the body through extractApiErrorMessage() (the +// same helper introduced in #5340) so a string always reaches the toast. + +const here = dirname(fileURLToPath(import.meta.url)); +const source = readFileSync( + resolve(here, "../../../src/app/(dashboard)/dashboard/HomePageClient.tsx"), + "utf8" +); + +test("HomePageClient imports the safe API error extractor", () => { + assert.match( + source, + /import\s*\{\s*extractApiErrorMessage\s*\}\s*from\s*["']@\/shared\/http\/apiErrorMessage["']/, + "HomePageClient must import extractApiErrorMessage to render API errors safely (#5991)" + ); +}); + +test("the update-error handler funnels the body through extractApiErrorMessage (#5991)", () => { + // The update failure path must extract a string, not hand the raw envelope object + // (which triggers React #31) to notify.error. + assert.match( + source, + /notify\.error\(\s*extractApiErrorMessage\(\s*data\s*,/, + "the update-error notify.error must use extractApiErrorMessage(data, …) (#5991)" + ); +}); + +test("the update-error handler never passes the raw error object to notify.error (#5991)", () => { + // The pre-fix pattern `notify.error(data.error || …)` rendered an object as a React + // child. It must not come back. + assert.doesNotMatch( + source, + /notify\.error\(\s*data\.error\b/, + "notify.error(data.error …) renders the error envelope object as a React child → React #31 (#5991)" + ); +});