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 "" = @'...'@ 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(', ')}`); } });