根因:ReleaseTargetsView 在 overview 缺失时自己补 {state:'unknown'},而
productionTargetClass 把"unknown 且没有 empty_baseline"一律当成非空。后端对
"已探测且非空"发的正是这个形状(Go struct 上 omitempty),所以页面本地默认值
与真实观测在 JSON 上无法区分:首次加载、整库状态读取失败、当前范围没有数据
三条路径都会显示"目标非空,尚无权威版本",管理员还会拿到会返回 409 的
"建立目标权威基线"入口。
修法:没有 overview 时 production 保持 null,分类走新的 no-observation 分支;
四类有效响应(known / 已读且为空 / 非空无版本 / 不可读)的判定与入口不变。
刷新失败保留上一份 overview 时标注"上次观测 · 刷新失败,可能已过期",不把旧
结果当成刚验证的状态。后端 gate、权限、预备份与 API 拒绝一律未改。
测试:把旧"三次 SSR 当刷新"的用例撤掉,改为同一挂载 + 可控 fetch 的真实行为
测试(首次读取未返回 / 空→不可读→恢复 / 刷新失败保留旧观测 / 内外切换 /
overview 属于别的范围);缺观测的三条路径另以 SSR 负例复用审核者的反例。
新增 jsdom 仅作为 vitest 的 DOM 环境(商品代码零改动,npm ci 与锁文件已核)。
验证:make verify 16 项 success(tests-web 504 passed、tsc 无错、npm audit 0);
新用例先在原实现上红(断言收到"目标非空"),修后绿。未部署、未合并、未改
候选运行栈。
This commit is contained in:
@@ -1,5 +1,15 @@
|
||||
# Changelog
|
||||
|
||||
## 开发快照(发布管理:空目标观测与基线入口)
|
||||
|
||||
### Fixed
|
||||
|
||||
- 发布管理:还没有读到生产目标状态时(首次加载、整库状态读取失败、当前范围没有数据)
|
||||
不再把本地默认值当成"目标非空",也不再提供会返回 409/503 的"建立目标权威基线"入口;
|
||||
有效四类响应(有权威版本 / 已读且为空 / 非空无版本 / 不可读)的呈现与入口保持不变。
|
||||
- 发布管理:刷新失败但仍保留上一次目标观测时,页面标明"上次观测 · 刷新失败,可能已过期",
|
||||
不把旧结果当成刚刚验证过的状态。
|
||||
|
||||
## 开发快照(发布 HTTP 成功闭环:真实监听 POST /library/syncs → handoff → executor → 幂等重放)
|
||||
|
||||
### Added
|
||||
|
||||
Generated
+527
@@ -17,12 +17,66 @@
|
||||
"@types/react": "19.1.10",
|
||||
"@types/react-dom": "19.1.7",
|
||||
"@vitejs/plugin-react": "5.0.1",
|
||||
"jsdom": "^30.1.3",
|
||||
"prettier": "3.9.10",
|
||||
"typescript": "5.9.2",
|
||||
"vite": "7.3.7",
|
||||
"vitest": "5.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/css-color": {
|
||||
"version": "7.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.1.9.tgz",
|
||||
"integrity": "sha512-vZ6JQVq8Ivgz6b/IBlAf90wZviXdluY13cQOPO+nJHbmXwGrPl2GcpqUY7a55/8ZVdfONXpCFMpRijOBCHNlcQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@csstools/css-calc": "^3.4.3",
|
||||
"@csstools/css-color-parser": "^4.2.6",
|
||||
"@csstools/css-parser-algorithms": "^4.0.2",
|
||||
"@csstools/css-tokenizer": "^4.0.2",
|
||||
"lru-cache": "^11.5.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
|
||||
"version": "11.5.3",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz",
|
||||
"integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/dom-selector": {
|
||||
"version": "10.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-10.0.5.tgz",
|
||||
"integrity": "sha512-EgmJ7oEaGQECTn3H3wmnQpEy4hKY66VvM7VQndwPIQob3p877BgbyOUE3EX0LgXQCciF2H8KMdR+RznqjMNOLw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bidi-js": "^1.1.0",
|
||||
"css-tree": "^3.2.1",
|
||||
"is-potential-custom-element-name": "^1.0.1",
|
||||
"lru-cache": "^11.5.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": {
|
||||
"version": "11.5.3",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz",
|
||||
"integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
||||
@@ -305,6 +359,159 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@bramus/specificity": {
|
||||
"version": "2.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.3.tgz",
|
||||
"integrity": "sha512-0GEz8qJWokkUVYQYRukmkSHIFNoBH8QTreeHrAFbvz1XFYc3Y+puhMs8PQ5x8GNugiC2l5jvDBXKLbBIByeSBw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"css-tree": "^3.2.1"
|
||||
},
|
||||
"bin": {
|
||||
"specificity": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/color-helpers": {
|
||||
"version": "6.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.2.tgz",
|
||||
"integrity": "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-calc": {
|
||||
"version": "3.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.3.tgz",
|
||||
"integrity": "sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-parser-algorithms": "^4.0.2",
|
||||
"@csstools/css-tokenizer": "^4.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-color-parser": {
|
||||
"version": "4.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.7.tgz",
|
||||
"integrity": "sha512-zgm9yjQz5iJB4VL8RyVwd71TWB8Q89FSxt/NzXoWqPSkEBOVtn9L6vAPtSc9hpKfB0Awo/AL2VKFHjYetBLFHQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@csstools/color-helpers": "^6.1.2",
|
||||
"@csstools/css-calc": "^3.4.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-parser-algorithms": "^4.0.2",
|
||||
"@csstools/css-tokenizer": "^4.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-parser-algorithms": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.2.tgz",
|
||||
"integrity": "sha512-40cSKyMvK+tq4qz6Awrlye2WGuOKt3FwPgtGg6KTfbHOWNw+Rk1rzbAtZnZ6IBhsY491HLRnDXwoyBAijmmILA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-tokenizer": "^4.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-syntax-patches-for-csstree": {
|
||||
"version": "1.1.16",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.16.tgz",
|
||||
"integrity": "sha512-LsDbJPP/B8cPasUJyTA5S99F7C4G93ngW8hUluXz8CFeOaaRnBPtBiIlvgcPYU0HmHNz8CXg+1vwFrIB4F6Dgw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT-0",
|
||||
"peerDependencies": {
|
||||
"css-tree": "^3.2.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"css-tree": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-tokenizer": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.2.tgz",
|
||||
"integrity": "sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
|
||||
@@ -747,6 +954,24 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@exodus/bytes": {
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz",
|
||||
"integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@noble/hashes": "^1.8.0 || ^2.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@noble/hashes": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
@@ -1405,6 +1630,16 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bidi-js": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz",
|
||||
"integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"require-from-string": "^2.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
"version": "4.28.8",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz",
|
||||
@@ -1537,12 +1772,40 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/css-tree": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
|
||||
"integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mdn-data": "2.27.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/data-urls": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-8.0.0.tgz",
|
||||
"integrity": "sha512-JQUCCK+/CAGbGGx618PlFZMXH81Jtj7t73CHv9dd/zVWvxgGT2zLFRjnKb+Ng46GIwcy+f7f88qd4duqllK+WQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-mimetype": "^5.0.0",
|
||||
"whatwg-url": "^17.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.14.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -1560,6 +1823,13 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js": {
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
||||
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/decode-named-character-reference": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
|
||||
@@ -1602,6 +1872,19 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "9.0.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-9.0.0.tgz",
|
||||
"integrity": "sha512-oJFNDJg3kTPwAD1cNn2+WmUq4wFoo09npc3Ydf1D7ED4smRiWrsKnaLxykrTWgyGEcusc87P5dxlAleol9kpkg==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/es-module-lexer": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz",
|
||||
@@ -1792,6 +2075,19 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/html-encoding-sniffer": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-7.0.0.tgz",
|
||||
"integrity": "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@exodus/bytes": "^1.15.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.13.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/html-url-attributes": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
|
||||
@@ -1864,6 +2160,13 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/is-potential-custom-element-name": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
|
||||
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
@@ -1871,6 +2174,56 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jsdom": {
|
||||
"version": "30.1.3",
|
||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.3.tgz",
|
||||
"integrity": "sha512-hQMUPvRgN7KxsfIytDQji7bimuJeRQa6Qo5+iuiZQk5CafeRxLIA10/cG5YCBFNpD9RO3oqIiN4CO1Y44kxqXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@asamuzakjp/css-color": "^7.1.3",
|
||||
"@asamuzakjp/dom-selector": "^10.0.3",
|
||||
"@bramus/specificity": "^2.4.2",
|
||||
"@csstools/css-syntax-patches-for-csstree": "^1.1.15",
|
||||
"@exodus/bytes": "^1.16.0",
|
||||
"css-tree": "^3.2.1",
|
||||
"data-urls": "^8.0.0",
|
||||
"decimal.js": "^10.6.0",
|
||||
"html-encoding-sniffer": "^7.0.0",
|
||||
"is-potential-custom-element-name": "^1.0.1",
|
||||
"lru-cache": "^11.5.3",
|
||||
"parse5": "^8.0.1",
|
||||
"saxes": "^6.0.0",
|
||||
"tough-cookie": "^6.0.2",
|
||||
"undici": "^8.12.1",
|
||||
"w3c-xmlserializer": "^6.0.0",
|
||||
"webidl-conversions": "^8.0.1",
|
||||
"whatwg-mimetype": "^5.0.0",
|
||||
"whatwg-url": "^17.2.0",
|
||||
"xml-name-validator": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"canvas": "^3.2.3"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"canvas": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/jsdom/node_modules/lru-cache": {
|
||||
"version": "11.5.3",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz",
|
||||
"integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/jsesc": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
|
||||
@@ -2209,6 +2562,13 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/mdn-data": {
|
||||
"version": "2.27.1",
|
||||
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
|
||||
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
|
||||
"dev": true,
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
"node_modules/micromark": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.3.tgz",
|
||||
@@ -2867,6 +3227,19 @@
|
||||
"integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/parse5": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.1.0.tgz",
|
||||
"integrity": "sha512-sq3qBFpee5jeocbFbO48VHlGr8sT7mbXS0vxlKN6sottBtJzrOG3998sr0llSZcQAGgfpdmEYAIZE0P9z0dE8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"entities": "^9.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -2942,6 +3315,16 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.1.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz",
|
||||
@@ -3066,6 +3449,16 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.63.1",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz",
|
||||
@@ -3112,6 +3505,19 @@
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/saxes": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
|
||||
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"xmlchars": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=v12.22.7"
|
||||
}
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.26.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz",
|
||||
@@ -3224,6 +3630,52 @@
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/tldts": {
|
||||
"version": "7.4.19",
|
||||
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.19.tgz",
|
||||
"integrity": "sha512-48dH6aSpQnpbzDUc4vIQZ1VjB8tkK+e8c1yx05/SZK+rkssgjEOlxDd/yU8/a4vGYQ9vRgJr9dwMq9TJ0b3X2Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tldts-core": "^7.4.19"
|
||||
},
|
||||
"bin": {
|
||||
"tldts": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/tldts-core": {
|
||||
"version": "7.4.20",
|
||||
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.20.tgz",
|
||||
"integrity": "sha512-C630TWXZCZmLq0F2bW4QqwnaaxCQ/svpAqvJwnC4JvgEM5ohhBnGsEzTLSOXcN+xjz+kEaHS5uoWIw2klnqd0A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tough-cookie": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz",
|
||||
"integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"tldts": "^7.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-7.0.0.tgz",
|
||||
"integrity": "sha512-BcDSDW+SIGWVOns1Zm6Yg91q9AEj7eaID0QDY9oIqJD1A8dscNwMcl9Sms2DeuWCNov1X0/oXanHGYQBSLwZsg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"punycode": "^2.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.14.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/trim-lines": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
|
||||
@@ -3258,6 +3710,16 @@
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "8.12.3",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-8.12.3.tgz",
|
||||
"integrity": "sha512-+fdndUTwBP86cVjihTp21hZHo7d6oj1c/47XPA3lL8mM0XHs+C4EkcjmgRsFnFk2f/UONGDy9ItBItmXsLOQHw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=22.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unified": {
|
||||
"version": "11.0.5",
|
||||
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
|
||||
@@ -3562,6 +4024,54 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-xmlserializer": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-6.0.0.tgz",
|
||||
"integrity": "sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"xml-name-validator": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
|
||||
"integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-mimetype": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
|
||||
"integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "17.3.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.3.0.tgz",
|
||||
"integrity": "sha512-KwOAJrpf+GTgBvcBQJcP38sMF0DsQKdwtG+TxM2pW44P6E5k0hDbWrDmyuJDIF0Uef0aQq0ZoJs+5oI+k8ddhg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@exodus/bytes": "^1.16.0",
|
||||
"tr46": "^7.0.0",
|
||||
"webidl-conversions": "^8.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.14.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/why-is-node-running": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz",
|
||||
@@ -3575,6 +4085,23 @@
|
||||
"node": ">=20.11"
|
||||
}
|
||||
},
|
||||
"node_modules/xml-name-validator": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
|
||||
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/xmlchars": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
|
||||
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"@types/react": "19.1.10",
|
||||
"@types/react-dom": "19.1.7",
|
||||
"@vitejs/plugin-react": "5.0.1",
|
||||
"jsdom": "^30.1.3",
|
||||
"prettier": "3.9.10",
|
||||
"typescript": "5.9.2",
|
||||
"vite": "7.3.7",
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// 同一挂载上的真实刷新/切换行为:ReleaseTargets 自己走 load() + fetch。
|
||||
// 旧测试拿三次 renderToStaticMarkup 当成"刷新",那是三个互不相关的静态输入,
|
||||
// 既不执行 effect 也不发请求,证明不了刷新后不残留旧状态(G1)。
|
||||
// 这里只用一个挂载 + 可控 fetch,覆盖:
|
||||
// - 首次读取未返回 / 请求失败 / 当前范围没有 overview:都不许说"目标非空";
|
||||
// - 空 → 不可读 → 恢复(同一挂载上的真实重新读取);
|
||||
// - 刷新失败保留旧观测时必须标成上次观测,恢复后清掉;
|
||||
// - 对内/对外切换不串用,overview 属于别的范围时也不借用。
|
||||
import { act } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import type { Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { AuthContext } from './auth'
|
||||
import type { LibraryOverview } from './librarySnapshots'
|
||||
import { ReleaseTargets } from './ReleaseTargets'
|
||||
|
||||
const adminValue = {
|
||||
identity: {
|
||||
id: 'u-admin',
|
||||
username: 'admin',
|
||||
display_name: '管理员',
|
||||
role: 'admin' as const,
|
||||
must_change_password: false,
|
||||
},
|
||||
ready: true,
|
||||
login: async () => null,
|
||||
logout: async () => undefined,
|
||||
changePassword: async () => null,
|
||||
signedOut: 0,
|
||||
notifySignedOut: () => undefined,
|
||||
}
|
||||
|
||||
type Scope = 'internal' | 'external'
|
||||
type Reply = { status: number; body: unknown; hold?: Promise<void> }
|
||||
|
||||
const emptyTarget: LibraryOverview['production'] = {
|
||||
state: 'unknown',
|
||||
empty_baseline: true,
|
||||
reason: '生产目标可读且为空:本次是首次发布,写入前仍会生成完整预备份',
|
||||
}
|
||||
const unreadableTarget: LibraryOverview['production'] = {
|
||||
state: 'unreachable',
|
||||
reason: '无法读取目标整库状态:请检查目标连接后重试,不能据此判定空库或建立基线',
|
||||
}
|
||||
|
||||
const snapshot = {
|
||||
snapshot_id: 'f992d64ea45bf737a0721a465b5aef2b150ef7757157eb84f3aaf8b6972d98f9',
|
||||
scope: 'internal',
|
||||
knowledge_base_id: '0c84a098-d3ed-4b82-a165-e6de9691d6a8',
|
||||
tenant_id: 10000,
|
||||
created_at: '2026-11-02T02:00:00Z',
|
||||
validation_policy: 'artifact-acceptance-v1',
|
||||
format: 'weknora-library-snapshot/v1',
|
||||
compatibility: {
|
||||
weknora_version: 'v0.7.1',
|
||||
schema_version: 74,
|
||||
retriever_engine: 'elasticsearch_v8',
|
||||
embedding_model: 'text-embedding-v4',
|
||||
embedding_dimension: 1024,
|
||||
index_mapping_sha256: 'a'.repeat(64),
|
||||
},
|
||||
parts: [],
|
||||
logical_bytes: 0,
|
||||
unique_bytes: 0,
|
||||
shared_bytes: 0,
|
||||
totals: { documents: 0, chunks: 0, index_documents: 0, resources: 0, wiki_pages: 0 },
|
||||
}
|
||||
|
||||
const overviewBody = (scope: string, production: LibraryOverview['production']) => ({
|
||||
scope,
|
||||
test_sealed: { ...snapshot, scope },
|
||||
production,
|
||||
prebackup: { state: 'unknown', reason: '本次目标快照还没有生产预备份' },
|
||||
diff: { state: 'unknown', reason: '生产与测试的文档级差异读取未接线', added: [], updated: [], removed: [] },
|
||||
jobs: [],
|
||||
gate: { sync_allowed: false, reasons: ['没有与目标快照匹配的有效发布授权'] },
|
||||
authorization: { state: 'none', reason: '没有与目标快照匹配的有效发布授权' },
|
||||
capture_available: false,
|
||||
now: '2026-11-02T02:05:00Z',
|
||||
})
|
||||
|
||||
const snapshotsBody = {
|
||||
available: true,
|
||||
reason: '',
|
||||
archive_root: '/tmp/archive',
|
||||
snapshots: [],
|
||||
versions: 0,
|
||||
content_bytes: 0,
|
||||
blob_file_bytes: 0,
|
||||
blob_allocated_bytes: 0,
|
||||
unreferenced_blobs: 0,
|
||||
unreferenced_bytes: 0,
|
||||
manifest_file_bytes: 0,
|
||||
manifest_allocated_bytes: 0,
|
||||
capture_note: '',
|
||||
capture_available: false,
|
||||
capture_reason: '',
|
||||
}
|
||||
|
||||
const reply = (body: unknown, status = 200): Reply => ({ status, body })
|
||||
const json = (body: unknown, status: number) =>
|
||||
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } })
|
||||
|
||||
let overviews: Record<Scope, Reply>
|
||||
let container: HTMLDivElement
|
||||
let root: Root
|
||||
let originalFetch: typeof fetch
|
||||
|
||||
const mount = async () => {
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<AuthContext.Provider value={adminValue}>
|
||||
<ReleaseTargets />
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
})
|
||||
await settle()
|
||||
}
|
||||
|
||||
// load() 是几层 await(fetch → json → setState);按微任务轮次推进到稳定。
|
||||
const settle = async () => {
|
||||
for (let round = 0; round < 6; round += 1) {
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const text = () => container.textContent ?? ''
|
||||
const buttonLabels = () => Array.from(container.querySelectorAll('button')).map((node) => node.textContent ?? '')
|
||||
|
||||
const click = async (label: string) => {
|
||||
const target = Array.from(container.querySelectorAll('button')).find((node) =>
|
||||
(node.textContent ?? '').includes(label)
|
||||
)
|
||||
if (!target) throw new Error(`没有找到按钮:${label}(现有:${buttonLabels().join(' / ')})`)
|
||||
await act(async () => {
|
||||
target.dispatchEvent(new window.MouseEvent('click', { bubbles: true }))
|
||||
})
|
||||
await settle()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
originalFetch = globalThis.fetch
|
||||
overviews = {
|
||||
internal: reply(overviewBody('internal', emptyTarget)),
|
||||
external: reply(overviewBody('external', unreadableTarget)),
|
||||
}
|
||||
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
||||
const url = String(input)
|
||||
if (url.startsWith('/api/v1/library/snapshots')) return json(snapshotsBody, 200)
|
||||
if (url.startsWith('/api/v1/library/captures')) return json({ jobs: [] }, 200)
|
||||
if (url.startsWith('/api/v1/library/maintenance')) return json({}, 200)
|
||||
if (url.startsWith('/api/v1/library/overview')) {
|
||||
const scope = new URL(url, 'http://candidate.invalid').searchParams.get('scope')
|
||||
const answer = overviews[scope === 'external' ? 'external' : 'internal']
|
||||
if (answer.hold) await answer.hold
|
||||
return json(answer.body, answer.status)
|
||||
}
|
||||
return json({ error: '测试未声明该请求' }, 404)
|
||||
}) as typeof fetch
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
if (root) {
|
||||
await act(async () => {
|
||||
root.unmount()
|
||||
})
|
||||
}
|
||||
container?.remove()
|
||||
globalThis.fetch = originalFetch
|
||||
})
|
||||
|
||||
describe('ReleaseTargets 同一挂载上的目标观测(F1/G1)', () => {
|
||||
it('首次读取未返回时只说正在读取,不说目标非空', async () => {
|
||||
let release: () => void = () => undefined
|
||||
const hold = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
overviews.internal = { ...reply(overviewBody('internal', emptyTarget)), hold }
|
||||
await mount()
|
||||
expect(text()).toContain('正在读取目标状态')
|
||||
expect(text()).not.toContain('目标非空')
|
||||
expect(text()).not.toContain('目标已读且为空')
|
||||
release()
|
||||
await settle()
|
||||
expect(text()).toContain('目标已读且为空')
|
||||
expect(text()).not.toContain('正在读取目标状态')
|
||||
})
|
||||
|
||||
it('空 → 不可读 → 恢复:同一挂载按每次真实读取重新分类', async () => {
|
||||
await mount()
|
||||
expect(text()).toContain('目标已读且为空')
|
||||
expect(text()).not.toContain('目标非空')
|
||||
// 管理员 + 已封存时基线入口本来会出现,但空目标不该给(后端会 409)。
|
||||
expect(buttonLabels()).not.toContain('建立目标权威基线(维护隔离)')
|
||||
|
||||
overviews.internal = reply(overviewBody('internal', unreadableTarget))
|
||||
await click('刷新')
|
||||
expect(text()).toContain('无法读取目标整库状态')
|
||||
expect(text()).not.toContain('目标已读且为空')
|
||||
expect(text()).not.toContain('目标非空')
|
||||
expect(buttonLabels()).not.toContain('建立目标权威基线(维护隔离)')
|
||||
|
||||
overviews.internal = reply(overviewBody('internal', emptyTarget))
|
||||
await click('刷新')
|
||||
expect(text()).toContain('目标已读且为空')
|
||||
expect(text()).not.toContain('无法读取目标整库状态')
|
||||
expect(text()).not.toContain('目标非空')
|
||||
})
|
||||
|
||||
it('刷新失败保留旧观测时标成上次观测,恢复读取后清除', async () => {
|
||||
await mount()
|
||||
expect(text()).toContain('目标已读且为空')
|
||||
expect(text()).not.toContain('上次观测')
|
||||
|
||||
overviews.internal = reply({ error: '上游不可用' }, 502)
|
||||
await click('刷新')
|
||||
expect(text()).toContain('读取失败')
|
||||
// 旧观测仍在,但不能冒充刚验证。
|
||||
expect(text()).toContain('目标已读且为空')
|
||||
expect(text()).toContain('上次观测 · 刷新失败,可能已过期')
|
||||
expect(text()).not.toContain('目标非空')
|
||||
|
||||
overviews.internal = reply(overviewBody('internal', emptyTarget))
|
||||
await click('刷新')
|
||||
expect(text()).not.toContain('上次观测')
|
||||
expect(text()).not.toContain('读取失败')
|
||||
expect(text()).toContain('目标已读且为空')
|
||||
})
|
||||
|
||||
it('内外切换不串用:对外的不可读不会被内对的空顶替', async () => {
|
||||
await mount()
|
||||
expect(text()).toContain('目标已读且为空')
|
||||
|
||||
await click('对外知识库')
|
||||
expect(text()).toContain('无法读取目标整库状态')
|
||||
expect(text()).not.toContain('目标已读且为空')
|
||||
expect(text()).not.toContain('目标非空')
|
||||
expect(buttonLabels()).not.toContain('建立目标权威基线(维护隔离)')
|
||||
|
||||
overviews.external = reply(overviewBody('external', emptyTarget))
|
||||
await click('刷新')
|
||||
expect(text()).toContain('目标已读且为空')
|
||||
expect(text()).not.toContain('无法读取目标整库状态')
|
||||
|
||||
await click('对内知识库')
|
||||
expect(text()).toContain('目标已读且为空')
|
||||
})
|
||||
|
||||
it('当前范围没有自己的 overview 时不借用别的范围分类', async () => {
|
||||
// 服务端把对内的 overview 回给了对外请求:scope 不匹配,等于没有观测。
|
||||
overviews.external = reply(overviewBody('internal', emptyTarget))
|
||||
await mount()
|
||||
await click('对外知识库')
|
||||
expect(text()).toContain('还没有读到')
|
||||
expect(text()).not.toContain('目标已读且为空')
|
||||
expect(text()).not.toContain('目标非空')
|
||||
expect(buttonLabels()).not.toContain('建立目标权威基线(维护隔离)')
|
||||
})
|
||||
})
|
||||
@@ -801,15 +801,41 @@ describe('生产目标四类状态:known / 空 / 非空 / 不可读', () => {
|
||||
expect(html).not.toMatch(/建立目标权威基线(维护隔离)<\/button>/)
|
||||
})
|
||||
|
||||
it('刷新后分类只由当前 overview 决定:空 → 不可读 → 空 不残留旧状态', () => {
|
||||
const seenEmpty = render({ baseline, overview: { ...overview, production: emptyTarget } })
|
||||
const seenUnreadable = render({ baseline, overview: { ...overview, production: unreadableTarget } })
|
||||
const recovered = render({ baseline, overview: { ...overview, production: emptyTarget } })
|
||||
expect(seenEmpty).toContain('目标已读且为空')
|
||||
expect(seenUnreadable).not.toContain('目标已读且为空')
|
||||
expect(seenUnreadable).toContain('无法读取目标整库状态')
|
||||
expect(recovered).toContain('目标已读且为空')
|
||||
expect(recovered).not.toContain('无法读取目标整库状态')
|
||||
// 真正的一次挂载刷新由 ReleaseTargets.mount.test.tsx 覆盖:三次 renderToStaticMarkup
|
||||
// 只是三个互不相关的静态输入,不执行 load/effect/fetch,不能算刷新证据。
|
||||
|
||||
// F1:没有读到 overview 时,页面自己造的 {state:'unknown'} 与后端"已探测且非空"
|
||||
// 在 JSON 上长得一模一样。缺观测的三条真实路径都不能落进 nonempty 分支,
|
||||
// 也不能提供会返回 409 的建基线入口。
|
||||
it('没有目标观测时不冒充非空:初始加载 / 整体读取失败 / 范围不匹配', () => {
|
||||
const cases: Array<[string, Partial<ViewProps>, string]> = [
|
||||
['初始加载中', { loading: true, overview: null }, '正在读取目标状态'],
|
||||
['初始 overview 请求失败', { error: '发布状态读取失败(HTTP 503)', overview: null }, '不能据此判定空或非空'],
|
||||
[
|
||||
'选中对外但只有对内 overview',
|
||||
{ scope: 'external', overview: { ...overview, scope: 'internal' } },
|
||||
'还没有读到',
|
||||
],
|
||||
]
|
||||
for (const [name, overrides, expected] of cases) {
|
||||
const html = render({ baseline, ...overrides })
|
||||
expect(html, name).toContain(expected)
|
||||
expect(html, name).not.toContain('目标非空')
|
||||
expect(html, name).not.toContain('目标已读且为空')
|
||||
expect(html, name).not.toMatch(/建立目标权威基线(维护隔离)<\/button>/)
|
||||
}
|
||||
})
|
||||
|
||||
it('刷新失败但保留旧观测时标成上次观测,不冒充刚验证', () => {
|
||||
const html = render({
|
||||
baseline,
|
||||
error: '发布状态读取失败(HTTP 502)',
|
||||
overview: { ...overview, production: emptyTarget },
|
||||
})
|
||||
expect(html).toContain('目标已读且为空')
|
||||
expect(html).toContain('上次观测 · 刷新失败,可能已过期')
|
||||
expect(html).not.toContain('正在读取目标状态')
|
||||
expect(html).not.toMatch(/建立目标权威基线(维护隔离)<\/button>/)
|
||||
})
|
||||
|
||||
it('空目标但没有测试封存:启动同步保持禁用并说明缺候选', () => {
|
||||
|
||||
+53
-15
@@ -162,15 +162,29 @@ const scopeName = (scope: Scope) => (scope === 'internal' ? '对内知识库' :
|
||||
// 生产目标必须按接口的四类状态原样呈现:把"已读且为空"和"读不到"都写成"未知",
|
||||
// 会让人去点一个必然被拒绝的"建立基线"(后端对空目标返回 409 library_baseline_target_empty,
|
||||
// 对读不到的目标返回 503 library_baseline_unprobeable)。
|
||||
type ProductionTargetClass = 'known' | 'empty' | 'nonempty' | 'unreachable'
|
||||
type ProductionTargetClass = 'known' | 'empty' | 'nonempty' | 'unreachable' | 'no-observation'
|
||||
|
||||
function productionTargetClass(production: LibraryOverview['production']): ProductionTargetClass {
|
||||
// 只有真正读到的 overview 才能分类。页面自己补的 {state:'unknown'} 与后端
|
||||
// "已探测且非空"在 JSON 上无法区分,所以缺观测必须单列一类(no-observation),
|
||||
// 否则首次加载、整体读取失败、当前范围没数据都会被误报成"目标非空"(F1)。
|
||||
function productionTargetClass(production: LibraryOverview['production'] | null | undefined): ProductionTargetClass {
|
||||
if (!production) return 'no-observation'
|
||||
if (production.state === 'known' && production.version) return 'known'
|
||||
if (production.state === 'unreachable') return 'unreachable'
|
||||
// unknown 有两种:可读且空(空基线,首次发布合法起点)与可读但非空(缺权威版本)。
|
||||
return production.empty_baseline ? 'empty' : 'nonempty'
|
||||
}
|
||||
|
||||
// 刷新失败时页面会保留上一次的 overview:那份观测仍是当时的真实结果,
|
||||
// 但已经不是刚刚验证过的状态,必须标出来,不能混作当前结论。
|
||||
const STALE_NOTE = '上次观测 · 刷新失败,可能已过期'
|
||||
|
||||
function noObservationText(loading: boolean, error: string | null): string {
|
||||
if (loading) return '正在读取目标状态(尚未确认空或非空)'
|
||||
if (error) return '读取失败,还没有读到目标状态(不能据此判定空或非空)'
|
||||
return '还没有读到该范围的目标状态'
|
||||
}
|
||||
|
||||
// 不可发起时必须说清是哪一条不满足:读的是接口给出的真实原因(执行器不可达、
|
||||
// 指向别的实例、降级、观测过期),不是一句写死的"通道未接线"。
|
||||
function captureHint(view: ScopeView, canWrite: boolean): string {
|
||||
@@ -696,6 +710,8 @@ function ChangeTable({ diff }: { diff: LibraryOverview['diff'] }) {
|
||||
|
||||
function ProtectionCard({
|
||||
production,
|
||||
productionNote,
|
||||
stale,
|
||||
prebackup,
|
||||
gate,
|
||||
authorization,
|
||||
@@ -704,7 +720,10 @@ function ProtectionCard({
|
||||
baseline,
|
||||
onOpen,
|
||||
}: {
|
||||
production: LibraryOverview['production']
|
||||
production: LibraryOverview['production'] | null
|
||||
// 没有目标观测时显示的原因(加载中 / 整体读取失败 / 当前范围没数据)。
|
||||
productionNote: string
|
||||
stale: boolean
|
||||
prebackup: LibraryOverview['prebackup']
|
||||
gate: LibraryOverview['gate']
|
||||
authorization: LibraryOverview['authorization']
|
||||
@@ -715,9 +734,10 @@ function ProtectionCard({
|
||||
}) {
|
||||
const backupKnown = prebackup.state === 'known' && prebackup.version
|
||||
// 基线入口只在"目标非空、却没有生效版本"时才是答案;空库走首次同步(预备份+登记),
|
||||
// 读不到时必须先修连接,两者都不能被引导到会 409/503 的基线入口。
|
||||
// 读不到或还没读到观测时必须先修连接/等读取,两者都不能被引导到会 409/503 的基线入口。
|
||||
const productionTarget = productionTargetClass(production)
|
||||
const baselineNeeded = productionTarget === 'nonempty' && targetSealed
|
||||
const staleNote = stale ? `(${STALE_NOTE})` : ''
|
||||
return (
|
||||
<aside className="rel-panel rel-protection">
|
||||
<header className="rel-panel-head">
|
||||
@@ -749,7 +769,7 @@ function ProtectionCard({
|
||||
</dd>
|
||||
<dt>生产当前版本</dt>
|
||||
<dd>
|
||||
{productionTarget === 'known' && production.version ? (
|
||||
{productionTarget === 'known' && production?.version ? (
|
||||
<>
|
||||
{production.version.version_label || production.version.snapshot_id.slice(0, 12)}
|
||||
<br />
|
||||
@@ -758,11 +778,13 @@ function ProtectionCard({
|
||||
</small>
|
||||
</>
|
||||
) : productionTarget === 'empty' ? (
|
||||
'目标已读且为空:首次发布还没有生效版本(写前仍会生成完整预备份)'
|
||||
`目标已读且为空:首次发布还没有生效版本(写前仍会生成完整预备份)${staleNote}`
|
||||
) : productionTarget === 'unreachable' ? (
|
||||
production.reason || '读取失败:无法确认目标是否为空'
|
||||
`${production?.reason || '读取失败:无法确认目标是否为空'}${staleNote}`
|
||||
) : productionTarget === 'nonempty' ? (
|
||||
`未知(目标非空且还没有权威版本)${staleNote}`
|
||||
) : (
|
||||
'未知(目标非空且还没有权威版本)'
|
||||
productionNote
|
||||
)}
|
||||
</dd>
|
||||
<dt>备份范围</dt>
|
||||
@@ -810,7 +832,10 @@ function ProtectionCard({
|
||||
interface ReadyProps {
|
||||
view: ScopeView
|
||||
diff: LibraryOverview['diff']
|
||||
production: LibraryOverview['production']
|
||||
production: LibraryOverview['production'] | null
|
||||
// 缺观测时的说明与"旧观测"标记都由上层按 loading/error 判定,子组件只负责显示。
|
||||
productionNote: string
|
||||
stale: boolean
|
||||
prebackup: LibraryOverview['prebackup']
|
||||
gate: LibraryOverview['gate']
|
||||
authorization: LibraryOverview['authorization']
|
||||
@@ -827,6 +852,8 @@ function ReadyBody({
|
||||
view,
|
||||
diff,
|
||||
production,
|
||||
productionNote,
|
||||
stale,
|
||||
prebackup,
|
||||
gate,
|
||||
authorization,
|
||||
@@ -845,6 +872,8 @@ function ReadyBody({
|
||||
<ChangeTable diff={diff} />
|
||||
<ProtectionCard
|
||||
production={production}
|
||||
productionNote={productionNote}
|
||||
stale={stale}
|
||||
prebackup={prebackup}
|
||||
gate={gate}
|
||||
authorization={authorization}
|
||||
@@ -1394,8 +1423,13 @@ export function ReleaseTargetsView(props: ViewProps) {
|
||||
const active = Boolean(view.activeJob) || view.job?.state === 'running' || view.job?.state === 'pending'
|
||||
// 三类版本严格分开:target 是测试封存,production 是生产权威版本,prebackup 是本次预备份。
|
||||
const target = view.overview?.test_sealed
|
||||
const production = view.overview?.production ?? { state: 'unknown' as const }
|
||||
// 没有 overview 时不能补一个 {state:'unknown'} 再当成"已探测且非空":
|
||||
// production 保持 null,分类走 no-observation。
|
||||
const production = view.overview?.production ?? null
|
||||
const productionClass = productionTargetClass(production)
|
||||
// 刷新失败但仍保留上一份 overview:显示可以继续,但必须标成上次观测。
|
||||
const productionStale = Boolean(error) && production !== null
|
||||
const productionNote = noObservationText(loading, error)
|
||||
const prebackup = view.overview?.prebackup ?? { state: 'unknown' as const }
|
||||
const diff = view.overview?.diff ?? {
|
||||
state: 'unknown' as const,
|
||||
@@ -1489,7 +1523,7 @@ export function ReleaseTargetsView(props: ViewProps) {
|
||||
</div>
|
||||
<p className="rel-versionline">
|
||||
生产当前{' '}
|
||||
{productionClass === 'known' && production.version ? (
|
||||
{productionClass === 'known' && production?.version ? (
|
||||
<b>
|
||||
{production.version.version_label || `生效 ${production.version.snapshot_id.slice(0, 12)}`}
|
||||
{' · '}
|
||||
@@ -1498,11 +1532,13 @@ export function ReleaseTargetsView(props: ViewProps) {
|
||||
) : productionClass === 'empty' ? (
|
||||
<b>目标已读且为空(首次发布)</b>
|
||||
) : productionClass === 'unreachable' ? (
|
||||
<b className="rel-unknown">读取失败:{production.reason || '无法读取目标整库状态'}</b>
|
||||
) : (
|
||||
<b className="rel-unknown">读取失败:{production?.reason || '无法读取目标整库状态'}</b>
|
||||
) : productionClass === 'nonempty' ? (
|
||||
<b className="rel-unknown">目标非空,尚无权威版本</b>
|
||||
)}{' '}
|
||||
→ 拟发布{' '}
|
||||
) : (
|
||||
<b className="rel-unknown">{productionNote}</b>
|
||||
)}
|
||||
{productionStale ? <em className="rel-scope">({STALE_NOTE})</em> : null} → 拟发布{' '}
|
||||
<b>{target ? `封存 ${target.snapshot_id.slice(0, 12)} · ${stamp(target.created_at)}` : '还没有测试封存'}</b> ·{' '}
|
||||
{publishStateText(view.job)}
|
||||
</p>
|
||||
@@ -1584,6 +1620,8 @@ export function ReleaseTargetsView(props: ViewProps) {
|
||||
view={view}
|
||||
diff={diff}
|
||||
production={production}
|
||||
productionNote={productionNote}
|
||||
stale={productionStale}
|
||||
prebackup={prebackup}
|
||||
gate={gate}
|
||||
authorization={authorization}
|
||||
|
||||
Reference in New Issue
Block a user