档案 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 上一致。
187 lines
8.4 KiB
JavaScript
187 lines
8.4 KiB
JavaScript
#!/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)
|