mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-08-06 13:43:50 +08:00
chore(release): prepare v0.18.6
This commit is contained in:
32
tests/hermes-model-sync-runtime.test.js
Normal file
32
tests/hermes-model-sync-runtime.test.js
Normal 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 })
|
||||
}
|
||||
})
|
||||
@@ -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"/)
|
||||
})
|
||||
|
||||
87
tests/hermes-stable-release.test.js
Normal file
87
tests/hermes-stable-release.test.js
Normal 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\)/)
|
||||
})
|
||||
71
tests/media-background-queue.test.js
Normal file
71
tests/media-background-queue.test.js
Normal 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"\]/, '视频提交后应解锁当前表单按钮')
|
||||
})
|
||||
@@ -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',
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
|
||||
@@ -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 =====
|
||||
|
||||
|
||||
@@ -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\)/)
|
||||
})
|
||||
|
||||
81
tests/openclaw-config-runtime.test.js
Normal file
81
tests/openclaw-config-runtime.test.js
Normal 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,
|
||||
)
|
||||
})
|
||||
@@ -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'/)
|
||||
|
||||
@@ -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 注册表保持一致', () => {
|
||||
|
||||
Reference in New Issue
Block a user