按 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:
312
tests/i18n-pages.test.mjs
Normal file
312
tests/i18n-pages.test.mjs
Normal 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');
|
||||
});
|
||||
Reference in New Issue
Block a user