test(studio): accept-suggestion 改真生成删 stub,清除 line176② 最后 AI 假绿(采纳断层已修)

采纳断层修复后(commit a16c596 ADR-020,真候选 authz 字符串 envelope 可采纳),accept-suggestion 正路删 /ai/tasks+/stream stub,复用 ai-generation 真链路:WorkspacePage→AIPanel→真 AI task→SSE done→CandidatePanel 采纳替换→真 suggestion-merges code=0+newRevision 自增。反假绿:authz 真 rpe-local 字符串(非种子数值)、sourceAttribution.parentSuggestionId==真生成 id(证非种子)、block revision 实读动态比对(漂移不影响重复跑)。连跑 2 次 2/2 绿(suggestionId 25→26 revision 72→74)+ ai-generation/agent-slot-bind 回归绿。line176② AI 生成 stub 假绿完全清除。flaky:真 SSE 冷启动偶慢(与 ai-generation 同源,CI retries:2)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
lili 2026-06-25 01:24:01 -07:00
parent a16c59662f
commit 2ede7cb384

View File

@ -1,98 +1,213 @@
import { expect, test } from '@playwright/test';
import { expect, test, type Page } from '@playwright/test';
import { Client } from 'pg';
/**
* AI mock
* AI + New-API LLM stub绿
*
* spec 绿2026-06-242/2 2 muse-cloud
*48080+ muse_slice_live fixture global-setup PG §3 work3/block3/suggestion3
* MSW e2e VITE_API_MOCK=false suggestion-merges/ mock 绿
* spec page.route AI
* WorkspacePageAIPanel AI New-API LLMSSE done
* CandidatePanel POST suggestion-merges Canonical line176
* AI e2e stub /ai/tasks AI stub 绿
*
* AI 2026-06-24
* AI ai-generation.spec AI task New-API LLMsuggestion
* ** muse_ai_suggestion.diff_summary outputComplianceResultId /
* staticCheckResultId / licenseRestrictionSnapshotstatus=pendingsource_status=activecontent
* **authorization_snapshot_id null
* runtime runtimePermissionEnvelopeId `rpe-local-<uuid>`()
* MuseAiRuntimeProjectionService.numericEnvelopeId Long.parseLong authorization_snapshot_id=null
* AiSuggestionMergeProjectionFacade.getSuggestion authorization_snapshot_id==null unavailable
* mergeBlockSuggestion CONTENT_EXTERNAL_OWNER_UNAVAILABLE(1041001001) owner
* suggestion(id=17) accept_as_is merge_after_edit mergeMode 1041001001(
* mergeMode/revision ) suggestion3(authorization_snapshot_id=9001) ()
* ADR-020 commit a16c596
* runtime runtimePermissionEnvelopeId `rpe-local-<uuid>`()
* authorization_snapshot_id BIGINT authz=null
* AiSuggestionMergeProjectionFacade authz==null unavailable 1041001001
* /ai/tasks + /stream onDone suggestion3(authz=9001 )
* ADR-020 Aauthorization_snapshot_id BIGINTVARCHAR envelope
* authz `rpe-local-*`
* 2026-06-25 suggestion(id=20authz=rpe-local-*) accept_as_is
* merge code=0newRevision 7071sourceAttribution.authorizationSnapshotId
* ai-generation.spec work1/block1 + writing.continuation
*
* suggestion3(work3/block3global-setup §3 pending/active+block3 rev1)
* /ai/tasks + /stream onDone suggestion3 POST merge(绿)
* Canonical ()
* 绿
* POST /app-api/muse/ai/tasks2xx + code=0 + taskId mock
* SSE done CandidatePaneldoneonCandidateGenerated
* DB muse_ai_suggestion id > work1/block1content
* POST .../suggestion-merges HTTP 200 + code=0 + data.newRevision revision
* sourceAttribution.parentSuggestionId == suggestionId
* Canonical stub
*
* 2 绿
* suggestionid suggestion
* expectedBlockRevision chapterDetail.blocks[0].revision
* newRevision > revision block revision +1
* work1 writing.continuation global-setup §15 agent1/active Agent
*
* / +
* - muse-server 48080V30 applied + ADR-020 authz=VARCHAR+ New-API 线 + muse_slice_live
* e2e MSWVITE_API_MOCK=falsevite /app-api48080
* - work1(id=1)/chapter1(id=1)/block1(id=1) writing.continuation activeglobal-setup §15
* - DB ~/.config/muse-repo/infra.env MUSE_POSTGRES_*
* - AI New-API LLM(~7s) + dispatcher + 20s test timeout 150s
*
* 线( spec ) AI ** (numericEnvelopeId rpe-local-*
* authorization_snapshot_id envelope)使
* ( ai-generation.spec ) 1041001001/绿
* merge P1rContentMergeSuggestionIT PG ////
* P1rContentMergeGeneratedSuggestionIT ** envelope(9001)
* New-API runtime envelope IT 绿 ( spec )
*
* global-setup §3
* ADR-020 spec
*/
// 真实后端 mock 登录模式下的访问令牌mockSecret 'test' + userId 1 → 'test1')。
const SEEDED_ACCESS_TOKEN = 'test1';
// 已种子化、归属于上述登录用户的作品 ID。
const SEEDED_WORK_ID = '3';
// 上述作品下、已种子化的目标 Block ID采纳写入的目标 Canonical Blockglobal-setup §3 每轮复位 rev1
const SEEDED_BLOCK_ID = '3';
// 已种子化、审齐authorization_snapshot_id=9001 + 审三件套)、状态可被采纳的 AI 候选 ID。
const SEEDED_SUGGESTION_ID = '3';
// 采纳前该 Block 的当前 revisionglobal-setup §3 复位为 1用于断言采纳后 newRevision 自增)。
const SEEDED_BLOCK_REVISION = 1;
/**
* localStorage
* WHYstudio HTTP localStorage accessToken Bearere2e
* 使 addInitScript setItem
*/
async function seedAccessToken(page: import('@playwright/test').Page): Promise<void> {
await page.addInitScript((token) => {
window.localStorage.setItem('accessToken', token);
}, SEEDED_ACCESS_TOKEN);
/** 真实后端 mock 登录模式下的访问令牌mockSecret 'test' + userId 1 → 'test1')。 */
const TOKEN = 'test1';
/** 真生成 + 采纳所用作品work1 有 writing.continuation 槽位global-setup §15 每轮复位 agent1/active。 */
const WORK_ID = 1;
/** work1/chapter1 下唯一 BlockAIPanel 自动以 blocks[0]=block1 为目标,采纳写入该 Canonical Block。 */
const BLOCK_ID = 1;
/** 与 work1 KB 同主题,确保 grounding 续写命中global-setup 已为 work1 种世界观/魔法体系 schema。 */
const USER_INSTRUCTION = '继续描写星环大陆的魔法体系与元素对抗禁咒';
/** 整套用例放宽 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,
});
}
test.describe('AI 候选采纳写入 Canonical真实后端', () => {
test('采纳候选后 newRevision 自增,且刷新后 Canonical 正文已更新', async ({ page }) => {
await seedAccessToken(page);
// 桩掉 AI「生成」一段令其 onDone 回传种子审齐候选 suggestion3 的 id非「本机不可用」真生成本机可跑
// 但真生成候选 authorization_snapshot_id=null → 采纳被合并门禁拒 1041001001详见文件头根因
// WHYvite 以 VITE_API_MOCK=false 运行MSW 关page.route 可在网络层拦截 AI 任务与流;
// 作品/Block 读与采纳 POST 不拦截,直打真实后端 → 采纳真实合并入 Canonical反假绿
await page.route('**/app-api/muse/ai/tasks', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ code: 0, msg: '', data: { taskId: 't-e2e' } }),
})
/** 读运行前 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]
);
await page.route('**/app-api/muse/ai/tasks/*/stream', (route) =>
route.fulfill({
status: 200,
contentType: 'text/event-stream',
body:
`event: chunk\ndata: ${JSON.stringify({ content: '活体生成候选片段' })}\n\n` +
`event: done\ndata: ${JSON.stringify({ taskId: 't-e2e', suggestionId: Number(SEEDED_SUGGESTION_ID) })}\n\n`,
})
return Number(r.rows[0].max_id);
} finally {
await c.end();
}
}
/** 读 id 大于基准的最新真生成 suggestionDB 核验本轮真落库;返回其 id 供采纳后比对 parentSuggestionId。 */
async function readLatestSuggestionAbove(
baselineId: number
): Promise<{ id: number; status: string; authz: string | null; content: string } | null> {
const c = createPgClient();
await c.connect();
try {
const r = await c.query(
`SELECT id, status, authorization_snapshot_id AS authz, 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),
authz: row.authz == null ? null : String(row.authz),
content: row.content == null ? '' : String(row.content),
};
} finally {
await c.end();
}
}
// 导航到真实创作工作台路由(见 src/app/routes/index.tsx 的 workspace/:workId
await page.goto(`/workspace/${SEEDED_WORK_ID}`);
/** 实读目标 Block 当前 revision与前端 blockRevision 同口径:取 muse_content_block.revision。 */
async function readBlockRevision(): Promise<number> {
const c = createPgClient();
await c.connect();
try {
const r = await c.query(
`SELECT revision FROM muse_content_block WHERE tenant_id=1 AND work_id=$1 AND id=$2`,
[WORK_ID, BLOCK_ID]
);
if (r.rowCount === 0) throw new Error(`block id=${BLOCK_ID} 不存在(基础种子缺失)`);
return Number(r.rows[0].revision);
} finally {
await c.end();
}
}
// 触发 AI 面板生成候选(指令文本任意),等待流式生成结束、对比面板出现。
// 注意:此处依赖真实 AI 任务能产出已种子化的 suggestionId若环境改为直接种子候选可改为直接调用采纳入口。
/** 读 work1/block1 最新一条 pending 候选 id供负路乐观锁用例版本冲突在 authz 校验前先判,故任意 pending 即可)。 */
async function readLatestPendingSuggestionId(): Promise<number | null> {
const c = createPgClient();
await c.connect();
try {
const r = await c.query(
`SELECT id FROM muse_ai_suggestion
WHERE tenant_id=1 AND work_id=$1 AND block_id=$2 AND deleted=false AND status='pending'
ORDER BY id DESC LIMIT 1`,
[WORK_ID, BLOCK_ID]
);
return r.rowCount === 0 ? null : Number(r.rows[0].id);
} finally {
await c.end();
}
}
/**
* + localStorage
* WHYstudio HTTP localStorage accessToken/tenantId Bearer/tenant-ide2e
* addInitScript setItem
*/
async function seedToken(page: Page): Promise<void> {
await page.addInitScript((t) => {
window.localStorage.setItem('accessToken', t);
window.localStorage.setItem('tenantId', '1');
}, TOKEN);
}
test.describe('AI 候选采纳写入 Canonical真实后端 + 真生成,不 stub', () => {
test('真生成候选 → 采纳 → newRevision 自增(全链真后端,无 AI stub', async ({ page }) => {
// 0. 记录运行前 work1/block1 的 suggestion 基准 id本轮 UI 真生成应产出 id 更大的新行DB 反假绿锚点)。
const baselineSuggestionId = await readMaxSuggestionId();
await seedToken(page);
// 导航到真实创作工作台路由(见 src/app/routes/index.tsx 的 workspace/:workId
// WorkspacePage 自动激活 chapter1 + blocks[0]=block1AIPanel 拿到 blockId=1。
await page.goto(`/workspace/${WORK_ID}`);
// 1. 等工作台就绪AIPanel 发送按钮出现。
await expect(page.getByRole('button', { name: /发送指令/ })).toBeVisible({ timeout: 30_000 });
// 2. ① 填真实指令并捕获真 POST /ai/tasks反假绿真打真接口非 page.route 截获)。
const promptBox = page.getByPlaceholder(/在此处输入创作描述/);
await promptBox.fill('请基于本章续写一段e2e 种子)');
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 acceptButton = page.getByRole('button', { name: /采纳替换/ });
await expect(acceptButton).toBeVisible({ timeout: 30_000 });
const taskResponse = await taskResponsePromise;
// 异步受理:真后端对 POST /ai/tasks 返回 2xx任务入队稍后经 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();
expect(taskBody.code, `创建 AI 任务响应: ${JSON.stringify(taskBody)}`).toBe(0);
expect(taskBody?.data?.taskId, 'AI 任务应回传 taskId').toBeTruthy();
// 点击采纳,并捕获真实 suggestion-merges 写入响应。
// 3. ② 等真 SSE done → onCandidateGenerated → CandidatePanel「采纳替换」按钮可见证候选已就绪。
// 放宽到 120s覆盖 New-API cold/慢 + dispatcher 延迟 + 5s 轮询粒度。绝不因 flaky 改 stub 绕过。
const acceptButton = page.getByRole('button', { name: /采纳替换/ });
await expect(acceptButton).toBeVisible({ timeout: 120_000 });
// 4. ③ DB 核验:本轮真生成应在 muse_ai_suggestion 落新行id > 基准work1/block1、内容非空、authz 非空。
// authz 非空是 ADR-020 修复的直接见证(修前真生成候选 authz=null → 不可采纳)。
const dbRow = await readLatestSuggestionAbove(baselineSuggestionId);
expect(dbRow, `应在 DB 找到本轮真生成 suggestionid > ${baselineSuggestionId}`).not.toBeNull();
expect(dbRow!.content.length, 'DB suggestion 内容应非空(真 LLM 生成)').toBeGreaterThan(0);
expect(dbRow!.authz, 'ADR-020 后真生成候选 authz 应为非空字符串 envelope').toBeTruthy();
const generatedSuggestionId = dbRow!.id;
// 5. 采纳前实读 Block revision前端 expectedBlockRevision 同口径:取 block.revision
// 动态读而非硬编码block revision 逐轮采纳 +1 漂移,跑 2 次须可重复。
const revisionBeforeAccept = await readBlockRevision();
// 6. ④ 点击采纳,捕获真实 suggestion-merges 写入响应(直打真后端 merge反假绿核心
const [mergeResponse] = await Promise.all([
page.waitForResponse(
(resp) => /\/suggestion-merges$/.test(resp.url()) && resp.request().method() === 'POST',
@ -101,43 +216,59 @@ test.describe('AI 候选采纳写入 Canonical真实后端', () => {
acceptButton.click(),
]);
// (d) 断言写入命令 HTTP 200。
// (a) 写入命令 HTTP 200。
expect(mergeResponse.status()).toBe(200);
// (e) 断言响应 newRevision 自增(相对采纳前 SEEDED_BLOCK_REVISION
const mergeBody = await mergeResponse.json();
// (b) 业务码 code=0真采纳成功非被门禁/乐观锁拒)。
expect(mergeBody.code, `采纳响应: ${JSON.stringify(mergeBody).slice(0, 200)}`).toBe(0);
// (c) newRevision 自增(相对采纳前实读 revision动态比较不硬编码 revision
const newRevision = mergeBody?.data?.newRevision;
expect(typeof newRevision).toBe('number');
expect(newRevision).toBeGreaterThan(SEEDED_BLOCK_REVISION);
expect(newRevision).toBeGreaterThan(revisionBeforeAccept);
// (d) 采纳的确是本轮真生成候选parentSuggestionId == 真生成 suggestionId证非种子/非 mock。
expect(String(mergeBody?.data?.sourceAttribution?.lineage?.parentSuggestionId)).toBe(
String(generatedSuggestionId)
);
// (f) 刷新后断言 Canonical Block 正文已变(对比面板关闭、编辑器仍渲染该作品工作台)。
// 留痕:打印真 suggestionId 与 revision 跃迁,作为反假绿证据(运行日志可见)。
console.log(
`[accept-suggestion] 真生成 suggestionId=${generatedSuggestionId} authz=${dbRow!.authz} ` +
`revision ${revisionBeforeAccept}${newRevision}`
);
// (e) 刷新后断言工作台仍渲染该作品(对比面板已关闭,回到 AI 面板)。
await page.reload();
await expect(page.getByRole('button', { name: /发送指令/ })).toBeVisible({ timeout: 30_000 });
// TODO: 如需断言具体正文文本,请在此处用 SEEDED 的「采纳后预期正文片段」替换下面占位断言。
// await expect(page.getByText('<FILL_ME_expected_canonical_text>')).toBeVisible();
});
test('错误的 expectedBlockRevision 导致采纳因乐观锁冲突被拒HTTP 200 + 业务错误码)', async ({ page }) => {
await seedAccessToken(page);
await seedToken(page);
// 直接以错误 revision 调用真实 suggestion-merges断言乐观锁冲突。
// WHY用 page.request 直接打接口,绕开 UI专注验证后端乐观锁负路径Bearer 由 Authorization 头显式带上。
// 取 work1/block1 一条 pending 候选作 suggestionId版本冲突在 authz 校验前先判,故任意 pending 即可
//(活体已证 null-authz 候选 + stale revision 同样先返回 1041000002与候选 authz 无关)。
const pendingId = await readLatestPendingSuggestionId();
expect(pendingId, 'work1/block1 应至少有一条 pending 候选供负路用例').not.toBeNull();
// 实读当前 revision再以「明显过期」revision 触发乐观锁冲突(动态读,避免硬编码漂移误红)。
const liveRevision = await readBlockRevision();
const wrongRevision = liveRevision + 999;
// 用 page.request 直接打接口,绕开 UI专注验证后端乐观锁负路径Bearer/tenant 显式带上。
// 注意Yudao 全局异常处理把 ServiceException 包成 HTTP 200 + CommonResult{code≠0,msg},并非 REST 409
//P1rContentMergeSuggestionIT 已证实该口径)。
const wrongRevision = SEEDED_BLOCK_REVISION + 999;
const response = await page.request.post(
`/app-api/muse/works/${SEEDED_WORK_ID}/blocks/${SEEDED_BLOCK_ID}/suggestion-merges`,
`/app-api/muse/works/${WORK_ID}/blocks/${BLOCK_ID}/suggestion-merges`,
{
headers: {
'X-API-Version': '1',
'Authorization': `Bearer ${SEEDED_ACCESS_TOKEN}`,
// 后端按 tenant-id 头解析租户;page.request 绕开 client.ts,需手动带上(否则报租户未传递)
Authorization: `Bearer ${TOKEN}`,
// 后端按 tenant-id 头解析租户page.request 绕开 client.ts需手动带上否则报租户未传递
'tenant-id': '1',
'Content-Type': 'application/json',
},
data: {
commandId: crypto.randomUUID(),
suggestionId: SEEDED_SUGGESTION_ID,
suggestionId: String(pendingId),
expectedBlockRevision: wrongRevision,
decisionType: 'accept',
mergeMode: 'accept_as_is',