feat: 海外化改造(新加坡市场)—— 阶段 0-3
Some checks failed
Tests / test (push) Has been cancelled

按 U-Claw-海外化改造方案.md 与 范围决策记录.md 实施。这是 fork,不回上游:
海外版删掉的正是上游的中国市场默认值。

阶段 0 地基
- 下载源全部改国际:脚本/CI 61 处 + lockfile 880 条 npmmirror URL 归零
  (lockfile 那 880 条是 npm 的 resolved 字段,脚本层参数化根本绕不过它)
- 移除 install.ps1 里三个第三方 GitHub 加速代理,bundle 改直连 + SHA256 校验
  (原来只检查"文件大于 1MB"就解压运行)
- 技能内容与分发分离:skills/manifest.json 单一来源,install.sh 1170→658 行、
  install.ps1 721→546 行,两者技能内容归零
  实测原来是三份不一致:skills-cn 完整、install.sh 约 40%、install.ps1 约 17%,
  且 7 个通用技能只有 U 盘版有 —— 一键安装的用户一个能用的技能都没有
- Node 版本三种(v22.14/16/22.1)统一,新建 NODE_VERSION 单一来源
- Config 页三份合一。portable/Config.html 用根相对路径调 API 却只从 file:// 打开,
  保存功能已静默失效两个月;现缩为 120 行重定向壳
- 测试接入 CI(此前 node --test 无人运行,所有断言形同虚设)

