Files
ai-novel-studio/scripts/e2e.mjs
T
jocay 283f40892a 优化 API 配置:一个弹窗完成接口、Key 与模型选择
- 去掉预设供应商,直接选择接口类型并填写 Base URL、API Key
- 可在保存前从接口拉取模型,搜索并勾选多个;也可手动输入模型名
- 适配中转站:漏写 /v1(Gemini 为 /v1beta)时自动补全,同时发送 Bearer 与官方鉴权头
- 保存时服务端同步连接下的模型列表;编辑时不填 Key 也可拉取
- 单模型测试与参数设置;手机端布局优化

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 02:23:09 +08:00

221 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 端到端测试:配合 scripts/mock-llm.mjs 跑通完整写作流程
// 用法:node scripts/e2e.mjs <appBaseUrl> <mockBaseUrl> [角色卡.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('<highest_priority_instructions>'), '用户自定义提示词应位于最前')
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)