mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-08-06 22:24:13 +08:00
163 lines
8.7 KiB
JavaScript
163 lines
8.7 KiB
JavaScript
import test from 'node:test'
|
||
import assert from 'node:assert/strict'
|
||
import { readFileSync } from 'node:fs'
|
||
|
||
const read = rel => readFileSync(new URL(rel, import.meta.url), 'utf8')
|
||
|
||
const lib = read('../src/lib/model-channels.js')
|
||
const page = read('../src/pages/model-channels.js')
|
||
const modelsPage = read('../src/pages/models.js')
|
||
const mainJs = read('../src/main.js')
|
||
const sidebar = read('../src/components/sidebar.js')
|
||
const tauriApi = read('../src/lib/tauri-api.js')
|
||
const devApi = read('../scripts/dev-api.js')
|
||
const rustLib = read('../src-tauri/src/lib.rs')
|
||
const rustModule = read('../src-tauri/src/commands/model_channels.rs')
|
||
const rustHermes = read('../src-tauri/src/commands/hermes.rs')
|
||
const rustConfig = read('../src-tauri/src/commands/config.rs')
|
||
const rustMedia = read('../src-tauri/src/commands/media.rs')
|
||
const localesIndex = read('../src/locales/index.js')
|
||
|
||
test('模型渠道命令注册链完整(Rust + tauri-api + dev-api + ALWAYS_LOCAL)', () => {
|
||
for (const cmd of ['read_model_channels', 'write_model_channels', 'reveal_model_channel_key']) {
|
||
assert.match(rustLib, new RegExp(`model_channels::${cmd}`), `lib.rs 缺少 ${cmd} 注册`)
|
||
assert.match(devApi, new RegExp(`${cmd}\\(`), `dev-api.js 缺少 ${cmd} handler`)
|
||
assert.match(devApi, new RegExp(`'${cmd}'`), `${cmd} 必须加入 ALWAYS_LOCAL(本机属性不可代理远程)`)
|
||
}
|
||
assert.match(tauriApi, /readModelChannels:/, 'tauri-api 缺少 readModelChannels 封装')
|
||
assert.match(tauriApi, /writeModelChannels:/, 'tauri-api 缺少 writeModelChannels 封装')
|
||
assert.match(tauriApi, /revealModelChannelKey:/, 'tauri-api 缺少 revealModelChannelKey 封装')
|
||
})
|
||
|
||
test('渠道读取只返回掩码,写入支持保留旧 Key 哨兵', () => {
|
||
assert.match(rustModule, /apiKeySaved/, '读取必须返回 apiKeySaved')
|
||
assert.match(rustModule, /apiKeyMask/, '读取必须返回 apiKeyMask')
|
||
assert.match(rustModule, /__KEEP__/, '写入必须支持 __KEEP__ 哨兵')
|
||
assert.match(devApi, /isChannelKeepSentinel/, 'dev-api 必须实现相同的哨兵语义')
|
||
})
|
||
|
||
test('Hermes 同步契约与内核注册表一致(已按内核源码核对)', () => {
|
||
// 回退 provider id 必须是注册表真实存在的 API Key 型 id
|
||
assert.match(lib, /'openai-completions':\s*\{\s*fallbackProvider:\s*'custom'/, 'OpenAI 兼容渠道应回退到 custom provider')
|
||
assert.match(lib, /'anthropic-messages':\s*\{\s*fallbackProvider:\s*'anthropic'/, 'Anthropic 渠道应回退到 anthropic provider')
|
||
assert.match(lib, /'google-generative-ai':\s*\{\s*fallbackProvider:\s*'gemini'/, 'Gemini 渠道应回退到 gemini provider(内核经 OpenAI 兼容端点接入)')
|
||
assert.match(lib, /authType === 'api_key'/, 'OAuth/SDK 型 provider 必须被排除在渠道同步之外')
|
||
// 内核不解析 "provider/model" 前缀:model.default 必须写纯模型 ID
|
||
assert.match(lib, /model:\s*channel\.defaultModel\s*\|\|\s*''/, 'model.default 必须是纯模型 ID,不得拼接 provider 前缀')
|
||
// 自定义端点只对 OpenAI 兼容渠道生效,避免破坏 anthropic/gemini 的专用端点
|
||
assert.match(lib, /channel\.apiType === 'openai-completions'\s*&&\s*Boolean\(channel\.baseUrl\)/, '自定义 Base URL 仅限 OpenAI 兼容渠道')
|
||
})
|
||
|
||
test('OpenClaw 模型条目恒写完整对象(内核 strict schema 要求 id/name 必填)', () => {
|
||
assert.match(lib, /name:\s*model\.name\s*\|\|\s*prevObj\.name\s*\|\|\s*model\.id/, '模型条目 name 必填,缺省回退为 id')
|
||
assert.doesNotMatch(lib, /:\s*model\.id\s*\n\s*\}\)\s*$/m, '不得写裸字符串模型条目')
|
||
})
|
||
|
||
test('OpenClaw 同步只 upsert 单个 provider 并保留未知字段', () => {
|
||
assert.match(lib, /\.\.\.existing,/, '写入 provider 时必须展开旧对象保留未知字段')
|
||
assert.match(lib, /providers:\s*\{\s*\[providerKey\]:\s*providerPatch\s*\}/, '必须按 provider 键发送最小补丁')
|
||
})
|
||
|
||
test('结构化模型 SecretRef 不会被编辑器或渠道同步转成字符串', () => {
|
||
assert.match(lib, /channel\?\.apiKeyRef/, '渠道同步必须识别结构化 SecretRef')
|
||
assert.match(lib, /apiKey:\s*apiKeyValue/, 'OpenClaw 写入必须保留原始凭据值类型')
|
||
assert.match(
|
||
modelsPage,
|
||
/hasStructuredApiKey[\s\S]{0,900}!String\(apiKey\s*\|\|\s*''\)\.trim\(\)[\s\S]{0,300}existingApiKey/,
|
||
'旧模型编辑器留空时必须保留结构化 SecretRef',
|
||
)
|
||
})
|
||
|
||
test('同步与删除必须经过确认弹窗', () => {
|
||
assert.match(page, /showConfirm\(t\('modelChannels\.syncOpenclawConfirm'/, '同步 OpenClaw 前必须确认')
|
||
assert.match(page, /showConfirm\(t\('modelChannels\.syncHermesConfirm'/, '同步 Hermes 前必须确认')
|
||
assert.match(page, /showConfirm\(t\('modelChannels\.syncAssistantConfirm'/, '同步助手前必须确认')
|
||
assert.match(page, /showConfirm\(t\('modelChannels\.deleteConfirm'/, '删除渠道前必须确认')
|
||
})
|
||
|
||
test('页面注册链完整(路由 + 侧栏 + 语言包)', () => {
|
||
assert.match(mainJs, /registerRoute\('\/model-channels'/, 'main.js 缺少路由注册')
|
||
assert.match(sidebar, /route: '\/model-channels'/, '侧栏缺少入口')
|
||
assert.match(sidebar, /'channels-hub':/, '侧栏缺少图标')
|
||
assert.match(localesIndex, /modelChannels/, '语言包聚合缺少 modelChannels 模块')
|
||
})
|
||
|
||
test('删除 OpenClaw provider 使用显式墓碑补丁并等待后端成功', () => {
|
||
assert.match(tauriApi, /deleteOpenclawModelProvider:/, 'tauri-api 缺少 provider 删除封装')
|
||
assert.match(
|
||
modelsPage,
|
||
/await api\.deleteOpenclawModelProvider\(providerKey,\s*\{\s*noReload:\s*true\s*\}\)/,
|
||
'模型页必须等待后端删除成功后再更新 UI',
|
||
)
|
||
assert.doesNotMatch(
|
||
modelsPage,
|
||
/case 'delete-provider':[\s\S]{0,500}autoSave\(state\)/,
|
||
'删除 provider 不得继续依赖省略键自动保存',
|
||
)
|
||
})
|
||
|
||
test('Web 配置写入使用备份、fsync、替换和回读校验', () => {
|
||
assert.match(devApi, /writeJsonAtomic\(CONFIG_PATH,\s*cleaned,\s*\{\s*backup:\s*true\s*\}\)/)
|
||
assert.match(devApi, /fs\.fsyncSync\(/, '候选配置落盘后必须 fsync')
|
||
assert.match(devApi, /配置写入后回读不一致/, '替换后必须回读验证')
|
||
assert.match(
|
||
devApi,
|
||
/writeJsonAtomic\(modelChannelsPath\(\),\s*normalized,\s*\{\s*backup:\s*true\s*\}\)/,
|
||
'渠道密钥文件也必须保留备份',
|
||
)
|
||
})
|
||
|
||
test('桌面端渠道密钥文件使用安全替换、备份和回读校验', () => {
|
||
assert.match(rustMedia, /OpenOptions::new\(\)[\s\S]{0,500}sync_all\(\)/, '临时文件必须完整落盘')
|
||
assert.match(rustMedia, /JSON 写入后回读不一致/, '替换后必须回读验证')
|
||
assert.doesNotMatch(
|
||
rustMedia,
|
||
/if path\.exists\(\)[\s\S]{0,120}remove_file\(path\)/,
|
||
'不得先删除有效文件再尝试替换',
|
||
)
|
||
assert.match(
|
||
rustModule,
|
||
/channels_path\(\)[\s\S]{0,500}model-channels\.json\.bak|with_extension\("json\.bak"\)/,
|
||
'渠道密钥文件必须保留最后有效备份',
|
||
)
|
||
})
|
||
|
||
test('Hermes 同步在后端解析 OpenClaw 环境变量引用', () => {
|
||
assert.match(
|
||
rustHermes,
|
||
/super::config::resolve_model_api_key\(&api_key\)/,
|
||
'桌面端必须在写 Hermes .env 前解析环境变量引用',
|
||
)
|
||
assert.match(
|
||
devApi,
|
||
/hermes_sync_provider\([\s\S]{0,300}apiKey:\s*resolveModelApiKey\(apiKey\)/,
|
||
'Web 端必须在写 Hermes .env 前解析环境变量引用',
|
||
)
|
||
})
|
||
|
||
test('Hermes 配置事务在提交前落盘并在提交后回读', () => {
|
||
assert.match(rustHermes, /sync_all\(\)/, '桌面端 Hermes 临时文件必须 fsync')
|
||
assert.match(rustHermes, /Hermes 配置写入后回读不一致/, '桌面端 Hermes 提交后必须回读')
|
||
assert.match(
|
||
devApi,
|
||
/replaceHermesFilesTransaction[\s\S]{0,1600}fs\.fsyncSync\([\s\S]{0,800}Hermes 配置写入后回读不一致/,
|
||
'Web 端 Hermes 事务必须 fsync 并回读',
|
||
)
|
||
})
|
||
|
||
test('模型连通性测试不把不同协议静默降级成 Chat Completions', () => {
|
||
assert.match(rustConfig, /"openai-responses"\s*\|\s*"azure-openai-responses"[\s\S]{0,500}\/responses/)
|
||
assert.match(devApi, /\['openai-responses',\s*'azure-openai-responses'\][\s\S]{0,600}\/responses/)
|
||
assert.match(rustConfig, /该 API 类型需要由 OpenClaw 运行时验证/)
|
||
assert.match(devApi, /该 API 类型需要由 OpenClaw 运行时验证/)
|
||
assert.doesNotMatch(rustConfig, /返回成功但带提示[\s\S]{0,200}return Ok\(/)
|
||
assert.doesNotMatch(devApi, /return `⚠ 连接正常/)
|
||
})
|
||
|
||
test('同步状态只接受回读验证成功的记录', () => {
|
||
assert.match(page, /record\.verified\s*===\s*true/, '旧的未验证同步记录不得显示为已同步')
|
||
assert.match(page, /providerKey:\s*result\.providerKey,\s*verified:\s*result\.verified/)
|
||
assert.match(page, /providerId:\s*result\.providerId,\s*verified:\s*result\.verified/)
|
||
assert.match(page, /model:\s*result\.model,\s*verified:\s*result\.verified/)
|
||
})
|