Files
dsh_shenxian/scripts/verify-models-render.cjs
T
admin 29c57a8207 feat(web): 模型设置页复刻官方交互 + 官方推荐插件按 dsh 版本过滤(档案 91/92/93)
档案 91 ——「模型设置」页折行 + 复刻官方交互(插件 0.3.15)
· 折行根因不是"窄":原「我已添加的厂家」是 4 列表格(列宽 auto)⇒ 名称/状态/两按钮三处折行同源;
  改官方 dsh-client-ui-settings-models 的**卡片行**(rowHead + rowActions margin-left:auto,两侧 nowrap)
· 「新增」改官方**两步式**:默认两个虚线按钮 → 点开才出卡片,主字段只剩「API 密钥」
  (官方原文:页面从不询问环境变量名);次要字段收进 <details>「自定义设置」
· CSS 数值逐值照抄官方 CSS 模块;顺修「由 admin 配置」「密钥可留空」两处错文案,补删除二次确认

档案 92 —— 官方推荐插件列表按 dsh 版本过滤(插件 0.3.16)
· 官方目录不带 dsh 版本字段 ⇒ 逐包查 npm 声明与平台真实版本比,分类 match/none/newer/older/unknown
· 默认隐藏「仅兼容更旧版本」,「需更高版本」橙色标注;勾选框为逃生口;说明行给隐藏计数(不静默消失)
· ⚠️ 判 satisfies 必须带 includePrerelease(平台是 prerelease,默认语义会把在跑的 dshmarket 之类判成更旧)

档案 93 —— 兼容判定收口(服务器 CI 由红转绿)
· 「单测长期红」真相 = 服务器源码落后于 git(本机 48/0 全绿)⇒ 同步即修,非代码问题
· 伞包 @deepseek-ai/dsh 收口到 dsh-install.platformPackageVersion()(它不在自己的 node_modules 里,
  原先恒判 null ⇒ 声明在伞包上的"要求更高版本"全被放行);闸门与列表共用同一份版本表
· 闸门判据 A 加 prerelease 兜底:实测抽样 300 里 prerelease-artifact 117 条(≈39% 假阳性,
  含在跑的 dshmarket)⇒ 容忍下能满足则只计数(prereleaseOnly)不阻断

校验
· 新增 scripts/verify-dsh-compat.mjs(21 条回归锚点)与 verify-models-dict.mjs / verify-models-render.cjs
· verify-platform-admin-section.mjs:断言从旧 UI(表格/下拉「自定义厂家…」)改成断言新 UI
  (含模拟点击两个新增入口 + 选中目录厂家)
· 两个仓库外的动作:服务器 npm run build + ci.sh(CI OK)+ systemctl restart dshs;插件 0.3.16 已铺发

⚠️ 本提交未包含 src/supervisor/orchestrator.ts —— 那是别人在途的内存配额改动
(BASE 160→448 / MIN 384→512 / MAX 1024→1536 / mcn 128→256),其对应的客户端常量尚未同步,
本机 verify-mem-model 因此有 4 项红;提交后仓库树两边均为旧值,该断言在 HEAD 上一致。
2026-09-14 21:13:32 +08:00

