litellm/ui/litellm-dashboard/tests/setupTests.ts
yuneng-jiang 663e647bc8
refactor(ui): migrate antd Modal onto the shared shadcn Dialog (#37540)
* test(ui): cover the two modals no test would catch breaking

Both files sit in the antd Modal migration's blind spot. EditSSOSettingsModal's
test replaced antd wholesale with a stub Modal and asserted the stub's own
data-testid markup, so it proved nothing about the modal a user sees and would
have stayed green through any regression. routing_groups had no test at all.

Rewrite the first against the real antd Modal, querying by dialog role and
accessible name so the assertions hold under either library, and add an
integration test for the second that drives the row menu and the delete
confirmation end to end.

Modal width drops out of the SSO assertions: antd carries it as an inline style
and shadcn as a max-width class, so either form couples the test to the library
rather than to anything a user perceives.

* refactor(ui): move the straightforward antd Modals onto the shared Dialog

Twenty files whose Modal only used title, open, width, footer, className and
onCancel, so each one maps onto Dialog without judgement calls. Width becomes a
max-width class, the body gets the house scroll cap so tall content stays
reachable, and destroyOnHidden goes away because Base UI unmounts a closed
dialog on its own.

EditMembership needed a real fix rather than a translation. Clearing the form
after a submit resolved only ever worked by accident: the reset set every field
to undefined, which react-hook-form does not push out to a subscribed
Controller, and the fields looked cleared only because antd's Modal happened to
re-render the subtree afterwards. Dialog does not, so the stale values showed
through. emptyMemberFormValues now returns the empty value each control
actually understands, an empty string, null or an empty list, and the reset
lands whatever renders around it. Its test asserted the undefined shape while
describing the behaviour it was missing, so it now checks the values instead.

* refactor(ui): migrate the antd Modals that needed a judgement call

Sixteen more files. Most carried a prop that does not translate literally:
maskClosable becomes disablePointerDismissal, afterOpenChange becomes
onOpenChangeComplete, and closable={false} becomes showCloseButton={false}.

The styles prop went away everywhere it appeared. All but one instance set the
body to 24px and the header to 24px with no border, which is what DialogContent
already renders, so keeping it would have meant writing the default back by
hand.

Several Modals passed onOk alongside footer={null}, so antd rendered no OK
button and the handler could never fire. Each of those handlers was a
character-for-character copy of the neighbouring onCancel, so they are gone
rather than translated.

Rich titles now sit inside DialogHeader with DialogTitle carrying the heading
text, instead of the whole header block being nested inside DialogTitle. That
had put an h2 inside another h2, which is invalid and gave one dialog two
headings.

UserEnvVarsModal loses its formGeneration counter. Remounting the form when the
modal finished opening only mattered because antd kept a closed modal's
children mounted; Base UI unmounts them, so reopening is blank on its own. Its
test helper had encoded that remount as a timing assumption, so the file now
states the requirement outright and checks that reopening shows an empty field.

CreateMCPServer's cancel test read the tool list while the modal was closed,
which only worked because forceRender kept it mounted. It now asserts what a
user can actually observe: the panel is gone while closed, and reopening brings
back an empty URL and no tools.

Unmounting an open Base UI dialog leaves its scroll lock on <html> and <body>,
which survives cleanup() and makes every later test in the file see a locked
page where popups compute pointer-events: none and clicks quietly do nothing.
The shared setup now releases it.

* refactor(ui): finish the antd Modal migration onto the shared Dialog

Fifteen files whose Modal relied on antd's built-in footer. okText, cancelText,
onOk, okButtonProps, cancelButtonProps and confirmLoading collapse into two
explicit buttons in a DialogFooter, with danger becoming the destructive
variant and the various loading flags becoming disabled plus aria-busy. The one
okButtonProps that also hand-set a red background drops it, since the variant
already carries that.

add_guardrail_form keeps its own chrome, so its DialogContent turns off the
built-in close button and the padding, and its heading becomes the DialogTitle.
Under antd it passed title={null} and had no accessible name at all.

Modals that positioned themselves near the top of the viewport needed
translate-y-0 alongside top-8, because DialogContent centres itself with a
transform that top alone does not undo.

TeamGuardrailsTab's test reached its Mode select by index into every combobox on
the page. A modal dialog hides the rest of the page from assistive technology,
which antd never did, so the count changed and the index pointed at the wrong
control. It asks for the field by label now.

The mask-dismissal test drove antd's .ant-modal-wrap class directly; it uses the
overlay slot our own component exposes, and still fails if
disablePointerDismissal is dropped.

CreateUserButton stays on antd. Its Modal converts cleanly, but the colocated
test file then fails a varying handful of cases, and the cause sits in the test
file rather than the component, so it wants its own change.

Pruning suppressions for the touched files also cleared four
react-hooks/set-state-in-effect entries on CreateMCPServer that were already
stale before this branch.

* test(ui): pick Base UI select options through the shared helper

React 19's flush timing loses the race this test was relying on: the option
lands in the DOM one render before its positioner drops pointer-events: none,
so user-event refused the click. tests/test-utils already exports
chooseSelectOption for exactly this, added alongside the React 19 upgrade.
2026-08-19 23:16:42 +00:00

229 lines
7.5 KiB
TypeScript

import "@testing-library/jest-dom";
import { cleanup } from "@testing-library/react";
import { afterEach, vi } from "vitest";
const ensureTestLocalStorage = () => {
if (typeof window === "undefined" || typeof window.Storage === "undefined") {
return;
}
if (typeof window.localStorage?.getItem === "function" && typeof window.localStorage?.clear === "function") {
return;
}
const storageStores = new WeakMap<Storage, Map<string, string>>();
const storagePrototype = window.Storage.prototype;
const getStore = (storage: Storage) => {
let store = storageStores.get(storage);
if (store === undefined) {
store = new Map<string, string>();
storageStores.set(storage, store);
}
return store;
};
Object.defineProperties(storagePrototype, {
getItem: {
configurable: true,
writable: true,
value(this: Storage, key: string) {
const store = getStore(this);
const normalizedKey = String(key);
return store.has(normalizedKey) ? store.get(normalizedKey)! : null;
},
},
setItem: {
configurable: true,
writable: true,
value(this: Storage, key: string, value: string) {
const store = getStore(this);
store.set(String(key), String(value));
},
},
removeItem: {
configurable: true,
writable: true,
value(this: Storage, key: string) {
const store = getStore(this);
store.delete(String(key));
},
},
clear: {
configurable: true,
writable: true,
value(this: Storage) {
const store = getStore(this);
store.clear();
},
},
key: {
configurable: true,
writable: true,
value(this: Storage, index: number) {
const store = getStore(this);
return Array.from(store.keys())[index] ?? null;
},
},
});
const localStorage = Object.create(storagePrototype);
storageStores.set(localStorage, new Map<string, string>());
Object.defineProperty(localStorage, "length", {
configurable: true,
get() {
return getStore(localStorage).size;
},
});
Object.defineProperty(window, "localStorage", {
configurable: true,
value: localStorage,
});
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: localStorage,
});
};
ensureTestLocalStorage();
// Global mock so every test can assert on toast calls; toast.test.ts opts back in with vi.unmock
vi.mock("@/lib/toast", () => ({
toast: {
success: vi.fn(),
info: vi.fn(),
warning: vi.fn(),
error: vi.fn(),
fromError: vi.fn(),
dismiss: vi.fn(),
},
}));
// Global mock for useAuthorized hook to avoid repeating the same mock in every test file
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
default: () => ({
token: "123",
accessToken: "123",
userId: "user-1",
userEmail: "user@example.com",
userRole: "Admin",
premiumUser: false,
disabledPersonalKeyCreation: null,
showSSOBanner: false,
}),
}));
// Unmounting a Base UI dialog that is still open leaves its scroll lock behind: the <html>
// and <body> inline styles and the marker attribute survive cleanup() and make every later
// test in the file see a locked page, where popups compute pointer-events: none and clicks
// silently do nothing. Real users never unmount an open dialog, so undo it here.
const releaseBaseUiScrollLock = () => {
const root = document.documentElement;
if (!root.hasAttribute("data-base-ui-scroll-locked")) return;
root.removeAttribute("data-base-ui-scroll-locked");
for (const property of ["scrollbar-gutter", "overflow-y", "overflow-x", "scroll-behavior"]) {
root.style.removeProperty(property);
}
for (const property of ["position", "height", "width", "box-sizing", "overflow", "scroll-behavior"]) {
document.body.style.removeProperty(property);
}
};
afterEach(() => {
cleanup();
releaseBaseUiScrollLock();
});
// Make toLocaleString deterministic in tests; individual tests can override
// This returns ISO-like strings to keep assertions stable.
vi.spyOn(Date.prototype, "toLocaleString").mockImplementation(function (this: Date, ..._args: unknown[]) {
const d = this;
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
});
if (typeof window !== "undefined") {
// Fixed matchMedia not found error in tests: https://github.com/vitest-dev/vitest/issues/821
Object.defineProperty(window, "matchMedia", {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}),
});
// Silence jsdom "getComputedStyle with pseudo-elements" not implemented warnings
// by ignoring the second argument and delegating to the native implementation.
const realGetComputedStyle = window.getComputedStyle.bind(window);
window.getComputedStyle = ((elt: Element) => realGetComputedStyle(elt)) as any;
// Avoid "navigation to another Document" warnings when clicking <a> with blob: URLs
// used by download flows in tests.
Object.defineProperty(HTMLAnchorElement.prototype, "click", {
configurable: true,
writable: true,
value: vi.fn(),
});
if (!document.getAnimations) {
document.getAnimations = () => [];
}
// Base UI's ScrollAreaViewport calls viewport.getAnimations() from a timer, which jsdom
// does not implement, so the TypeError surfaces as an unhandled error and fails the run.
// BASE_UI_ANIMATIONS_DISABLED keeps useAnimationsFinished on the synchronous path it
// already took while getAnimations was missing, so popup unmount timing is unchanged.
(globalThis as { BASE_UI_ANIMATIONS_DISABLED?: boolean }).BASE_UI_ANIMATIONS_DISABLED = true;
if (!Element.prototype.getAnimations) {
Element.prototype.getAnimations = () => [];
}
// Stub URL.revokeObjectURL so vi.spyOn can intercept it in tests
if (!URL.revokeObjectURL) {
URL.revokeObjectURL = () => {};
}
// Mock ResizeObserver for components that use it (recharts, Tremor UI components).
// JSDOM has no layout, so for observers inside a shadcn ChartContainer ([data-slot="chart"])
// the mock immediately reports a fixed 800x400 box; recharts renders nothing until it
// observes a size. Scoped to chart subtrees only: firing for every observer re-enters
// React mid-effect for headlessui consumers whose tests assume the old no-op
// (chart text would duplicate getByText targets, popover clicks go stale).
const MOCK_RESIZE_BOX = { inlineSize: 800, blockSize: 400 };
const MOCK_RESIZE_RECT: DOMRectReadOnly = {
width: 800,
height: 400,
top: 0,
left: 0,
bottom: 400,
right: 800,
x: 0,
y: 0,
toJSON: () => ({}),
};
global.ResizeObserver = class ResizeObserver {
private readonly callback: ResizeObserverCallback;
constructor(callback: ResizeObserverCallback) {
this.callback = callback;
}
observe(target: Element) {
if (!target.closest('[data-slot="chart"]')) return;
const entry: ResizeObserverEntry = {
target,
contentRect: MOCK_RESIZE_RECT,
borderBoxSize: [MOCK_RESIZE_BOX],
contentBoxSize: [MOCK_RESIZE_BOX],
devicePixelContentBoxSize: [MOCK_RESIZE_BOX],
};
this.callback([entry], this);
}
unobserve() {}
disconnect() {}
};
}