Files
mnote/scripts/tree-shell-smoke-helpers.js
T

555 lines
17 KiB
JavaScript

"use strict";
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const DOCUMENT_UI_BASE_URL = (
process.env.MNOTE_TREE_SMOKE_DOCUMENT_BASE_URL ||
process.env.MNOTE_LEGACY_UI_BASE_URL ||
BASE_URL
).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 = "测试账号快速登录";
const TEST_USERNAME_PREFIX = "mnote-e2e-";
const TEST_EMAIL = "mnote.e2e@example.com";
const TEST_PASSWORD = "MnoteE2E123!";
function assert(condition, message) {
if (!condition) {
throw new Error(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,
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 内容:${snippet}`);
}
return payload;
}
async function createTempDocument(requestContext, parentId = null) {
const payload = await requestJson(requestContext, "/api/tree/commands", {
method: "POST",
data: {
action: "create",
parentId,
},
});
const result = payload && typeof payload.result === "object" ? payload.result : null;
const documentId =
result && typeof result.documentId === "string"
? result.documentId
: payload && typeof payload.id === "string"
? payload.id
: "";
const workspaceId =
result && typeof result.workspaceId === "string"
? result.workspaceId
: payload && typeof payload.workspace_id === "string"
? payload.workspace_id
: payload && typeof payload.workspaceId === "string"
? payload.workspaceId
: "";
assert(documentId, "创建临时页面失败:缺少 documentId/id");
assert(workspaceId, "创建临时页面失败:缺少 workspaceId/workspace_id");
return {
documentId,
workspaceId,
};
}
async function renameDocument(requestContext, workspaceId, documentId, title) {
await requestJson(requestContext, "/api/documents/title", {
method: "POST",
data: {
workspaceId,
documentId,
title,
commandName: "page.head.updateTitle",
},
});
}
async function purgeDocument(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 tryApiQuickLogin(requestContext) {
await requestJson(requestContext, "/api/auth", {
method: "POST",
data: {
action: "auth:signIn",
args: {
provider: "password",
params: {
email: TEST_EMAIL,
password: TEST_PASSWORD,
flow: "signIn",
},
},
},
});
}
async function waitForAuthenticatedRedirect(page, timeout = 8_000) {
await page.waitForURL((url) => !url.toString().includes("/auth"), {
timeout,
waitUntil: "commit",
});
}
async function isVisible(locator) {
try {
return await locator.evaluateAll((elements) =>
elements.some((element) => {
if (!(element instanceof HTMLElement)) {
return false;
}
const style = window.getComputedStyle(element);
const rect = element.getBoundingClientRect();
return style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0;
}),
);
} catch {
return false;
}
}
async function clickButtonByExactText(page, text) {
await page.evaluate((label) => {
const button = Array.from(document.querySelectorAll("button")).find((candidate) => {
const content = (candidate.textContent || "").trim();
return content === label;
});
if (!(button instanceof HTMLButtonElement)) {
throw new Error(`未找到按钮:${label}`);
}
button.click();
}, text);
}
async function completeUsernameSetupIfNeeded(page) {
const saveButton = page.getByRole("button", { name: "保存并继续" });
if (!(await isVisible(saveButton))) {
return false;
}
const usernameInput = page.locator('input[name="username"]').last();
await usernameInput.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const currentValue = ((await usernameInput.inputValue().catch(() => "")) || "").trim();
const username = currentValue || `${TEST_USERNAME_PREFIX}${Date.now().toString().slice(-6)}`;
await usernameInput.fill(username, { timeout: UI_TIMEOUT_MS });
await saveButton.click({ timeout: UI_TIMEOUT_MS });
await waitForAuthenticatedRedirect(page, UI_TIMEOUT_MS);
return true;
}
async function registerTestAccountIfNeeded(page) {
const switchButton = page.getByRole("button", { name: "还没有账户?立即注册" });
if (!(await isVisible(switchButton))) {
return false;
}
await switchButton.click({ timeout: UI_TIMEOUT_MS });
await page.locator('input[name="account"]').fill(TEST_EMAIL, { timeout: UI_TIMEOUT_MS });
await page.locator('input[name="password"]').fill(TEST_PASSWORD, { timeout: UI_TIMEOUT_MS });
await page.getByRole("button", { name: "注册" }).click({ timeout: UI_TIMEOUT_MS });
try {
await waitForAuthenticatedRedirect(page, UI_TIMEOUT_MS);
return true;
} catch {
return completeUsernameSetupIfNeeded(page);
}
}
async function waitForViewerIdentity(requestContext, attempts = 6) {
let lastError = null;
for (let index = 0; index < attempts; index += 1) {
try {
return await getViewerIdentity(requestContext);
} catch (error) {
lastError = error;
await new Promise((resolve) => setTimeout(resolve, 1000));
}
}
throw lastError instanceof Error ? lastError : new Error("获取当前用户失败");
}
async function ensureAuthenticatedViaUi(page, requestContext) {
await page.goto(`${BASE_URL}/auth`, { waitUntil: "commit", timeout: UI_TIMEOUT_MS });
if (page.url().includes("/auth")) {
const quickLoginButton = page.getByRole("button", { name: TEST_LOGIN_BUTTON_NAME });
const deadline = Date.now() + UI_TIMEOUT_MS;
while (page.url().includes("/auth") && Date.now() < deadline) {
if (await isVisible(quickLoginButton)) {
break;
}
await page.waitForTimeout(200);
}
if (!page.url().includes("/auth")) {
return await waitForViewerIdentity(requestContext);
}
if (!(await isVisible(quickLoginButton))) {
await page.reload({ waitUntil: "commit", timeout: UI_TIMEOUT_MS }).catch(() => undefined);
const retryDeadline = Date.now() + Math.min(8_000, UI_TIMEOUT_MS);
while (page.url().includes("/auth") && Date.now() < retryDeadline) {
if (await isVisible(quickLoginButton)) {
break;
}
await page.waitForTimeout(200);
}
}
if (!page.url().includes("/auth")) {
return await waitForViewerIdentity(requestContext);
}
if (!(await isVisible(quickLoginButton))) {
throw new Error("认证页未出现测试账号快速登录按钮");
}
await quickLoginButton.click({ timeout: UI_TIMEOUT_MS });
try {
await waitForAuthenticatedRedirect(page, UI_TIMEOUT_MS);
} catch {
const completedUsername = await completeUsernameSetupIfNeeded(page);
if (!completedUsername) {
const registered = await registerTestAccountIfNeeded(page);
if (!registered) {
throw new Error("测试账号登录后仍停留在 /auth,且未进入可恢复的用户名/注册流程");
}
}
}
}
return await waitForViewerIdentity(requestContext);
}
async function ensureAuthenticated(page, requestContext) {
const existingViewer = await waitForViewerIdentity(requestContext, 1).catch(() => null);
if (existingViewer) {
return existingViewer;
}
const preferUiAuth = Boolean(process.env.DISPLAY);
if (preferUiAuth) {
try {
return await ensureAuthenticatedViaUi(page, requestContext);
} catch {
// 说明:有图形环境时优先走真实 UI 登录;如果异常,再回退到 API 登录。
}
}
const apiViewer = await (async () => {
try {
await tryApiQuickLogin(requestContext);
return await waitForViewerIdentity(requestContext, 3);
} catch {
return null;
}
})();
if (apiViewer) {
return apiViewer;
}
return await ensureAuthenticatedViaUi(page, requestContext);
}
async function prepareTempTreeFixture(requestContext) {
const uniqueSuffix = Date.now().toString();
const parentTitle = `task-tree-parent-${uniqueSuffix}`;
const childTitle = `task-tree-child-${uniqueSuffix}`;
const parent = await createTempDocument(requestContext, null);
const child = await createTempDocument(requestContext, parent.documentId);
await renameDocument(requestContext, parent.workspaceId, parent.documentId, parentTitle);
await renameDocument(requestContext, parent.workspaceId, child.documentId, childTitle);
return {
uniqueSuffix,
workspaceId: parent.workspaceId,
parentId: parent.documentId,
childId: child.documentId,
parentTitle,
childTitle,
createdIds: [parent.documentId, child.documentId],
};
}
async function cleanupDocuments(requestContext, createdIds) {
for (const documentId of [...createdIds].reverse()) {
await purgeDocument(requestContext, documentId);
}
}
async function openDocument(page, workspaceId, documentId) {
const url = `${DOCUMENT_UI_BASE_URL}/documents/${documentId}?workspaceId=${encodeURIComponent(workspaceId)}`;
const hasSidebarControls = async () => {
const pageTab = page.locator('[data-mnote-sidebar-tree-tab="page"]').first();
const filetreeTab = page.locator('[data-mnote-sidebar-tree-tab="filetree"]').first();
const groupButton = page.getByRole("button", { name: "分组" });
const fileButton = page.getByRole("button", { name: "文件" });
if ((await isVisible(pageTab)) || (await isVisible(filetreeTab))) {
return true;
}
if ((await isVisible(groupButton)) || (await isVisible(fileButton))) {
return true;
}
return await page.evaluate(() => {
const pageShell = document.querySelector('[data-testid="wolai-sidebar-page-tree-shell"]');
const legacyShell = document.querySelector('[data-testid="sidebar-page-tree-shell"]');
return [pageShell, legacyShell].some((node) => {
if (!(node instanceof HTMLElement)) return false;
const style = window.getComputedStyle(node);
const rect = node.getBoundingClientRect();
return !node.hidden && style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0;
});
});
};
const gotoDocument = async () => {
try {
await page.goto(url, { waitUntil: "commit", timeout: UI_TIMEOUT_MS });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (!message.includes("ERR_ABORTED")) {
throw error;
}
await page.waitForTimeout(500);
await page.goto(url, { waitUntil: "commit", timeout: UI_TIMEOUT_MS });
}
};
await gotoDocument();
const firstDeadline = Date.now() + Math.min(UI_TIMEOUT_MS, 10_000);
while (Date.now() < firstDeadline) {
if (await hasSidebarControls()) {
return url;
}
await page.waitForTimeout(250);
}
await page.reload({ waitUntil: "commit", timeout: UI_TIMEOUT_MS }).catch(() => undefined);
const secondDeadline = Date.now() + Math.min(UI_TIMEOUT_MS, 10_000);
while (Date.now() < secondDeadline) {
if (await hasSidebarControls()) {
return url;
}
await page.waitForTimeout(250);
}
return url;
}
async function openSectionView(page) {
const waitForHost = () =>
page.waitForFunction(
() => {
const pagePanel = document.querySelector('[data-mnote-sidebar-tree-panel="page"]');
const pageRoot = document.getElementById("sidebar-tree-root");
const legacyHost = document.querySelector('[data-testid="sidebar-page-tree-shell"]');
const isVisible = (node) =>
node instanceof HTMLElement &&
!node.hidden &&
window.getComputedStyle(node).display !== "none" &&
window.getComputedStyle(node).visibility !== "hidden" &&
node.getClientRects().length > 0;
if (isVisible(legacyHost)) return true;
return isVisible(pageRoot) && (!pagePanel || isVisible(pagePanel));
},
undefined,
{ timeout: UI_TIMEOUT_MS },
);
try {
await waitForHost();
return;
} catch {
// 说明:未处于分组视图时才需要点击切换。
}
for (let attempt = 0; attempt < 2; attempt += 1) {
const switched = await page.evaluate(() => {
const selectors = [
'[data-mnote-sidebar-tree-tab="page"]',
'button[aria-label="分组"]',
];
for (const selector of selectors) {
const node = document.querySelector(selector);
if (node instanceof HTMLElement) {
node.click();
return true;
}
}
return false;
});
if (!switched) {
throw new Error("未找到页面树切换入口");
}
try {
await waitForHost();
return;
} catch (error) {
if (attempt === 1) {
throw error;
}
}
}
}
async function openFilesystemView(page) {
const waitForHost = () =>
page.waitForFunction(
() => {
const filePanel = document.querySelector('[data-mnote-sidebar-tree-panel="filetree"]');
const fileRoot = document.getElementById("sidebar-file-tree-root");
const legacyHost = document.querySelector('[data-testid="sidebar-file-tree-shell"]');
const isVisible = (node) =>
node instanceof HTMLElement &&
!node.hidden &&
window.getComputedStyle(node).display !== "none" &&
window.getComputedStyle(node).visibility !== "hidden" &&
node.getClientRects().length > 0;
if (isVisible(legacyHost)) return true;
return isVisible(fileRoot) && (!filePanel || isVisible(filePanel));
},
undefined,
{ timeout: UI_TIMEOUT_MS },
);
try {
await waitForHost();
return;
} catch {
// 说明:未处于文件视图时才需要点击切换。
}
for (let attempt = 0; attempt < 2; attempt += 1) {
const switched = await page.evaluate(() => {
const selectors = [
'[data-mnote-sidebar-tree-tab="filetree"]',
'button[aria-label="文件"]',
];
for (const selector of selectors) {
const node = document.querySelector(selector);
if (node instanceof HTMLElement) {
node.click();
return true;
}
}
return false;
});
if (!switched) {
throw new Error("未找到文件树切换入口");
}
try {
await waitForHost();
return;
} catch (error) {
if (attempt === 1) {
throw error;
}
}
}
}
async function ensurePageOptionsVisible(page) {
const toggle = page.getByRole("button", { name: /显示页面选项|隐藏页面选项/ });
await toggle.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const readLabel = async () => (await toggle.getAttribute("aria-label")) || "";
const waitUntilExpanded = async () => {
await page.waitForFunction(
() => {
const buttons = Array.from(document.querySelectorAll("button"));
return buttons.some((button) => (button.getAttribute("aria-label") || "").includes("隐藏页面选项"));
},
undefined,
{ timeout: UI_TIMEOUT_MS },
);
};
const label = await readLabel();
if (label.includes("显示")) {
await toggle.click({ timeout: UI_TIMEOUT_MS });
try {
await waitUntilExpanded();
} catch {
await toggle.click({ timeout: UI_TIMEOUT_MS });
await waitUntilExpanded();
}
}
}
module.exports = {
BASE_URL,
DOCUMENT_UI_BASE_URL,
MNOTE_WEB_BASE_URL,
REQUEST_TIMEOUT_MS,
UI_TIMEOUT_MS,
assert,
requireMnoteWebSmokeBaseUrl,
cleanupDocuments,
createTempDocument,
ensureAuthenticated,
ensurePageOptionsVisible,
getViewerIdentity,
openDocument,
openFilesystemView,
openSectionView,
prepareTempTreeFixture,
purgeDocument,
renameDocument,
requestJson,
};