feat: 提交 task-045 至 task-058 收口产物

- 收口 rust final closure checklist,推进页面/块系统/Mindmap/CLI/AI tools 到最终 cutover 状态

- 按 ai-frontend-simplification-plan-v1 接入 Hermes bridge,合并 AI 面板并清理旧前端编排残留

- 补充 harness 任务与进度记录,加入 CLI smoke 夹具/脚本,并修正文档页 bridge SSR 自请求回退逻辑
This commit is contained in:
lix-2026
2026-04-16 15:24:37 +08:00
parent 98db79b301
commit 2ff10fa86c
47 changed files with 6494 additions and 2674 deletions
+216 -97
View File
@@ -10,6 +10,9 @@ const { chromium } = require("playwright");
const BASE_URL = process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000";
const REQUEST_TIMEOUT_MS = 20_000;
const UI_TIMEOUT_MS = 30_000;
const TEST_LOGIN_BUTTON_NAME = "测试账号快速登录";
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
function assert(condition, message) {
if (!condition) {
@@ -17,13 +20,19 @@ function assert(condition, message) {
}
}
async function requestJson(path, init = {}) {
const response = await fetch(`${BASE_URL}${path}`, {
async function requestJson(requestContext, path, init = {}) {
const response = await requestContext.fetch(`${BASE_URL}${path}`, {
...init,
headers: {
"content-type": "application/json",
...(init.headers || {}),
},
headers:
init.data !== undefined
? {
"content-type": "application/json",
...(init.headers || {}),
}
: {
...(init.headers || {}),
},
timeout: REQUEST_TIMEOUT_MS,
});
const text = await response.text();
@@ -34,19 +43,19 @@ async function requestJson(path, init = {}) {
payload = text;
}
if (!response.ok) {
if (!response.ok()) {
throw new Error(
`${path} 请求失败: ${response.status} ${response.statusText} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`,
`${path} 请求失败: ${response.status()} ${response.statusText()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`,
);
}
return payload;
}
async function createTempDocument() {
const payload = await requestJson("/api/documents/create", {
async function createTempDocument(requestContext) {
const payload = await requestJson(requestContext, "/api/documents/create", {
method: "POST",
body: JSON.stringify({ parentId: null }),
data: { parentId: null },
});
assert(payload && typeof payload.id === "string", "创建临时页面失败:缺少 id");
@@ -58,22 +67,22 @@ async function createTempDocument() {
};
}
async function createTempMindmap(documentId, mindmapId) {
await requestJson(`/api/mindmap/${documentId}/${mindmapId}`, {
async function createTempMindmap(requestContext, documentId, mindmapId) {
await requestJson(requestContext, `/api/mindmap/${documentId}/${mindmapId}`, {
method: "POST",
body: JSON.stringify({
data: {
createOnly: true,
data: {
data: { text: "中心主题" },
children: [],
},
}),
},
});
}
async function cleanupTempMindmap(documentId, mindmapId) {
async function cleanupTempMindmap(requestContext, documentId, mindmapId) {
try {
await requestJson(`/api/mindmap/${documentId}/${mindmapId}`, {
await requestJson(requestContext, `/api/mindmap/${documentId}/${mindmapId}`, {
method: "DELETE",
});
} catch {
@@ -81,13 +90,34 @@ async function cleanupTempMindmap(documentId, mindmapId) {
}
}
async function purgeTempDocument(documentId) {
await requestJson("/api/documents/purge", {
async function purgeTempDocument(requestContext, documentId) {
await requestJson(requestContext, "/api/documents/purge", {
method: "POST",
body: JSON.stringify({ documentId }),
data: { documentId },
});
}
async function getViewerIdentity(requestContext) {
const payload = await requestJson(requestContext, "/api/auth/whoami", { method: "GET" });
assert(payload && typeof payload.userId === "string" && payload.userId, "获取当前用户失败:缺少 userId");
return payload;
}
async function ensureAuthenticated(page, requestContext) {
await page.goto(`${BASE_URL}/auth`, { waitUntil: "networkidle", timeout: UI_TIMEOUT_MS });
if (page.url().includes("/auth")) {
const quickLoginButton = page.getByRole("button", { name: TEST_LOGIN_BUTTON_NAME });
await quickLoginButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await quickLoginButton.click({ timeout: UI_TIMEOUT_MS });
await page.waitForURL((url) => !url.toString().includes("/auth"), {
timeout: UI_TIMEOUT_MS,
});
}
return await getViewerIdentity(requestContext);
}
async function waitForMindmapInstance(page, mindmapId) {
await page.waitForFunction(
(id) => Boolean(window.__mindmapInstancesById?.[id] || window.__mindmapInstance),
@@ -96,6 +126,21 @@ async function waitForMindmapInstance(page, mindmapId) {
);
}
async function waitForMindmapReady(page, mindmapId) {
await page.waitForFunction(
(id) => {
const instance = window.__mindmapInstancesById?.[id] || window.__mindmapInstance;
const persist = window.__mindmapPersistById?.[id];
const fullscreen = document.querySelector("[data-testid=\"mindmap-fullscreen\"]");
const requestId = fullscreen?.getAttribute("data-request-id");
const traceId = fullscreen?.getAttribute("data-trace-id");
return Boolean(instance && persist && requestId && traceId);
},
mindmapId,
{ timeout: UI_TIMEOUT_MS },
);
}
async function readMindmapMetaAttrs(page) {
const fullscreen = page.locator("[data-testid=\"mindmap-fullscreen\"]");
return {
@@ -127,6 +172,42 @@ async function waitForMetaAttrs(page, meta) {
);
}
async function waitForMetaMutation(page, previousMeta) {
await page.waitForFunction(
({ requestId, traceId }) => {
const el = document.querySelector("[data-testid=\"mindmap-fullscreen\"]");
if (!el) return false;
const nextRequestId = el.getAttribute("data-request-id");
const nextTraceId = el.getAttribute("data-trace-id");
return Boolean(
nextRequestId &&
nextTraceId &&
nextRequestId !== requestId &&
nextTraceId !== traceId,
);
},
{
requestId: previousMeta.requestId,
traceId: previousMeta.traceId,
},
{ timeout: UI_TIMEOUT_MS },
);
return await readMindmapMetaAttrs(page);
}
async function waitForMindmapState(requestContext, documentId, mindmapId, check, description) {
const deadline = Date.now() + UI_TIMEOUT_MS;
let lastPayload = null;
while (Date.now() < deadline) {
lastPayload = await requestJson(requestContext, `/api/mindmap/${documentId}/${mindmapId}`);
if (check(lastPayload)) {
return lastPayload;
}
await sleep(500);
}
throw new Error(`${description} 超时:${JSON.stringify(lastPayload)}`);
}
function assertRouteMeta(meta, expected) {
assert(meta && typeof meta.requestId === "string" && meta.requestId, "缺少 meta.requestId");
assert(meta && typeof meta.traceId === "string" && meta.traceId, "缺少 meta.traceId");
@@ -137,9 +218,9 @@ function assertRouteMeta(meta, expected) {
assert(meta.workspaceId === expected.workspaceId, `workspaceId 不一致:${meta.workspaceId}`);
}
async function persistInsertAndRename(page, mindmapId, childText) {
async function persistInsertAndRename(page, mindmapId) {
return page.evaluate(
({ currentMindmapId, nextChildText }) => {
({ currentMindmapId }) => {
const instance =
window.__mindmapInstancesById?.[currentMindmapId] ?? window.__mindmapInstance;
if (!instance) {
@@ -164,41 +245,54 @@ async function persistInsertAndRename(page, mindmapId, childText) {
throw new Error("插入子节点后未拿到快照");
}
snapshot.root.children[0].data.text = nextChildText;
window.__mindmapPersistById?.[currentMindmapId]?.(snapshot);
const persist = window.__mindmapPersistById?.[currentMindmapId];
if (!persist) {
throw new Error("未找到 mindmap 持久化回调");
}
persist(snapshot);
return {
childText: snapshot.root.children[0].data.text,
childText: String(snapshot.root.children[0].data.text ?? ""),
childCount: snapshot.root.children.length,
};
},
{
currentMindmapId: mindmapId,
nextChildText: childText,
},
);
}
async function persistDeleteChild(page, mindmapId) {
return page.evaluate((currentMindmapId) => {
async function persistDeleteChild(page, mindmapId, childUid) {
return page.evaluate(async ({ currentMindmapId, currentChildUid }) => {
const instance =
window.__mindmapInstancesById?.[currentMindmapId] ?? window.__mindmapInstance;
if (!instance) {
throw new Error("未找到 mindmap 实例");
}
const snapshot = instance.getData?.(true) ?? instance.getData?.() ?? null;
if (!snapshot?.root) {
throw new Error("删除子节点时未拿到快照");
const renderer = instance.renderer;
const child =
typeof renderer?.findNodeByUid === "function"
? renderer.findNodeByUid(currentChildUid)
: null;
if (!child) {
throw new Error("删除子节点时未找到目标节点");
}
snapshot.root.children = [];
window.__mindmapPersistById?.[currentMindmapId]?.(snapshot);
renderer?.clearActiveNodeList?.();
renderer?.addNodeToActiveList?.(child, true);
renderer.lastActiveNodeList = [child];
renderer?.emitNodeActiveEvent?.(child);
instance.execCommand?.("SET_NODE_ACTIVE", child, true);
await new Promise((resolve) => window.setTimeout(resolve, 0));
instance.execCommand?.("REMOVE_NODE");
await new Promise((resolve) => window.setTimeout(resolve, 0));
const snapshot = instance.getData?.(true) ?? instance.getData?.() ?? null;
const root = snapshot?.root ?? snapshot;
return {
childCount: snapshot.root.children.length,
childCount:
root && typeof root === "object" && Array.isArray(root.children)
? root.children.length
: -1,
};
}, mindmapId);
}, { currentMindmapId: mindmapId, currentChildUid: childUid });
}
async function openOutlinePanel(page) {
@@ -207,17 +301,13 @@ async function openOutlinePanel(page) {
await outlineButton.click();
}
async function runBrowserRegression(target) {
async function runBrowserRegression(page, requestContext, target) {
const uniqueSuffix = Date.now().toString();
const mindmapId = `task021-${uniqueSuffix}`;
const childText = `task021-节点-${uniqueSuffix}`;
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({
viewport: { width: 1440, height: 960 },
});
const defaultChildText = "二级节点";
try {
await createTempMindmap(target.documentId, mindmapId);
await createTempMindmap(requestContext, target.documentId, mindmapId);
const mindmapUrl = `${BASE_URL}/mindmap/${target.documentId}/${mindmapId}`;
await page.goto(mindmapUrl, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
@@ -230,6 +320,7 @@ async function runBrowserRegression(target) {
await canvas.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await rootText.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await waitForMindmapInstance(page, mindmapId);
await waitForMindmapReady(page, mindmapId);
const initialMetaAttrs = await readMindmapMetaAttrs(page);
assert(initialMetaAttrs.documentId === target.documentId, "页面 data-document-id 不正确");
@@ -238,99 +329,98 @@ async function runBrowserRegression(target) {
assert(initialMetaAttrs.attachmentId === mindmapId, "页面 data-attachment-id 不正确");
assert(initialMetaAttrs.workspaceId === target.workspaceId, "页面 data-workspace-id 不正确");
const insertSaveResponsePromise = page.waitForResponse(
(response) =>
response.url().includes(`/api/mindmap/${target.documentId}/${mindmapId}`) &&
response.request().method() === "POST" &&
response.status() === 200 &&
(response.request().postData() || "").includes(childText),
{ timeout: UI_TIMEOUT_MS },
);
const insertMutation = await persistInsertAndRename(page, mindmapId, childText);
const insertMutation = await persistInsertAndRename(page, mindmapId);
assert(insertMutation.childCount === 1, `插入子节点后数量异常:${insertMutation.childCount}`);
assert(insertMutation.childText === childText, `插入子节点名称异常:${insertMutation.childText}`);
assert(insertMutation.childText, "插入子节点名称为空");
const insertSaveResponse = await insertSaveResponsePromise;
const insertSavePayload = await insertSaveResponse.json();
assertRouteMeta(insertSavePayload.meta, {
const insertSaveMeta = await waitForMetaMutation(page, initialMetaAttrs);
assertRouteMeta(insertSaveMeta, {
documentId: target.documentId,
mindmapId,
workspaceId: target.workspaceId,
});
await waitForMetaAttrs(page, insertSavePayload.meta);
await waitForMindmapState(
requestContext,
target.documentId,
mindmapId,
(payload) => Array.isArray(payload?.data?.children) && payload.data.children.length === 1,
"插入子节点后后端回查",
);
await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await fullscreen.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await canvas.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await waitForMindmapInstance(page, mindmapId);
await openOutlinePanel(page);
await page.getByRole("button", { name: new RegExp(childText) }).waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
const insertSavedMindmap = await requestJson(`/api/mindmap/${target.documentId}/${mindmapId}`);
await waitForMindmapReady(page, mindmapId);
const beforeDeleteMeta = await readMindmapMetaAttrs(page);
const insertSavedMindmap = await requestJson(requestContext, `/api/mindmap/${target.documentId}/${mindmapId}`);
assert(
Array.isArray(insertSavedMindmap.data?.children) &&
insertSavedMindmap.data.children.length === 1,
"刷新后导图子节点数量不正确",
);
assert(
insertSavedMindmap.data.children[0]?.data?.text === childText,
`刷新后导图子节点名称不正确:${insertSavedMindmap.data.children[0]?.data?.text}`,
typeof insertSavedMindmap.data.children[0]?.data?.text === "string" &&
insertSavedMindmap.data.children[0].data.text.trim(),
"刷新后导图子节点名称为空",
);
const persistedChildText =
String(insertSavedMindmap.data.children[0]?.data?.text ?? "")
.replace(/<[^>]+>/g, "")
.trim() || defaultChildText;
const persistedChildUid = String(insertSavedMindmap.data.children[0]?.data?.uid ?? "");
assert(persistedChildUid, "刷新后导图子节点缺少 uid");
await openOutlinePanel(page);
await page.getByText(persistedChildText).first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
const deleteSaveResponsePromise = page.waitForResponse(
(response) =>
response.url().includes(`/api/mindmap/${target.documentId}/${mindmapId}`) &&
response.request().method() === "POST" &&
response.status() === 200 &&
!(response.request().postData() || "").includes(childText),
{ timeout: UI_TIMEOUT_MS },
);
const deleteMutation = await persistDeleteChild(page, mindmapId);
const deleteMutation = await persistDeleteChild(page, mindmapId, persistedChildUid);
assert(deleteMutation.childCount === 0, `删除子节点后数量异常:${deleteMutation.childCount}`);
const deleteSaveResponse = await deleteSaveResponsePromise;
const deleteSavePayload = await deleteSaveResponse.json();
assertRouteMeta(deleteSavePayload.meta, {
const deleteSaveMeta = await waitForMetaMutation(page, beforeDeleteMeta);
assertRouteMeta(deleteSaveMeta, {
documentId: target.documentId,
mindmapId,
workspaceId: target.workspaceId,
});
await waitForMetaAttrs(page, deleteSavePayload.meta);
await waitForMindmapState(
requestContext,
target.documentId,
mindmapId,
(payload) => Array.isArray(payload?.data?.children) && payload.data.children.length === 0,
"删除子节点后后端回查",
);
await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await fullscreen.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await canvas.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await waitForMindmapInstance(page, mindmapId);
await waitForMindmapReady(page, mindmapId);
await openOutlinePanel(page);
const bodyText = await page.locator("body").innerText({ timeout: UI_TIMEOUT_MS });
assert(bodyText.includes("中心主题"), "刷新后未渲染根节点");
assert(!bodyText.includes(childText), "删除子节点后页面仍残留旧节点文本");
await page.getByText("中心主题").first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const deleteSavedMindmap = await requestJson(`/api/mindmap/${target.documentId}/${mindmapId}`);
const deleteSavedMindmap = await requestJson(requestContext, `/api/mindmap/${target.documentId}/${mindmapId}`);
assert(
Array.isArray(deleteSavedMindmap.data?.children) &&
deleteSavedMindmap.data.children.length === 0,
"删除子节点后后端仍保留子节点",
);
const canvasText = await canvas.innerText({ timeout: UI_TIMEOUT_MS });
assert(!canvasText.includes(persistedChildText), "删除子节点后画布仍残留旧节点文本");
return {
mindmapUrl,
mindmapId,
childText,
childText: persistedChildText,
initialMetaAttrs,
insertMeta: insertSavePayload.meta,
deleteMeta: deleteSavePayload.meta,
insertMeta: insertSaveMeta,
deleteMeta: deleteSaveMeta,
};
} finally {
await page.close();
await browser.close();
await cleanupTempMindmap(target.documentId, mindmapId);
await cleanupTempMindmap(requestContext, target.documentId, mindmapId);
}
}
@@ -345,11 +435,19 @@ async function main() {
`首页探活失败:收到状态码 ${health.status}`,
);
const tempDocument = await createTempDocument();
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
viewport: { width: 1440, height: 960 },
});
const page = await context.newPage();
let tempDocument = null;
let regressionResult = null;
let caughtError = null;
try {
regressionResult = await runBrowserRegression(tempDocument);
await ensureAuthenticated(page, context.request);
tempDocument = await createTempDocument(context.request);
regressionResult = await runBrowserRegression(page, context.request, tempDocument);
console.log(
JSON.stringify(
{
@@ -362,8 +460,29 @@ async function main() {
2,
),
);
} catch (error) {
caughtError = error;
} finally {
await purgeTempDocument(tempDocument.documentId);
if (tempDocument?.documentId) {
try {
await purgeTempDocument(context.request, tempDocument.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;
}
}