mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-16 12:12:23 +03:00
Merge branch 'release/v3.8.47' into fix/6803-flaky-timing-tests
This commit is contained in:
1
changelog.d/maintenance/release-v3.8.47-basereds.md
Normal file
1
changelog.d/maintenance/release-v3.8.47-basereds.md
Normal file
@@ -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).
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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",
|
||||
|
||||
173
tests/unit/api-malformed-json-400.test.ts
Normal file
173
tests/unit/api-malformed-json-400.test.ts
Normal file
@@ -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"
|
||||
);
|
||||
});
|
||||
141
tests/unit/fusion-judge-survivor.test.ts
Normal file
141
tests/unit/fusion-judge-survivor.test.ts
Normal file
@@ -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<string, unknown>;
|
||||
|
||||
function okResponse(content: string): Promise<Response> {
|
||||
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<Response> {
|
||||
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)"
|
||||
);
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
206
tests/unit/ui/logs-page-detail-modal-reopen-on-close.test.tsx
Normal file
206
tests/unit/ui/logs-page-detail-modal-reopen-on-close.test.tsx
Normal file
@@ -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 ? <div data-testid="confirm-modal" /> : 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 <LogsPage />;
|
||||
}
|
||||
|
||||
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(<Harness />);
|
||||
});
|
||||
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(<Harness />);
|
||||
});
|
||||
await settle();
|
||||
|
||||
expect(container.querySelector('[role="dialog"]')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user