187 lines
8.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* verify-models-render.cjs —— 「模型设置」分区(`poc/business-plugins/lib/client.js`)
* 的**真渲染冒烟**:把 `ModelSettingsSection` 抠出来,用桩 React / jsxRuntime 跑一遍,
* 覆盖「卡片行 / 添加提供方卡片 / 自定义提供方卡片 / 默认双按钮 / 删除确认弹窗」五个分支。
*
* 为什么需要(语法检查抓不到的东西):
* `node --check` 只保证能解析;`verify-models-dict.mjs` 只保证词典齐全与版式锚点。
* 但组件里还有一类**只在渲染时才炸**的错:未定义变量、jsxRuntime 用法错、
* 访问了 `undefined.trim()` 之类。这类错在浏览器里表现为「整个设置页白屏」,
* 而本仓库没有前端测试运行器。本脚本用最小桩把渲染跑通,把这类错拦在构建期。
*
* ⚠️ **维护约束**:桩 `useState` 按**调用序号**喂值(见 `STATE_MAP`)。若给组件新增/调换
* `useState`,请同步 `STATE_MAP`;脚本会先断言调用数量,数量不符时**明确报错**而不是静默测错。
*
* 用法:node scripts/verify-models-render.cjs 退出码 0=全绿 / 1=有失败
*/
const { readFileSync } = require('node:fs')
const { join, dirname } = require('node:path')
const ROOT = join(__dirname, '..')
const FILE = join(ROOT, 'poc', 'business-plugins', 'lib', 'client.js')
const src = readFileSync(FILE, 'utf8')
let failed = 0
const ok = (name, cond, extra = '') => {
console.log((cond ? ' ✓ ' : ' ✗ ') + name + (extra ? ' ' + extra : ''))
if (!cond) failed++
}
/** 从 `function <name>() {` 起按大括号配平截取函数源码(跳过字符串与注释)。 */
function extractFn(text, name) {
const at = text.indexOf('function ' + name + '()')
if (at < 0) return null
const start = text.indexOf('{', at)
let depth = 0
let i = start
while (i < text.length) {
const c = text[i]
if (c === '/' && text[i + 1] === '/') { while (i < text.length && text[i] !== '\n') i++; continue }
if (c === '/' && text[i + 1] === '*') { i = text.indexOf('*/', i) + 2; continue }
if (c === '"' || c === "'" || c === '`') {
const q = c
i++
while (i < text.length && text[i] !== q) { if (text[i] === '\\') i++; i++ }
i++
continue
}
if (c === '{') depth++
else if (c === '}') { depth--; if (depth === 0) return text.slice(at, i + 1) }
i++
}
return null
}
const fnSrc = extractFn(src, 'ModelSettingsSection')
ok('取到 ModelSettingsSection 源码', fnSrc !== null)
if (fnSrc === null) process.exit(1)
/** 真词典(顺便验证键真的存在)。 */
function grabObj(text, name) {
const at = text.indexOf('var ' + name + ' = {')
const start = text.indexOf('{', at)
let depth = 0
for (let i = start; i < text.length; i++) {
if (text[i] === '{') depth++
else if (text[i] === '}') { depth--; if (depth === 0) return text.slice(start, i + 1) }
}
return null
}
const zh = eval('(' + grabObj(src, 'zh') + ')')
/**
* 桩 `useState` 的**位置→语义**映射。顺序 = 组件里 `var x = useState(...)` 的先后。
* 键是调用序号,值是「该序号在真实组件里是什么」——仅用于可读性,实际取值看 `over`。
*/
const STATE_MAP = [
'data', 'providers', 'loading', 'busy', 'msg', 'mode',
'fPick', 'fKey', 'fName', 'fRoute', 'fUrl', 'fApi', 'fModels', 'fShow', 'delTarget', 'catalog',
]
const mockedKeys = []
const FAKE_DATA = {
keys: [
{ id: 1, name: '内置 DeepSeek', route: null, enabled: true, baseUrl: null, api: null },
{ id: 2, name: 'Moonshot', route: 'moonshot', enabled: false, baseUrl: null, api: null },
{ id: 3, name: '我的中转', route: 'my-gw', enabled: true, baseUrl: 'https://gw.example/v1', api: 'openai-completions' },
],
shared: { available: true, name: '内置 DeepSeek', owner: 'admin', ownerIsMe: true, count: 1 },
sharedModelEnabled: true,
protocols: ['openai-completions', 'anthropic-messages'],
}
const FAKE_PROVIDERS = [
{ id: 'moonshot', label: '月之暗面', cn: true, modelCount: 3, models: ['kimi-k2', 'kimi-lite', 'kimi-pro'] },
{ id: 'openai', label: 'OpenAI', cn: false, modelCount: 2, models: ['gpt-5', 'gpt-5-mini'] },
]
function makeUseState(pass, counter) {
return function useState(init) {
const k = counter.i++
const over = pass.over || {}
// 未提供的字段回落到 init(而不是 undefined)—— 免得桩的疏漏被当成组件缺陷
const v = Object.prototype.hasOwnProperty.call(over, k) && over[k] !== undefined ? over[k] : init
return [v, function () {}]
}
}
const React = { useState: null, useEffect: function () {} }
const jsxRuntime = {
jsx: (type, props) => ({ type, props: props || {} }),
jsxs: (type, props) => ({ type, props: props || {} }),
Fragment: 'Fragment',
}
const t = (k) => {
if (!(k in zh)) { mockedKeys.push(k); return '<' + k + '>' }
return zh[k]
}
const noopReq = () => Promise.resolve({ ok: true, json: () => Promise.resolve({}) })
const MS_ERR = {}
const portalHost = () => ''
function render(pass) {
const counter = { i: 0 }
React.useState = makeUseState(pass, counter)
const factory = new Function(
'React', 'jsxRuntime', 't', 'paReq', 'papostJson', 'portalHost', 'MS_ERR',
fnSrc + '\nreturn ModelSettingsSection;',
)
const tree = factory(React, jsxRuntime, t, noopReq, noopReq, portalHost, MS_ERR)()
return { tree, calls: counter.i }
}
function walk(node, out) {
if (node == null || typeof node !== 'object') return
if (Array.isArray(node)) { for (const n of node) walk(n, out); return }
const p = node.props || {}
if (p.className) out.classes.push(String(p.className))
if (typeof p.children === 'string') out.text.push(p.children)
if (Array.isArray(p.children)) {
for (const c of p.children) { if (typeof c === 'string') out.text.push(c); else walk(c, out) }
} else if (p.children && typeof p.children === 'object') walk(p.children, out)
}
const BASE = { key: 'sk-abc', msg: '', over: {} }
const CASES = [
['默认态(两个虚线按钮)', { over: { 0: FAKE_DATA, 1: FAKE_PROVIDERS, 2: false, 3: false, 4: '', 5: '', 6: '', 7: BASE.key, 12: [''], 13: false, 14: null, 15: null } },
['ms-rows', 'ms-rowCard', 'ms-rowHead', 'ms-rowIdentity', 'ms-rowActions', 'ms-addActions', 'ms-addBtn']],
['添加提供方卡片', { over: { 0: FAKE_DATA, 1: FAKE_PROVIDERS, 2: false, 3: false, 4: '', 5: 'add', 6: 'moonshot', 7: BASE.key, 12: [''], 13: false, 14: null, 15: null } },
['ms-card', 'ms-field', 'ms-fieldLabel', 'ms-details', 'ms-summary', 'ms-editorActions']],
['自定义提供方卡片', { over: { 0: FAKE_DATA, 1: FAKE_PROVIDERS, 2: false, 3: false, 4: '', 5: 'custom', 6: '', 7: BASE.key, 8: '我的网关', 9: 'my-gw', 10: 'https://gw.example/v1', 11: 'openai-completions', 12: ['m1', ''], 13: false, 14: null, 15: null } },
['ms-modelList', 'ms-modelEntry', 'ms-modelRow', 'ms-iconBtn']],
['删除确认弹窗', { over: { 0: FAKE_DATA, 1: FAKE_PROVIDERS, 2: false, 3: false, 4: '', 5: '', 6: '', 7: BASE.key, 12: [''], 13: false, 14: FAKE_DATA.keys[1], 15: null } },
['pa-overlay', 'pa-modal', 'ms-editorActions']],
]
let sawCalls = 0
for (const [title, pass, expect] of CASES) {
let tree, calls
try {
const r = render(pass)
tree = r.tree
calls = r.calls
} catch (e) {
console.log(' ✗ ' + title + ' → 抛异常: ' + e.message)
failed++
continue
}
sawCalls = calls
const out = { classes: [], text: [] }
walk(tree, out)
const joined = out.classes.join(' | ')
const miss = expect.filter((c) => joined.indexOf(c) < 0)
const problems = []
if (miss.length) problems.push('缺 class: ' + miss.join(', '))
const unrendered = out.text.filter((s) => /^<ms\./.test(s))
if (unrendered.length) problems.push('未渲染词典键: ' + unrendered.join(', '))
if (problems.length) { console.log(' ✗ ' + title + ' → ' + problems.join(' ; ')); failed++ }
else console.log(' ✓ ' + title + ' (class ' + out.classes.length + ' 个, 文本 ' + out.text.length + ' 段)')
}
ok('useState 调用数量与 STATE_MAP 对齐', sawCalls === STATE_MAP.length,
sawCalls === STATE_MAP.length ? '-> ' + sawCalls : '-> 实测 ' + sawCalls + ' / 映射 ' + STATE_MAP.length + '(组件加了 state?请同步本脚本 STATE_MAP)')
ok('渲染期未出现缺词典键', mockedKeys.length === 0, mockedKeys.length ? '-> ' + [...new Set(mockedKeys)].join(', ') : '')
console.log(failed === 0 ? '\n渲染冒烟全部通过' : '\n' + failed + ' 项失败')
process.exit(failed === 0 ? 0 : 1)