阶段 1 双语可用
- 浏览器侧 i18n:JSON 为源、生成经典 script(file:// 下 fetch 本地 JSON 被拦)
  语言跟盘走不跟机器走:启动器写 data/.openclaw/locale.js
- 8 处硬编码 lang="zh-CN" 归零,data-i18n 覆盖 213 处,词条 en/zh 各 279 条
- B3 单框 Key:12 张模型卡 → 一个输入框,前缀识别 provider,
  服务端 /api/test-key 发 1-token 请求实测,错误映射成人话
  Key 填错到得知:从"直到对话失败"降到 ≤1 秒
- 区域格式 SG:DD/MM/YYYY、12 小时、S$、Asia/Singapore
  (ICU 在 en-SG 下把 SGD 渲染成裸 $,与美元无法区分,故自行拼 S$)
- README 内容分叉而非翻译,§1.3 证据清单逐条清零

阶段 2 降门槛
- 启动逻辑上移 lib/start.mjs:Windows-Start.bat 220→28 行、
  Mac-Start.command 235→33 行
  修掉 Mac 侧两个 bug:控制台端口硬编码 18788(回落时打开死页)、
  微信插件从未在 Mac 上安装
- U 盘根目录 23 → 3 个可点文件,其余进 advanced/
- 首启向导:语言 → 用途(7 角色,manifest 驱动)→ 密钥,答过不再问
- 三档界面,Simple 档隐藏一切技术名词
- 自动自愈:启动失败先自查自修,修不好导出脱敏诊断包
  (Doctor 从"用户要知道去点的工具"变成后台机制)

阶段 3 技能库
- 19 个英文技能,planned 归零。sg-weather / sg-transport 的端点均实测过
- SkillHub 从 56 张手写第三方卡片改为 manifest 生成:703→125 行,中文归零

其他
- origin.json 收拢所有运行时地址,tests/origin.test.mjs 保证迁移不会漏
- portable/ 下用户可见中文归零(由断言保证)
- 82 项测试

未验证(本机无 Windows / 无 pwsh):
- install.ps1、setup.ps1 约 210 行改动从未经 PowerShell 解析器
- 完整启动路径仅在假 node + 假 openclaw 上冒烟
- 8 个 .bat 的盘根推导仅静态断言
详见 U盘实测清单.md

受阻:
- 隐藏黑窗口 —— 需代码签名证书(.vbs 已被 Windows 弃用,替代方案都要签名)
- 场景卡 —— OpenClaw 上游 Dashboard 无预填 prompt 接口
- 官网 36 条 —— 上游 2026-04-14 拆到私有仓库,无权限
This commit is contained in:
2026-08-17 18:37:49 +08:00
parent 9a23aec097
commit b076815171
123 changed files with 13396 additions and 14723 deletions

View File

@@ -0,0 +1,64 @@
import { readFileSync } 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));
const read = (...parts) => readFileSync(join(repoRoot, ...parts), 'utf8');
const CANONICAL = ['portable', 'config-server', 'public', 'index.html'];
const SHIM = ['portable', 'advanced', 'Config.html'];
const SYNCED = ['u-claw-app', 'resources', 'Config.html'];
// There were three settings pages: this shim (748 lines), the served page, and a
// copy inside the Electron app. The shim could never work — it calls
// fetch('/api/config') with a relative path, which from file:// resolves to
// file:///api/config and is blocked, so Save failed silently for anyone who
// double-clicked it. One implementation now; this file is a redirect.
test('portable/Config.html is a redirect shim, not a second implementation', () => {
const shim = read(...SHIM);
assert.ok(shim.split('\n').length < 200, 'the shim should stay small');
assert.doesNotMatch(
shim,
/fetch\(\s*['"`]\/api\//,
'a file:// page cannot call a relative API path — that was the original bug',
);
assert.match(shim, /location\.replace/, 'the shim should redirect to the running server');
});
test('the shim probes the whole config-server port range', () => {
const shim = read(...SHIM);
const server = read('portable', 'config-server', 'server.js');
const start = Number(server.match(/PORT_RANGE_START\s*=\s*(\d+)/)?.[1]);
const end = Number(server.match(/PORT_RANGE_END\s*=\s*(\d+)/)?.[1]);
assert.ok(Number.isInteger(start) && Number.isInteger(end), 'server must declare its port range');
// Hardcoding 18788 is exactly what made Mac-Start.command open a dead page.
assert.match(shim, new RegExp(`PORT_FROM\\s*=\\s*${start}`), `shim should probe from ${start}`);
assert.match(shim, new RegExp(`PORT_TO\\s*=\\s*${end}`), `shim should probe to ${end}`);
});
test('the Electron copy is generated from the canonical page, not hand-maintained', () => {
const sync = read('u-claw-app', 'scripts', 'sync-lib.js');
assert.match(
sync,
/config-server['"],\s*['"]public['"],\s*['"]index\.html/,
'sync-lib.js must copy from config-server/public/index.html',
);
assert.equal(
read(...SYNCED),
read(...CANONICAL),
'u-claw-app/resources/Config.html is out of sync — run node u-claw-app/scripts/sync-lib.js',
);
});
test('the shim explains what to do when nothing is listening', () => {
const shim = read(...SHIM);
// House rule: what happened, why, and one thing to do next.
for (const key of ['what', 'why', 'action']) {
assert.match(shim, new RegExp(`data-msg="${key}"`), `the offline state needs a "${key}" line`);
}
for (const locale of ['en', "'zh-CN'"]) {
assert.match(shim, new RegExp(locale.replace(/[-']/g, (c) => `\\${c}`)), `shim should carry ${locale} strings`);
}
});

312
tests/i18n-pages.test.mjs Normal file
View File

@@ -0,0 +1,312 @@
import { readFileSync, readdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { join } from 'node:path';
import vm from 'node:vm';
import test from 'node:test';
import assert from 'node:assert/strict';
import { detectProvider, classifyFailure } from '../portable/lib/provider-detect.mjs';
const repoRoot = fileURLToPath(new URL('..', import.meta.url));
const read = (...parts) => readFileSync(join(repoRoot, ...parts), 'utf8');
const PAGES = [
['portable', 'Read me first.html'],
['portable', 'advanced', 'U-Claw.html'],
['portable', 'advanced', 'SkillHub.html'],
['portable', 'advanced', 'Config.html'],
['portable', 'lib', 'loading.html'],
['portable', 'config-server', 'public', 'index.html'],
['u-claw-app', 'src', 'loading.html'],
['u-claw-app', 'resources', 'Config.html'],
];
test('no page hardcodes a Chinese locale', () => {
const offenders = PAGES.filter((parts) => /lang="zh-CN"/.test(read(...parts))).map((p) => p.join('/'));
// The drive's language has to be able to differ from the machine's; a
// hardcoded lang attribute makes that impossible for screen readers and CSS.
assert.deepEqual(offenders, [], `pages still pinned to zh-CN: ${offenders.join(', ')}`);
});
test('the generated browser catalogue matches the JSON source', () => {
// messages.js exists because pages opened from file:// cannot fetch JSON.
// If it goes stale, the pages and the launcher disagree about wording.
const generated = read('portable', 'lib', 'i18n', 'messages.js');
for (const locale of ['en', 'zh-CN']) {
const source = JSON.parse(read('portable', 'lib', 'messages', `${locale}.json`));
const embedded = JSON.parse(generated.slice(generated.indexOf('{'), generated.lastIndexOf('}') + 1))[locale];
assert.deepEqual(embedded, source, `${locale} is stale — run node portable/lib/i18n/build-messages.mjs`);
}
});
function loadI18n(locale) {
const ctx = {
window: null,
document: { readyState: 'complete', documentElement: {}, querySelectorAll: () => [], addEventListener() {} },
navigator: { language: locale },
location: { search: '' },
URLSearchParams,
Intl,
};
ctx.window = ctx;
vm.createContext(ctx);
vm.runInContext(read('portable', 'lib', 'i18n', 'messages.js'), ctx);
ctx.UCLAW_LOCALE = locale;
vm.runInContext(read('portable', 'lib', 'i18n', 'i18n.js'), ctx);
return ctx.UClawI18n;
}
test('every data-i18n key used by a page resolves in both locales', () => {
const missing = [];
for (const locale of ['en', 'zh-CN']) {
const i18n = loadI18n(locale);
for (const parts of PAGES) {
const html = read(...parts);
const keys = new Set([...html.matchAll(/data-i18n="([^"]+)"/g)].map((m) => m[1]));
for (const [, pair] of html.matchAll(/data-i18n-attr="([^"]+)"/g)) {
pair.split(',').forEach((entry) => keys.add(entry.split(':')[1]?.trim()));
}
for (const key of keys) {
if (key && i18n.t(key) === key) missing.push(`${locale} ${parts.join('/')}${key}`);
}
}
}
assert.deepEqual(missing, [], `unresolved keys:\n${missing.join('\n')}`);
});
test('the i18n script loads before any inline script that uses it', () => {
for (const parts of PAGES) {
const html = read(...parts);
if (!html.includes('UClawI18n')) continue;
const runtimeAt = html.indexOf('i18n/i18n.js');
const firstUse = html.indexOf('UClawI18n');
assert.ok(runtimeAt !== -1, `${parts.join('/')} uses UClawI18n but never loads the runtime`);
assert.ok(
runtimeAt < firstUse,
`${parts.join('/')} uses UClawI18n at ${firstUse} before loading it at ${runtimeAt}`,
);
}
});
test('regional formats follow Singapore conventions, not China or the US', () => {
const i18n = loadI18n('en');
const when = new Date(Date.UTC(2026, 7, 17, 6, 5));
assert.equal(i18n.region().timeZone, 'Asia/Singapore');
assert.match(i18n.formatDate(when), /^17\/08\/2026$/, 'dates should be DD/MM/YYYY');
assert.match(i18n.formatTime(when), /(am|pm)/i, 'times should be 12-hour with am/pm');
// ICU renders SGD as a bare "$" in en-SG, which reads as USD next to a US price.
assert.match(i18n.formatMoney(1234.5), /^S\$1,234\.50$/, 'money should be written S$');
});
test('API keys are identified by prefix so nobody has to know what a Base URL is', () => {
const cases = [
['sk-ant-api03-abc', 'anthropic'],
['sk-or-v1-abc', 'openrouter'],
['AIzaSyABC', 'google'],
['gsk_abc', 'groq'],
['sk-proj-abc', 'openai'],
['sk-plain', 'openai'],
];
for (const [key, expected] of cases) {
assert.equal(detectProvider(key)?.id, expected, `${key} should be ${expected}`);
}
assert.equal(detectProvider('not-a-key'), null);
assert.equal(detectProvider(''), null);
// "sk-ant-" must win over the plain "sk-" catch-all.
assert.equal(detectProvider('sk-ant-x').id, 'anthropic');
});
test('every key-check failure maps to a message that says what to do next', () => {
const outcomes = [
{ status: 401 }, { status: 403 }, { status: 429 }, { status: 404 }, { status: 503 },
{ code: 'ENOTFOUND' }, { code: 'ETIMEDOUT' }, {},
];
const en = JSON.parse(read('portable', 'lib', 'messages', 'en.json'));
const zh = JSON.parse(read('portable', 'lib', 'messages', 'zh-CN.json'));
for (const outcome of outcomes) {
const key = classifyFailure(outcome);
assert.ok(en[key], `${JSON.stringify(outcome)}${key} has no English message`);
assert.ok(zh[key], `${JSON.stringify(outcome)}${key} has no Chinese message`);
// A bare status code is not an instruction. Every message has to be a sentence.
assert.ok(en[key].length > 25, `${key} is too terse to tell anyone what to do`);
}
});
test('the settings page asks for a key and nothing else on the first screen', () => {
const page = read('portable', 'config-server', 'public', 'index.html');
const firstScreen = page.slice(page.indexOf('id="step1"'), page.indexOf('id="step3"'));
assert.match(firstScreen, /id="apiKey"/, 'the first screen should ask for the key');
// Base URL and model name used to be required fields on the way in. They still
// exist, but only behind Advanced.
const advancedAt = firstScreen.indexOf('id="advanced"');
assert.ok(advancedAt !== -1, 'Advanced section is missing');
assert.ok(firstScreen.indexOf('id="customBase"') > advancedAt, 'Base URL must sit inside Advanced');
assert.ok(firstScreen.indexOf('id="customModel"') > advancedAt, 'model name must sit inside Advanced');
assert.match(page, /\/api\/test-key/, 'the page should verify the key before accepting it');
assert.match(page, /setTimeout\(checkKey, 800\)/, 'the check should debounce rather than fire per keystroke');
});
test('the settings page no longer front-loads a wall of model choices', () => {
const page = read('portable', 'config-server', 'public', 'index.html');
const firstScreen = page.slice(page.indexOf('id="step1"'), page.indexOf('id="step3"'));
const cards = [...firstScreen.matchAll(/class="model-card"/g)].length;
// Twelve provider cards on the first screen was the choice-paralysis problem.
assert.equal(cards, 0, `first screen still shows ${cards} model cards`);
});
test('the key-check endpoint runs server-side and bounds the request', () => {
const server = read('portable', 'config-server', 'server.js');
assert.match(server, /'\/api\/test-key'/, 'endpoint is missing');
assert.match(server, /max_tokens: 1/, 'the probe should be the smallest possible request');
assert.match(server, /body\.length > 8192/, 'the endpoint should bound the request body');
assert.match(server, /AbortController/, 'the probe needs a timeout');
});
test('message catalogues stay in step with each other', () => {
const en = JSON.parse(read('portable', 'lib', 'messages', 'en.json'));
for (const file of readdirSync(join(repoRoot, 'portable', 'lib', 'messages'))) {
if (!file.endsWith('.json') || file === 'en.json') continue;
const other = JSON.parse(read('portable', 'lib', 'messages', file));
const missing = Object.keys(en).filter((k) => !(k in other));
assert.deepEqual(missing, [], `${file} is missing: ${missing.join(', ')}`);
}
});
test('the first run asks for a language with two buttons, not a dropdown', () => {
const page = read('portable', 'lib', 'language.html');
// A <select> requires you to already read the interface language to find your
// own. Two buttons, each written in its own language, need no instructions.
assert.doesNotMatch(page, /<select/, 'the language choice must not be a dropdown');
assert.match(page, /English/, 'English option missing');
assert.match(page, /中文/, 'Chinese option missing');
const start = read('portable', 'lib', 'start.mjs');
assert.match(start, /language\.html/, 'start.mjs should open the chooser on first run');
assert.match(start, /localeChosenOnDrive/, 'the choice must be remembered');
// Stored on the drive, not in the browser: the language travels with the drive.
assert.match(start, /driveSetting\('locale'\)/, 'the choice should be read from the drive config');
assert.match(start, /uclaw\?\.\[key\]/, 'drive settings live under the uclaw key in openclaw.json');
});
test('the language choice is written to the drive and only asked once', () => {
const page = read('portable', 'lib', 'language.html');
assert.match(page, /config\.uclaw = /, 'the chooser should write uclaw.locale');
assert.match(page, /PORT_FROM = 18788/, 'the chooser should probe the config server range');
assert.doesNotMatch(page, /localStorage/, 'the choice must not live in a browser profile');
});
test('chat platforms lead with the ones used in the target market', () => {
const page = read('portable', 'config-server', 'public', 'index.html');
const step3 = page.slice(page.indexOf('id="step3"'));
const order = ['whatsapp', 'telegram', 'slack', 'discord']
.map((id) => step3.indexOf(`toggleChannel(this, '${id}')`));
assert.ok(order.every((i) => i !== -1), 'WhatsApp, Slack and Discord should all be offered');
assert.deepEqual([...order].sort((a, b) => a - b), order, 'channels are out of order');
// QQ / Feishu / WeCom stay available for users in China, but folded away.
const chinaGroup = step3.indexOf('ch.china_group');
assert.ok(chinaGroup !== -1, 'the China-only group is missing');
for (const id of ['qqbot', 'feishu', 'wecom']) {
assert.ok(
step3.indexOf(`toggleChannel(this, '${id}')`) > chinaGroup,
`${id} should sit inside the folded China group`,
);
}
assert.ok(chinaGroup > Math.max(...order), 'the China group should come after the main channels');
});
test('WhatsApp carries its ban risk where the user connects it', () => {
// Baileys is an unofficial protocol; burying that in a doc nobody reads is
// not informed consent.
const en = JSON.parse(read('portable', 'lib', 'messages', 'en.json'));
assert.match(en['ch.whatsapp_warning'], /banned/i);
assert.match(read('portable', 'config-server', 'public', 'index.html'), /ch\.whatsapp_warning/);
});
test('the model can still be changed after setup', () => {
const page = read('portable', 'config-server', 'public', 'index.html');
// Deriving the model from the key alone would lock users into whatever the
// first key implied, with no way back except editing the config file.
assert.match(page, /id="modelPicker"/, 'there should be a model picker');
assert.match(page, /populateModels/, 'the picker should be filled from the detected provider');
assert.match(page, /modelChoice\.value/, 'the picker should win when saving');
assert.match(page, /done\.change_key/, 'the done screen should offer a way back');
// loadConfig used to look for model cards that no longer exist.
assert.doesNotMatch(page, /model-card\[data-provider/, 'stale model-card lookup is back');
});
test('the first run asks one question instead of presenting settings', () => {
const page = read('portable', 'lib', 'persona.html');
// Cards, not a form: this is asked before the user knows what the product does.
assert.match(page, /api\/personas/, 'the wizard should read personas from the manifest');
assert.doesNotMatch(page, /<input/, 'the wizard should not ask anyone to type anything');
assert.match(page, /persona\.skip/, 'skipping must be possible');
const manifest = JSON.parse(read('skills', 'manifest.json'));
const i18n = loadI18n('en');
for (const persona of manifest.personas) {
assert.notEqual(
i18n.t(`persona.${persona.id}`), `persona.${persona.id}`,
`persona ${persona.id} has no label — the wizard would show a raw key`,
);
assert.ok(['simple', 'standard', 'expert'].includes(persona.tier), `${persona.id} has no valid tier`);
}
});
test('first run walks language then persona then settings, and never repeats', () => {
const start = read('portable', 'lib', 'start.mjs');
const langAt = start.indexOf("openUrl(pathToFileURL(join(libDir, 'language.html'))");
const personaAt = start.indexOf("openUrl(pathToFileURL(join(libDir, 'persona.html'))");
assert.ok(langAt !== -1 && personaAt !== -1, 'both wizard steps should be reachable');
assert.ok(langAt < personaAt, 'language comes before the persona question');
// Re-asking would silently undo whatever the user set up last time.
assert.match(start, /personaChosenOnDrive/, 'the persona answer must be remembered');
assert.match(read('portable', 'lib', 'language.html'), /persona\.html/, 'language should hand off to the wizard');
});
test('picking several roles gives the most capable interface, not the least', () => {
const page = read('portable', 'lib', 'persona.html');
assert.match(page, /TIER_ORDER = \['simple', 'standard', 'expert'\]/, 'tiers should be ordered');
// Showing a developer the simple interface is worse than showing an admin one
// extra menu, so the highest tier wins on a multi-select.
assert.match(page, /indexOf\(p\.tier\) > TIER_ORDER\.indexOf\(best\)/, 'the highest tier should win');
});
test('the interface tier actually hides things, and hides the right things', () => {
const page = read('portable', 'config-server', 'public', 'index.html');
// Simple must not show endpoints, model names, config files or the
// China-only channel group — those are the "technical words on the default
// path" the redesign set out to remove.
assert.match(page, /body\[data-tier="simple"\] \[data-tier-min="standard"\]/, 'simple should hide standard-tier controls');
assert.match(page, /body\[data-tier="standard"\] \[data-tier-min="expert"\]/, 'standard should hide expert-tier controls');
for (const id of ['advanced', 'modelPicker']) {
const at = page.indexOf(`id="${id}"`);
const tag = page.slice(at, page.indexOf('>', at));
assert.match(tag, /data-tier-min="standard"/, `#${id} should be hidden at the simple tier`);
}
assert.match(page, /data-tier-min="expert"[^>]*data-i18n="done\.view_config"|done\.view_config[^>]*data-tier-min="expert"/,
'the config-file button belongs to the expert tier');
});
test('the tier can be changed later and says what changed', () => {
const page = read('portable', 'config-server', 'public', 'index.html');
assert.match(page, /id="tierChoice"/, 'there should be a tier switch');
assert.match(page, /setTier\(this\.value\)/, 'changing it should persist');
const en = JSON.parse(read('portable', 'lib', 'messages', 'en.json'));
// Revealing new controls without a word of explanation reads as a glitch.
for (const tier of ['simple', 'standard', 'expert']) {
assert.ok(en[`tier.changed_${tier}`], `no confirmation message for the ${tier} tier`);
}
});
test('persona skills install through the one shared installer', () => {
const server = read('portable', 'config-server', 'server.js');
assert.match(server, /'\/api\/install-skills'/, 'endpoint is missing');
assert.match(server, /install-skills\.mjs/, 'it must reuse the shared installer, not reimplement it');
// On the drive the installer sits in lib/; in a checkout it is at the repo root.
assert.match(server, /'\.\.', '\.\.', 'lib', 'install-skills\.mjs'/, 'the checkout fallback path is missing');
assert.match(server, /'--persona', persona/, 'it should filter by persona');
});

View File

@@ -0,0 +1,104 @@
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { join, relative, extname } from 'node:path';
import test from 'node:test';
import assert from 'node:assert/strict';
const repoRoot = fileURLToPath(new URL('..', import.meta.url));
// Overseas build: every download source must be reachable without routing through
// China. These patterns are slower from Singapore, and the GitHub proxies put an
// unaccountable third party in the middle of the supply chain.
const FORBIDDEN = [
{ pattern: /npmmirror\.com/, use: 'registry.npmjs.org / nodejs.org' },
{ pattern: /mirrors\.(tuna\.tsinghua|ustc|aliyun|cloud\.tencent|huaweicloud)/, use: 'the upstream project mirror' },
{ pattern: /ghfast\.top|ghproxy\.net|gh\.idayer\.com/, use: 'github.com directly' },
];
// Executable surfaces only. Docs and the Config model list are handled by the
// content pass (they need rewriting, not a URL swap).
const SCANNED_EXTENSIONS = new Set(['.sh', '.ps1', '.bat', '.command', '.yml', '.yaml', '.mjs', '.js']);
const SKIPPED_DIRS = new Set(['.git', 'node_modules', 'dist', '.download-cache']);
function* walk(dir) {
for (const entry of readdirSync(dir)) {
if (SKIPPED_DIRS.has(entry)) continue;
const full = join(dir, entry);
if (statSync(full).isDirectory()) yield* walk(full);
else yield full;
}
}
test('no China-routed download sources in scripts, launchers or CI', () => {
const offenders = [];
for (const file of walk(repoRoot)) {
if (!SCANNED_EXTENSIONS.has(extname(file))) continue;
const lines = readFileSync(file, 'utf8').split(/\r?\n/);
lines.forEach((line, i) => {
for (const { pattern, use } of FORBIDDEN) {
if (pattern.test(line)) {
offenders.push(`${relative(repoRoot, file)}:${i + 1} matches ${pattern} — use ${use}`);
}
}
});
}
assert.deepEqual(offenders, [], `China-routed sources found:\n${offenders.join('\n')}`);
});
test('u-claw-app lockfile resolves every package from the official npm registry', () => {
const lock = JSON.parse(readFileSync(join(repoRoot, 'u-claw-app', 'package-lock.json'), 'utf8'));
const offenders = Object.entries(lock.packages ?? {})
.filter(([, meta]) => meta.resolved && !meta.resolved.startsWith('https://registry.npmjs.org/'))
.map(([name, meta]) => `${name} -> ${meta.resolved}`);
// A lockfile bakes the mirror host into every `resolved` field, so `npm ci`
// keeps hitting it no matter what --registry the scripts pass. Regenerating
// requires deleting the lockfile first: npm will not rewrite existing entries.
assert.deepEqual(offenders, [], `lockfile pins non-official sources:\n${offenders.join('\n')}`);
});
test('lockfile openclaw version matches OPENCLAW_VERSION', () => {
const declared = readFileSync(join(repoRoot, 'OPENCLAW_VERSION'), 'utf8').trim();
const lock = JSON.parse(readFileSync(join(repoRoot, 'u-claw-app', 'package-lock.json'), 'utf8'));
const locked = lock.packages?.['node_modules/openclaw']?.version;
assert.equal(locked, declared, `OPENCLAW_VERSION says ${declared} but the lockfile pins ${locked}`);
});
test('no user-facing Chinese is left in the English build surfaces', () => {
// Chinese belongs in lib/messages/zh-CN.json and in the two deliberately
// bilingual pages — not hardcoded in a script an English-speaking user sees.
// Comments are fine: they are for whoever maintains this next.
const CJK = /[一-鿿]/;
const ALLOWED = new Set([
'portable/lib/messages/zh-CN.json', // the Chinese catalogue itself
'portable/lib/i18n/messages.js', // generated from it
'portable/lib/language.html', // bilingual by design
'portable/advanced/Config.html', // bilingual by design
]);
// Strip comments before looking, so a Chinese note at the end of a code line
// or inside a multi-line block does not read as a user-facing string.
function stripComments(text, file) {
let out = text
.replace(/\/\*[\s\S]*?\*\//g, '') // /* ... */
.replace(/<!--[\s\S]*?-->/g, ''); // <!-- ... -->
return out
.split(/\r?\n/)
.map((line) => {
if (/\.(sh|command)$/.test(file)) return line.replace(/(^|\s)#.*$/, '');
if (/\.(bat|ps1)$/.test(file)) return line.replace(/(^|\s)(REM\b|::|#).*$/i, '');
return line.replace(/(^|[^:])\/\/.*$/, '$1');
})
.join('\n');
}
const offenders = [];
for (const file of walk(join(repoRoot, 'portable'))) {
const rel = relative(repoRoot, file);
if (ALLOWED.has(rel)) continue;
if (!/\.(sh|ps1|bat|command|mjs|js|html)$/.test(file)) continue;
stripComments(readFileSync(file, 'utf8'), file)
.split(/\r?\n/)
.forEach((line, i) => { if (CJK.test(line)) offenders.push(`${rel}:${i + 1} ${line.trim().slice(0, 70)}`); });
}
assert.deepEqual(offenders, [], `Chinese left in user-facing strings:\n${offenders.join('\n')}`);
});

144
tests/origin.test.mjs Normal file
View File

@@ -0,0 +1,144 @@
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { join, relative, extname } from 'node:path';
import test from 'node:test';
import assert from 'node:assert/strict';
const repoRoot = fileURLToPath(new URL('..', import.meta.url));
const origin = JSON.parse(readFileSync(join(repoRoot, 'origin.json'), 'utf8'));
const SKIPPED_DIRS = new Set(['.git', 'node_modules', 'dist', '.download-cache', 'tests']);
// Lockfiles are full of third-party funding and repository links. They say
// nothing about where *this* build fetches from.
const SKIPPED_FILES = new Set(['package-lock.json']);
const SCANNED = new Set(['.sh', '.ps1', '.bat', '.command', '.mjs', '.js', '.yml', '.yaml', '.json', '.html', '.md']);
function* walk(dir) {
for (const entry of readdirSync(dir)) {
if (SKIPPED_DIRS.has(entry)) continue;
const full = join(dir, entry);
if (statSync(full).isDirectory()) yield* walk(full);
else yield full;
}
}
test('origin.json describes a coherent origin', () => {
assert.match(origin.repo.owner, /^[\w.-]+$/);
assert.match(origin.repo.name, /^[\w.-]+$/);
// A template, not a base: Gitea and GitHub lay raw paths out differently.
assert.match(origin.urls.rawTemplate, /\{ref\}.*\{path\}/, 'rawTemplate needs both placeholders');
for (const key of ['web', 'rawTemplate', 'releases', 'issues', 'website']) {
assert.match(origin.urls[key], /^https:\/\//, `${key} should be an https URL`);
}
assert.match(origin.urls.ssh, /^ssh:\/\//, 'ssh should be an ssh URL');
});
// This is a fork. Every URL the build fetches at runtime points at somebody
// else's host until we move it. The move is only safe if nothing is hiding in a
// file we forgot about, which is what this test is for.
test('no file points at an owner other than the one origin.json declares', () => {
const declaredOwner = origin.repo.owner;
const OWNER_PATTERN = new RegExp(
`${origin.repo.host.replace(/\./g, '\\.')}\\/([\\w.-]+)\\/|github\\.com\\/([\\w.-]+)\\/`, 'g');
const KNOWN_THIRD_PARTY = new Set([
'openclaw', // upstream runtime, a real dependency
'electron', // electron mirrors
'ventoy', // bootable USB
'dongsheng123132', // the project we forked — referenced, never fetched from
]);
const offenders = [];
for (const file of walk(repoRoot)) {
if (!SCANNED.has(extname(file))) continue;
const rel = relative(repoRoot, file);
if (rel === 'origin.json' || SKIPPED_FILES.has(rel.split('/').pop())) continue;
const content = readFileSync(file, 'utf8');
content.split(/\r?\n/).forEach((line, i) => {
for (const match of line.matchAll(OWNER_PATTERN)) {
const owner = match[1] ?? match[2];
if (owner === declaredOwner || KNOWN_THIRD_PARTY.has(owner)) continue;
offenders.push(`${rel}:${i + 1} points at ${owner}`);
}
});
}
assert.deepEqual(offenders, [], `unexpected owners:\n${offenders.join('\n')}`);
});
// The one-line installers run through curl|bash with no checkout, so they cannot
// read origin.json and carry the same URLs as literals. Without this check a
// migration would update origin.json, look done, and leave curl|bash users
// fetching from the old host.
test('the standalone installers carry URLs that match origin.json', () => {
const expected = {
releases: origin.urls.releases,
website: origin.urls.website,
};
for (const script of ['install/install.sh', 'install/install.ps1']) {
const content = readFileSync(join(repoRoot, script), 'utf8');
const rawPrefix = origin.urls.rawTemplate.split('{ref}')[0];
assert.ok(
!/raw\.githubusercontent\.com/.test(content) || content.includes(rawPrefix),
`${script} still fetches raw content from GitHub`,
);
if (content.includes('install-skills.mjs')) {
assert.ok(content.includes(rawPrefix), `${script} should fetch the installer from ${rawPrefix}`);
}
const releaseUrls = [...content.matchAll(new RegExp(`https://[\\w.-]+/[\\w.-]+/[\\w.-]+/releases`, 'g'))].map((m) => m[0]);
for (const url of releaseUrls) {
assert.equal(url, expected.releases, `${script} downloads from ${url}, origin.json says ${expected.releases}`);
}
const siteUrls = [...content.matchAll(/https:\/\/u-claw\.org|https:\/\/[\w.-]*u-claw[\w.-]*\.\w+/g)].map((m) => m[0]);
for (const url of siteUrls) {
assert.equal(url, expected.website, `${script} points at ${url}, origin.json says ${expected.website}`);
}
}
});
test('install-skills.mjs reads origin.json rather than hardcoding the host', () => {
const content = readFileSync(join(repoRoot, 'lib', 'install-skills.mjs'), 'utf8');
assert.match(content, /origin\.json/, 'it should read origin.json');
// A literal fallback is fine and necessary — the remote installers download
// this file on its own, with no repo around it — but it must agree.
// A literal fallback is necessary — the remote installers download this file on
// its own, with no repo around it — but it must agree with origin.json.
const fallback = content.match(/\?\?\s*'(https:\/\/[^']+)'/)?.[1];
assert.ok(fallback, 'there should be a hardcoded fallback template');
assert.equal(fallback, origin.urls.rawTemplate, 'the fallback template disagrees with origin.json');
});
test('every u-claw.org address in the tree is one origin.json accounts for', () => {
// Not "all the same" — they legitimately differ by role. The point is that no
// address exists that nobody has thought about, because on a fork some of
// these route to the upstream maintainer rather than to us.
const known = new Set(Object.values(origin.support).filter((v) => /@/.test(v)));
const offenders = [];
for (const file of walk(repoRoot)) {
if (!SCANNED.has(extname(file))) continue;
const rel = relative(repoRoot, file);
if (rel === 'origin.json' || SKIPPED_FILES.has(rel.split('/').pop())) continue;
readFileSync(file, 'utf8').split(/\r?\n/).forEach((line, i) => {
for (const [found] of line.matchAll(/[\w.+-]+@u-claw\.org/g)) {
if (!known.has(found)) offenders.push(`${rel}:${i + 1} uses ${found}`);
}
});
}
assert.deepEqual(offenders, [], `addresses origin.json does not account for:\n${offenders.join('\n')}`);
});
test('origin.json flags the addresses that still belong to upstream', () => {
// This is the thing that is easy to ship without noticing: a fork whose
// SECURITY.md sends vulnerability reports to someone who did not write the
// code, and cannot fix it.
assert.ok(origin.support.note, 'origin.json should say which addresses are not ours yet');
assert.match(origin.support.note, /security/i);
const security = readFileSync(join(repoRoot, 'SECURITY.md'), 'utf8');
assert.ok(
security.includes(origin.support.security),
'SECURITY.md and origin.json disagree about where to report a vulnerability',
);
});

140
tests/self-heal.test.mjs Normal file
View File

@@ -0,0 +1,140 @@
import { mkdirSync, mkdtempSync, writeFileSync, readFileSync, existsSync, readdirSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import test from 'node:test';
import assert from 'node:assert/strict';
import { buildChecks, runRepairs, writeDiagnostics } from '../portable/lib/self-heal.mjs';
const repoRoot = fileURLToPath(new URL('..', import.meta.url));
const HEALTHY_CONFIG = {
gateway: { auth: { token: 'uclaw' } },
models: { providers: { anthropic: { apiKey: 'sk-ant-api03-SECRETVALUE0123456789', baseUrl: 'https://api.anthropic.com/v1' } } },
channels: { telegram: { botToken: '123456:TELEGRAMSECRET' } },
uclaw: { locale: 'en', personas: ['finance'], tier: 'standard' },
};
function makeDrive() {
const root = mkdtempSync(join(tmpdir(), 'uclaw-heal-'));
for (const dir of ['data/.openclaw', 'data/memory', 'data/backups', 'data/logs', 'app/core/node_modules/openclaw']) {
mkdirSync(join(root, dir), { recursive: true });
}
writeFileSync(join(root, 'app/core/node_modules/openclaw/openclaw.mjs'), '// entry\n');
const paths = {
data: join(root, 'data'),
state: join(root, 'data/.openclaw'),
config: join(root, 'data/.openclaw/openclaw.json'),
runtimeJson: join(root, 'data/.openclaw/runtime.json'),
core: join(root, 'app/core'),
};
writeFileSync(paths.config, JSON.stringify(HEALTHY_CONFIG, null, 2));
return { root, paths };
}
const checksFor = (paths, stamp = 'TEST') =>
buildChecks({ paths, defaultConfigPath: null, portRange: { from: 18789, to: 18799 }, stamp });
test('a healthy drive is left completely alone', async () => {
const { root, paths } = makeDrive();
try {
const before = readFileSync(paths.config, 'utf8');
const applied = await runRepairs(checksFor(paths));
// Every check runs on every failed start, including failures none of them
// explain. Touching a healthy drive would turn one problem into two.
assert.deepEqual(applied, [], `repairs fired on a healthy drive: ${JSON.stringify(applied)}`);
assert.equal(readFileSync(paths.config, 'utf8'), before, 'the settings file was rewritten');
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('a corrupt settings file is rebuilt, and the original is kept', async () => {
const { root, paths } = makeDrive();
try {
writeFileSync(paths.config, '{"gateway": {half-written');
const applied = await runRepairs(checksFor(paths, 'STAMP1'));
assert.ok(applied.some((a) => a.id === 'config-readable'), 'the damaged file was not noticed');
JSON.parse(readFileSync(paths.config, 'utf8')); // throws if still broken
// Repairs must never destroy user data — the diagnosis could be wrong.
const kept = readdirSync(paths.state).filter((n) => n.includes('broken-STAMP1'));
assert.equal(kept.length, 1, 'the original file was not preserved');
assert.match(readFileSync(join(paths.state, kept[0]), 'utf8'), /half-written/);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('an interrupted copy is detected so startup can fetch it again', async () => {
const { root, paths } = makeDrive();
try {
rmSync(join(paths.core, 'node_modules/openclaw/openclaw.mjs'));
const applied = await runRepairs(checksFor(paths, 'STAMP2'));
assert.ok(applied.some((a) => a.id === 'openclaw-present'), 'a half-copied drive went unnoticed');
assert.ok(!existsSync(join(paths.core, 'node_modules')), 'node_modules should be moved aside so it reinstalls');
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('a leftover port record from a crash is cleared', async () => {
const { root, paths } = makeDrive();
try {
// Nothing is listening on 18795, so this record only makes the launcher wait.
writeFileSync(paths.runtimeJson, JSON.stringify({ configServerPort: 18795 }));
const applied = await runRepairs(checksFor(paths));
assert.ok(applied.some((a) => a.id === 'stale-runtime'), 'the stale record survived');
assert.ok(!existsSync(paths.runtimeJson));
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('missing folders are recreated', async () => {
const { root, paths } = makeDrive();
try {
rmSync(join(paths.data, 'memory'), { recursive: true });
rmSync(join(paths.data, 'logs'), { recursive: true });
const applied = await runRepairs(checksFor(paths));
assert.ok(applied.some((a) => a.id === 'data-dirs'));
assert.ok(existsSync(join(paths.data, 'memory')) && existsSync(join(paths.data, 'logs')));
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('the diagnostics report carries no secrets', async () => {
const { root, paths } = makeDrive();
try {
const checks = checksFor(paths);
const report = await writeDiagnostics({
paths, checks, applied: [], stamp: 'STAMP3',
versions: { openclaw: '2026.7.1-2', node_pinned: 'v22.22.1' },
});
const body = readFileSync(report, 'utf8');
// The user should be able to read this before deciding to send it anywhere.
for (const secret of ['sk-ant-api03-SECRETVALUE0123456789', 'TELEGRAMSECRET']) {
assert.ok(!body.includes(secret), `${secret} leaked into the diagnostics report`);
}
assert.match(body, /redacted/, 'redaction should be visible so the user can tell it happened');
assert.match(body, /api\.anthropic\.com/, 'non-secret settings should survive — the report has to be useful');
assert.match(body, /## System[\s\S]*## Repairs attempted[\s\S]*## Settings/, 'report sections are missing');
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('startup heals before it complains, and never dead-ends', () => {
const start = readFileSync(join(repoRoot, 'portable', 'lib', 'start.mjs'), 'utf8');
assert.match(start, /healAndRetry/, 'startup should try to repair itself');
// Diagnose was a tool you had to know existed; by the time startup visibly
// failed, anyone who did not know had already given up.
assert.match(start, /start\.checking/, 'the user should see "checking", not a stack trace');
assert.match(start, /writeDiagnostics/, 'an unrepairable failure should still leave a report');
assert.match(start, /start\.diagnostics_written/, 'the user should be told where the report is');
const en = JSON.parse(readFileSync(join(repoRoot, 'portable', 'lib', 'messages', 'en.json'), 'utf8'));
assert.match(en['start.diagnostics_hint'], /help@u-claw\.org/, 'the report needs somewhere to go');
assert.match(en['start.diagnostics_hint'], /removed/i, 'say that keys were stripped, or nobody will send it');
});

View File

@@ -0,0 +1,178 @@
import { readFileSync, existsSync, 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));
const skillsDir = join(repoRoot, 'skills');
const manifest = JSON.parse(readFileSync(join(skillsDir, 'manifest.json'), 'utf8'));
function readRepoFile(...parts) {
return readFileSync(join(repoRoot, ...parts), 'utf8');
}
const shipping = manifest.skills.filter((s) => s.status === 'shipping');
test('every shipping skill has content for each locale it claims', () => {
const missing = [];
for (const skill of shipping) {
for (const locale of skill.locales ?? []) {
const path = join(skillsDir, locale, skill.id, 'SKILL.md');
if (!existsSync(path)) missing.push(`${locale}/${skill.id}/SKILL.md`);
}
}
assert.deepEqual(missing, [], `manifest lists content that does not exist:\n${missing.join('\n')}`);
});
test('every shipping skill declares at least one locale', () => {
const empty = shipping.filter((s) => (s.locales ?? []).length === 0).map((s) => s.id);
assert.deepEqual(empty, [], `shipping skills with no locale: ${empty.join(', ')}`);
});
test('planned skills carry no content and no locales', () => {
const planned = manifest.skills.filter((s) => s.status === 'planned');
const wrong = planned
.filter((s) => (s.locales ?? []).length > 0)
.map((s) => s.id);
assert.deepEqual(wrong, [], `planned skills must not declare locales: ${wrong.join(', ')}`);
});
test('SKILL.md front matter name matches the manifest id', () => {
const mismatched = [];
for (const skill of shipping) {
for (const locale of skill.locales ?? []) {
const content = readFileSync(join(skillsDir, locale, skill.id, 'SKILL.md'), 'utf8');
const declared = content.match(/^name:\s*(\S+)\s*$/m)?.[1];
if (declared !== skill.id) mismatched.push(`${locale}/${skill.id}: front matter says "${declared}"`);
}
}
assert.deepEqual(mismatched, [], mismatched.join('\n'));
});
test('no orphaned skill directories outside the manifest', () => {
const declared = new Set(manifest.skills.map((s) => s.id));
const orphans = [];
for (const locale of readdirSync(skillsDir, { withFileTypes: true })) {
if (!locale.isDirectory()) continue;
for (const dir of readdirSync(join(skillsDir, locale.name))) {
if (!declared.has(dir)) orphans.push(`${locale.name}/${dir}`);
}
}
assert.deepEqual(orphans, [], `skill directories with no manifest entry:\n${orphans.join('\n')}`);
});
test('every persona referenced by a skill is declared in the manifest', () => {
const known = new Set(manifest.personas.map((p) => p.id));
const unknown = new Set();
for (const skill of manifest.skills) {
for (const persona of skill.personas ?? []) {
if (!known.has(persona)) unknown.add(`${skill.id} -> ${persona}`);
}
}
assert.deepEqual([...unknown], [], `unknown personas:\n${[...unknown].join('\n')}`);
});
// This is the guard that replaces the old three-way divergence: install.sh had
// 10 skills at ~40% length, install.ps1 had the same 10 at ~17%, and 7 more
// existed only on the USB build. Neither installer may carry skill text again.
test('installers contain no skill content of their own', () => {
for (const script of ['install/install.sh', 'install/install.ps1']) {
const content = readRepoFile(script);
// Matches how the content used to be embedded: a heredoc writing SKILL.md in
// the shell script, and a "<skill-id>" = @'...'@ hashtable entry in the
// PowerShell one. Plain `$var = @'` here-strings (start.bat, uninstall.bat)
// are legitimate and must keep passing.
assert.doesNotMatch(
content,
/SKILL\.md["']?\s*<<|^\s*"[a-z0-9-]+"\s*=\s*@'/m,
`${script} appears to inline skill content again — it must call lib/install-skills.mjs instead`
);
assert.match(
content,
/install-skills\.mjs/,
`${script} must install skills via lib/install-skills.mjs`
);
}
});
test('both installers resolve skills the same way', () => {
const sh = readRepoFile('install/install.sh');
const ps1 = readRepoFile('install/install.ps1');
for (const [name, content] of [['install.sh', sh], ['install.ps1', ps1]]) {
assert.match(content, /--target/, `${name} must pass --target`);
assert.match(content, /--locale/, `${name} must pass --locale`);
assert.match(content, /UCLAW_LOCALE/, `${name} must honour UCLAW_LOCALE`);
// curl|bash has no checkout, so both installers must be able to fetch content.
assert.match(content, /\/raw\/branch\/|raw\.githubusercontent\.com/, `${name} needs a no-checkout fallback source`);
}
});
test('installers pin the same OpenClaw version as OPENCLAW_VERSION', () => {
const declared = readRepoFile('OPENCLAW_VERSION').trim();
for (const script of ['install/install.sh', 'install/install.ps1']) {
const content = readRepoFile(script);
const pinned = content.match(/OPENCLAW_VERSION\s*=\s*"([^"]+)"/)?.[1];
assert.equal(pinned, declared, `${script} pins ${pinned}, OPENCLAW_VERSION says ${declared}`);
}
});
test('a locally built drive carries the same files as a released one', () => {
// release.yml stages skills/, the installer and both version files onto the
// drive. setup.sh has to do the same, or a drive built from source silently
// differs from the one users download: the first-run wizard cannot install
// skills, and the drive cannot switch locale offline.
const setup = readRepoFile('portable', 'advanced', 'setup.sh');
for (const needed of [/skills/, /install-skills\.mjs/, /NODE_VERSION/, /OPENCLAW_VERSION/]) {
assert.match(setup, needed, `setup.sh should stage ${needed} like release.yml does`);
}
});
test('no persona is left with a near-empty toolbox', () => {
// The wizard promises "this sets up the right tools for you". A persona that
// ends up with one or two skills makes that a lie, and finance in particular
// was down to two before the catalogue was filled out.
const thin = manifest.personas
.map((p) => ({
id: p.id,
count: shipping.filter((s) => (s.personas ?? []).includes(p.id)).length,
}))
.filter((p) => p.count < 3);
assert.deepEqual(thin, [], `personas with too few skills: ${JSON.stringify(thin)}`);
});
test('every shipping skill is offered to at least one persona', () => {
// A skill nobody's persona includes ships as dead weight — it only ever
// arrives for someone who picked "just having a look".
const orphans = shipping.filter((s) => (s.personas ?? []).length === 0).map((s) => s.id);
assert.deepEqual(orphans, [], `skills no persona receives: ${orphans.join(', ')}`);
});
test('SkillHub lists what is actually on the drive, not a third-party catalogue', () => {
const page = readRepoFile('portable', 'advanced', 'SkillHub.html');
// It used to be 56 hand-written cards advertising third-party ClawHub skills,
// in Chinese, with install counts nobody could verify. None of it was ours.
assert.match(page, /UCLAW_SKILLS/, 'the page should read the generated manifest data');
assert.match(page, /skills-data\.js/, 'file:// cannot fetch manifest.json — it needs the generated script');
assert.doesNotMatch(page, /skill-card|cat-card/, 'the hand-written catalogue markup is back');
assert.ok(page.split('\n').length < 200, 'the page should stay generated, not hand-maintained');
// Catalogue strings are data; rendering them as markup would be an injection path.
assert.match(page, /textContent = t\('skill\./, 'skill names must be set as text, not HTML');
});
test('the generated skill data matches the manifest', () => {
const generated = readRepoFile('portable', 'lib', 'i18n', 'skills-data.js');
const embedded = JSON.parse(generated.slice(generated.indexOf('{'), generated.lastIndexOf('}') + 1));
const expected = shipping.map((s) => s.id).sort();
assert.deepEqual(
embedded.skills.map((s) => s.id).sort(),
expected,
'skills-data.js is stale — run node portable/lib/i18n/build-messages.mjs',
);
// A skill with no label renders as a raw key like "skill.sg-weather.name".
for (const locale of ['en', 'zh-CN']) {
const catalogue = JSON.parse(readRepoFile('portable', 'lib', 'messages', `${locale}.json`));
const unlabelled = expected.filter((id) => !catalogue[`skill.${id}.name`] || !catalogue[`skill.${id}.desc`]);
assert.deepEqual(unlabelled, [], `${locale} is missing labels for: ${unlabelled.join(', ')}`);
}
});

View File

@@ -0,0 +1,59 @@
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { join, relative, extname } from 'node:path';
import test from 'node:test';
import assert from 'node:assert/strict';
const repoRoot = fileURLToPath(new URL('..', import.meta.url));
const SCANNED = new Set(['.sh', '.ps1', '.bat', '.command', '.yml', '.yaml', '.mjs']);
const SKIPPED_DIRS = new Set(['.git', 'node_modules', 'dist', '.download-cache']);
function* walk(dir) {
for (const entry of readdirSync(dir)) {
if (SKIPPED_DIRS.has(entry)) continue;
const full = join(dir, entry);
if (statSync(full).isDirectory()) yield* walk(full);
else yield full;
}
}
// Node was pinned to three different versions across six scripts (v22.14.0,
// v22.16.0, v22.22.1), so which runtime you got depended on how you installed.
// Same class of bug as the skill-content split. NODE_VERSION is now the source
// of truth; .sh/.command read it directly, .bat/.ps1 keep a literal because
// reading a file there is more trouble than it is worth — this test is what
// stops the literal from drifting.
test('every pinned Node version matches NODE_VERSION', () => {
const expected = readFileSync(join(repoRoot, 'NODE_VERSION'), 'utf8').trim();
assert.match(expected, /^v\d+\.\d+\.\d+$/, 'NODE_VERSION should look like v22.22.1');
const offenders = [];
for (const file of walk(repoRoot)) {
if (!SCANNED.has(extname(file))) continue;
// Tests describe versions in prose; they do not pin a runtime.
if (relative(repoRoot, file).startsWith('tests/')) continue;
readFileSync(file, 'utf8').split(/\r?\n/).forEach((line, i) => {
for (const [, found] of line.matchAll(/\bv(\d+\.\d+\.\d+)\b/g)) {
// Only Node pins are in scope; ignore OpenClaw tags and release tags.
if (!/node/i.test(line)) continue;
if (`v${found}` !== expected) {
offenders.push(`${relative(repoRoot, file)}:${i + 1} pins v${found}, expected ${expected}`);
}
}
});
}
assert.deepEqual(offenders, [], `Node version drift:\n${offenders.join('\n')}`);
});
test('OPENCLAW_VERSION and NODE_VERSION ship on the drive', () => {
const release = readFileSync(join(repoRoot, '.github', 'workflows', 'release.yml'), 'utf8');
// Launchers and setup scripts read these off the drive at runtime; if the
// release forgets to stage them the fallback literal silently takes over.
for (const name of ['OPENCLAW_VERSION', 'NODE_VERSION']) {
assert.match(
release,
new RegExp(`cp ${name} "\\$stage_dir/${name}"`),
`release.yml must copy ${name} onto the drive`
);
}
});

View File

@@ -1,4 +1,4 @@
import { readFileSync } from 'node:fs';
import { readFileSync, readdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { join } from 'node:path';
import test from 'node:test';
@@ -17,94 +17,154 @@ function lineOf(content, needle) {
return lines[index];
}
test('Windows-Start dependency fallback text escapes parentheses inside IF block', () => {
const script = readRepoFile('portable', 'Windows-Start.bat');
assert.match(
lineOf(script, 'Falling back to npm install'),
/\^\(USB drives may take 20\+ minutes\^\)\./,
);
assert.match(
lineOf(script, 'pre-installed deps'),
/\^\(~200 MB\^\)\./,
);
// 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 Windows launchers disable OpenClaw bonjour discovery', () => {
for (const scriptName of [
'Windows-Start.bat',
'Windows-Menu.bat',
'Windows-Install.bat',
]) {
const script = readRepoFile('portable', scriptName);
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`,
);
}
});
test('Windows startup keeps Config Center available even after model setup', () => {
const script = readRepoFile('portable', 'Windows-Start.bat');
// The Start path now sets it in the shared launcher, for both platforms at once.
assert.match(
script,
/Opening Config Center[\s\S]*start "" http:\/\/127\.0\.0\.1:%CONFIG_PORT%\//,
'Windows-Start.bat should always open Config Center for model/channel changes',
);
assert.doesNotMatch(
script,
/if not defined MODEL_CONFIGURED/,
'Config Center should not be gated on first-time setup only',
readRepoFile('portable', 'lib', 'start.mjs'),
/OPENCLAW_DISABLE_BONJOUR\s*=\s*'1'/,
'start.mjs should disable bonjour discovery',
);
});
test('Windows gateway fallback does not force-open Dashboard', () => {
const script = readRepoFile('portable', 'lib', 'wait-gateway.bat');
test('startup always opens Config Center on its real port', () => {
const start = readRepoFile('portable', 'lib', 'start.mjs');
assert.match(
script,
/:timeout[\s\S]*start "" http:\/\/127\.0\.0\.1:%CONFIG_PORT%\//,
'wait-gateway.bat should return users to Config Center on timeout',
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(
script,
/#token=uclaw/,
'fallback should not push configured users straight into Dashboard',
);
assert.doesNotMatch(
lineOf(script, ':ready') + '\n' + lineOf(script, 'exit /b 0'),
/start ""/,
'ready fallback should not open duplicate browser tabs',
start,
/openUrl\(`http:\/\/127\.0\.0\.1:18788/,
'Config Center must not be opened on a hardcoded port',
);
});
test('portable launchers route configured model hosts around the system proxy', () => {
const winStart = readRepoFile('portable', 'Windows-Start.bat');
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(
winStart,
/resolve-no-proxy\.mjs[\s\S]*UCLAW_NO_PROXY[\s\S]*set "NO_PROXY=/,
'Windows-Start.bat should set NO_PROXY from resolve-no-proxy.mjs',
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',
);
});
const macStart = readRepoFile('portable', 'Mac-Start.command');
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(
macStart,
/resolve-no-proxy\.mjs[\s\S]*export NO_PROXY=/,
'Mac-Start.command should export NO_PROXY from resolve-no-proxy.mjs',
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']);
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 [
'Windows-Start.bat',
'Windows-IntranetFix.bat',
'Windows-LocalModel.bat',
'OpenClaw-Doctor.bat',
'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);
@@ -115,10 +175,10 @@ test('customer-facing .bat launchers are pure ASCII (cmd.exe mis-parses UTF-8 Ch
test('macOS .command launchers are LF-only (CRLF breaks #!/bin/bash on macOS)', () => {
for (const name of [
'Mac-Start.command',
'Mac-IntranetFix.command',
'Mac-LocalModel.command',
'Mac-OpenClaw-Doctor.command',
'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);
@@ -128,18 +188,18 @@ test('macOS .command launchers are LF-only (CRLF breaks #!/bin/bash on macOS)',
});
test('macOS local-model / intranet launchers call the shared cross-platform scripts', () => {
assert.match(readRepoFile('portable', 'Mac-IntranetFix.command'), /lib\/intranet-check\.mjs/);
assert.match(readRepoFile('portable', 'Mac-LocalModel.command'), /lib\/setup-local-model\.mjs/);
assert.match(readRepoFile('portable', 'Mac-OpenClaw-Doctor.command'), /doctor --non-interactive/);
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', 'Windows-LocalModel.bat');
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', 'OpenClaw-Doctor.bat');
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/);
@@ -175,13 +235,13 @@ function unescapedParenEchoesInsideBlocks(bat) {
test('Windows launchers have no unescaped parens in echoes inside IF/FOR blocks (v2.1.10 flash-exit regression)', () => {
for (const name of [
'Windows-Start.bat',
'Windows-IntranetFix.bat',
'Windows-LocalModel.bat',
'OpenClaw-Doctor.bat',
'Windows-Diagnose.bat',
'Windows-Menu.bat',
'Windows-Install.bat',
'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);
@@ -213,3 +273,50 @@ test('Electron desktop launcher disables OpenClaw bonjour discovery on Windows o
'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`,
);
}
});