- 收口 rust final closure checklist,推进页面/块系统/Mindmap/CLI/AI tools 到最终 cutover 状态 - 按 ai-frontend-simplification-plan-v1 接入 Hermes bridge,合并 AI 面板并清理旧前端编排残留 - 补充 harness 任务与进度记录,加入 CLI smoke 夹具/脚本,并修正文档页 bridge SSR 自请求回退逻辑
493 lines
17 KiB
JavaScript
493 lines
17 KiB
JavaScript
"use strict";
|
|
|
|
// 说明:
|
|
// - 这是 task-021 的最小真实浏览器回归脚本。
|
|
// - 目标覆盖 Mindmap 全屏页、节点新增/删除、保存链与 requestId/traceId 元信息同步。
|
|
// - 脚本会创建临时页面和临时导图,回归结束后清理,避免污染现有数据。
|
|
|
|
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) {
|
|
throw new Error(message);
|
|
}
|
|
}
|
|
|
|
async function requestJson(requestContext, path, init = {}) {
|
|
const response = await requestContext.fetch(`${BASE_URL}${path}`, {
|
|
...init,
|
|
headers:
|
|
init.data !== undefined
|
|
? {
|
|
"content-type": "application/json",
|
|
...(init.headers || {}),
|
|
}
|
|
: {
|
|
...(init.headers || {}),
|
|
},
|
|
timeout: REQUEST_TIMEOUT_MS,
|
|
});
|
|
|
|
const text = await response.text();
|
|
let payload = null;
|
|
try {
|
|
payload = text ? JSON.parse(text) : null;
|
|
} catch {
|
|
payload = text;
|
|
}
|
|
|
|
if (!response.ok()) {
|
|
throw new Error(
|
|
`${path} 请求失败: ${response.status()} ${response.statusText()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`,
|
|
);
|
|
}
|
|
|
|
return payload;
|
|
}
|
|
|
|
async function createTempDocument(requestContext) {
|
|
const payload = await requestJson(requestContext, "/api/documents/create", {
|
|
method: "POST",
|
|
data: { parentId: null },
|
|
});
|
|
|
|
assert(payload && typeof payload.id === "string", "创建临时页面失败:缺少 id");
|
|
assert(payload && typeof payload.workspace_id === "string", "创建临时页面失败:缺少 workspace_id");
|
|
|
|
return {
|
|
documentId: payload.id,
|
|
workspaceId: payload.workspace_id,
|
|
};
|
|
}
|
|
|
|
async function createTempMindmap(requestContext, documentId, mindmapId) {
|
|
await requestJson(requestContext, `/api/mindmap/${documentId}/${mindmapId}`, {
|
|
method: "POST",
|
|
data: {
|
|
createOnly: true,
|
|
data: {
|
|
data: { text: "中心主题" },
|
|
children: [],
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
async function cleanupTempMindmap(requestContext, documentId, mindmapId) {
|
|
try {
|
|
await requestJson(requestContext, `/api/mindmap/${documentId}/${mindmapId}`, {
|
|
method: "DELETE",
|
|
});
|
|
} catch {
|
|
// 忽略清理失败,继续尝试 purge 文档。
|
|
}
|
|
}
|
|
|
|
async function purgeTempDocument(requestContext, documentId) {
|
|
await requestJson(requestContext, "/api/documents/purge", {
|
|
method: "POST",
|
|
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),
|
|
mindmapId,
|
|
{ timeout: UI_TIMEOUT_MS },
|
|
);
|
|
}
|
|
|
|
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 {
|
|
documentId: await fullscreen.getAttribute("data-document-id"),
|
|
pageId: await fullscreen.getAttribute("data-page-id"),
|
|
attachmentId: await fullscreen.getAttribute("data-attachment-id"),
|
|
mindmapId: await fullscreen.getAttribute("data-mindmap-id"),
|
|
workspaceId: await fullscreen.getAttribute("data-workspace-id"),
|
|
requestId: await fullscreen.getAttribute("data-request-id"),
|
|
traceId: await fullscreen.getAttribute("data-trace-id"),
|
|
};
|
|
}
|
|
|
|
async function waitForMetaAttrs(page, meta) {
|
|
await page.waitForFunction(
|
|
({ requestId, traceId }) => {
|
|
const el = document.querySelector("[data-testid=\"mindmap-fullscreen\"]");
|
|
if (!el) return false;
|
|
return (
|
|
el.getAttribute("data-request-id") === requestId &&
|
|
el.getAttribute("data-trace-id") === traceId
|
|
);
|
|
},
|
|
{
|
|
requestId: meta.requestId,
|
|
traceId: meta.traceId,
|
|
},
|
|
{ timeout: UI_TIMEOUT_MS },
|
|
);
|
|
}
|
|
|
|
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");
|
|
assert(meta.documentId === expected.documentId, `documentId 不一致:${meta.documentId}`);
|
|
assert(meta.pageId === expected.documentId, `pageId 不一致:${meta.pageId}`);
|
|
assert(meta.mindmapId === expected.mindmapId, `mindmapId 不一致:${meta.mindmapId}`);
|
|
assert(meta.attachmentId === expected.mindmapId, `attachmentId 不一致:${meta.attachmentId}`);
|
|
assert(meta.workspaceId === expected.workspaceId, `workspaceId 不一致:${meta.workspaceId}`);
|
|
}
|
|
|
|
async function persistInsertAndRename(page, mindmapId) {
|
|
return page.evaluate(
|
|
({ currentMindmapId }) => {
|
|
const instance =
|
|
window.__mindmapInstancesById?.[currentMindmapId] ?? window.__mindmapInstance;
|
|
if (!instance) {
|
|
throw new Error("未找到 mindmap 实例");
|
|
}
|
|
|
|
const renderer = instance.renderer;
|
|
const root = renderer?.root ?? renderer?.renderTree?._node;
|
|
if (!root) {
|
|
throw new Error("未找到根节点");
|
|
}
|
|
|
|
renderer?.clearActiveNodeList?.();
|
|
renderer?.addNodeToActiveList?.(root, true);
|
|
renderer.lastActiveNodeList = [root];
|
|
renderer?.emitNodeActiveEvent?.(root);
|
|
instance.execCommand?.("SET_NODE_ACTIVE", root, true);
|
|
instance.execCommand?.("INSERT_CHILD_NODE", false, [root]);
|
|
|
|
const snapshot = instance.getData?.(true) ?? instance.getData?.() ?? null;
|
|
if (!snapshot?.root?.children?.[0]?.data) {
|
|
throw new Error("插入子节点后未拿到快照");
|
|
}
|
|
|
|
const persist = window.__mindmapPersistById?.[currentMindmapId];
|
|
if (!persist) {
|
|
throw new Error("未找到 mindmap 持久化回调");
|
|
}
|
|
persist(snapshot);
|
|
return {
|
|
childText: String(snapshot.root.children[0].data.text ?? ""),
|
|
childCount: snapshot.root.children.length,
|
|
};
|
|
},
|
|
{
|
|
currentMindmapId: mindmapId,
|
|
},
|
|
);
|
|
}
|
|
|
|
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 renderer = instance.renderer;
|
|
const child =
|
|
typeof renderer?.findNodeByUid === "function"
|
|
? renderer.findNodeByUid(currentChildUid)
|
|
: null;
|
|
if (!child) {
|
|
throw new Error("删除子节点时未找到目标节点");
|
|
}
|
|
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:
|
|
root && typeof root === "object" && Array.isArray(root.children)
|
|
? root.children.length
|
|
: -1,
|
|
};
|
|
}, { currentMindmapId: mindmapId, currentChildUid: childUid });
|
|
}
|
|
|
|
async function openOutlinePanel(page) {
|
|
const outlineButton = page.getByRole("button", { name: "大纲" });
|
|
await outlineButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
await outlineButton.click();
|
|
}
|
|
|
|
async function runBrowserRegression(page, requestContext, target) {
|
|
const uniqueSuffix = Date.now().toString();
|
|
const mindmapId = `task021-${uniqueSuffix}`;
|
|
const defaultChildText = "二级节点";
|
|
|
|
try {
|
|
await createTempMindmap(requestContext, target.documentId, mindmapId);
|
|
|
|
const mindmapUrl = `${BASE_URL}/mindmap/${target.documentId}/${mindmapId}`;
|
|
await page.goto(mindmapUrl, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
|
|
|
const fullscreen = page.locator("[data-testid=\"mindmap-fullscreen\"]");
|
|
const canvas = page.locator("[data-testid=\"mindmap-canvas\"]");
|
|
const rootText = page.getByText("中心主题").first();
|
|
|
|
await fullscreen.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
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 不正确");
|
|
assert(initialMetaAttrs.pageId === target.documentId, "页面 data-page-id 不正确");
|
|
assert(initialMetaAttrs.mindmapId === mindmapId, "页面 data-mindmap-id 不正确");
|
|
assert(initialMetaAttrs.attachmentId === mindmapId, "页面 data-attachment-id 不正确");
|
|
assert(initialMetaAttrs.workspaceId === target.workspaceId, "页面 data-workspace-id 不正确");
|
|
|
|
const insertMutation = await persistInsertAndRename(page, mindmapId);
|
|
assert(insertMutation.childCount === 1, `插入子节点后数量异常:${insertMutation.childCount}`);
|
|
assert(insertMutation.childText, "插入子节点后名称为空");
|
|
|
|
const insertSaveMeta = await waitForMetaMutation(page, initialMetaAttrs);
|
|
assertRouteMeta(insertSaveMeta, {
|
|
documentId: target.documentId,
|
|
mindmapId,
|
|
workspaceId: target.workspaceId,
|
|
});
|
|
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 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(
|
|
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 deleteMutation = await persistDeleteChild(page, mindmapId, persistedChildUid);
|
|
assert(deleteMutation.childCount === 0, `删除子节点后数量异常:${deleteMutation.childCount}`);
|
|
|
|
const deleteSaveMeta = await waitForMetaMutation(page, beforeDeleteMeta);
|
|
assertRouteMeta(deleteSaveMeta, {
|
|
documentId: target.documentId,
|
|
mindmapId,
|
|
workspaceId: target.workspaceId,
|
|
});
|
|
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);
|
|
|
|
await page.getByText("中心主题").first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
|
|
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: persistedChildText,
|
|
initialMetaAttrs,
|
|
insertMeta: insertSaveMeta,
|
|
deleteMeta: deleteSaveMeta,
|
|
};
|
|
} finally {
|
|
await cleanupTempMindmap(requestContext, target.documentId, mindmapId);
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
const health = await fetch(`${BASE_URL}/`, {
|
|
method: "HEAD",
|
|
redirect: "manual",
|
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
});
|
|
assert(
|
|
[200, 307, 308].includes(health.status),
|
|
`首页探活失败:收到状态码 ${health.status}`,
|
|
);
|
|
|
|
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 {
|
|
await ensureAuthenticated(page, context.request);
|
|
tempDocument = await createTempDocument(context.request);
|
|
regressionResult = await runBrowserRegression(page, context.request, tempDocument);
|
|
console.log(
|
|
JSON.stringify(
|
|
{
|
|
ok: true,
|
|
workspaceId: tempDocument.workspaceId,
|
|
documentId: tempDocument.documentId,
|
|
...regressionResult,
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
);
|
|
} catch (error) {
|
|
caughtError = error;
|
|
} finally {
|
|
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;
|
|
}
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
|
process.exit(1);
|
|
});
|