From 4b903fdd3ba388cb136d4aa85e16f4625377a736 Mon Sep 17 00:00:00 2001 From: Cita Date: Mon, 23 Mar 2026 16:02:13 +0800 Subject: [PATCH] fix: tighten ci typecheck coverage --- .github/workflows/ci.yml | 22 +++++++ package-lock.json | 61 +++++++++++++------ package.json | 6 ++ src/web/App.mobile-layout.test.tsx | 2 +- src/web/App.sidebar-mobile.test.tsx | 2 +- .../components/ModelAnalysisPanel.test.tsx | 8 +-- src/web/components/centered-modal.test.tsx | 2 +- .../charts/SiteDistributionChart.test.tsx | 8 +-- .../charts/SiteDistributionChart.tsx | 26 +++++--- src/web/components/mobile-drawer.test.tsx | 2 +- .../components/search-modal.results.test.tsx | 2 +- src/web/components/useIsMobile.test.tsx | 14 ++--- src/web/main.tsx | 2 +- src/web/pages/DownstreamKeys.mobile.test.tsx | 2 +- src/web/pages/DownstreamKeys.test.tsx | 24 ++++---- src/web/pages/ImportExport.test.tsx | 16 ++--- src/web/pages/ModelTester.tsx | 14 +++-- .../pages/Models.marketplace-text.test.tsx | 14 ++--- src/web/pages/NotificationSettings.test.tsx | 4 +- src/web/pages/OAuthManagement.test.tsx | 18 +++--- src/web/pages/ProgramLogs.status.test.tsx | 4 +- .../pages/ProxyLogs.server-driven.test.tsx | 12 ++-- src/web/pages/ProxyLogs.tsx | 19 ++++-- src/web/pages/SiteAnnouncements.tsx | 2 +- src/web/pages/TokenRoutes.tsx | 3 +- src/web/pages/Tokens.tsx | 20 +++++- src/web/pages/accounts.batch-actions.test.tsx | 6 +- src/web/pages/accounts.create-intent.test.tsx | 2 +- src/web/pages/accounts.edit-panel.test.tsx | 8 +-- .../pages/accounts.mobile-actions.test.tsx | 4 +- .../accounts.proxy-only-expired.test.tsx | 2 +- .../accounts.rebind-panel-focus.test.tsx | 2 +- .../accounts.refresh-reload-on-error.test.tsx | 2 +- .../accounts.segmented-connections.test.tsx | 4 +- src/web/pages/accounts.tokens-header.test.tsx | 2 +- .../pages/accounts.verify-feedback.test.tsx | 2 +- .../pages/dashboard.performance-card.test.tsx | 32 +++++----- .../dashboard.site-observability.test.tsx | 2 +- .../dashboard.site-speed-button.test.tsx | 4 +- src/web/pages/dashboardHookOrder.test.tsx | 2 +- .../downstreamKeys.mobile-layout.test.tsx | 2 +- src/web/pages/helpers/sitesEditor.test.ts | 14 +++-- src/web/pages/models.mobile-layout.test.tsx | 4 +- src/web/pages/programLogs.mobile.test.tsx | 2 +- src/web/pages/settings.factory-reset.test.tsx | 2 +- .../settings.schedule-log-cleanup.test.tsx | 6 +- src/web/pages/settings.system-proxy.test.tsx | 4 +- src/web/pages/siteAnnouncements.test.tsx | 2 +- src/web/pages/sites.create-redirect.test.tsx | 2 +- .../pages/sites.disabled-models-save.test.tsx | 24 ++++---- src/web/pages/sites.edit-scroll.test.tsx | 4 +- src/web/pages/sites.mobile-actions.test.tsx | 2 +- .../pages/sites.system-proxy-bulk.test.tsx | 4 +- src/web/pages/token-routes/types.ts | 1 + .../pages/token-routes/useRouteChannels.ts | 5 +- .../pages/tokenRoutes.group-collapse.test.tsx | 46 +++++++------- .../pages/tokenRoutes.mobile-layout.test.tsx | 4 +- src/web/pages/tokenRoutes.mobile.test.tsx | 2 +- .../tokenRoutes.refresh-decision.test.tsx | 2 +- .../tokenRoutes.routing-strategy.test.tsx | 4 +- .../pages/tokenRoutes.snapshot-load.test.tsx | 2 +- src/web/pages/tokens.batch-actions.test.tsx | 4 +- src/web/pages/tokens.edit-and-select.test.tsx | 10 +-- src/web/pages/tokens.focus.test.tsx | 2 +- src/web/pages/tokens.mobile-actions.test.tsx | 2 +- src/web/pages/tokens.redirect.test.tsx | 2 +- src/web/test-types.d.ts | 1 + tsconfig.web.json | 8 +++ tsconfig.web.test.json | 7 +++ 69 files changed, 335 insertions(+), 222 deletions(-) create mode 100644 src/web/test-types.d.ts create mode 100644 tsconfig.web.json create mode 100644 tsconfig.web.test.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8569640..9dfe41b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -138,6 +138,28 @@ jobs: - name: Build desktop run: npm run build:desktop + typecheck: + name: Typecheck + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + + - name: Install dependencies + run: npm ci --prefer-offline --no-audit --no-fund + + - name: Run typecheck + run: npm run typecheck + schema-sqlite: name: Schema Check (SQLite) if: github.event_name == 'pull_request' diff --git a/package-lock.json b/package-lock.json index 30ba000..3c1c3d1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,6 +43,7 @@ "@types/pg": "^8.15.6", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", + "@types/react-test-renderer": "^19.1.0", "@vitejs/plugin-react": "^4.3.4", "concurrently": "^9.1.0", "cross-env": "^7.0.3", @@ -66,7 +67,7 @@ "wait-on": "^9.0.4" }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=22.15.0" } }, "node_modules/@algolia/abtesting": { @@ -230,6 +231,7 @@ "integrity": "sha512-Nt9hri7nbOo0RipAsGjIssHkpLMHHN/P7QqENywAq5TLsoYDzUyJGny8FEiD/9KJUxtGH8blGpMedilI6kK3rA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@algolia/client-common": "5.49.1", "@algolia/requester-browser-xhr": "5.49.1", @@ -443,6 +445,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -846,6 +849,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" }, @@ -894,6 +898,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" } @@ -922,6 +927,7 @@ "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -967,6 +973,7 @@ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "license": "MIT", + "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -1456,7 +1463,6 @@ "dev": true, "license": "BSD-2-Clause", "optional": true, - "peer": true, "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", @@ -1478,7 +1484,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -1495,7 +1500,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "universalify": "^2.0.0" }, @@ -1510,7 +1514,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">= 10.0.0" } @@ -4679,6 +4682,7 @@ "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/node": "*" } @@ -5088,6 +5092,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz", "integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -5115,6 +5120,7 @@ "integrity": "sha512-gT+oueVQkqnj6ajGJXblFR4iavIXWsGAFCk3dP4Kki5+a9R4NMt0JARdk6s8cUKcfUoqP5dAtDSLU8xYUTFV+Q==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/node": "*", "pg-protocol": "*", @@ -5146,6 +5152,7 @@ "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -5161,6 +5168,16 @@ "@types/react": "^18.0.0" } }, + "node_modules/@types/react-test-renderer": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-19.1.0.tgz", + "integrity": "sha512-XD0WZrHqjNrxA/MaR9O22w/RNidWR9YZmBdRGI7wcnWGrv/3dA8wKCJ8m63Sn+tLJhcjmuhOi629N66W6kgWzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/responselike": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", @@ -5883,6 +5900,7 @@ "integrity": "sha512-X3Pp2aRQhg4xUC6PQtkubn5NpRKuUPQ9FPDQlx36SmpFwwH2N0/tw4c+NXV3nw3PsgeUs+BuWGP0gjz3TvENLQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@algolia/abtesting": "1.15.1", "@algolia/client-abtesting": "5.49.1", @@ -6317,6 +6335,7 @@ "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", @@ -6371,6 +6390,7 @@ "integrity": "sha512-RxD2Vd96sQDjQr20kdP+F+dK/1OUNiVOl200vKBZY8u0vTwysfolF6Hq+3ZK2+h8My9YvZhHsF+RSGZW2VYrPQ==", "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" @@ -6460,6 +6480,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -6863,6 +6884,7 @@ "integrity": "sha512-opLQzEVriiH1uUQ4Kctsd49bRoFDXGGSC4GUqj7pGyxM3RehRhvTlZJc1FL/Flew2p5uwxa1tUDWKzI4wNM8pg==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@chevrotain/cst-dts-gen": "11.1.2", "@chevrotain/gast": "11.1.2", @@ -7218,8 +7240,7 @@ "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", "dev": true, "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/cross-env": { "version": "7.0.3", @@ -7282,6 +7303,7 @@ "integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10" } @@ -7715,6 +7737,7 @@ "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "dev": true, "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -8170,6 +8193,7 @@ "integrity": "sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "app-builder-lib": "26.8.1", "builder-util": "26.8.1", @@ -8765,7 +8789,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", @@ -8786,7 +8809,6 @@ "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", @@ -9407,6 +9429,7 @@ "integrity": "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "tabbable": "^6.4.0" } @@ -11500,7 +11523,6 @@ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "minimist": "^1.2.6" }, @@ -11545,6 +11567,7 @@ "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.20.0.tgz", "integrity": "sha512-eCLUs7BNbgA6nf/MZXsaBO1SfGs0LtLVrJD3WeWq+jPLDWkSufTD+aGMwykfUVPdZnblaUK1a8G/P63cl9FkKg==", "license": "MIT", + "peer": true, "dependencies": { "aws-ssl-profiles": "^1.1.2", "denque": "^2.1.0", @@ -12113,6 +12136,7 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", "license": "MIT", + "peer": true, "dependencies": { "pg-connection-string": "^2.12.0", "pg-pool": "^3.13.0", @@ -12210,6 +12234,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -12390,7 +12415,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "commander": "^9.4.0" }, @@ -12408,7 +12432,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": "^12.20.0 || >=14" } @@ -12596,6 +12619,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -12608,6 +12632,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -12896,7 +12921,6 @@ "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "glob": "^7.1.3" }, @@ -12909,8 +12933,7 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/rimraf/node_modules/brace-expansion": { "version": "1.1.12", @@ -12918,7 +12941,6 @@ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -12931,7 +12953,6 @@ "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -12953,7 +12974,6 @@ "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "brace-expansion": "^1.1.7" }, @@ -13953,7 +13973,6 @@ "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" @@ -14765,6 +14784,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -15018,6 +15038,7 @@ "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -16675,6 +16696,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -16790,6 +16812,7 @@ "integrity": "sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.29", "@vue/compiler-sfc": "3.5.29", diff --git a/package.json b/package.json index c5f3f4d..6b98320 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,11 @@ "build:server": "tsc -p tsconfig.server.json && tsx scripts/dev/copy-runtime-db-generated.ts", "build:desktop": "tsc -p tsconfig.desktop.json", "build": "npm run build:web && npm run build:server && npm run build:desktop", + "typecheck:web": "tsc -p tsconfig.web.json", + "typecheck:web:test": "tsc -p tsconfig.web.test.json", + "typecheck:server": "tsc --noEmit -p tsconfig.server.json", + "typecheck:desktop": "tsc --noEmit -p tsconfig.desktop.json", + "typecheck": "npm run typecheck:web && npm run typecheck:web:test && npm run typecheck:server && npm run typecheck:desktop", "dist:desktop": "npm run build && electron-builder --config electron-builder.yml --publish never", "dist:desktop:mac:intel": "npm run build && electron-builder --config electron-builder.yml --publish never --mac --x64", "package:desktop": "npm run desktop:icons && electron-builder --config electron-builder.yml --publish never", @@ -93,6 +98,7 @@ "@types/pg": "^8.15.6", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", + "@types/react-test-renderer": "^19.1.0", "@vitejs/plugin-react": "^4.3.4", "concurrently": "^9.1.0", "cross-env": "^7.0.3", diff --git a/src/web/App.mobile-layout.test.tsx b/src/web/App.mobile-layout.test.tsx index 464744d..ce5662f 100644 --- a/src/web/App.mobile-layout.test.tsx +++ b/src/web/App.mobile-layout.test.tsx @@ -155,7 +155,7 @@ describe('App mobile layout', () => { 'uses the shared breakpoint at width $width', async ({ width, expectedLayout, hasHamburger }) => { setupRuntime(width); - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { diff --git a/src/web/App.sidebar-mobile.test.tsx b/src/web/App.sidebar-mobile.test.tsx index 7280c3b..8e224ac 100644 --- a/src/web/App.sidebar-mobile.test.tsx +++ b/src/web/App.sidebar-mobile.test.tsx @@ -151,7 +151,7 @@ describe('App mobile sidebar', () => { it('opens the mobile drawer from the hamburger trigger and exposes the close affordance', async () => { setupRuntime(768); - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { diff --git a/src/web/components/ModelAnalysisPanel.test.tsx b/src/web/components/ModelAnalysisPanel.test.tsx index c467314..6abc28c 100644 --- a/src/web/components/ModelAnalysisPanel.test.tsx +++ b/src/web/components/ModelAnalysisPanel.test.tsx @@ -42,7 +42,7 @@ describe('ModelAnalysisPanel token summaries', () => { }); it('renders total token summaries with compact units', () => { - let root: ReturnType | null = null; + let root!: WebTestRenderer; act(() => { root = create( @@ -70,10 +70,10 @@ describe('ModelAnalysisPanel token summaries', () => { globalThis.document = { documentElement: {}, } as unknown as Document; - delete (globalThis as typeof globalThis & { getComputedStyle?: typeof getComputedStyle }).getComputedStyle; - delete (globalThis as typeof globalThis & { MutationObserver?: typeof MutationObserver }).MutationObserver; + Reflect.deleteProperty(globalThis as typeof globalThis & Record, 'getComputedStyle'); + Reflect.deleteProperty(globalThis as typeof globalThis & Record, 'MutationObserver'); - let root: ReturnType | null = null; + let root!: WebTestRenderer; await expect(act(async () => { root = create( diff --git a/src/web/components/centered-modal.test.tsx b/src/web/components/centered-modal.test.tsx index 5ffcf46..4a25cfc 100644 --- a/src/web/components/centered-modal.test.tsx +++ b/src/web/components/centered-modal.test.tsx @@ -7,7 +7,7 @@ import CenteredModal from './CenteredModal.js'; describe('CenteredModal component', () => { it('does not close on backdrop click by default and exposes an explicit close button', async () => { const onClose = vi.fn(); - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { diff --git a/src/web/components/charts/SiteDistributionChart.test.tsx b/src/web/components/charts/SiteDistributionChart.test.tsx index 3e746f1..4486a89 100644 --- a/src/web/components/charts/SiteDistributionChart.test.tsx +++ b/src/web/components/charts/SiteDistributionChart.test.tsx @@ -17,8 +17,8 @@ describe('SiteDistributionChart', () => { getAttribute: vi.fn(), }, } as unknown as Document; - delete (globalThis as typeof globalThis & { getComputedStyle?: typeof getComputedStyle }).getComputedStyle; - delete (globalThis as typeof globalThis & { MutationObserver?: typeof MutationObserver }).MutationObserver; + Reflect.deleteProperty(globalThis as typeof globalThis & Record, 'getComputedStyle'); + Reflect.deleteProperty(globalThis as typeof globalThis & Record, 'MutationObserver'); }); afterEach(() => { @@ -28,7 +28,7 @@ describe('SiteDistributionChart', () => { }); it('renders with fallback label color when browser theme APIs are unavailable', async () => { - let renderer: ReturnType | null = null; + let renderer!: WebTestRenderer; await expect(act(async () => { renderer = create( @@ -46,6 +46,6 @@ describe('SiteDistributionChart', () => { ); })).resolves.toBeUndefined(); - renderer?.unmount(); + renderer.unmount(); }); }); diff --git a/src/web/components/charts/SiteDistributionChart.tsx b/src/web/components/charts/SiteDistributionChart.tsx index 0e91764..a652dad 100644 --- a/src/web/components/charts/SiteDistributionChart.tsx +++ b/src/web/components/charts/SiteDistributionChart.tsx @@ -17,6 +17,10 @@ interface SiteDistributionChartProps { type ViewMode = 'balance' | 'spend'; +function coerceDatumRecord(datum: unknown): Record { + return datum && typeof datum === 'object' ? datum as Record : {}; +} + function safeNumber(value: unknown): number { if (typeof value !== 'number' || Number.isNaN(value) || !Number.isFinite(value)) return 0; return value; @@ -120,24 +124,32 @@ export default function SiteDistributionChart({ data, loading }: SiteDistributio mark: { content: [ { - key: (datum: Record) => datum.siteName as string, - value: (datum: Record) => { - const val = safeNumber(datum.value); + key: (datum: unknown) => { + const item = coerceDatumRecord(datum); + return String(item.siteName || '-'); + }, + value: (datum: unknown) => { + const item = coerceDatumRecord(datum); + const val = safeNumber(item.value); return `$${val.toFixed(2)}`; }, }, { key: '占比', - value: (datum: Record) => { - const pct = datum._percent_ as number; + value: (datum: unknown) => { + const item = coerceDatumRecord(datum); + const pct = safeNumber(item._percent_); return `${safeNumber(pct).toFixed(1)}%`; }, }, { key: '账户数', - value: (datum: Record) => `${datum.accountCount}`, + value: (datum: unknown) => { + const item = coerceDatumRecord(datum); + return String(item.accountCount || 0); + }, }, - ], + ] as any, }, }, color: PIE_COLORS, diff --git a/src/web/components/mobile-drawer.test.tsx b/src/web/components/mobile-drawer.test.tsx index c251945..bdf94fc 100644 --- a/src/web/components/mobile-drawer.test.tsx +++ b/src/web/components/mobile-drawer.test.tsx @@ -11,7 +11,7 @@ vi.mock('react-dom', () => ({ describe('MobileDrawer', () => { it('renders content, locks body scroll, and exposes explicit close affordances', async () => { const onClose = vi.fn(); - let root: ReturnType | null = null; + let root!: WebTestRenderer; vi.stubGlobal('document', { body: { style: { diff --git a/src/web/components/search-modal.results.test.tsx b/src/web/components/search-modal.results.test.tsx index 539fcd2..8970235 100644 --- a/src/web/components/search-modal.results.test.tsx +++ b/src/web/components/search-modal.results.test.tsx @@ -92,7 +92,7 @@ describe('SearchModal results', () => { }); const onClose = vi.fn(); - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = create( diff --git a/src/web/components/useIsMobile.test.tsx b/src/web/components/useIsMobile.test.tsx index 23534e2..435955f 100644 --- a/src/web/components/useIsMobile.test.tsx +++ b/src/web/components/useIsMobile.test.tsx @@ -22,7 +22,7 @@ describe('useIsMobile', () => { removeEventListener: (event: string, handler: () => void) => { listeners.get(event)?.delete(handler); }, - dispatchEvent: (event: { type: string }) => { + dispatchEvent: (event: Event) => { listeners.get(event.type)?.forEach((handler) => handler()); return true; }, @@ -39,7 +39,7 @@ describe('useIsMobile', () => { innerWidth: 767, }); - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { @@ -66,7 +66,7 @@ describe('useIsMobile', () => { }), }); - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { @@ -84,7 +84,7 @@ describe('useIsMobile', () => { }); it('treats 767 and 768 as mobile, but 769 as desktop', async () => { - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { @@ -97,7 +97,7 @@ describe('useIsMobile', () => { widthRef.current = 767; window.innerWidth = 767; await act(async () => { - window.dispatchEvent({ type: 'resize' }); + window.dispatchEvent(new Event('resize')); }); expect(root.root.findByType('div').props['data-mobile']).toBe('true'); @@ -105,7 +105,7 @@ describe('useIsMobile', () => { widthRef.current = 768; window.innerWidth = 768; await act(async () => { - window.dispatchEvent({ type: 'resize' }); + window.dispatchEvent(new Event('resize')); }); expect(root.root.findByType('div').props['data-mobile']).toBe('true'); @@ -113,7 +113,7 @@ describe('useIsMobile', () => { widthRef.current = 769; window.innerWidth = 769; await act(async () => { - window.dispatchEvent({ type: 'resize' }); + window.dispatchEvent(new Event('resize')); }); expect(root.root.findByType('div').props['data-mobile']).toBe('false'); diff --git a/src/web/main.tsx b/src/web/main.tsx index ca39a51..7998f04 100644 --- a/src/web/main.tsx +++ b/src/web/main.tsx @@ -6,7 +6,7 @@ import './index.css'; ReactDOM.createRoot(document.getElementById('root')!).render( - + diff --git a/src/web/pages/DownstreamKeys.mobile.test.tsx b/src/web/pages/DownstreamKeys.mobile.test.tsx index 14ee8f3..514b101 100644 --- a/src/web/pages/DownstreamKeys.mobile.test.tsx +++ b/src/web/pages/DownstreamKeys.mobile.test.tsx @@ -190,7 +190,7 @@ describe('DownstreamKeys mobile layout', () => { }); it('renders mobile cards and supports select-all-visible batch actions', async () => { - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { diff --git a/src/web/pages/DownstreamKeys.test.tsx b/src/web/pages/DownstreamKeys.test.tsx index 0ff7256..73bfb36 100644 --- a/src/web/pages/DownstreamKeys.test.tsx +++ b/src/web/pages/DownstreamKeys.test.tsx @@ -165,7 +165,7 @@ afterEach(() => { describe('DownstreamKeys page', () => { it('loads management data and renders merged row content', async () => { - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = create( @@ -193,7 +193,7 @@ describe('DownstreamKeys page', () => { expect(text).toContain('主分组'); expect(text).toContain('移动端'); } finally { - root?.unmount(); + root.unmount(); } }); @@ -213,7 +213,7 @@ describe('DownstreamKeys page', () => { ], }); - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = create( @@ -236,7 +236,7 @@ describe('DownstreamKeys page', () => { const select = root!.root.findAllByType('select').find((node) => collectText(node).includes('仅禁用')); await act(async () => { - select.props.onChange({ target: { value: 'disabled' } }); + select!.props.onChange({ target: { value: 'disabled' } }); }); await flushMicrotasks(); expect(collectText(root!.root)).toContain('batch-key'); @@ -247,7 +247,7 @@ describe('DownstreamKeys page', () => { }); it('supports create flow and drawer trend loading', async () => { - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = create( @@ -327,7 +327,7 @@ describe('DownstreamKeys page', () => { ], }); - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = create( @@ -415,7 +415,7 @@ describe('DownstreamKeys page', () => { }) .mockImplementationOnce(() => new Promise(() => {})); - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = create( @@ -447,12 +447,12 @@ describe('DownstreamKeys page', () => { expect(collectText(root!.root)).not.toContain('固定窗口对比'); expect(collectText(root!.root)).not.toContain('trend:2'); } finally { - root?.unmount(); + root.unmount(); } }); it('separates exact models from group routes in advanced config and uses single-column layout', async () => { - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = create( @@ -503,7 +503,7 @@ describe('DownstreamKeys page', () => { { id: 14, modelPattern: 'claude-opus-4-6', displayName: 'Claude Opus 4.6', enabled: true }, ]); - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = create( @@ -570,7 +570,7 @@ describe('DownstreamKeys page', () => { }); it('uses backend batch api for selected rows', async () => { - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = create( @@ -606,7 +606,7 @@ describe('DownstreamKeys page', () => { }); it('supports group and tag editing plus batch metadata update', async () => { - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = create( diff --git a/src/web/pages/ImportExport.test.tsx b/src/web/pages/ImportExport.test.tsx index 0b9677b..5e32155 100644 --- a/src/web/pages/ImportExport.test.tsx +++ b/src/web/pages/ImportExport.test.tsx @@ -283,7 +283,7 @@ describe('ImportExport', () => { }); it('shows ALL-API-Hub V2 preview counts and ignored sections', async () => { - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = create( @@ -309,7 +309,7 @@ describe('ImportExport', () => { }); it('does not label native metapi backups as ALL-API-Hub V2', async () => { - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = create( @@ -335,7 +335,7 @@ describe('ImportExport', () => { it('uses backend import summary in the completion toast', async () => { const confirmSpy = vi.mocked((window as { confirm: (...args: unknown[]) => boolean }).confirm); - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { @@ -385,7 +385,7 @@ describe('ImportExport', () => { }); it('loads webdav config and saves updates from the import/export page', async () => { - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { @@ -422,7 +422,7 @@ describe('ImportExport', () => { }); it('shows v2.1 config-backup wording and local-state notice', async () => { - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { @@ -445,7 +445,7 @@ describe('ImportExport', () => { }); it('renders webdav export type with ModernSelect instead of a native select', async () => { - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { @@ -493,7 +493,7 @@ describe('ImportExport', () => { }); it('disables webdav actions while config has unsaved changes', async () => { - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { @@ -552,7 +552,7 @@ describe('ImportExport', () => { }, }); - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { diff --git a/src/web/pages/ModelTester.tsx b/src/web/pages/ModelTester.tsx index e017b27..48a6943 100644 --- a/src/web/pages/ModelTester.tsx +++ b/src/web/pages/ModelTester.tsx @@ -41,12 +41,14 @@ import { type DebugTab, type ModelTesterInputs, type ModelTesterModeState, - type ParameterEnabled, - type PlaygroundMode, - type PlaygroundMultipartFile, - type TestTargetFormat, - type TestChatPayload, -} from './helpers/modelTesterSession.js'; + type ParameterEnabled, + type PlaygroundMode, + type PlaygroundProtocol, + type PlaygroundMultipartFile, + type ProxyTestEnvelope, + type TestTargetFormat, + type TestChatPayload, + } from './helpers/modelTesterSession.js'; import { buildConversationFileAccept, buildConversationFileHint, diff --git a/src/web/pages/Models.marketplace-text.test.tsx b/src/web/pages/Models.marketplace-text.test.tsx index 6e642e1..e3d46f0 100644 --- a/src/web/pages/Models.marketplace-text.test.tsx +++ b/src/web/pages/Models.marketplace-text.test.tsx @@ -82,7 +82,7 @@ describe('Models marketplace text', () => { }); it('renders readable Chinese labels and fallback descriptions for marketplace models', async () => { - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { @@ -172,7 +172,7 @@ describe('Models marketplace text', () => { ], }); - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { @@ -213,7 +213,7 @@ describe('Models marketplace text', () => { globalThis.window = nextWindow; globalThis.matchMedia = nextWindow.matchMedia; - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { @@ -241,7 +241,7 @@ describe('Models marketplace text', () => { } as unknown as Window & typeof globalThis; apiMock.getModelsMarketplace.mockImplementation(() => new Promise(() => {})); - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { @@ -331,7 +331,7 @@ describe('Models marketplace text', () => { ], }); - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { @@ -470,7 +470,7 @@ describe('Models marketplace text', () => { ], }); - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { @@ -547,7 +547,7 @@ describe('Models marketplace text', () => { ], }); - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { diff --git a/src/web/pages/NotificationSettings.test.tsx b/src/web/pages/NotificationSettings.test.tsx index bfa8c0b..b12e3de 100644 --- a/src/web/pages/NotificationSettings.test.tsx +++ b/src/web/pages/NotificationSettings.test.tsx @@ -68,7 +68,7 @@ describe('NotificationSettings', () => { }); it('loads and saves telegram api base url and topic id', async () => { - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = create( @@ -139,7 +139,7 @@ describe('NotificationSettings', () => { notifyCooldownSec: 300, }); - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = create( diff --git a/src/web/pages/OAuthManagement.test.tsx b/src/web/pages/OAuthManagement.test.tsx index b3de514..1fbfcbe 100644 --- a/src/web/pages/OAuthManagement.test.tsx +++ b/src/web/pages/OAuthManagement.test.tsx @@ -108,7 +108,7 @@ describe('OAuthManagement page', () => { offset: 0, }); - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = create( @@ -206,7 +206,7 @@ describe('OAuthManagement page', () => { accountId: 7, }); - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = create( @@ -304,7 +304,7 @@ describe('OAuthManagement page', () => { }); apiMock.submitOAuthManualCallback.mockResolvedValue({ success: true }); - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = create( @@ -398,7 +398,7 @@ describe('OAuthManagement page', () => { offset: 0, }); - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = create( @@ -464,7 +464,7 @@ describe('OAuthManagement page', () => { offset: 0, }); - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = create( @@ -526,7 +526,7 @@ describe('OAuthManagement page', () => { status: 'pending', }); - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = create( @@ -618,7 +618,7 @@ describe('OAuthManagement page', () => { status: 'pending', }); - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = create( @@ -717,7 +717,7 @@ describe('OAuthManagement page', () => { .mockResolvedValueOnce({ items: [], total: 0, limit: 100, offset: 0 }); apiMock.deleteOAuthConnection.mockResolvedValue({ success: true }); - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = create( @@ -860,7 +860,7 @@ describe('OAuthManagement page', () => { }, }); - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = create( diff --git a/src/web/pages/ProgramLogs.status.test.tsx b/src/web/pages/ProgramLogs.status.test.tsx index c30a20b..321b5d9 100644 --- a/src/web/pages/ProgramLogs.status.test.tsx +++ b/src/web/pages/ProgramLogs.status.test.tsx @@ -54,7 +54,7 @@ describe('ProgramLogs status label', () => { }, ]); - let root: ReturnType | null = null; + let root!: WebTestRenderer; await act(async () => { root = create( @@ -90,7 +90,7 @@ describe('ProgramLogs status label', () => { }, ]); - let root: ReturnType | null = null; + let root!: WebTestRenderer; await act(async () => { root = create( diff --git a/src/web/pages/ProxyLogs.server-driven.test.tsx b/src/web/pages/ProxyLogs.server-driven.test.tsx index 20a44e2..d6e0f32 100644 --- a/src/web/pages/ProxyLogs.server-driven.test.tsx +++ b/src/web/pages/ProxyLogs.server-driven.test.tsx @@ -158,7 +158,7 @@ describe('ProxyLogs server-driven page', () => { }); it('requests paginated data from the server and renders server summary counts', async () => { - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { @@ -194,7 +194,7 @@ describe('ProxyLogs server-driven page', () => { }); it('keeps the model badge sized to the model name in desktop rows', async () => { - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { @@ -250,7 +250,7 @@ describe('ProxyLogs server-driven page', () => { ], })); - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { @@ -277,7 +277,7 @@ describe('ProxyLogs server-driven page', () => { }); it('re-queries the server for status, client, and search changes instead of filtering locally', async () => { - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { @@ -342,7 +342,7 @@ describe('ProxyLogs server-driven page', () => { }); it('loads detail on first expand and reuses the cached detail on re-expand', async () => { - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { @@ -385,7 +385,7 @@ describe('ProxyLogs server-driven page', () => { }); it('hydrates site and time filters from the route query', async () => { - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { diff --git a/src/web/pages/ProxyLogs.tsx b/src/web/pages/ProxyLogs.tsx index 477d4d5..814b607 100644 --- a/src/web/pages/ProxyLogs.tsx +++ b/src/web/pages/ProxyLogs.tsx @@ -34,6 +34,12 @@ type ProxyLogDetailState = { error?: string; }; +type ProxyLogSiteFilterOption = { + id: number; + name: string; + status: string | null; +}; + const PAGE_SIZES = [20, 50, 100]; const DEFAULT_PAGE_SIZE = 50; const PROXY_LOG_CLIENT_FAMILY_LABELS: Record = { @@ -353,14 +359,17 @@ export default function ProxyLogs() { try { const result = await api.getSites(); const rows = Array.isArray(result) ? result : (result?.sites || []); - const normalized = rows + const normalized: ProxyLogSiteFilterOption[] = rows .map((site: any) => ({ id: Number(site?.id || 0), name: String(site?.name || '').trim() || `站点 #${site?.id ?? ''}`, status: typeof site?.status === 'string' ? site.status : null, })) - .filter((site) => site.id > 0) - .sort((left, right) => left.name.localeCompare(right.name, 'zh-CN')); + .filter((site: ProxyLogSiteFilterOption) => site.id > 0) + .sort( + (left: ProxyLogSiteFilterOption, right: ProxyLogSiteFilterOption) => + left.name.localeCompare(right.name, 'zh-CN'), + ); if (!cancelled) setSites(normalized); } catch (error) { console.error('Failed to load sites for proxy log filters:', error); @@ -701,7 +710,7 @@ export default function ProxyLogs() { const detailState = detailById[log.id]; const detail = detailState?.data; const detailLog: ProxyLogRenderItem = detail ? { ...log, ...detail } : log; - const pathMeta = parseProxyLogPathMeta(detailLog.errorMessage); + const pathMeta = parseProxyLogPathMeta(detailLog.errorMessage ?? undefined); const billingDetailSummary = detail ? formatBillingDetailSummary(detailLog) : null; const billingProcessLines = detail ? buildBillingProcessLines(detailLog) : []; const downstreamKeySummary = renderDownstreamKeySummary(detailLog); @@ -808,7 +817,7 @@ export default function ProxyLogs() { const detailState = detailById[log.id]; const detail = detailState?.data; const detailLog: ProxyLogRenderItem = detail ? { ...log, ...detail } : log; - const pathMeta = parseProxyLogPathMeta(detailLog.errorMessage); + const pathMeta = parseProxyLogPathMeta(detailLog.errorMessage ?? undefined); const billingDetailSummary = detail ? formatBillingDetailSummary(detailLog) : null; const billingProcessLines = detail ? buildBillingProcessLines(detailLog) : []; const downstreamKeySummary = renderDownstreamKeySummary(detailLog); diff --git a/src/web/pages/SiteAnnouncements.tsx b/src/web/pages/SiteAnnouncements.tsx index 6332429..4a6bbaf 100644 --- a/src/web/pages/SiteAnnouncements.tsx +++ b/src/web/pages/SiteAnnouncements.tsx @@ -43,7 +43,7 @@ export default function SiteAnnouncements() { const [serverTimeZone, setServerTimeZone] = useState(undefined); const [highlightAnnouncementId, setHighlightAnnouncementId] = useState(null); const rowRefs = useRef>(new Map()); - const highlightTimerRef = useRef(null); + const highlightTimerRef = useRef | null>(null); const viewerTimeZone = useMemo(() => readClientTimeZone(), []); const displayTimeZone = resolveSiteAnnouncementTimeZone(viewerTimeZone, serverTimeZone); diff --git a/src/web/pages/TokenRoutes.tsx b/src/web/pages/TokenRoutes.tsx index 8bdfff9..cb16311 100644 --- a/src/web/pages/TokenRoutes.tsx +++ b/src/web/pages/TokenRoutes.tsx @@ -238,7 +238,8 @@ export default function TokenRoutes() { if (candidatesPromiseRef.current && !force) return; const seq = ++candidatesSeqRef.current; candidatesLoadedRef.current = true; - const promise = (async () => { + let promise!: Promise; + promise = (async () => { try { const candidateRows = await api.getModelTokenCandidates(); if (candidatesSeqRef.current !== seq) return; // stale diff --git a/src/web/pages/Tokens.tsx b/src/web/pages/Tokens.tsx index bdf6ec4..6946ab9 100644 --- a/src/web/pages/Tokens.tsx +++ b/src/web/pages/Tokens.tsx @@ -44,6 +44,20 @@ type AccountTokenSyncResult = { }; }; +type SyncableAccount = { + id: number; + username?: string | null; + accessToken?: string | null; + status?: string | null; + credentialMode?: string | null; + capabilities?: { + proxyOnly?: boolean; + } | null; + site?: { + status?: string | null; + name?: string | null; + } | null; +}; const isAccountSyncable = (account: any) => resolveAccountCredentialMode(account) === 'session' && account?.status === 'active' @@ -162,11 +176,11 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token const nextTokens = tokenRows || []; setTokens(nextTokens); setSelectedTokenIds((current) => current.filter((id) => nextTokens.some((token: any) => token.id === id))); - const latestAccounts = accountRows || []; + const latestAccounts: SyncableAccount[] = Array.isArray(accountRows) ? accountRows : []; setAccounts(latestAccounts); const syncableAccounts = latestAccounts.filter(isAccountSyncable); - const hasCurrentSelected = syncableAccounts.some((account) => account.id === syncingAccountId); + const hasCurrentSelected = syncableAccounts.some((account: SyncableAccount) => account.id === syncingAccountId); if (!hasCurrentSelected) { setSyncingAccountId(syncableAccounts[0]?.id || 0); } @@ -209,7 +223,7 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token api.getAccountTokenGroups(form.accountId) .then((res: any) => { if (cancelled) return; - const groups = Array.isArray(res?.groups) + const groups: string[] = Array.isArray(res?.groups) ? res.groups.map((item: any) => String(item || '').trim()).filter(Boolean) : []; const normalized = Array.from(new Set(groups)); diff --git a/src/web/pages/accounts.batch-actions.test.tsx b/src/web/pages/accounts.batch-actions.test.tsx index 8bbf97a..764c8ad 100644 --- a/src/web/pages/accounts.batch-actions.test.tsx +++ b/src/web/pages/accounts.batch-actions.test.tsx @@ -61,7 +61,7 @@ describe('Accounts batch actions', () => { }); it('refreshes balance for selected accounts through the batch toolbar', async () => { - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = create( @@ -97,7 +97,7 @@ describe('Accounts batch actions', () => { }); it('selects an account when clicking the row instead of only the checkbox', async () => { - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = create( @@ -147,7 +147,7 @@ describe('Accounts batch actions', () => { }, ]); - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = create( diff --git a/src/web/pages/accounts.create-intent.test.tsx b/src/web/pages/accounts.create-intent.test.tsx index f23c416..6199e3f 100644 --- a/src/web/pages/accounts.create-intent.test.tsx +++ b/src/web/pages/accounts.create-intent.test.tsx @@ -31,7 +31,7 @@ async function renderAccounts(initialEntry: string) { ]); apiMock.getAccountTokens.mockResolvedValue([]); - let root: ReturnType | null = null; + let root!: WebTestRenderer; await act(async () => { root = create( diff --git a/src/web/pages/accounts.edit-panel.test.tsx b/src/web/pages/accounts.edit-panel.test.tsx index 400b246..69a71f5 100644 --- a/src/web/pages/accounts.edit-panel.test.tsx +++ b/src/web/pages/accounts.edit-panel.test.tsx @@ -94,7 +94,7 @@ describe('Accounts edit panel', () => { }); it('opens edit panel from account row action', async () => { - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = create( @@ -140,7 +140,7 @@ describe('Accounts edit panel', () => { disabledCount: 0, }); - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = create( @@ -205,7 +205,7 @@ describe('Accounts edit panel', () => { return Promise.resolve({ siteId: accountId, siteName: 'Unknown', models: [] }); }); - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = create( @@ -269,7 +269,7 @@ describe('Accounts edit panel', () => { }); apiMock.rebuildRoutes.mockRejectedValue(new Error('rebuild failed')); - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = create( diff --git a/src/web/pages/accounts.mobile-actions.test.tsx b/src/web/pages/accounts.mobile-actions.test.tsx index 737d7f0..8f16ee0 100644 --- a/src/web/pages/accounts.mobile-actions.test.tsx +++ b/src/web/pages/accounts.mobile-actions.test.tsx @@ -80,7 +80,7 @@ describe('Accounts mobile actions', () => { }); it('supports select-all-visible from the mobile toolbar', async () => { - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = create( @@ -115,7 +115,7 @@ describe('Accounts mobile actions', () => { }); it('clears only the visible segment selection when toggling mobile select-all off', async () => { - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { apiMock.getAccounts.mockResolvedValue([ { diff --git a/src/web/pages/accounts.proxy-only-expired.test.tsx b/src/web/pages/accounts.proxy-only-expired.test.tsx index aa79639..ab58a05 100644 --- a/src/web/pages/accounts.proxy-only-expired.test.tsx +++ b/src/web/pages/accounts.proxy-only-expired.test.tsx @@ -62,7 +62,7 @@ describe('Accounts proxy-only expired state', () => { ]); apiMock.getSites.mockResolvedValue([{ id: 10, name: '小呆api', platform: 'new-api', status: 'active' }]); - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = create( diff --git a/src/web/pages/accounts.rebind-panel-focus.test.tsx b/src/web/pages/accounts.rebind-panel-focus.test.tsx index 52951b4..0d0369b 100644 --- a/src/web/pages/accounts.rebind-panel-focus.test.tsx +++ b/src/web/pages/accounts.rebind-panel-focus.test.tsx @@ -58,7 +58,7 @@ describe('Accounts rebind modal', () => { ]); apiMock.getSites.mockResolvedValue([{ id: 10, name: 'Demo Site', platform: 'new-api', status: 'active' }]); - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = create( diff --git a/src/web/pages/accounts.refresh-reload-on-error.test.tsx b/src/web/pages/accounts.refresh-reload-on-error.test.tsx index 7f594ba..439f77e 100644 --- a/src/web/pages/accounts.refresh-reload-on-error.test.tsx +++ b/src/web/pages/accounts.refresh-reload-on-error.test.tsx @@ -75,7 +75,7 @@ describe('Accounts refresh action', () => { apiMock.getSites.mockResolvedValue([{ id: 10, name: 'Demo Site', platform: 'new-api', status: 'active' }]); apiMock.refreshBalance.mockRejectedValueOnce(new Error('无权进行此操作,access token 无效')); - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = create( diff --git a/src/web/pages/accounts.segmented-connections.test.tsx b/src/web/pages/accounts.segmented-connections.test.tsx index 8c85675..1f3ae08 100644 --- a/src/web/pages/accounts.segmented-connections.test.tsx +++ b/src/web/pages/accounts.segmented-connections.test.tsx @@ -67,7 +67,7 @@ describe('Accounts segmented connections view', () => { ]); apiMock.getAccountTokens.mockResolvedValue([]); - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = create( @@ -116,7 +116,7 @@ describe('Accounts segmented connections view', () => { ]); apiMock.getAccountTokens.mockResolvedValue([]); - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = create( diff --git a/src/web/pages/accounts.tokens-header.test.tsx b/src/web/pages/accounts.tokens-header.test.tsx index f13137c..e7f8bfa 100644 --- a/src/web/pages/accounts.tokens-header.test.tsx +++ b/src/web/pages/accounts.tokens-header.test.tsx @@ -59,7 +59,7 @@ describe('Accounts tokens embedded header', () => { ]); apiMock.getAccountTokens.mockResolvedValue([]); - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = create( diff --git a/src/web/pages/accounts.verify-feedback.test.tsx b/src/web/pages/accounts.verify-feedback.test.tsx index 833f6a7..b126a65 100644 --- a/src/web/pages/accounts.verify-feedback.test.tsx +++ b/src/web/pages/accounts.verify-feedback.test.tsx @@ -53,7 +53,7 @@ describe('Accounts verify feedback', () => { ]); apiMock.verifyToken.mockRejectedValueOnce(new Error('Failed to fetch')); - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = create( diff --git a/src/web/pages/dashboard.performance-card.test.tsx b/src/web/pages/dashboard.performance-card.test.tsx index ac0e1e4..fd82601 100644 --- a/src/web/pages/dashboard.performance-card.test.tsx +++ b/src/web/pages/dashboard.performance-card.test.tsx @@ -74,7 +74,7 @@ describe('Dashboard performance stat card', () => { modelAnalysis: null, }); - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { @@ -107,14 +107,14 @@ describe('Dashboard performance stat card', () => { }); it('shows five skeleton stat cards while dashboard data is still loading', async () => { - let resolveDashboard: ((value: Record) => void) | null = null; + let resolveDashboard: ((value: Record) => void) | undefined; apiMock.getDashboard.mockImplementation(() => ( new Promise((resolve) => { resolveDashboard = resolve as (value: Record) => void; }) )); - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { @@ -133,18 +133,20 @@ describe('Dashboard performance stat card', () => { expect(statCards).toHaveLength(5); } finally { - resolveDashboard?.({ - totalBalance: 0, - totalUsed: 0, - todaySpend: 0, - todayReward: 0, - activeAccounts: 0, - totalAccounts: 0, - todayCheckin: { success: 0, total: 0 }, - proxy24h: { success: 0, total: 0, totalTokens: 0 }, - performance: { windowSeconds: 60, requestsPerMinute: 0, tokensPerMinute: 0 }, - modelAnalysis: null, - }); + if (resolveDashboard) { + resolveDashboard({ + totalBalance: 0, + totalUsed: 0, + todaySpend: 0, + todayReward: 0, + activeAccounts: 0, + totalAccounts: 0, + todayCheckin: { success: 0, total: 0 }, + proxy24h: { success: 0, total: 0, totalTokens: 0 }, + performance: { windowSeconds: 60, requestsPerMinute: 0, tokensPerMinute: 0 }, + modelAnalysis: null, + }); + } root?.unmount(); } }); diff --git a/src/web/pages/dashboard.site-observability.test.tsx b/src/web/pages/dashboard.site-observability.test.tsx index 3889d06..bb73742 100644 --- a/src/web/pages/dashboard.site-observability.test.tsx +++ b/src/web/pages/dashboard.site-observability.test.tsx @@ -85,7 +85,7 @@ describe('Dashboard site observability panel', () => { }); it('renders site availability strips and summary metrics', async () => { - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { diff --git a/src/web/pages/dashboard.site-speed-button.test.tsx b/src/web/pages/dashboard.site-speed-button.test.tsx index 9fe9a50..4aa926d 100644 --- a/src/web/pages/dashboard.site-speed-button.test.tsx +++ b/src/web/pages/dashboard.site-speed-button.test.tsx @@ -69,7 +69,7 @@ describe('Dashboard site speed buttons', () => { }); it('updates site speed status without imperative document lookups', async () => { - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { @@ -99,7 +99,7 @@ describe('Dashboard site speed buttons', () => { }); it('updates bulk site speed status without imperative document lookups', async () => { - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { diff --git a/src/web/pages/dashboardHookOrder.test.tsx b/src/web/pages/dashboardHookOrder.test.tsx index d738450..3127736 100644 --- a/src/web/pages/dashboardHookOrder.test.tsx +++ b/src/web/pages/dashboardHookOrder.test.tsx @@ -40,7 +40,7 @@ describe('Dashboard hook order', () => { }); it('keeps hook order stable when switching from loading to loaded render', async () => { - let root: ReturnType | null = null; + let root!: WebTestRenderer; let renderedTree = ''; const dashboardData = { totalBalance: 0, diff --git a/src/web/pages/downstreamKeys.mobile-layout.test.tsx b/src/web/pages/downstreamKeys.mobile-layout.test.tsx index 3990165..4ec6ae2 100644 --- a/src/web/pages/downstreamKeys.mobile-layout.test.tsx +++ b/src/web/pages/downstreamKeys.mobile-layout.test.tsx @@ -180,7 +180,7 @@ describe('DownstreamKeys mobile layout', () => { }); it('renders mobile cards and supports select-all-visible batch actions', async () => { - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { diff --git a/src/web/pages/helpers/sitesEditor.test.ts b/src/web/pages/helpers/sitesEditor.test.ts index d51a527..74b3271 100644 --- a/src/web/pages/helpers/sitesEditor.test.ts +++ b/src/web/pages/helpers/sitesEditor.test.ts @@ -72,7 +72,7 @@ describe('buildSiteSaveAction', () => { it('throws when edit mode has no site id', () => { expect(() => buildSiteSaveAction( - { mode: 'edit' }, + { mode: 'edit' } as unknown as Parameters[0], { name: 'site-c', url: 'https://c.example.com', @@ -88,10 +88,7 @@ describe('buildSiteSaveAction', () => { }); it('does not expose deprecated apiKey in site editor state', () => { - expect(emptySiteForm()).not.toHaveProperty('apiKey'); - expect(emptySiteForm().customHeaders).toEqual([emptySiteCustomHeader()]); - expect(emptySiteForm().proxyUrl).toBe(''); - expect(siteFormFromSite({ + const legacySite = { name: 'site-d', url: 'https://d.example.com', externalCheckinUrl: null, @@ -100,7 +97,12 @@ describe('buildSiteSaveAction', () => { customHeaders: '{"x-site-token":"alpha"}', globalWeight: 1, apiKey: 'sk-legacy-site-key', - })).not.toHaveProperty('apiKey'); + } as unknown as Parameters[0]; + + expect(emptySiteForm()).not.toHaveProperty('apiKey'); + expect(emptySiteForm().customHeaders).toEqual([emptySiteCustomHeader()]); + expect(emptySiteForm().proxyUrl).toBe(''); + expect(siteFormFromSite(legacySite)).not.toHaveProperty('apiKey'); expect(siteFormFromSite({ proxyUrl: 'http://127.0.0.1:8080', }).proxyUrl).toBe('http://127.0.0.1:8080'); diff --git a/src/web/pages/models.mobile-layout.test.tsx b/src/web/pages/models.mobile-layout.test.tsx index 875b76b..69d3a87 100644 --- a/src/web/pages/models.mobile-layout.test.tsx +++ b/src/web/pages/models.mobile-layout.test.tsx @@ -105,7 +105,7 @@ describe('Models mobile layout', () => { }); it('keeps a mobile filter entry and hides the table-view toggle on small screens', async () => { - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { @@ -130,7 +130,7 @@ describe('Models mobile layout', () => { }); it('renders stacked account detail cards instead of tables when a mobile card expands', async () => { - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { diff --git a/src/web/pages/programLogs.mobile.test.tsx b/src/web/pages/programLogs.mobile.test.tsx index 43566cc..0971fc2 100644 --- a/src/web/pages/programLogs.mobile.test.tsx +++ b/src/web/pages/programLogs.mobile.test.tsx @@ -68,7 +68,7 @@ describe('ProgramLogs mobile layout', () => { }); it('renders mobile cards instead of only the desktop table on small screens', async () => { - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { diff --git a/src/web/pages/settings.factory-reset.test.tsx b/src/web/pages/settings.factory-reset.test.tsx index 9e769b3..11161e6 100644 --- a/src/web/pages/settings.factory-reset.test.tsx +++ b/src/web/pages/settings.factory-reset.test.tsx @@ -116,7 +116,7 @@ describe('Settings factory reset', () => { }); it('shows a 3 second danger confirmation and clears local state after reset', async () => { - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = create( diff --git a/src/web/pages/settings.schedule-log-cleanup.test.tsx b/src/web/pages/settings.schedule-log-cleanup.test.tsx index b64112d..c5a26f8 100644 --- a/src/web/pages/settings.schedule-log-cleanup.test.tsx +++ b/src/web/pages/settings.schedule-log-cleanup.test.tsx @@ -77,7 +77,7 @@ describe('Settings log cleanup schedule', () => { }); it('saves schedule mode and interval fields together with other schedule settings', async () => { - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = create( @@ -119,7 +119,7 @@ describe('Settings log cleanup schedule', () => { it('triggers a one-off checkin from the schedule card', async () => { apiMock.triggerCheckinAll.mockResolvedValue({ success: true }); - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = create( @@ -150,7 +150,7 @@ describe('Settings log cleanup schedule', () => { }); it('renders schedule mode controls with modern selects and ghost action styling', async () => { - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = create( diff --git a/src/web/pages/settings.system-proxy.test.tsx b/src/web/pages/settings.system-proxy.test.tsx index c899d41..008bb97 100644 --- a/src/web/pages/settings.system-proxy.test.tsx +++ b/src/web/pages/settings.system-proxy.test.tsx @@ -87,7 +87,7 @@ describe('Settings system proxy', () => { }); it('saves system proxy url from settings', async () => { - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = create( @@ -127,7 +127,7 @@ describe('Settings system proxy', () => { }); it('tests system proxy from settings', async () => { - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = create( diff --git a/src/web/pages/siteAnnouncements.test.tsx b/src/web/pages/siteAnnouncements.test.tsx index e0522c4..29bced9 100644 --- a/src/web/pages/siteAnnouncements.test.tsx +++ b/src/web/pages/siteAnnouncements.test.tsx @@ -85,7 +85,7 @@ describe('SiteAnnouncements page', () => { }); it('loads announcements, highlights the focused row, and clears local rows', async () => { - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = create( diff --git a/src/web/pages/sites.create-redirect.test.tsx b/src/web/pages/sites.create-redirect.test.tsx index ff3774f..913cbf6 100644 --- a/src/web/pages/sites.create-redirect.test.tsx +++ b/src/web/pages/sites.create-redirect.test.tsx @@ -42,7 +42,7 @@ async function createSiteAndCollectLocation(createdSite: { id: number; platform? apiMock.getSites.mockResolvedValue([]); apiMock.addSite.mockResolvedValue(createdSite); - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = create( diff --git a/src/web/pages/sites.disabled-models-save.test.tsx b/src/web/pages/sites.disabled-models-save.test.tsx index 66ff774..3d69495 100644 --- a/src/web/pages/sites.disabled-models-save.test.tsx +++ b/src/web/pages/sites.disabled-models-save.test.tsx @@ -70,8 +70,8 @@ describe('Sites disabled models save', () => { vi.clearAllMocks(); }); - async function renderSitesEditor() { - let root: ReturnType | null = null; + async function renderSitesEditor(): Promise { + let root!: WebTestRenderer; await act(async () => { root = create( @@ -83,7 +83,7 @@ describe('Sites disabled models save', () => { }); await flushMicrotasks(); - const editButton = root.root.find((node) => ( + const editButton = root.root.find((node: ReactTestInstance) => ( node.type === 'button' && typeof node.props.onClick === 'function' && collectText(node).trim() === '编辑' @@ -100,7 +100,7 @@ describe('Sites disabled models save', () => { it('reports route rebuild failure after saving disabled models from sites editor', async () => { apiMock.rebuildRoutes.mockRejectedValue(new Error('rebuild failed')); - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { root = await renderSitesEditor(); @@ -129,11 +129,11 @@ describe('Sites disabled models save', () => { apiMock.getSiteAvailableModels.mockRejectedValue(new Error('available models failed')); apiMock.rebuildRoutes.mockResolvedValue({ success: true }); - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { root = await renderSitesEditor(); - const saveButton = root.root.find((node) => ( + const saveButton = root.root.find((node: ReactTestInstance) => ( node.type === 'button' && typeof node.props.onClick === 'function' && collectText(node).includes('保存禁用列表') @@ -146,18 +146,18 @@ describe('Sites disabled models save', () => { expect(apiMock.updateSiteDisabledModels).toHaveBeenCalledWith(1, ['gpt-4o']); } finally { - root?.unmount(); + root.unmount(); } }); it('allows adding a disabled model manually when no available models are discovered', async () => { apiMock.rebuildRoutes.mockResolvedValue({ success: true }); - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { root = await renderSitesEditor(); - const manualInput = root.root.find((node) => ( + const manualInput = root.root.find((node: ReactTestInstance) => ( node.type === 'input' && node.props.placeholder === '输入模型名称,如 gpt-4o' )); @@ -166,7 +166,7 @@ describe('Sites disabled models save', () => { manualInput.props.onChange({ target: { value: 'gpt-4o' } }); }); - const addButton = root.root.find((node) => ( + const addButton = root.root.find((node: ReactTestInstance) => ( node.type === 'button' && typeof node.props.onClick === 'function' && collectText(node).trim() === '添加模型' @@ -177,7 +177,7 @@ describe('Sites disabled models save', () => { }); await flushMicrotasks(); - const saveButton = root.root.find((node) => ( + const saveButton = root.root.find((node: ReactTestInstance) => ( node.type === 'button' && typeof node.props.onClick === 'function' && collectText(node).includes('保存禁用列表') @@ -190,7 +190,7 @@ describe('Sites disabled models save', () => { expect(apiMock.updateSiteDisabledModels).toHaveBeenCalledWith(1, ['gpt-4o']); } finally { - root?.unmount(); + root.unmount(); } }); }); diff --git a/src/web/pages/sites.edit-scroll.test.tsx b/src/web/pages/sites.edit-scroll.test.tsx index dc87e90..ce0cf6f 100644 --- a/src/web/pages/sites.edit-scroll.test.tsx +++ b/src/web/pages/sites.edit-scroll.test.tsx @@ -58,7 +58,7 @@ describe('Sites edit behavior', () => { value: scrollToMock, }); - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = create( @@ -91,7 +91,7 @@ describe('Sites edit behavior', () => { it('restores header add button label after closing add modal', async () => { apiMock.getSites.mockResolvedValue([]); - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = create( diff --git a/src/web/pages/sites.mobile-actions.test.tsx b/src/web/pages/sites.mobile-actions.test.tsx index 8ac15af..4ad9d5f 100644 --- a/src/web/pages/sites.mobile-actions.test.tsx +++ b/src/web/pages/sites.mobile-actions.test.tsx @@ -59,7 +59,7 @@ describe('Sites mobile actions', () => { }); it('supports select-all-visible and preserves the primary site url in mobile cards', async () => { - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = create( diff --git a/src/web/pages/sites.system-proxy-bulk.test.tsx b/src/web/pages/sites.system-proxy-bulk.test.tsx index d3fc75d..db1ab57 100644 --- a/src/web/pages/sites.system-proxy-bulk.test.tsx +++ b/src/web/pages/sites.system-proxy-bulk.test.tsx @@ -55,7 +55,7 @@ describe('Sites system proxy bulk actions', () => { }); it('sends selected site ids to enable system proxy', async () => { - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = create( @@ -92,7 +92,7 @@ describe('Sites system proxy bulk actions', () => { }); it('selects a site when clicking the row instead of only the checkbox', async () => { - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = create( diff --git a/src/web/pages/token-routes/types.ts b/src/web/pages/token-routes/types.ts index e2e55e8..f5c17b6 100644 --- a/src/web/pages/token-routes/types.ts +++ b/src/web/pages/token-routes/types.ts @@ -1,6 +1,7 @@ import type { ReactNode } from 'react'; import type { BrandInfo } from '../../components/BrandIcon.js'; import type { RouteDecision, RouteDecisionCandidate, RouteMode } from '../../../shared/tokenRouteContract.js'; +export type { RouteDecision, RouteDecisionCandidate, RouteMode } from '../../../shared/tokenRouteContract.js'; export type RouteSortBy = 'modelPattern' | 'channelCount'; export type RouteSortDir = 'asc' | 'desc'; diff --git a/src/web/pages/token-routes/useRouteChannels.ts b/src/web/pages/token-routes/useRouteChannels.ts index b708fb2..b4318c7 100644 --- a/src/web/pages/token-routes/useRouteChannels.ts +++ b/src/web/pages/token-routes/useRouteChannels.ts @@ -25,8 +25,11 @@ export function useRouteChannels() { } }, []); - const invalidateChannels = useCallback((routeId: number) => { + const invalidateChannels = useCallback((routeId?: number) => { setChannelsByRouteId((prev) => { + if (routeId === undefined) { + return {}; + } const next = { ...prev }; delete next[routeId]; return next; diff --git a/src/web/pages/tokenRoutes.group-collapse.test.tsx b/src/web/pages/tokenRoutes.group-collapse.test.tsx index 04df77e..684b2d9 100644 --- a/src/web/pages/tokenRoutes.group-collapse.test.tsx +++ b/src/web/pages/tokenRoutes.group-collapse.test.tsx @@ -122,7 +122,7 @@ describe('TokenRoutes grouped source models', () => { }, ]); - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = create( @@ -181,7 +181,7 @@ describe('TokenRoutes grouped source models', () => { ]); apiMock.getRouteChannels.mockResolvedValue(channels); - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = create( @@ -241,7 +241,7 @@ describe('TokenRoutes grouped source models', () => { ]); apiMock.getRouteChannels.mockResolvedValue(channels); - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = create( @@ -296,7 +296,7 @@ describe('TokenRoutes grouped source models', () => { }); apiMock.getRouteChannels.mockResolvedValue([]); - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = create( @@ -336,7 +336,7 @@ describe('TokenRoutes grouped source models', () => { }, }); - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = create( @@ -382,7 +382,7 @@ describe('TokenRoutes grouped source models', () => { }, }); - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = create( @@ -449,7 +449,7 @@ describe('TokenRoutes grouped source models', () => { }, }); - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = create( @@ -497,7 +497,7 @@ describe('TokenRoutes grouped source models', () => { }); apiMock.getRouteChannels.mockResolvedValue([]); - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = create( @@ -543,7 +543,7 @@ describe('TokenRoutes grouped source models', () => { }, }); - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = create( @@ -594,7 +594,7 @@ describe('TokenRoutes grouped source models', () => { }, ]); - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = create( @@ -640,7 +640,7 @@ describe('TokenRoutes grouped source models', () => { endpointTypesByModel: {}, }); - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = create( @@ -683,7 +683,7 @@ describe('TokenRoutes grouped source models', () => { endpointTypesByModel: {}, }); - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = create( @@ -735,7 +735,7 @@ describe('TokenRoutes grouped source models', () => { }, ]); - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = create( @@ -774,7 +774,7 @@ describe('TokenRoutes grouped source models', () => { }, }); - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = create( @@ -824,7 +824,7 @@ describe('TokenRoutes grouped source models', () => { }, ]); - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = create( @@ -855,7 +855,7 @@ describe('TokenRoutes grouped source models', () => { }, ]); - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = create( @@ -928,7 +928,7 @@ describe('TokenRoutes grouped source models', () => { }, }); - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = create( @@ -1035,7 +1035,7 @@ describe('TokenRoutes grouped source models', () => { }, ]); - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = create( @@ -1089,7 +1089,7 @@ describe('TokenRoutes grouped source models', () => { }, ]); - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = create( @@ -1159,7 +1159,7 @@ describe('TokenRoutes grouped source models', () => { }, ]); - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = create( @@ -1231,7 +1231,7 @@ describe('TokenRoutes grouped source models', () => { ]); apiMock.getRouteChannels.mockResolvedValue([]); - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = create( @@ -1317,7 +1317,7 @@ describe('TokenRoutes grouped source models', () => { ]); apiMock.getRouteChannels.mockResolvedValue([]); - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = create( @@ -1412,7 +1412,7 @@ describe('TokenRoutes grouped source models', () => { }, ]); - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = create( diff --git a/src/web/pages/tokenRoutes.mobile-layout.test.tsx b/src/web/pages/tokenRoutes.mobile-layout.test.tsx index eb5ca37..59d3c52 100644 --- a/src/web/pages/tokenRoutes.mobile-layout.test.tsx +++ b/src/web/pages/tokenRoutes.mobile-layout.test.tsx @@ -137,7 +137,7 @@ describe('TokenRoutes mobile layout', () => { }); it('shows direct mobile actions and reveals the management panel after expansion', async () => { - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { @@ -178,7 +178,7 @@ describe('TokenRoutes mobile layout', () => { }); it('lets mobile users toggle route enabled state from the summary card', async () => { - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { diff --git a/src/web/pages/tokenRoutes.mobile.test.tsx b/src/web/pages/tokenRoutes.mobile.test.tsx index 268f193..1273be5 100644 --- a/src/web/pages/tokenRoutes.mobile.test.tsx +++ b/src/web/pages/tokenRoutes.mobile.test.tsx @@ -125,7 +125,7 @@ describe('TokenRoutes mobile actions', () => { }); it('shows mobile detail expansion and direct management actions', async () => { - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { diff --git a/src/web/pages/tokenRoutes.refresh-decision.test.tsx b/src/web/pages/tokenRoutes.refresh-decision.test.tsx index 569cd08..dd57036 100644 --- a/src/web/pages/tokenRoutes.refresh-decision.test.tsx +++ b/src/web/pages/tokenRoutes.refresh-decision.test.tsx @@ -81,7 +81,7 @@ describe('TokenRoutes refresh decision action', () => { }); it('passes refreshPricingCatalog and persistSnapshots when user clicks refresh selection probability', async () => { - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = create( diff --git a/src/web/pages/tokenRoutes.routing-strategy.test.tsx b/src/web/pages/tokenRoutes.routing-strategy.test.tsx index b1fd508..029c5fd 100644 --- a/src/web/pages/tokenRoutes.routing-strategy.test.tsx +++ b/src/web/pages/tokenRoutes.routing-strategy.test.tsx @@ -79,7 +79,7 @@ describe('TokenRoutes routing strategy updates', () => { }); it('keeps the optimistic routing strategy when refresh fails after a successful save', async () => { - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = create( @@ -129,7 +129,7 @@ describe('TokenRoutes routing strategy updates', () => { }); it('supports switching to stable_first and keeps the optimistic label when refresh fails', async () => { - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = create( diff --git a/src/web/pages/tokenRoutes.snapshot-load.test.tsx b/src/web/pages/tokenRoutes.snapshot-load.test.tsx index 8874fd5..902f879 100644 --- a/src/web/pages/tokenRoutes.snapshot-load.test.tsx +++ b/src/web/pages/tokenRoutes.snapshot-load.test.tsx @@ -113,7 +113,7 @@ describe('TokenRoutes cached snapshot load', () => { }); it('shows cached probabilities immediately from getRoutes snapshot data', async () => { - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = create( diff --git a/src/web/pages/tokens.batch-actions.test.tsx b/src/web/pages/tokens.batch-actions.test.tsx index 1b34089..8329ddd 100644 --- a/src/web/pages/tokens.batch-actions.test.tsx +++ b/src/web/pages/tokens.batch-actions.test.tsx @@ -71,7 +71,7 @@ describe('Tokens batch actions', () => { }); it('deletes selected tokens through the batch toolbar', async () => { - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = create( @@ -117,7 +117,7 @@ describe('Tokens batch actions', () => { }); it('selects a token when clicking the row instead of only the checkbox', async () => { - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = create( diff --git a/src/web/pages/tokens.edit-and-select.test.tsx b/src/web/pages/tokens.edit-and-select.test.tsx index 1a16844..765d438 100644 --- a/src/web/pages/tokens.edit-and-select.test.tsx +++ b/src/web/pages/tokens.edit-and-select.test.tsx @@ -143,7 +143,7 @@ describe('Tokens edit modal and row selection', () => { }); it('opens the centered edit modal when editing a token', async () => { - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = buildRoot(); @@ -191,7 +191,7 @@ describe('Tokens edit modal and row selection', () => { }); it('selects a token when clicking the row body, but not when clicking an action button', async () => { - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = buildRoot(); @@ -232,7 +232,7 @@ describe('Tokens edit modal and row selection', () => { it('does not repeatedly refetch groups when edit-group loading fails once', async () => { apiMock.getAccountTokenGroups.mockRejectedValueOnce(new Error('账号会话可能已过期,请重新登录后再拉取分组')); - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = buildRoot(); @@ -274,7 +274,7 @@ describe('Tokens edit modal and row selection', () => { ]); apiMock.getAccountTokenValue.mockRejectedValueOnce(new Error('当前仅保存了脱敏令牌,无法展开/复制。请在站点重新生成并同步,或手动更新为完整令牌。')); - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = buildTokensRoot(); @@ -356,7 +356,7 @@ describe('Tokens edit modal and row selection', () => { }); apiMock.getAccountTokenValue.mockRejectedValueOnce(new Error('当前仅保存了脱敏令牌,无法展开/复制。请在站点重新生成并同步,或手动更新为完整令牌。')); - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = buildTokensRoot(); diff --git a/src/web/pages/tokens.focus.test.tsx b/src/web/pages/tokens.focus.test.tsx index a5e91db..707e84e 100644 --- a/src/web/pages/tokens.focus.test.tsx +++ b/src/web/pages/tokens.focus.test.tsx @@ -71,7 +71,7 @@ describe('Tokens focus navigation', () => { }, ]); - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = create( diff --git a/src/web/pages/tokens.mobile-actions.test.tsx b/src/web/pages/tokens.mobile-actions.test.tsx index 58490ca..6ddfe45 100644 --- a/src/web/pages/tokens.mobile-actions.test.tsx +++ b/src/web/pages/tokens.mobile-actions.test.tsx @@ -77,7 +77,7 @@ describe('Tokens mobile actions', () => { }); it('supports select-all-visible and expandable mobile token details', async () => { - let root: ReturnType | null = null; + let root!: WebTestRenderer; try { await act(async () => { root = create( diff --git a/src/web/pages/tokens.redirect.test.tsx b/src/web/pages/tokens.redirect.test.tsx index 9e5341a..6df914e 100644 --- a/src/web/pages/tokens.redirect.test.tsx +++ b/src/web/pages/tokens.redirect.test.tsx @@ -23,7 +23,7 @@ function LocationProbe() { describe('Tokens legacy route redirect', () => { it('redirects /tokens to the merged accounts tokens segment', async () => { - let root: ReturnType | null = null; + let root!: WebTestRenderer; await act(async () => { root = create( diff --git a/src/web/test-types.d.ts b/src/web/test-types.d.ts new file mode 100644 index 0000000..4e39687 --- /dev/null +++ b/src/web/test-types.d.ts @@ -0,0 +1 @@ +type WebTestRenderer = import('react-test-renderer').ReactTestRenderer; diff --git a/tsconfig.web.json b/tsconfig.web.json new file mode 100644 index 0000000..8b6c3b8 --- /dev/null +++ b/tsconfig.web.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true + }, + "include": ["src/web/**/*.ts", "src/web/**/*.tsx"], + "exclude": ["src/web/**/*.test.ts", "src/web/**/*.test.tsx"] +} diff --git a/tsconfig.web.test.json b/tsconfig.web.test.json new file mode 100644 index 0000000..7e33e0f --- /dev/null +++ b/tsconfig.web.test.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true + }, + "include": ["src/web/**/*.test.ts", "src/web/**/*.test.tsx", "src/web/**/*.d.ts"] +}