chore(release): prepare v0.19.2

This commit is contained in:
晴天
2026-08-05 12:35:52 +08:00
parent cc3bf3b38b
commit 1ef5b45b5b
26 changed files with 707 additions and 83 deletions

View File

@@ -0,0 +1,20 @@
import assert from 'node:assert/strict'
import net from 'node:net'
import test from 'node:test'
import { probeTcpPort } from '../scripts/dev-api.js'
test('Web Gateway 端口探测在 ESM 运行时返回布尔值', async () => {
const server = net.createServer(socket => socket.end())
await new Promise((resolve, reject) => {
server.once('error', reject)
server.listen(0, '127.0.0.1', resolve)
})
const address = server.address()
assert.equal(typeof address, 'object')
assert.equal(await probeTcpPort(address.port, '127.0.0.1', 1000), true)
await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve()))
assert.equal(await probeTcpPort(address.port, '127.0.0.1', 1000), false)
})

View File

@@ -0,0 +1,117 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { syncProvidersToAgentModels } from '../scripts/dev-api.js'
function writeAgentModels(root, agentId, value) {
const file = path.join(root, 'agents', agentId, 'agent', 'models.json')
fs.mkdirSync(path.dirname(file), { recursive: true })
fs.writeFileSync(file, JSON.stringify(value, null, 2))
return file
}
function readJson(file) {
return JSON.parse(fs.readFileSync(file, 'utf8'))
}
test('Web API 同步 providers 时会更新每个 Agent 的连接信息和上下文元数据', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'clawpanel-models-sync-'))
try {
const oldModels = {
providers: {
lmstudio: {
baseUrl: 'http://127.0.0.1:1234/v1-old',
apiKey: 'old-key',
api: 'openai-completions',
models: [{
id: 'qwen-local',
name: 'Qwen Local',
contextWindow: 50000,
contextTokens: 50000,
maxTokens: 4096,
customRuntimeFlag: true,
}],
modelOverrides: {
'qwen-local': { contextWindow: 50000, contextTokens: 50000 },
},
},
stale: { baseUrl: 'http://stale.example/v1', models: [{ id: 'old' }] },
},
}
const mainPath = writeAgentModels(tmp, 'main', oldModels)
const workerPath = writeAgentModels(tmp, 'worker', oldModels)
const result = syncProvidersToAgentModels({
agents: { list: [{ id: 'worker' }] },
models: {
providers: {
lmstudio: {
baseUrl: 'http://127.0.0.1:1234/v1',
apiKey: 'new-key',
api: 'openai-completions',
models: [{
id: 'qwen-local',
name: 'Qwen Local',
contextWindow: 131072,
contextTokens: 131072,
maxTokens: 8192,
}, { id: 'new-local', name: 'New Local', contextWindow: 65536 }],
},
},
},
}, tmp)
assert.deepEqual(result.updated.sort(), [mainPath, workerPath].sort())
for (const file of [mainPath, workerPath]) {
const synced = readJson(file)
assert.equal(synced.providers.lmstudio.baseUrl, 'http://127.0.0.1:1234/v1')
assert.equal(synced.providers.lmstudio.apiKey, 'new-key')
assert.equal(synced.providers.stale, undefined)
const local = synced.providers.lmstudio.models.find(model => model.id === 'qwen-local')
assert.equal(local.contextWindow, 131072)
assert.equal(local.contextTokens, 131072)
assert.equal(local.maxTokens, 8192)
assert.equal(local.customRuntimeFlag, true)
assert.equal(synced.providers.lmstudio.modelOverrides['qwen-local'].contextWindow, 131072)
assert.equal(synced.providers.lmstudio.modelOverrides['qwen-local'].contextTokens, 131072)
assert.equal(synced.providers.lmstudio.models.some(model => model.id === 'new-local'), true)
}
} finally {
fs.rmSync(tmp, { recursive: true, force: true })
}
})
test('Web API 同步保留 Agent 运行时手动添加的模型', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'clawpanel-models-sync-'))
try {
const modelsPath = writeAgentModels(tmp, 'main', {
providers: {
lmstudio: {
models: [
{ id: 'qwen-local', contextWindow: 50000 },
{ id: 'manual-only', contextWindow: 32768 },
],
},
},
})
syncProvidersToAgentModels({
models: {
providers: {
lmstudio: {
models: [{ id: 'qwen-local', contextWindow: 65536 }],
},
},
},
}, tmp)
const synced = readJson(modelsPath)
assert.equal(synced.providers.lmstudio.models.find(model => model.id === 'qwen-local').contextWindow, 65536)
assert.equal(synced.providers.lmstudio.models.some(model => model.id === 'manual-only'), true)
} finally {
fs.rmSync(tmp, { recursive: true, force: true })
}
})

View File

