package-smoke-test.js 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139
  1. 'use strict';
  2. const assert = require('assert/strict');
  3. const fs = require('fs');
  4. const os = require('os');
  5. const path = require('path');
  6. const { spawnSync } = require('child_process');
  7. const ROOT = path.resolve(__dirname, '..');
  8. function readJson(relativePath) {
  9. return JSON.parse(fs.readFileSync(path.join(ROOT, relativePath), 'utf8').replace(/^\uFEFF/, ''));
  10. }
  11. function runNpmPackDryRun() {
  12. const command = process.platform === 'win32' ? 'cmd.exe' : 'npm';
  13. const args = process.platform === 'win32'
  14. ? ['/d', '/s', '/c', 'npm', 'pack', '--dry-run', '--json']
  15. : ['pack', '--dry-run', '--json'];
  16. const result = spawnSync(command, args, {
  17. cwd: ROOT,
  18. encoding: 'utf8',
  19. env: { ...process.env, npm_config_cache: path.join(os.tmpdir(), 'fmode-qiwei-npm-cache') },
  20. });
  21. if (result.status !== 0) throw new Error('npm pack --dry-run 执行失败');
  22. return JSON.parse(result.stdout)[0];
  23. }
  24. function main() {
  25. const pkg = readJson('package.json');
  26. const lock = readJson('package-lock.json');
  27. const manifest = readJson('skill-package-manifest.json');
  28. const plugin = readJson('.claude-plugin/plugin.json');
  29. const catalog = readJson('mcp/catalog/qiwei-endpoints.json');
  30. const catalogSource = fs.readFileSync(path.join(ROOT, 'mcp', 'catalog', 'qiwei-endpoints.json'), 'utf8');
  31. const serverSource = fs.readFileSync(path.join(ROOT, 'mcp', 'src', 'server.js'), 'utf8');
  32. const dashboardSource = fs.readFileSync(path.join(ROOT, 'mcp', 'src', 'dashboard', 'app.js'), 'utf8');
  33. const serverVersion = serverSource.match(/new McpServer\([\s\S]*?version:\s*['"]([^'"]+)/)?.[1];
  34. const toolNames = [...serverSource.matchAll(/registerTool\(\s*['"]([^'"]+)['"]/g)].map(match => match[1]);
  35. const skillDirs = fs.readdirSync(path.join(ROOT, 'skills'), { withFileTypes: true })
  36. .filter(entry => entry.isDirectory() && fs.existsSync(path.join(ROOT, 'skills', entry.name, 'SKILL.md')))
  37. .map(entry => entry.name)
  38. .sort();
  39. assert.equal(pkg.name, 'fmode-qiwei');
  40. assert.equal(pkg.bin?.['fmode-qiwei'], 'install.js');
  41. assert.equal(pkg.version, lock.version);
  42. assert.equal(pkg.version, lock.packages?.['']?.version);
  43. assert.equal(pkg.version, manifest.version);
  44. assert.equal(pkg.version, plugin.version);
  45. assert.equal(pkg.version, serverVersion);
  46. assert.equal(manifest.name, pkg.name);
  47. assert.equal(manifest.npmPackage, pkg.name);
  48. assert.equal(manifest.status, 'published');
  49. assert.match(manifest.installCommand, /fmode-qiwei@latest/);
  50. assert.equal(pkg.engines?.node, '>=22.5.0');
  51. assert.equal(new Set(toolNames).size, toolNames.length, 'MCP 工具名不能重复');
  52. assert.equal(toolNames.length, manifest.mcpToolCount, 'MCP 工具数量与 manifest 不一致');
  53. assert.deepEqual(skillDirs, [...manifest.skills].sort(), 'Skill 目录与 manifest 不一致');
  54. assert.equal(
  55. (catalogSource.match(/q-[a-z-]+=/gi) || []).length,
  56. 0,
  57. '公开接口清单不能包含外部签名参数'
  58. );
  59. const createClient = catalog.endpoints.find(endpoint => endpoint.id === 'client.createClient');
  60. assert(createClient, '接口清单缺少 client.createClient');
  61. const hiddenNetworkParam = ['a', 'i', 'd'].join('');
  62. assert(
  63. !createClient.params.some(param => String(param.name).toLowerCase() === hiddenNetworkParam),
  64. '公开接口清单不能暴露内部网络组件参数'
  65. );
  66. assert.equal(
  67. createClient.params.find(param => param.name === 'clientVersion')?.desc,
  68. '客户端版本由 Fmode 服务自动选择,通常无需填写。'
  69. );
  70. assert.doesNotMatch(
  71. dashboardSource,
  72. /(?:window\s*\.\s*)?(?:alert|confirm|prompt)\s*\(/,
  73. 'Dashboard 不能调用浏览器原生 alert、confirm 或 prompt'
  74. );
  75. const pack = runNpmPackDryRun();
  76. const packedPaths = new Set(pack.files.map(item => item.path.replace(/\\/g, '/')));
  77. for (const required of [
  78. '.claude-plugin/plugin.json',
  79. '.env.example',
  80. '.mcp.json',
  81. 'install.js',
  82. 'mcp/src/core/runtime-context.js',
  83. 'mcp/src/core/startup-summary.js',
  84. 'mcp/src/core/relay-daemon.js',
  85. 'mcp/src/core/agent-memory.js',
  86. 'mcp/src/core/agent-context-builder.js',
  87. 'mcp/src/core/agent-memory-worker.js',
  88. 'mcp/src/core/voice-clone-service.js',
  89. 'mcp/src/server.js',
  90. 'mcp/src/tools/qiwei-agent-control-run.js',
  91. 'qiwei.runtime.config.example.mjs',
  92. 'runtime/callback-service/package.json',
  93. 'runtime/callback-service/src/index.mjs',
  94. 'runtime/callback-service/src/personal-polling.mjs',
  95. 'runtime/callback-service/src/enterprise-relay-client.mjs',
  96. 'scripts/start-callback-runtime.mjs',
  97. 'scripts/callback-runtime-smoke-test.mjs',
  98. 'scripts/callback-relay-smoke-test.js',
  99. 'scripts/voice-clone-smoke-test.js',
  100. 'scripts/agent-console-smoke-test.js',
  101. 'scripts/preview-dashboard.js',
  102. 'scripts/startup-preview-smoke-test.js',
  103. 'skill-package-manifest.json',
  104. 'templates/workspace.gitignore',
  105. 'THIRD_PARTY_NOTICES.md',
  106. ]) {
  107. assert(packedPaths.has(required), `npm 包缺少 ${required}`);
  108. }
  109. const forbidden = [...packedPaths].filter(item =>
  110. /(^|\/)(?:node_modules|outputs?|\.playwright-cli|coverage|dist)(\/|$)/i.test(item) ||
  111. /(^|\/)\.env\.local$/i.test(item) ||
  112. /\.(?:db|sqlite|sqlite3|log|tgz|zip)$/i.test(item)
  113. );
  114. assert.deepEqual(forbidden, [], `npm 包包含本地运行文件:${forbidden.join(', ')}`);
  115. process.stdout.write(`${JSON.stringify({
  116. status: 'ok',
  117. version: pkg.version,
  118. skillCount: skillDirs.length,
  119. mcpToolCount: toolNames.length,
  120. packageEntries: pack.entryCount,
  121. packageSize: pack.size,
  122. unpackedSize: pack.unpackedSize,
  123. forbiddenFiles: forbidden.length,
  124. }, null, 2)}\n`);
  125. }
  126. try { main(); }
  127. catch (error) {
  128. process.stderr.write(`${error.message}\n`);
  129. process.exit(1);
  130. }