smoke.mjs 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353
  1. // 自动质检冒烟测试
  2. // ---------------------------------------------------------------------------
  3. // 运行:npm test 或 node test/smoke.mjs
  4. // 零依赖,仅用 node:assert 与 node:test。
  5. import test from 'node:test';
  6. import assert from 'node:assert/strict';
  7. import path from 'node:path';
  8. import fs from 'node:fs';
  9. import { fileURLToPath } from 'node:url';
  10. import * as core from '../lib/index.mjs';
  11. const __dirname = path.dirname(fileURLToPath(import.meta.url));
  12. const ROOT = path.resolve(__dirname, '..');
  13. // ============================================================
  14. // 1. ESM 入口导出完整性
  15. // ============================================================
  16. test('lib/index.mjs 导出全部公共接口', () => {
  17. const required = [
  18. 'VERSION',
  19. 'PLATFORM',
  20. 'ENDPOINTS',
  21. 'CREDENTIAL_CHAIN',
  22. 'TIERS',
  23. 'RUNTIMES',
  24. 'CHECKS',
  25. 'runChecks',
  26. 'summarize',
  27. 'validateName',
  28. 'validatePackageJson',
  29. 'validateManifest',
  30. 'validateFrontmatter',
  31. 'parseFrontmatter',
  32. 'resolveApiToken',
  33. 'bootstrap',
  34. 'INVENTORY',
  35. 'byTier',
  36. 'stats',
  37. 'publishPlan',
  38. ];
  39. for (const k of required) {
  40. assert.ok(k in core, `缺少导出:${k}`);
  41. }
  42. assert.equal(core.VERSION, '1.0.3');
  43. });
  44. test('ENDPOINTS 真值表结构合法', () => {
  45. for (const [key, e] of Object.entries(core.ENDPOINTS)) {
  46. assert.ok(e.url, `${key} 缺 url`);
  47. assert.ok(['live', 'planned', 'deprecated'].includes(e.status), `${key} status 非法:${e.status}`);
  48. assert.ok(e.auth, `${key} 缺 auth`);
  49. assert.ok(e.purpose, `${key} 缺 purpose`);
  50. }
  51. // 至少要有 live 端点,否则平台没得用
  52. assert.ok(core.endpointsByStatus('live').length >= 4, 'live 端点数量异常');
  53. });
  54. // ============================================================
  55. // 2. 命名校验
  56. // ============================================================
  57. test('validateName 接受合法名、拒绝非法名', () => {
  58. assert.equal(core.validateName('skill-my-thing').ok, true);
  59. assert.equal(core.validateName('fmode-image').ok, true);
  60. assert.equal(core.validateName('my-thing').ok, false);
  61. assert.equal(core.validateName('skill-My_Thing').ok, false);
  62. assert.equal(core.validateName('').ok, false);
  63. assert.equal(core.validateName(null).ok, false);
  64. });
  65. // ============================================================
  66. // 3. package.json 校验
  67. // ============================================================
  68. test('validatePackageJson 对合法包通过', () => {
  69. const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf-8'));
  70. const r = core.validatePackageJson(pkg);
  71. assert.equal(r.ok, true, `本仓库 package.json 应合法,实际错误:${r.errors.join(';')}`);
  72. });
  73. test('validatePackageJson 捕获缺失字段与 CJS 残留', () => {
  74. const bad = { name: 'skill-x', version: '1.0.0' };
  75. const r = core.validatePackageJson(bad);
  76. assert.equal(r.ok, false);
  77. assert.ok(r.errors.some((e) => e.includes('type')));
  78. assert.ok(r.errors.some((e) => e.includes('exports')));
  79. const cjs = {
  80. name: 'skill-x',
  81. version: '1.0.0',
  82. description: 'd',
  83. type: 'module',
  84. main: './lib/index.mjs',
  85. exports: { '.': { import: './lib/index.mjs', default: './lib/index.mjs' } },
  86. bin: { x: './bin/x.mjs' },
  87. files: [],
  88. license: 'MIT',
  89. require: './lib/index.cjs',
  90. };
  91. const r2 = core.validatePackageJson(cjs);
  92. assert.equal(r2.ok, false);
  93. assert.ok(r2.errors.some((e) => e.includes('require')));
  94. });
  95. // ============================================================
  96. // 4. frontmatter 解析(三种格式)
  97. // ============================================================
  98. test('parseFrontmatter 解析标量与行内数组', () => {
  99. const text = [
  100. '---',
  101. 'name: skill-demo',
  102. 'description: "一段描述"',
  103. 'version: 1.0.0',
  104. 'tags: [a, b, c]',
  105. '---',
  106. '',
  107. '# 标题',
  108. ].join('\n');
  109. const { data, body, raw } = core.parseFrontmatter(text);
  110. assert.equal(data.name, 'skill-demo');
  111. assert.equal(data.description, '一段描述');
  112. assert.deepEqual(data.tags, ['a', 'b', 'c']);
  113. assert.ok(raw !== null);
  114. assert.ok(body.includes('# 标题'));
  115. });
  116. test('validateFrontmatter 校验 hermes 与 skillhub 两种格式', () => {
  117. const hermes = '---\nname: skill-demo\ndescription: d\nversion: 1.0.0\ntags: [x]\n---\nbody';
  118. const h = core.validateFrontmatter(hermes, 'hermes');
  119. assert.equal(h.ok, true, h.errors.join(';'));
  120. const skillhub = '---\nslug: fmode-skill-demo\ndisplayName: skill-demo\nversion: 1.0.0\nsummary: s\nlicense: MIT\n---\nbody';
  121. const s = core.validateFrontmatter(skillhub, 'skillhub');
  122. assert.equal(s.ok, true, s.errors.join(';'));
  123. // 缺字段应失败
  124. const broken = '---\nname: skill-demo\n---\nbody';
  125. assert.equal(core.validateFrontmatter(broken, 'skillhub').ok, false);
  126. });
  127. test('本仓库 SKILL.md 满足 skillhub 格式', () => {
  128. const text = fs.readFileSync(path.join(ROOT, 'SKILL.md'), 'utf-8');
  129. const r = core.validateFrontmatter(text, 'skillhub');
  130. assert.equal(r.ok, true, `SKILL.md frontmatter 不合法:${r.errors.join(';')}`);
  131. });
  132. // ============================================================
  133. // 5. manifest 校验
  134. // ============================================================
  135. test('validateManifest 对本仓库清单通过', () => {
  136. const m = JSON.parse(fs.readFileSync(path.join(ROOT, 'skill-package-manifest.json'), 'utf-8'));
  137. const r = core.validateManifest(m);
  138. assert.equal(r.ok, true, r.errors.join(';'));
  139. });
  140. // ============================================================
  141. // 6. 清单数据完整性
  142. // ============================================================
  143. test('INVENTORY 覆盖三个分层且条目字段完整', () => {
  144. const s = core.stats();
  145. assert.ok(s.total >= 15, `清单条目过少:${s.total}`);
  146. assert.ok(s.byTier.system > 0 && s.byTier.service > 0 && s.byTier.application > 0);
  147. for (const sk of core.INVENTORY) {
  148. assert.ok(sk.name, '条目缺 name');
  149. assert.ok(sk.displayName, `${sk.name} 缺 displayName`);
  150. assert.ok(sk.summary, `${sk.name} 缺 summary`);
  151. assert.ok(Array.isArray(sk.platforms) && sk.platforms.length, `${sk.name} 缺 platforms`);
  152. assert.ok(core.TIERS[sk.tier], `${sk.name} tier 非法:${sk.tier}`);
  153. for (const p of sk.platforms) {
  154. assert.ok(core.CHANNELS[p], `${sk.name} 平台非法:${p}`);
  155. }
  156. // npm 渠道必须给出包名
  157. if (sk.platforms.includes('npm')) {
  158. assert.ok(sk.npmName, `${sk.name} 声明了 npm 渠道但缺 npmName`);
  159. }
  160. }
  161. });
  162. test('任务书点名的技能全部在清单中', () => {
  163. const expected = [
  164. 'skill-heterarchy',
  165. 'skill-multi-branch',
  166. 'skill-bypass-permission',
  167. 'skill-task-progress',
  168. 'plugin-wecom-fix',
  169. 'skill-agent-clone',
  170. 'skill-storage',
  171. 'skill-image',
  172. 'skill-vision',
  173. 'skill-listen',
  174. 'fmode-ffmpeg',
  175. 'fmode-qiwei',
  176. 'skill-study-report',
  177. 'skill-present',
  178. 'fmode-product-lab',
  179. ];
  180. const names = core.INVENTORY.map((s) => s.name);
  181. for (const e of expected) {
  182. assert.ok(names.includes(e), `清单缺少任务书点名的技能:${e}`);
  183. }
  184. });
  185. // ============================================================
  186. // 7. 分发计划
  187. // ============================================================
  188. test('publishPlan 覆盖四渠道且命令非空', () => {
  189. const plan = core.publishPlan('skill-demo', { dir: '.', changelog: 'test' });
  190. assert.equal(plan.length, 4);
  191. assert.deepEqual(plan.map((p) => p.channel), ['gogs', 'github', 'npm', 'skillhub']);
  192. for (const p of plan) {
  193. assert.ok(p.steps.length > 0, `${p.channel} 无步骤`);
  194. for (const s of p.steps) assert.equal(typeof s, 'string');
  195. }
  196. });
  197. // ============================================================
  198. // 8. 六项检查注册表
  199. // ============================================================
  200. test('CHECKS 恰好六项且 id 唯一', () => {
  201. assert.equal(core.CHECKS.length, 6);
  202. const ids = core.CHECKS.map((c) => c.id);
  203. assert.equal(new Set(ids).size, 6);
  204. assert.deepEqual(ids, ['functional', 'apiConnectivity', 'sop', 'dashboard', 'loop', 'multiRuntime']);
  205. });
  206. test('summarize 正确聚合(含 skip 语义)', () => {
  207. const s = core.summarize([
  208. { id: 'a', status: 'pass' },
  209. { id: 'b', status: 'pass' },
  210. { id: 'c', status: 'skip' },
  211. ]);
  212. assert.equal(s.total, 3);
  213. assert.equal(s.pass, 2);
  214. assert.equal(s.skip, 1);
  215. assert.equal(s.ok, false, '有 skip 时不应判定为完全通过');
  216. assert.equal(s.partial, true);
  217. assert.deepEqual(s.unverified, ['c']);
  218. const allPass = core.summarize([{ id: 'a', status: 'pass' }, { id: 'b', status: 'pass' }]);
  219. assert.equal(allPass.ok, true);
  220. assert.equal(allPass.partial, false);
  221. });
  222. // ============================================================
  223. // 9. 离线质检自跑(本仓库对自己做质检)
  224. // ============================================================
  225. test('runChecks --offline 在本仓库上可运行且无失败项', async () => {
  226. const report = await core.runChecks(ROOT, { offline: true, skipExec: false });
  227. assert.equal(report.results.length, 6);
  228. const failed = report.results.filter((r) => r.status === 'fail');
  229. assert.equal(
  230. failed.length,
  231. 0,
  232. `本仓库质检不应有失败项,实际:\n${failed.map((f) => `${f.id}: ${f.detail}`).join('\n')}`,
  233. );
  234. });
  235. test('runChecks 拒绝未知检查项', async () => {
  236. await assert.rejects(
  237. () => core.runChecks(ROOT, { only: ['nope'] }),
  238. /未知检查项/,
  239. );
  240. });
  241. // ============================================================
  242. // 10. 凭据解析(不联网,只验证形态与回落行为)
  243. // ============================================================
  244. test('validateToken 拒绝 sk-ant- 与非法形态', async () => {
  245. const { validateToken } = await import('../lib/bootstrap.mjs');
  246. assert.equal(validateToken('sk-ant-api03-xxxx').ok, false);
  247. assert.equal(validateToken('not-a-key').ok, false);
  248. assert.equal(validateToken('').ok, false);
  249. assert.equal(validateToken(null).ok, false);
  250. assert.equal(validateToken('sk-abcdefgh12345678').ok, true);
  251. });
  252. test('resolveFmodeDir 尊重 FMODE_HOME 覆盖', async () => {
  253. const { resolveFmodeDir } = await import('../lib/bootstrap.mjs');
  254. const prev = process.env.FMODE_HOME;
  255. process.env.FMODE_HOME = '/tmp/fmode-test-home';
  256. try {
  257. assert.equal(resolveFmodeDir(), '/tmp/fmode-test-home');
  258. } finally {
  259. if (prev === undefined) delete process.env.FMODE_HOME;
  260. else process.env.FMODE_HOME = prev;
  261. }
  262. });
  263. // ============================================================
  264. // 11. 浏览器 bundle 无 Node 依赖
  265. // ============================================================
  266. test('browser/index.mjs 不 import 任何 node: 内置模块', () => {
  267. const src = fs.readFileSync(path.join(ROOT, 'browser', 'index.mjs'), 'utf-8');
  268. const nodeImports = [...src.matchAll(/from\s+['"]node:[a-z_]+['"]/g)];
  269. assert.equal(nodeImports.length, 0, `browser bundle 违规引入:${nodeImports.map((m) => m[0]).join(', ')}`);
  270. });
  271. test('browser bundle 可独立 import 且导出核心常量', async () => {
  272. const b = await import('../browser/index.mjs');
  273. assert.equal(b.VERSION, '1.0.3');
  274. assert.ok(b.PLATFORM.apiBase.includes('fmode.cn'));
  275. assert.ok(Object.keys(b.ENDPOINTS).length > 0);
  276. assert.equal(typeof b.validatePackageJson, 'function');
  277. assert.equal(typeof b.checkApiConnectivity, 'function');
  278. });
  279. // ============================================================
  280. // 12. 脚手架模板完整性
  281. // ============================================================
  282. test('templates/skill-starter 脚手架文件齐全', () => {
  283. const tpl = path.join(ROOT, 'templates', 'skill-starter');
  284. assert.ok(fs.existsSync(tpl), '脚手架模板目录不存在');
  285. const required = [
  286. 'SKILL.md',
  287. 'package.json',
  288. 'lib/index.mjs',
  289. 'skill-package-manifest.json',
  290. 'README.md',
  291. 'LICENSE',
  292. ];
  293. for (const f of required) {
  294. assert.ok(fs.existsSync(path.join(tpl, f)), `脚手架缺少 ${f}`);
  295. }
  296. const binDir = path.join(tpl, 'bin');
  297. assert.ok(fs.existsSync(binDir), '脚手架缺少 bin/');
  298. const bins = fs.readdirSync(binDir).filter((f) => f.endsWith('.mjs'));
  299. assert.ok(bins.length > 0, '脚手架 bin/ 下无 .mjs 入口');
  300. const testDir = path.join(tpl, 'test');
  301. assert.ok(fs.existsSync(testDir), '脚手架缺少 test/');
  302. });
  303. test('脚手架占位符齐全(可被 init 替换)', () => {
  304. const tpl = path.join(ROOT, 'templates', 'skill-starter');
  305. const pkg = JSON.parse(fs.readFileSync(path.join(tpl, 'package.json'), 'utf-8'));
  306. assert.equal(pkg.name, '__SKILL_NAME__', 'package.json 应使用 __SKILL_NAME__ 占位符');
  307. assert.ok(pkg.type === 'module');
  308. assert.equal(pkg.main, './lib/index.mjs');
  309. assert.ok(pkg.bin['__CLI_NAME__'], 'bin 应使用 __CLI_NAME__ 占位符');
  310. });