mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-11 17:52:31 +03:00
Merge branch 'pr/9013' into feat/personal-build
This commit is contained in:
1
changelog.d/fixes/9013-model-param-filter-save.md
Normal file
1
changelog.d/fixes/9013-model-param-filter-save.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(dashboard):** model-level allowed/blocked param edits now persist when the compatibility popover is closed by clicking outside, and a failed save no longer clears the edit or reports success ([#9013](https://github.com/diegosouzapw/OmniRoute/pull/9013))
|
||||
@@ -127,12 +127,6 @@
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionsListPanel.tsx": {
|
||||
"TS2322": 2
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/components/CustomModelsSection.tsx": {
|
||||
"TS2739": 1
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/components/ModelCompatPopover.tsx": {
|
||||
"TS2304": 5
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/components/ProviderModalsPanel.tsx": {
|
||||
"TS2322": 3,
|
||||
"TS2739": 1,
|
||||
@@ -147,9 +141,6 @@
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/components/ProviderPlaygroundPanel.tsx": {
|
||||
"TS2503": 1
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/components/__tests__/phase1d.test.tsx": {
|
||||
"TS2739": 2
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx": {
|
||||
"TS2322": 1
|
||||
},
|
||||
|
||||
@@ -695,6 +695,8 @@ export default function CustomModelsSection({
|
||||
</button>
|
||||
<ModelCompatPopover
|
||||
t={t}
|
||||
providerId={providerId}
|
||||
modelId={model.id!}
|
||||
effectiveModelNormalize={(p) =>
|
||||
effectiveNormalizeForProtocol(model.id!, p, customMap, overrideMap)
|
||||
}
|
||||
|
||||
@@ -26,6 +26,34 @@ function recordToHeaderRows(rec: Record<string, string>, genId: () => string): H
|
||||
return entries.map(([name, value]) => ({ id: genId(), name, value }));
|
||||
}
|
||||
|
||||
// Bounded re-run budget for the model param-filter save: if the draft changed while the PUT was
|
||||
// in flight, the save repeats with the newer draft instead of clearing the dirty flag on a
|
||||
// payload that no longer matches what the user typed (#8910).
|
||||
const PARAM_SAVE_MAX_ATTEMPTS = 3;
|
||||
|
||||
// Param filters are stored as one document per provider. Model rows render independent popover
|
||||
// instances, so their GET -> whole-document PUT transactions must share a provider-level queue;
|
||||
// instance-local saving refs cannot prevent sibling rows from overwriting each other's updates.
|
||||
const paramFilterSaveQueues = new Map<string, Promise<void>>();
|
||||
|
||||
async function serializeProviderParamFilterSave<T>(
|
||||
providerId: string,
|
||||
save: () => Promise<T>
|
||||
): Promise<T> {
|
||||
const previous = paramFilterSaveQueues.get(providerId) ?? Promise.resolve();
|
||||
const result = previous.then(save);
|
||||
const tail = result.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
);
|
||||
paramFilterSaveQueues.set(providerId, tail);
|
||||
try {
|
||||
return await result;
|
||||
} finally {
|
||||
if (paramFilterSaveQueues.get(providerId) === tail) paramFilterSaveQueues.delete(providerId);
|
||||
}
|
||||
}
|
||||
|
||||
function parseCommaList(text: string): string[] {
|
||||
return text
|
||||
? text
|
||||
@@ -42,6 +70,21 @@ interface ParamFilterConfigLike {
|
||||
autoLearn?: boolean;
|
||||
}
|
||||
|
||||
// An unsaved param-filter draft, bound to the provider/model it was typed for. The save path
|
||||
// writes through THIS target instead of the props the callback happens to close over, so a draft
|
||||
// can never be persisted under a provider/model the user never edited (#8910).
|
||||
interface ParamFilterDraft {
|
||||
key: string;
|
||||
providerId: string;
|
||||
modelId: string;
|
||||
block: string;
|
||||
allow: string;
|
||||
}
|
||||
|
||||
function paramTargetKeyOf(providerId: string, modelId: string): string {
|
||||
return `${providerId}\u0000${modelId}`;
|
||||
}
|
||||
|
||||
// Builds the PUT body for the model-level block/allow save. Extracted so the
|
||||
// caller's async handler stays simple — this is pure payload-shaping logic.
|
||||
function buildModelParamFilterPayload(
|
||||
@@ -96,6 +139,8 @@ export interface ModelCompatPopoverProps {
|
||||
|
||||
export default function ModelCompatPopover({
|
||||
t,
|
||||
providerId,
|
||||
modelId,
|
||||
effectiveModelNormalize,
|
||||
effectiveModelPreserveDeveloper,
|
||||
getUpstreamHeadersRecord,
|
||||
@@ -109,8 +154,8 @@ export default function ModelCompatPopover({
|
||||
const [headerRows, setHeaderRows] = useState<HeaderDraftRow[]>([]);
|
||||
const [blockText, setBlockText] = useState("");
|
||||
const [allowText, setAllowText] = useState("");
|
||||
const [paramDirty, setParamDirty] = useState(false);
|
||||
const [paramSaving, setParamSaving] = useState(false);
|
||||
const [paramSaveFailed, setParamSaveFailed] = useState(false);
|
||||
const [valuePeekRowId, setValuePeekRowId] = useState<string | null>(null);
|
||||
const [valueFocusRowId, setValueFocusRowId] = useState<string | null>(null);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
@@ -125,6 +170,78 @@ export default function ModelCompatPopover({
|
||||
const headerRowsRef = useRef<HeaderDraftRow[]>([]);
|
||||
headerRowsRef.current = headerRows;
|
||||
|
||||
// Param-filter drafts are mirrored into a ref so the close/unmount save path reads the
|
||||
// latest typed values instead of the values captured when the handler was created (#8910).
|
||||
const paramSavingRef = useRef(false);
|
||||
// Every unsaved draft, keyed by the provider/model it was typed for. A per-target map (rather
|
||||
// than a single slot) is required because a live popover can be re-pointed at another target
|
||||
// while a draft is still unsaved: with one slot the next keystroke on the new target destroyed
|
||||
// the previous target's unsaved work, and the new target's successful save then cleared the
|
||||
// failure indicator — a green UI over data that was never written, i.e. exactly the silent
|
||||
// data loss reported in #8910. Drafts are recorded when edited — never derived from a completed
|
||||
// load — so an unsaved draft survives even when no load ever succeeded for that target, and
|
||||
// every write lands on the draft's own target rather than whatever the popover now points at.
|
||||
const paramDraftsRef = useRef<Map<string, ParamFilterDraft>>(new Map());
|
||||
|
||||
const paramTargetKey = paramTargetKeyOf(providerId, modelId);
|
||||
const paramTargetRef = useRef<{ key: string; providerId: string; modelId: string }>({
|
||||
key: paramTargetKey,
|
||||
providerId,
|
||||
modelId,
|
||||
});
|
||||
paramTargetRef.current = { key: paramTargetKey, providerId, modelId };
|
||||
|
||||
// Mirrors of the displayed text, so an edit can snapshot both fields synchronously.
|
||||
const blockTextRef = useRef("");
|
||||
const allowTextRef = useRef("");
|
||||
blockTextRef.current = blockText;
|
||||
allowTextRef.current = allowText;
|
||||
// Which target the values currently in the fields belong to. Guards the invariant that
|
||||
// blockTextRef/allowTextRef never hold content belonging to a target other than the one being
|
||||
// displayed — the desync that let one model's server values be saved under another (#8910).
|
||||
const fieldsTargetKeyRef = useRef<string | null>(null);
|
||||
|
||||
const applyParamFields = useCallback((targetKey: string, block: string, allow: string) => {
|
||||
fieldsTargetKeyRef.current = targetKey;
|
||||
blockTextRef.current = block;
|
||||
allowTextRef.current = allow;
|
||||
setBlockText(block);
|
||||
setAllowText(allow);
|
||||
}, []);
|
||||
|
||||
// Every edit rewrites the draft for the CURRENT target. The counterpart field is only trusted
|
||||
// when the values on screen belong to this target; otherwise it is taken from this target's own
|
||||
// pending draft (or empty), so another target's value can never be captured into this draft and
|
||||
// then persisted here (#8910). Object identity doubles as the draft revision an in-flight save
|
||||
// compares against.
|
||||
const editParamDraft = useCallback(
|
||||
(field: "block" | "allow", value: string) => {
|
||||
const target = paramTargetRef.current;
|
||||
const fieldsOwned = fieldsTargetKeyRef.current === target.key;
|
||||
const pending = paramDraftsRef.current.get(target.key);
|
||||
const block =
|
||||
field === "block" ? value : fieldsOwned ? blockTextRef.current : (pending?.block ?? "");
|
||||
const allow =
|
||||
field === "allow" ? value : fieldsOwned ? allowTextRef.current : (pending?.allow ?? "");
|
||||
applyParamFields(target.key, block, allow);
|
||||
paramDraftsRef.current.set(target.key, {
|
||||
key: target.key,
|
||||
providerId: target.providerId,
|
||||
modelId: target.modelId,
|
||||
block,
|
||||
allow,
|
||||
});
|
||||
},
|
||||
[applyParamFields]
|
||||
);
|
||||
const mountedRef = useRef(true);
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const genHeaderRowId = () => {
|
||||
headerRowIdRef.current += 1;
|
||||
return `uh-${headerRowIdRef.current}`;
|
||||
@@ -167,40 +284,140 @@ export default function ModelCompatPopover({
|
||||
// Load model-level block/allow from param-filters API
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const draftKey = paramTargetKeyOf(providerId, modelId);
|
||||
const draftForThisTarget = () => paramDraftsRef.current.get(draftKey);
|
||||
// The fields must always show THIS target's content, and nothing else may ever be read back
|
||||
// out of them. A draft that is still pending for this exact provider/model was never persisted
|
||||
// (failed or exhausted save): restore it into the inputs instead of loading server state over
|
||||
// it, because reloading here would silently revert it — the very complaint behind #8910.
|
||||
const pending = draftForThisTarget();
|
||||
if (pending) {
|
||||
applyParamFields(draftKey, pending.block, pending.allow);
|
||||
return;
|
||||
}
|
||||
// No draft for this target: drop whatever the previously displayed target left on screen so
|
||||
// the fields can never present (or contribute) another target's values.
|
||||
if (fieldsTargetKeyRef.current !== draftKey) applyParamFields(draftKey, "", "");
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/providers/${providerId}/param-filters`);
|
||||
if (!res.ok) throw new Error(`param-filters GET failed: ${res.status}`);
|
||||
const data = await res.json();
|
||||
if (cancelled || !mountedRef.current) return;
|
||||
// Re-check after the await: the user may have typed while the GET was in flight, and a
|
||||
// load result must never overwrite (or acknowledge) a draft that is not on the server.
|
||||
if (draftForThisTarget()) return;
|
||||
const modelCfg = data?.models?.[modelId];
|
||||
setBlockText(modelCfg ? (modelCfg.block ?? []).join(", ") : "");
|
||||
setAllowText(modelCfg ? (modelCfg.allow ?? []).join(", ") : "");
|
||||
applyParamFields(
|
||||
draftKey,
|
||||
modelCfg ? (modelCfg.block ?? []).join(", ") : "",
|
||||
modelCfg ? (modelCfg.allow ?? []).join(", ") : ""
|
||||
);
|
||||
// A load never clears a draft: any draft still pending here belongs to a DIFFERENT
|
||||
// target and is still owed a write to that target (#8910). The failure indicator is
|
||||
// only cleared once nothing is left unsaved anywhere.
|
||||
if (paramDraftsRef.current.size === 0) setParamSaveFailed(false);
|
||||
} catch {
|
||||
setBlockText("");
|
||||
setAllowText("");
|
||||
// Keep whatever the user has in the fields (and its dirty flag) on load failure.
|
||||
}
|
||||
setParamDirty(false);
|
||||
})();
|
||||
}, [open]);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// Reload only when opening or when the popover targets a different provider/model.
|
||||
}, [open, providerId, modelId, applyParamFields]);
|
||||
|
||||
// Drains EVERY pending draft, each written through its OWN provider/model — never the props this
|
||||
// callback happens to be bound to — so a draft typed for target A can never be persisted under a
|
||||
// target B the user never edited, and re-pointing the popover cannot destroy target A's unsaved
|
||||
// work (#8910). Each draft is re-read after every await for the same reason the load effect
|
||||
// re-checks its guard: the user can keep typing while a write is in flight.
|
||||
const saveModelParamFilters = useCallback(async () => {
|
||||
if (!paramDirty) return;
|
||||
setParamSaving(true);
|
||||
if (paramDraftsRef.current.size === 0 || paramSavingRef.current) return;
|
||||
paramSavingRef.current = true;
|
||||
if (mountedRef.current) setParamSaving(true);
|
||||
|
||||
// Returns true once nothing is owed for this target any more.
|
||||
const saveDraftForTarget = async (key: string): Promise<boolean> => {
|
||||
// Re-run while the draft changed under the in-flight write: the payload is snapshotted
|
||||
// before the PUT resolves, so a keystroke landing in that window would otherwise be
|
||||
// acknowledged (draft dropped) but never persisted — the #8910 lost update.
|
||||
for (let attempt = 0; attempt < PARAM_SAVE_MAX_ATTEMPTS; attempt += 1) {
|
||||
const draft = paramDraftsRef.current.get(key);
|
||||
if (!draft) return true;
|
||||
try {
|
||||
const wroteDraft = await serializeProviderParamFilterSave(draft.providerId, async () => {
|
||||
const res = await fetch(`/api/providers/${draft.providerId}/param-filters`);
|
||||
if (!res.ok) throw new Error(`param-filters GET failed: ${res.status}`);
|
||||
const current = await res.json();
|
||||
// The fetched config belongs to draft.providerId; if the draft was replaced by a newer
|
||||
// one while the GET (or this instance's queue wait) was in flight, restart fresh.
|
||||
if (paramDraftsRef.current.get(key) !== draft) return false;
|
||||
const payload = buildModelParamFilterPayload(
|
||||
current,
|
||||
draft.modelId,
|
||||
draft.block,
|
||||
draft.allow
|
||||
);
|
||||
const putRes = await fetch(`/api/providers/${draft.providerId}/param-filters`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!putRes.ok) throw new Error(`param-filters PUT failed: ${putRes.status}`);
|
||||
return true;
|
||||
});
|
||||
if (!wroteDraft) continue;
|
||||
// Only the exact draft that was written may be discarded.
|
||||
if (paramDraftsRef.current.get(key) === draft) {
|
||||
paramDraftsRef.current.delete(key);
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// Save failed — the draft (and the target it belongs to) is intentionally preserved so
|
||||
// the next blur/close/unmount save retries it against its own provider/model.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// Budget exhausted (the user is still typing): stay dirty for this target.
|
||||
return false;
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/providers/${providerId}/param-filters`);
|
||||
const current = await res.json();
|
||||
const payload = buildModelParamFilterPayload(current, modelId, blockText, allowText);
|
||||
await fetch(`/api/providers/${providerId}/param-filters`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
setParamDirty(false);
|
||||
} catch {
|
||||
// Silently ignore save error
|
||||
// Do not snapshot the keys once: a second target can become dirty while an earlier target's
|
||||
// PUT is in flight. Keep selecting live drafts until only revisions that already failed in
|
||||
// this drain remain. Remember the failed object (not just its key), so a newer edit for that
|
||||
// target that lands while another request is pending still gets one save attempt.
|
||||
const failedDrafts = new Map<string, ParamFilterDraft>();
|
||||
while (true) {
|
||||
const next = Array.from(paramDraftsRef.current.entries()).find(
|
||||
([key, draft]) => failedDrafts.get(key) !== draft
|
||||
);
|
||||
if (!next) break;
|
||||
const [key] = next;
|
||||
if (!(await saveDraftForTarget(key))) {
|
||||
const failedDraft = paramDraftsRef.current.get(key);
|
||||
if (failedDraft) failedDrafts.set(key, failedDraft);
|
||||
}
|
||||
}
|
||||
// The indicator tracks unsaved work across ALL targets: a successful write for the target
|
||||
// now on screen must not signal "saved" while another target's draft is still owed a write.
|
||||
if (mountedRef.current) setParamSaveFailed(paramDraftsRef.current.size > 0);
|
||||
} finally {
|
||||
setParamSaving(false);
|
||||
paramSavingRef.current = false;
|
||||
if (mountedRef.current) setParamSaving(false);
|
||||
}
|
||||
}, [paramDirty, blockText, allowText]);
|
||||
}, []);
|
||||
|
||||
// Persist pending param-filter drafts when the popover closes, unmounts, or is re-pointed at a
|
||||
// different provider/model (#8910).
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
return () => {
|
||||
void saveModelParamFilters();
|
||||
};
|
||||
}, [open, paramTargetKey, saveModelParamFilters]);
|
||||
|
||||
useEffect(() => {
|
||||
setValuePeekRowId(null);
|
||||
@@ -359,10 +576,7 @@ export default function ModelCompatPopover({
|
||||
<input
|
||||
type="text"
|
||||
value={blockText}
|
||||
onChange={(e) => {
|
||||
setBlockText(e.target.value);
|
||||
setParamDirty(true);
|
||||
}}
|
||||
onChange={(e) => editParamDraft("block", e.target.value)}
|
||||
onBlur={() => saveModelParamFilters()}
|
||||
placeholder={t("compatBlockedParamsPlaceholder")}
|
||||
disabled={disabled}
|
||||
@@ -371,16 +585,22 @@ export default function ModelCompatPopover({
|
||||
<p className="text-[10px] text-text-muted">
|
||||
{t("compatBlockedParamsHint") ?? "Blocked params (stripped from requests)"}
|
||||
{paramSaving && ` ● ${t("compatSaving")}`}
|
||||
{paramSaveFailed && !paramSaving && (
|
||||
<span
|
||||
role="alert"
|
||||
className="ml-1 font-medium text-red-600 dark:text-red-400"
|
||||
title={t("failedSaveConnectionRetry")}
|
||||
>
|
||||
● {t("failed")}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
value={allowText}
|
||||
onChange={(e) => {
|
||||
setAllowText(e.target.value);
|
||||
setParamDirty(true);
|
||||
}}
|
||||
onChange={(e) => editParamDraft("allow", e.target.value)}
|
||||
onBlur={() => saveModelParamFilters()}
|
||||
placeholder={t("compatAllowedParamsPlaceholder")}
|
||||
disabled={disabled}
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
// @vitest-environment jsdom
|
||||
// Regression coverage for the concurrency defects found while fixing #8910:
|
||||
// 1. an edit landing after the PUT payload snapshot but before the PUT resolves must still
|
||||
// be persisted (lost update);
|
||||
// 2. a save that failed must not be silently reverted on reopen, and the failure must be
|
||||
// visible in the panel.
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import ModelCompatPopover from "../ModelCompatPopover";
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
interface ParamFilterState {
|
||||
block: string[];
|
||||
allow: string[];
|
||||
models?: Record<string, { block: string[]; allow: string[] }>;
|
||||
autoLearn: boolean;
|
||||
}
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
function setInputValue(input: HTMLInputElement, value: string) {
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
|
||||
setter?.call(input, value);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
|
||||
async function flushEffects() {
|
||||
await act(async () => {
|
||||
for (let i = 0; i < 8; i += 1) await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
function blockInput() {
|
||||
return document.querySelector(
|
||||
'input[placeholder="compatBlockedParamsPlaceholder"]'
|
||||
) as HTMLInputElement | null;
|
||||
}
|
||||
|
||||
function renderPopover() {
|
||||
act(() => {
|
||||
root.render(
|
||||
<ModelCompatPopover
|
||||
t={(key) => key}
|
||||
providerId="openai"
|
||||
modelId="gpt-test"
|
||||
effectiveModelNormalize={() => false}
|
||||
effectiveModelPreserveDeveloper={() => true}
|
||||
getUpstreamHeadersRecord={() => ({})}
|
||||
onCompatPatch={vi.fn()}
|
||||
/>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function openPopover() {
|
||||
const trigger = container.querySelector("button") as HTMLButtonElement;
|
||||
await act(async () => trigger.click());
|
||||
await flushEffects();
|
||||
}
|
||||
|
||||
async function closePopoverByOutsideClick() {
|
||||
await act(async () => {
|
||||
document.body.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
|
||||
});
|
||||
await flushEffects();
|
||||
}
|
||||
|
||||
describe("ModelCompatPopover param-filter save concurrency (#8910)", () => {
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
document.body.innerHTML = "";
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("persists an edit typed after the payload snapshot but before the PUT resolves", async () => {
|
||||
let serverState: ParamFilterState = { block: [], allow: [], models: {}, autoLearn: false };
|
||||
let releasePut: (() => void) | null = null;
|
||||
let holdPut = false;
|
||||
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (init?.method === "PUT") {
|
||||
serverState = JSON.parse(String(init.body)) as ParamFilterState;
|
||||
if (holdPut) {
|
||||
await new Promise<void>((resolve) => {
|
||||
releasePut = resolve;
|
||||
});
|
||||
}
|
||||
return { ok: true, json: async () => ({ success: true }) } as Response;
|
||||
}
|
||||
return { ok: true, json: async () => structuredClone(serverState) } as Response;
|
||||
})
|
||||
);
|
||||
|
||||
renderPopover();
|
||||
await openPopover();
|
||||
|
||||
await act(async () => setInputValue(blockInput()!, "temperature"));
|
||||
holdPut = true;
|
||||
// Blur starts the save: GET resolves, payload is snapshotted, PUT is issued and held open.
|
||||
await act(async () => {
|
||||
blockInput()!.dispatchEvent(new FocusEvent("focusout", { bubbles: true }));
|
||||
});
|
||||
await flushEffects();
|
||||
expect(releasePut).not.toBeNull();
|
||||
|
||||
// The user keeps typing while that PUT is still in flight, then closes the popover.
|
||||
await act(async () => setInputValue(blockInput()!, "temperature, seed"));
|
||||
await closePopoverByOutsideClick();
|
||||
|
||||
holdPut = false;
|
||||
await act(async () => {
|
||||
releasePut?.();
|
||||
await Promise.resolve();
|
||||
});
|
||||
await flushEffects();
|
||||
|
||||
expect(serverState.models).toEqual({
|
||||
"gpt-test": { block: ["temperature", "seed"], allow: [] },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the draft and surfaces the failure when the save fails, instead of reverting on reopen", async () => {
|
||||
const serverState: ParamFilterState = { block: [], allow: [], models: {}, autoLearn: false };
|
||||
let putAttempts = 0;
|
||||
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (init?.method === "PUT") {
|
||||
putAttempts += 1;
|
||||
return { ok: false, status: 500, json: async () => ({}) } as Response;
|
||||
}
|
||||
return { ok: true, json: async () => structuredClone(serverState) } as Response;
|
||||
})
|
||||
);
|
||||
|
||||
renderPopover();
|
||||
await openPopover();
|
||||
|
||||
await act(async () => setInputValue(blockInput()!, "temperature"));
|
||||
await closePopoverByOutsideClick();
|
||||
expect(putAttempts).toBe(1);
|
||||
|
||||
// Reopening must NOT clobber the unsaved draft with server state (#8910 complaint class).
|
||||
await openPopover();
|
||||
expect(blockInput()!.value).toBe("temperature");
|
||||
// The failure is visible to the user rather than silently swallowed.
|
||||
expect(document.querySelector('[role="alert"]')?.textContent).toContain("failed");
|
||||
|
||||
// ...and the retained draft is actually retryable through the normal close path.
|
||||
await closePopoverByOutsideClick();
|
||||
expect(putAttempts).toBe(2);
|
||||
});
|
||||
|
||||
it("clears the failure indicator once a later save succeeds", async () => {
|
||||
let serverState: ParamFilterState = { block: [], allow: [], models: {}, autoLearn: false };
|
||||
let failNextPut = true;
|
||||
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (init?.method === "PUT") {
|
||||
if (failNextPut) return { ok: false, status: 500, json: async () => ({}) } as Response;
|
||||
serverState = JSON.parse(String(init.body)) as ParamFilterState;
|
||||
return { ok: true, json: async () => ({ success: true }) } as Response;
|
||||
}
|
||||
return { ok: true, json: async () => structuredClone(serverState) } as Response;
|
||||
})
|
||||
);
|
||||
|
||||
renderPopover();
|
||||
await openPopover();
|
||||
await act(async () => setInputValue(blockInput()!, "temperature"));
|
||||
await act(async () => {
|
||||
blockInput()!.dispatchEvent(new FocusEvent("focusout", { bubbles: true }));
|
||||
});
|
||||
await flushEffects();
|
||||
expect(document.querySelector('[role="alert"]')).not.toBeNull();
|
||||
|
||||
failNextPut = false;
|
||||
await act(async () => setInputValue(blockInput()!, "temperature, seed"));
|
||||
await act(async () => {
|
||||
blockInput()!.dispatchEvent(new FocusEvent("focusout", { bubbles: true }));
|
||||
});
|
||||
await flushEffects();
|
||||
|
||||
expect(document.querySelector('[role="alert"]')).toBeNull();
|
||||
expect(serverState.models).toEqual({
|
||||
"gpt-test": { block: ["temperature", "seed"], allow: [] },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
// @vitest-environment jsdom
|
||||
// Regression coverage for #8910: sibling model-row popovers for the same provider must serialize
|
||||
// their whole-document param-filter updates so one successful save cannot erase the other.
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import ModelCompatPopover from "../ModelCompatPopover";
|
||||
|
||||
vi.mock("next-intl", () => ({ useTranslations: () => (key: string) => key }));
|
||||
|
||||
type ParamFilterState = {
|
||||
block: string[];
|
||||
allow: string[];
|
||||
models?: Record<string, { block: string[]; allow: string[] }>;
|
||||
autoLearn: boolean;
|
||||
};
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
let releaseFirstPut: (() => void) | null;
|
||||
|
||||
function setInputValue(input: HTMLInputElement, value: string) {
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
|
||||
setter?.call(input, value);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
|
||||
async function flushEffects(rounds = 60) {
|
||||
await act(async () => {
|
||||
for (let i = 0; i < rounds; i += 1) await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
releaseFirstPut = null;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => {
|
||||
releaseFirstPut?.();
|
||||
await Promise.resolve();
|
||||
});
|
||||
act(() => root.unmount());
|
||||
document.body.innerHTML = "";
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("preserves both model updates when sibling popovers save the same provider concurrently", async () => {
|
||||
let server: ParamFilterState = { block: [], allow: [], models: {}, autoLearn: false };
|
||||
let putCount = 0;
|
||||
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (init?.method === "PUT") {
|
||||
putCount += 1;
|
||||
const body = JSON.parse(String(init.body)) as ParamFilterState;
|
||||
if (putCount === 1) {
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseFirstPut = resolve;
|
||||
});
|
||||
}
|
||||
server = { ...body, models: body.models ?? {} };
|
||||
return { ok: true, json: async () => ({ success: true }) } as Response;
|
||||
}
|
||||
return { ok: true, json: async () => structuredClone(server) } as Response;
|
||||
})
|
||||
);
|
||||
|
||||
const props = (modelId: string) => ({
|
||||
t: (key: string) => key,
|
||||
providerId: "openai",
|
||||
modelId,
|
||||
effectiveModelNormalize: () => false,
|
||||
effectiveModelPreserveDeveloper: () => true,
|
||||
getUpstreamHeadersRecord: () => ({}),
|
||||
onCompatPatch: vi.fn(),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<div>
|
||||
<div id="model-a">
|
||||
<ModelCompatPopover {...props("model-a")} />
|
||||
</div>
|
||||
<div id="model-b">
|
||||
<ModelCompatPopover {...props("model-b")} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const triggerA = container.querySelector("#model-a button") as HTMLButtonElement;
|
||||
const triggerB = container.querySelector("#model-b button") as HTMLButtonElement;
|
||||
const blockInput = () =>
|
||||
document.querySelector(
|
||||
'input[placeholder="compatBlockedParamsPlaceholder"]'
|
||||
) as HTMLInputElement;
|
||||
|
||||
await act(async () => triggerA.click());
|
||||
await flushEffects();
|
||||
await act(async () => setInputValue(blockInput(), "aaa"));
|
||||
await act(async () => {
|
||||
blockInput().dispatchEvent(new FocusEvent("focusout", { bubbles: true }));
|
||||
});
|
||||
await flushEffects();
|
||||
expect(releaseFirstPut).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
triggerB.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
|
||||
});
|
||||
await flushEffects();
|
||||
await act(async () => triggerB.click());
|
||||
await flushEffects();
|
||||
await act(async () => setInputValue(blockInput(), "bbb"));
|
||||
await act(async () => {
|
||||
blockInput().dispatchEvent(new FocusEvent("focusout", { bubbles: true }));
|
||||
});
|
||||
await flushEffects();
|
||||
|
||||
await act(async () => {
|
||||
releaseFirstPut?.();
|
||||
await Promise.resolve();
|
||||
});
|
||||
await flushEffects();
|
||||
|
||||
expect(server.models).toEqual({
|
||||
"model-a": { block: ["aaa"], allow: [] },
|
||||
"model-b": { block: ["bbb"], allow: [] },
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
// @vitest-environment jsdom
|
||||
// Regression coverage for the cross-target write defect found while fixing #8910.
|
||||
//
|
||||
// ModelCompatPopover instances are not always keyed by a stable identity (CompatibleModelsSection
|
||||
// keys by `${alias}:${modelId}`, PassthroughModelsSection by the full model string, and providerId
|
||||
// is threaded from route/page state), so a re-render can re-point a LIVE, mounted popover at a
|
||||
// different provider/model. When the draft for the old target failed to save, the save callback —
|
||||
// now bound to the new target — used to PUT the old draft into the new target's config,
|
||||
// destructively overwriting a model/provider the user never edited.
|
||||
//
|
||||
// Contract asserted here: a write always lands on the provider/model the draft was typed for, and
|
||||
// an orphaned draft whose target is no longer displayed is preserved (retried later) rather than
|
||||
// silently dropped or redirected.
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import ModelCompatPopover from "../ModelCompatPopover";
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
interface ParamFilterState {
|
||||
block: string[];
|
||||
allow: string[];
|
||||
models: Record<string, { block: string[]; allow: string[] }>;
|
||||
autoLearn: boolean;
|
||||
}
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
function setInputValue(input: HTMLInputElement, value: string) {
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
|
||||
setter?.call(input, value);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
|
||||
async function flushEffects(rounds = 40) {
|
||||
await act(async () => {
|
||||
for (let i = 0; i < rounds; i += 1) await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
function blockInput() {
|
||||
return document.querySelector(
|
||||
'input[placeholder="compatBlockedParamsPlaceholder"]'
|
||||
) as HTMLInputElement | null;
|
||||
}
|
||||
|
||||
function renderPopover(props: { providerId: string; modelId: string }) {
|
||||
act(() => {
|
||||
root.render(
|
||||
<ModelCompatPopover
|
||||
t={(key) => key}
|
||||
providerId={props.providerId}
|
||||
modelId={props.modelId}
|
||||
effectiveModelNormalize={() => false}
|
||||
effectiveModelPreserveDeveloper={() => true}
|
||||
getUpstreamHeadersRecord={() => ({})}
|
||||
onCompatPatch={vi.fn()}
|
||||
/>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function openPopover() {
|
||||
const trigger = container.querySelector("button") as HTMLButtonElement;
|
||||
await act(async () => trigger.click());
|
||||
await flushEffects();
|
||||
}
|
||||
|
||||
async function closePopoverByOutsideClick() {
|
||||
await act(async () => {
|
||||
document.body.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
|
||||
});
|
||||
await flushEffects();
|
||||
}
|
||||
|
||||
describe("ModelCompatPopover param-filter cross-target writes (#8910)", () => {
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
try {
|
||||
act(() => root.unmount());
|
||||
} catch {
|
||||
// already unmounted by the test
|
||||
}
|
||||
document.body.innerHTML = "";
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("never writes a draft typed for one model under a different model", async () => {
|
||||
const server: ParamFilterState = {
|
||||
block: [],
|
||||
allow: [],
|
||||
models: { "model-b": { block: ["bval"], allow: [] } },
|
||||
autoLearn: false,
|
||||
};
|
||||
const puts: { url: string; models?: Record<string, unknown> }[] = [];
|
||||
let getFails = true;
|
||||
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
if (init?.method === "PUT") {
|
||||
const body = JSON.parse(String(init.body));
|
||||
puts.push({ url, models: body.models });
|
||||
server.models = body.models ?? {};
|
||||
return { ok: true, json: async () => ({ success: true }) } as Response;
|
||||
}
|
||||
if (getFails) return { ok: false, status: 503, json: async () => ({}) } as Response;
|
||||
return { ok: true, json: async () => structuredClone(server) } as Response;
|
||||
})
|
||||
);
|
||||
|
||||
renderPopover({ providerId: "openai", modelId: "model-a" });
|
||||
await openPopover();
|
||||
// The load failed, so the fields are empty; the user types a draft for model-a.
|
||||
await act(async () => setInputValue(blockInput()!, "aaa"));
|
||||
|
||||
// A re-render re-points this live popover at model-b while model-a's draft is still dirty.
|
||||
// The close-time save for model-a runs here and fails (its GET is still 503).
|
||||
renderPopover({ providerId: "openai", modelId: "model-b" });
|
||||
await flushEffects();
|
||||
|
||||
// The network recovers and the popover closes: the retried save must target model-a.
|
||||
getFails = false;
|
||||
await closePopoverByOutsideClick();
|
||||
|
||||
// model-b's real server config is untouched...
|
||||
expect(server.models["model-b"]).toEqual({ block: ["bval"], allow: [] });
|
||||
// ...and the orphaned model-a draft is not silently dropped either — it lands on model-a.
|
||||
expect(server.models["model-a"]).toEqual({ block: ["aaa"], allow: [] });
|
||||
expect(puts.every((p) => p.url.includes("/openai/"))).toBe(true);
|
||||
});
|
||||
|
||||
it("never writes a draft typed for one provider under a different provider", async () => {
|
||||
const byProvider: Record<string, ParamFilterState> = {
|
||||
alpha: { block: [], allow: [], models: {}, autoLearn: false },
|
||||
beta: {
|
||||
block: [],
|
||||
allow: [],
|
||||
models: { "gpt-test": { block: ["betaval"], allow: [] } },
|
||||
autoLearn: false,
|
||||
},
|
||||
};
|
||||
const puts: { providerId: string; models?: Record<string, unknown> }[] = [];
|
||||
let getFails = true;
|
||||
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
const providerId = url.match(/providers\/([^/]+)\//)![1];
|
||||
if (init?.method === "PUT") {
|
||||
const body = JSON.parse(String(init.body));
|
||||
puts.push({ providerId, models: body.models });
|
||||
byProvider[providerId].models = body.models ?? {};
|
||||
return { ok: true, json: async () => ({ success: true }) } as Response;
|
||||
}
|
||||
if (getFails) return { ok: false, status: 503, json: async () => ({}) } as Response;
|
||||
return { ok: true, json: async () => structuredClone(byProvider[providerId]) } as Response;
|
||||
})
|
||||
);
|
||||
|
||||
renderPopover({ providerId: "alpha", modelId: "gpt-test" });
|
||||
await openPopover();
|
||||
await act(async () => setInputValue(blockInput()!, "alpha-only"));
|
||||
|
||||
// Re-point the live popover at provider beta while alpha's draft is dirty and its save fails.
|
||||
renderPopover({ providerId: "beta", modelId: "gpt-test" });
|
||||
await flushEffects();
|
||||
|
||||
getFails = false;
|
||||
await closePopoverByOutsideClick();
|
||||
|
||||
// beta must receive no write at all; its stored config survives intact.
|
||||
expect(puts.filter((p) => p.providerId === "beta")).toEqual([]);
|
||||
expect(byProvider.beta.models).toEqual({ "gpt-test": { block: ["betaval"], allow: [] } });
|
||||
// The alpha draft is preserved and eventually persisted under alpha.
|
||||
expect(byProvider.alpha.models).toEqual({ "gpt-test": { block: ["alpha-only"], allow: [] } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
// @vitest-environment jsdom
|
||||
// Regression coverage for the load-effect clobber defects found while fixing #8910:
|
||||
// 1. a draft typed while the initial load GET is still in flight must survive the load
|
||||
// result and still be persisted on close (otherwise keystrokes vanish silently);
|
||||
// 2. after a failed initial load, the retained draft must not be destroyed by the next
|
||||
// successful reopen load — that reopen is the user's retry.
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import ModelCompatPopover from "../ModelCompatPopover";
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
interface ParamFilterState {
|
||||
block: string[];
|
||||
allow: string[];
|
||||
models?: Record<string, { block: string[]; allow: string[] }>;
|
||||
autoLearn: boolean;
|
||||
}
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
function setInputValue(input: HTMLInputElement, value: string) {
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
|
||||
setter?.call(input, value);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
|
||||
async function flushEffects(rounds = 12) {
|
||||
await act(async () => {
|
||||
for (let i = 0; i < rounds; i += 1) await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
function blockInput() {
|
||||
return document.querySelector(
|
||||
'input[placeholder="compatBlockedParamsPlaceholder"]'
|
||||
) as HTMLInputElement | null;
|
||||
}
|
||||
|
||||
function renderPopover() {
|
||||
act(() => {
|
||||
root.render(
|
||||
<ModelCompatPopover
|
||||
t={(key) => key}
|
||||
providerId="openai"
|
||||
modelId="gpt-test"
|
||||
effectiveModelNormalize={() => false}
|
||||
effectiveModelPreserveDeveloper={() => true}
|
||||
getUpstreamHeadersRecord={() => ({})}
|
||||
onCompatPatch={vi.fn()}
|
||||
/>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function openPopover() {
|
||||
const trigger = container.querySelector("button") as HTMLButtonElement;
|
||||
await act(async () => trigger.click());
|
||||
await flushEffects();
|
||||
}
|
||||
|
||||
async function closePopoverByOutsideClick() {
|
||||
await act(async () => {
|
||||
document.body.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
|
||||
});
|
||||
await flushEffects();
|
||||
}
|
||||
|
||||
describe("ModelCompatPopover param-filter load clobber (#8910)", () => {
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
try {
|
||||
act(() => root.unmount());
|
||||
} catch {
|
||||
// already unmounted by the test
|
||||
}
|
||||
document.body.innerHTML = "";
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("keeps and saves a draft typed while the initial load GET is still in flight", async () => {
|
||||
const serverState: ParamFilterState = {
|
||||
block: [],
|
||||
allow: [],
|
||||
models: { "gpt-test": { block: ["old"], allow: [] } },
|
||||
autoLearn: false,
|
||||
};
|
||||
const puts: ParamFilterState[] = [];
|
||||
let releaseGet: (() => void) | null = null;
|
||||
let heldFirstGet = false;
|
||||
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (init?.method === "PUT") {
|
||||
puts.push(JSON.parse(String(init.body)) as ParamFilterState);
|
||||
return { ok: true, json: async () => ({ success: true }) } as Response;
|
||||
}
|
||||
if (!heldFirstGet) {
|
||||
heldFirstGet = true;
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseGet = resolve;
|
||||
});
|
||||
}
|
||||
return { ok: true, json: async () => structuredClone(serverState) } as Response;
|
||||
})
|
||||
);
|
||||
|
||||
renderPopover();
|
||||
const trigger = container.querySelector("button") as HTMLButtonElement;
|
||||
await act(async () => trigger.click());
|
||||
await flushEffects();
|
||||
|
||||
// The field is already rendered while the load GET is still pending — the user types into it.
|
||||
await act(async () => setInputValue(blockInput()!, "temperature"));
|
||||
await act(async () => {
|
||||
releaseGet?.();
|
||||
await Promise.resolve();
|
||||
});
|
||||
await flushEffects();
|
||||
|
||||
// The load result must not overwrite the dirty draft.
|
||||
expect(blockInput()!.value).toBe("temperature");
|
||||
|
||||
await closePopoverByOutsideClick();
|
||||
|
||||
expect(puts.length).toBe(1);
|
||||
expect(puts[0]?.models).toEqual({ "gpt-test": { block: ["temperature"], allow: [] } });
|
||||
});
|
||||
|
||||
it("does not clobber a retained draft with the successful reopen load after a failed initial load", async () => {
|
||||
const serverState: ParamFilterState = {
|
||||
block: [],
|
||||
allow: [],
|
||||
models: { "gpt-test": { block: ["serverval"], allow: [] } },
|
||||
autoLearn: false,
|
||||
};
|
||||
const puts: ParamFilterState[] = [];
|
||||
let failGet = true;
|
||||
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (init?.method === "PUT") {
|
||||
puts.push(JSON.parse(String(init.body)) as ParamFilterState);
|
||||
return { ok: true, json: async () => ({ success: true }) } as Response;
|
||||
}
|
||||
if (failGet) return { ok: false, status: 503, json: async () => ({}) } as Response;
|
||||
return { ok: true, json: async () => structuredClone(serverState) } as Response;
|
||||
})
|
||||
);
|
||||
|
||||
renderPopover();
|
||||
// First open: the load GET fails, so nothing is loaded and the field stays empty.
|
||||
await openPopover();
|
||||
expect(blockInput()!.value).toBe("");
|
||||
|
||||
await act(async () => setInputValue(blockInput()!, "temperature"));
|
||||
// Close: the save's own GET still fails, so the draft is retained and flagged as failed.
|
||||
await closePopoverByOutsideClick();
|
||||
expect(puts.length).toBe(0);
|
||||
|
||||
// The network recovers and the user reopens the popover to retry the save.
|
||||
failGet = false;
|
||||
await openPopover();
|
||||
expect(blockInput()!.value).toBe("temperature");
|
||||
// The unsaved-state indicator must still be visible — nothing was persisted.
|
||||
expect(document.querySelector('[role="alert"]')).not.toBeNull();
|
||||
|
||||
await closePopoverByOutsideClick();
|
||||
expect(puts.length).toBe(1);
|
||||
expect(puts[0]?.models).toEqual({ "gpt-test": { block: ["temperature"], allow: [] } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
// @vitest-environment jsdom
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { expect, it, vi } from "vitest";
|
||||
import ModelCompatPopover from "../ModelCompatPopover";
|
||||
|
||||
vi.mock("next-intl", () => ({ useTranslations: () => (key: string) => key }));
|
||||
|
||||
function setInputValue(input: HTMLInputElement, value: string) {
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
|
||||
setter?.call(input, value);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
|
||||
async function flushEffects(rounds = 40) {
|
||||
await act(async () => {
|
||||
for (let i = 0; i < rounds; i += 1) await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
it("O5 preserves a new target edited while the older target save is in flight", async () => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
let server = {
|
||||
block: [] as string[],
|
||||
allow: [] as string[],
|
||||
models: {} as Record<string, { block: string[]; allow: string[] }>,
|
||||
autoLearn: false,
|
||||
};
|
||||
let releaseFirstPut: (() => void) | null = null;
|
||||
let putCount = 0;
|
||||
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (init?.method === "PUT") {
|
||||
putCount += 1;
|
||||
const body = JSON.parse(String(init.body)) as typeof server;
|
||||
if (putCount === 1) {
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseFirstPut = resolve;
|
||||
});
|
||||
}
|
||||
server = body;
|
||||
return { ok: true, json: async () => ({ success: true }) } as Response;
|
||||
}
|
||||
return { ok: true, json: async () => structuredClone(server) } as Response;
|
||||
})
|
||||
);
|
||||
|
||||
const renderFor = (modelId: string) => {
|
||||
act(() => {
|
||||
root.render(
|
||||
<ModelCompatPopover
|
||||
t={(key) => key}
|
||||
providerId="openai"
|
||||
modelId={modelId}
|
||||
effectiveModelNormalize={() => false}
|
||||
effectiveModelPreserveDeveloper={() => true}
|
||||
getUpstreamHeadersRecord={() => ({})}
|
||||
onCompatPatch={vi.fn()}
|
||||
/>
|
||||
);
|
||||
});
|
||||
};
|
||||
const blockInput = () =>
|
||||
document.querySelector(
|
||||
'input[placeholder="compatBlockedParamsPlaceholder"]'
|
||||
) as HTMLInputElement;
|
||||
const blurBlock = async () => {
|
||||
await act(async () => {
|
||||
blockInput().dispatchEvent(new FocusEvent("focusout", { bubbles: true }));
|
||||
});
|
||||
await flushEffects();
|
||||
};
|
||||
|
||||
renderFor("model-a");
|
||||
await act(async () => (container.querySelector("button") as HTMLButtonElement).click());
|
||||
await flushEffects();
|
||||
await act(async () => setInputValue(blockInput(), "aaa"));
|
||||
await blurBlock();
|
||||
expect(releaseFirstPut).not.toBeNull();
|
||||
|
||||
renderFor("model-b");
|
||||
await flushEffects();
|
||||
expect(blockInput().value).toBe("");
|
||||
await act(async () => setInputValue(blockInput(), "bbb"));
|
||||
await blurBlock();
|
||||
|
||||
await act(async () => {
|
||||
releaseFirstPut?.();
|
||||
await Promise.resolve();
|
||||
});
|
||||
await flushEffects();
|
||||
|
||||
expect(server.models).toEqual({
|
||||
"model-a": { block: ["aaa"], allow: [] },
|
||||
"model-b": { block: ["bbb"], allow: [] },
|
||||
});
|
||||
expect(document.querySelector('[role="alert"]')).toBeNull();
|
||||
|
||||
act(() => root.unmount());
|
||||
document.body.innerHTML = "";
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
// @vitest-environment jsdom
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { expect, it, vi } from "vitest";
|
||||
import ModelCompatPopover from "../ModelCompatPopover";
|
||||
|
||||
vi.mock("next-intl", () => ({ useTranslations: () => (key: string) => key }));
|
||||
|
||||
function setInputValue(input: HTMLInputElement, value: string) {
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
|
||||
setter?.call(input, value);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
|
||||
async function flushEffects(rounds = 40) {
|
||||
await act(async () => {
|
||||
for (let i = 0; i < rounds; i += 1) await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
it("O5 does not lose the new target when unmounted during the older target save", async () => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
let server = {
|
||||
block: [] as string[],
|
||||
allow: [] as string[],
|
||||
models: {} as Record<string, { block: string[]; allow: string[] }>,
|
||||
autoLearn: false,
|
||||
};
|
||||
let releaseFirstPut: (() => void) | null = null;
|
||||
let putCount = 0;
|
||||
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (init?.method === "PUT") {
|
||||
putCount += 1;
|
||||
const body = JSON.parse(String(init.body)) as typeof server;
|
||||
if (putCount === 1) {
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseFirstPut = resolve;
|
||||
});
|
||||
}
|
||||
server = body;
|
||||
return { ok: true, json: async () => ({ success: true }) } as Response;
|
||||
}
|
||||
return { ok: true, json: async () => structuredClone(server) } as Response;
|
||||
})
|
||||
);
|
||||
|
||||
const renderFor = (modelId: string) => {
|
||||
act(() => {
|
||||
root.render(
|
||||
<ModelCompatPopover
|
||||
t={(key) => key}
|
||||
providerId="openai"
|
||||
modelId={modelId}
|
||||
effectiveModelNormalize={() => false}
|
||||
effectiveModelPreserveDeveloper={() => true}
|
||||
getUpstreamHeadersRecord={() => ({})}
|
||||
onCompatPatch={vi.fn()}
|
||||
/>
|
||||
);
|
||||
});
|
||||
};
|
||||
const blockInput = () =>
|
||||
document.querySelector(
|
||||
'input[placeholder="compatBlockedParamsPlaceholder"]'
|
||||
) as HTMLInputElement;
|
||||
const blurBlock = async () => {
|
||||
await act(async () => {
|
||||
blockInput().dispatchEvent(new FocusEvent("focusout", { bubbles: true }));
|
||||
});
|
||||
await flushEffects();
|
||||
};
|
||||
|
||||
renderFor("model-a");
|
||||
await act(async () => (container.querySelector("button") as HTMLButtonElement).click());
|
||||
await flushEffects();
|
||||
await act(async () => setInputValue(blockInput(), "aaa"));
|
||||
await blurBlock();
|
||||
expect(releaseFirstPut).not.toBeNull();
|
||||
|
||||
renderFor("model-b");
|
||||
await flushEffects();
|
||||
await act(async () => setInputValue(blockInput(), "bbb"));
|
||||
await blurBlock();
|
||||
|
||||
// Every close/unmount save is rejected while model-a owns paramSavingRef. The active save took
|
||||
// its key snapshot before model-b existed, so model-b has no later save scheduled.
|
||||
act(() => root.unmount());
|
||||
await act(async () => {
|
||||
releaseFirstPut?.();
|
||||
await Promise.resolve();
|
||||
});
|
||||
await flushEffects();
|
||||
console.log("O5 server after unmount:", JSON.stringify(server.models), "PUTs:", putCount);
|
||||
|
||||
expect(server.models).toEqual({
|
||||
"model-a": { block: ["aaa"], allow: [] },
|
||||
"model-b": { block: ["bbb"], allow: [] },
|
||||
});
|
||||
|
||||
document.body.innerHTML = "";
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
@@ -0,0 +1,261 @@
|
||||
// @vitest-environment jsdom
|
||||
// Regression coverage for the two target-re-point defects found while fixing #8910.
|
||||
//
|
||||
// A ModelCompatPopover instance is not always keyed by a stable identity, so a re-render can
|
||||
// re-point a LIVE, still-open popover at a different provider/model while a draft for the previous
|
||||
// target is unsaved. Two failures followed from that:
|
||||
//
|
||||
// 1. (C10) The inputs are driven by blockText/allowText, which used to be written only by the
|
||||
// load effect — and that effect early-returned whenever a draft was dirty. Re-pointing
|
||||
// A -> B -> A therefore left B's server values on screen under A, and the next keystroke
|
||||
// snapshotted them into A's draft, persisting B's content into A's entry.
|
||||
// 2. (C11) The pending draft lived in a single slot that every edit overwrote, so typing into
|
||||
// the new target destroyed the previous target's unsaved work, and the new target's
|
||||
// successful save cleared the failure indicator — a green UI over data never written.
|
||||
//
|
||||
// Contract asserted here: the fields always show the displayed target's own content (its pending
|
||||
// draft when it has one), never another target's; and every target's unsaved draft survives until
|
||||
// it is actually persisted to its own provider/model.
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import ModelCompatPopover from "../ModelCompatPopover";
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
interface ParamFilterState {
|
||||
block: string[];
|
||||
allow: string[];
|
||||
models: Record<string, { block: string[]; allow: string[] }>;
|
||||
autoLearn: boolean;
|
||||
}
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
function setInputValue(input: HTMLInputElement, value: string) {
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
|
||||
setter?.call(input, value);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
|
||||
async function flushEffects(rounds = 40) {
|
||||
await act(async () => {
|
||||
for (let i = 0; i < rounds; i += 1) await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
function blockInput() {
|
||||
return document.querySelector(
|
||||
'input[placeholder="compatBlockedParamsPlaceholder"]'
|
||||
) as HTMLInputElement | null;
|
||||
}
|
||||
|
||||
function renderPopover(modelId: string) {
|
||||
act(() => {
|
||||
root.render(
|
||||
<ModelCompatPopover
|
||||
t={(key) => key}
|
||||
providerId="openai"
|
||||
modelId={modelId}
|
||||
effectiveModelNormalize={() => false}
|
||||
effectiveModelPreserveDeveloper={() => true}
|
||||
getUpstreamHeadersRecord={() => ({})}
|
||||
onCompatPatch={vi.fn()}
|
||||
/>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// React 19 delegates onBlur through focusout — a bare "blur" event does not reach the handler.
|
||||
async function blurBlockInput() {
|
||||
await act(async () => {
|
||||
blockInput()!.dispatchEvent(new FocusEvent("focusout", { bubbles: true }));
|
||||
});
|
||||
await flushEffects();
|
||||
}
|
||||
|
||||
describe("ModelCompatPopover param-filter target re-point (#8910)", () => {
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
try {
|
||||
act(() => root.unmount());
|
||||
} catch {
|
||||
// already unmounted by the test
|
||||
}
|
||||
document.body.innerHTML = "";
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("restores the dirty draft into the fields on return, instead of showing the other model's values", async () => {
|
||||
const server: ParamFilterState = {
|
||||
block: [],
|
||||
allow: [],
|
||||
models: { "model-b": { block: ["secret-b"], allow: [] } },
|
||||
autoLearn: false,
|
||||
};
|
||||
let putFails = true;
|
||||
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (init?.method === "PUT") {
|
||||
if (putFails) return { ok: false, status: 500, json: async () => ({}) } as Response;
|
||||
const body = JSON.parse(String(init.body));
|
||||
server.models = body.models ?? {};
|
||||
return { ok: true, json: async () => ({ success: true }) } as Response;
|
||||
}
|
||||
return { ok: true, json: async () => structuredClone(server) } as Response;
|
||||
})
|
||||
);
|
||||
|
||||
renderPopover("model-a");
|
||||
const trigger = container.querySelector("button") as HTMLButtonElement;
|
||||
await act(async () => trigger.click());
|
||||
await flushEffects();
|
||||
|
||||
// model-a has no server entry, so the field loads empty; the user types a draft whose save fails.
|
||||
await act(async () => setInputValue(blockInput()!, "aaa"));
|
||||
await blurBlockInput();
|
||||
expect(blockInput()!.value).toBe("aaa");
|
||||
|
||||
// The live popover is re-pointed at model-b, which does have a server value...
|
||||
renderPopover("model-b");
|
||||
await flushEffects();
|
||||
expect(blockInput()!.value).toBe("secret-b");
|
||||
|
||||
// ...and back to model-a, whose draft is still unsaved: the field must show model-a's draft.
|
||||
renderPopover("model-a");
|
||||
await flushEffects();
|
||||
expect(blockInput()!.value).toBe("aaa");
|
||||
|
||||
// The user appends to what they can see and closes; the write must stay inside model-a.
|
||||
putFails = false;
|
||||
await act(async () => setInputValue(blockInput()!, `${blockInput()!.value}, extra`));
|
||||
await act(async () => {
|
||||
document.body.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
|
||||
});
|
||||
await flushEffects();
|
||||
|
||||
expect(server.models["model-a"]).toEqual({ block: ["aaa", "extra"], allow: [] });
|
||||
expect(JSON.stringify(server.models["model-a"])).not.toContain("secret-b");
|
||||
expect(server.models["model-b"]).toEqual({ block: ["secret-b"], allow: [] });
|
||||
});
|
||||
|
||||
it("keeps one model's unsaved draft alive while the user edits another model", async () => {
|
||||
const server: ParamFilterState = { block: [], allow: [], models: {}, autoLearn: false };
|
||||
let putFails = true;
|
||||
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (init?.method === "PUT") {
|
||||
if (putFails) return { ok: false, status: 500, json: async () => ({}) } as Response;
|
||||
const body = JSON.parse(String(init.body));
|
||||
server.models = body.models ?? {};
|
||||
return { ok: true, json: async () => ({ success: true }) } as Response;
|
||||
}
|
||||
return { ok: true, json: async () => structuredClone(server) } as Response;
|
||||
})
|
||||
);
|
||||
|
||||
renderPopover("model-a");
|
||||
const trigger = container.querySelector("button") as HTMLButtonElement;
|
||||
await act(async () => trigger.click());
|
||||
await flushEffects();
|
||||
|
||||
await act(async () => setInputValue(blockInput()!, "aaa"));
|
||||
await blurBlockInput();
|
||||
expect(document.querySelector('[role="alert"]')?.textContent).toContain("failed");
|
||||
|
||||
// Re-pointed at model-b; the network recovers and the user edits model-b.
|
||||
renderPopover("model-b");
|
||||
await flushEffects();
|
||||
putFails = false;
|
||||
await act(async () => setInputValue(blockInput()!, "bbb"));
|
||||
await blurBlockInput();
|
||||
|
||||
// model-a's draft must not have been destroyed by the model-b edit: both are persisted...
|
||||
expect(server.models["model-a"]).toEqual({ block: ["aaa"], allow: [] });
|
||||
expect(server.models["model-b"]).toEqual({ block: ["bbb"], allow: [] });
|
||||
// ...and with nothing left unsaved anywhere the failure indicator is finally cleared.
|
||||
expect(document.querySelector('[role="alert"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("does not report success while another target's draft is still unsaved", async () => {
|
||||
const byProvider: Record<string, ParamFilterState> = {
|
||||
alpha: { block: [], allow: [], models: {}, autoLearn: false },
|
||||
beta: { block: [], allow: [], models: {}, autoLearn: false },
|
||||
};
|
||||
let failing = new Set(["alpha"]);
|
||||
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
const providerId = url.match(/providers\/([^/]+)\//)![1];
|
||||
if (init?.method === "PUT") {
|
||||
if (failing.has(providerId)) {
|
||||
return { ok: false, status: 503, json: async () => ({}) } as Response;
|
||||
}
|
||||
const body = JSON.parse(String(init.body));
|
||||
byProvider[providerId].models = body.models ?? {};
|
||||
return { ok: true, json: async () => ({ success: true }) } as Response;
|
||||
}
|
||||
return { ok: true, json: async () => structuredClone(byProvider[providerId]) } as Response;
|
||||
})
|
||||
);
|
||||
|
||||
const renderFor = (providerId: string) => {
|
||||
act(() => {
|
||||
root.render(
|
||||
<ModelCompatPopover
|
||||
t={(key) => key}
|
||||
providerId={providerId}
|
||||
modelId="gpt-test"
|
||||
effectiveModelNormalize={() => false}
|
||||
effectiveModelPreserveDeveloper={() => true}
|
||||
getUpstreamHeadersRecord={() => ({})}
|
||||
onCompatPatch={vi.fn()}
|
||||
/>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
renderFor("alpha");
|
||||
const trigger = container.querySelector("button") as HTMLButtonElement;
|
||||
await act(async () => trigger.click());
|
||||
await flushEffects();
|
||||
await act(async () => setInputValue(blockInput()!, "alpha-draft"));
|
||||
await blurBlockInput();
|
||||
expect(document.querySelector('[role="alert"]')?.textContent).toContain("failed");
|
||||
|
||||
// beta saves fine, but alpha is still broken: the indicator must stay up.
|
||||
renderFor("beta");
|
||||
await flushEffects();
|
||||
await act(async () => setInputValue(blockInput()!, "beta-draft"));
|
||||
await blurBlockInput();
|
||||
|
||||
expect(byProvider.beta.models).toEqual({ "gpt-test": { block: ["beta-draft"], allow: [] } });
|
||||
expect(byProvider.alpha.models).toEqual({});
|
||||
expect(document.querySelector('[role="alert"]')?.textContent).toContain("failed");
|
||||
|
||||
// Once alpha recovers, the preserved draft is written to alpha and the indicator clears.
|
||||
failing = new Set();
|
||||
await act(async () => {
|
||||
document.body.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
|
||||
});
|
||||
await flushEffects();
|
||||
expect(byProvider.alpha.models).toEqual({ "gpt-test": { block: ["alpha-draft"], allow: [] } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
// @vitest-environment jsdom
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import ModelCompatPopover from "../ModelCompatPopover";
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
interface ParamFilterState {
|
||||
block: string[];
|
||||
allow: string[];
|
||||
models: Record<string, { block: string[]; allow: string[] }>;
|
||||
autoLearn: boolean;
|
||||
}
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
function setInputValue(input: HTMLInputElement, value: string) {
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
|
||||
setter?.call(input, value);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
|
||||
async function flushEffects() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
async function openPopover() {
|
||||
const trigger = container.querySelector("button") as HTMLButtonElement;
|
||||
await act(async () => trigger.click());
|
||||
await flushEffects();
|
||||
}
|
||||
|
||||
describe("ModelCompatPopover model param filters (#8910)", () => {
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
document.body.innerHTML = "";
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("persists the latest block and allow drafts when an outside mousedown closes the popover", async () => {
|
||||
const apiPath = "/api/providers/openai/param-filters";
|
||||
let serverState: ParamFilterState = {
|
||||
block: ["provider-block"],
|
||||
allow: ["provider-allow"],
|
||||
models: {
|
||||
"other-model": { block: ["keep-block"], allow: ["keep-allow"] },
|
||||
},
|
||||
autoLearn: true,
|
||||
};
|
||||
const putBodies: unknown[] = [];
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (String(input) !== apiPath) throw new Error(`Unexpected param-filter URL: ${input}`);
|
||||
if (init?.method === "PUT") {
|
||||
const body = JSON.parse(String(init.body)) as ParamFilterState;
|
||||
putBodies.push(body);
|
||||
serverState = body;
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => structuredClone(serverState),
|
||||
} as Response;
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<ModelCompatPopover
|
||||
t={(key) => key}
|
||||
providerId="openai"
|
||||
modelId="gpt-test"
|
||||
effectiveModelNormalize={() => false}
|
||||
effectiveModelPreserveDeveloper={() => true}
|
||||
getUpstreamHeadersRecord={() => ({})}
|
||||
onCompatPatch={vi.fn()}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
await openPopover();
|
||||
const blockInput = document.querySelector(
|
||||
'input[placeholder="compatBlockedParamsPlaceholder"]'
|
||||
) as HTMLInputElement;
|
||||
const allowInput = document.querySelector(
|
||||
'input[placeholder="compatAllowedParamsPlaceholder"]'
|
||||
) as HTMLInputElement;
|
||||
|
||||
await act(async () => {
|
||||
setInputValue(blockInput, "temperature, top_p");
|
||||
setInputValue(allowInput, "tools, response_format");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
document.body.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
|
||||
});
|
||||
await flushEffects();
|
||||
|
||||
expect(
|
||||
document.querySelector('input[placeholder="compatBlockedParamsPlaceholder"]')
|
||||
).toBeNull();
|
||||
expect(putBodies).toEqual([
|
||||
{
|
||||
block: ["provider-block"],
|
||||
allow: ["provider-allow"],
|
||||
models: {
|
||||
"other-model": { block: ["keep-block"], allow: ["keep-allow"] },
|
||||
"gpt-test": {
|
||||
block: ["temperature", "top_p"],
|
||||
allow: ["tools", "response_format"],
|
||||
},
|
||||
},
|
||||
autoLearn: true,
|
||||
},
|
||||
]);
|
||||
|
||||
await openPopover();
|
||||
expect(
|
||||
(
|
||||
document.querySelector(
|
||||
'input[placeholder="compatBlockedParamsPlaceholder"]'
|
||||
) as HTMLInputElement
|
||||
).value
|
||||
).toBe("temperature, top_p");
|
||||
expect(
|
||||
(
|
||||
document.querySelector(
|
||||
'input[placeholder="compatAllowedParamsPlaceholder"]'
|
||||
) as HTMLInputElement
|
||||
).value
|
||||
).toBe("tools, response_format");
|
||||
});
|
||||
});
|
||||
@@ -177,6 +177,8 @@ describe("phase-1d extractions (#3501)", () => {
|
||||
const c = renderComponent(
|
||||
<ModelCompatPopover
|
||||
t={(k: string) => k}
|
||||
providerId="openai"
|
||||
modelId="gpt-test"
|
||||
effectiveModelNormalize={() => false}
|
||||
effectiveModelPreserveDeveloper={() => true}
|
||||
getUpstreamHeadersRecord={() => ({})}
|
||||
@@ -190,6 +192,8 @@ describe("phase-1d extractions (#3501)", () => {
|
||||
const c = renderComponent(
|
||||
<ModelCompatPopover
|
||||
t={(k: string) => k}
|
||||
providerId="openai"
|
||||
modelId="gpt-test"
|
||||
effectiveModelNormalize={() => true}
|
||||
effectiveModelPreserveDeveloper={() => false}
|
||||
getUpstreamHeadersRecord={() => ({ "X-Custom": "value" })}
|
||||
|
||||
Reference in New Issue
Block a user