"use strict"; const { chromium } = require("playwright"); const { BASE_URL, REQUEST_TIMEOUT_MS, UI_TIMEOUT_MS, assert, createTempDocument, ensureAuthenticated, openDocument, purgeDocument, renameDocument, requestJson, } = require("./tree-shell-smoke-helpers"); const AI_TIMEOUT_MS = Number(process.env.MNOTE_AI_SMOKE_TIMEOUT_MS || 180_000); function parseJsonSafely(text) { try { return text ? JSON.parse(text) : null; } catch { return text; } } function parseSseText(rawText) { return String(rawText || "") .split(/\n\n+/) .map((chunk) => chunk.trim()) .filter(Boolean) .filter((chunk) => !chunk.startsWith(":")) .map((chunk) => { const lines = chunk.split(/\r?\n/); let type = "message"; const dataLines = []; for (const line of lines) { if (line.startsWith("event:")) { type = line.slice("event:".length).trim() || "message"; continue; } if (line.startsWith("data:")) { dataLines.push(line.slice("data:".length).trimStart()); } } const dataText = dataLines.join("\n"); return { type, dataText, data: parseJsonSafely(dataText), }; }); } function serializeForError(value) { try { return JSON.stringify(value, null, 2); } catch { return String(value); } } async function requestSseEvents(requestContext, path, body) { const response = await requestContext.fetch(`${BASE_URL}${path}`, { method: "POST", headers: { "content-type": "application/json", }, data: body, timeout: AI_TIMEOUT_MS, }); const text = await response.text(); if (!response.ok()) { throw new Error(`${path} 请求失败: ${response.status()} ${response.statusText()} ${text.slice(0, 600)}`); } const contentType = response.headers()["content-type"] || ""; assert(contentType.includes("text/event-stream"), `${path} 未返回 SSE:${contentType}`); return parseSseText(text); } async function fetchPageAggregate(requestContext, documentId, workspaceId) { return await requestJson( requestContext, `/api/page-aggregate/${encodeURIComponent(documentId)}?workspaceId=${encodeURIComponent(workspaceId)}`, { method: "GET" }, ); } async function fetchDocumentMeta(requestContext, documentId, workspaceId) { return await requestJson( requestContext, `/api/documents/meta?documentId=${encodeURIComponent(documentId)}&workspaceId=${encodeURIComponent(workspaceId)}`, { method: "GET" }, ); } async function fetchDocumentContent(requestContext, documentId, workspaceId) { return await requestJson( requestContext, `/api/documents/content?documentId=${encodeURIComponent(documentId)}&workspaceId=${encodeURIComponent(workspaceId)}`, { method: "GET" }, ); } function buildAiContextFromPageAggregate(pagePayload) { const page = pagePayload?.result ?? null; const pageSubtree = page?.tree?.pageSubtree ?? null; const subtreeNodes = Array.isArray(pageSubtree?.subtree?.nodes) ? pageSubtree.subtree.nodes.slice(0, 160) : []; return { documentId: page?.identity?.documentId ?? null, documentBlocks: page?.body?.content ?? null, node: pageSubtree?.rootNode ?? null, subtree: pageSubtree ? { projectionId: pageSubtree.projectionId ?? null, rootNodeId: pageSubtree?.subtree?.rootNodeId ?? null, stats: pageSubtree?.stats ?? null, nodes: subtreeNodes, } : null, outline: Array.isArray(pageSubtree?.outline) ? pageSubtree.outline.slice(0, 40) : null, evidence: Array.isArray(pageSubtree?.evidence) ? pageSubtree.evidence.slice(0, 32) : null, pageOptions: page?.layout?.pageOptions ?? {}, }; } function findSuccessfulToolResult(events, toolName) { return [...events] .reverse() .find( (event) => event?.type === "tool_result" && event?.data && event.data.tool === toolName && event.data.ok === true, ) ?? null; } function findErrorEvent(events) { return events.find((event) => event?.type === "error") ?? null; } function extractRenamedTitleFromSlashToolResult(toolEvent, documentId) { if (!toolEvent?.data?.result || toolEvent?.data?.tool !== "slash_run") { return null; } const resultRecord = toolEvent.data.result && typeof toolEvent.data.result === "object" ? toolEvent.data.result : null; const parsed = resultRecord?.parsed && typeof resultRecord.parsed === "object" ? resultRecord.parsed : null; const params = parsed?.params && typeof parsed.params === "object" ? parsed.params : null; if (!parsed || parsed.command !== "rename_doc" || !params) { return null; } if (String(params.documentId ?? "") !== documentId) { return null; } const title = String(params.title ?? "").trim(); return title || null; } function extractBodySnapshotFromToolResult(toolEvent) { if (!toolEvent?.data?.result || !["doc_insert_blocks", "doc_replace_range"].includes(toolEvent?.data?.tool)) { return null; } const resultRecord = toolEvent.data.result && typeof toolEvent.data.result === "object" ? toolEvent.data.result : null; return resultRecord && Array.isArray(resultRecord.data) ? resultRecord.data : null; } function extractInsertedBlockIdFromToolResult(toolEvent) { if (!toolEvent?.data?.result || toolEvent?.data?.tool !== "doc_insert_blocks") { return null; } const resultRecord = toolEvent.data.result && typeof toolEvent.data.result === "object" ? toolEvent.data.result : null; const inserted = resultRecord && Array.isArray(resultRecord.inserted) ? resultRecord.inserted : null; const blockId = typeof inserted?.[0] === "string" ? inserted[0].trim() : ""; return blockId || null; } async function persistAiTitleResult(requestContext, fixture, toolEvent) { const renamedTitle = extractRenamedTitleFromSlashToolResult(toolEvent, fixture.documentId); assert(renamedTitle, `slash_run 结果未产出当前页标题:${serializeForError(toolEvent)}`); await renameDocument(requestContext, fixture.workspaceId, fixture.documentId, renamedTitle); return renamedTitle; } async function persistAiBodyResult(requestContext, fixture, toolEvent) { const nextBlocks = extractBodySnapshotFromToolResult(toolEvent); assert(Array.isArray(nextBlocks), `文档写工具未返回 legacy blocks 快照:${serializeForError(toolEvent)}`); const aggregate = await fetchPageAggregate(requestContext, fixture.documentId, fixture.workspaceId); await requestJson(requestContext, "/api/documents/save", { method: "POST", data: { documentId: fixture.documentId, workspaceId: fixture.workspaceId, // task111 对齐正式 page body save:正文写工具结果只接受 legacy blocks 快照。 content: nextBlocks, revision: aggregate?.page?.body?.revision ?? null, conflictDetectionKey: aggregate?.page?.body?.conflictDetectionKey ?? null, snapshotCapturedAt: new Date().toISOString(), blockCount: nextBlocks.length, }, }); return nextBlocks; } async function waitFor(check, description, timeoutMs = UI_TIMEOUT_MS, intervalMs = 500) { const deadline = Date.now() + timeoutMs; let lastValue = null; while (Date.now() < deadline) { lastValue = await check(); if (lastValue) { return lastValue; } await new Promise((resolve) => setTimeout(resolve, intervalMs)); } throw new Error(`${description} 超时,最后结果:${serializeForError(lastValue)}`); } async function waitForPageAggregateBodyText(requestContext, fixture, expectedText) { await waitFor( async () => { const pagePayload = await fetchPageAggregate(requestContext, fixture.documentId, fixture.workspaceId); const raw = JSON.stringify(pagePayload?.page?.body?.content ?? null); return raw.includes(expectedText) ? pagePayload : null; }, `等待 page aggregate 正文同步到 ${expectedText}`, AI_TIMEOUT_MS, ); } async function waitForRuntimeIsland(page) { await page.waitForFunction( () => { const host = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]'); const editor = host?.querySelector(".editor-surface .ProseMirror[contenteditable='true']"); return ( host?.getAttribute("data-editor-host-kind") === "leptos_tiptap_island" && host?.getAttribute("data-runtime-editor-status") !== "error" && editor instanceof HTMLElement && editor.isContentEditable ); }, null, { timeout: UI_TIMEOUT_MS }, ); } async function waitForTitleInput(page) { const input = page.getByLabel("页面标题"); await input.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); return input; } async function waitForEditorText(page, expectedText) { await page.waitForFunction( (text) => { const editor = document.querySelector( '[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror', ); return (editor?.textContent ?? "").includes(text); }, expectedText, { timeout: UI_TIMEOUT_MS }, ); } async function readVisibleTitle(page) { const input = page.getByLabel("页面标题"); if (await input.isVisible().catch(() => false)) { return ((await input.inputValue().catch(() => "")) || "").trim(); } const heading = page.locator("h1").first(); return ((await heading.textContent().catch(() => "")) || "").trim(); } async function readEditorText(page) { return await page.evaluate(() => { const editor = document.querySelector( '[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror', ); return (editor?.textContent ?? "").trim(); }); } async function typeIntoEditor(page, text) { const editor = page .locator('[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror[contenteditable="true"]') .first(); await editor.click({ timeout: UI_TIMEOUT_MS }); await page.keyboard.press("Control+a"); await page.keyboard.press("Backspace"); await page.keyboard.type(text, { delay: 20 }); } async function waitForPersistedSave(page, saveRequests, documentId, expectedText) { const deadline = Date.now() + UI_TIMEOUT_MS; while (Date.now() < deadline) { const editorText = await readEditorText(page); const hasSaveRequest = saveRequests.some((item) => item.documentId === documentId); const runtimeStatus = await page.evaluate(() => { return ( document .querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]') ?.getAttribute("data-runtime-editor-status") ?? null ); }); if (editorText.includes(expectedText) && (runtimeStatus === "saved" || hasSaveRequest)) { return; } await page.waitForTimeout(250); } throw new Error(`等待编辑器保存超时:${documentId}`); } async function runTitleRename(requestContext, fixture, renamedTitle) { const aggregate = await fetchPageAggregate(requestContext, fixture.documentId, fixture.workspaceId); const events = await requestSseEvents(requestContext, "/api/ai-agent/run", { stream: true, maxSteps: 8, scope: "document", messages: [ { role: "user", content: `请把当前页面标题改成“${renamedTitle}”。只改标题,不要改正文;改完后用一句话确认。`, }, ], context: buildAiContextFromPageAggregate(aggregate), options: { ai: { provider: "online", }, }, }); const errorEvent = findErrorEvent(events); assert(!errorEvent, `标题改名流返回 error:${serializeForError(errorEvent)}`); const slashResult = findSuccessfulToolResult(events, "slash_run"); assert(slashResult, `标题改名未得到成功的 slash_run:${serializeForError(events)}`); const persistedTitle = await persistAiTitleResult(requestContext, fixture, slashResult); assert( persistedTitle === renamedTitle, `AI 标题结果与目标不一致,期望 ${renamedTitle},实际 ${persistedTitle}`, ); await waitFor( async () => { const meta = await fetchDocumentMeta(requestContext, fixture.documentId, fixture.workspaceId); return String(meta?.doc?.title ?? "").trim() === renamedTitle ? meta : null; }, "等待标题改名落盘", AI_TIMEOUT_MS, ); return events; } async function runBodyInsert(requestContext, fixture, initialBody) { const aggregate = await fetchPageAggregate(requestContext, fixture.documentId, fixture.workspaceId); const events = await requestSseEvents(requestContext, "/api/ai-agent/run", { stream: true, maxSteps: 8, scope: "document", messages: [ { role: "user", content: `请在当前页面插入一段正文:“${initialBody}”。只插入这一段,不要改标题;完成后简短确认。`, }, ], context: buildAiContextFromPageAggregate(aggregate), options: { ai: { provider: "online", }, }, }); const errorEvent = findErrorEvent(events); assert(!errorEvent, `正文插入流返回 error:${serializeForError(errorEvent)}`); const insertResult = findSuccessfulToolResult(events, "doc_insert_blocks"); assert(insertResult, `正文插入未得到成功的 doc_insert_blocks:${serializeForError(events)}`); await persistAiBodyResult(requestContext, fixture, insertResult); const insertedBlockId = extractInsertedBlockIdFromToolResult(insertResult); assert(insertedBlockId, `正文插入结果缺少 inserted blockId:${serializeForError(insertResult)}`); await waitFor( async () => { const content = await fetchDocumentContent(requestContext, fixture.documentId, fixture.workspaceId); const raw = JSON.stringify(content?.content ?? null); return raw.includes(initialBody) ? content : null; }, "等待正文插入落盘", AI_TIMEOUT_MS, ); await waitForPageAggregateBodyText(requestContext, fixture, initialBody); return { events, insertedBlockId, }; } async function runBodyRewrite(requestContext, fixture, targetBlockId, rewrittenBody) { const aggregate = await fetchPageAggregate(requestContext, fixture.documentId, fixture.workspaceId); const events = await requestSseEvents(requestContext, "/api/ai-agent/run", { stream: true, maxSteps: 8, scope: "document", messages: [ { role: "user", content: `请把当前页面中 blockId 为“${targetBlockId}”的那一段改写为“${rewrittenBody}”。只改这个 blockId 对应的段落,不要改标题;直接用 doc_replace_range 完成,不需要先搜索。改完后简短确认。`, }, ], context: buildAiContextFromPageAggregate(aggregate), options: { ai: { provider: "online", }, }, }); const errorEvent = findErrorEvent(events); assert(!errorEvent, `正文改写流返回 error:${serializeForError(errorEvent)}`); const replaceResult = findSuccessfulToolResult(events, "doc_replace_range"); assert(replaceResult, `正文改写未得到成功的 doc_replace_range:${serializeForError(events)}`); await persistAiBodyResult(requestContext, fixture, replaceResult); await waitFor( async () => { const content = await fetchDocumentContent(requestContext, fixture.documentId, fixture.workspaceId); const raw = JSON.stringify(content?.content ?? null); return raw.includes(rewrittenBody) ? content : null; }, "等待正文改写落盘", AI_TIMEOUT_MS, ); return events; } async function verifyDocumentUi(page, fixture, expectedTitle, expectedBody) { await openDocument(page, fixture.workspaceId, fixture.documentId); await waitForRuntimeIsland(page); await waitForTitleInput(page); await waitForEditorText(page, expectedBody); const visibleTitle = await readVisibleTitle(page); const editorText = await readEditorText(page); assert( visibleTitle.includes(expectedTitle), `页面标题未同步到 UI,期望包含 ${expectedTitle},实际为 ${visibleTitle}`, ); assert( editorText.includes(expectedBody), `编辑区正文未同步到 UI,期望包含 ${expectedBody},实际为 ${editorText}`, ); } async function main() { const browser = await chromium.launch({ headless: true }); const context = await browser.newContext({ viewport: { width: 1440, height: 960 }, }); const page = await context.newPage(); let fixture = null; let caughtError = null; try { const viewer = await ensureAuthenticated(page, context.request); fixture = await createTempDocument(context.request, null); const uniqueSuffix = Date.now().toString().slice(-6); const initialTitle = `phase7-ai-initial-${uniqueSuffix}`; const renamedTitle = `phase7-ai-renamed-${uniqueSuffix}`; const initialBody = `phase7 AI 原始正文 ${uniqueSuffix}`; const rewrittenBody = `phase7 AI 改写正文 ${uniqueSuffix}`; await renameDocument(context.request, fixture.workspaceId, fixture.documentId, initialTitle); const insertRun = await runBodyInsert(context.request, fixture, initialBody); const renameEvents = await runTitleRename(context.request, fixture, renamedTitle); const rewriteEvents = await runBodyRewrite( context.request, fixture, insertRun.insertedBlockId, rewrittenBody, ); const meta = await fetchDocumentMeta(context.request, fixture.documentId, fixture.workspaceId); const content = await fetchDocumentContent(context.request, fixture.documentId, fixture.workspaceId); assert( String(meta?.doc?.title ?? "").trim() === renamedTitle, `标题未完成持久化,期望 ${renamedTitle},实际 ${String(meta?.doc?.title ?? "").trim()}`, ); const serializedContent = JSON.stringify(content?.content ?? null); assert( serializedContent.includes(rewrittenBody), `正文未完成持久化,未命中 ${rewrittenBody}`, ); assert( !serializedContent.includes(initialBody), `正文仍保留旧文本 ${initialBody}`, ); const pageAggregate = await fetchPageAggregate(context.request, fixture.documentId, fixture.workspaceId); assert( String(pageAggregate?.page?.head?.title ?? "").trim() === renamedTitle, `page aggregate 标题未更新为 ${renamedTitle}`, ); assert( JSON.stringify(pageAggregate?.page?.body?.content ?? null).includes(rewrittenBody), `page aggregate 正文未更新为 ${rewrittenBody}`, ); console.log( JSON.stringify( { ok: true, baseUrl: BASE_URL, viewerUserId: viewer.userId, workspaceId: fixture.workspaceId, documentId: fixture.documentId, renamedTitle, rewrittenBody, insertToolSequence: insertRun.events .filter((event) => event.type === "tool_result") .map((event) => event.data?.tool ?? null) .filter(Boolean), renameToolSequence: renameEvents .filter((event) => event.type === "tool_result") .map((event) => event.data?.tool ?? null) .filter(Boolean), rewriteToolSequence: rewriteEvents .filter((event) => event.type === "tool_result") .map((event) => event.data?.tool ?? null) .filter(Boolean), }, null, 2, ), ); } catch (error) { caughtError = error; } finally { if (fixture?.documentId) { try { await purgeDocument(context.request, fixture.documentId); } catch (cleanupError) { if (!caughtError) { caughtError = cleanupError; } else { console.error( `清理临时页面失败:${ cleanupError instanceof Error ? cleanupError.stack || cleanupError.message : String(cleanupError) }`, ); } } } await page.close().catch(() => undefined); await context.close().catch(() => undefined); await browser.close().catch(() => undefined); } if (caughtError) { throw caughtError; } } main().catch((error) => { console.error(error instanceof Error ? error.stack || error.message : String(error)); process.exit(1); });