Files
mnote/scripts/task111-phase7-document-ai-online-smoke.js
T

602 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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);
});