#!/usr/bin/env node "use strict"; const assert = require("node:assert"); const fs = require("node:fs/promises"); const path = require("node:path"); const { chromium } = require("playwright"); const { BASE_URL, cleanupDocuments, createTempDocument, ensureAuthenticated, openDocument, renameDocument, requestJson, } = require("./tree-shell-smoke-helpers"); const OUT_DIR = path.join(process.cwd(), "tmp", "page-block-ai-conflict-idempotency-smoke"); async function callMnoteTool(request, payload) { return requestJson(request, "/api/hermes/tools/mnote/call", { method: "POST", headers: { "x-mnote-actor-id": payload.actorId || "smoke-user" }, data: payload, }); } async function callMnoteToolRaw(request, payload) { const response = await request.fetch(`${BASE_URL}/api/hermes/tools/mnote/call`, { method: "POST", headers: { "content-type": "application/json", "x-mnote-actor-id": payload.actorId || "smoke-user", }, data: JSON.stringify(payload), }); const text = await response.text(); let body = null; try { body = text ? JSON.parse(text) : null; } catch { body = text; } return { status: response.status(), headers: response.headers(), body, text, }; } function blockById(blocks, blockId) { return blocks.find((block) => block.blockId === blockId); } async function fetchBlocks(request, target, suffix, actorId, label) { const response = await callMnoteTool(request, { toolName: "mnote.doc.fetch", workspaceId: target.workspaceId, documentId: target.documentId, actorId, sessionId: `sess_conflict_fetch_${suffix}`, runId: `run_conflict_fetch_${suffix}_${label}`, toolCallId: `call_conflict_fetch_${suffix}_${label}`, traceId: `trace_conflict_fetch_${suffix}_${label}`, capabilityScope: ["page.read"], args: { scope: "full", detail: "with_ids", maxBlocks: 20, }, }); assert.equal(response.ok, true, `${label}: doc.fetch 应成功`); assert(Array.isArray(response.result.blocks), `${label}: doc.fetch 应返回 blocks`); return response.result; } async function main() { const suffix = Date.now().toString(36); const title = `TEST-AI-CONFLICT-IDEMPOTENCY-${suffix}`; const createdIds = []; const evidence = { ok: false, baseUrl: BASE_URL, title, steps: [], }; await fs.mkdir(OUT_DIR, { recursive: true }); const browser = await chromium.launch({ headless: true }); const context = await browser.newContext({ viewport: { width: 1280, height: 900 } }); const page = await context.newPage(); try { const viewer = await ensureAuthenticated(page, context.request); const actorId = viewer.userId || "smoke-user"; const target = await createTempDocument(context.request); createdIds.push(target.documentId); evidence.documentId = target.documentId; evidence.workspaceId = target.workspaceId; await renameDocument(context.request, target.workspaceId, target.documentId, title); const seed = await callMnoteTool(context.request, { toolName: "mnote.page.save", workspaceId: target.workspaceId, documentId: target.documentId, actorId, sessionId: `sess_conflict_seed_${suffix}`, runId: `run_conflict_seed_${suffix}`, toolCallId: `call_conflict_seed_${suffix}`, traceId: `trace_conflict_seed_${suffix}`, idempotencyKey: `idem_conflict_seed_${suffix}`, dryRun: false, capabilityScope: ["page.write"], args: { mode: "replace", content: [ { id: "p_1", type: "paragraph", content: [{ type: "text", text: `第一段 ${suffix}` }] }, { id: "p_2", type: "paragraph", content: [{ type: "text", text: `第二段 ${suffix}` }] }, ], }, }); assert.equal(seed.result.commandName, "page.body.save", "初始化必须走 page.body.save"); evidence.steps.push({ name: "seed", commandName: seed.result.commandName }); const initial = await fetchBlocks(context.request, target, suffix, actorId, "initial"); const initialP2 = blockById(initial.blocks, "p_2"); assert(initialP2?.revisionRef, "初始化后 p_2 必须有 revisionRef"); const firstText = `第二段首次替换 ${suffix}`; const idempotencyKey = `idem_conflict_replace_${suffix}`; const firstReplacePayload = { toolName: "mnote.block.replace", workspaceId: target.workspaceId, documentId: target.documentId, actorId, sessionId: `sess_conflict_replace_${suffix}`, runId: `run_conflict_replace_first_${suffix}`, toolCallId: `call_conflict_replace_first_${suffix}`, traceId: `trace_conflict_replace_first_${suffix}`, idempotencyKey, dryRun: false, capabilityScope: ["block.write"], args: { blockId: "p_2", content: firstText, revision: initial.revision, conflictDetectionKey: initial.conflictDetectionKey, blockRevisionRef: initialP2.revisionRef, }, }; const firstReplace = await callMnoteTool(context.request, firstReplacePayload); assert.equal(firstReplace.result.commandName, "page.body.save", "block.replace 必须走 page.body.save"); evidence.steps.push({ name: "block.replace.first", commandId: firstReplace.audit.commandId, idempotencyKey, }); const afterFirst = await fetchBlocks(context.request, target, suffix, actorId, "after_first"); const afterFirstP2 = blockById(afterFirst.blocks, "p_2"); assert.equal(afterFirstP2.text, firstText, "首次 replace 后 AI fetch 必须回读新文本"); assert.notEqual(afterFirstP2.revisionRef, initialP2.revisionRef, "首次 replace 后 p_2 revisionRef 应变化"); const replayText = `不应被重复 idempotency 写入 ${suffix}`; const replay = await callMnoteTool(context.request, { ...firstReplacePayload, runId: `run_conflict_replace_replay_${suffix}`, toolCallId: `call_conflict_replace_replay_${suffix}`, traceId: `trace_conflict_replace_replay_${suffix}`, args: { ...firstReplacePayload.args, content: replayText, }, }); assert.equal(replay.audit.commandId, firstReplace.audit.commandId, "重复 idempotencyKey 应返回缓存 commandId"); const afterReplay = await fetchBlocks(context.request, target, suffix, actorId, "after_replay"); assert.equal(blockById(afterReplay.blocks, "p_2").text, firstText, "重复 idempotencyKey 不应写入新 content"); assert.notEqual(blockById(afterReplay.blocks, "p_2").text, replayText, "重复 idempotencyKey 不应造成二次写入"); assert.equal(afterReplay.revision, afterFirst.revision, "重复 idempotencyKey 后 revision 不应再次递增"); evidence.steps.push({ name: "block.replace.idempotency_replay", replayCommandId: replay.audit.commandId, finalText: blockById(afterReplay.blocks, "p_2").text, revisionAfterFirst: afterFirst.revision, revisionAfterReplay: afterReplay.revision, }); const staleRevision = await callMnoteToolRaw(context.request, { toolName: "mnote.block.replace", workspaceId: target.workspaceId, documentId: target.documentId, actorId, sessionId: `sess_conflict_stale_revision_${suffix}`, runId: `run_conflict_stale_revision_${suffix}`, toolCallId: `call_conflict_stale_revision_${suffix}`, traceId: `trace_conflict_stale_revision_${suffix}`, idempotencyKey: `idem_conflict_stale_revision_${suffix}`, dryRun: false, capabilityScope: ["block.write"], args: { blockId: "p_2", content: `旧 revision 不应写入 ${suffix}`, revision: initial.revision, conflictDetectionKey: initial.conflictDetectionKey, blockRevisionRef: afterFirstP2.revisionRef, }, }); assert.equal(staleRevision.status, 400, "旧 revision 写入应返回 400"); assert.equal(staleRevision.headers["x-error-code"], "mnote_tool_conflict", "旧 revision 应返回 mnote_tool_conflict"); assert.equal(staleRevision.body?.code, "mnote_tool_conflict", "旧 revision body 应返回 mnote_tool_conflict"); evidence.steps.push({ name: "block.replace.stale_revision", status: staleRevision.status, errorCode: staleRevision.body?.code, }); const staleBlockRef = await callMnoteToolRaw(context.request, { toolName: "mnote.block.replace", workspaceId: target.workspaceId, documentId: target.documentId, actorId, sessionId: `sess_conflict_stale_block_ref_${suffix}`, runId: `run_conflict_stale_block_ref_${suffix}`, toolCallId: `call_conflict_stale_block_ref_${suffix}`, traceId: `trace_conflict_stale_block_ref_${suffix}`, idempotencyKey: `idem_conflict_stale_block_ref_${suffix}`, dryRun: false, capabilityScope: ["block.write"], args: { blockId: "p_2", content: `旧 blockRevisionRef 不应写入 ${suffix}`, revision: afterFirst.revision, conflictDetectionKey: afterFirst.conflictDetectionKey, blockRevisionRef: initialP2.revisionRef, }, }); assert.equal(staleBlockRef.status, 400, "旧 blockRevisionRef 写入应返回 400"); assert.equal(staleBlockRef.headers["x-error-code"], "mnote_tool_conflict", "旧 blockRevisionRef 应返回 mnote_tool_conflict"); assert.equal(staleBlockRef.body?.code, "mnote_tool_conflict", "旧 blockRevisionRef body 应返回 mnote_tool_conflict"); evidence.steps.push({ name: "block.replace.stale_block_revision_ref", status: staleBlockRef.status, errorCode: staleBlockRef.body?.code, }); const finalSnapshot = await fetchBlocks(context.request, target, suffix, actorId, "final"); assert.equal(blockById(finalSnapshot.blocks, "p_2").text, firstText, "conflict 失败后正文应保持首次替换结果"); await openDocument(page, target.workspaceId, target.documentId); await page.getByText(firstText).waitFor({ state: "visible", timeout: 30_000 }); const screenshotPath = path.join(OUT_DIR, `${suffix}-page.png`); await page.screenshot({ path: screenshotPath, fullPage: true }); evidence.screenshot = screenshotPath; evidence.finalRevision = finalSnapshot.revision; evidence.finalText = blockById(finalSnapshot.blocks, "p_2").text; evidence.ok = true; const evidencePath = path.join(OUT_DIR, `${suffix}.json`); await fs.writeFile(evidencePath, JSON.stringify(evidence, null, 2), "utf8"); console.log(JSON.stringify({ ...evidence, evidencePath }, null, 2)); } finally { await cleanupDocuments(context.request, createdIds).catch(() => undefined); await context.close().catch(() => undefined); await browser.close().catch(() => undefined); } } main().catch((error) => { console.error(error instanceof Error ? error.stack || error.message : String(error)); process.exit(1); });