import { readFileSync, readdirSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { join } from 'node:path'; import test from 'node:test'; import assert from 'node:assert/strict'; const repoRoot = fileURLToPath(new URL('..', import.meta.url)); function readRepoFile(...parts) { return readFileSync(join(repoRoot, ...parts), 'utf8'); } function lineOf(content, needle) { const lines = content.split(/\r?\n/); const index = lines.findIndex((line) => line.includes(needle)); assert.notEqual(index, -1, `missing line containing: ${needle}`); return lines[index]; } // The start logic moved out of the .bat/.command into lib/start.mjs so the two // platforms cannot drift and so user-facing text can be translated. These // assertions moved with it — the launchers are now only expected to find node // and hand off. test('Start launchers are thin shells that delegate to lib/start.mjs', () => { for (const scriptName of ['START HERE - Windows.bat', 'START HERE - Mac.command']) { const script = readRepoFile('portable', scriptName); assert.match(script, /start\.mjs/, `${scriptName} should hand off to lib/start.mjs`); const lines = script.split(/\r?\n/).filter((line) => line.trim() && !line.trim().startsWith('REM') && !line.trim().startsWith('#')); assert.ok( lines.length <= 30, `${scriptName} should stay a thin shell, got ${lines.length} code lines`, ); // Startup responsibilities that must NOT come back into a launcher script. for (const leaked of [/netstat/, /lsof/, /npm.{0,20}install/, /node_modules/]) { assert.doesNotMatch(script, leaked, `${scriptName} should not re-implement ${leaked}`); } } }); test('portable launchers disable OpenClaw bonjour discovery', () => { // Windows-Menu/Install still launch OpenClaw directly, so they keep the env var. for (const scriptName of ['Windows-Menu.bat', 'Windows-Install.bat']) { const script = readRepoFile('portable', 'advanced', scriptName); assert.match( script, /OPENCLAW_DISABLE_BONJOUR=1/, `${scriptName} should disable bonjour discovery`, ); } // The Start path now sets it in the shared launcher, for both platforms at once. assert.match( readRepoFile('portable', 'lib', 'start.mjs'), /OPENCLAW_DISABLE_BONJOUR\s*=\s*'1'/, 'start.mjs should disable bonjour discovery', ); }); test('startup always opens Config Center on its real port', () => { const start = readRepoFile('portable', 'lib', 'start.mjs'); assert.match( start, /openUrl\(`http:\/\/127\.0\.0\.1:\$\{configPort\}\/`\)/, 'start.mjs should always open Config Center for model/channel changes', ); // Mac-Start.command used to hardcode 18788 and opened a dead page whenever the // config server fell back to another port. The port now comes from runtime.json. assert.match( start, /configServerPort/, 'the Config Center port must come from runtime.json, not a hardcoded default', ); assert.doesNotMatch( start, /openUrl\(`http:\/\/127\.0\.0\.1:18788/, 'Config Center must not be opened on a hardcoded port', ); }); test('gateway fallback returns users to Config Center, not the Dashboard', () => { // Was lib/wait-gateway.bat (Windows only). Now in start.mjs, so macOS gets it too. const start = readRepoFile('portable', 'lib', 'start.mjs'); assert.match( start, /watchGatewayReady[\s\S]*openUrl\(`http:\/\/127\.0\.0\.1:\$\{configPort\}\/`\)/, 'the fallback watcher should reopen Config Center on timeout', ); const bodyStart = start.indexOf('function watchGatewayReady'); const body = start.slice(bodyStart, start.indexOf('\n}', bodyStart)); assert.doesNotMatch( body, /#token=/, 'the fallback should not push users straight into the Dashboard', ); }); test('startup routes configured model hosts around the system proxy', () => { // One implementation now covers both platforms; this used to be duplicated in // the .bat and the .command with subtly different parsing. assert.match( readRepoFile('portable', 'lib', 'start.mjs'), /resolve-no-proxy\.mjs[\s\S]*UCLAW_NO_PROXY[\s\S]*process\.env\.NO_PROXY/, 'start.mjs should set NO_PROXY from resolve-no-proxy.mjs', ); }); test('startup keeps the accelerators the launchers used to wire up', () => { const start = readRepoFile('portable', 'lib', 'start.mjs'); for (const helper of ['portable-cache.mjs', 'prewarm.mjs', 'check-update.mjs', 'loading.html']) { assert.match(start, new RegExp(helper.replace('.', '\\.')), `start.mjs should still use ${helper}`); } }); test('the WeChat plugin install is no longer Windows-only', () => { // Windows-Start.bat installed it; Mac-Start.command never did, so Mac users // silently had no WeChat channel. It lives in the shared launcher now. assert.match( readRepoFile('portable', 'lib', 'start.mjs'), /openclaw-weixin/, 'start.mjs should install the WeChat plugin on every platform', ); }); test('every message key used by start.mjs exists in all catalogues', () => { const start = readRepoFile('portable', 'lib', 'start.mjs'); const keys = new Set(); for (const [, key] of start.matchAll(/\bt\(\s*'([a-z0-9_.]+)'/g)) keys.add(key); // bail() composes .what/.why/.action from a base key. for (const [, base] of start.matchAll(/bail\(t,\s*'([a-z0-9_.]+)'/g)) { for (const part of ['what', 'why', 'action']) keys.add(`${base}.${part}`); } assert.ok(keys.size > 0, 'expected start.mjs to reference message keys'); for (const locale of ['en', 'zh-CN']) { const catalogue = JSON.parse(readRepoFile('portable', 'lib', 'messages', `${locale}.json`)); const missing = [...keys].filter((key) => !(key in catalogue)).sort(); assert.deepEqual(missing, [], `${locale}.json is missing keys:\n${missing.join('\n')}`); } }); test('message catalogues have no untranslated leftovers', () => { const en = JSON.parse(readRepoFile('portable', 'lib', 'messages', 'en.json')); const zh = JSON.parse(readRepoFile('portable', 'lib', 'messages', 'zh-CN.json')); const extra = Object.keys(zh).filter((key) => !(key in en)); assert.deepEqual(extra, [], `zh-CN.json has keys English does not: ${extra.join(', ')}`); // Product names and technical identifiers are the same in every language. // Everything else being identical means a string was copied, not translated. const NOT_TRANSLATED_BY_DESIGN = new Set(['start.node_version']); const identical = Object.keys(en).filter( (key) => !NOT_TRANSLATED_BY_DESIGN.has(key) && zh[key] === en[key] && /[A-Za-z]{4,}/.test(en[key]) && !/^\{/.test(en[key]) ); assert.deepEqual(identical, [], `zh-CN.json still holds English text for: ${identical.join(', ')}`); }); test('customer-facing .bat launchers are pure ASCII (cmd.exe mis-parses UTF-8 Chinese on GBK Windows)', () => { // Non-ASCII bytes in a .bat get read as GBK by Chinese Windows cmd.exe, which // garbles parsing ("usebackq is not a command"). Chinese UX must live in the // node tools' stdout (rendered fine under chcp 65001), never in the .bat itself. for (const name of [ 'START HERE - Windows.bat', 'advanced/Windows-IntranetFix.bat', 'advanced/Windows-LocalModel.bat', 'advanced/OpenClaw-Doctor.bat', ]) { const bytes = readFileSync(join(repoRoot, 'portable', name)); const offending = bytes.findIndex((b) => b > 0x7f); assert.equal(offending, -1, `${name} has a non-ASCII byte at offset ${offending}`); assert.ok(bytes.includes(0x0d), `${name} must use CRLF line endings`); } }); test('macOS .command launchers are LF-only (CRLF breaks #!/bin/bash on macOS)', () => { for (const name of [ 'START HERE - Mac.command', 'advanced/Mac-IntranetFix.command', 'advanced/Mac-LocalModel.command', 'advanced/Mac-OpenClaw-Doctor.command', ]) { const bytes = readFileSync(join(repoRoot, 'portable', name)); const cr = bytes.indexOf(0x0d); assert.equal(cr, -1, `${name} has a CR byte at offset ${cr} (must be LF-only)`); assert.ok(bytes.toString('utf8').startsWith('#!/bin/bash'), `${name} must start with a clean shebang`); } }); test('macOS local-model / intranet launchers call the shared cross-platform scripts', () => { assert.match(readRepoFile('portable', 'advanced', 'Mac-IntranetFix.command'), /lib\/intranet-check\.mjs/); assert.match(readRepoFile('portable', 'advanced', 'Mac-LocalModel.command'), /lib\/setup-local-model\.mjs/); assert.match(readRepoFile('portable', 'advanced', 'Mac-OpenClaw-Doctor.command'), /doctor --non-interactive/); }); test('local-model setup launcher calls setup-local-model.mjs', () => { const bat = readRepoFile('portable', 'advanced', 'Windows-LocalModel.bat'); assert.match(bat, /lib\\setup-local-model\.mjs/); }); test('OpenClaw doctor launcher is read-only (no destructive repair flags)', () => { const bat = readRepoFile('portable', 'advanced', 'OpenClaw-Doctor.bat'); assert.match(bat, /OPENCLAW_MJS%" doctor --non-interactive/); // Must not auto-apply repairs that could overwrite user config/state. assert.doesNotMatch(bat, /doctor[^\n]*--fix/); assert.doesNotMatch(bat, /doctor[^\n]*--repair/); assert.doesNotMatch(bat, /doctor[^\n]*--force/); }); // cmd.exe treats ')' as the end of an IF/FOR ( ... ) block, so an unescaped paren in // an `echo` *inside* a block aborts parsing ("was unexpected at this time") and the // window flash-closes. This shipped once in v2.1.10 (echo Direct-connect (NO_PROXY)). // Track block depth structurally and flag any unescaped ( or ) in echoes inside a block. function unescapedParenEchoesInsideBlocks(bat) { const offenders = []; let depth = 0; for (const raw of bat.split(/\r?\n/)) { const line = raw.trim(); if (depth > 0 && /^echo\b/i.test(line)) { // cmd eats an unescaped ')' that sits at the END of an echo (cosmetic: the ')' // just disappears). The flash-exit only happens when a ')' is followed by more // text on the line (e.g. "(NO_PROXY): value" → ')' closes the block, ": value" // then errors). So: drop escaped ^), drop trailing ')'/whitespace, and flag any // ')' that survives (meaning it had text after it). let s = line.replace(/\^\)/g, '').replace(/[)\s]+$/, ''); if (s.includes(')')) offenders.push(raw.trim()); } // structural depth: ') else (' keeps depth; leading ')' closes; if/for line ending in '(' opens if (/^\)\s*else\b.*\($/i.test(line)) { /* same depth */ } else if (/^\)/.test(line)) depth = Math.max(0, depth - 1); if (/^\($/.test(line) || /\b(if|for)\b.*[^^]\(\s*$/i.test(line)) depth += 1; } return offenders; } test('Windows launchers have no unescaped parens in echoes inside IF/FOR blocks (v2.1.10 flash-exit regression)', () => { for (const name of [ 'START HERE - Windows.bat', 'advanced/Windows-IntranetFix.bat', 'advanced/Windows-LocalModel.bat', 'advanced/OpenClaw-Doctor.bat', 'advanced/Windows-Diagnose.bat', 'advanced/Windows-Menu.bat', 'advanced/Windows-Install.bat', ]) { const bat = readRepoFile('portable', name); const offenders = unescapedParenEchoesInsideBlocks(bat); assert.deepEqual(offenders, [], `${name} has unescaped parens in block echo(es): ${offenders.join(' | ')}`); } }); test('PowerShell installer generated start.bat disables OpenClaw bonjour discovery', () => { const script = readRepoFile('install', 'install.ps1'); assert.match(script, /\$startBat = @'/); assert.match( script, /set "OPENCLAW_DISABLE_BONJOUR=1"/, 'generated start.bat should disable bonjour discovery', ); }); test('Electron desktop launcher disables OpenClaw bonjour discovery on Windows only', () => { const source = readRepoFile('u-claw-app', 'src', 'main.js'); assert.match( source, /if\s*\(\s*process\.platform\s*===\s*['"]win32['"]\s*\)\s*{[\s\S]*?env\.OPENCLAW_DISABLE_BONJOUR\s*=\s*['"]1['"]/, ); assert.doesNotMatch( source, /OPENCLAW_DISABLE_BONJOUR:\s*['"]1['"]/, 'bonjour disable flag should not be in the unconditional env object', ); }); test('the drive root stays down to a handful of clickable files', () => { // 23 clickable files in the root was the first thing anyone saw on plugging in, // and they had to work out which applied to their computer before anything ran. const CLICKABLE = /\.(bat|command|html|ps1|sh|exe|vbs)$/i; const root = readdirSync(join(repoRoot, 'portable'), { withFileTypes: true }) .filter((e) => e.isFile() && CLICKABLE.test(e.name)) .map((e) => e.name) .sort(); assert.deepEqual( root, ['Read me first.html', 'START HERE - Mac.command', 'START HERE - Windows.bat'], 'the drive root should hold exactly the two entry points and the guide', ); }); test('entry-point filenames avoid characters cmd.exe treats as syntax', () => { // A filename with brackets breaks every echo that mentions it inside an // IF (...) block — the same flash-exit class as the v2.1.10 regression. for (const name of readdirSync(join(repoRoot, 'portable'))) { assert.doesNotMatch(name, /[()&^|<>]/, `${name} contains a character cmd.exe treats as syntax`); } }); test('tools moved into advanced/ resolve the drive root one level up', () => { const dir = join(repoRoot, 'portable', 'advanced'); for (const name of readdirSync(dir)) { if (!/\.(command|sh)$/.test(name)) continue; const body = readFileSync(join(dir, name), 'utf8'); if (!/(UCLAW_DIR|SCRIPT_DIR)=/.test(body)) continue; assert.match( body, /(UCLAW_DIR|SCRIPT_DIR)="\$\(cd "\$\(dirname "\$0"\)\/\.\." && pwd\)"/, `${name} must resolve the drive root as one level up`, ); } for (const name of readdirSync(dir)) { if (!name.endsWith('.bat')) continue; const body = readFileSync(join(dir, name), 'utf8'); if (!/(UCLAW_DIR|SCRIPT_DIR)=/.test(body)) continue; assert.doesNotMatch( body, /set "(UCLAW_DIR|SCRIPT_DIR)=%~dp0"/, `${name} still treats its own folder as the drive root`, ); } });