import { test } from 'node:test' import assert from 'node:assert/strict' import os from 'node:os' import path from 'node:path' import { promises as fs } from 'node:fs' import { fileURLToPath } from 'node:url' import { execFile } from 'node:child_process' import { promisify } from 'node:util' const exec = promisify(execFile) const __dirname = path.dirname(fileURLToPath(import.meta.url)) const BIN = path.join(__dirname, '../../bin/webnovel-writer.js') /** * M5 出口判据 D2(AC5):写章八阶段每一步走宿主可调的 CLI 通道,全程子进程 spawn bin, * 零进程内调用。建书 → next → 细纲 → 草稿 → 备料 → 机检 → 审稿输入 → 两审入库 → 定稿 → next 不重抄。 * 路径含中文(工作目录名/书名),兼做中文路径链路探针。 */ test('主循环全程 CLI:建书→写1章→两审→定稿→next 报第2章', async () => { const workdir = await fs.mkdtemp(path.join(os.tmpdir(), 'wnw-cli-工作目录-')) const run = (args, opts = {}) => exec(process.execPath, [BIN, ...args], { cwd: workdir, encoding: 'utf8', ...opts }) const runFail = async (args) => { try { await run(args) return null } catch (err) { return err } } try { // 0. 模拟已安装的工作目录(安装器落位前的最小标记;init 集成随第 3 步任务补 CI 全链路) await fs.mkdir(path.join(workdir, '.webnovel'), { recursive: true }) // 1. 空工作目录 next --json → 序1 建书引导(经真 bin,不是进程内) const r1 = await run(['next', '--json']) const s1 = JSON.parse(r1.stdout) assert.equal(s1.序, 1) assert.equal(s1.state, 'create-book') // 2. 建书(persist-book --file)→ 登记为当前书 await fs.writeFile( path.join(workdir, '建书.json'), JSON.stringify({ book: { spec_version: '7.0', 书名: '青云试剑', 卷规模: 40, 体检周期: 50 }, 总纲: '# 总纲\n## 结局\n林晚登顶。', 卷纲: '# 第1卷\n入门与试炼。', }), 'utf8' ) const r2 = await run(['persist-book', '--file=建书.json']) assert.ok(r2.stdout.includes('已登记为当前书'), r2.stdout) const repo = path.join(workdir, '青云试剑') await fs.access(path.join(repo, 'book.yaml')) await fs.access(path.join(repo, 'AGENTS.md')) // 3. list-books 看到当前书 const r3 = await run(['list-books']) assert.ok(r3.stdout.includes('青云试剑')) // 4. next --json → 序6 起草第 1 章(建书产物已随 init commit,不误触序2 手改) const r4 = await run(['next', '--json']) const s4 = JSON.parse(r4.stdout) assert.equal(s4.序, 6, `建书后应序6,实际:${r4.stdout}`) assert.equal(s4.dto.nextChapter, 1) // 5. 细纲回流(persist-outline --file) await fs.writeFile( path.join(workdir, '细纲.json'), JSON.stringify({ 细纲: '## 本章要写到的事\n林晚初入青云宗,得玉佩。\n' }), 'utf8' ) await run(['persist-outline', `--file=${path.join(workdir, '细纲.json')}`]) await fs.access(path.join(repo, '工作区', '细纲.md')) // 6. 备料 + 草稿 + 机检(草稿是 AI 产物,落工作区不经 CLI) const r6 = await run(['prepare-chapter', '1']) assert.ok(r6.stdout.includes('本章写作材料'), r6.stdout) await fs.writeFile( path.join(repo, '工作区', '草稿-A.md'), '---\n章号: 1\n标题: 初入青云\n---\n林晚背着行囊踏入青云宗山门,腰间玉佩微微发烫。', 'utf8' ) const r6b = await run(['mechanical-check', '1']) const mc = JSON.parse(r6b.stdout) assert.ok('pass' in mc && Array.isArray(mc.issues)) // 7. 审稿输入 → 两审(桩产物)入库 const r7 = await run(['review-input', '1']) assert.ok(r7.stdout.includes('审稿输入.json')) const input = JSON.parse(await fs.readFile(path.join(repo, '工作区', '审稿输入.json'), 'utf8')) assert.equal(input.章号, 1) await fs.writeFile( path.join(workdir, '两审.json'), JSON.stringify({ 事实审查: { chapter: 1, issues: [] }, 编辑审: { chapter: 1, issues: [] }, 章摘要: '林晚入宗,玉佩异动。', }), 'utf8' ) const r7b = await run(['save-review', '1', `--file=${path.join(workdir, '两审.json')}`]) assert.ok(r7b.stdout.includes('0 个阻断'), r7b.stdout) await fs.access(path.join(repo, '工作区', '审稿.md')) // 8. 定稿(finalize --payload)→ 正文入档、工作区清理 await fs.writeFile( path.join(workdir, '定稿包.json'), JSON.stringify({ frontMatter: { 章号: 1, 标题: '初入青云', 卷: 1, 字数: 30, 章定位: '铺垫', 钩子: '悬念钩-中', 情绪定位: '铺垫' }, body: '林晚背着行囊踏入青云宗山门,腰间玉佩微微发烫。', summary: '林晚入宗,玉佩异动。', commitLines: {}, workspaceFiles: ['工作区/草稿-A.md', '工作区/细纲.md', '工作区/审稿输入.json', '工作区/审稿.md', '工作区/本章写作材料.md'], }), 'utf8' ) const r8 = await run(['finalize', '1', `--payload=${path.join(workdir, '定稿包.json')}`]) assert.ok(r8.stdout.includes('已定稿'), r8.stdout) await fs.access(path.join(repo, '定稿', '正文', '0001-初入青云.md')) await fs.access(path.join(repo, '定稿', '摘要', '章摘要', '0001.md')) await assert.rejects(fs.access(path.join(repo, '工作区', '草稿-A.md')), '定稿后草稿应清理') // 9. next --json → 序6 起草第 2 章(定稿刷新缓存,不重抄第 1 章) const r9 = await run(['next', '--json']) const s9 = JSON.parse(r9.stdout) assert.equal(s9.序, 6, `定稿后应序6,实际:${r9.stdout}`) assert.equal(s9.dto.nextChapter, 2, '定稿后 next 应起草第 2 章(不重抄)') // 10. 换书人话报错路径:switch-book 不存在的书 → 退出码非零 + 候选 const err = await runFail(['switch-book', '不存在的书']) assert.ok(err, '应失败退出') assert.ok(String(err.stderr).includes('青云试剑'), `候选应含现有书:${err.stderr}`) } finally { await fs.rm(workdir, { recursive: true, force: true }) } })