Files
mnote/scripts/task019-document-ui-regression.js
T

235 lines
8.0 KiB
JavaScript
Raw Normal View History

"use strict";
// 说明:
// - 这是 task-019 的最小真实浏览器回归脚本。
// - 目标只覆盖文档页元信息、Sidebar、BlockNote 保存链,不扩大到 Mindmap / OnlyOffice。
// - 脚本会先创建一篇临时页面,完成回归后再彻底删除,避免污染现有数据。
const { chromium } = require("playwright");
2026-04-15 20:01:12 +08:00
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 = "测试账号快速登录";
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)}`,
);
}
const contentType = response.headers()["content-type"] || "";
if (!contentType.includes("application/json")) {
const snippet = typeof payload === "string" ? payload.slice(0, 200) : JSON.stringify(payload).slice(0, 200);
throw new Error(
`${path} 返回了非 JSON 内容,当前回归脚本需要可直接调用的 API 会话。` +
`如果页面被重定向到 /auth 或返回 HTML,说明前端未启用 MNOTE_DEV_AUTH=1,或当前节点没有带上有效的 Convex Auth 会话。` +
`响应片段:${snippet}`,
);
}
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 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 runBrowserRegression(page, target) {
const uniqueSuffix = Date.now().toString();
const nextTitle = `task019-ui-${uniqueSuffix}`;
const nextBody = `task019 正文保存回归 ${uniqueSuffix}`;
const documentUrl = `${BASE_URL}/documents/${target.documentId}?workspaceId=${encodeURIComponent(target.workspaceId)}`;
await page.goto(documentUrl, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
const sidebarPanel = page.getByText("页面树");
const privateSection = page.getByText("私有 / 我的页面");
const titleInput = page.getByLabel("页面标题");
const editorSurface = page.locator(".wolai-editor [contenteditable=\"true\"]").first();
const saveIndicator = page.locator("text=已保存");
await sidebarPanel.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await privateSection.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await titleInput.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await editorSurface.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await saveIndicator.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await titleInput.fill(nextTitle);
const titleSaveResponse = page.waitForResponse(
(response) =>
response.url().includes("/api/documents/title") &&
response.request().method() === "POST" &&
response.status() === 200,
{ timeout: UI_TIMEOUT_MS },
);
await titleInput.evaluate((node) => {
node.blur();
});
await titleSaveResponse;
const saveResponse = page.waitForResponse(
(response) =>
response.url().includes("/api/documents/save") &&
response.request().method() === "POST" &&
response.status() === 200 &&
(response.request().postData() || "").includes(nextBody),
{ timeout: UI_TIMEOUT_MS },
);
await editorSurface.click({ timeout: UI_TIMEOUT_MS });
await editorSurface.fill(nextBody, { timeout: UI_TIMEOUT_MS });
await saveResponse;
await saveIndicator.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await sidebarPanel.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await privateSection.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await titleInput.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator(`text=${nextBody}`).first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const persistedTitle = await titleInput.inputValue();
assert(persistedTitle === nextTitle, `标题刷新后不一致:期望 ${nextTitle},实际 ${persistedTitle}`);
const editorText = await page.locator(".wolai-editor").innerText({ timeout: UI_TIMEOUT_MS });
assert(editorText.includes(nextBody), "正文刷新后未保留刚写入的内容");
return {
documentUrl,
nextTitle,
nextBody,
};
}
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, 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);
});