mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-04 07:27:51 +00:00
* test(ui): pin the Workflows page copy with characterization tests Locks the header, table, filters drawer, detail drawer, relative time and duration text on the Workflows page so the upcoming move onto i18next can be checked against it without editing any test * feat(ui): add i18next plumbing and move the Workflows page copy onto it Adds an English-only i18next instance with typed keys from src/i18n/locales/en.json, wraps the root layout in I18nProvider, and loads the instance in the test setup. Every string the Workflows page authors now renders through t(), while backend values such as run status, roles, event types and metadata keys stay raw. The rendered English is unchanged
262 lines
8.9 KiB
TypeScript
262 lines
8.9 KiB
TypeScript
import "@testing-library/jest-dom";
|
|
import { cleanup } from "@testing-library/react";
|
|
import { afterEach, vi } from "vitest";
|
|
|
|
import "@/i18n/i18n";
|
|
|
|
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;
|
|
};
|
|
|
|
const storageMethods: PropertyDescriptorMap = {
|
|
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;
|
|
},
|
|
},
|
|
};
|
|
Object.defineProperties(storagePrototype, storageMethods);
|
|
|
|
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() {}
|
|
};
|
|
}
|
|
|
|
// jsdom lacks the layout and input APIs ProseMirror editors use. Browsers report the selection as an edit's
|
|
// target range; layout calls only need a value of the right shape, since tests do not assert on positions.
|
|
if (typeof InputEvent !== "undefined" && !("getTargetRanges" in InputEvent.prototype)) {
|
|
Object.defineProperty(InputEvent.prototype, "getTargetRanges", {
|
|
configurable: true,
|
|
value: () => {
|
|
const selection = document.getSelection();
|
|
return selection && selection.rangeCount > 0 ? [selection.getRangeAt(0)] : [];
|
|
},
|
|
});
|
|
}
|
|
if (typeof Range !== "undefined" && !("getClientRects" in Range.prototype)) {
|
|
Object.defineProperties(Range.prototype, {
|
|
getClientRects: { configurable: true, value: () => [] },
|
|
getBoundingClientRect: { configurable: true, value: () => new DOMRect() },
|
|
});
|
|
}
|
|
// Virtualized InspectorTables render no rows in a zero-height viewport, and jsdom reports 0 for every offsetHeight.
|
|
if (typeof HTMLElement !== "undefined") {
|
|
Object.defineProperty(HTMLElement.prototype, "offsetHeight", {
|
|
configurable: true,
|
|
get(this: HTMLElement) {
|
|
return this.dataset.slot === "inspector-table" ? 720 : 0;
|
|
},
|
|
});
|
|
}
|
|
if (typeof document !== "undefined" && !("elementFromPoint" in document)) {
|
|
Object.defineProperty(document, "elementFromPoint", { configurable: true, value: () => null });
|
|
}
|