Some checks failed
Tests / test (push) Has been cancelled
中文版一直是上游的旧内容,和英文版已经完全对不上:虾盘云首选卡片、 10 个中国技能、QQ 列为预装、还夹着上游另一个产品的推广段落。312 → 165 行。 现在两版讲同一个产品,面向新加坡的华语用户,而不是中国市场。 顺带修掉一条和事实相反的建议:中文版原来写着「U 盘请用 NTFS 格式(不要 exFAT/FAT32)」。macOS 对 NTFS 只读 —— 照做的话「配置跟着盘走」在 Mac 上 直接失效,首次启动连配置都写不进去。这和 release.yml 里那条是同一个错误, 两处都改了。 英文版补上 fork 说明(原来只写在 CLAUDE.md 里),两版现在都写明这是 dongsheng123132/u-claw 的分支、且不向上游提交。 新增断言 tests/i18n-pages.test.mjs:两版必须互链、必须都写明是 fork、 必须都建议 exFAT 且不得出现推荐 NTFS 的措辞、中文版不得再出现英文版 已经删掉的那批上游内容。这条断言的存在是因为它们上次就是这么漂开的。
342 lines
17 KiB
JavaScript
342 lines
17 KiB
JavaScript
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');
|
|
});
|
|
|
|
test('the two READMEs describe the same product', () => {
|
|
// They drifted badly before: the English one had been rewritten for the fork
|
|
// while the Chinese one still pitched the upstream China-market build —
|
|
// relay service first, ten China-platform skills, QQ pre-installed.
|
|
const en = read('README.md');
|
|
const zh = read('README.zh-CN.md');
|
|
|
|
for (const [name, body] of [['README.md', en], ['README.zh-CN.md', zh]]) {
|
|
// Both must link to the other, or half the audience is stranded.
|
|
assert.match(body, name === 'README.md' ? /README\.zh-CN\.md/ : /README\.md/, `${name} should link to the other language`);
|
|
// Both must say this is a fork. Claiming otherwise misrepresents whose work it is.
|
|
assert.match(body, /dongsheng123132\/u-claw/, `${name} should name the upstream project`);
|
|
// The file-system guidance is a correctness issue, not a preference:
|
|
// macOS cannot write NTFS, so an NTFS drive breaks the whole premise.
|
|
assert.match(body, /exFAT/, `${name} should say to format the drive exFAT`);
|
|
assert.doesNotMatch(
|
|
body,
|
|
/(use|请用|格式化).{0,12}NTFS/i,
|
|
`${name} appears to recommend NTFS, which macOS cannot write to`,
|
|
);
|
|
}
|
|
|
|
// Anything the English one dropped for being upstream's pitch must be gone
|
|
// from the Chinese one too.
|
|
for (const stale of ['虾盘云', '免翻墙', '国内镜像', '小红书', 'hecare888']) {
|
|
assert.ok(!zh.includes(stale), `README.zh-CN.md still carries upstream's "${stale}"`);
|
|
}
|
|
});
|