@@ -0,0 +1,29 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import test from 'node:test'
const dockerfile = fs.readFileSync(new URL('../Dockerfile', import.meta.url), 'utf8')
const dockerignore = fs.readFileSync(new URL('../.dockerignore', import.meta.url), 'utf8')
const compose = fs.readFileSync(new URL('../docker-compose.yml', import.meta.url), 'utf8')
test('Docker 生产镜像复用官方 node 用户,避免 Alpine UID/GID 1000 冲突', () => {
assert.doesNotMatch(dockerfile, /addgroup\s+-g\s+1000/)
assert.match(dockerfile, /--chown=node:node/)
assert.match(dockerfile, /chown -R node:node \/app/)
})
test('Docker 构建上下文和生产镜像包含 Web API 运行时依赖', () => {
assert.match(dockerignore, /!public\/\*\*/)
assert.match(dockerfile, /COPY public\/ \.\/public\//)
assert.match(dockerignore, /!scripts\/dev-api\.js/)
assert.match(dockerignore, /!scripts\/media-background-queue\.js/)
assert.match(dockerignore, /!scripts\/lib\/\*\*/)
assert.match(dockerfile, /\/build\/src\/lib\/model-presets\.js/)
})
test('Docker 健康检查跟随 Web 自定义端口', () => {
assert.match(dockerfile, /ENV PORT=1420/)
assert.match(dockerfile, /localhost:\$\{PORT:-1420\}\/__api\/health/)
assert.match(compose, /PORT=\$\{CLAWPANEL_PORT:-1420\}/)
assert.match(compose, /localhost:\$\$\{PORT:-1420\}\/__api\/health/)
})

View File

@@ -0,0 +1,46 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { parseHermesProfileListOutput } from '../scripts/dev-api.js'
const profilePage = readFileSync(new URL('../src/engines/hermes/pages/profiles.js', import.meta.url), 'utf8')
const chatPage = readFileSync(new URL('../src/engines/hermes/pages/chat.js', import.meta.url), 'utf8')
const componentCss = readFileSync(new URL('../src/style/components.css', import.meta.url), 'utf8')
test('Hermes 新版 profile list 的星号格式可以正确解析', () => {
const result = parseHermesProfileListOutput(`Available profiles:\n default\n* work\n dev\n personal`)
assert.equal(result.active, 'work')
assert.deepEqual(result.profiles.map(profile => profile.name), ['default', 'work', 'dev', 'personal'])
assert.equal(result.profiles.find(profile => profile.name === 'work')?.active, true)
assert.equal(result.profiles.find(profile => profile.name === 'default')?.active, false)
})
test('Hermes 旧版 profile 表格仍保留模型和 Gateway 状态', () => {
const result = parseHermesProfileListOutput([
'Profile Model Gateway Alias',
'◆ default anthropic/claude stopped —',
' work openai/gpt running work',
].join('\n'))
assert.equal(result.active, 'default')
assert.deepEqual(result.profiles, [
{ name: 'default', active: true, model: 'anthropic/claude', gatewayRunning: false, alias: '' },
{ name: 'work', active: false, model: 'openai/gpt', gatewayRunning: true, alias: 'work' },
])
})
test('Hermes Profile 页面提供按 Profile 写入模型的入口', () => {
assert.match(profilePage, /hermesDashboardApi\('PUT', `\/api\/profiles\/\$\{encodeURIComponent\(name\)\}\/model`/)
assert.match(profilePage, /data-action="configure"/)
})
test('Hermes 聊天页提供直接启动 Gateway 的入口', () => {
assert.match(chatPage, /id="hm-chat-start-gateway"/)
assert.match(chatPage, /hermesGatewayAction\('start'\)/)
})
test('Hermes Profile 移动端操作按钮满足触控高度', () => {
assert.match(componentCss, /\.lazy-deps-card-actions \.btn\s*\{[^}]*min-height:\s*40px/s)
})

View File

@@ -28,6 +28,10 @@ test('Linux Web 构建后必须重启已有服务并输出实际版本', () => {
assert.match(script, /ClawPanel 版本:.*PANEL_VERSION/)
})
test('Linux Web 部署允许通过环境变量切换到未占用端口', () => {
assert.match(script, /PANEL_PORT="\$\{CLAWPANEL_PORT:-\$\{PANEL_PORT:-1420\}\}"/)
})
test('Linux Web 升级文档必须区分 system 与 user 服务并提供可诊断命令', () => {
for (const [name, content] of [
['README.md', readme],

View File

@@ -17,8 +17,18 @@ const originalApi = {
readOpenclawConfig: api.readOpenclawConfig,
writeOpenclawConfig: api.writeOpenclawConfig,
revealModelChannelKey: api.revealModelChannelKey,
probeGatewayPort: api.probeGatewayPort,
restartGateway: api.restartGateway,
reloadGateway: api.reloadGateway,
}
test.beforeEach(() => {
// 运行时应用动作由专门测试覆盖;其余同步测试保持纯配置回读。
api.probeGatewayPort = async () => false
api.restartGateway = async () => true
api.reloadGateway = async () => true
})
function restoreApi() {
Object.assign(api, originalApi)
storage.clear()
@@ -138,6 +148,23 @@ test('同步旧 Codex 渠道时写入 7.1 正式 API 类型', async () => {
assert.equal(written.models.providers.legacy.api, 'openai-chatgpt-responses')
})
test('Web 模型渠道同步后会重启运行中的 Gateway 以加载 Agent 模型注册表', async () => {
let written = null
let restartCount = 0
api.probeGatewayPort = async () => true
api.restartGateway = async () => { restartCount += 1 }
api.revealModelChannelKey = async () => 'sk-test'
api.readOpenclawConfig = async () => written || ({ models: { providers: {} } })
api.writeOpenclawConfig = async config => { written = config }
await channels.syncChannelToOpenclaw({
id: 'lmstudio', name: 'LM Studio', baseUrl: 'http://127.0.0.1:1234/v1',
apiType: 'openai-completions', models: [{ id: 'qwen-local' }], defaultModel: 'qwen-local',
})
assert.equal(restartCount, 1)
})
test('OpenClaw 同步必须通过目标配置回读核对', async () => {
api.revealModelChannelKey = async () => 'sk-test'
api.readOpenclawConfig = async () => ({ models: { providers: {} } })