diff --git a/changelog.d/maintenance/release-v3.8.47-basereds.md b/changelog.d/maintenance/release-v3.8.47-basereds.md new file mode 100644 index 0000000000..5305f83ac7 --- /dev/null +++ b/changelog.d/maintenance/release-v3.8.47-basereds.md @@ -0,0 +1 @@ +- **chore(ci):** fix two shared base-reds on the release tip that blocked the PR queue — register `cliproxyapi-model-mapping-dispatch.test.ts` in `stryker.conf.json` `tap.testFiles` (mutation-coverage gap left by #6903) and update `provider-models-route-codex.test.ts` to expect Codex client version `0.144.0` (stale assertion left by #6780's production bump). diff --git a/open-sse/services/fusion.ts b/open-sse/services/fusion.ts index d089186823..000f5a21c5 100644 --- a/open-sse/services/fusion.ts +++ b/open-sse/services/fusion.ts @@ -354,14 +354,31 @@ export async function handleFusionChat({ // surviving panel answer, rather than silently substituting the panel // member for the configured judge (issue #6455). The judge still adds // value reviewing/polishing a lone source per its documented contract. + } + + // Resolve the judge that ACTUALLY runs synthesis. An explicit judgeModel is + // honored as configured (operator intent — kept even if it was down during + // fan-out; that's the operator's choice). With NO explicit judge the judge + // defaulted to panel[0] — but panel[0] may have FAILED fan-out (timeout / + // rate-limit / dropped straggler → it lands in `failures`, not `answers`). + // Handing synthesis to a dead panel[0] sinks the whole request despite a + // healthy quorum — exactly the case fusion exists to tolerate. So pick a + // SURVIVOR: prefer panel[0] when it survived, otherwise the first survivor. + const effectiveJudge = hasExplicitJudge + ? judge + : answers.some((a) => a.model === panel[0]) + ? panel[0] + : answers[0].model; + + if (answers.length === 1) { log.info( "FUSION", - `Only ${answers[0].model} succeeded — judging single answer with ${judge}` + `Only ${answers[0].model} succeeded — judging single answer with ${effectiveJudge}` ); } // 4. Judge analyzes + writes one final answer (streams to client if requested). const judgeBody = appendUserTurn(body, buildJudgePrompt(answers)); - log.info("FUSION", `Judging ${answers.length} answers with ${judge}`); - return handleSingleModel(judgeBody, judge); + log.info("FUSION", `Judging ${answers.length} answers with ${effectiveJudge}`); + return handleSingleModel(judgeBody, effectiveJudge); } diff --git a/src/app/(dashboard)/dashboard/logs/page.tsx b/src/app/(dashboard)/dashboard/logs/page.tsx index 4b88afde7e..d7051f8612 100644 --- a/src/app/(dashboard)/dashboard/logs/page.tsx +++ b/src/app/(dashboard)/dashboard/logs/page.tsx @@ -46,9 +46,13 @@ export default function LogsPage() { return () => document.removeEventListener("mousedown", handleClickOutside); }, []); - // initial id from URL (synchronously on client) so child can open on mount - const initialId = - typeof window !== "undefined" ? new URL(window.location.href).searchParams.get("id") : null; + // initial id from URL (synchronously on client) so child can open on mount. + // Read once via lazy state: window.location lags router.replace() by one + // render, so re-reading it on every render flips this prop mid-session and + // re-triggers the child's deep-link effect right when the modal closes. + const [initialId] = useState(() => + typeof window !== "undefined" ? new URL(window.location.href).searchParams.get("id") : null + ); async function handleExport(hours: number) { setExporting(true); diff --git a/src/app/api/model-combo-mappings/route.ts b/src/app/api/model-combo-mappings/route.ts index eb8b7d23f4..955530e4f5 100644 --- a/src/app/api/model-combo-mappings/route.ts +++ b/src/app/api/model-combo-mappings/route.ts @@ -4,11 +4,11 @@ * POST — Create a new mapping */ -import { z } from "zod"; import { NextResponse } from "next/server"; +import { z } from "zod"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; -import { getModelComboMappings, createModelComboMapping } from "@/lib/localDb"; -import { validateBody, isValidationFailure } from "@/shared/validation/helpers"; +import { createModelComboMapping, getModelComboMappings } from "@/lib/localDb"; +import { validatedJsonBody } from "@/shared/validation/helpers"; const createMappingSchema = z.object({ pattern: z.string().min(1, "Pattern is required").max(500), @@ -36,13 +36,12 @@ export async function POST(request: Request) { if (authError) return authError; try { - const rawBody = await request.json(); - const validation = validateBody(createMappingSchema, rawBody); - if (isValidationFailure(validation)) { - return NextResponse.json({ error: validation.error }, { status: 400 }); + const parsed = await validatedJsonBody(request, createMappingSchema); + if (!parsed.success) { + return parsed.response; } - const { data } = validation; + const { data } = parsed; const mapping = await createModelComboMapping({ pattern: data.pattern.trim(), comboId: data.comboId, diff --git a/src/app/api/plugins/[name]/config/route.ts b/src/app/api/plugins/[name]/config/route.ts index dbbae2ae32..e6b94375b8 100644 --- a/src/app/api/plugins/[name]/config/route.ts +++ b/src/app/api/plugins/[name]/config/route.ts @@ -1,9 +1,9 @@ -import { NextRequest, NextResponse } from "next/server"; -import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors"; import { buildErrorBody } from "@omniroute/open-sse/utils/error"; -import { getPluginByName, updatePluginConfig } from "@/lib/db/plugins"; -import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { type NextRequest, NextResponse } from "next/server"; import { z } from "zod"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { getPluginByName, updatePluginConfig } from "@/lib/db/plugins"; +import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors"; export async function OPTIONS() { return handleCorsOptions(); @@ -41,7 +41,16 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ const authError = await requireManagementAuth(request); if (authError) return authError; const { name } = await params; - const body = await request.json(); + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json(buildErrorBody(400, "Invalid JSON body"), { + status: 400, + headers: CORS_HEADERS, + }); + } const schema = z.object({ config: z.record(z.string(), z.unknown()), diff --git a/stryker.conf.json b/stryker.conf.json index f7e4f5de32..11fcc96c5b 100644 --- a/stryker.conf.json +++ b/stryker.conf.json @@ -108,6 +108,7 @@ "tests/unit/cli-simulate.test.ts", "tests/unit/cline-response-envelope.test.ts", "tests/unit/clinepass-provider.test.ts", + "tests/unit/cliproxyapi-model-mapping-dispatch.test.ts", "tests/unit/codex-failover.test.ts", "tests/unit/codex-quota-selection-hydration.test.ts", "tests/unit/codex-session-affinity-reset-aware-5903.test.ts", diff --git a/tests/unit/api-malformed-json-400.test.ts b/tests/unit/api-malformed-json-400.test.ts new file mode 100644 index 0000000000..1b0cce21c1 --- /dev/null +++ b/tests/unit/api-malformed-json-400.test.ts @@ -0,0 +1,173 @@ +/** + * Regression tests: mutating API routes must return 400 (not 500) on a malformed + * JSON request body. + * + * Before the fix, both handlers called `await request.json()` on a raw body: + * - PUT /api/plugins/[name]/config had no try/catch → unhandled 500. + * - POST /api/model-combo-mappings parsed inside the outer try whose catch + * returns a generic 500. + * A malformed body must instead surface as a clean 400 with the standard error + * envelope, while a well-formed body keeps its existing behavior. + * + * DB/auth setup mirrors tests/unit/agentSkills-routes.test.ts: a temp DATA_DIR + * with no configured password means requireManagementAuth() is a no-op (auth is + * not required), so the handlers run unauthenticated. DB handles are released in + * test.after (resetDbInstance) per CLAUDE.md — unreleased SQLite handles hang the + * Node test runner. + */ + +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +// ── DB / auth setup ───────────────────────────────────────────────────────── + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-malformed-json-400-")); +const ORIGINAL_DATA_DIR = process.env.DATA_DIR; +const ORIGINAL_API_KEY_SECRET = process.env.API_KEY_SECRET; +const ORIGINAL_INITIAL_PASSWORD = process.env.INITIAL_PASSWORD; + +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET ?? "malformed-json-400-test-secret"; +delete process.env.INITIAL_PASSWORD; // ensure auth is NOT required + +// Import DB first (order matters — sets DATA_DIR before localDb loads) +const core = await import("../../src/lib/db/core.ts"); +const combosDb = await import("../../src/lib/db/combos.ts"); + +// Import routes AFTER env vars are set +const pluginConfigRoute = await import("../../src/app/api/plugins/[name]/config/route.ts"); +const modelComboRoute = await import("../../src/app/api/model-combo-mappings/route.ts"); + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/** Build a POST/PUT Request that CLAIMS to be JSON but carries a non-JSON body. */ +function malformedJsonRequest(url: string, method = "POST"): Request { + return new Request(url, { + method, + headers: { "content-type": "application/json" }, + body: "not-json", + }); +} + +/** Build a POST/PUT Request with a well-formed JSON body. */ +function jsonRequest(url: string, body: unknown, method = "POST"): Request { + return new Request(url, { + method, + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +// ── Lifecycle ───────────────────────────────────────────────────────────────── + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + + if (ORIGINAL_DATA_DIR === undefined) delete process.env.DATA_DIR; + else process.env.DATA_DIR = ORIGINAL_DATA_DIR; + + if (ORIGINAL_API_KEY_SECRET === undefined) delete process.env.API_KEY_SECRET; + else process.env.API_KEY_SECRET = ORIGINAL_API_KEY_SECRET; + + if (ORIGINAL_INITIAL_PASSWORD === undefined) delete process.env.INITIAL_PASSWORD; + else process.env.INITIAL_PASSWORD = ORIGINAL_INITIAL_PASSWORD; +}); + +// ═════════════════════════════════════════════════════════════════════════════ +// PUT /api/plugins/[name]/config +// ═════════════════════════════════════════════════════════════════════════════ + +test("PUT /api/plugins/[name]/config — malformed JSON body returns 400 (was 500)", async () => { + const req = malformedJsonRequest("http://localhost/api/plugins/demo/config", "PUT"); + const res = await pluginConfigRoute.PUT(req, { + params: Promise.resolve({ name: "demo" }), + }); + + assert.equal(res.status, 400, "malformed JSON must yield 400, not an unhandled 500"); + const body = (await res.json()) as { error?: { message?: string } }; + assert.ok(body.error, "response must carry a structured error envelope"); + assert.equal(typeof body.error.message, "string"); + // Hard rule #12: no stack trace leaked in the error message. + assert.ok(!body.error.message?.includes("at /"), "error message must not leak a stack trace"); +}); + +test("PUT /api/plugins/[name]/config — valid body does NOT 400 on parse (404 for unknown plugin)", async () => { + // Well-formed body against a plugin that does not exist: the handler must get + // PAST body parsing/validation and reach the not-found branch (404) — proving + // the happy path is unregressed. + const req = jsonRequest( + "http://localhost/api/plugins/does-not-exist/config", + { config: { foo: "bar" } }, + "PUT" + ); + const res = await pluginConfigRoute.PUT(req, { + params: Promise.resolve({ name: "does-not-exist" }), + }); + + assert.notEqual(res.status, 400, "a well-formed body must not be rejected as invalid JSON"); + assert.equal(res.status, 404, "unknown plugin should surface as 404, past the parse guard"); +}); + +// ═════════════════════════════════════════════════════════════════════════════ +// POST /api/model-combo-mappings +// ═════════════════════════════════════════════════════════════════════════════ + +test("POST /api/model-combo-mappings — malformed JSON body returns 400 (was 500)", async () => { + const req = malformedJsonRequest("http://localhost/api/model-combo-mappings"); + const res = await modelComboRoute.POST(req); + + assert.equal(res.status, 400, "malformed JSON must yield 400, not a generic 500"); + const body = (await res.json()) as { + error?: { + message?: string; + details?: Array<{ field: string; message: string }>; + }; + }; + // Uses the canonical validatedJsonBody envelope. + assert.equal(body.error?.message, "Invalid request"); + assert.deepEqual(body.error?.details, [{ field: "body", message: "Invalid JSON body" }]); +}); + +test("POST /api/model-combo-mappings — well-formed body succeeds (201), no parse regression", async () => { + // Seed a real combo so the mapping's FK (combo_id → combos.id) is satisfied. + await combosDb.createCombo({ + id: "combo-under-test", + name: "combo-under-test", + }); + + const req = jsonRequest("http://localhost/api/model-combo-mappings", { + pattern: "gpt-4*", + comboId: "combo-under-test", + }); + const res = await modelComboRoute.POST(req); + + assert.equal(res.status, 201, "a valid body must still create the mapping (201)"); + const body = (await res.json()) as { + mapping?: { pattern?: string; comboId?: string }; + }; + assert.equal(body.mapping?.pattern, "gpt-4*"); + assert.equal(body.mapping?.comboId, "combo-under-test"); +}); + +test("POST /api/model-combo-mappings — well-formed but invalid body still returns 400", async () => { + // Empty pattern fails the Zod schema — same 400 envelope as the malformed path. + const req = jsonRequest("http://localhost/api/model-combo-mappings", { + pattern: "", + comboId: "combo-x", + }); + const res = await modelComboRoute.POST(req); + + assert.equal(res.status, 400); + const body = (await res.json()) as { + error?: { message?: string; details?: Array<{ field: string }> }; + }; + assert.equal(body.error?.message, "Invalid request"); + assert.ok( + body.error?.details?.some((d) => d.field === "pattern"), + "validation failure should name the offending field" + ); +}); diff --git a/tests/unit/fusion-judge-survivor.test.ts b/tests/unit/fusion-judge-survivor.test.ts new file mode 100644 index 0000000000..22eb2e5bda --- /dev/null +++ b/tests/unit/fusion-judge-survivor.test.ts @@ -0,0 +1,141 @@ +/** + * Regression test: with NO explicit judgeModel, the synthesis judge must be a + * SURVIVING panel member — never a panel[0] that failed fan-out. + * + * Bug: `handleFusionChat` fixed the default judge to `panel[0]` BEFORE fan-out + * and never reassigned it. When panel[0] timed out / was rate-limited / dropped + * as a straggler it landed in `failures`, not `answers` — yet the multi-answer + * synthesis path still dispatched `handleSingleModel(judgeBody, panel[0])`, + * handing synthesis to a dead model. The whole fusion request then errored even + * though a quorum of OTHER panel members succeeded — exactly the failure mode + * fusion exists to tolerate. + * + * Fix: when no explicit judge is configured, resolve the effective judge from a + * survivor (prefer panel[0] only when it survived, else the first survivor). An + * explicitly configured judge is still honored unchanged. + */ + +import assert from "node:assert/strict"; +import test from "node:test"; + +import { handleFusionChat } from "../../open-sse/services/fusion.ts"; + +const noop = () => {}; +const log = { info: noop, warn: noop, debug: noop, error: noop }; + +type Body = Record; + +function okResponse(content: string): Promise { + const body = JSON.stringify({ + choices: [{ message: { role: "assistant", content } }], + }); + return Promise.resolve( + new Response(body, { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ); +} + +function errResponse(status: number): Promise { + const body = JSON.stringify({ error: { message: "boom" } }); + return Promise.resolve( + new Response(body, { + status, + headers: { "Content-Type": "application/json" }, + }) + ); +} + +const PANEL = ["prov/model-a", "prov/model-b", "prov/model-c"]; + +test("fusion judge-survivor: no explicit judge + panel[0] fails fan-out → synthesis uses a surviving member, not the dead panel[0]", async () => { + const seen: string[] = []; + const handleSingleModel = (_b: Body, m: string) => { + seen.push(m); + // panel[0] (model-a) fails fan-out; B & C succeed. + if (m === "prov/model-a") return errResponse(429); + return okResponse(`ans-${m}`); + }; + + const res = await handleFusionChat({ + body: { messages: [{ role: "user", content: "hi" }] }, + models: PANEL, + handleSingleModel, + log, + // NO explicit judge — this is the default-judge path that was broken. + tuning: { minPanel: 1, stragglerGraceMs: 4000, panelHardTimeoutMs: 60000 }, + }); + + assert.notEqual( + res.status, + 503, + "a healthy quorum (B, C) must not error just because panel[0] died" + ); + const body = (await res.clone().json()) as { + choices?: Array<{ message?: { content?: string } }>; + }; + assert.ok( + (body.choices?.[0]?.message?.content ?? "").length > 0, + "must carry a real synthesized answer" + ); + + // The synthesis dispatch is the LAST handleSingleModel call. + const synthesisJudge = seen[seen.length - 1]; + assert.notEqual(synthesisJudge, "prov/model-a", "judge must NOT be the failed panel[0]"); + assert.ok( + synthesisJudge === "prov/model-b" || synthesisJudge === "prov/model-c", + `judge must be a survivor (B or C), got ${synthesisJudge}` + ); +}); + +test("fusion judge-survivor: no explicit judge + panel[0] survives → panel[0] is still chosen (existing-good case unchanged)", async () => { + const seen: string[] = []; + const handleSingleModel = (_b: Body, m: string) => { + seen.push(m); + // panel[0] (model-a) survives; model-b fails. + if (m === "prov/model-b") return errResponse(429); + return okResponse(`ans-${m}`); + }; + + const res = await handleFusionChat({ + body: { messages: [{ role: "user", content: "hi" }] }, + models: PANEL, + handleSingleModel, + log, + tuning: { minPanel: 1, stragglerGraceMs: 4000, panelHardTimeoutMs: 60000 }, + }); + + assert.notEqual(res.status, 503); + assert.equal( + seen[seen.length - 1], + "prov/model-a", + "when panel[0] survives it remains the default judge" + ); +}); + +test("fusion judge-survivor: explicit judge is honored unchanged even if it failed fan-out", async () => { + const seen: string[] = []; + const handleSingleModel = (_b: Body, m: string) => { + seen.push(m); + // The configured judge (model-a) fails fan-out; B & C succeed. + if (m === "prov/model-a") return errResponse(429); + return okResponse(`ans-${m}`); + }; + + const res = await handleFusionChat({ + body: { messages: [{ role: "user", content: "hi" }] }, + models: PANEL, + handleSingleModel, + log, + judgeModel: "prov/model-a", // explicit — operator intent is honored as-is. + tuning: { minPanel: 1, stragglerGraceMs: 4000, panelHardTimeoutMs: 60000 }, + }); + + assert.notEqual(res.status, 503); + assert.equal( + seen[seen.length - 1], + "prov/model-a", + "an explicitly configured judge is dispatched unchanged (operator's choice)" + ); +}); diff --git a/tests/unit/provider-models-route-codex.test.ts b/tests/unit/provider-models-route-codex.test.ts index 42f14e78bb..c6b6ba9291 100644 --- a/tests/unit/provider-models-route-codex.test.ts +++ b/tests/unit/provider-models-route-codex.test.ts @@ -145,11 +145,11 @@ test("provider models route discovers live Codex models and preserves static ali assert.equal(body.source, "api"); assert.deepEqual(seenRequests, [ { - url: "https://chatgpt.com/backend-api/codex/models?client_version=0.142.0", + url: "https://chatgpt.com/backend-api/codex/models?client_version=0.144.0", authorization: "Bearer codex-access-token", workspaceId: "account-123", originator: "codex_cli_rs", - userAgent: "codex-cli/0.142.0 (Windows 10.0.26200; x64)", + userAgent: "codex-cli/0.144.0 (Windows 10.0.26200; x64)", }, { url: "https://raw.githubusercontent.com/openai/codex/refs/heads/main/codex-rs/models-manager/models.json", diff --git a/tests/unit/ui/logs-page-detail-modal-reopen-on-close.test.tsx b/tests/unit/ui/logs-page-detail-modal-reopen-on-close.test.tsx new file mode 100644 index 0000000000..27d418ee46 --- /dev/null +++ b/tests/unit/ui/logs-page-detail-modal-reopen-on-close.test.tsx @@ -0,0 +1,206 @@ +// @vitest-environment jsdom +/** + * TDD regression: dashboard/logs — closing the request-detail modal after the + * FIRST row click immediately reopens it; only the second close works. + * + * Root cause: LogsPage computes `initialId` from `window.location` on EVERY + * render, but Next.js App Router syncs `window.location` only after the + * navigation commits — i.e. after the re-render triggered by + * `router.replace()`. So: + * + * 1. Row click → openDetail → router.replace("?id=X"): page re-renders while + * location is still the old URL → initialId stays null, the child's + * one-shot `initialOpenedRef` guard is never consumed. + * 2. First close → closeDetail → router.replace(no id): page re-renders while + * location STILL carries "?id=X" → initialId flips null → "X" → the child's + * deep-link effect fires (guard still unarmed) → openDetail reopens the + * modal. + * 3. Second close works because the guard is now armed. + * + * The router mock below reproduces that ordering: replace() re-renders the + * page synchronously (like the App Router segment re-render) and the URL is + * committed to window.location separately, after the render — via + * commitPendingUrl(). + */ +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const routerControl = vi.hoisted(() => ({ + pendingUrl: null as string | null, + bumpPageRender: () => {}, +})); + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ + replace: (url: string) => { + // Mirror App Router ordering: the segment re-renders first, the URL is + // synced to window.location only after the commit (commitPendingUrl()). + routerControl.pendingUrl = url; + routerControl.bumpPageRender(); + }, + push: vi.fn(), + prefetch: vi.fn(), + refresh: vi.fn(), + }), + usePathname: () => "/dashboard/logs", + useSearchParams: () => new URLSearchParams(globalThis.location.search), +})); + +vi.mock("@/store/emailPrivacyStore", () => ({ + default: () => ({ emailsVisible: true }), +})); + +// LogsPage imports { ConfirmModal, RequestLoggerV2 } from the barrel; keep the +// real logger (the component under test) and stub the unrelated ConfirmModal +// so the test doesn't drag the whole barrel into jsdom. +vi.mock("@/shared/components", async () => { + const { default: RequestLoggerV2 } = await import( + "../../../src/shared/components/RequestLoggerV2.tsx" + ); + const ConfirmModal = ({ isOpen }: { isOpen: boolean }) => + isOpen ?
: null; + return { RequestLoggerV2, ConfirmModal }; +}); + +const { default: LogsPage } = await import( + "../../../src/app/(dashboard)/dashboard/logs/page.tsx" +); + +// Stands in for the App Router segment root: router.replace() re-renders the +// whole page tree, which is exactly what re-evaluates LogsPage's initialId. +function Harness() { + const [, setVersion] = React.useState(0); + React.useEffect(() => { + routerControl.bumpPageRender = () => setVersion((v) => v + 1); + return () => { + routerControl.bumpPageRender = () => {}; + }; + }, []); + return ; +} + +function commitPendingUrl() { + if (routerControl.pendingUrl != null) { + window.history.replaceState(null, "", routerControl.pendingUrl); + routerControl.pendingUrl = null; + } +} + +class FakeIntersectionObserver { + observe() {} + unobserve() {} + disconnect() {} + takeRecords() { + return []; + } +} + +const LOG_ROW = { + id: "log-1", + status: 200, + timestamp: new Date().toISOString(), + model: "gpt-4o", + provider: "openai", + account: "user@example.com", + tokens: { in: 10, out: 20 }, + duration: 1234, +}; + +let container: HTMLElement; +let root: Root; + +async function settle() { + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); +} + +beforeEach(() => { + localStorage.clear(); + routerControl.pendingUrl = null; + routerControl.bumpPageRender = () => {}; + vi.stubGlobal("IntersectionObserver", FakeIntersectionObserver); + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.startsWith("/api/usage/call-logs")) { + return Response.json([LOG_ROW]); + } + if (url.startsWith("/api/logs/detail")) { + return Response.json({ enabled: false }); + } + if (url.startsWith(`/api/logs/${LOG_ROW.id}`)) { + return Response.json({ ...LOG_ROW, active: false }); + } + if (url.startsWith("/api/provider-nodes")) { + return Response.json({ nodes: [] }); + } + return Response.json({}); + }) + ); + vi.useFakeTimers(); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(async () => { + await act(async () => { + root.unmount(); + }); + container.remove(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + window.history.replaceState(null, "", "/dashboard/logs"); +}); + +describe("LogsPage detail modal — first-close reopen regression", () => { + it("closing the modal after the first row click keeps it closed", async () => { + window.history.replaceState(null, "", "/dashboard/logs"); + + await act(async () => { + root.render(); + }); + await settle(); + + // First click on the log row opens the detail modal. + const row = container.querySelector("tbody tr") as HTMLTableRowElement; + expect(row).not.toBeNull(); + await act(async () => { + row.click(); + }); + await settle(); + // Navigation commits after the render: URL now carries ?id=log-1. + commitPendingUrl(); + expect(window.location.search).toContain("id=log-1"); + expect(container.querySelector('[role="dialog"]')).not.toBeNull(); + + // First close: closeDetail() re-renders the page while window.location + // still has ?id=log-1 (the close navigation has not committed yet). + const dialog = container.querySelector('[role="dialog"]') as HTMLElement; + await act(async () => { + dialog.click(); // backdrop click → onClose + }); + await settle(); + + // The modal must stay closed — the stale ?id in location must not reopen it. + expect(container.querySelector('[role="dialog"]')).toBeNull(); + }); + + it("deep link ?id= still opens the modal on mount", async () => { + window.history.replaceState(null, "", `/dashboard/logs?id=${LOG_ROW.id}`); + + await act(async () => { + root.render(); + }); + await settle(); + + expect(container.querySelector('[role="dialog"]')).not.toBeNull(); + }); +});