// 端到端测试:配合 scripts/mock-llm.mjs 跑通完整写作流程 // 用法:node scripts/e2e.mjs [角色卡.png] import { readFileSync } from 'node:fs' import assert from 'node:assert/strict' const [app = 'http://localhost:3000', mock = 'http://localhost:4010/v1', cardPath] = process.argv.slice(2) async function api(path, method = 'GET', body) { const res = await fetch(app + path, { method, headers: body instanceof FormData || body === undefined ? undefined : { 'Content-Type': 'application/json' }, body: body instanceof FormData ? body : body === undefined ? undefined : JSON.stringify(body) }) const text = await res.text() if (!res.ok) throw new Error(`${method} ${path} → ${res.status} ${text}`) try { return JSON.parse(text) } catch { return text } } const sleep = ms => new Promise(r => setTimeout(r, ms)) async function waitIdle(pid, timeout = 30000) { const start = Date.now() await sleep(150) while (Date.now() - start < timeout) { const p = await api(`/api/projects/${pid}`) if (!p.running) return p await sleep(200) } throw new Error('等待任务完成超时') } const step = msg => console.log(`✔ ${msg}`) // 1. API 连接与模型 const mockRoot = mock.replace(/\/v1$/, '') const pulled = await api('/api/providers/remote-models', 'POST', { type: 'openai-compatible', baseUrl: mockRoot, apiKey: 'test-key' }) assert.deepEqual(pulled.models, ['mock-editor', 'mock-writer-trunc']) assert.equal(pulled.baseUrl, mockRoot + '/v1', '漏写 /v1 时应自动补全') const provider = await api('/api/providers', 'POST', { type: 'openai-compatible', baseUrl: pulled.baseUrl, apiKey: 'test-key', models: [...pulled.models, 'extra-model'] }) const providers = await api('/api/providers') const savedProvider = providers.find(p => p.id === provider.id) assert.equal(savedProvider.name, new URL(mock).host, '未填名称时使用域名') assert.ok(!JSON.stringify(providers).includes('test-key'), '接口不应返回明文 Key') const pulledAgain = await api('/api/providers/remote-models', 'POST', { type: 'openai-compatible', baseUrl: pulled.baseUrl, providerId: provider.id }) assert.equal(pulledAgain.models.length, 2, '编辑时不填 Key 应使用已保存的 Key') const synced = await api(`/api/providers/${provider.id}`, 'PUT', { models: pulled.models }) assert.equal(synced.removed, 1) let allModels = (await api('/api/models')).filter(m => m.providerId === provider.id) assert.deepEqual(allModels.map(m => m.modelId).sort(), ['mock-editor', 'mock-writer-trunc']) const editorModel = allModels.find(m => m.modelId === 'mock-editor') const writerModel = allModels.find(m => m.modelId === 'mock-writer-trunc') await api(`/api/models/${writerModel.id}`, 'PUT', { label: '模拟作家', maxOutputTokens: 4096 }) const test = await api(`/api/models/${editorModel.id}/test`, 'POST', {}) assert.equal(test.ok, true, JSON.stringify(test)) step('API 连接:拉取模型(自动补全 /v1)、批量保存、同步、连接测试') // 2. 项目与设定 const { id: pid } = await api('/api/projects', 'POST', { title: 'E2E 测试作品' }) await api(`/api/projects/${pid}`, 'PUT', { worldSetting: '2011 年的昆明,雨季。', storyOutline: '主角回到昆明,与旧识简琳琅重逢,逐步揭开百鸟县的往事。', userName: '陈默' }) const agents = await api(`/api/projects/${pid}/agents`) assert.equal(agents.length, 3) const editor = agents.find(a => a.role === 'editor') const writer = agents.find(a => a.role === 'writer') await api(`/api/agents/${editor.id}`, 'PUT', { modelId: editorModel.id, systemPrompt: '你是一位苛刻的主编。' }) await api(`/api/agents/${writer.id}`, 'PUT', { modelId: writerModel.id, styleNotes: '氛围描写' }) await api(`/api/agents/${agents.find(a => a.role === 'summarizer').id}`, 'PUT', { modelId: editorModel.id }) const writer2 = await api(`/api/projects/${pid}/agents`, 'POST', { role: 'writer', name: '作家二号', modelId: writerModel.id }) step('项目/AI 团队配置') // 3. 角色卡导入 if (cardPath) { const buf = readFileSync(cardPath) const fd = new FormData() fd.append('file', new Blob([buf], { type: 'image/png' }), 'card.png') const preview = await api(`/api/projects/${pid}/cards/preview`, 'POST', fd) assert.ok(preview.name) assert.ok(preview.lore.length > 0) const fd2 = new FormData() fd2.append('file', new Blob([buf], { type: 'image/png' }), 'card.png') fd2.append('options', JSON.stringify({ identity: '女主角' })) const imported = await api(`/api/projects/${pid}/cards/import`, 'POST', fd2) const chars = await api(`/api/projects/${pid}/characters`) assert.equal(chars.length, 1) assert.equal(chars[0].name, preview.name) assert.ok(chars[0].avatar) const lore = await api(`/api/projects/${pid}/lore`) assert.equal(lore.length, imported.loreCount) const avatar = await fetch(`${app}/api/avatars/${pid}/${chars[0].avatar}`) assert.equal(avatar.status, 200) step(`角色卡导入:${preview.name}(${preview.spec},世界书 ${lore.length} 条)`) } // 4. SSE 监听 const events = [] const ac = new AbortController() ;(async () => { try { const res = await fetch(`${app}/api/projects/${pid}/events`, { signal: ac.signal }) const reader = res.body.getReader() const dec = new TextDecoder() let buf = '' for (;;) { const { value, done } = await reader.read() if (done) break buf += dec.decode(value, { stream: true }) let i while ((i = buf.indexOf('\n\n')) >= 0) { const block = buf.slice(0, i) buf = buf.slice(i + 2) const data = block.split('\n').filter(l => l.startsWith('data:')).map(l => l.slice(5).trim()).join('') if (data && !block.includes('event: ping')) events.push(JSON.parse(data)) } } } catch {} })() await sleep(300) // 5. 继续下一章(逐章模式):规划 → 简报 → 写作 → 暂停 await api(`/api/projects/${pid}/run`, 'POST', {}) await waitIdle(pid) let chapters = await api(`/api/projects/${pid}/chapters`) assert.equal(chapters.length, 3, '主编应规划 3 章') assert.equal(chapters[0].status, 'written') assert.equal(chapters[1].status, 'planned') let ch1 = await api(`/api/chapters/${chapters[0].id}`) assert.ok(ch1.brief.includes('情节要点')) assert.equal(ch1.title, '雨落昆明') assert.equal(ch1.writerAgentId, writer.id) assert.ok(ch1.content.includes('百鸟县'), '自动续写后应包含续写内容') assert.equal(ch1.versions.length, 1) const deltas = events.filter(e => e.type === 'delta' && e.chapterId === ch1.id).map(e => e.text).join('') assert.equal(deltas, ch1.content, 'SSE 推送的增量应与文件内容一致') step(`逐章模式写完第1章(${ch1.versions[0].wordCount} 字,SSE 增量 ${events.filter(e => e.type === 'delta').length} 条)`) // 6. 待确认时不能继续 const blocked = await api(`/api/projects/${pid}/run`, 'POST', {}) await waitIdle(pid) chapters = await api(`/api/projects/${pid}/chapters`) assert.equal(chapters[1].status, 'planned', '第1章未确认时不应开始第2章') assert.ok(blocked.started) // 7. 确认并继续 await api(`/api/chapters/${ch1.id}/approve`, 'POST', { continue: true }) await waitIdle(pid) ch1 = await api(`/api/chapters/${ch1.id}`) assert.equal(ch1.status, 'approved') assert.ok(ch1.summary.includes('百鸟县')) let project = await api(`/api/projects/${pid}`) assert.equal(project.memory.characterStates.length, 1) assert.equal(project.memory.foreshadowing.length, 1) assert.equal(project.memory.timeline.length, 1) chapters = await api(`/api/projects/${pid}/chapters`) assert.equal(chapters[1].status, 'written') step('确认第1章 → 摘要与故事记忆已更新 → 第2章写完') // 8. 手动锁定作家 await api(`/api/chapters/${chapters[2].id}`, 'PUT', { writerAgentId: writer2.id, writerLocked: true }) // 9. 审稿与修改 const ch2Id = chapters[1].id await api(`/api/chapters/${ch2Id}/review`, 'POST', {}) await waitIdle(pid) let ch2 = await api(`/api/chapters/${ch2Id}`) assert.ok(ch2.reviewNotes.includes('总评分')) await api(`/api/chapters/${ch2Id}/write`, 'POST', { feedback: ch2.reviewNotes, revise: true }) await waitIdle(pid) ch2 = await api(`/api/chapters/${ch2Id}`) assert.equal(ch2.versions.length, 2) assert.equal(ch2.activeVersionId, ch2.versions[0].id) const writeJob = (await api(`/api/projects/${pid}/jobs`)).find(j => j.type === 'write' && j.chapterId === ch2Id) const writeJobDetail = await api(`/api/jobs/${writeJob.id}`) assert.ok(writeJobDetail.prompt.prompt.includes('revision_feedback')) assert.ok(writeJobDetail.prompt.prompt.includes('previous_draft')) step('主编审稿 → 按意见修改生成 v2') // 10. 自动模式 await api(`/api/projects/${pid}`, 'PUT', { settings: { ...project.settings, autoMode: true, autoChapterLimit: 1 } }) await api(`/api/projects/${pid}/run`, 'POST', {}) await waitIdle(pid) chapters = await api(`/api/projects/${pid}/chapters`) assert.equal(chapters[1].status, 'approved', '自动模式应自动确认第2章') assert.equal(chapters[2].status, 'written') const ch3 = await api(`/api/chapters/${chapters[2].id}`) assert.equal(ch3.writerAgentId, writer2.id, '锁定的作家不应被主编更换') const briefJob = (await api(`/api/projects/${pid}/jobs`)).find(j => j.type === 'brief' && j.chapterId === ch3.id) const briefDetail = await api(`/api/jobs/${briefJob.id}`) assert.ok(briefDetail.prompt.system.startsWith(''), '用户自定义提示词应位于最前') assert.ok(briefDetail.prompt.prompt.includes('简琳琅') || !cardPath) step('自动模式:自动确认第2章并写完第3章(遵守章节上限与作家锁定)') // 11. 停止 await api(`/api/chapters/${chapters[2].id}/write`, 'POST', {}) await sleep(100) await api(`/api/projects/${pid}/stop`, 'POST', {}) await waitIdle(pid) const ch3b = await api(`/api/chapters/${chapters[2].id}`) assert.equal(ch3b.status, 'written', '取消后应恢复为之前的状态') assert.equal(ch3b.versions[0].status, 'cancelled') assert.equal(ch3b.activeVersionId, ch3.activeVersionId, '取消的版本不应成为当前版本') step('写作中途停止 → 状态回滚,旧版本保留') // 12. 导出 const txt = await api(`/api/projects/${pid}/export?format=txt&scope=approved`) assert.ok(txt.includes('第1章 雨落昆明')) assert.ok(!txt.includes('第3章')) step('导出 TXT') const jobs = await api(`/api/projects/${pid}/jobs`) const errors = jobs.filter(j => j.status === 'error') assert.equal(errors.length, 0, JSON.stringify(errors, null, 2)) step(`共 ${jobs.length} 个任务,无错误;token 统计:${jobs.reduce((s, j) => s + j.inputTokens + j.outputTokens, 0)}`) ac.abort() await api(`/api/projects/${pid}`, 'DELETE') console.log('\n全部通过 ✅') process.exit(0)