602 lines
20 KiB
JavaScript
602 lines
20 KiB
JavaScript
"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/documents/page?documentId=${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?.page ?? 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);
|
|||
|
|
});
|