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:
lili 2026-06-24 17:59:10 -07:00
parent e8ca37c9b9
commit 494c9e9b89
2 changed files with 206 additions and 5 deletions

View 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 WorkspacePageAIPanel AI taskSSE LLM suggestion
*
* SSE
* MuseAiTaskStreamServiceImpl + 5s muse_ai_task_event
* curl -N /ai/tasks/{id}/stream + DB event
* done/error chunkhistogram: done/error chunk=0
* chunk dispatcher done AIPanel streamContent
* onChunk done suggestionId onCandidateGenerated('', suggestionId)
*
* LLM streamContent suggestion
* content_snapshot.contentGET /suggestions/{id} data.content spec
* LLM suggestion API + DB streamContent UI done
* CandidatePanel SSE doneonCandidateGenerated
*
* 绿
* POST /app-api/muse/ai/tasks code=0 taskId mock
* suggestion LLM GET /suggestions/{id} data.content >
* ///LLM ++
* done WorkspacePage CandidatePanelonCandidateGenerated 线
* DB 绿muse_ai_suggestion id idwork_id=1
* block_id=1content_snapshot.content UI mock 绿
*
*
* - muse-server 48080dispatcher initial-delay 8s+ New-API 线
* - work1(id=1)/chapter1(id=1)/block1(id=1) blockCount=1writing.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 20sNew-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 groundingglobal-setup 已为 work1 种世界观/魔法体系 schema
const USER_INSTRUCTION = '继续描写星环大陆的魔法体系与元素对抗禁咒';
// 真 LLM 文本最小长度阈值:续写至少应有可观字数;取保守值避免 LLM 漂移误红,又足以排除空串/占位。
const MIN_LLM_CONTENT_LENGTH = 12;
// 主题相关字样(任一命中即可):验生成确为本指令的 grounding 续写而非无关占位文本。LLM 输出不定,故用宽集合。
const THEME_KEYWORDS = ['星环', '魔法', '禁咒', '元素'];
/** 整套用例放宽 timeoutAI 生成有外部 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]=block1AIPanel 拿到 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 找到本轮新生成的 suggestionid > ${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)}`);
});

View File

@ -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) => {