chore(release): prepare v0.18.6

This commit is contained in:
晴天
2026-07-14 23:25:42 -07:00
parent 46c9c7743b
commit 90df159d5e
39 changed files with 3349 additions and 909 deletions

View File

@@ -0,0 +1,32 @@
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 { syncHermesProviderFilesAt } from '../scripts/dev-api.js'
test('Hermes 模型同步仅在事务回读成功后返回 verified', () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'clawpanel-hermes-sync-'))
try {
const oldEnv = 'KEEP=value\n'
const oldConfig = 'model:\n default: old-model\nlogging:\n level: INFO\n'
fs.writeFileSync(path.join(home, '.env'), oldEnv)
fs.writeFileSync(path.join(home, 'config.yaml'), oldConfig)
const result = syncHermesProviderFilesAt(home, {
provider: 'custom',
apiKey: 'sk-test-only',
baseUrl: 'https://example.com/v1',
model: 'gpt-test',
setDefault: true,
})
assert.equal(result.verified, true)
assert.equal(result.providerId, 'custom')
assert.match(fs.readFileSync(path.join(home, '.env'), 'utf8'), /OPENAI_API_KEY=sk-test-only/)
assert.match(fs.readFileSync(path.join(home, 'config.yaml'), 'utf8'), /default: gpt-test/)
assert.equal(fs.readFileSync(path.join(home, '.env.bak'), 'utf8'), oldEnv)
assert.equal(fs.readFileSync(path.join(home, 'config.yaml.bak'), 'utf8'), oldConfig)
} finally {
fs.rmSync(home, { recursive: true, force: true })
}
})

View File

