feat: land page aggregate and phase7 document ai mainline

- 收口 page aggregate 读取、本地状态与命令客户端\n- 接入 phase7 document ai sidecar 与前端编排入口\n- 更新 architecture 与 design 状态迁移
This commit is contained in:
lix-2026
2026-04-23 07:38:34 +08:00
parent 8353aea2f9
commit 41e958769e
93 changed files with 8778 additions and 2222 deletions
+14 -5
View File
@@ -8,7 +8,7 @@
const { chromium } = require("playwright");
const BASE_URL = process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000";
const MNOTE_WEB_BASE_URL = process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3104";
const MNOTE_WEB_BASE_URL = process.env.MNOTE_WEB_SMOKE_BASE_URL || "";
const REQUEST_TIMEOUT_MS = 20_000;
const UI_TIMEOUT_MS = 30_000;
const TEST_LOGIN_BUTTON_NAME = "测试账号快速登录";
@@ -19,6 +19,14 @@ function assert(condition, message) {
}
}
function requireMnoteWebSmokeBaseUrl() {
assert(
Boolean(MNOTE_WEB_BASE_URL),
"当前 smoke 仅用于 legacy mnote-web tree shell,对应端口已默认退役;如需执行,请显式设置 MNOTE_WEB_SMOKE_BASE_URL。",
);
return MNOTE_WEB_BASE_URL;
}
async function requestJson(requestContext, path, init = {}) {
const response = await requestContext.fetch(`${BASE_URL}${path}`, {
...init,
@@ -110,6 +118,7 @@ async function ensureAuthenticated(page, requestContext) {
}
async function runTreeShellRegression(page, requestContext, viewer, target) {
const runtimeBaseUrl = requireMnoteWebSmokeBaseUrl();
const uniqueSuffix = Date.now().toString();
const rootTitle = `task091-root-${uniqueSuffix}`;
const childTitle = `task091-child-${uniqueSuffix}`;
@@ -132,7 +141,7 @@ async function runTreeShellRegression(page, requestContext, viewer, target) {
const iframe = page.locator('iframe[title="mnote-web tree shell"]');
await iframe.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const iframeSrc = await iframe.getAttribute("src");
assert(iframeSrc && iframeSrc.includes(`${MNOTE_WEB_BASE_URL}/tree`), `Sidebar 未挂载 mnote-web tree shell${iframeSrc}`);
assert(iframeSrc && iframeSrc.includes(`${runtimeBaseUrl}/tree`), `Sidebar 未挂载 mnote-web tree shell${iframeSrc}`);
assert(iframeSrc.includes(`actorId=${encodeURIComponent(viewer.userId)}`), `tree shell 未透传当前用户 actorId${iframeSrc}`);
const getTreeFrame = () => page.frameLocator('iframe[title="mnote-web tree shell"]');
@@ -177,7 +186,7 @@ async function runTreeShellRegression(page, requestContext, viewer, target) {
const createResponsePromise = page.waitForResponse(
(response) =>
response.url().includes(`${MNOTE_WEB_BASE_URL}/api/tree/commands`) &&
response.url().includes(`${runtimeBaseUrl}/api/tree/commands`) &&
response.request().method() === "POST" &&
response.status() === 200 &&
(response.request().postData() || "").includes('"action":"create"'),
@@ -200,7 +209,7 @@ async function runTreeShellRegression(page, requestContext, viewer, target) {
page.once("dialog", (dialog) => dialog.accept(renamedTitle));
const renameResponsePromise = page.waitForResponse(
(response) =>
response.url().includes(`${MNOTE_WEB_BASE_URL}/api/tree/commands`) &&
response.url().includes(`${runtimeBaseUrl}/api/tree/commands`) &&
response.request().method() === "POST" &&
response.status() === 200 &&
(response.request().postData() || "").includes('"action":"rename"'),
@@ -220,7 +229,7 @@ async function runTreeShellRegression(page, requestContext, viewer, target) {
const moveResponsePromise = page.waitForResponse(
(response) =>
response.url().includes(`${MNOTE_WEB_BASE_URL}/api/tree/commands`) &&
response.url().includes(`${runtimeBaseUrl}/api/tree/commands`) &&
response.request().method() === "POST" &&
response.status() === 200 &&
(response.request().postData() || "").includes('"action":"move"'),
-21
View File
@@ -6,7 +6,6 @@
// - 未登录场景允许 "/" 返回 30x 到 "/auth",也允许直接返回 HTML。
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const MNOTE_WEB_BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3104").replace(/\/+$/, "");
const TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_TIMEOUT_MS || 8000);
function assert(condition, message) {
@@ -35,23 +34,6 @@ async function readSnippet(response) {
return text.slice(0, 200);
}
async function probeMnoteWeb() {
try {
const response = await fetchWithTimeout(`${MNOTE_WEB_BASE_URL}/health`, {
method: "GET",
});
return {
reachable: true,
status: response.status,
};
} catch (error) {
return {
reachable: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
async function validateAuthEntry() {
const response = await fetchWithTimeout(`${BASE_URL}/auth`, { method: "GET" });
const contentType = response.headers.get("content-type") || "";
@@ -98,7 +80,6 @@ async function validateRootEntry() {
}
async function main() {
const mnoteWeb = await probeMnoteWeb();
const authEntry = await validateAuthEntry();
const rootEntry = await validateRootEntry();
@@ -107,8 +88,6 @@ async function main() {
{
ok: true,
baseUrl: BASE_URL,
mnoteWebBaseUrl: MNOTE_WEB_BASE_URL,
mnoteWeb,
authEntry,
rootEntry,
},
@@ -10,6 +10,7 @@ const {
ensureAuthenticated,
openDocument,
prepareTempTreeFixture,
requireMnoteWebSmokeBaseUrl,
} = require("./tree-shell-smoke-helpers");
async function runDefaultShellPath(page, fixture) {
@@ -30,9 +31,10 @@ async function runDefaultShellPath(page, fixture) {
}
async function runRuntimeDebugPath(page, fixture) {
const runtimeBaseUrl = requireMnoteWebSmokeBaseUrl();
await page.goto(`${BASE_URL}/api/auth/mnote-web-token`, { waitUntil: "commit", timeout: UI_TIMEOUT_MS });
await page.goto(
`${MNOTE_WEB_BASE_URL}/document-debug?documentId=${fixture.parentId}&workspaceId=${fixture.workspaceId}&host=task103-smoke`,
`${runtimeBaseUrl}/document-debug?documentId=${fixture.parentId}&workspaceId=${fixture.workspaceId}&host=task103-smoke`,
{ waitUntil: "commit", timeout: UI_TIMEOUT_MS },
);
await page.getByText("Document Runtime Debug").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
@@ -70,6 +72,7 @@ async function main() {
workspaceId: fixture.workspaceId,
parentId: fixture.parentId,
childId: fixture.childId,
debugRuntimeBaseUrl: MNOTE_WEB_BASE_URL || null,
primary,
debug,
},
@@ -3,19 +3,20 @@
const { chromium } = require("playwright");
const {
BASE_URL,
MNOTE_WEB_BASE_URL,
UI_TIMEOUT_MS,
assert,
cleanupDocuments,
ensureAuthenticated,
prepareTempTreeFixture,
openDocument,
requireMnoteWebSmokeBaseUrl,
} = require("./tree-shell-smoke-helpers");
async function openRuntimeDebug(page, fixture) {
const runtimeBaseUrl = requireMnoteWebSmokeBaseUrl();
await page.goto(`${BASE_URL}/api/auth/mnote-web-token`, { waitUntil: "commit", timeout: UI_TIMEOUT_MS });
await page.goto(
`${MNOTE_WEB_BASE_URL}/document-debug?documentId=${fixture.parentId}&workspaceId=${fixture.workspaceId}&host=task104-smoke`,
`${runtimeBaseUrl}/document-debug?documentId=${fixture.parentId}&workspaceId=${fixture.workspaceId}&host=task104-smoke`,
{ waitUntil: "commit", timeout: UI_TIMEOUT_MS },
);
await page.getByText("Document Runtime Debug").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
@@ -3,19 +3,20 @@
const { chromium } = require("playwright");
const {
BASE_URL,
MNOTE_WEB_BASE_URL,
UI_TIMEOUT_MS,
assert,
cleanupDocuments,
ensureAuthenticated,
prepareTempTreeFixture,
openDocument,
requireMnoteWebSmokeBaseUrl,
} = require("./tree-shell-smoke-helpers");
async function openRuntimeDebug(page, fixture) {
const runtimeBaseUrl = requireMnoteWebSmokeBaseUrl();
await page.goto(`${BASE_URL}/api/auth/mnote-web-token`, { waitUntil: "commit", timeout: UI_TIMEOUT_MS });
await page.goto(
`${MNOTE_WEB_BASE_URL}/document-debug?documentId=${fixture.parentId}&workspaceId=${fixture.workspaceId}&host=task105-smoke`,
`${runtimeBaseUrl}/document-debug?documentId=${fixture.parentId}&workspaceId=${fixture.workspaceId}&host=task105-smoke`,
{ waitUntil: "commit", timeout: UI_TIMEOUT_MS },
);
await page.getByText("Document Runtime Debug").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
@@ -3,19 +3,20 @@
const { chromium } = require("playwright");
const {
BASE_URL,
MNOTE_WEB_BASE_URL,
UI_TIMEOUT_MS,
assert,
cleanupDocuments,
ensureAuthenticated,
prepareTempTreeFixture,
openDocument,
requireMnoteWebSmokeBaseUrl,
} = require("./tree-shell-smoke-helpers");
async function openRuntimeDebug(page, fixture) {
const runtimeBaseUrl = requireMnoteWebSmokeBaseUrl();
await page.goto(`${BASE_URL}/api/auth/mnote-web-token`, { waitUntil: "commit", timeout: UI_TIMEOUT_MS });
await page.goto(
`${MNOTE_WEB_BASE_URL}/document-debug?documentId=${fixture.parentId}&workspaceId=${fixture.workspaceId}&host=task106-smoke`,
`${runtimeBaseUrl}/document-debug?documentId=${fixture.parentId}&workspaceId=${fixture.workspaceId}&host=task106-smoke`,
{ waitUntil: "commit", timeout: UI_TIMEOUT_MS },
);
await page.getByText("Document Runtime Debug").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
@@ -3,19 +3,20 @@
const { chromium } = require("playwright");
const {
BASE_URL,
MNOTE_WEB_BASE_URL,
UI_TIMEOUT_MS,
assert,
cleanupDocuments,
ensureAuthenticated,
prepareTempTreeFixture,
openDocument,
requireMnoteWebSmokeBaseUrl,
} = require("./tree-shell-smoke-helpers");
async function openRuntimeDebug(page, fixture) {
const runtimeBaseUrl = requireMnoteWebSmokeBaseUrl();
await page.goto(`${BASE_URL}/api/auth/mnote-web-token`, { waitUntil: "commit", timeout: UI_TIMEOUT_MS });
await page.goto(
`${MNOTE_WEB_BASE_URL}/document-debug?documentId=${fixture.parentId}&workspaceId=${fixture.workspaceId}&host=task107-smoke`,
`${runtimeBaseUrl}/document-debug?documentId=${fixture.parentId}&workspaceId=${fixture.workspaceId}&host=task107-smoke`,
{ waitUntil: "commit", timeout: UI_TIMEOUT_MS },
);
await page.getByText("Document Runtime Debug").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
@@ -0,0 +1,601 @@
"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);
});
+10 -1
View File
@@ -1,7 +1,7 @@
"use strict";
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const MNOTE_WEB_BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3104").replace(/\/+$/, "");
const MNOTE_WEB_BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "").replace(/\/+$/, "");
const REQUEST_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_TIMEOUT_MS || 20_000);
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
const TEST_LOGIN_BUTTON_NAME = "测试账号快速登录";
@@ -13,6 +13,14 @@ function assert(condition, message) {
}
}
function requireMnoteWebSmokeBaseUrl() {
assert(
Boolean(MNOTE_WEB_BASE_URL),
"当前 smoke 仅用于 legacy mnote-web debug/runtime,对应端口已默认退役;如需执行,请显式设置 MNOTE_WEB_SMOKE_BASE_URL。",
);
return MNOTE_WEB_BASE_URL;
}
async function requestJson(requestContext, path, init = {}) {
const response = await requestContext.fetch(`${BASE_URL}${path}`, {
...init,
@@ -251,6 +259,7 @@ module.exports = {
REQUEST_TIMEOUT_MS,
UI_TIMEOUT_MS,
assert,
requireMnoteWebSmokeBaseUrl,
cleanupDocuments,
createTempDocument,
ensureAuthenticated,