1
0

install.test.js 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184
  1. import { test } from 'node:test'
  2. import assert from 'node:assert/strict'
  3. import os from 'node:os'
  4. import path from 'node:path'
  5. import { promises as fs } from 'node:fs'
  6. import { fileURLToPath } from 'node:url'
  7. import { execFile } from 'node:child_process'
  8. import { promisify } from 'node:util'
  9. import { installWorkdir } from '../../src/installer/index.js'
  10. import { readManifest } from '../../src/installer/manifest.js'
  11. const exec = promisify(execFile)
  12. const __dirname = path.dirname(fileURLToPath(import.meta.url))
  13. const PKG_ROOT = path.join(__dirname, '../..')
  14. const NO_ENV = { env: { PATH: '' } } // 探测归零,宿主全靠显式指定 → 测试确定性
  15. async function tmpWorkdir() {
  16. const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wnw-init-'))
  17. return { root, ctx: { workdir: root, packageRoot: PKG_ROOT }, cleanup: () => fs.rm(root, { recursive: true, force: true }) }
  18. }
  19. const read = (root, rel) => fs.readFile(path.join(root, rel), 'utf8')
  20. const exists = async (root, rel) => {
  21. try {
  22. await fs.access(path.join(root, rel))
  23. return true
  24. } catch {
  25. return false
  26. }
  27. }
  28. test('init:一条命令装出完整布局(AC2),vendored bin 自包含可跑', async () => {
  29. const { root, ctx, cleanup } = await tmpWorkdir()
  30. try {
  31. const r = await installWorkdir(ctx, { hostsOverride: 'claude-code,codex', ...NO_ENV })
  32. assert.equal(r.ok, true, r.error)
  33. // .webnovel/ 运行时自包含
  34. for (const rel of [
  35. '.webnovel/bin/webnovel-writer.js',
  36. '.webnovel/src/session/index.js',
  37. '.webnovel/roles/事实审查.md',
  38. '.webnovel/roles/编辑审.md',
  39. '.webnovel/node_modules/js-yaml/package.json',
  40. '.webnovel/node_modules/argparse/package.json',
  41. '.webnovel/package.json',
  42. '.webnovel/manifest.json',
  43. '.webnovel/books.jsonl',
  44. ]) {
  45. assert.ok(await exists(root, rel), `缺 ${rel}`)
  46. }
  47. // 平台壳落位
  48. assert.ok(await exists(root, '.claude/skills/webnovel-writer/SKILL.md'))
  49. assert.ok(await exists(root, '.claude/agents/事实审查.md'))
  50. assert.ok(await exists(root, '.codex/agents/事实审查.toml'))
  51. // hook 接线
  52. const settings = JSON.parse(await read(root, '.claude/settings.json'))
  53. assert.ok(JSON.stringify(settings).includes('session-context'))
  54. // AGENTS.md 标记块
  55. const agents = await read(root, 'AGENTS.md')
  56. assert.ok(agents.includes('<!-- WEBNOVEL:START -->') && agents.includes('<!-- WEBNOVEL:END -->'))
  57. // 报告人话
  58. assert.ok(r.report.includes('claude-code') && r.report.includes('下一步'))
  59. // vendored bin 真的能跑(js-yaml 链路含在内):list-books 走 session→storage→yaml
  60. const out = await exec(process.execPath, [path.join(root, '.webnovel/bin/webnovel-writer.js'), 'list-books'], {
  61. cwd: root,
  62. encoding: 'utf8',
  63. })
  64. assert.ok(out.stdout.includes('还没有书'), out.stdout)
  65. } finally {
  66. await cleanup()
  67. }
  68. })
  69. test('init:未检测到宿主 → 只装公约数层 + --hosts 指引(D-2)', async () => {
  70. const { root, ctx, cleanup } = await tmpWorkdir()
  71. try {
  72. const r = await installWorkdir(ctx, NO_ENV)
  73. assert.equal(r.ok, true, r.error)
  74. assert.ok(await exists(root, '.webnovel/bin/webnovel-writer.js'))
  75. assert.ok(await exists(root, 'AGENTS.md'))
  76. assert.ok(!(await exists(root, '.claude')), '未检测到不应装 claude 壳')
  77. assert.ok(r.report.includes('--hosts'), '报告应指引 --hosts 补装')
  78. } finally {
  79. await cleanup()
  80. }
  81. })
  82. test('update 三态:未改→更新;手改→跳过并列出;--force→覆盖;删了→重建(AC3)', async () => {
  83. const { root, ctx, cleanup } = await tmpWorkdir()
  84. try {
  85. await installWorkdir(ctx, { hostsOverride: 'claude-code', ...NO_ENV })
  86. const skillRel = '.claude/skills/webnovel-writer/SKILL.md'
  87. const rolesRel = '.claude/agents/事实审查.md'
  88. // 手改一个生成文件
  89. await fs.writeFile(path.join(root, skillRel), '作者自己改过的 SKILL', 'utf8')
  90. // 删一个生成文件
  91. await fs.rm(path.join(root, rolesRel))
  92. const r = await installWorkdir(ctx, { hostsOverride: 'claude-code', ...NO_ENV })
  93. assert.equal(r.ok, true, r.error)
  94. assert.ok(r.skipped.includes(skillRel), `手改文件应跳过:${r.skipped}`)
  95. assert.equal(await read(root, skillRel), '作者自己改过的 SKILL', '手改内容不得被覆盖')
  96. assert.ok(await exists(root, rolesRel), '删掉的生成文件应重建')
  97. assert.ok(r.report.includes('跳过') && r.report.includes('--force'))
  98. // --force 覆盖手改
  99. const rf = await installWorkdir(ctx, { hostsOverride: 'claude-code', force: true, ...NO_ENV })
  100. assert.equal(rf.ok, true)
  101. assert.notEqual(await read(root, skillRel), '作者自己改过的 SKILL')
  102. } finally {
  103. await cleanup()
  104. }
  105. })
  106. test('update:AGENTS.md 只动标记块,块外用户内容保留;books.jsonl 永不覆盖', async () => {
  107. const { root, ctx, cleanup } = await tmpWorkdir()
  108. try {
  109. await installWorkdir(ctx, { hostsOverride: 'codex', ...NO_ENV })
  110. // 用户在块外加了自己的说明;books.jsonl 有登记
  111. const agents = await read(root, 'AGENTS.md')
  112. await fs.writeFile(path.join(root, 'AGENTS.md'), agents + '\n## 我自己的备注\n别动这里。\n', 'utf8')
  113. await fs.writeFile(path.join(root, '.webnovel/books.jsonl'), '{"书名":"星海","目录":"星海","当前":true}\n', 'utf8')
  114. const r = await installWorkdir(ctx, { hostsOverride: 'codex', ...NO_ENV })
  115. assert.equal(r.ok, true, r.error)
  116. const after = await read(root, 'AGENTS.md')
  117. assert.ok(after.includes('我自己的备注'), '块外用户内容必须保留')
  118. assert.ok(after.includes('<!-- WEBNOVEL:START -->'))
  119. assert.ok((await read(root, '.webnovel/books.jsonl')).includes('星海'), 'books.jsonl 不得覆盖')
  120. } finally {
  121. await cleanup()
  122. }
  123. })
  124. test('init:用户已有无标记块的 AGENTS.md → 块前置,原内容保留', async () => {
  125. const { root, ctx, cleanup } = await tmpWorkdir()
  126. try {
  127. await fs.writeFile(path.join(root, 'AGENTS.md'), '# 我的项目说明\n手写内容。\n', 'utf8')
  128. const r = await installWorkdir(ctx, NO_ENV)
  129. assert.equal(r.ok, true)
  130. const after = await read(root, 'AGENTS.md')
  131. assert.ok(after.startsWith('<!-- WEBNOVEL:START -->'))
  132. assert.ok(after.includes('我的项目说明'))
  133. assert.ok(r.notes.some((n) => n.includes('AGENTS.md')))
  134. } finally {
  135. await cleanup()
  136. }
  137. })
  138. test('护栏:书仓库里 init 拒绝;vendored 副本自更新指引 npx', async () => {
  139. const { root, ctx, cleanup } = await tmpWorkdir()
  140. try {
  141. await fs.writeFile(path.join(root, 'book.yaml'), '书名: 测\n', 'utf8')
  142. const r = await installWorkdir(ctx, NO_ENV)
  143. assert.equal(r.ok, false)
  144. assert.ok(r.error.includes('上一层'))
  145. const vendored = await installWorkdir(
  146. { workdir: root, packageRoot: path.join(root, '.webnovel') },
  147. NO_ENV
  148. )
  149. assert.equal(vendored.ok, false)
  150. assert.ok(vendored.error.includes('npx'))
  151. } finally {
  152. await cleanup()
  153. }
  154. })
  155. test('update:既装宿主无需重新探测也继续跟新(manifest 记忆)', async () => {
  156. const { root, ctx, cleanup } = await tmpWorkdir()
  157. try {
  158. await installWorkdir(ctx, { hostsOverride: 'codex', ...NO_ENV })
  159. // 不给 hostsOverride、探测为空 → 仍应给 codex 跟新(从 manifest 推断既装)
  160. const r = await installWorkdir(ctx, NO_ENV)
  161. assert.equal(r.ok, true)
  162. assert.deepEqual(r.hosts, ['codex'])
  163. const m = await readManifest(root)
  164. assert.ok(Object.keys(m.files).some((k) => k.startsWith('.codex')))
  165. } finally {
  166. await cleanup()
  167. }
  168. })