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 { 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, / { 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}"`); } });