@@ -59,5 +59,63 @@ test('Web install_hermes does not block the server event loop with spawnSync', (
const body = source.match(/async install_hermes\([^]*?\n \},\n\n async configure_hermes/)?.[0]
assert.ok(body, 'install_hermes handler must be present')
assert.doesNotMatch(body, /spawnSync\s*\(/)
assert.match(body, /await runHermesInstallCommand\s*\(/)
assert.match(body, /await runHermesInstallWithCacheRecovery\s*\(/)
})
test('Hermes rejects uv releases older than the archive collision fix', async () => {
const { isHermesUvVersionSupported } = await import('../scripts/dev-api.js')
assert.equal(isHermesUvVersionSupported('uv 0.11.14 (3fdfdc7d4 2026-05-12 x86_64-pc-windows-msvc)'), false)
assert.equal(isHermesUvVersionSupported('uv 0.11.24 (5e04460 2026-06-23 x86_64-pc-windows-msvc)'), true)
assert.equal(isHermesUvVersionSupported('uv 0.11.28 (ebf0f43 2026-07-07 x86_64-pc-windows-msvc)'), true)
})
test('Web Hermes install isolates the cache for unsafe uv and retries cache metadata failures', async () => {
const { runHermesInstallWithCacheRecovery } = await import('../scripts/dev-api.js')
const oldUvCalls = []
const oldUvResult = await runHermesInstallWithCacheRecovery(
async (_program, args) => {
oldUvCalls.push(args)
return { status: 0, stdout: 'ok', stderr: '' }
},
'uv',
['tool', 'install', 'hermes-agent'],
{},
'uv 0.11.14',
)
assert.equal(oldUvResult.status, 0)
assert.equal(oldUvCalls.length, 1)
assert.ok(oldUvCalls[0].includes('--no-cache'))
const retryCalls = []
const retryResult = await runHermesInstallWithCacheRecovery(
async (_program, args) => {
retryCalls.push(args)
if (retryCalls.length === 1) {
return {
status: 2,
stdout: '',
stderr: 'The wheel is invalid: Metadata field Name not found',
}
}
return { status: 0, stdout: 'ok', stderr: '' }
},
'uv',
['tool', 'install', 'hermes-agent'],
{},
'uv 0.11.28',
)
assert.equal(retryResult.status, 0)
assert.equal(retryCalls.length, 2)
assert.equal(retryCalls[0].includes('--no-cache'), false)
assert.ok(retryCalls[1].includes('--no-cache'))
})
test('Tauri Hermes installer pins a uv release with the archive collision fix', () => {
const source = fs.readFileSync(path.join(root, 'src-tauri/src/commands/hermes.rs'), 'utf8')
assert.match(source, /const HERMES_UV_VERSION: &str = "0\.11\.28"/)
assert.match(source, /const HERMES_MIN_UV_VERSION: &str = "0\.11\.24"/)
assert.match(source, /is_uv_wheel_cache_error/)
assert.match(source, /"--no-cache"/)
})

View File

@@ -0,0 +1,87 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import test from 'node:test'
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const EXPECTED_VERSION = '0.18.2'
const EXPECTED_TAG = 'v2026.7.7.2'
function readSource(relativePath) {
return fs.readFileSync(path.join(root, relativePath), 'utf8')
}
test('Web and Tauri installers pin the supported Hermes stable release', () => {
const webSource = readSource('scripts/dev-api.js')
const tauriSource = readSource('src-tauri/src/commands/hermes.rs')
assert.match(webSource, new RegExp(`HERMES_STABLE_VERSION = '${EXPECTED_VERSION}'`))
assert.match(webSource, new RegExp(`HERMES_STABLE_TAG = '${EXPECTED_TAG.replaceAll('.', '\\.')}'`))
assert.match(webSource, /git\+\$\{HERMES_REPO_URL\}@\$\{HERMES_STABLE_TAG\}/)
assert.match(tauriSource, new RegExp(`HERMES_STABLE_VERSION: &str = "${EXPECTED_VERSION}"`))
assert.match(tauriSource, new RegExp(`HERMES_STABLE_TAG: &str = "${EXPECTED_TAG.replaceAll('.', '\\.')}"`))
assert.match(tauriSource, /git\+\{HERMES_GIT_REPO_URL\}@\{HERMES_STABLE_TAG\}/)
})
test('Web install and update paths share the Hermes runtime dependency list', () => {
const webSource = readSource('scripts/dev-api.js')
const installBody = webSource.match(/async install_hermes\([^]*?\n \},\n\n async configure_hermes/)?.[0]
const updateBody = webSource.match(/async update_hermes\([^]*?\n \},\n\n async uninstall_hermes/)?.[0]
assert.ok(installBody, 'install_hermes handler must be present')
assert.ok(updateBody, 'update_hermes handler must be present')
assert.match(webSource, /const HERMES_RUNTIME_EXTRA_DEPS = \['croniter', 'httpx', 'openai', 'aiohttp', 'websockets'\]/)
assert.match(installBody, /\.\.\.hermesRuntimeExtraArgs\(\)/)
assert.match(updateBody, /\.\.\.hermesRuntimeExtraArgs\(\)/)
})
test('uv pip fallback installers include the required Hermes runtime dependencies', () => {
const webSource = readSource('scripts/dev-api.js')
const tauriSource = readSource('src-tauri/src/commands/hermes.rs')
const webInstallBody = webSource.match(/async install_hermes\([^]*?\n \},\n\n async configure_hermes/)?.[0]
const tauriPipBody = tauriSource.match(/async fn install_via_uv_pip\([^]*?\n}\n\n\/\/ /)?.[0]
assert.ok(webInstallBody, 'Web install_hermes handler must be present')
assert.ok(tauriPipBody, 'Tauri install_via_uv_pip helper must be present')
assert.match(webInstallBody, /\['pip', 'install', pkg, \.\.\.HERMES_RUNTIME_EXTRA_DEPS\]/)
assert.match(tauriPipBody, /pip_cmd\.args\(HERMES_RUNTIME_EXTRA_DEPS\)/)
assert.match(tauriPipBody, /is_uv_wheel_cache_error/)
assert.match(tauriPipBody, /build_pip_command\(true\)/)
})
test('ClawPanel provides a token-bootstrap Dashboard dist for Hermes 0.18.2', async () => {
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'clawpanel-hermes-dashboard-'))
try {
const { ensureHermesDashboardFallbackDist } = await import('../scripts/dev-api.js')
const dist = ensureHermesDashboardFallbackDist(tempHome)
const indexPath = path.join(dist, 'index.html')
assert.equal(dist, path.join(tempHome, 'clawpanel-dashboard-web-dist'))
assert.equal(fs.statSync(path.join(dist, 'assets')).isDirectory(), true)
assert.match(fs.readFileSync(indexPath, 'utf8'), /clawpanel-dashboard-spa-stub/)
fs.writeFileSync(indexPath, 'preserve-existing-dashboard-index')
ensureHermesDashboardFallbackDist(tempHome)
assert.equal(fs.readFileSync(indexPath, 'utf8'), 'preserve-existing-dashboard-index')
} finally {
fs.rmSync(tempHome, { recursive: true, force: true })
}
})
test('Web and Tauri Dashboard launchers use the managed dist without opening a browser', () => {
const webSource = readSource('scripts/dev-api.js')
const tauriSource = readSource('src-tauri/src/commands/hermes.rs')
const webStartBody = webSource.match(/async hermes_dashboard_start\([^]*?\n \},\n\n async hermes_dashboard_stop/)?.[0]
const tauriStartBody = tauriSource.match(/pub async fn hermes_dashboard_start\([^]*?\n}\n\n/)?.[0]
assert.ok(webStartBody, 'Web hermes_dashboard_start handler must be present')
assert.ok(tauriStartBody, 'Tauri hermes_dashboard_start command must be present')
assert.match(webStartBody, /envVars\.HERMES_WEB_DIST = ensureHermesDashboardFallbackDist\(home\)/)
assert.match(webStartBody, /spawn\('hermes', \['dashboard', '--no-open'\]/)
assert.match(tauriStartBody, /ensure_hermes_dashboard_fallback_dist\(&home\)/)
assert.match(tauriStartBody, /cmd\.args\(\["dashboard", "--no-open"\]\)/)
assert.match(tauriStartBody, /cmd\.env\("HERMES_WEB_DIST", dashboard_dist\)/)
})

View File

@@ -0,0 +1,71 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
let createBackgroundJobQueue
try {
({ createBackgroundJobQueue } = await import('../scripts/media-background-queue.js'))
} catch {}
const rustSource = readFileSync(new URL('../src-tauri/src/commands/media.rs', import.meta.url), 'utf8')
const webSource = readFileSync(new URL('../scripts/dev-api.js', import.meta.url), 'utf8')
const pageSource = readFileSync(new URL('../src/pages/media.js', import.meta.url), 'utf8')
async function waitFor(predicate, timeoutMs = 500) {
const deadline = Date.now() + timeoutMs
while (!predicate()) {
if (Date.now() >= deadline) throw new Error('等待后台队列状态超时')
await new Promise(resolve => setTimeout(resolve, 5))
}
}
test('后台队列限制并发并拒绝重复入队', async () => {
assert.equal(typeof createBackgroundJobQueue, 'function', '应提供可复用的后台任务队列')
const releases = []
const started = []
let active = 0
let maxActive = 0
const queue = createBackgroundJobQueue({
concurrency: 2,
worker: async jobId => {
started.push(jobId)
active += 1
maxActive = Math.max(maxActive, active)
await new Promise(resolve => releases.push(resolve))
active -= 1
},
})
assert.equal(queue.enqueue('job-1'), true)
assert.equal(queue.enqueue('job-1'), false, '同一任务不能重复消费')
assert.equal(queue.enqueue('job-2'), true)
assert.equal(queue.enqueue('job-3'), true)
await waitFor(() => started.length === 2)
assert.equal(maxActive, 2)
assert.deepEqual(started, ['job-1', 'job-2'])
releases.shift()()
await waitFor(() => started.length === 3)
assert.equal(started[2], 'job-3')
while (releases.length) releases.shift()()
await queue.whenIdle()
assert.deepEqual(queue.stats(), { active: 0, pending: 0 })
})
test('Tauri 和 Web 提交立即持久化 queued 并由后台恢复消费', () => {
for (const [label, source] of [['Tauri', rustSource], ['Web', webSource]]) {
assert.match(source, /MEDIA_QUEUE_CONCURRENCY/, `${label} 应限制媒体生成并发`)
assert.match(source, /status["']?\s*[:=]\s*["']queued["']|["']status["']\s*:\s*["']queued["']/, `${label} 应持久化 queued 状态`)
assert.match(source, /schedule_media_job|queueMediaJob/, `${label} 应在后台调度任务`)
assert.match(source, /recover_media_queue|recoverMediaQueue/, `${label} 应恢复持久化的未完成任务`)
}
})
test('创作中心提供生成队列、自动刷新并在提交后解锁当前表单', () => {
assert.match(pageSource, /['"]queue['"][\s\S]*media\.tabQueue/, '页面应提供生成队列页签')
assert.match(pageSource, /setInterval\([\s\S]*refresh/, '页面应自动刷新任务状态')
assert.match(pageSource, /export function cleanup\(/, '离开页面时应停止自动刷新')
assert.match(pageSource, /querySelector\(['"]#media-image-form button\[type="submit"\]/, '图片提交后应解锁当前表单按钮')
assert.match(pageSource, /querySelector\(['"]#media-video-form button\[type="submit"\]/, '视频提交后应解锁当前表单按钮')
})

View File

@@ -1,100 +1,219 @@
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 { api } from '../src/lib/tauri-api.js'
import { syncChannelToHermes } from '../src/lib/model-channels.js'
globalThis.window = { location: { hostname: 'localhost' } }
const storage = new Map()
globalThis.localStorage = {
getItem: key => storage.get(key) ?? null,
setItem: (key, value) => storage.set(key, value),
removeItem: key => storage.delete(key),
}
test('Hermes 渠道同步使用专用原子命令而不是通用 env 编辑器', async () => {
const originals = {
hermesListProviders: api.hermesListProviders,
revealModelChannelKey: api.revealModelChannelKey,
hermesEnvSet: api.hermesEnvSet,
hermesSyncProvider: api.hermesSyncProvider,
const { api } = await import('../src/lib/tauri-api.js')
const channels = await import('../src/lib/model-channels.js')
const devApi = await import('../scripts/dev-api.js')
const originalApi = {
readOpenclawConfig: api.readOpenclawConfig,
writeOpenclawConfig: api.writeOpenclawConfig,
revealModelChannelKey: api.revealModelChannelKey,
}
function restoreApi() {
Object.assign(api, originalApi)
storage.clear()
}
test.afterEach(restoreApi)
test('渠道指纹覆盖模型能力元数据和凭据版本', () => {
const base = {
id: 'ch-1', name: '渠道', baseUrl: 'https://example.com/v1',
apiType: 'openai-responses', apiKeyMask: 'sk-***same', credentialVersion: 1,
models: [{ id: 'vision', input: ['text', 'image'], reasoning: true, maxTokens: 4096 }],
defaultModel: 'vision',
}
assert.notEqual(
channels.channelFingerprint(base),
channels.channelFingerprint({ ...base, models: [{ ...base.models[0], maxTokens: 8192 }] }),
)
assert.notEqual(
channels.channelFingerprint(base),
channels.channelFingerprint({ ...base, credentialVersion: 2 }),
)
})
let request = null
try {
api.hermesListProviders = async () => [{
id: 'custom',
authType: 'api_key',
baseUrl: '',
baseUrlEnvVar: 'OPENAI_BASE_URL',
apiKeyEnvVars: ['OPENAI_API_KEY', 'CUSTOM_API_KEY'],
}]
api.revealModelChannelKey = async () => 'sk-runtime-test'
api.hermesEnvSet = async () => {
throw new Error('通用 env 编辑器不应被模型渠道同步调用')
}
api.hermesSyncProvider = async payload => {
request = payload
return { providerId: payload.provider, envKey: 'OPENAI_API_KEY' }
}
test('从 OpenClaw 导入渠道时保留完整模型能力字段', async () => {
api.readOpenclawConfig = async () => ({
models: {
providers: {
rich: {
baseUrl: 'https://example.com/v1',
api: 'openai-responses',
apiKey: '${RICH_API_KEY}',
headers: { 'x-tenant': 'tenant-a' },
models: [{
id: 'vision', name: 'Vision', input: ['text', 'image'], reasoning: true,
contextWindow: 200000, contextTokens: 160000, maxTokens: 8192,
compat: { supportsDeveloperRole: false }, cost: { input: 1, output: 2 },
}],
},
},
},
})
const result = await syncChannelToHermes({
id: 'channel-1',
presetKey: '',
apiType: 'openai-completions',
baseUrl: 'https://gateway.example/v1',
const [channel] = await channels.importChannelsFromOpenclaw([])
assert.deepEqual(channel.models[0].input, ['text', 'image'])
assert.equal(channel.models[0].reasoning, true)
assert.equal(channel.models[0].contextTokens, 160000)
assert.equal(channel.models[0].maxTokens, 8192)
assert.deepEqual(channel.models[0].compat, { supportsDeveloperRole: false })
assert.deepEqual(channel.models[0].cost, { input: 1, output: 2 })
assert.deepEqual(channel.providerConfig.headers, { 'x-tenant': 'tenant-a' })
})
test('从 OpenClaw 导入和同步渠道时原样保留结构化 SecretRef', async () => {
const secretRef = { source: 'env', provider: 'default', id: 'RICH_API_KEY' }
let written = null
api.readOpenclawConfig = async () => written
? { models: { providers: written.models.providers } }
: ({
models: {
providers: {
rich: {
baseUrl: 'https://example.com/v1',
api: 'openai-responses',
apiKey: secretRef,
models: [{ id: 'vision', name: 'Vision' }],
},
},
},
})
api.revealModelChannelKey = async () => {
throw new Error('SecretRef 同步 OpenClaw 时不应读取明文')
}
api.writeOpenclawConfig = async patch => { written = patch }
const [channel] = await channels.importChannelsFromOpenclaw([])
assert.equal(channel.apiKey, '')
assert.deepEqual(channel.apiKeyRef, secretRef)
const result = await channels.syncChannelToOpenclaw(channel)
assert.equal(result.verified, true)
assert.deepEqual(written.models.providers.rich.apiKey, secretRef)
})
test('Web 渠道存储保留 SecretRef并允许新明文 Key 显式替换', () => {
assert.equal(typeof devApi.normalizeModelChannelsDoc, 'function')
const secretRef = { source: 'file', provider: 'default', id: 'providers/rich/apiKey' }
const input = {
channels: [{
id: 'secret-ref', name: 'Secret Ref', baseUrl: 'https://example.com/v1',
apiType: 'openai-responses', apiKey: '', apiKeyRef: secretRef,
models: [{ id: 'vision' }],
}],
}
const stored = devApi.normalizeModelChannelsDoc(input, null)
assert.deepEqual(stored.channels[0].apiKeyRef, secretRef)
assert.equal(stored.channels[0].apiKey, '')
const replaced = devApi.normalizeModelChannelsDoc({
channels: [{ ...stored.channels[0], apiKey: 'sk-new' }],
}, stored)
assert.equal(replaced.channels[0].apiKey, 'sk-new')
assert.equal('apiKeyRef' in replaced.channels[0], false)
})
test('同步旧 Codex 渠道时写入 7.1 正式 API 类型', async () => {
let written = null
api.revealModelChannelKey = async () => 'sk-test'
api.readOpenclawConfig = async () => written || ({ models: { providers: {} } })
api.writeOpenclawConfig = async config => { written = config; return { verified: true } }
await channels.syncChannelToOpenclaw({
id: 'legacy', name: 'Legacy', baseUrl: 'https://example.com/v1',
apiType: 'openai-codex-responses', models: [{ id: 'gpt-test' }], defaultModel: 'gpt-test',
})
assert.equal(written.models.providers.legacy.api, 'openai-chatgpt-responses')
})
test('OpenClaw 同步必须通过目标配置回读核对', async () => {
api.revealModelChannelKey = async () => 'sk-test'
api.readOpenclawConfig = async () => ({ models: { providers: {} } })
api.writeOpenclawConfig = async () => ({ verified: true })
await assert.rejects(
channels.syncChannelToOpenclaw({
id: 'verify', name: 'Verify', baseUrl: 'https://example.com/v1',
apiType: 'openai-responses', models: [{ id: 'gpt-test', maxTokens: 1024 }],
defaultModel: 'gpt-test',
}, { setDefault: true })
assert.deepEqual(request, {
provider: 'custom',
apiKey: 'sk-runtime-test',
baseUrl: 'https://gateway.example/v1',
model: 'gpt-test',
setDefault: true,
})
assert.equal(result.providerId, 'custom')
} finally {
Object.assign(api, originals)
}
}),
/回读|核对|verify/i,
)
})
test('Web Hermes Provider 同步保留其它 Provider 凭据', async () => {
const devApi = await import('../scripts/dev-api.js')
assert.equal(typeof devApi.syncHermesProviderFilesAt, 'function')
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'clawpanel-hermes-sync-'))
try {
fs.writeFileSync(path.join(home, '.env'), [
'ANTHROPIC_API_KEY=keep-me',
'OPENAI_API_KEY=old',
'CUSTOM_FLAG=keep',
'',
].join('\n'))
fs.writeFileSync(path.join(home, 'config.yaml'), [
'model:',
' default: old-model',
' provider: anthropic',
'logging:',
' level: INFO',
'',
].join('\n'))
devApi.syncHermesProviderFilesAt(home, {
provider: 'custom',
apiKey: 'sk-new',
baseUrl: 'https://gateway.example/v1',
model: 'gpt-test',
setDefault: true,
})
const env = fs.readFileSync(path.join(home, '.env'), 'utf8')
assert.match(env, /ANTHROPIC_API_KEY=keep-me/)
assert.match(env, /CUSTOM_FLAG=keep/)
assert.match(env, /OPENAI_API_KEY=sk-new/)
assert.match(env, /CUSTOM_API_KEY=sk-new/)
assert.match(env, /OPENAI_BASE_URL=https:\/\/gateway\.example\/v1/)
const config = fs.readFileSync(path.join(home, 'config.yaml'), 'utf8')
assert.match(config, /default: gpt-test/)
assert.match(config, /provider: custom/)
assert.match(config, /level: INFO/)
} finally {
fs.rmSync(home, { recursive: true, force: true })
test('OpenClaw 同步只发送目标 provider 的最小补丁', async () => {
let written = null
const existing = {
gateway: { port: 18789, auth: { token: 'keep-private' } },
models: { providers: { keep: { api: 'openai-completions', models: [] } } },
}
api.revealModelChannelKey = async () => 'sk-target'
api.readOpenclawConfig = async () => written
? { ...existing, models: { providers: { ...existing.models.providers, ...written.models.providers } } }
: existing
api.writeOpenclawConfig = async patch => { written = patch }
await channels.syncChannelToOpenclaw({
id: 'target', name: 'Target', baseUrl: 'https://example.com/v1',
apiType: 'openai-responses', models: [{ id: 'gpt-test' }], defaultModel: 'gpt-test',
})
assert.deepEqual(Object.keys(written), ['models'])
assert.deepEqual(Object.keys(written.models.providers), ['target'])
assert.equal(written.models.providers.target.apiKey, 'sk-target')
})
test('OpenClaw 同步回读必须核对凭据', async () => {
api.revealModelChannelKey = async () => 'sk-expected'
api.readOpenclawConfig = async () => ({
models: { providers: { target: {
baseUrl: 'https://example.com/v1', api: 'openai-responses', apiKey: 'sk-wrong',
models: [{ id: 'gpt-test', name: 'gpt-test' }],
} } },
})
api.writeOpenclawConfig = async () => undefined
await assert.rejects(
channels.syncChannelToOpenclaw({
id: 'target', name: 'Target', baseUrl: 'https://example.com/v1',
apiType: 'openai-responses', models: [{ id: 'gpt-test' }], defaultModel: 'gpt-test',
}),
/回读核对失败/,
)
})
test('助手同步拒绝把环境变量引用当成 API Key 保存', () => {
assert.throws(
() => channels.syncChannelToAssistant({ baseUrl: 'https://example.com', apiType: 'openai-completions' }, '${OPENAI_API_KEY}'),
/环境变量|env/i,
)
assert.equal(storage.has(channels.ASSISTANT_STORAGE_KEY), false)
})
test('助手同步仅在 localStorage 回读一致后返回 verified', () => {
const result = channels.syncChannelToAssistant({
baseUrl: 'https://example.com/v1',
apiType: 'openai-responses',
defaultModel: 'gpt-test',
}, 'sk-test', 'gpt-test')
assert.equal(result.verified, true)
assert.deepEqual(JSON.parse(storage.get(channels.ASSISTANT_STORAGE_KEY)), {
baseUrl: 'https://example.com/v1',
apiKey: 'sk-test',
model: 'gpt-test',
apiType: 'openai-responses',
})
})

View File

@@ -6,12 +6,16 @@ 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', () => {
@@ -51,7 +55,17 @@ test('OpenClaw 模型条目恒写完整对象(内核 strict schema 要求 id/n
test('OpenClaw 同步只 upsert 单个 provider 并保留未知字段', () => {
assert.match(lib, /\.\.\.existing,/, '写入 provider 时必须展开旧对象保留未知字段')
assert.match(lib, /config\.models\.providers\[providerKey\]/, '必须按 provider 键 upsert 而非整体重写')
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('同步与删除必须经过确认弹窗', () => {
@@ -67,3 +81,82 @@ test('页面注册链完整(路由 + 侧栏 + 语言包)', () => {
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/)
})

View File

@@ -1,11 +1,38 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import {
API_TYPES,
PROVIDER_PRESETS,
MODEL_PRESETS,
} from '../src/lib/model-presets.js'
import * as presets from '../src/lib/model-presets.js'
const { API_TYPES, PROVIDER_PRESETS, MODEL_PRESETS } = presets
test('OpenClaw 7.1 API 类型与上游契约一致', () => {
assert.deepEqual(API_TYPES.map(item => item.value), [
'openai-completions',
'openai-responses',
'openai-chatgpt-responses',
'anthropic-messages',
'google-generative-ai',
'google-vertex',
'github-copilot',
'bedrock-converse-stream',
'ollama',
'azure-openai-responses',
])
})
test('旧 Codex Responses API 类型迁移到 7.1 正式名称', () => {
assert.equal(typeof presets.normalizeModelApiType, 'function')
assert.equal(presets.normalizeModelApiType('openai-codex-responses'), 'openai-chatgpt-responses')
assert.equal(presets.normalizeModelApiType('openai-responses'), 'openai-responses')
assert.equal(presets.normalizeModelApiType('future-adapter'), 'future-adapter')
})
test('编辑未知 OpenClaw API 类型时保留原值供用户选择', () => {
const options = presets.modelApiTypeOptions('future-adapter')
assert.equal(options[0].value, 'future-adapter')
assert.equal(options.filter(item => item.value === 'future-adapter').length, 1)
assert.ok(options.some(item => item.value === 'openai-completions'))
})
// ===== Provider Presets =====

View File

@@ -7,6 +7,9 @@ const featureCatalog = readFileSync(new URL('../src/lib/feature-catalog.js', imp
const linuxDeploy = readFileSync(new URL('../scripts/linux-deploy.sh', import.meta.url), 'utf8')
const webBackend = readFileSync(new URL('../scripts/dev-api.js', import.meta.url), 'utf8')
const desktopDevice = readFileSync(new URL('../src-tauri/src/commands/device.rs', import.meta.url), 'utf8')
const desktopConfig = readFileSync(new URL('../src-tauri/src/commands/config.rs', import.meta.url), 'utf8')
const desktopService = readFileSync(new URL('../src-tauri/src/commands/service.rs', import.meta.url), 'utf8')
const chatPage = readFileSync(new URL('../src/pages/chat.js', import.meta.url), 'utf8')
test('ClawPanel recommends the matching official and Chinese 2026.7.1 stable builds', () => {
assert.equal(policy.default.official.recommended, '2026.7.1')
@@ -35,3 +38,37 @@ test('Gateway connect frames retain a range that overlaps OpenClaw 2026.7.1 prot
assert.match(desktopDevice, /"minProtocol": 3/)
assert.match(desktopDevice, /"maxProtocol": 4/)
})
test('OpenClaw 2026.7.1 config reload uses the kernel watcher without probing panel ports', () => {
assert.match(desktopConfig, /fn\s+supports_native_config_reload\s*\(/)
assert.match(desktopConfig, /OPENCLAW_NATIVE_CONFIG_RELOAD_VERSION_FLOOR:\s*&str\s*=\s*"2026\.7\.1"/)
assert.doesNotMatch(desktopConfig, /control_ports\s*=\s*\[gw_port\s*\+\s*2,\s*18792\]/)
assert.doesNotMatch(desktopConfig, /__api\/reload/)
assert.match(
desktopConfig,
/pub\s+async\s+fn\s+reload_gateway[\s\S]*?reload_gateway_internal\(Some\(&app\)\)\.await/,
)
assert.match(
desktopConfig,
/pub\s+async\s+fn\s+restart_gateway[\s\S]*?restart_gateway_guarded\(Some\(&app\)\)\.await/,
)
assert.match(webBackend, /supportsNativeConfigReload\s*\(/)
})
test('Windows Gateway terminal closes after its managed process exits', () => {
assert.match(
desktopService,
/"cmd",\s*"\/D",\s*"\/C",\s*runner_path_str\.as_str\(\)/,
)
assert.doesNotMatch(
desktopService,
/"cmd",\s*"\/D",\s*"\/K",\s*runner_path_str\.as_str\(\)/,
)
assert.doesNotMatch(desktopService, /pause \^>nul/)
})
test('Chat can abort a run while waiting for the first response event', () => {
assert.match(chatPage, /let\s+_isAwaitingResponse\s*=\s*false/)
assert.match(chatPage, /if\s*\(_isStreaming\s*\|\|\s*_isAwaitingResponse\)\s*stopGeneration\(\)/)
assert.match(chatPage, /wsClient\.chatAbort\(_sessionKey,\s*_currentRunId\s*\|\|\s*undefined\)/)
})

View File

@@ -0,0 +1,81 @@
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 {
stripUiFields,
resolveModelApiKey,
validateModelProviderEnvRefs,
writeJsonAtomic,
} from '../scripts/dev-api.js'
test('Web 保存前把旧字符串模型迁移为 OpenClaw 7.1 完整对象', () => {
const config = {
models: {
providers: {
legacy: {
api: 'openai-codex-responses',
headers: { 'x-tenant': 'keep-me' },
models: ['legacy-string-model'],
},
},
},
}
const cleaned = stripUiFields(config)
assert.equal(cleaned.models.providers.legacy.api, 'openai-chatgpt-responses')
assert.deepEqual(cleaned.models.providers.legacy.models, [
{ id: 'legacy-string-model', name: 'legacy-string-model' },
])
assert.deepEqual(cleaned.models.providers.legacy.headers, { 'x-tenant': 'keep-me' })
})
test('Web JSON 原子写入保留最后有效备份并完成回读', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'clawpanel-config-write-'))
const target = path.join(root, 'openclaw.json')
try {
fs.writeFileSync(target, JSON.stringify({ keep: 'old' }))
writeJsonAtomic(target, { keep: 'new', nested: { ok: true } }, { backup: true })
assert.deepEqual(JSON.parse(fs.readFileSync(target, 'utf8')), { keep: 'new', nested: { ok: true } })
assert.deepEqual(JSON.parse(fs.readFileSync(`${target}.bak`, 'utf8')), { keep: 'old' })
} finally {
fs.rmSync(root, { recursive: true, force: true })
}
})
test('Web 保存无关配置时不阻断未改动的外部环境变量引用', () => {
const previous = {
models: { providers: { external: { apiKey: '${CLAWPANEL_TEST_EXTERNAL_ONLY}' } } },
}
const unchanged = {
...previous,
gateway: { port: 18789 },
}
assert.doesNotThrow(() => validateModelProviderEnvRefs(unchanged, previous))
const changed = {
models: { providers: { external: { apiKey: '${CLAWPANEL_TEST_NEW_MISSING}' } } },
}
assert.throws(
() => validateModelProviderEnvRefs(changed, previous),
/CLAWPANEL_TEST_NEW_MISSING/,
)
})
test('Web 模型测试在后端解析 env SecretRef 并拒绝 file/exec 伪明文', () => {
process.env.CLAWPANEL_TEST_SECRET_REF = 'sk-secret-ref'
try {
assert.equal(resolveModelApiKey({
source: 'env', provider: 'default', id: 'CLAWPANEL_TEST_SECRET_REF',
}), 'sk-secret-ref')
} finally {
delete process.env.CLAWPANEL_TEST_SECRET_REF
}
assert.throws(
() => resolveModelApiKey({ source: 'file', provider: 'default', id: 'providers/openai/apiKey' }),
/OpenClaw.*运行时|runtime/i,
)
})

View File

@@ -3,7 +3,9 @@ import assert from 'node:assert/strict'
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { replaceStandaloneInstall } from '../scripts/dev-api.js'
import * as devApiModule from '../scripts/dev-api.js'
const { replaceStandaloneInstall } = devApiModule
const setup = readFileSync(new URL('../src/pages/setup.js', import.meta.url), 'utf8')
const tauriApi = readFileSync(new URL('../src/lib/tauri-api.js', import.meta.url), 'utf8')
@@ -63,6 +65,33 @@ test('standalone installation validates a staging directory before replacing the
)
})
test('standalone validation rejects a version-matching archive with missing runtime dependencies', () => {
assert.equal(typeof devApiModule.verifyStandaloneInstall, 'function')
const root = mkdtempSync(join(tmpdir(), 'clawpanel-standalone-runtime-'))
const cliName = process.platform === 'win32' ? 'openclaw.cmd' : 'openclaw'
const packageDir = join(root, 'node_modules', '@qingchencloud', 'openclaw-zh')
try {
mkdirSync(packageDir, { recursive: true })
writeFileSync(join(root, cliName), '')
writeFileSync(join(root, 'VERSION'), 'openclaw_version=2026.7.1-zh.2\n')
writeFileSync(join(packageDir, 'package.json'), JSON.stringify({
name: '@qingchencloud/openclaw-zh',
version: '2026.7.1-zh.2',
dependencies: {
'@openclaw/ai': '2026.7.1',
},
}))
assert.throws(
() => devApiModule.verifyStandaloneInstall(root, '2026.7.1-zh.2'),
/standalone.*缺少运行时依赖.*@openclaw\/ai/i,
)
} finally {
rmSync(root, { recursive: true, force: true })
}
})
test('GitHub standalone fallback can resolve a pinned version without the CDN manifest', () => {
const webInstall = sliceFunction(devApi, 'async function _tryStandaloneInstall(', 'function r2PlatformKey()')
assert.match(webInstall, /overrideBaseUrl && version !== 'latest'/)

View File

@@ -23,10 +23,31 @@ test('Vite 使用修复开发服务器漏洞的 6.4.3 或更高 6.x 版本', ()
assert.ok(minor > 4 || (minor === 4 && patch >= 3))
})
test('未打标签前 0.18.6 不伪装成已发布版本', () => {
test('当前待发布版本必须有正式更新日志且保留 Unreleased 入口', () => {
const pkg = JSON.parse(read('package.json'))
const changelog = read('CHANGELOG.md')
assert.doesNotMatch(changelog, /^## \[0\.18\.6\] - \d{4}-\d{2}-\d{2}$/m)
assert.match(changelog, /^## \[0\.18\.6 候选\] - 尚未发布$/m)
const releaseHeading = new RegExp(`^## \\[${pkg.version.replaceAll('.', '\\.')}\\] - \\d{4}-\\d{2}-\\d{2}$`, 'm')
assert.match(changelog, releaseHeading)
assert.doesNotMatch(changelog, new RegExp(`^## \\[${pkg.version.replaceAll('.', '\\.')} 候选\\]`, 'm'))
assert.ok(changelog.indexOf('## [未发布 (Unreleased)]') < changelog.search(releaseHeading))
})
test('OpenClaw 7.1 发布与容器基线不低于 Node.js 22.22.3', () => {
for (const file of [
'.github/workflows/ci.yml',
'.github/workflows/release.yml',
'Dockerfile',
'docker-compose.yml',
'README.md',
'docs/docker-deploy.md',
'docs/linux-deploy.md',
]) {
assert.doesNotMatch(read(file), /node(?:-version:|:)\s*22\.19\.0/i, `${file} 仍引用旧 Node.js 基线`)
}
assert.match(read('.github/workflows/ci.yml'), /node-version:\s*22\.22\.3/)
assert.match(read('.github/workflows/release.yml'), /node-version:\s*22\.22\.3/)
assert.match(read('Dockerfile'), /FROM node:22\.22\.3-alpine AS production/)
})
test('Hermes Rust 与 Web 关键 Provider 注册表保持一致', () => {