test(studio): 补 AIPanel AI 生成真后端 e2e + 修 connectAIStream 漏鉴权头(反假绿暴露真 bug)
补 ai-generation.spec(真后端不 stub,消除 accept-suggestion stub 假绿,进度总账 line176②):真浏览器 WorkspacePage→AIPanel 发送指令→真后端 AI task→SSE→真 New-API LLM→suggestion 落库。**反假绿暴露真 bug**:connectAIStream(src/lib/sse.ts)漏带 Authorization+tenant-id 头→活体 SSE 必 401/400→done 永收不到→候选永不渲染(被 accept-suggestion 的 stub 长期掩盖)。修:补头,与 connectEventStream/client.ts 同源 @/api/auth。SSE 真行为:后端"持久化事件回放+5s 轮询",运行时只落 done 不落 chunk(streamContent 真链路恒空),真 LLM 文本在 suggestion 记录→断言落 suggestion(API+DB 双证)。四断言反假绿:POST 2xx+code0+taskId / suggestion 真 LLM 文本(非空+阈值+主题宽集合不硬编码句子) / CandidatePanel 采纳按钮 / DB muse_ai_suggestion 新行。自验+子代理连跑 4 次绿(suggestionId 12→13→14→15 各不同真 LLM,11-21s 稳定);vitest 102/102(sse.test 16/16);"AI 本机不可用"旧判断已过时(New-API 在线真生成)。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e8ca37c9b9
commit
494c9e9b89
190
muse-studio/e2e/ai-generation.spec.ts
Normal file
190
muse-studio/e2e/ai-generation.spec.ts
Normal file
@ -0,0 +1,190 @@
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
import { Client } from 'pg';
|
||||
|
||||
/**
|
||||
* AIPanel AI 生成 UI e2e(真实后端 + 真 New-API LLM,绝不 stub,反假绿)。
|
||||
*
|
||||
* 目的:消除 accept-suggestion.spec 用 page.route 桩掉 /ai/tasks + /stream 的假绿
|
||||
* (进度总账 line176② “AI 生成链 e2e 一律 stub /ai/tasks”)。本 spec 全程不 page.route、不 stub,
|
||||
* MSW 关(playwright webServer 已配 VITE_API_MOCK=false),前端经真实浏览器直连 muse-server(48080)
|
||||
* 与真 New-API,验证 WorkspacePage→AIPanel→真后端 AI task→SSE→真 LLM 文本→suggestion 落库的完整命脉。
|
||||
*
|
||||
* ── 实测 SSE 行为(关键,决定断言取向)──────────────────────────────────────────
|
||||
* 后端 MuseAiTaskStreamServiceImpl 是「持久化事件回放 + 5s 轮询」模型,只回放 muse_ai_task_event 里
|
||||
* 已落库的事件。实测(curl -N 连 /ai/tasks/{id}/stream + DB 直查 event 直方图):本运行时全链路
|
||||
* 只持久化 done/error 事件,从不持久化 chunk(histogram: done/error 有,chunk=0)。即后端不做实时逐字
|
||||
* chunk 推送——dispatcher 同步生成完整文本后只写一条终态 done。故前端 AIPanel 的 streamContent(仅由
|
||||
* onChunk 累计)在真链路里恒为空,done 事件携带 suggestionId 触发 onCandidateGenerated('', suggestionId)。
|
||||
*
|
||||
* 因此「真 LLM 文本」不在 streamContent(它在真链路里为空),而在生成的 suggestion 记录里
|
||||
* (content_snapshot.content,GET /suggestions/{id} 透出为 data.content)。本 spec 据此把
|
||||
* 「验真 LLM 文本」落在 suggestion 上(API 读 + DB 直查双证),而非 streamContent;同时验 UI 经 done
|
||||
* 事件渲染候选(CandidatePanel「采纳替换」按钮),证 SSE done→onCandidateGenerated 真贯通浏览器。
|
||||
*
|
||||
* ── 反假绿断言(四条)────────────────────────────────────────────────────────
|
||||
* ① 发送指令后真打 POST /app-api/muse/ai/tasks,业务码 code=0 且回传 taskId(证真接口、非 mock 截获)。
|
||||
* ② 生成的 suggestion 含真 LLM 文本:GET /suggestions/{id} 的 data.content 非空、长度 > 阈值、
|
||||
* 含主题相关字样(“星环/魔法/禁咒/元素”任一);LLM 输出不定,故只验非空+合理长度+主题命中,不硬编码句子。
|
||||
* ③ done 后 WorkspacePage 渲染候选:CandidatePanel「采纳替换」按钮可见(onCandidateGenerated 接线生效)。
|
||||
* ④ DB 核验落库(反假绿核心):muse_ai_suggestion 出现本轮新行(id 大于运行前最大 id)、work_id=1、
|
||||
* block_id=1、content_snapshot.content 非空,证 UI 触发的生成真落库非 mock 假绿。
|
||||
*
|
||||
* ── 前置 ──────────────────────────────────────────────────────────────────────
|
||||
* - 真实 muse-server 在 48080(dispatcher initial-delay 8s)+ New-API 在线可真生成;
|
||||
* - work1(id=1)/chapter1(id=1)/block1(id=1) 已存在(blockCount=1),writing.continuation 槽位 active
|
||||
* (global-setup #15 每轮复位为 agent1/revision=1/active);
|
||||
* - DB 凭据从 ~/.config/muse-repo/infra.env 环境变量读取(MUSE_POSTGRES_*)。
|
||||
*
|
||||
* ── 慢 + 异步 ────────────────────────────────────────────────────────────────
|
||||
* 依赖 New-API LLM(~7s) + dispatcher(initial-delay 8s) + 5s 轮询粒度,实测端到端约 20s;New-API 偶发
|
||||
* cold/慢会更久,故 test timeout 放宽到 150s、等候选/落库的 expect timeout 放宽到 120s。绝不因 flaky 改 stub
|
||||
* 绕过(stub 即失去本 e2e 反假绿意义)。
|
||||
*/
|
||||
|
||||
const TOKEN = 'test1';
|
||||
const WORK_ID = 1;
|
||||
const BLOCK_ID = 1;
|
||||
// 与 task7/8 同主题,确保 P-A 检索命中 KB + LLM grounding(global-setup 已为 work1 种世界观/魔法体系 schema)。
|
||||
const USER_INSTRUCTION = '继续描写星环大陆的魔法体系与元素对抗禁咒';
|
||||
// 真 LLM 文本最小长度阈值:续写至少应有可观字数;取保守值避免 LLM 漂移误红,又足以排除空串/占位。
|
||||
const MIN_LLM_CONTENT_LENGTH = 12;
|
||||
// 主题相关字样(任一命中即可):验生成确为本指令的 grounding 续写,而非无关占位文本。LLM 输出不定,故用宽集合。
|
||||
const THEME_KEYWORDS = ['星环', '魔法', '禁咒', '元素'];
|
||||
|
||||
/** 整套用例放宽 timeout:AI 生成有外部 LLM + dispatcher 延迟 + 轮询粒度,远超默认 30s。 */
|
||||
test.setTimeout(150_000);
|
||||
|
||||
/** 创建直连 muse_slice_live 的 pg 客户端(凭据从 infra.env 环境变量,绝不入库)。 */
|
||||
function createPgClient(): Client {
|
||||
return new Client({
|
||||
host: process.env.MUSE_POSTGRES_HOST,
|
||||
port: Number(process.env.MUSE_POSTGRES_PORT ?? '5433'),
|
||||
database: process.env.MUSE_POSTGRES_DATABASE ?? 'muse_slice_live',
|
||||
user: process.env.MUSE_POSTGRES_USERNAME ?? 'root',
|
||||
password: process.env.MUSE_POSTGRES_PASSWORD,
|
||||
ssl: false,
|
||||
connectionTimeoutMillis: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** 读运行前 work1/block1 的 suggestion 最大 id,作为「本轮新生成」的基准(workers=1 串行,无并发污染)。 */
|
||||
async function readMaxSuggestionId(): Promise<number> {
|
||||
const c = createPgClient();
|
||||
await c.connect();
|
||||
try {
|
||||
const r = await c.query(
|
||||
`SELECT COALESCE(MAX(id), 0) AS max_id FROM muse_ai_suggestion
|
||||
WHERE tenant_id=1 AND work_id=$1 AND block_id=$2 AND deleted=false`,
|
||||
[WORK_ID, BLOCK_ID]
|
||||
);
|
||||
return Number(r.rows[0].max_id);
|
||||
} finally {
|
||||
await c.end();
|
||||
}
|
||||
}
|
||||
|
||||
/** 读运行后 work1/block1 中 id 大于基准的最新 suggestion 行(DB 核验本轮真落库)。 */
|
||||
async function readLatestSuggestionAbove(
|
||||
baselineId: number
|
||||
): Promise<{ id: number; status: string; sourceStatus: string; content: string } | null> {
|
||||
const c = createPgClient();
|
||||
await c.connect();
|
||||
try {
|
||||
const r = await c.query(
|
||||
`SELECT id, status, source_status, content_snapshot->>'content' AS content
|
||||
FROM muse_ai_suggestion
|
||||
WHERE tenant_id=1 AND work_id=$1 AND block_id=$2 AND deleted=false AND id > $3
|
||||
ORDER BY id DESC LIMIT 1`,
|
||||
[WORK_ID, BLOCK_ID, baselineId]
|
||||
);
|
||||
if (r.rowCount === 0) return null;
|
||||
const row = r.rows[0];
|
||||
return {
|
||||
id: Number(row.id),
|
||||
status: String(row.status),
|
||||
sourceStatus: String(row.source_status),
|
||||
content: row.content == null ? '' : String(row.content),
|
||||
};
|
||||
} finally {
|
||||
await c.end();
|
||||
}
|
||||
}
|
||||
|
||||
/** 首屏脚本执行前注入登录令牌 + 租户(client.ts 从 localStorage 取 Bearer/tenant 注入;SSE 同源走代理)。 */
|
||||
async function seedToken(page: Page): Promise<void> {
|
||||
await page.addInitScript((t) => {
|
||||
window.localStorage.setItem('accessToken', t);
|
||||
window.localStorage.setItem('tenantId', '1');
|
||||
}, TOKEN);
|
||||
}
|
||||
|
||||
test('用户经真实浏览器在 AIPanel 发起真后端 AI 生成,得到真 LLM 文本候选并落库(不 stub)', async ({ page }) => {
|
||||
// 0. 记录运行前 suggestion 基准 id:本轮 UI 生成应产出 id 更大的新行(DB 核验锚点)。
|
||||
const baselineSuggestionId = await readMaxSuggestionId();
|
||||
|
||||
await seedToken(page);
|
||||
await page.goto(`/workspace/${WORK_ID}`);
|
||||
|
||||
// 1. 等工作台就绪:标题渲染真实书名(work1 = 活体验证样书),且 AIPanel 发送按钮出现
|
||||
// (WorkspacePage 自动激活 chapter1 + blocks[0]=block1,AIPanel 拿到 blockId=1)。
|
||||
await expect(page.getByRole('button', { name: /发送指令/ })).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// 2. 填真实指令并捕获真 POST /ai/tasks(反假绿断言①:真打真接口)。
|
||||
const promptBox = page.getByPlaceholder(/在此处输入创作描述/);
|
||||
await promptBox.fill(USER_INSTRUCTION);
|
||||
|
||||
const taskResponsePromise = page.waitForResponse(
|
||||
(resp) => /\/app-api\/muse\/ai\/tasks$/.test(resp.url()) && resp.request().method() === 'POST',
|
||||
{ timeout: 30_000 }
|
||||
);
|
||||
await page.getByRole('button', { name: /发送指令/ }).click();
|
||||
|
||||
const taskResponse = await taskResponsePromise;
|
||||
// 异步受理:真后端对 POST /ai/tasks 返回 202 Accepted(任务入队、稍后经 SSE done 推 suggestion),
|
||||
// 故只断言 2xx,业务真伪以下面的 code=0 为准(不强求 200,避免把正确的 202 误判为红)。
|
||||
expect(taskResponse.status(), 'POST /ai/tasks HTTP 状态应为 2xx').toBeGreaterThanOrEqual(200);
|
||||
expect(taskResponse.status(), 'POST /ai/tasks HTTP 状态应为 2xx').toBeLessThan(300);
|
||||
const taskBody = await taskResponse.json();
|
||||
// ① 业务码 code=0 + 回传 taskId,证真后端真受理(非 mock 截获的假绿)。
|
||||
expect(taskBody.code, `创建 AI 任务响应: ${JSON.stringify(taskBody)}`).toBe(0);
|
||||
const taskId = taskBody?.data?.taskId;
|
||||
expect(taskId, 'AI 任务应回传 taskId').toBeTruthy();
|
||||
|
||||
// 3. ③ 等真 SSE done 事件经 connectAIStream 触发 onCandidateGenerated → WorkspacePage 渲染候选面板,
|
||||
// CandidatePanel「采纳替换」按钮可见。这一步真贯通:POST→dispatcher(8s)→真 LLM(~7s)→done→候选 UI。
|
||||
// 放宽到 120s:覆盖 New-API cold/慢 + dispatcher 延迟 + 5s 轮询粒度。
|
||||
const acceptButton = page.getByRole('button', { name: /采纳替换/ });
|
||||
await expect(acceptButton).toBeVisible({ timeout: 120_000 });
|
||||
|
||||
// 4. ④ DB 核验:本轮 UI 生成应在 muse_ai_suggestion 落新行(id > 基准),work1/block1、内容非空。
|
||||
// 这是反假绿核心——证 UI 触发的生成真落库,非 mock 截获。
|
||||
const dbRow = await readLatestSuggestionAbove(baselineSuggestionId);
|
||||
expect(dbRow, `应在 DB 找到本轮新生成的 suggestion(id > ${baselineSuggestionId})`).not.toBeNull();
|
||||
expect(dbRow!.content.length, 'DB suggestion 内容应非空(真 LLM 生成)').toBeGreaterThan(MIN_LLM_CONTENT_LENGTH);
|
||||
|
||||
// 5. ② 经真后端读路径 GET /suggestions/{id} 复验真 LLM 文本(与 DB 双证,且过一遍前端实际读契约)。
|
||||
// 用 page.request 直打(绕 client.ts,需手动带 Bearer + tenant-id)。
|
||||
const suggestionResp = await page.request.get(`/app-api/muse/suggestions/${dbRow!.id}`, {
|
||||
headers: {
|
||||
'X-API-Version': '1',
|
||||
Authorization: `Bearer ${TOKEN}`,
|
||||
'tenant-id': '1',
|
||||
},
|
||||
});
|
||||
expect(suggestionResp.status()).toBe(200);
|
||||
const suggestionBody = await suggestionResp.json();
|
||||
expect(suggestionBody.code, `GET suggestion 响应: ${JSON.stringify(suggestionBody).slice(0, 200)}`).toBe(0);
|
||||
const content: string = suggestionBody?.data?.content ?? '';
|
||||
|
||||
// 非空 + 合理长度(排除空串/占位)。
|
||||
expect(content.length, '真 LLM 续写文本应有合理长度').toBeGreaterThan(MIN_LLM_CONTENT_LENGTH);
|
||||
// 主题命中(任一关键字),证为本指令的 grounding 续写而非无关文本;不硬编码具体句子(LLM 漂移即红)。
|
||||
const hitKeyword = THEME_KEYWORDS.some((kw) => content.includes(kw));
|
||||
expect(hitKeyword, `真 LLM 文本应含主题相关字样(${THEME_KEYWORDS.join('/')}),实际开头: ${content.slice(0, 60)}`).toBe(
|
||||
true
|
||||
);
|
||||
|
||||
// 留痕:截断打印真 LLM 文本样例,证非空非 stub(运行日志可见)。
|
||||
console.log(`[ai-generation] suggestionId=${dbRow!.id} status=${dbRow!.status}/${dbRow!.sourceStatus}`);
|
||||
console.log(`[ai-generation] 真 LLM 文本样例(截断120): ${content.slice(0, 120)}`);
|
||||
});
|
||||
@ -1,4 +1,4 @@
|
||||
import { readAccessToken } from '@/api/auth';
|
||||
import { readAccessToken, readTenantId } from '@/api/auth';
|
||||
|
||||
/**
|
||||
* AI 生成通道的回调事件声明
|
||||
@ -248,11 +248,22 @@ export function connectAIStream(
|
||||
onLastEventId: () => {},
|
||||
});
|
||||
|
||||
// 注入登录态 + 租户:AI 流端点同样经 Sa-Token 鉴权(无 token 返回 401「账号未登录」),且需 tenant-id 头解析
|
||||
// 租户上下文(缺失返回 400「请求的租户标识未传递」)。此前 connectAIStream 只带 Accept/版本号、漏了二者,
|
||||
// 在 MSW mock 下不校验故未暴露;对接活体后端时 stream 必然 401/400、done 事件永远收不到 → 候选无法渲染。
|
||||
// 与全局事件流(buildEventStreamHeaders)、HTTP 客户端(client.ts)取同一 token/tenant 单一来源(@/api/auth),避免漂移。
|
||||
const streamHeaders: Record<string, string> = {
|
||||
'Accept': 'text/event-stream',
|
||||
'X-API-Version': '1',
|
||||
'tenant-id': readTenantId(),
|
||||
};
|
||||
const accessToken = readAccessToken();
|
||||
if (accessToken) {
|
||||
streamHeaders['Authorization'] = `Bearer ${accessToken}`;
|
||||
}
|
||||
|
||||
fetch(normalizeStreamUrl(url), {
|
||||
headers: {
|
||||
'Accept': 'text/event-stream',
|
||||
'X-API-Version': '1',
|
||||
},
|
||||
headers: streamHeaders,
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then(async (response) => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user