pack-install-e2e.mjs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250
  1. #!/usr/bin/env node
  2. // M5 出口 D1(AC1 的 CI 半):npm pack 产物 → 干净中文路径工作目录 → init → 建书 → next → update 全链路。
  3. // 经 `npm run e2e:install` 运行(用 npm_execpath 定位 npm,全程 args 数组不走 shell,中文路径安全)。
  4. import { execFile } from 'node:child_process'
  5. import { promisify } from 'node:util'
  6. import { promises as fs } from 'node:fs'
  7. import os from 'node:os'
  8. import path from 'node:path'
  9. import { fileURLToPath } from 'node:url'
  10. const exec = promisify(execFile)
  11. const pkgRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url)))
  12. const npmCli = process.env.npm_execpath
  13. if (!npmCli) {
  14. console.error('请通过 npm run e2e:install 运行(脚本需要 npm_execpath 定位 npm)')
  15. process.exit(1)
  16. }
  17. const npm = (args, opts = {}) =>
  18. exec(process.execPath, [npmCli, ...args], { encoding: 'utf8', maxBuffer: 32 * 1024 * 1024, ...opts })
  19. let failed = false
  20. const check = (cond, msg) => {
  21. console.log(`${cond ? '✓' : '✗'} ${msg}`)
  22. if (!cond) failed = true
  23. }
  24. const exists = async (p) => {
  25. try {
  26. await fs.access(p)
  27. return true
  28. } catch {
  29. return false
  30. }
  31. }
  32. const listRelativeFiles = async (root) => {
  33. const files = []
  34. const walk = async (dir, base = '') => {
  35. for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
  36. const rel = base ? `${base}/${entry.name}` : entry.name
  37. if (entry.isDirectory()) await walk(path.join(dir, entry.name), rel)
  38. else if (entry.isFile()) files.push(rel)
  39. }
  40. }
  41. await walk(root)
  42. return files
  43. }
  44. const parsePackResult = (stdout) => {
  45. const parsed = JSON.parse(stdout)
  46. return Array.isArray(parsed) ? parsed[0] : Object.values(parsed)[0]
  47. }
  48. const cleanups = []
  49. try {
  50. // 1. pack(发布产物 = npx 消费的同一份包内容)
  51. const packDest = await fs.mkdtemp(path.join(os.tmpdir(), 'wnw-pack-'))
  52. cleanups.push(packDest)
  53. const packOut = await npm(['pack', '--json', '--pack-destination', packDest], { cwd: pkgRoot })
  54. const pack = parsePackResult(packOut.stdout)
  55. const packFiles = (pack?.files || []).map((entry) => (typeof entry === 'string' ? entry : entry.path))
  56. const packedDocs = packFiles.filter((rel) => rel.startsWith('docs/'))
  57. check(packFiles.includes('LICENSE'), 'pack 清单含 GPL 许可证')
  58. check(packFiles.includes('docs/migration-guide.md'), 'pack 清单含迁移指南')
  59. check(
  60. packedDocs.every((rel) => rel.startsWith('docs/knowledge/') || rel === 'docs/migration-guide.md'),
  61. 'pack 清单 docs 仅含 knowledge 与 migration-guide',
  62. )
  63. check(!packFiles.some((rel) => rel.toLowerCase().includes('story-repo-spec')), 'pack 清单不含 story-repo-spec')
  64. check(!packFiles.some((rel) => rel.startsWith('docs/architecture/')), 'pack 清单不含 docs/architecture')
  65. const tgzName = pack?.filename
  66. const tgz = path.join(packDest, tgzName)
  67. check(await exists(tgz), `npm pack 产物:${tgzName}`)
  68. // 2. 装进干净沙箱
  69. const sandbox = await fs.mkdtemp(path.join(os.tmpdir(), 'wnw-sandbox-'))
  70. cleanups.push(sandbox)
  71. await npm(['install', tgz, '--prefix', sandbox, '--no-audit', '--no-fund'], { cwd: sandbox })
  72. const installedBin = path.join(sandbox, 'node_modules', 'webnovel-writer', 'bin', 'webnovel-writer.js')
  73. const installedRoot = path.join(sandbox, 'node_modules', 'webnovel-writer')
  74. check(await exists(installedBin), '安装产物 bin 存在')
  75. const installedFiles = await listRelativeFiles(installedRoot)
  76. const installedDocs = installedFiles.filter((rel) => rel.startsWith('docs/'))
  77. check(
  78. installedDocs.every((rel) => rel.startsWith('docs/knowledge/') || rel === 'docs/migration-guide.md'),
  79. '安装包 docs 仅含 knowledge 与 migration-guide',
  80. )
  81. check(!installedFiles.some((rel) => rel.toLowerCase().includes('story-repo-spec')), '安装包不含 story-repo-spec')
  82. check(!installedFiles.some((rel) => rel.startsWith('docs/architecture/')), '安装包不含 docs/architecture')
  83. // 3. 中文用户名模拟路径里 init(一条命令装出工作目录)
  84. const base = await fs.mkdtemp(path.join(os.tmpdir(), '中文用户名-'))
  85. cleanups.push(base)
  86. const workdir = path.join(base, '工作目录')
  87. await fs.mkdir(workdir, { recursive: true })
  88. const userPreToolUse = [
  89. { matcher: 'Write|Edit', hooks: [{ type: 'command', command: 'node 用户自己的写前检查.mjs' }] },
  90. ]
  91. await fs.mkdir(path.join(workdir, '.claude'), { recursive: true })
  92. await fs.writeFile(
  93. path.join(workdir, '.claude', 'settings.json'),
  94. JSON.stringify({ hooks: { PreToolUse: userPreToolUse } }, null, 2) + '\n',
  95. 'utf8',
  96. )
  97. const node = (bin, args) =>
  98. exec(process.execPath, [bin, ...args], { cwd: workdir, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 })
  99. const init = await node(installedBin, ['init', '--hosts=claude-code,codex'])
  100. console.log('--- init 报告 ---\n' + init.stdout + '-----------------')
  101. for (const rel of [
  102. '.webnovel/bin/webnovel-writer.js',
  103. '.webnovel/node_modules/js-yaml/package.json',
  104. '.webnovel/manifest.json',
  105. '.webnovel/books.jsonl',
  106. '.webnovel/references/路由.csv',
  107. '.webnovel/references/节拍/PA-001-压抑蓄力爆发.md',
  108. '.webnovel/docs/knowledge/维度宪章.md',
  109. '.webnovel/docs/knowledge/调用者与字段矩阵.md',
  110. '.webnovel/docs/knowledge/策展规则.md',
  111. '.claude/skills/webnovel-writer/SKILL.md',
  112. '.claude/agents/事实审查.md',
  113. '.claude/settings.json',
  114. '.codex/agents/事实审查.toml',
  115. 'AGENTS.md',
  116. ]) {
  117. check(await exists(path.join(workdir, rel)), `init 布局:${rel}`)
  118. }
  119. const initializedFiles = await listRelativeFiles(path.join(workdir, '.webnovel'))
  120. const initializedDocs = initializedFiles.filter((rel) => rel.startsWith('docs/'))
  121. check(
  122. initializedDocs.every((rel) => rel.startsWith('docs/knowledge/')),
  123. 'init 后 .webnovel/docs 仅含知识治理文档',
  124. )
  125. check(!initializedFiles.some((rel) => rel.toLowerCase().includes('story-repo-spec')), 'init 后不含 story-repo-spec')
  126. check(!initializedFiles.some((rel) => rel.startsWith('docs/architecture/')), 'init 后不含 docs/architecture')
  127. const initializedSettings = JSON.parse(await fs.readFile(path.join(workdir, '.claude/settings.json'), 'utf8'))
  128. check(
  129. JSON.stringify(initializedSettings.hooks.PreToolUse) === JSON.stringify(userPreToolUse),
  130. 'init 保留用户自有 PreToolUse',
  131. )
  132. check(
  133. Array.isArray(initializedSettings.hooks.SessionStart)
  134. && initializedSettings.hooks.SessionStart.length === 1
  135. && initializedSettings.hooks.SessionStart[0]?.hooks?.[0]?.command?.endsWith('session-context'),
  136. 'alpha 只新增 SessionStart 上下文 hook',
  137. )
  138. // 4. vendored bin 建第一本书(干净环境一条命令装出并建书 = AC1 链路)
  139. const vendoredBin = path.join(workdir, '.webnovel', 'bin', 'webnovel-writer.js')
  140. await fs.writeFile(
  141. path.join(workdir, '建书.json'),
  142. JSON.stringify({
  143. book: {
  144. spec_version: '7.0',
  145. 书名: '雪落长安',
  146. 类型: '玄幻',
  147. 副题材: [],
  148. 流派: [],
  149. 卷规模: 40,
  150. 体检周期: 50,
  151. },
  152. 总纲: '# 总纲\n## 结局\n终成一代大家。',
  153. 卷纲: '# 第1卷\n初入长安。',
  154. 作品契约: [
  155. '---',
  156. '类型: 玄幻',
  157. '副题材:',
  158. '流派:',
  159. '创意约束:',
  160. '来源版本:',
  161. ' - 作者自定义',
  162. '契约版本: 1',
  163. '生效起章: 1',
  164. '更新原因: 初始建书',
  165. '变更类型: 建书',
  166. '---',
  167. '## 核心读者承诺',
  168. '成长与选择都有可见后果。',
  169. '## 骨架约定',
  170. '按卷推进长期目标。',
  171. '## 题材融合协议',
  172. '本书暂不混合副题材。',
  173. '## 创意约束落地',
  174. '主线困境不由临时奖励解决。',
  175. '## 差异化点',
  176. '- 线索改变人物关系。',
  177. '- 成长代价延续到后文。',
  178. '- 反派掌握先手信息。',
  179. '## 冲突与关系结算原则',
  180. '- 主角底线:不牺牲无辜。',
  181. '- 伤害后果:责任必须可见。',
  182. '- 和解条件:先有修复行动。',
  183. '- 救赎条件:持续付出代价。',
  184. '- 允许余地:善良与克制可以成立。',
  185. '## 本书专属毒点',
  186. '- 不用旁白直接揭谜。',
  187. '## 节奏与兑现参数',
  188. '统计对象:主线发生不可逆变化的章节。',
  189. '使用目的:让主线推进保持可观察且不靠固定套路。',
  190. '失效条件:进入终局兑现阶段后改用终局清算标准。',
  191. '本书配额:每三章推进一次主线。',
  192. ].join('\n'),
  193. 知识选择: [{ 维度: '题材', 名称: '玄幻', 来源: '作者自定义' }],
  194. 作者已确认: true,
  195. }),
  196. 'utf8'
  197. )
  198. const pb = await node(vendoredBin, ['persist-book', '--file=建书.json'])
  199. check(pb.stdout.includes('已登记为当前书'), '建书并登记为当前书(vendored bin)')
  200. check(await exists(path.join(workdir, '雪落长安', 'book.yaml')), '书仓库 book.yaml')
  201. check(await exists(path.join(workdir, '雪落长安', 'AGENTS.md')), '书仓库指路 AGENTS.md')
  202. check(await exists(path.join(workdir, '雪落长安', '作品契约', '作品契约.md')), '书仓库作品契约真源')
  203. check(await exists(path.join(workdir, '雪落长安', '作品契约', '知识选择记录.md')), '书仓库知识选择记录')
  204. // 5. next --json → 序6 起草第 1 章
  205. const nx = await node(vendoredBin, ['next', '--json'])
  206. const dto = JSON.parse(nx.stdout)
  207. check(dto.序 === 6 && dto.dto.nextChapter === 1, `next 判定起草第 1 章(实际:序${dto.序})`)
  208. // 6. update 幂等重跑
  209. const up = await node(installedBin, ['update'])
  210. check(/升级完成|安装完成/.test(up.stdout), 'update 幂等可重跑')
  211. const afterUpdateSettings = JSON.parse(await fs.readFile(path.join(workdir, '.claude/settings.json'), 'utf8'))
  212. check(
  213. JSON.stringify(afterUpdateSettings.hooks.PreToolUse) === JSON.stringify(userPreToolUse),
  214. 'update 保留用户自有 PreToolUse',
  215. )
  216. check(
  217. Array.isArray(afterUpdateSettings.hooks.SessionStart)
  218. && afterUpdateSettings.hooks.SessionStart.length === 1,
  219. 'update 不重复安装 SessionStart',
  220. )
  221. if (failed) {
  222. console.error('\n安装链路 e2e 存在失败项')
  223. process.exit(1)
  224. }
  225. console.log('\n安装链路 e2e 全通过:pack → 安装 → 中文路径 init → 建书 → next → update')
  226. } catch (err) {
  227. console.error(`安装链路 e2e 异常:${err.message}`)
  228. if (err.stdout) console.error(err.stdout)
  229. if (err.stderr) console.error(err.stderr)
  230. process.exit(1)
  231. } finally {
  232. for (const d of cleanups) {
  233. try {
  234. await fs.rm(d, { recursive: true, force: true, maxRetries: 3 })
  235. } catch {
  236. // 临时目录清理尽力而为
  237. }
  238. }
  239. }