Some checks failed
Tests / test (push) Has been cancelled
不是翻译 —— 多数文档描述的行为已经不存在了。 先修一个更基本的问题:我们不拥有 u-claw.org 域名,那是上游的。所以之前写在 README、诊断包提示、联系方式里的 help@u-claw.org 全都会把用户的问题发给 上游 —— 一个没有理由回复的人。改为指向我们自己的 issue tracker,并加断言 禁止再出现指向该域名的支持入口。 重写(内容过时,不是语言问题): - install/README.md —— 还写着 10 个中国技能、DeepSeek 优先、国内镜像。 现在按实际流程写:技能读 manifest、模型菜单 Gemini 优先、bundle 有 SHA256 校验。并如实写明 curl|bash 在受管企业电脑上会被 EDR 拦。 - CLAUDE.md 的模型配置整节 —— 还在描述虾盘云首选卡片和 12 个 provider, 那个界面已经换成单框 Key 输入了。 - SECURITY.md —— 安全报告原本指向上游维护者个人邮箱。fork 之后那条路由 是错的:漏洞会发给写不了这份代码、也修不了的人。 - CONTRIBUTING.md —— 补上 fork 关系、pre-push 钩子怎么装、以及 `node --test tests/` 为什么不能用。 翻译并保留: - bootable/README.md、TROUBLESHOOTING.md —— 面向用户,顺带把 「国内镜像」「小米/华为 BIOS 按键」等换成目标市场的实际情况 HANDOFF.md 重写为一份事故复盘:原文一半是过时的一次性交接笔记(引用的 website/guide.html 已不在本仓库),另一半是 persistence.dat 未格式化导致 启动失败的排查记录 —— 后者有长期价值,尤其是「读 offset 1080 的两字节 验证 ext4」这个判断方法,已同时写进 bootable/README.md。 bootable/IMPROVEMENTS_SUMMARY.md 保留中文,加了说明:它是上游 fork 前的 历史改进记录,没人引用,描述的是已完成的工作而非当前行为。翻译它反而会 让人误以为是现行文档。 新增 skills/en/uclaw-help —— 把「怎么用、东西在哪、出问题怎么办」做成 内置知识,每个角色都装。方案 C10.8:能问的产品才是不需要学的产品。
323 lines
14 KiB
JavaScript
323 lines
14 KiB
JavaScript
import { readFileSync, readdirSync } from 'node:fs';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { join } from 'node:path';
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
|
|
const repoRoot = fileURLToPath(new URL('..', import.meta.url));
|
|
|
|
function readRepoFile(...parts) {
|
|
return readFileSync(join(repoRoot, ...parts), 'utf8');
|
|
}
|
|
|
|
function lineOf(content, needle) {
|
|
const lines = content.split(/\r?\n/);
|
|
const index = lines.findIndex((line) => line.includes(needle));
|
|
assert.notEqual(index, -1, `missing line containing: ${needle}`);
|
|
return lines[index];
|
|
}
|
|
|
|
// The start logic moved out of the .bat/.command into lib/start.mjs so the two
|
|
// platforms cannot drift and so user-facing text can be translated. These
|
|
// assertions moved with it — the launchers are now only expected to find node
|
|
// and hand off.
|
|
test('Start launchers are thin shells that delegate to lib/start.mjs', () => {
|
|
for (const scriptName of ['START HERE - Windows.bat', 'START HERE - Mac.command']) {
|
|
const script = readRepoFile('portable', scriptName);
|
|
assert.match(script, /start\.mjs/, `${scriptName} should hand off to lib/start.mjs`);
|
|
const lines = script.split(/\r?\n/).filter((line) => line.trim() && !line.trim().startsWith('REM') && !line.trim().startsWith('#'));
|
|
assert.ok(
|
|
lines.length <= 30,
|
|
`${scriptName} should stay a thin shell, got ${lines.length} code lines`,
|
|
);
|
|
// Startup responsibilities that must NOT come back into a launcher script.
|
|
for (const leaked of [/netstat/, /lsof/, /npm.{0,20}install/, /node_modules/]) {
|
|
assert.doesNotMatch(script, leaked, `${scriptName} should not re-implement ${leaked}`);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('portable launchers disable OpenClaw bonjour discovery', () => {
|
|
// Windows-Menu/Install still launch OpenClaw directly, so they keep the env var.
|
|
for (const scriptName of ['Windows-Menu.bat', 'Windows-Install.bat']) {
|
|
const script = readRepoFile('portable', 'advanced', scriptName);
|
|
assert.match(
|
|
script,
|
|
/OPENCLAW_DISABLE_BONJOUR=1/,
|
|
`${scriptName} should disable bonjour discovery`,
|
|
);
|
|
}
|
|
// The Start path now sets it in the shared launcher, for both platforms at once.
|
|
assert.match(
|
|
readRepoFile('portable', 'lib', 'start.mjs'),
|
|
/OPENCLAW_DISABLE_BONJOUR\s*=\s*'1'/,
|
|
'start.mjs should disable bonjour discovery',
|
|
);
|
|
});
|
|
|
|
test('startup always opens Config Center on its real port', () => {
|
|
const start = readRepoFile('portable', 'lib', 'start.mjs');
|
|
|
|
assert.match(
|
|
start,
|
|
/openUrl\(`http:\/\/127\.0\.0\.1:\$\{configPort\}\/`\)/,
|
|
'start.mjs should always open Config Center for model/channel changes',
|
|
);
|
|
// Mac-Start.command used to hardcode 18788 and opened a dead page whenever the
|
|
// config server fell back to another port. The port now comes from runtime.json.
|
|
assert.match(
|
|
start,
|
|
/configServerPort/,
|
|
'the Config Center port must come from runtime.json, not a hardcoded default',
|
|
);
|
|
assert.doesNotMatch(
|
|
start,
|
|
/openUrl\(`http:\/\/127\.0\.0\.1:18788/,
|
|
'Config Center must not be opened on a hardcoded port',
|
|
);
|
|
});
|
|
|
|
test('gateway fallback returns users to Config Center, not the Dashboard', () => {
|
|
// Was lib/wait-gateway.bat (Windows only). Now in start.mjs, so macOS gets it too.
|
|
const start = readRepoFile('portable', 'lib', 'start.mjs');
|
|
assert.match(
|
|
start,
|
|
/watchGatewayReady[\s\S]*openUrl\(`http:\/\/127\.0\.0\.1:\$\{configPort\}\/`\)/,
|
|
'the fallback watcher should reopen Config Center on timeout',
|
|
);
|
|
const bodyStart = start.indexOf('function watchGatewayReady');
|
|
const body = start.slice(bodyStart, start.indexOf('\n}', bodyStart));
|
|
assert.doesNotMatch(
|
|
body,
|
|
/#token=/,
|
|
'the fallback should not push users straight into the Dashboard',
|
|
);
|
|
});
|
|
|
|
test('startup routes configured model hosts around the system proxy', () => {
|
|
// One implementation now covers both platforms; this used to be duplicated in
|
|
// the .bat and the .command with subtly different parsing.
|
|
assert.match(
|
|
readRepoFile('portable', 'lib', 'start.mjs'),
|
|
/resolve-no-proxy\.mjs[\s\S]*UCLAW_NO_PROXY[\s\S]*process\.env\.NO_PROXY/,
|
|
'start.mjs should set NO_PROXY from resolve-no-proxy.mjs',
|
|
);
|
|
});
|
|
|
|
test('startup keeps the accelerators the launchers used to wire up', () => {
|
|
const start = readRepoFile('portable', 'lib', 'start.mjs');
|
|
for (const helper of ['portable-cache.mjs', 'prewarm.mjs', 'check-update.mjs', 'loading.html']) {
|
|
assert.match(start, new RegExp(helper.replace('.', '\\.')), `start.mjs should still use ${helper}`);
|
|
}
|
|
});
|
|
|
|
test('the WeChat plugin install is no longer Windows-only', () => {
|
|
// Windows-Start.bat installed it; Mac-Start.command never did, so Mac users
|
|
// silently had no WeChat channel. It lives in the shared launcher now.
|
|
assert.match(
|
|
readRepoFile('portable', 'lib', 'start.mjs'),
|
|
/openclaw-weixin/,
|
|
'start.mjs should install the WeChat plugin on every platform',
|
|
);
|
|
});
|
|
|
|
test('every message key used by start.mjs exists in all catalogues', () => {
|
|
const start = readRepoFile('portable', 'lib', 'start.mjs');
|
|
const keys = new Set();
|
|
for (const [, key] of start.matchAll(/\bt\(\s*'([a-z0-9_.]+)'/g)) keys.add(key);
|
|
// bail() composes .what/.why/.action from a base key.
|
|
for (const [, base] of start.matchAll(/bail\(t,\s*'([a-z0-9_.]+)'/g)) {
|
|
for (const part of ['what', 'why', 'action']) keys.add(`${base}.${part}`);
|
|
}
|
|
assert.ok(keys.size > 0, 'expected start.mjs to reference message keys');
|
|
|
|
for (const locale of ['en', 'zh-CN']) {
|
|
const catalogue = JSON.parse(readRepoFile('portable', 'lib', 'messages', `${locale}.json`));
|
|
const missing = [...keys].filter((key) => !(key in catalogue)).sort();
|
|
assert.deepEqual(missing, [], `${locale}.json is missing keys:\n${missing.join('\n')}`);
|
|
}
|
|
});
|
|
|
|
test('message catalogues have no untranslated leftovers', () => {
|
|
const en = JSON.parse(readRepoFile('portable', 'lib', 'messages', 'en.json'));
|
|
const zh = JSON.parse(readRepoFile('portable', 'lib', 'messages', 'zh-CN.json'));
|
|
const extra = Object.keys(zh).filter((key) => !(key in en));
|
|
assert.deepEqual(extra, [], `zh-CN.json has keys English does not: ${extra.join(', ')}`);
|
|
// Product names and technical identifiers are the same in every language.
|
|
// Everything else being identical means a string was copied, not translated.
|
|
const NOT_TRANSLATED_BY_DESIGN = new Set(['start.node_version', 'cat.uclaw']);
|
|
const identical = Object.keys(en).filter(
|
|
(key) =>
|
|
!NOT_TRANSLATED_BY_DESIGN.has(key) &&
|
|
zh[key] === en[key] &&
|
|
/[A-Za-z]{4,}/.test(en[key]) &&
|
|
!/^\{/.test(en[key])
|
|
);
|
|
assert.deepEqual(identical, [], `zh-CN.json still holds English text for: ${identical.join(', ')}`);
|
|
});
|
|
|
|
test('customer-facing .bat launchers are pure ASCII (cmd.exe mis-parses UTF-8 Chinese on GBK Windows)', () => {
|
|
// Non-ASCII bytes in a .bat get read as GBK by Chinese Windows cmd.exe, which
|
|
// garbles parsing ("usebackq is not a command"). Chinese UX must live in the
|
|
// node tools' stdout (rendered fine under chcp 65001), never in the .bat itself.
|
|
for (const name of [
|
|
'START HERE - Windows.bat',
|
|
'advanced/Windows-IntranetFix.bat',
|
|
'advanced/Windows-LocalModel.bat',
|
|
'advanced/OpenClaw-Doctor.bat',
|
|
]) {
|
|
const bytes = readFileSync(join(repoRoot, 'portable', name));
|
|
const offending = bytes.findIndex((b) => b > 0x7f);
|
|
assert.equal(offending, -1, `${name} has a non-ASCII byte at offset ${offending}`);
|
|
assert.ok(bytes.includes(0x0d), `${name} must use CRLF line endings`);
|
|
}
|
|
});
|
|
|
|
test('macOS .command launchers are LF-only (CRLF breaks #!/bin/bash on macOS)', () => {
|
|
for (const name of [
|
|
'START HERE - Mac.command',
|
|
'advanced/Mac-IntranetFix.command',
|
|
'advanced/Mac-LocalModel.command',
|
|
'advanced/Mac-OpenClaw-Doctor.command',
|
|
]) {
|
|
const bytes = readFileSync(join(repoRoot, 'portable', name));
|
|
const cr = bytes.indexOf(0x0d);
|
|
assert.equal(cr, -1, `${name} has a CR byte at offset ${cr} (must be LF-only)`);
|
|
assert.ok(bytes.toString('utf8').startsWith('#!/bin/bash'), `${name} must start with a clean shebang`);
|
|
}
|
|
});
|
|
|
|
test('macOS local-model / intranet launchers call the shared cross-platform scripts', () => {
|
|
assert.match(readRepoFile('portable', 'advanced', 'Mac-IntranetFix.command'), /lib\/intranet-check\.mjs/);
|
|
assert.match(readRepoFile('portable', 'advanced', 'Mac-LocalModel.command'), /lib\/setup-local-model\.mjs/);
|
|
assert.match(readRepoFile('portable', 'advanced', 'Mac-OpenClaw-Doctor.command'), /doctor --non-interactive/);
|
|
});
|
|
|
|
test('local-model setup launcher calls setup-local-model.mjs', () => {
|
|
const bat = readRepoFile('portable', 'advanced', 'Windows-LocalModel.bat');
|
|
assert.match(bat, /lib\\setup-local-model\.mjs/);
|
|
});
|
|
|
|
test('OpenClaw doctor launcher is read-only (no destructive repair flags)', () => {
|
|
const bat = readRepoFile('portable', 'advanced', 'OpenClaw-Doctor.bat');
|
|
assert.match(bat, /OPENCLAW_MJS%" doctor --non-interactive/);
|
|
// Must not auto-apply repairs that could overwrite user config/state.
|
|
assert.doesNotMatch(bat, /doctor[^\n]*--fix/);
|
|
assert.doesNotMatch(bat, /doctor[^\n]*--repair/);
|
|
assert.doesNotMatch(bat, /doctor[^\n]*--force/);
|
|
});
|
|
|
|
// cmd.exe treats ')' as the end of an IF/FOR ( ... ) block, so an unescaped paren in
|
|
// an `echo` *inside* a block aborts parsing ("was unexpected at this time") and the
|
|
// window flash-closes. This shipped once in v2.1.10 (echo Direct-connect (NO_PROXY)).
|
|
// Track block depth structurally and flag any unescaped ( or ) in echoes inside a block.
|
|
function unescapedParenEchoesInsideBlocks(bat) {
|
|
const offenders = [];
|
|
let depth = 0;
|
|
for (const raw of bat.split(/\r?\n/)) {
|
|
const line = raw.trim();
|
|
if (depth > 0 && /^echo\b/i.test(line)) {
|
|
// cmd eats an unescaped ')' that sits at the END of an echo (cosmetic: the ')'
|
|
// just disappears). The flash-exit only happens when a ')' is followed by more
|
|
// text on the line (e.g. "(NO_PROXY): value" → ')' closes the block, ": value"
|
|
// then errors). So: drop escaped ^), drop trailing ')'/whitespace, and flag any
|
|
// ')' that survives (meaning it had text after it).
|
|
let s = line.replace(/\^\)/g, '').replace(/[)\s]+$/, '');
|
|
if (s.includes(')')) offenders.push(raw.trim());
|
|
}
|
|
// structural depth: ') else (' keeps depth; leading ')' closes; if/for line ending in '(' opens
|
|
if (/^\)\s*else\b.*\($/i.test(line)) { /* same depth */ }
|
|
else if (/^\)/.test(line)) depth = Math.max(0, depth - 1);
|
|
if (/^\($/.test(line) || /\b(if|for)\b.*[^^]\(\s*$/i.test(line)) depth += 1;
|
|
}
|
|
return offenders;
|
|
}
|
|
|
|
test('Windows launchers have no unescaped parens in echoes inside IF/FOR blocks (v2.1.10 flash-exit regression)', () => {
|
|
for (const name of [
|
|
'START HERE - Windows.bat',
|
|
'advanced/Windows-IntranetFix.bat',
|
|
'advanced/Windows-LocalModel.bat',
|
|
'advanced/OpenClaw-Doctor.bat',
|
|
'advanced/Windows-Diagnose.bat',
|
|
'advanced/Windows-Menu.bat',
|
|
'advanced/Windows-Install.bat',
|
|
]) {
|
|
const bat = readRepoFile('portable', name);
|
|
const offenders = unescapedParenEchoesInsideBlocks(bat);
|
|
assert.deepEqual(offenders, [], `${name} has unescaped parens in block echo(es): ${offenders.join(' | ')}`);
|
|
}
|
|
});
|
|
|
|
test('PowerShell installer generated start.bat disables OpenClaw bonjour discovery', () => {
|
|
const script = readRepoFile('install', 'install.ps1');
|
|
|
|
assert.match(script, /\$startBat = @'/);
|
|
assert.match(
|
|
script,
|
|
/set "OPENCLAW_DISABLE_BONJOUR=1"/,
|
|
'generated start.bat should disable bonjour discovery',
|
|
);
|
|
});
|
|
|
|
test('Electron desktop launcher disables OpenClaw bonjour discovery on Windows only', () => {
|
|
const source = readRepoFile('u-claw-app', 'src', 'main.js');
|
|
|
|
assert.match(
|
|
source,
|
|
/if\s*\(\s*process\.platform\s*===\s*['"]win32['"]\s*\)\s*{[\s\S]*?env\.OPENCLAW_DISABLE_BONJOUR\s*=\s*['"]1['"]/,
|
|
);
|
|
assert.doesNotMatch(
|
|
source,
|
|
/OPENCLAW_DISABLE_BONJOUR:\s*['"]1['"]/,
|
|
'bonjour disable flag should not be in the unconditional env object',
|
|
);
|
|
});
|
|
|
|
test('the drive root stays down to a handful of clickable files', () => {
|
|
// 23 clickable files in the root was the first thing anyone saw on plugging in,
|
|
// and they had to work out which applied to their computer before anything ran.
|
|
const CLICKABLE = /\.(bat|command|html|ps1|sh|exe|vbs)$/i;
|
|
const root = readdirSync(join(repoRoot, 'portable'), { withFileTypes: true })
|
|
.filter((e) => e.isFile() && CLICKABLE.test(e.name))
|
|
.map((e) => e.name)
|
|
.sort();
|
|
assert.deepEqual(
|
|
root,
|
|
['Read me first.html', 'START HERE - Mac.command', 'START HERE - Windows.bat'],
|
|
'the drive root should hold exactly the two entry points and the guide',
|
|
);
|
|
});
|
|
|
|
test('entry-point filenames avoid characters cmd.exe treats as syntax', () => {
|
|
// A filename with brackets breaks every echo that mentions it inside an
|
|
// IF (...) block — the same flash-exit class as the v2.1.10 regression.
|
|
for (const name of readdirSync(join(repoRoot, 'portable'))) {
|
|
assert.doesNotMatch(name, /[()&^|<>]/, `${name} contains a character cmd.exe treats as syntax`);
|
|
}
|
|
});
|
|
|
|
test('tools moved into advanced/ resolve the drive root one level up', () => {
|
|
const dir = join(repoRoot, 'portable', 'advanced');
|
|
for (const name of readdirSync(dir)) {
|
|
if (!/\.(command|sh)$/.test(name)) continue;
|
|
const body = readFileSync(join(dir, name), 'utf8');
|
|
if (!/(UCLAW_DIR|SCRIPT_DIR)=/.test(body)) continue;
|
|
assert.match(
|
|
body,
|
|
/(UCLAW_DIR|SCRIPT_DIR)="\$\(cd "\$\(dirname "\$0"\)\/\.\." && pwd\)"/,
|
|
`${name} must resolve the drive root as one level up`,
|
|
);
|
|
}
|
|
for (const name of readdirSync(dir)) {
|
|
if (!name.endsWith('.bat')) continue;
|
|
const body = readFileSync(join(dir, name), 'utf8');
|
|
if (!/(UCLAW_DIR|SCRIPT_DIR)=/.test(body)) continue;
|
|
assert.doesNotMatch(
|
|
body,
|
|
/set "(UCLAW_DIR|SCRIPT_DIR)=%~dp0"/,
|
|
`${name} still treats its own folder as the drive root`,
|
|
);
|
|
}
|
|
});
|