- 收口 rust final closure checklist,推进页面/块系统/Mindmap/CLI/AI tools 到最终 cutover 状态 - 按 ai-frontend-simplification-plan-v1 接入 Hermes bridge,合并 AI 面板并清理旧前端编排残留 - 补充 harness 任务与进度记录,加入 CLI smoke 夹具/脚本,并修正文档页 bridge SSR 自请求回退逻辑
235 lines
8.0 KiB
JavaScript
235 lines
8.0 KiB
JavaScript
"use strict";
|
|
|
|
// 说明:
|
|
// - 这是 task-019 的最小真实浏览器回归脚本。
|
|
// - 目标只覆盖文档页元信息、Sidebar、BlockNote 保存链,不扩大到 Mindmap / OnlyOffice。
|
|
// - 脚本会先创建一篇临时页面,完成回归后再彻底删除,避免污染现有数据。
|
|
|
|
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 = "测试账号快速登录";
|
|
|
|
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);
|
|
});
|