#!/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 () {` 起按大括号配平截取函数源码(跳过字符串与注释)。 */ 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) => /^ ' + 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)