From 9968499aabf6d6b4e36579c05979fc1deda44098 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Tue, 30 Jun 2026 11:28:51 -0700 Subject: [PATCH 1/5] fix(ui): fix Router Settings Loadbalancing tab save (LIT-4057) The Loadbalancing tab rendered routing_groups as a generic text input and sent its array value back as the JSON string "[]", which fails Pydantic list validation on POST /config/update and returns 422. routing_groups has its own dedicated Routing Groups tab, so this tab must neither render nor write it; exclude it the same way retry_policy and model_group_retry_policy are excluded for the Model Retry Settings tab. The save was also fire-and-forget: setCallbacksCall was not awaited, so the rejected promise escaped the try/catch and the success toast fired unconditionally, showing success even when the backend rejected the change. Await the call, gate the success toast on resolution, and surface the error. --- .../ReliabilityRetriesSection.tsx | 5 ++- .../components/router_settings/index.test.tsx | 40 +++++++++++++++++++ .../src/components/router_settings/index.tsx | 13 +++--- 3 files changed, 49 insertions(+), 9 deletions(-) diff --git a/ui/litellm-dashboard/src/components/router_settings/ReliabilityRetriesSection.tsx b/ui/litellm-dashboard/src/components/router_settings/ReliabilityRetriesSection.tsx index fa48c1c97b9..da089552b11 100644 --- a/ui/litellm-dashboard/src/components/router_settings/ReliabilityRetriesSection.tsx +++ b/ui/litellm-dashboard/src/components/router_settings/ReliabilityRetriesSection.tsx @@ -20,14 +20,15 @@ const ReliabilityRetriesSection: React.FC = ({
{Object.entries(routerSettings) .filter( - ([param, value]) => + ([param]) => param != "fallbacks" && param != "context_window_fallbacks" && param != "routing_strategy_args" && param != "routing_strategy" && param != "enable_tag_filtering" && param != "retry_policy" && - param != "model_group_retry_policy", + param != "model_group_retry_policy" && + param != "routing_groups", ) .map(([param, value]) => (
diff --git a/ui/litellm-dashboard/src/components/router_settings/index.test.tsx b/ui/litellm-dashboard/src/components/router_settings/index.test.tsx index 78a0b4b0dff..0df1cd81d6b 100644 --- a/ui/litellm-dashboard/src/components/router_settings/index.test.tsx +++ b/ui/litellm-dashboard/src/components/router_settings/index.test.tsx @@ -146,4 +146,44 @@ describe("RouterSettings", () => { expect(NotificationsManager.success).toHaveBeenCalledWith("router settings updated successfully"); }); + + it("should not render or save routing_groups (owned by the Routing Groups tab) (LIT-4057)", async () => { + const user = userEvent.setup(); + vi.mocked(getCallbacksCall).mockResolvedValue({ + router_settings: { + routing_strategy: "simple-shuffle", + num_retries: 3, + routing_groups: [{ group_name: "g1", models: ["gpt-4"], routing_strategy: "simple-shuffle" }], + }, + }); + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByTestId("strategy-select")).toBeInTheDocument(); + }); + expect(document.querySelector('input[name="routing_groups"]')).toBeNull(); + + await user.click(screen.getByRole("button", { name: /save changes/i })); + + const payload = vi.mocked(setCallbacksCall).mock.calls[0][1] as { + router_settings: Record; + }; + expect(payload.router_settings).not.toHaveProperty("routing_groups"); + }); + + it("should surface an error and not claim success when saving fails (LIT-4057)", async () => { + const user = userEvent.setup(); + vi.mocked(setCallbacksCall).mockRejectedValue(new Error("422 Unprocessable Entity")); + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByTestId("strategy-select")).toBeInTheDocument(); + }); + await user.click(screen.getByRole("button", { name: /save changes/i })); + + await waitFor(() => { + expect(NotificationsManager.fromBackend).toHaveBeenCalled(); + }); + expect(NotificationsManager.success).not.toHaveBeenCalled(); + }); }); diff --git a/ui/litellm-dashboard/src/components/router_settings/index.tsx b/ui/litellm-dashboard/src/components/router_settings/index.tsx index d3753529058..360c7f41138 100644 --- a/ui/litellm-dashboard/src/components/router_settings/index.tsx +++ b/ui/litellm-dashboard/src/components/router_settings/index.tsx @@ -81,7 +81,7 @@ const RouterSettings: React.FC = ({ accessToken, userRole, }); }, [accessToken, userRole, userID]); - const handleSaveChanges = () => { + const handleSaveChanges = async () => { if (!accessToken) { return; } @@ -91,9 +91,9 @@ const RouterSettings: React.FC = ({ accessToken, userRole, const numberKeys = new Set(["allowed_fails", "cooldown_time", "num_retries", "timeout", "retry_after"]); const jsonKeys = new Set(["model_group_alias"]); - // retry_policy and model_group_retry_policy are owned exclusively by the - // Model Retry Settings tab; this page must not read or write them. - const tabOwnedKeys = new Set(["retry_policy", "model_group_retry_policy"]); + // retry_policy and model_group_retry_policy are owned by the Model Retry Settings tab; + // routing_groups is owned by the Routing Groups tab. This page must not read or write them. + const tabOwnedKeys = new Set(["retry_policy", "model_group_retry_policy", "routing_groups"]); const parseInputValue = (key: string, raw: string | undefined, fallback: unknown) => { if (raw === undefined) return fallback; @@ -172,12 +172,11 @@ const RouterSettings: React.FC = ({ accessToken, userRole, }; try { - setCallbacksCall(accessToken, payload); + await setCallbacksCall(accessToken, payload); + NotificationsManager.success("router settings updated successfully"); } catch (error) { NotificationsManager.fromBackend("Failed to update router settings: " + error); } - - NotificationsManager.success("router settings updated successfully"); }; if (!accessToken) { From 30141f86f824cdc53e425a4b27fe07e34cc61c64 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Tue, 30 Jun 2026 11:40:09 -0700 Subject: [PATCH 2/5] test(ui): make router settings save tests resilient to async timing Address Greptile P2: the routing_groups test read setCallbacksCall.mock.calls[0][1] immediately after the now-async save handler, so any latency in the mock would throw an opaque TypeError instead of a clean assertion failure. Assert through toHaveBeenCalledWith inside waitFor with expect.not.objectContaining, dropping the index access and the cast. Also drop the ticket id from the test names. --- .../src/components/router_settings/index.test.tsx | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/ui/litellm-dashboard/src/components/router_settings/index.test.tsx b/ui/litellm-dashboard/src/components/router_settings/index.test.tsx index 0df1cd81d6b..94cbb94d164 100644 --- a/ui/litellm-dashboard/src/components/router_settings/index.test.tsx +++ b/ui/litellm-dashboard/src/components/router_settings/index.test.tsx @@ -147,7 +147,7 @@ describe("RouterSettings", () => { expect(NotificationsManager.success).toHaveBeenCalledWith("router settings updated successfully"); }); - it("should not render or save routing_groups (owned by the Routing Groups tab) (LIT-4057)", async () => { + it("should not render or save routing_groups (owned by the Routing Groups tab)", async () => { const user = userEvent.setup(); vi.mocked(getCallbacksCall).mockResolvedValue({ router_settings: { @@ -165,13 +165,14 @@ describe("RouterSettings", () => { await user.click(screen.getByRole("button", { name: /save changes/i })); - const payload = vi.mocked(setCallbacksCall).mock.calls[0][1] as { - router_settings: Record; - }; - expect(payload.router_settings).not.toHaveProperty("routing_groups"); + await waitFor(() => + expect(setCallbacksCall).toHaveBeenCalledWith("test-token", { + router_settings: expect.not.objectContaining({ routing_groups: expect.anything() }), + }), + ); }); - it("should surface an error and not claim success when saving fails (LIT-4057)", async () => { + it("should surface an error and not claim success when saving fails", async () => { const user = userEvent.setup(); vi.mocked(setCallbacksCall).mockRejectedValue(new Error("422 Unprocessable Entity")); renderWithProviders(); From 540c860a9737838e5fa2146aa8d4cd2fab445548 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Tue, 30 Jun 2026 11:47:35 -0700 Subject: [PATCH 3/5] test(ui): add typed e2e for Router Settings Loadbalancing save (LIT-4057) Drives the real save flow against a live proxy: seeds a present routing_groups array (the LIT-4057 trigger) via the typed /config/update contract, changes num_retries on the Loadbalancing tab, and asserts the POST returns 200 instead of 422, the success toast appears, and the value still shows after a reload (the ticket's "refresh shows old values" symptom). The round-trip is typed against the OpenAPI-generated backend schema (ConfigYAML write, RouterSettingsResponse read) through a type-only import, so a backend contract drift fails the type check. --- .../tests/settings/routerSettings.spec.ts | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/ui/litellm-dashboard/e2e_tests/tests/settings/routerSettings.spec.ts b/ui/litellm-dashboard/e2e_tests/tests/settings/routerSettings.spec.ts index 98b86ec9b11..e560500fca6 100644 --- a/ui/litellm-dashboard/e2e_tests/tests/settings/routerSettings.spec.ts +++ b/ui/litellm-dashboard/e2e_tests/tests/settings/routerSettings.spec.ts @@ -3,6 +3,10 @@ import { ADMIN_STORAGE_PATH } from "../../constants"; import { navigateToPage } from "../../helpers/navigation"; import { Page } from "../../fixtures/pages"; import { Role, users } from "../../fixtures/users"; +// Type-only import of the OpenAPI-generated backend schema; esbuild erases it at +// runtime, so the round-trip below is checked against the real /config/update and +// /router/settings contracts without bundling the 2 MB definition file. +import type { components } from "../../../src/lib/http/schema"; const PRIMARY = "fake-openai-gpt-4"; const FALLBACK = "fake-anthropic-claude"; @@ -99,3 +103,83 @@ test.describe("Router Settings - Fallbacks", () => { await expect(newRow).toHaveCount(1, { timeout: 10_000 }); }); }); + +type ConfigYAML = components["schemas"]["ConfigYAML"]; +type RouterSettingsResponse = components["schemas"]["RouterSettingsResponse"]; + +const BASE_URL = "http://localhost:4000"; +const ADMIN_AUTH = { Authorization: `Bearer ${users[Role.ProxyAdmin].password}` }; + +/** + * Merge a router_settings patch into the live config through the typed + * /config/update contract, preserving any other settings already present. + */ +async function patchRouterSettings( + request: import("@playwright/test").APIRequestContext, + patch: Partial>, +) { + const current = await request.get(`${BASE_URL}/get/config/callbacks`, { headers: ADMIN_AUTH }); + const existing = current.ok() ? (await current.json())?.router_settings ?? {} : {}; + const payload = { router_settings: { ...(existing as Record), ...patch } }; + await request.post(`${BASE_URL}/config/update`, { headers: ADMIN_AUTH, data: payload }); +} + +test.describe("Router Settings - Loadbalancing", () => { + test.use({ storageState: ADMIN_STORAGE_PATH }); + + // Seed a present routing_groups array (the LIT-4057 trigger) plus a known + // num_retries so the UI assertions are deterministic across reruns. + const ROUTING_GROUP = { group_name: "e2e-lit-4057", models: [PRIMARY], routing_strategy: "simple-shuffle" }; + + test.beforeEach(async ({ request }) => { + await patchRouterSettings(request, { num_retries: 3, routing_groups: [ROUTING_GROUP] }); + }); + + test.afterEach(async ({ request }) => { + await patchRouterSettings(request, { num_retries: 3, routing_groups: [] }); + }); + + test("saves the Loadbalancing tab without a 422 when routing_groups is present, and persists", async ({ + page, + request, + }) => { + await navigateToPage(page, Page.RouterSettings); + await page.getByRole("tab", { name: "Loadbalancing" }).click(); + + const numRetries = page.locator('input[name="num_retries"]'); + await expect(numRetries).toHaveValue("3", { timeout: 15_000 }); + // routing_groups belongs to its own tab and must not leak into this form. + await expect(page.locator('input[name="routing_groups"]')).toHaveCount(0); + + await numRetries.fill("5"); + + // LIT-4057: the tab used to serialize routing_groups as the string "[]", + // which the backend rejects with 422 while the UI still claimed success. + // Assert the save actually succeeds at the network level. + const saveResponse = page.waitForResponse( + (res) => res.url().includes("/config/update") && res.request().method() === "POST", + { timeout: 15_000 }, + ); + await page.getByRole("button", { name: /save changes/i }).click(); + expect((await saveResponse).status()).toBe(200); + + await expect(page.getByText(/router settings updated successfully/i).first()).toBeVisible({ timeout: 10_000 }); + + // The ticket's core symptom was that a refresh showed the old value. + await navigateToPage(page, Page.RouterSettings); + await page.getByRole("tab", { name: "Loadbalancing" }).click(); + await expect(page.locator('input[name="num_retries"]')).toHaveValue("5", { timeout: 15_000 }); + + // The typed backend read agrees the change persisted. + await expect + .poll( + async () => { + const res = await request.get(`${BASE_URL}/router/settings`, { headers: ADMIN_AUTH }); + const data = (await res.json()) as RouterSettingsResponse; + return data.current_values?.num_retries; + }, + { timeout: 10_000 }, + ) + .toBe(5); + }); +}); From 3971469b71e4454bc02dc5ee1d5b9bd1618566ad Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Tue, 30 Jun 2026 12:08:15 -0700 Subject: [PATCH 4/5] test(ui): harden Router Settings e2e and make its typing a real CI gate Address an adversarial review of the Loadbalancing e2e: - The "typed against the backend schema" claim was hollow: nothing type-checked e2e_tests (the root tsconfig excludes it and no CI step runs tsc), so a contract drift would compile and run unchanged. Add e2e_tests/tsconfig.json, a typecheck:e2e script, and a CircleCI step so the schema typing actually gates. - The two describe blocks both mutate the proxy's shared router_settings, and the Loadbalancing save echoes the whole settings object, so they could clobber each other under local fullyParallel. Run the file serially. - patchRouterSettings swallowed a failed seed, which surfaced later as a misleading UI timeout. Assert the write succeeded, and rely on the server-side merge instead of echoing the whole settings object back (drops a cast and a GET). - Empty routing_groups already reproduces the bug, so drop the non-empty seed and its model coupling. --- .circleci/config.yml | 8 +++++ .../tests/settings/routerSettings.spec.ts | 36 +++++++++++-------- ui/litellm-dashboard/e2e_tests/tsconfig.json | 9 +++++ ui/litellm-dashboard/package.json | 1 + 4 files changed, 39 insertions(+), 15 deletions(-) create mode 100644 ui/litellm-dashboard/e2e_tests/tsconfig.json diff --git a/.circleci/config.yml b/.circleci/config.yml index f13e9bf66f1..009884cbfe4 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -2687,6 +2687,14 @@ jobs: paths: - ui/litellm-dashboard/node_modules - ~/.cache/ms-playwright + - run: + name: Type-check E2E specs + # The specs type their request/response round-trips against the generated + # OpenAPI schema; this step turns that typing into a real gate, so a backend + # contract drift fails here instead of silently passing at runtime. + command: | + cd ui/litellm-dashboard + npm run typecheck:e2e - run: name: Build UI from source # Prior version used `cp -r out/ ../../litellm/proxy/_experimental/out/`. diff --git a/ui/litellm-dashboard/e2e_tests/tests/settings/routerSettings.spec.ts b/ui/litellm-dashboard/e2e_tests/tests/settings/routerSettings.spec.ts index e560500fca6..64dab6d7cf6 100644 --- a/ui/litellm-dashboard/e2e_tests/tests/settings/routerSettings.spec.ts +++ b/ui/litellm-dashboard/e2e_tests/tests/settings/routerSettings.spec.ts @@ -3,11 +3,16 @@ import { ADMIN_STORAGE_PATH } from "../../constants"; import { navigateToPage } from "../../helpers/navigation"; import { Page } from "../../fixtures/pages"; import { Role, users } from "../../fixtures/users"; -// Type-only import of the OpenAPI-generated backend schema; esbuild erases it at -// runtime, so the round-trip below is checked against the real /config/update and -// /router/settings contracts without bundling the 2 MB definition file. +// Type-only import of the OpenAPI-generated backend schema. esbuild erases it at +// runtime; the round-trip below is enforced by the `typecheck:e2e` CI step (tsc over +// e2e_tests), so a drift in the /config/update or /router/settings contract fails the +// build rather than silently passing here. import type { components } from "../../../src/lib/http/schema"; +// These tests mutate the proxy's shared router_settings, and the Loadbalancing save +// echoes the whole settings object, so they must not run concurrently. +test.describe.configure({ mode: "serial" }); + const PRIMARY = "fake-openai-gpt-4"; const FALLBACK = "fake-anthropic-claude"; @@ -111,32 +116,33 @@ const BASE_URL = "http://localhost:4000"; const ADMIN_AUTH = { Authorization: `Bearer ${users[Role.ProxyAdmin].password}` }; /** - * Merge a router_settings patch into the live config through the typed - * /config/update contract, preserving any other settings already present. + * Apply a router_settings patch through the typed /config/update contract. The + * server merges it over existing settings (request wins), so only the passed keys + * change. Fails loudly if the write is rejected instead of leaving a silent bad seed. */ async function patchRouterSettings( request: import("@playwright/test").APIRequestContext, patch: Partial>, ) { - const current = await request.get(`${BASE_URL}/get/config/callbacks`, { headers: ADMIN_AUTH }); - const existing = current.ok() ? (await current.json())?.router_settings ?? {} : {}; - const payload = { router_settings: { ...(existing as Record), ...patch } }; - await request.post(`${BASE_URL}/config/update`, { headers: ADMIN_AUTH, data: payload }); + const res = await request.post(`${BASE_URL}/config/update`, { + headers: ADMIN_AUTH, + data: { router_settings: patch }, + }); + expect(res.ok(), `seed /config/update failed: ${res.status()} ${await res.text()}`).toBeTruthy(); } test.describe("Router Settings - Loadbalancing", () => { test.use({ storageState: ADMIN_STORAGE_PATH }); - // Seed a present routing_groups array (the LIT-4057 trigger) plus a known - // num_retries so the UI assertions are deterministic across reruns. - const ROUTING_GROUP = { group_name: "e2e-lit-4057", models: [PRIMARY], routing_strategy: "simple-shuffle" }; - + // Pin num_retries and an empty routing_groups so the assertions are deterministic. + // Empty already reproduces LIT-4057: the old tab serialized [] to the string "[]" + // and the save 422'd. test.beforeEach(async ({ request }) => { - await patchRouterSettings(request, { num_retries: 3, routing_groups: [ROUTING_GROUP] }); + await patchRouterSettings(request, { num_retries: 3, routing_groups: [] }); }); test.afterEach(async ({ request }) => { - await patchRouterSettings(request, { num_retries: 3, routing_groups: [] }); + await patchRouterSettings(request, { num_retries: 3 }); }); test("saves the Loadbalancing tab without a 422 when routing_groups is present, and persists", async ({ diff --git a/ui/litellm-dashboard/e2e_tests/tsconfig.json b/ui/litellm-dashboard/e2e_tests/tsconfig.json new file mode 100644 index 00000000000..abda3fdb8b6 --- /dev/null +++ b/ui/litellm-dashboard/e2e_tests/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "noEmit": true, + "types": ["node"] + }, + "include": ["**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/ui/litellm-dashboard/package.json b/ui/litellm-dashboard/package.json index a8948f4be34..1d2b505a172 100644 --- a/ui/litellm-dashboard/package.json +++ b/ui/litellm-dashboard/package.json @@ -19,6 +19,7 @@ "e2e:ui": "playwright test --ui --config e2e_tests/playwright.config.ts", "e2e:migration": "playwright test e2e_tests/tests/migration/migratedPages.spec.ts --config e2e_tests/playwright.config.ts", "e2e:migration:root": "playwright test --config e2e_tests/migration.serverRootPath.config.ts", + "typecheck:e2e": "tsc -p e2e_tests/tsconfig.json --noEmit", "knip": "knip", "knip:fix": "knip --fix", "gen:api": "node scripts/gen-api-types.mjs" From 4f41a9e140ebcf12ec56bfc8d24b4dd5ba78ed4f Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Tue, 30 Jun 2026 12:49:46 -0700 Subject: [PATCH 5/5] test(ui): drop the e2e typecheck CI gate, keep the typed import for the editor The e2e runs against the real proxy, so a contract drift already fails the test at runtime; tsc only checks the spec against schema.d.ts, a generated snapshot, so a backend change with a stale snapshot would pass tsc while the live test still catches it. The dedicated tsconfig + script + CI step were circular ceremony for that. Keep the zero-runtime-cost type-only import, which still catches mistakes in the editor, and make its comment honest about what enforces the contract. --- .circleci/config.yml | 8 -------- .../e2e_tests/tests/settings/routerSettings.spec.ts | 7 +++---- ui/litellm-dashboard/e2e_tests/tsconfig.json | 9 --------- ui/litellm-dashboard/package.json | 1 - 4 files changed, 3 insertions(+), 22 deletions(-) delete mode 100644 ui/litellm-dashboard/e2e_tests/tsconfig.json diff --git a/.circleci/config.yml b/.circleci/config.yml index 009884cbfe4..f13e9bf66f1 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -2687,14 +2687,6 @@ jobs: paths: - ui/litellm-dashboard/node_modules - ~/.cache/ms-playwright - - run: - name: Type-check E2E specs - # The specs type their request/response round-trips against the generated - # OpenAPI schema; this step turns that typing into a real gate, so a backend - # contract drift fails here instead of silently passing at runtime. - command: | - cd ui/litellm-dashboard - npm run typecheck:e2e - run: name: Build UI from source # Prior version used `cp -r out/ ../../litellm/proxy/_experimental/out/`. diff --git a/ui/litellm-dashboard/e2e_tests/tests/settings/routerSettings.spec.ts b/ui/litellm-dashboard/e2e_tests/tests/settings/routerSettings.spec.ts index 64dab6d7cf6..3e140b9ab56 100644 --- a/ui/litellm-dashboard/e2e_tests/tests/settings/routerSettings.spec.ts +++ b/ui/litellm-dashboard/e2e_tests/tests/settings/routerSettings.spec.ts @@ -3,10 +3,9 @@ import { ADMIN_STORAGE_PATH } from "../../constants"; import { navigateToPage } from "../../helpers/navigation"; import { Page } from "../../fixtures/pages"; import { Role, users } from "../../fixtures/users"; -// Type-only import of the OpenAPI-generated backend schema. esbuild erases it at -// runtime; the round-trip below is enforced by the `typecheck:e2e` CI step (tsc over -// e2e_tests), so a drift in the /config/update or /router/settings contract fails the -// build rather than silently passing here. +// Type-only import of the OpenAPI-generated backend schema, erased at runtime by +// esbuild. It types the round-trips below so mistakes surface in the editor; the live +// test against the real proxy is what actually enforces the contract. import type { components } from "../../../src/lib/http/schema"; // These tests mutate the proxy's shared router_settings, and the Loadbalancing save diff --git a/ui/litellm-dashboard/e2e_tests/tsconfig.json b/ui/litellm-dashboard/e2e_tests/tsconfig.json deleted file mode 100644 index abda3fdb8b6..00000000000 --- a/ui/litellm-dashboard/e2e_tests/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../tsconfig.json", - "compilerOptions": { - "noEmit": true, - "types": ["node"] - }, - "include": ["**/*.ts"], - "exclude": ["node_modules"] -} diff --git a/ui/litellm-dashboard/package.json b/ui/litellm-dashboard/package.json index 1d2b505a172..a8948f4be34 100644 --- a/ui/litellm-dashboard/package.json +++ b/ui/litellm-dashboard/package.json @@ -19,7 +19,6 @@ "e2e:ui": "playwright test --ui --config e2e_tests/playwright.config.ts", "e2e:migration": "playwright test e2e_tests/tests/migration/migratedPages.spec.ts --config e2e_tests/playwright.config.ts", "e2e:migration:root": "playwright test --config e2e_tests/migration.serverRootPath.config.ts", - "typecheck:e2e": "tsc -p e2e_tests/tsconfig.json --noEmit", "knip": "knip", "knip:fix": "knip --fix", "gen:api": "node scripts/gen-api-types.mjs"