diff --git a/ui/litellm-dashboard/package-lock.json b/ui/litellm-dashboard/package-lock.json index 186d38234d5..7069450a75c 100644 --- a/ui/litellm-dashboard/package-lock.json +++ b/ui/litellm-dashboard/package-lock.json @@ -32,9 +32,9 @@ "openapi-fetch": "^0.17.0", "openapi-react-query": "^0.5.4", "papaparse": "5.5.3", - "react": "18.3.1", + "react": "19.2.8", "react-copy-to-clipboard": "5.1.1", - "react-dom": "18.3.1", + "react-dom": "19.2.8", "react-hook-form": "7.82.0", "react-json-view-lite": "2.5.0", "react-markdown": "9.1.0", @@ -55,9 +55,9 @@ "@testing-library/react": "16.3.2", "@testing-library/user-event": "14.6.1", "@types/node": "20.19.37", - "@types/react": "18.2.48", + "@types/react": "19.2.18", "@types/react-copy-to-clipboard": "5.0.7", - "@types/react-dom": "18.3.7", + "@types/react-dom": "19.2.4", "@types/react-syntax-highlighter": "15.5.13", "@vitest/coverage-v8": "3.2.6", "@vitest/ui": "3.2.6", @@ -200,9 +200,9 @@ } }, "node_modules/@ant-design/icons-svg": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/@ant-design/icons-svg/-/icons-svg-4.4.2.tgz", - "integrity": "sha512-vHbT+zJEVzllwP+CM+ul7reTEfBR0vgxFe7+lREAsAA7YGsYpboiq2sQNeQeRvh09GfQgs/GyFEvZpJ9cLXpXA==", + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@ant-design/icons-svg/-/icons-svg-4.5.0.tgz", + "integrity": "sha512-1BTUFyKPTBZ53MuTP8s0k5SFEXL7o3VHEOwLgzaoWKwnBeqIcqUtVshc4SKzhI6uACfqhJqBwBUE9FsWR3uULA==", "license": "MIT" }, "node_modules/@ant-design/react-slick": { @@ -606,19 +606,6 @@ } } }, - "node_modules/@base-ui/react/node_modules/@floating-ui/react-dom": { - "version": "2.1.8", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", - "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", - "license": "MIT", - "dependencies": { - "@floating-ui/dom": "^1.7.6" - }, - "peerDependencies": { - "react": ">=16.8.0", - "react-dom": ">=16.8.0" - } - }, "node_modules/@base-ui/utils": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/@base-ui/utils/-/utils-0.3.1.tgz", @@ -1441,28 +1428,41 @@ } }, "node_modules/@floating-ui/core": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", - "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", "license": "MIT", "dependencies": { - "@floating-ui/utils": "^0.2.11" + "@floating-ui/utils": "^0.2.12" } }, "node_modules/@floating-ui/dom": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", - "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", "license": "MIT", "dependencies": { - "@floating-ui/core": "^1.7.5", - "@floating-ui/utils": "^0.2.11" + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz", + "integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.8.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" } }, "node_modules/@floating-ui/utils": { - "version": "0.2.11", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", - "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", "license": "MIT" }, "node_modules/@headlessui/tailwindcss": { @@ -2627,9 +2627,9 @@ "license": "MIT" }, "node_modules/@rc-component/async-validator": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@rc-component/async-validator/-/async-validator-5.1.0.tgz", - "integrity": "sha512-n4HcR5siNUXRX23nDizbZBQPO0ZM/5oTtmKZ6/eqL0L2bo747cklFdZGRN2f+c9qWGICwDzrhW0H7tE9PptdcA==", + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@rc-component/async-validator/-/async-validator-5.1.2.tgz", + "integrity": "sha512-WYbrZSjzznU1ekD0qFq2qRxt309VoS61MTG5npnFQlKYcoy9IzU8T+ZCIhq5bGAXRbXysABFWTspicMfmWFwow==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.24.4" @@ -2669,9 +2669,9 @@ } }, "node_modules/@rc-component/mini-decimal": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rc-component/mini-decimal/-/mini-decimal-1.1.3.tgz", - "integrity": "sha512-bk/FJ09fLf+NLODMAFll6CfYrHPBioTedhW6lxDBuuWucJEqFUd4l/D/5JgIi3dina6sYahB8iuPAZTNz2pMxw==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rc-component/mini-decimal/-/mini-decimal-1.1.4.tgz", + "integrity": "sha512-xiuXcaCwyOWpD8a8scdExFl+bntNphAW8XeenL1ig2en0AAZY0Pcp4pC0dI22qJ+NvxKn9RoNIoRdqYU3BLH4w==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.18.0" @@ -2717,9 +2717,9 @@ } }, "node_modules/@rc-component/qrcode": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@rc-component/qrcode/-/qrcode-1.1.1.tgz", - "integrity": "sha512-LfLGNymzKdUPjXUbRP+xOhIWY4jQ+YMj5MmWAcgcAq1Ij8XP7tRmAXqyuv96XvLUBE/5cA8hLFl9eO1JQMujrA==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rc-component/qrcode/-/qrcode-1.1.3.tgz", + "integrity": "sha512-aGv6alnn4HbDEsURzKP+jv13rbi1VxmAYfBNZr5GKF1iohMNWy5tAVoJ1E3cOvzMB1kbUPvCXchM6zSFlRGPhA==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.24.7" @@ -3661,12 +3661,12 @@ } }, "node_modules/@tanstack/react-store": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.11.0.tgz", - "integrity": "sha512-tX4YXh3PDkmpvGQWkWqKpzs/MSqbtuwY9dWdWhtV9Q50PmO+jOkUKIWIX4G85dwt7lxdHLXsiaEKPdKmC8F41w==", + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.11.1.tgz", + "integrity": "sha512-HaIGKI3YLmjBYIvy5DFDY23oNaYZIsTZfngey07Uh5iLVJgM3bIGCnZeOFOqzjFld9JHWcaHJnasD/bKoGKwJQ==", "license": "MIT", "dependencies": { - "@tanstack/store": "0.11.0", + "@tanstack/store": "0.11.1", "use-sync-external-store": "^1.6.0" }, "funding": { @@ -3699,9 +3699,9 @@ } }, "node_modules/@tanstack/store": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.11.0.tgz", - "integrity": "sha512-WlzzCt3xi0G6pCAJu1U+2jiECwabETDpQDi3hfkFZvJii9AuZqEKbOiVarX1/bWhTNjU486yQtJCCasi/0q+Cw==", + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.11.1.tgz", + "integrity": "sha512-mzTOBhypOuDJAy/D8n2MfUZ1HFkXnmSETviRyhqEC8LUE7/IZQExOTxMANj3KjTofYTkFNpBY67qaVrT41YccA==", "license": "MIT", "funding": { "type": "github", @@ -3999,21 +3999,13 @@ "@types/node": "*" } }, - "node_modules/@types/prop-types": { - "version": "15.7.15", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", - "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "license": "MIT" - }, "node_modules/@types/react": { - "version": "18.2.48", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.48.tgz", - "integrity": "sha512-qboRCl6Ie70DQQG9hhNREz81jqC1cs9EVNcjQ1AU+jH6NFfSAhVVbrrY/+nSF+Bsk4AOwm9Qa61InvMCyV+H3w==", + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", "license": "MIT", "dependencies": { - "@types/prop-types": "*", - "@types/scheduler": "*", - "csstype": "^3.0.2" + "csstype": "^3.2.2" } }, "node_modules/@types/react-copy-to-clipboard": { @@ -4027,13 +4019,13 @@ } }, "node_modules/@types/react-dom": { - "version": "18.3.7", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", - "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==", "dev": true, "license": "MIT", "peerDependencies": { - "@types/react": "^18.0.0" + "@types/react": "^19.2.0" } }, "node_modules/@types/react-syntax-highlighter": { @@ -4046,12 +4038,6 @@ "@types/react": "*" } }, - "node_modules/@types/scheduler": { - "version": "0.26.0", - "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.26.0.tgz", - "integrity": "sha512-WFHp9YUJQ6CKshqoC37iOlHnQSmxNc795UhB26CyBBttrN9svdIrUjl/NjnNmfcwtncN0h/0PPAFWv9ovP8mLA==", - "license": "MIT" - }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -11565,13 +11551,10 @@ } }, "node_modules/react": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", - "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - }, "engines": { "node": ">=0.10.0" } @@ -11590,16 +11573,15 @@ } }, "node_modules/react-dom": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", - "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", "license": "MIT", "dependencies": { - "loose-envify": "^1.1.0", - "scheduler": "^0.23.2" + "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^18.3.1" + "react": "^19.2.8" } }, "node_modules/react-hook-form": { @@ -12204,13 +12186,10 @@ } }, "node_modules/scheduler": { - "version": "0.23.2", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", - "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - } + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" }, "node_modules/scroll-into-view-if-needed": { "version": "3.1.0", diff --git a/ui/litellm-dashboard/package.json b/ui/litellm-dashboard/package.json index ac13a12620d..962cabcba7b 100644 --- a/ui/litellm-dashboard/package.json +++ b/ui/litellm-dashboard/package.json @@ -48,9 +48,9 @@ "openapi-fetch": "^0.17.0", "openapi-react-query": "^0.5.4", "papaparse": "5.5.3", - "react": "18.3.1", + "react": "19.2.8", "react-copy-to-clipboard": "5.1.1", - "react-dom": "18.3.1", + "react-dom": "19.2.8", "react-hook-form": "7.82.0", "react-json-view-lite": "2.5.0", "react-markdown": "9.1.0", @@ -71,9 +71,9 @@ "@testing-library/react": "16.3.2", "@testing-library/user-event": "14.6.1", "@types/node": "20.19.37", - "@types/react": "18.2.48", + "@types/react": "19.2.18", "@types/react-copy-to-clipboard": "5.0.7", - "@types/react-dom": "18.3.7", + "@types/react-dom": "19.2.4", "@types/react-syntax-highlighter": "15.5.13", "@vitest/coverage-v8": "3.2.6", "@vitest/ui": "3.2.6", diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.test.tsx index 14e26fa5393..96685bb8359 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.test.tsx @@ -1939,6 +1939,24 @@ describe("MCPServerEdit OAuth flow prefill display", () => { expect(screen.queryByText("This server has no OAuth flow set")).not.toBeInTheDocument(); }); + it("never flashes the warning while mounting a server that already has a flow", async () => { + const flashes: Node[] = []; + const observer = new MutationObserver((records) => { + for (const record of records) { + for (const node of record.addedNodes) { + if (node.textContent?.includes("This server has no OAuth flow set")) flashes.push(node); + } + } + }); + observer.observe(document.body, { childList: true, subtree: true }); + + renderEdit({ oauth2_flow: "client_credentials" }); + await screen.findByText("Machine-to-Machine (M2M)"); + observer.disconnect(); + + expect(flashes).toHaveLength(0); + }); + it("does not warn for a delegate (PKCE passthrough) server even with no flow set", () => { renderEdit({ oauth2_flow: null, delegate_auth_to_upstream: true }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.tsx index 94b66f1bdfa..4b092aa3f80 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.tsx @@ -173,7 +173,10 @@ const MCPServerEdit: React.FC = ({ const isTokenExchangeAuthType = authType === AUTH_TYPE.OAUTH2_TOKEN_EXCHANGE; const isIdJagAuthType = authType === AUTH_TYPE.OAUTH2_ID_JAG; const isAwsSigV4AuthType = authType === AUTH_TYPE.AWS_SIGV4; - const oauthFlowTypeValue = mountedValues.oauth_flow_type as string | undefined; + // Same fallback as the delegate switch below: the value is undefined until the field mounts, so + // reading it alone flashes the "no OAuth flow set" warning at a server that already has one. + const oauthFlowTypeValue = + (mountedValues.oauth_flow_type as string | undefined) ?? oauth2FlowToFormValue(mcpServer.oauth2_flow); const isM2MFlow = isOAuthAuthType && oauthFlowTypeValue === OAUTH_FLOW.M2M; // Watch reflects a live toggle when the delegate switch is mounted; fall back to // the stored value otherwise (useWatch returns undefined for an unmounted field, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_editor_view/conversation_panel/MessageList.tsx b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_editor_view/conversation_panel/MessageList.tsx index 347553630ae..48d6697a052 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_editor_view/conversation_panel/MessageList.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_editor_view/conversation_panel/MessageList.tsx @@ -8,7 +8,7 @@ interface MessageListProps { messages: Message[]; isLoading: boolean; hasVariables: boolean; - messagesEndRef: React.RefObject; + messagesEndRef: React.RefObject; } const MessageList: React.FC = ({ messages, isLoading, hasVariables, messagesEndRef }) => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsageViewSelect/UsageViewSelect.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsageViewSelect/UsageViewSelect.test.tsx index 3d835c98f52..8848ef9f49e 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsageViewSelect/UsageViewSelect.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsageViewSelect/UsageViewSelect.test.tsx @@ -1,6 +1,7 @@ import { render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { chooseSelectOption } from "@/../tests/test-utils"; import { UsageViewSelect } from "./UsageViewSelect"; const openMenu = async (user: ReturnType) => { @@ -35,9 +36,7 @@ describe("UsageViewSelect", () => { const user = userEvent.setup(); render(); - await openMenu(user); - const matches = screen.getAllByText("Team Usage"); - await user.click(matches[matches.length - 1]); + await chooseSelectOption(user, screen.getByRole("combobox"), /^Team Usage/); expect(mockOnChange).toHaveBeenCalled(); expect(mockOnChange.mock.calls[0][0]).toBe("team"); diff --git a/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesPanel.test.tsx b/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesPanel.test.tsx index aaa70a5e357..a2d3539a58b 100644 --- a/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesPanel.test.tsx +++ b/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesPanel.test.tsx @@ -4,7 +4,7 @@ import { act, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { focusManager, QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { renderWithProviders, testQueryClient } from "../../../tests/test-utils"; +import { chooseSelectOption, renderWithProviders, testQueryClient } from "../../../tests/test-utils"; import type { ToolRow } from "@/components/networking"; import { ToolPoliciesPanel } from "./ToolPoliciesPanel"; import { toast } from "@/lib/toast"; @@ -84,17 +84,6 @@ const policyValue = (toolId: string, kind: "input" | "output"): string => { const isSaving = (toolId: string, kind: "input" | "output"): boolean => policySelect(toolId, kind).hasAttribute("disabled"); -const chooseOption = async (user: ReturnType, trigger: HTMLElement, label: string) => { - await user.click(trigger); - // The label also renders in the trigger once selected, so take the last match: - // the popup is portalled after the table in document order. - const option = await waitFor(() => { - const matches = screen.getAllByText(label); - return matches[matches.length - 1]; - }); - await user.click(option); -}; - const renderPanel = (onSelectTool = vi.fn()) => renderWithProviders(); @@ -202,7 +191,7 @@ describe("ToolPoliciesPanel inline policy editing", () => { renderPanel(); await waitForRows(); - await chooseOption(user, policySelect("tool-1", "input"), "trusted"); + await chooseSelectOption(user, policySelect("tool-1", "input"), "trusted"); expect(updateToolPolicy).toHaveBeenCalledWith("sk-token", "get_weather", { input_policy: "trusted" }); await waitFor(() => expect(policyValue("tool-1", "input")).toBe("trusted")); @@ -214,7 +203,7 @@ describe("ToolPoliciesPanel inline policy editing", () => { renderPanel(); await waitForRows(); - await chooseOption(user, policySelect("tool-1", "output"), "trusted"); + await chooseSelectOption(user, policySelect("tool-1", "output"), "trusted"); expect(updateToolPolicy).toHaveBeenCalledWith("sk-token", "get_weather", { output_policy: "trusted" }); }); @@ -225,8 +214,8 @@ describe("ToolPoliciesPanel inline policy editing", () => { renderPanel(); await waitForRows(); - await chooseOption(user, policySelect("tool-1", "input"), "trusted"); - await chooseOption(user, policySelect("tool-2", "input"), "blocked"); + await chooseSelectOption(user, policySelect("tool-1", "input"), "trusted"); + await chooseSelectOption(user, policySelect("tool-2", "input"), "blocked"); expect(isSaving("tool-2", "input")).toBe(true); expect(isSaving("tool-1", "input")).toBe(true); @@ -241,8 +230,8 @@ describe("ToolPoliciesPanel inline policy editing", () => { renderPanel(); await waitForRows(); - await chooseOption(user, policySelect("tool-1", "input"), "trusted"); - await chooseOption(user, policySelect("tool-2", "input"), "blocked"); + await chooseSelectOption(user, policySelect("tool-1", "input"), "trusted"); + await chooseSelectOption(user, policySelect("tool-2", "input"), "blocked"); await act(async () => { finishFirst(); }); @@ -262,7 +251,7 @@ describe("ToolPoliciesPanel inline policy editing", () => { () => new Promise((resolve) => (landStaleRefresh = () => resolve(TOOLS))), ); await user.click(screen.getByTestId("datatable-refresh")); - await chooseOption(user, policySelect("tool-1", "input"), "trusted"); + await chooseSelectOption(user, policySelect("tool-1", "input"), "trusted"); await waitFor(() => expect(policyValue("tool-1", "input")).toBe("trusted")); await act(async () => { @@ -281,7 +270,7 @@ describe("ToolPoliciesPanel inline policy editing", () => { renderPanel(); await waitForRows(); - await chooseOption(user, policySelect("tool-1", "input"), "trusted"); + await chooseSelectOption(user, policySelect("tool-1", "input"), "trusted"); await waitFor(() => expect(fromBackend).toHaveBeenCalledWith("Failed to update input policy: nope")); expect(policyValue("tool-1", "input")).toBe("untrusted"); @@ -293,7 +282,7 @@ describe("ToolPoliciesPanel inline policy editing", () => { renderPanel(); await waitForRows(); - await chooseOption(user, policySelect("tool-1", "input"), "trusted"); + await chooseSelectOption(user, policySelect("tool-1", "input"), "trusted"); await waitFor(() => expect(isSaving("tool-1", "input")).toBe(true)); expect(isSaving("tool-1", "output")).toBe(false); diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx index 77f35762528..a7e8e6788dd 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx @@ -3,7 +3,7 @@ import * as networking from "@/components/networking"; import { fireEvent, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { renderWithProviders, testQueryClient } from "../../../tests/test-utils"; +import { chooseSelectOption, renderWithProviders, testQueryClient } from "../../../tests/test-utils"; import TeamInfoView from "./TeamInfo"; const authState = vi.hoisted(() => ({ userRole: "Admin" })); @@ -993,8 +993,7 @@ describe("TeamInfoView", () => { const user = userEvent.setup({ delay: null }); const resetBudgetSelect = await openSettingsEditorForTeam(user, { budget_duration: "30d" }); - await user.click(resetBudgetSelect); - await user.click(await screen.findByText("Never resets")); + await chooseSelectOption(user, resetBudgetSelect, "Never resets"); await waitFor(() => { expect(resetBudgetSelect).toHaveTextContent("Never resets"); @@ -1028,8 +1027,7 @@ describe("TeamInfoView", () => { const user = userEvent.setup({ delay: null }); const resetBudgetSelect = await openSettingsEditorForTeam(user, { budget_duration: null }); - await user.click(resetBudgetSelect); - await user.click(await screen.findByText("weekly")); + await chooseSelectOption(user, resetBudgetSelect, "weekly"); await user.click(screen.getByRole("button", { name: /save changes/i })); diff --git a/ui/litellm-dashboard/src/components/ui/ref-forwarding.test.tsx b/ui/litellm-dashboard/src/components/ui/ref-forwarding.test.tsx index 48b1e8be226..66f7bd27138 100644 --- a/ui/litellm-dashboard/src/components/ui/ref-forwarding.test.tsx +++ b/ui/litellm-dashboard/src/components/ui/ref-forwarding.test.tsx @@ -138,15 +138,13 @@ describe("ui primitives forward refs to their DOM node", () => { }); }); -describe("setupTests ref tripwire", () => { - it("records a violation when a ref is passed to a plain function component", () => { - const Plain = (props: React.ComponentPropsWithoutRef<"span">) => ; +describe("plain function components", () => { + it("receives a ref as a prop instead of dropping it", () => { + const Plain = (props: React.ComponentPropsWithoutRef<"span"> & { ref?: React.Ref }) => ( + + ); const ref = React.createRef(); - render(React.createElement(Plain as never, { ref })); - const consume = (globalThis as { __consumePendingRefWarnings?: () => string[] }).__consumePendingRefWarnings; - expect(consume).toBeDefined(); - const violations = consume!(); - expect(violations).toHaveLength(1); - expect(violations[0]).toContain("Function components cannot be given refs"); + render(ok); + expect(ref.current).toBeInstanceOf(HTMLSpanElement); }); }); diff --git a/ui/litellm-dashboard/tests/setupTests.ts b/ui/litellm-dashboard/tests/setupTests.ts index 3eceef211a5..d007206e681 100644 --- a/ui/litellm-dashboard/tests/setupTests.ts +++ b/ui/litellm-dashboard/tests/setupTests.ts @@ -113,31 +113,7 @@ vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ }), })); -const pendingRefWarnings: string[] = []; -const consumePendingRefWarnings = (): string[] => pendingRefWarnings.splice(0, pendingRefWarnings.length); -(globalThis as { __consumePendingRefWarnings?: () => string[] }).__consumePendingRefWarnings = - consumePendingRefWarnings; - -const originalConsoleError = console.error.bind(console); -vi.spyOn(console, "error").mockImplementation((...args: unknown[]) => { - originalConsoleError(...args); - if (typeof args[0] === "string" && args[0].includes("Function components cannot be given refs")) { - pendingRefWarnings.push(args.map(String).join(" ")); - } -}); - -afterEach(() => { - cleanup(); - const refWarnings = consumePendingRefWarnings(); - if (refWarnings.length > 0) { - throw new Error( - "A ref was passed to a plain function component and silently dropped under React 18, which breaks " + - "ref-based composition (Base UI render triggers, tooltips, focus). Wrap the component in React.forwardRef. " + - "This tripwire lives in tests/setupTests.ts and can be removed after the React 19 upgrade.\n\n" + - refWarnings.join("\n\n"), - ); - } -}); +afterEach(cleanup); // Make toLocaleString deterministic in tests; individual tests can override // This returns ISO-like strings to keep assertions stable. diff --git a/ui/litellm-dashboard/tests/test-utils.tsx b/ui/litellm-dashboard/tests/test-utils.tsx index 2573fbdfeac..66966201a9c 100644 --- a/ui/litellm-dashboard/tests/test-utils.tsx +++ b/ui/litellm-dashboard/tests/test-utils.tsx @@ -1,7 +1,9 @@ import React, { PropsWithChildren } from "react"; -import { render, RenderOptions } from "@testing-library/react"; +import { render, RenderOptions, screen, waitFor } from "@testing-library/react"; +import type userEvent from "@testing-library/user-event"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { NuqsTestingAdapter, OnUrlUpdateFunction } from "nuqs/adapters/testing"; +import { expect } from "vitest"; // Create a client for testing export const testQueryClient = new QueryClient({ @@ -35,4 +37,29 @@ export const renderWithProviders = (ui: React.ReactElement, options?: RenderOpti return render(ui, { wrapper: Providers, ...renderOptions }); }; +const pointerBlocked = (element: HTMLElement): boolean => { + for (let node: HTMLElement | null = element; node !== null; node = node.parentElement) { + if (node.style.pointerEvents === "none") return true; + } + return false; +}; + +/** + * Opens a Base UI Select and picks an option by its accessible name. + * + * The option is in the DOM one render before the popup finishes entering, and until then its + * positioner still carries `pointer-events: none`, which user-event refuses to click. Waiting on + * the option text alone is a race that React 19's flush timing loses. + */ +export const chooseSelectOption = async ( + user: ReturnType, + trigger: HTMLElement, + optionName: string | RegExp, +) => { + await user.click(trigger); + const option = await screen.findByRole("option", { name: optionName }); + await waitFor(() => expect(pointerBlocked(option)).toBe(false)); + await user.click(option); +}; + export * from "@testing-library/react";