2026-04-26 04:29:23 +08:00
|
|
|
"use strict";
|
|
|
|
|
|
|
|
|
|
// 说明:
|
|
|
|
|
// - 这份 smoke 用来覆盖 tree rust family checklist 第 9 节的通用回归要求。
|
2026-04-29 12:24:44 +08:00
|
|
|
// - legacy React island 负责验证真实导航、上下文菜单、picker 对话框;3000 Rust gateway 负责 API 与 stream。
|
2026-04-26 04:29:23 +08:00
|
|
|
// - `/api/tree/shell` 直连页已退到显式 debug/internal 边界,默认不再进入这条链路。
|
|
|
|
|
|
|
|
|
|
const { chromium } = require("playwright");
|
|
|
|
|
const {
|
|
|
|
|
BASE_URL,
|
2026-04-29 12:24:44 +08:00
|
|
|
DOCUMENT_UI_BASE_URL,
|
2026-04-26 04:29:23 +08:00
|
|
|
UI_TIMEOUT_MS,
|
|
|
|
|
assert,
|
|
|
|
|
createTempDocument,
|
|
|
|
|
ensureAuthenticated,
|
|
|
|
|
ensurePageOptionsVisible,
|
|
|
|
|
openDocument,
|
|
|
|
|
openFilesystemView,
|
|
|
|
|
openSectionView,
|
|
|
|
|
purgeDocument,
|
|
|
|
|
renameDocument,
|
|
|
|
|
requestJson,
|
|
|
|
|
} = require("./tree-shell-smoke-helpers");
|
|
|
|
|
|
|
|
|
|
const RUN_DIRECT_TREE_SHELL_CHECKS =
|
|
|
|
|
String(process.env.MNOTE_TREE_SHELL_DIRECT_SMOKE || "").trim() === "1";
|
2026-04-28 16:30:51 +08:00
|
|
|
const ALLOW_LEGACY_TREE_SHELL_IFRAME_HOST =
|
|
|
|
|
String(process.env.NEXT_PUBLIC_TREE_SHELL_LEGACY_IFRAME_HOST || "").trim() === "1";
|
2026-04-29 12:24:44 +08:00
|
|
|
const CHROMIUM_STABLE_ARGS = ["--disable-dev-shm-usage", "--disable-gpu"];
|
2026-04-26 04:29:23 +08:00
|
|
|
|
|
|
|
|
function buildTreeShellUrl(workspaceId, params = {}) {
|
|
|
|
|
const search = new URLSearchParams({
|
|
|
|
|
workspaceId,
|
|
|
|
|
...Object.fromEntries(
|
|
|
|
|
Object.entries(params).filter(([, value]) => value !== undefined && value !== null && value !== ""),
|
|
|
|
|
),
|
|
|
|
|
});
|
|
|
|
|
return `${BASE_URL}/api/tree/shell?${search.toString()}`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-27 10:27:15 +08:00
|
|
|
function treeHostImplementationUsesIframe(implementation) {
|
2026-04-28 16:30:51 +08:00
|
|
|
return (
|
|
|
|
|
implementation === "mnote_web_iframe_proxy" ||
|
|
|
|
|
implementation === "rust_inline_compat_host" ||
|
|
|
|
|
implementation === "rust_runtime_artifact_host"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function assertDefaultHostIsDomWasm(implementation, surfaceTestId) {
|
|
|
|
|
if (treeHostImplementationUsesIframe(implementation) && !ALLOW_LEGACY_TREE_SHELL_IFRAME_HOST) {
|
|
|
|
|
throw new Error(
|
|
|
|
|
`${surfaceTestId} 默认主路径不能再使用 legacy iframe host: ${implementation || "unknown"}`,
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-04-27 10:27:15 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function waitForInlineTreeShellReady(page, surfaceTestId) {
|
|
|
|
|
await page.waitForFunction(
|
|
|
|
|
(testId) => {
|
|
|
|
|
const host = document.querySelector(`[data-testid="${testId}"]`);
|
|
|
|
|
if (!(host instanceof HTMLElement) || host.getClientRects().length === 0) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
const implementation = host.getAttribute("data-tree-host-implementation") || "";
|
2026-04-28 16:30:51 +08:00
|
|
|
if (
|
|
|
|
|
implementation === "mnote_web_iframe_proxy" ||
|
|
|
|
|
implementation === "rust_inline_compat_host" ||
|
|
|
|
|
implementation === "rust_runtime_artifact_host"
|
|
|
|
|
) {
|
|
|
|
|
const iframe = host.querySelector(`[data-testid="${testId}-rust-iframe"]`);
|
|
|
|
|
if (!(iframe instanceof HTMLIFrameElement) || iframe.getClientRects().length === 0) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
const doc = iframe.contentDocument;
|
|
|
|
|
const status = doc?.querySelector("#tree-shell-status")?.textContent ?? "";
|
|
|
|
|
return Boolean(doc?.querySelector("#tree-shell-state")) && status.includes("就绪");
|
2026-04-27 10:27:15 +08:00
|
|
|
}
|
2026-04-28 16:30:51 +08:00
|
|
|
const domHost = host.querySelector(`[data-testid="${testId}-dom-host"]`);
|
|
|
|
|
return (
|
|
|
|
|
domHost instanceof HTMLElement &&
|
|
|
|
|
domHost.getClientRects().length > 0 &&
|
|
|
|
|
domHost.getAttribute("data-tree-browser-bridge") === "dom_wasm" &&
|
|
|
|
|
domHost.getAttribute("data-tree-dom-shell-ready") === "true"
|
|
|
|
|
);
|
2026-04-27 10:27:15 +08:00
|
|
|
},
|
|
|
|
|
surfaceTestId,
|
|
|
|
|
{ timeout: UI_TIMEOUT_MS },
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-26 04:29:23 +08:00
|
|
|
async function renameFixtureDocuments(requestContext, fixture) {
|
|
|
|
|
await renameDocument(requestContext, fixture.workspaceId, fixture.parentId, fixture.parentTitle);
|
|
|
|
|
await renameDocument(requestContext, fixture.workspaceId, fixture.childAId, fixture.childATitle);
|
|
|
|
|
await renameDocument(requestContext, fixture.workspaceId, fixture.childBId, fixture.childBTitle);
|
|
|
|
|
await renameDocument(requestContext, fixture.workspaceId, fixture.targetId, fixture.targetTitle);
|
|
|
|
|
for (let index = 0; index < fixture.extraChildIds.length; index += 1) {
|
|
|
|
|
await renameDocument(
|
|
|
|
|
requestContext,
|
|
|
|
|
fixture.workspaceId,
|
|
|
|
|
fixture.extraChildIds[index],
|
|
|
|
|
fixture.extraChildTitles[index],
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function prepareFixture(requestContext) {
|
|
|
|
|
const uniqueSuffix = Date.now().toString();
|
|
|
|
|
const parent = await createTempDocument(requestContext, null);
|
|
|
|
|
const childA = await createTempDocument(requestContext, parent.documentId);
|
|
|
|
|
const childB = await createTempDocument(requestContext, parent.documentId);
|
|
|
|
|
const target = await createTempDocument(requestContext, null);
|
|
|
|
|
const extraChildren = [];
|
|
|
|
|
const extraChildTitles = [];
|
|
|
|
|
for (let index = 0; index < 18; index += 1) {
|
|
|
|
|
const nextChild = await createTempDocument(requestContext, parent.documentId);
|
|
|
|
|
extraChildren.push(nextChild.documentId);
|
|
|
|
|
extraChildTitles.push(`task112-scroll-${index.toString().padStart(2, "0")}-${uniqueSuffix}`);
|
|
|
|
|
}
|
|
|
|
|
const fixture = {
|
|
|
|
|
workspaceId: parent.workspaceId,
|
|
|
|
|
parentId: parent.documentId,
|
|
|
|
|
childAId: childA.documentId,
|
|
|
|
|
childBId: childB.documentId,
|
|
|
|
|
targetId: target.documentId,
|
|
|
|
|
extraChildIds: extraChildren,
|
|
|
|
|
extraChildTitles,
|
|
|
|
|
parentTitle: `task112-parent-${uniqueSuffix}`,
|
|
|
|
|
childATitle: `task112-child-a-${uniqueSuffix}`,
|
|
|
|
|
childBTitle: `task112-child-b-${uniqueSuffix}`,
|
|
|
|
|
targetTitle: `task112-target-${uniqueSuffix}`,
|
|
|
|
|
fallbackTitle: `task112-fallback-${uniqueSuffix}`,
|
|
|
|
|
createdIds: [
|
|
|
|
|
parent.documentId,
|
|
|
|
|
childA.documentId,
|
|
|
|
|
childB.documentId,
|
|
|
|
|
target.documentId,
|
|
|
|
|
...extraChildren,
|
|
|
|
|
],
|
|
|
|
|
};
|
|
|
|
|
await renameFixtureDocuments(requestContext, fixture);
|
|
|
|
|
return fixture;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function cleanupFixture(requestContext, fixture) {
|
|
|
|
|
for (const documentId of [...fixture.createdIds].reverse()) {
|
|
|
|
|
try {
|
|
|
|
|
await purgeDocument(requestContext, documentId);
|
|
|
|
|
} catch {
|
|
|
|
|
// 说明:部分 smoke 会真实移动节点或由父级 purge 级联删除,这里忽略重复清理错误。
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function waitForUrlContains(page, fragment) {
|
|
|
|
|
await page.waitForURL((url) => url.toString().includes(fragment), {
|
|
|
|
|
timeout: UI_TIMEOUT_MS,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function waitForTextAnywhere(page, expectedText) {
|
|
|
|
|
await page.waitForFunction(
|
|
|
|
|
(text) => {
|
|
|
|
|
const bodyText = document.body?.textContent ?? "";
|
|
|
|
|
if (bodyText.includes(text)) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
return Array.from(document.querySelectorAll("iframe")).some((frame) => {
|
|
|
|
|
try {
|
|
|
|
|
return (frame.contentDocument?.body?.textContent ?? "").includes(text);
|
|
|
|
|
} catch {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
expectedText,
|
|
|
|
|
{ timeout: UI_TIMEOUT_MS },
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function getPageTreeHostDriver(page) {
|
|
|
|
|
const host = page.getByTestId("sidebar-page-tree-shell");
|
|
|
|
|
await host.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await page.waitForFunction(
|
|
|
|
|
() => {
|
|
|
|
|
const element = document.querySelector('[data-testid="sidebar-page-tree-shell"]');
|
|
|
|
|
if (!(element instanceof HTMLElement) || element.getClientRects().length === 0) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
const implementation = element.getAttribute("data-tree-host-implementation") || "";
|
2026-04-28 16:30:51 +08:00
|
|
|
if (
|
|
|
|
|
implementation === "mnote_web_iframe_proxy" ||
|
|
|
|
|
implementation === "rust_inline_compat_host" ||
|
|
|
|
|
implementation === "rust_runtime_artifact_host"
|
|
|
|
|
) {
|
2026-04-26 04:29:23 +08:00
|
|
|
const iframe = element.querySelector('[data-testid="sidebar-page-tree-shell-rust-iframe"]');
|
|
|
|
|
return iframe instanceof HTMLIFrameElement && iframe.getClientRects().length > 0;
|
|
|
|
|
}
|
2026-04-28 16:30:51 +08:00
|
|
|
const domHost = element.querySelector('[data-testid="sidebar-page-tree-shell-dom-host"]');
|
|
|
|
|
return (
|
|
|
|
|
domHost instanceof HTMLElement &&
|
|
|
|
|
domHost.getClientRects().length > 0 &&
|
|
|
|
|
domHost.getAttribute("data-tree-browser-bridge") === "dom_wasm" &&
|
|
|
|
|
domHost.getAttribute("data-tree-dom-shell-ready") === "true"
|
|
|
|
|
);
|
2026-04-26 04:29:23 +08:00
|
|
|
},
|
|
|
|
|
undefined,
|
|
|
|
|
{ timeout: UI_TIMEOUT_MS },
|
|
|
|
|
);
|
|
|
|
|
const implementation = await host.getAttribute("data-tree-host-implementation");
|
2026-04-28 16:30:51 +08:00
|
|
|
assertDefaultHostIsDomWasm(implementation, "sidebar-page-tree-shell");
|
2026-04-27 10:27:15 +08:00
|
|
|
if (treeHostImplementationUsesIframe(implementation)) {
|
|
|
|
|
await waitForInlineTreeShellReady(page, "sidebar-page-tree-shell");
|
2026-04-26 04:29:23 +08:00
|
|
|
return {
|
|
|
|
|
kind: "iframe",
|
|
|
|
|
host,
|
|
|
|
|
scope: page.frameLocator('[data-testid="sidebar-page-tree-shell-rust-iframe"]'),
|
|
|
|
|
};
|
|
|
|
|
}
|
2026-04-28 16:30:51 +08:00
|
|
|
await waitForInlineTreeShellReady(page, "sidebar-page-tree-shell");
|
|
|
|
|
return { kind: "dom", host, scope: host };
|
2026-04-26 04:29:23 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function getFileTreeHostDriver(page) {
|
|
|
|
|
const host = page.getByTestId("sidebar-file-tree-shell");
|
|
|
|
|
await host.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await page.waitForFunction(
|
|
|
|
|
() => {
|
|
|
|
|
const element = document.querySelector('[data-testid="sidebar-file-tree-shell"]');
|
|
|
|
|
if (!(element instanceof HTMLElement) || element.getClientRects().length === 0) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
const implementation = element.getAttribute("data-tree-host-implementation") || "";
|
2026-04-28 16:30:51 +08:00
|
|
|
if (
|
|
|
|
|
implementation === "mnote_web_iframe_proxy" ||
|
|
|
|
|
implementation === "rust_inline_compat_host" ||
|
|
|
|
|
implementation === "rust_runtime_artifact_host"
|
|
|
|
|
) {
|
2026-04-26 04:29:23 +08:00
|
|
|
const iframe = element.querySelector('[data-testid="sidebar-file-tree-shell-rust-iframe"]');
|
|
|
|
|
return iframe instanceof HTMLIFrameElement && iframe.getClientRects().length > 0;
|
|
|
|
|
}
|
2026-04-28 16:30:51 +08:00
|
|
|
const domHost = element.querySelector('[data-testid="sidebar-file-tree-shell-dom-host"]');
|
|
|
|
|
return (
|
|
|
|
|
domHost instanceof HTMLElement &&
|
|
|
|
|
domHost.getClientRects().length > 0 &&
|
|
|
|
|
domHost.getAttribute("data-tree-browser-bridge") === "dom_wasm" &&
|
|
|
|
|
domHost.getAttribute("data-tree-dom-shell-ready") === "true"
|
|
|
|
|
);
|
2026-04-26 04:29:23 +08:00
|
|
|
},
|
|
|
|
|
undefined,
|
|
|
|
|
{ timeout: UI_TIMEOUT_MS },
|
|
|
|
|
);
|
|
|
|
|
const implementation = await host.getAttribute("data-tree-host-implementation");
|
2026-04-28 16:30:51 +08:00
|
|
|
assertDefaultHostIsDomWasm(implementation, "sidebar-file-tree-shell");
|
2026-04-27 10:27:15 +08:00
|
|
|
if (treeHostImplementationUsesIframe(implementation)) {
|
|
|
|
|
await waitForInlineTreeShellReady(page, "sidebar-file-tree-shell");
|
2026-04-26 04:29:23 +08:00
|
|
|
return {
|
|
|
|
|
kind: "iframe",
|
|
|
|
|
host,
|
|
|
|
|
scope: page.frameLocator('[data-testid="sidebar-file-tree-shell-rust-iframe"]'),
|
|
|
|
|
};
|
|
|
|
|
}
|
2026-04-28 16:30:51 +08:00
|
|
|
await waitForInlineTreeShellReady(page, "sidebar-file-tree-shell");
|
|
|
|
|
return { kind: "dom", host, scope: host };
|
2026-04-26 04:29:23 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function openPageTreeDocumentFromHost(page, driver, documentId) {
|
|
|
|
|
const popupPromise = page.context().waitForEvent("page", {
|
|
|
|
|
timeout: UI_TIMEOUT_MS,
|
|
|
|
|
}).catch(() => null);
|
|
|
|
|
if (driver.kind === "iframe") {
|
|
|
|
|
const openButton = driver.scope.locator(`.tree-row[data-node-id="${documentId}"] [data-testid="tree-node-open"]`);
|
|
|
|
|
await openButton.waitFor({ state: "attached", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await openButton.evaluate((button) => {
|
|
|
|
|
if (!(button instanceof HTMLButtonElement)) {
|
|
|
|
|
throw new Error("页面树打开按钮不存在");
|
|
|
|
|
}
|
|
|
|
|
button.click();
|
|
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
await driver.scope
|
2026-04-28 16:30:51 +08:00
|
|
|
.locator(`.tree-row[data-shell-mode="page"][data-node-id="${documentId}"] [data-testid="tree-node-open"]`)
|
2026-04-26 04:29:23 +08:00
|
|
|
.click({ timeout: UI_TIMEOUT_MS });
|
|
|
|
|
}
|
|
|
|
|
const popupPage = await popupPromise;
|
|
|
|
|
if (popupPage) {
|
|
|
|
|
await popupPage.close().catch(() => undefined);
|
|
|
|
|
throw new Error("页面树普通打开不应创建新窗口");
|
|
|
|
|
}
|
|
|
|
|
await waitForUrlContains(page, `/documents/${documentId}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function openPageTreeContextMenuFromHost(page, driver, documentId) {
|
|
|
|
|
if (driver.kind === "iframe") {
|
|
|
|
|
const row = driver.scope.locator(`.tree-row[data-node-id="${documentId}"]`);
|
|
|
|
|
await row.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
const menuButton = row.locator('[data-testid="tree-action-menu"]');
|
|
|
|
|
await menuButton.waitFor({ state: "attached", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await menuButton.evaluate((button) => {
|
|
|
|
|
if (!(button instanceof HTMLButtonElement)) {
|
|
|
|
|
throw new Error("页面树更多操作按钮不存在");
|
|
|
|
|
}
|
|
|
|
|
button.click();
|
|
|
|
|
});
|
|
|
|
|
} else {
|
2026-04-28 16:30:51 +08:00
|
|
|
const row = driver.scope.locator(`.tree-row[data-shell-mode="page"][data-node-id="${documentId}"]`);
|
2026-04-26 04:29:23 +08:00
|
|
|
await row.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
2026-04-29 12:24:44 +08:00
|
|
|
const menuButton = row.locator('[data-testid="tree-action-menu"]');
|
|
|
|
|
await menuButton.waitFor({ state: "attached", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await menuButton.evaluate((button) => {
|
|
|
|
|
if (!(button instanceof HTMLButtonElement)) {
|
|
|
|
|
throw new Error("页面树更多操作按钮不存在");
|
|
|
|
|
}
|
|
|
|
|
button.click();
|
|
|
|
|
});
|
2026-04-26 04:29:23 +08:00
|
|
|
}
|
|
|
|
|
await page.getByText("重命名", { exact: true }).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
2026-04-29 12:24:44 +08:00
|
|
|
await page.evaluate(() => {
|
|
|
|
|
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
|
|
|
|
});
|
2026-04-26 04:29:23 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function doubleClickFileTreeDocumentFromHost(page, driver, documentId) {
|
|
|
|
|
const popupPromise = page.context().waitForEvent("page", {
|
|
|
|
|
timeout: UI_TIMEOUT_MS,
|
|
|
|
|
}).catch(() => null);
|
|
|
|
|
if (driver.kind === "iframe") {
|
|
|
|
|
await driver.scope
|
|
|
|
|
.locator(`[data-testid="filetree-doc-row"][data-document-id="${documentId}"]`)
|
|
|
|
|
.dblclick({ timeout: UI_TIMEOUT_MS });
|
|
|
|
|
} else {
|
|
|
|
|
await driver.scope
|
2026-04-28 16:30:51 +08:00
|
|
|
.locator(`[data-testid="filetree-doc-row"][data-document-id="${documentId}"], [data-testid="filetree-doc-row"][data-doc-id="${documentId}"]`)
|
2026-04-26 04:29:23 +08:00
|
|
|
.dblclick({ timeout: UI_TIMEOUT_MS });
|
|
|
|
|
}
|
|
|
|
|
const popupPage = await popupPromise;
|
|
|
|
|
if (popupPage) {
|
|
|
|
|
await popupPage.close().catch(() => undefined);
|
|
|
|
|
throw new Error("文件树普通打开不应创建新窗口");
|
|
|
|
|
}
|
|
|
|
|
await waitForUrlContains(page, `/documents/${documentId}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function waitForPageTitleInput(page) {
|
|
|
|
|
const input = page.getByLabel("页面标题");
|
|
|
|
|
await input.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
return input;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function renameThroughPageHead(page, documentId, title) {
|
|
|
|
|
const saveResponse = page.waitForResponse(
|
|
|
|
|
async (response) => {
|
|
|
|
|
if (!response.url().includes("/api/documents/title") || response.request().method() !== "POST") {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
const payload = response.request().postDataJSON();
|
|
|
|
|
return payload?.documentId === documentId && payload?.title === title && response.ok();
|
|
|
|
|
},
|
|
|
|
|
{ timeout: UI_TIMEOUT_MS },
|
|
|
|
|
);
|
|
|
|
|
const titleInput = await waitForPageTitleInput(page);
|
|
|
|
|
await titleInput.click({ timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await page.keyboard.press("Control+a");
|
|
|
|
|
await page.keyboard.type(title, { delay: 18 });
|
|
|
|
|
await titleInput.blur();
|
|
|
|
|
await saveResponse;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function waitForBreadcrumbTitle(page, title) {
|
|
|
|
|
await page.waitForFunction(
|
|
|
|
|
(expectedTitle) => {
|
|
|
|
|
const nav = document.querySelector("header nav");
|
|
|
|
|
return (nav?.textContent ?? "").includes(expectedTitle);
|
|
|
|
|
},
|
|
|
|
|
title,
|
|
|
|
|
{ timeout: UI_TIMEOUT_MS },
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function waitForSidebarRowTitle(page, documentId, title) {
|
|
|
|
|
const legacyRow = page.locator(`aside a[href="/documents/${documentId}"]`).first();
|
|
|
|
|
if ((await legacyRow.count()) > 0) {
|
|
|
|
|
await page.waitForFunction(
|
|
|
|
|
({ docId, expectedTitle }) => {
|
|
|
|
|
const row = document.querySelector(`aside a[href="/documents/${docId}"]`);
|
|
|
|
|
return (row?.textContent ?? "").includes(expectedTitle);
|
|
|
|
|
},
|
|
|
|
|
{ docId: documentId, expectedTitle: title },
|
|
|
|
|
{ timeout: UI_TIMEOUT_MS },
|
|
|
|
|
);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await waitForPageTreeTitle(page, documentId, title);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function waitForPageTreeTitle(page, documentId, title) {
|
|
|
|
|
await openSectionView(page);
|
|
|
|
|
const driver = await getPageTreeHostDriver(page);
|
|
|
|
|
if (driver.kind === "iframe") {
|
|
|
|
|
await driver.scope
|
|
|
|
|
.locator(`.tree-row[data-node-id="${documentId}"]`)
|
|
|
|
|
.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await driver.scope
|
|
|
|
|
.locator(`.tree-row[data-node-id="${documentId}"]`)
|
|
|
|
|
.getByText(title)
|
|
|
|
|
.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await driver.scope
|
2026-04-28 16:30:51 +08:00
|
|
|
.locator(`.tree-row[data-shell-mode="page"][data-node-id="${documentId}"]`)
|
2026-04-26 04:29:23 +08:00
|
|
|
.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await page.waitForFunction(
|
|
|
|
|
({ nodeId, expectedTitle }) => {
|
2026-04-28 16:30:51 +08:00
|
|
|
const row = document.querySelector(`.tree-row[data-shell-mode="page"][data-node-id="${nodeId}"]`);
|
2026-04-26 04:29:23 +08:00
|
|
|
return (row?.textContent ?? "").includes(expectedTitle);
|
|
|
|
|
},
|
|
|
|
|
{ nodeId: documentId, expectedTitle: title },
|
|
|
|
|
{ timeout: UI_TIMEOUT_MS },
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function waitForPageShellTitle(page, documentId, title) {
|
|
|
|
|
await page.waitForFunction(
|
|
|
|
|
({ nodeId, expectedTitle }) => {
|
|
|
|
|
const row = document.querySelector(`.tree-row[data-shell-mode="page"][data-node-id="${nodeId}"]`);
|
|
|
|
|
return row instanceof HTMLElement && (row.textContent ?? "").includes(expectedTitle);
|
|
|
|
|
},
|
|
|
|
|
{ nodeId: documentId, expectedTitle: title },
|
|
|
|
|
{ timeout: UI_TIMEOUT_MS },
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function waitForFileTreeTitle(page, documentId, title) {
|
|
|
|
|
await openFilesystemView(page);
|
|
|
|
|
const driver = await getFileTreeHostDriver(page);
|
|
|
|
|
if (driver.kind === "iframe") {
|
|
|
|
|
await driver.scope
|
|
|
|
|
.locator(`[data-testid="filetree-doc-row"][data-document-id="${documentId}"]`)
|
|
|
|
|
.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await driver.scope
|
|
|
|
|
.locator(`[data-testid="filetree-doc-row"][data-document-id="${documentId}"]`)
|
|
|
|
|
.getByText(title)
|
|
|
|
|
.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await driver.scope
|
2026-04-28 16:30:51 +08:00
|
|
|
.locator(`[data-testid="filetree-doc-row"][data-document-id="${documentId}"], [data-testid="filetree-doc-row"][data-doc-id="${documentId}"]`)
|
2026-04-26 04:29:23 +08:00
|
|
|
.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await page.waitForFunction(
|
|
|
|
|
({ docId, expectedTitle }) => {
|
2026-04-28 16:30:51 +08:00
|
|
|
const row = document.querySelector(
|
|
|
|
|
`[data-testid="filetree-doc-row"][data-document-id="${docId}"], [data-testid="filetree-doc-row"][data-doc-id="${docId}"]`,
|
|
|
|
|
);
|
2026-04-26 04:29:23 +08:00
|
|
|
return (row?.textContent ?? "").includes(expectedTitle);
|
|
|
|
|
},
|
|
|
|
|
{ docId: documentId, expectedTitle: title },
|
|
|
|
|
{ timeout: UI_TIMEOUT_MS },
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function readPageShellOrder(page, nodeIds) {
|
|
|
|
|
return await page.evaluate((ids) => {
|
|
|
|
|
const rows = ids
|
|
|
|
|
.map((nodeId) => {
|
|
|
|
|
const row = document.querySelector(`.tree-row[data-shell-mode="page"][data-node-id="${nodeId}"]`);
|
|
|
|
|
if (!(row instanceof HTMLElement)) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
const rect = row.getBoundingClientRect();
|
|
|
|
|
return { nodeId, top: rect.top };
|
|
|
|
|
})
|
|
|
|
|
.filter(Boolean);
|
|
|
|
|
return rows.sort((left, right) => left.top - right.top).map((row) => row.nodeId);
|
|
|
|
|
}, nodeIds);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function waitForPageShellOrder(page, expectedOrder) {
|
|
|
|
|
await page.waitForFunction(
|
|
|
|
|
(ids) => {
|
|
|
|
|
const rows = ids
|
|
|
|
|
.map((nodeId) => {
|
|
|
|
|
const row = document.querySelector(`.tree-row[data-shell-mode="page"][data-node-id="${nodeId}"]`);
|
|
|
|
|
if (!(row instanceof HTMLElement)) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
const rect = row.getBoundingClientRect();
|
|
|
|
|
return { nodeId, top: rect.top };
|
|
|
|
|
})
|
|
|
|
|
.filter(Boolean);
|
|
|
|
|
|
|
|
|
|
if (rows.length !== ids.length) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const order = rows.sort((left, right) => left.top - right.top).map((row) => row.nodeId);
|
|
|
|
|
return JSON.stringify(order) === JSON.stringify(ids);
|
|
|
|
|
},
|
|
|
|
|
expectedOrder,
|
|
|
|
|
{ timeout: UI_TIMEOUT_MS },
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function createFileDropDataTransfer(page, fileName, content) {
|
|
|
|
|
return await page.evaluateHandle(
|
|
|
|
|
({ nextFileName, nextContent }) => {
|
|
|
|
|
const dataTransfer = new DataTransfer();
|
|
|
|
|
dataTransfer.items.add(
|
|
|
|
|
new File([nextContent], nextFileName, {
|
|
|
|
|
type: "text/plain",
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
return dataTransfer;
|
|
|
|
|
},
|
|
|
|
|
{ nextFileName: fileName, nextContent: content },
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function dispatchTextDragSequence(page, input) {
|
|
|
|
|
await page.evaluate(({ sourceSelector, targetSelector, entries }) => {
|
|
|
|
|
const source = document.querySelector(sourceSelector);
|
|
|
|
|
const target = document.querySelector(targetSelector);
|
|
|
|
|
if (!(source instanceof HTMLElement) || !(target instanceof HTMLElement)) {
|
|
|
|
|
throw new Error(`拖拽节点不存在: ${sourceSelector} -> ${targetSelector}`);
|
|
|
|
|
}
|
|
|
|
|
const dataTransfer = new DataTransfer();
|
|
|
|
|
entries.forEach(({ type, value }) => {
|
|
|
|
|
dataTransfer.setData(type, value);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const createDragEvent = (type) =>
|
|
|
|
|
new DragEvent(type, {
|
|
|
|
|
bubbles: true,
|
|
|
|
|
cancelable: true,
|
|
|
|
|
dataTransfer,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
source.dispatchEvent(createDragEvent("dragstart"));
|
|
|
|
|
target.dispatchEvent(createDragEvent("dragover"));
|
|
|
|
|
target.dispatchEvent(createDragEvent("drop"));
|
|
|
|
|
source.dispatchEvent(createDragEvent("dragend"));
|
|
|
|
|
}, input);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildSnapshotSseBody(snapshot, workspaceId) {
|
|
|
|
|
return `event: snapshot\ndata: ${JSON.stringify({
|
|
|
|
|
kind: "snapshot",
|
|
|
|
|
stream: "workspace",
|
|
|
|
|
workspaceId,
|
|
|
|
|
rootNodeId: null,
|
|
|
|
|
cursor: "evt_task112_snapshot",
|
|
|
|
|
projection: "sidebar_tree",
|
|
|
|
|
data: snapshot,
|
|
|
|
|
})}\n\n`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function runPageTreeHostChecks(page, fixture) {
|
|
|
|
|
await openDocument(page, fixture.workspaceId, fixture.parentId);
|
|
|
|
|
await openSectionView(page);
|
|
|
|
|
const driver = await getPageTreeHostDriver(page);
|
|
|
|
|
await openPageTreeDocumentFromHost(page, driver, fixture.childAId);
|
|
|
|
|
|
|
|
|
|
await openSectionView(page);
|
|
|
|
|
const nextDriver = await getPageTreeHostDriver(page);
|
|
|
|
|
await openPageTreeContextMenuFromHost(page, nextDriver, fixture.childAId);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
pageTreeNavigate: true,
|
|
|
|
|
pageTreeContextMenu: true,
|
|
|
|
|
hostKind: nextDriver.kind,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function runPageTreeShellChecks(context, fixture) {
|
|
|
|
|
const page = await context.newPage();
|
|
|
|
|
try {
|
|
|
|
|
await page.setViewportSize({ width: 1280, height: 640 });
|
|
|
|
|
await page.goto(
|
|
|
|
|
buildTreeShellUrl(fixture.workspaceId, {
|
|
|
|
|
mode: "page",
|
|
|
|
|
activeDocumentId: fixture.childAId,
|
|
|
|
|
}),
|
|
|
|
|
{ waitUntil: "commit", timeout: UI_TIMEOUT_MS },
|
|
|
|
|
);
|
|
|
|
|
await page.locator("#tree-shell-title").getByText("页面树").waitFor({
|
|
|
|
|
state: "visible",
|
|
|
|
|
timeout: UI_TIMEOUT_MS,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const parentRow = page.locator(`.tree-row[data-shell-mode="page"][data-node-id="${fixture.parentId}"]`);
|
|
|
|
|
const childARow = page.locator(`.tree-row[data-shell-mode="page"][data-node-id="${fixture.childAId}"]`);
|
|
|
|
|
const childBRow = page.locator(`.tree-row[data-shell-mode="page"][data-node-id="${fixture.childBId}"]`);
|
|
|
|
|
await parentRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await childARow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await childBRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
|
|
|
|
|
await childARow.evaluate((element) => {
|
|
|
|
|
if (!(element instanceof HTMLElement) || element.dataset.active !== "true") {
|
|
|
|
|
throw new Error("当前页高亮未落到 activeDocumentId 对应行");
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
await parentRow.evaluate((element) => {
|
|
|
|
|
if (!(element instanceof HTMLElement) || element.getAttribute("aria-expanded") !== "true") {
|
|
|
|
|
throw new Error("当前页祖先未自动展开");
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await childARow.focus();
|
|
|
|
|
await childARow.press("ArrowDown");
|
|
|
|
|
await childBRow.evaluate((element) => {
|
|
|
|
|
if (!(element instanceof HTMLElement) || element.dataset.focused !== "true") {
|
|
|
|
|
throw new Error("ArrowDown 后焦点未移动到下一行");
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
await childBRow.press("ArrowUp");
|
|
|
|
|
await childARow.evaluate((element) => {
|
|
|
|
|
if (!(element instanceof HTMLElement) || element.dataset.focused !== "true") {
|
|
|
|
|
throw new Error("ArrowUp 后焦点未回到上一行");
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await parentRow.focus();
|
|
|
|
|
await parentRow.press("ArrowLeft");
|
|
|
|
|
await childARow.waitFor({ state: "hidden", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await childBRow.waitFor({ state: "hidden", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await parentRow.press("ArrowRight");
|
|
|
|
|
await childARow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await childBRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
|
|
|
|
|
const orderBefore = await readPageShellOrder(page, [fixture.childAId, fixture.childBId]);
|
|
|
|
|
assert(
|
|
|
|
|
JSON.stringify(orderBefore) === JSON.stringify([fixture.childAId, fixture.childBId]),
|
|
|
|
|
`页面树拖拽前顺序异常:${JSON.stringify(orderBefore)}`,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await dispatchTextDragSequence(page, {
|
|
|
|
|
sourceSelector: `.tree-row[data-shell-mode="page"][data-node-id="${fixture.childBId}"]`,
|
|
|
|
|
targetSelector: `.tree-row[data-shell-mode="page"][data-node-id="${fixture.childAId}"]`,
|
|
|
|
|
entries: [
|
|
|
|
|
{ type: "application/x-mnote-page-tree-node", value: fixture.childBId },
|
|
|
|
|
{ type: "text/plain", value: fixture.childBId },
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await waitForPageShellOrder(page, [fixture.childBId, fixture.childAId]);
|
|
|
|
|
const orderAfter = await readPageShellOrder(page, [fixture.childAId, fixture.childBId]);
|
|
|
|
|
assert(
|
|
|
|
|
JSON.stringify(orderAfter) === JSON.stringify([fixture.childBId, fixture.childAId]),
|
|
|
|
|
`页面树拖拽后顺序未更新:${JSON.stringify(orderAfter)}`,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await parentRow.hover({ timeout: UI_TIMEOUT_MS });
|
|
|
|
|
const createResponsePromise = page.waitForResponse(
|
|
|
|
|
async (response) => {
|
|
|
|
|
if (
|
|
|
|
|
!response.url().includes("/api/tree/commands") ||
|
|
|
|
|
response.request().method() !== "POST" ||
|
|
|
|
|
!response.ok()
|
|
|
|
|
) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
const payload = response.request().postDataJSON();
|
|
|
|
|
return payload?.action === "create" && payload?.parentId === fixture.parentId;
|
|
|
|
|
},
|
|
|
|
|
{ timeout: UI_TIMEOUT_MS },
|
|
|
|
|
);
|
|
|
|
|
await parentRow.locator('[data-testid="tree-action-create"]').click({ timeout: UI_TIMEOUT_MS });
|
|
|
|
|
const createResponse = await createResponsePromise;
|
|
|
|
|
const createPayload = await createResponse.json().catch(() => null);
|
|
|
|
|
const createdDocumentId = String(createPayload?.result?.documentId ?? createPayload?.id ?? "").trim();
|
|
|
|
|
assert(createdDocumentId, "页面树新建子页面未返回 documentId");
|
|
|
|
|
fixture.createdIds.push(createdDocumentId);
|
|
|
|
|
await waitForPageShellTitle(page, createdDocumentId, "无标题");
|
|
|
|
|
|
|
|
|
|
const renamedTitle = `task112-created-renamed-${Date.now().toString().slice(-6)}`;
|
|
|
|
|
const createdRow = page.locator(`.tree-row[data-shell-mode="page"][data-node-id="${createdDocumentId}"]`);
|
|
|
|
|
await createdRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await createdRow.hover({ timeout: UI_TIMEOUT_MS });
|
|
|
|
|
const renameDialogPromise = page
|
|
|
|
|
.waitForEvent("dialog", { timeout: UI_TIMEOUT_MS })
|
|
|
|
|
.then((dialog) => dialog.accept(renamedTitle));
|
|
|
|
|
const renameResponsePromise = page.waitForResponse(
|
|
|
|
|
async (response) => {
|
|
|
|
|
if (
|
|
|
|
|
!response.url().includes("/api/tree/commands") ||
|
|
|
|
|
response.request().method() !== "POST" ||
|
|
|
|
|
!response.ok()
|
|
|
|
|
) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
const payload = response.request().postDataJSON();
|
|
|
|
|
return (
|
|
|
|
|
payload?.action === "rename" &&
|
|
|
|
|
payload?.documentId === createdDocumentId &&
|
|
|
|
|
payload?.title === renamedTitle
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
{ timeout: UI_TIMEOUT_MS },
|
|
|
|
|
);
|
|
|
|
|
await createdRow.locator('[data-testid="tree-action-rename"]').click({ timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await renameDialogPromise;
|
|
|
|
|
await renameResponsePromise;
|
|
|
|
|
await waitForPageShellTitle(page, createdDocumentId, renamedTitle);
|
|
|
|
|
|
|
|
|
|
const visibleNodeIds = await page.evaluate(() =>
|
|
|
|
|
Array.from(document.querySelectorAll('.tree-row[data-shell-mode="page"]'))
|
|
|
|
|
.map((element) => element.getAttribute("data-node-id") || "")
|
|
|
|
|
.filter(Boolean),
|
|
|
|
|
);
|
|
|
|
|
assert(visibleNodeIds.length >= 12, `页面树大树夹具数量不足:${visibleNodeIds.length}`);
|
|
|
|
|
const lastVisibleNodeId = visibleNodeIds[visibleNodeIds.length - 1];
|
|
|
|
|
await parentRow.focus();
|
|
|
|
|
for (let index = 1; index < visibleNodeIds.length; index += 1) {
|
|
|
|
|
await page.keyboard.press("ArrowDown");
|
|
|
|
|
}
|
|
|
|
|
await page.waitForFunction(
|
|
|
|
|
(nodeId) => {
|
|
|
|
|
const row = document.querySelector(`.tree-row[data-shell-mode="page"][data-node-id="${nodeId}"]`);
|
|
|
|
|
return row instanceof HTMLElement && row.dataset.focused === "true";
|
|
|
|
|
},
|
|
|
|
|
lastVisibleNodeId,
|
|
|
|
|
{ timeout: UI_TIMEOUT_MS },
|
|
|
|
|
);
|
|
|
|
|
const scrollState = await page.evaluate((nodeId) => {
|
|
|
|
|
const scroller = document.querySelector("#tree-shell-app");
|
|
|
|
|
const row = document.querySelector(`.tree-row[data-shell-mode="page"][data-node-id="${nodeId}"]`);
|
|
|
|
|
if (!(row instanceof HTMLElement)) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
const rowRect = row.getBoundingClientRect();
|
|
|
|
|
const scrollerRect =
|
|
|
|
|
scroller instanceof HTMLElement ? scroller.getBoundingClientRect() : null;
|
|
|
|
|
return {
|
|
|
|
|
scrollTop: scroller instanceof HTMLElement ? scroller.scrollTop : 0,
|
|
|
|
|
scrollHeight: scroller instanceof HTMLElement ? scroller.scrollHeight : 0,
|
|
|
|
|
clientHeight: scroller instanceof HTMLElement ? scroller.clientHeight : 0,
|
|
|
|
|
windowScrollY: window.scrollY,
|
|
|
|
|
viewportHeight: window.innerHeight,
|
|
|
|
|
pageScrollHeight: document.documentElement.scrollHeight,
|
|
|
|
|
rowTop: rowRect.top,
|
|
|
|
|
rowBottom: rowRect.bottom,
|
|
|
|
|
scrollerTop: scrollerRect?.top ?? 0,
|
|
|
|
|
scrollerBottom: scrollerRect?.bottom ?? window.innerHeight,
|
|
|
|
|
};
|
|
|
|
|
}, lastVisibleNodeId);
|
|
|
|
|
assert(scrollState, "页面树滚动状态读取失败");
|
|
|
|
|
const hasOverflow =
|
|
|
|
|
scrollState.scrollHeight > scrollState.clientHeight ||
|
|
|
|
|
scrollState.pageScrollHeight > scrollState.viewportHeight;
|
|
|
|
|
assert(
|
|
|
|
|
hasOverflow,
|
|
|
|
|
`页面树大树场景未形成可滚动内容:${JSON.stringify(scrollState)}`,
|
|
|
|
|
);
|
|
|
|
|
assert(
|
|
|
|
|
scrollState.scrollTop > 0 || scrollState.windowScrollY > 0,
|
|
|
|
|
`页面树滚动未推进:${JSON.stringify(scrollState)}`,
|
|
|
|
|
);
|
|
|
|
|
assert(
|
|
|
|
|
scrollState.rowTop >= -2 &&
|
|
|
|
|
scrollState.rowBottom <= scrollState.viewportHeight + 2,
|
|
|
|
|
`页面树滚动后目标行仍未进入可视区:${JSON.stringify(scrollState)}`,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
pageTreeExpandCollapse: true,
|
|
|
|
|
pageTreeCurrentHighlight: true,
|
|
|
|
|
pageTreeAncestorAutoExpand: true,
|
|
|
|
|
pageTreeKeyboardNavigation: true,
|
|
|
|
|
pageTreeDrag: true,
|
|
|
|
|
pageTreeCreateChild: true,
|
|
|
|
|
pageTreeRename: true,
|
|
|
|
|
pageTreeStableScroll: true,
|
|
|
|
|
orderBefore,
|
|
|
|
|
orderAfter,
|
|
|
|
|
createdDocumentId,
|
|
|
|
|
renamedTitle,
|
|
|
|
|
scrollTop: scrollState.scrollTop,
|
|
|
|
|
windowScrollY: scrollState.windowScrollY,
|
|
|
|
|
visiblePageRowCount: visibleNodeIds.length,
|
|
|
|
|
};
|
|
|
|
|
} finally {
|
|
|
|
|
await page.close().catch(() => undefined);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function runFileTreeShellChecks(context, fixture) {
|
|
|
|
|
const page = await context.newPage();
|
|
|
|
|
try {
|
|
|
|
|
await page.goto(
|
|
|
|
|
buildTreeShellUrl(fixture.workspaceId, {
|
|
|
|
|
mode: "filetree",
|
|
|
|
|
activeDocumentId: fixture.parentId,
|
|
|
|
|
}),
|
|
|
|
|
{ waitUntil: "commit", timeout: UI_TIMEOUT_MS },
|
|
|
|
|
);
|
|
|
|
|
await page.locator("#tree-shell-title").getByText("资源管理器").waitFor({
|
|
|
|
|
state: "visible",
|
|
|
|
|
timeout: UI_TIMEOUT_MS,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const parentRow = page.locator(`[data-testid="filetree-doc-row"][data-document-id="${fixture.parentId}"]`);
|
|
|
|
|
const childARow = page.locator(`[data-testid="filetree-doc-row"][data-document-id="${fixture.childAId}"]`);
|
|
|
|
|
const childBRow = page.locator(`[data-testid="filetree-doc-row"][data-document-id="${fixture.childBId}"]`);
|
|
|
|
|
const targetRow = page.locator(`[data-testid="filetree-doc-row"][data-document-id="${fixture.targetId}"]`);
|
|
|
|
|
await parentRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await childARow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await childBRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await targetRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
|
|
|
|
|
await childARow.click({ timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await childBRow.click({ modifiers: ["Control"], timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await childARow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await childARow.evaluate((element) => {
|
|
|
|
|
if (!(element instanceof HTMLElement) || element.dataset.selected !== "true") {
|
|
|
|
|
throw new Error("多选后 childA 未进入选中态");
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
await childBRow.evaluate((element) => {
|
|
|
|
|
if (!(element instanceof HTMLElement) || element.dataset.selected !== "true") {
|
|
|
|
|
throw new Error("多选后 childB 未进入选中态");
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await parentRow.click({ timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await childBRow.click({ modifiers: ["Shift"], timeout: UI_TIMEOUT_MS });
|
|
|
|
|
const selectedCount = await page.locator('.tree-row[data-shell-mode="filetree"][data-selected="true"]').count();
|
|
|
|
|
assert(selectedCount >= 2, `范围选择后选中数量异常:${selectedCount}`);
|
|
|
|
|
|
|
|
|
|
const fileTreePayload = JSON.stringify({
|
|
|
|
|
type: "mnote-file-tree-dnd",
|
|
|
|
|
version: 1,
|
|
|
|
|
rowIds: [`doc:${fixture.childBId}`],
|
|
|
|
|
});
|
|
|
|
|
await dispatchTextDragSequence(page, {
|
|
|
|
|
sourceSelector: `[data-testid="filetree-doc-row"][data-document-id="${fixture.childBId}"]`,
|
|
|
|
|
targetSelector: `[data-testid="filetree-doc-row"][data-document-id="${fixture.targetId}"]`,
|
|
|
|
|
entries: [
|
|
|
|
|
{ type: "application/x-mnote-filetree-row-ids", value: fileTreePayload },
|
|
|
|
|
{ type: "application/x-mnote-file-tree", value: fileTreePayload },
|
|
|
|
|
{ type: "text/plain", value: fileTreePayload },
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
await page.waitForFunction(
|
|
|
|
|
() => (document.querySelector("#tree-shell-last-action")?.textContent ?? "").includes("已发送移动拖放到"),
|
|
|
|
|
undefined,
|
|
|
|
|
{ timeout: UI_TIMEOUT_MS },
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const dataTransfer = await createFileDropDataTransfer(page, "task112-upload.txt", "task112 external drop");
|
|
|
|
|
await targetRow.dispatchEvent("dragover", { dataTransfer });
|
|
|
|
|
await targetRow.dispatchEvent("drop", { dataTransfer });
|
|
|
|
|
await page.waitForFunction(
|
|
|
|
|
() => (document.querySelector("#tree-shell-last-action")?.textContent ?? "").includes("已发送 1 个外部文件到宿主"),
|
|
|
|
|
undefined,
|
|
|
|
|
{ timeout: UI_TIMEOUT_MS },
|
|
|
|
|
);
|
|
|
|
|
await dataTransfer.dispose();
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
fileTreeMultiSelect: true,
|
|
|
|
|
fileTreeRangeSelect: true,
|
|
|
|
|
fileTreeInternalDrop: true,
|
|
|
|
|
fileTreeExternalDrop: true,
|
|
|
|
|
selectedCount,
|
|
|
|
|
};
|
|
|
|
|
} finally {
|
|
|
|
|
await page.close().catch(() => undefined);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function runFileTreeHostDoubleClickCheck(context, fixture) {
|
|
|
|
|
const page = await context.newPage();
|
|
|
|
|
try {
|
|
|
|
|
await openDocument(page, fixture.workspaceId, fixture.parentId);
|
|
|
|
|
await openFilesystemView(page);
|
|
|
|
|
const driver = await getFileTreeHostDriver(page);
|
|
|
|
|
await doubleClickFileTreeDocumentFromHost(page, driver, fixture.childBId);
|
|
|
|
|
return {
|
|
|
|
|
fileTreeDoubleClickOpen: true,
|
|
|
|
|
hostKind: driver.kind,
|
|
|
|
|
};
|
|
|
|
|
} finally {
|
|
|
|
|
await page.close().catch(() => undefined);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 12:24:44 +08:00
|
|
|
function isPlaywrightTargetCrash(error) {
|
|
|
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
|
|
|
return message.includes("Target crashed");
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-26 04:29:23 +08:00
|
|
|
async function waitForPickerEmptyState(page, dialog, pickerUsesIframe) {
|
|
|
|
|
if (!pickerUsesIframe) {
|
|
|
|
|
const emptyText = dialog.getByText("没有匹配结果");
|
|
|
|
|
await emptyText.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
return "没有匹配结果";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await waitForTextAnywhere(page, "没有可选择的页面。");
|
|
|
|
|
return "没有可选择的页面。";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function waitForPickerInputFocused(page, searchInput) {
|
|
|
|
|
await searchInput.focus();
|
|
|
|
|
await page.waitForFunction(
|
|
|
|
|
() => {
|
|
|
|
|
const active = document.activeElement;
|
|
|
|
|
return active instanceof HTMLInputElement && active.placeholder === "移动到...";
|
|
|
|
|
},
|
|
|
|
|
undefined,
|
|
|
|
|
{ timeout: UI_TIMEOUT_MS },
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function runPickerDialogChecks(context, fixture) {
|
|
|
|
|
const page = await context.newPage();
|
|
|
|
|
try {
|
|
|
|
|
await openDocument(page, fixture.workspaceId, fixture.childAId);
|
|
|
|
|
await waitForPageTitleInput(page);
|
|
|
|
|
await ensurePageOptionsVisible(page);
|
|
|
|
|
await page.getByRole("button", { name: "页面选项", exact: true }).waitFor({
|
|
|
|
|
state: "visible",
|
|
|
|
|
timeout: UI_TIMEOUT_MS,
|
|
|
|
|
});
|
|
|
|
|
const openButton = page.getByRole("button", { name: "移动/嵌入到..." });
|
|
|
|
|
await openButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
const openPickerDialog = async () => {
|
|
|
|
|
await openButton.click({ timeout: UI_TIMEOUT_MS });
|
|
|
|
|
const dialog = page.getByRole("dialog");
|
|
|
|
|
await dialog.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
const pickerSurface = dialog.getByTestId("tree-picker-surface");
|
|
|
|
|
await pickerSurface.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
const searchInput = dialog.getByPlaceholder("移动到...");
|
2026-04-28 16:30:51 +08:00
|
|
|
await waitForInlineTreeShellReady(page, "tree-picker-surface");
|
|
|
|
|
const implementation = await pickerSurface.getAttribute("data-tree-host-implementation");
|
|
|
|
|
assertDefaultHostIsDomWasm(implementation, "tree-picker-surface");
|
|
|
|
|
const pickerUsesIframe =
|
|
|
|
|
treeHostImplementationUsesIframe(implementation) &&
|
|
|
|
|
(await pickerSurface.locator('[data-testid="tree-picker-surface-rust-iframe"]').count()) > 0;
|
2026-04-26 04:29:23 +08:00
|
|
|
return { dialog, pickerSurface, searchInput, pickerUsesIframe };
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const initialDialogState = await openPickerDialog();
|
|
|
|
|
const { dialog, searchInput, pickerUsesIframe } = initialDialogState;
|
|
|
|
|
if (pickerUsesIframe) {
|
|
|
|
|
await page.waitForFunction(
|
|
|
|
|
() => {
|
|
|
|
|
const frame = Array.from(
|
|
|
|
|
document.querySelectorAll('[data-testid="tree-picker-surface-rust-iframe"]'),
|
|
|
|
|
)
|
|
|
|
|
.reverse()
|
|
|
|
|
.find((element) => element instanceof HTMLIFrameElement) ?? null;
|
|
|
|
|
if (!(frame instanceof HTMLIFrameElement)) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
return Boolean(frame.contentDocument?.querySelector('[data-testid="tree-picker-root"]'));
|
|
|
|
|
},
|
|
|
|
|
undefined,
|
|
|
|
|
{ timeout: UI_TIMEOUT_MS },
|
|
|
|
|
);
|
|
|
|
|
assert(
|
|
|
|
|
!(await page.evaluate((nodeId) => {
|
|
|
|
|
const frame = Array.from(
|
|
|
|
|
document.querySelectorAll('[data-testid="tree-picker-surface-rust-iframe"]'),
|
|
|
|
|
)
|
|
|
|
|
.reverse()
|
|
|
|
|
.find((element) => element instanceof HTMLIFrameElement) ?? null;
|
|
|
|
|
if (!(frame instanceof HTMLIFrameElement)) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
return Boolean(frame.contentDocument?.querySelector(`.tree-row[data-node-id="${nodeId}"]`));
|
|
|
|
|
}, fixture.childAId)),
|
|
|
|
|
"picker 空查询态未排除当前页面",
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
await dialog.locator('[data-testid="tree-picker-root"]').waitFor({
|
|
|
|
|
state: "visible",
|
|
|
|
|
timeout: UI_TIMEOUT_MS,
|
|
|
|
|
});
|
|
|
|
|
assert(
|
|
|
|
|
(await dialog.locator(`[data-testid="tree-picker-row"][data-node-id="${fixture.childAId}"]`).count()) === 0,
|
|
|
|
|
"picker 空查询态未排除当前页面",
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
const pickerRootVisible = pickerUsesIframe
|
|
|
|
|
? await page.evaluate(() => {
|
|
|
|
|
const frame = Array.from(
|
|
|
|
|
document.querySelectorAll('[data-testid="tree-picker-surface-rust-iframe"]'),
|
|
|
|
|
)
|
|
|
|
|
.reverse()
|
|
|
|
|
.find((element) => element instanceof HTMLIFrameElement) ?? null;
|
|
|
|
|
if (!(frame instanceof HTMLIFrameElement)) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
return Boolean(frame.contentDocument?.querySelector('[data-testid="tree-picker-root"]'));
|
|
|
|
|
})
|
|
|
|
|
: (await dialog.locator('[data-testid="tree-picker-root"]').count()) > 0;
|
|
|
|
|
|
|
|
|
|
await searchInput.fill(`task112-no-result-${Date.now()}`, { timeout: UI_TIMEOUT_MS });
|
|
|
|
|
const emptyStateText = await waitForPickerEmptyState(page, dialog, pickerUsesIframe);
|
|
|
|
|
await page.keyboard.press("Escape");
|
|
|
|
|
await dialog.waitFor({ state: "hidden", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
|
|
|
|
|
const keyboardDialogState = await openPickerDialog();
|
|
|
|
|
const keyboardDialog = keyboardDialogState.dialog;
|
|
|
|
|
const keyboardInput = keyboardDialogState.searchInput;
|
|
|
|
|
const keyboardUsesIframe = keyboardDialogState.pickerUsesIframe;
|
|
|
|
|
const keyboardQuery = `task112-keyboard-${Date.now()}`;
|
|
|
|
|
await page.route("**/api/search/documents", async (route) => {
|
|
|
|
|
const payload = route.request().postDataJSON();
|
|
|
|
|
if (payload?.query !== keyboardQuery) {
|
|
|
|
|
await route.continue();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
await route.fulfill({
|
|
|
|
|
status: 200,
|
|
|
|
|
contentType: "application/json",
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
results: [
|
|
|
|
|
{
|
|
|
|
|
id: fixture.parentId,
|
|
|
|
|
title: fixture.parentTitle,
|
|
|
|
|
matchField: "title",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: fixture.targetId,
|
|
|
|
|
title: fixture.targetTitle,
|
|
|
|
|
matchField: "title",
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await keyboardInput.fill(keyboardQuery, { timeout: UI_TIMEOUT_MS });
|
|
|
|
|
if (keyboardUsesIframe) {
|
|
|
|
|
await page.waitForFunction(
|
|
|
|
|
(nodeId) => {
|
|
|
|
|
const frame = Array.from(
|
|
|
|
|
document.querySelectorAll('[data-testid="tree-picker-surface-rust-iframe"]'),
|
|
|
|
|
)
|
|
|
|
|
.reverse()
|
|
|
|
|
.find((element) => {
|
|
|
|
|
if (!(element instanceof HTMLIFrameElement)) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
const row = element.contentDocument?.querySelector(`.tree-row[data-node-id="${nodeId}"]`);
|
|
|
|
|
return Boolean(row);
|
|
|
|
|
}) ?? null;
|
|
|
|
|
return Boolean(frame);
|
|
|
|
|
},
|
|
|
|
|
fixture.targetId,
|
|
|
|
|
{ timeout: UI_TIMEOUT_MS },
|
|
|
|
|
);
|
|
|
|
|
} else {
|
2026-04-28 16:30:51 +08:00
|
|
|
await keyboardDialog
|
2026-04-26 04:29:23 +08:00
|
|
|
.locator(`[data-testid="tree-picker-row"][data-node-id="${fixture.targetId}"]`)
|
|
|
|
|
.waitFor({
|
|
|
|
|
state: "visible",
|
|
|
|
|
timeout: UI_TIMEOUT_MS,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const moveRequest = page.waitForResponse(
|
|
|
|
|
async (response) => {
|
|
|
|
|
if (
|
|
|
|
|
!response.url().includes("/api/tree/commands") ||
|
|
|
|
|
response.request().method() !== "POST" ||
|
|
|
|
|
!response.ok()
|
|
|
|
|
) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
const payload = response.request().postDataJSON();
|
|
|
|
|
return payload?.action === "move" && payload?.documentId === fixture.childAId && payload?.parentId === fixture.targetId;
|
|
|
|
|
},
|
|
|
|
|
{ timeout: UI_TIMEOUT_MS },
|
|
|
|
|
);
|
|
|
|
|
if (keyboardUsesIframe) {
|
|
|
|
|
await page.evaluate((nodeId) => {
|
|
|
|
|
const frame = Array.from(
|
|
|
|
|
document.querySelectorAll('[data-testid="tree-picker-surface-rust-iframe"]'),
|
|
|
|
|
)
|
|
|
|
|
.reverse()
|
|
|
|
|
.find((element) => element instanceof HTMLIFrameElement) ?? null;
|
|
|
|
|
if (!(frame instanceof HTMLIFrameElement)) {
|
|
|
|
|
throw new Error("picker iframe 不存在");
|
|
|
|
|
}
|
2026-04-27 10:27:15 +08:00
|
|
|
const escapedNodeId = CSS.escape(nodeId);
|
2026-04-26 04:29:23 +08:00
|
|
|
const button = frame.contentDocument?.querySelector(
|
2026-04-27 10:27:15 +08:00
|
|
|
[
|
|
|
|
|
`.tree-row[data-node-id="${escapedNodeId}"][data-testid="tree-picker-row"]`,
|
|
|
|
|
`.tree-row[data-node-id="${escapedNodeId}"] [data-testid="tree-picker-row"]`,
|
|
|
|
|
`.tree-row[data-node-id="${escapedNodeId}"] [data-testid="tree-node-open"]`,
|
|
|
|
|
].join(", "),
|
2026-04-26 04:29:23 +08:00
|
|
|
);
|
|
|
|
|
if (!button || typeof button.click !== "function") {
|
|
|
|
|
throw new Error("picker 目标页面按钮不存在");
|
|
|
|
|
}
|
|
|
|
|
button.click();
|
|
|
|
|
}, fixture.targetId);
|
|
|
|
|
} else {
|
|
|
|
|
await keyboardDialog
|
|
|
|
|
.locator(`[data-testid="tree-picker-row"][data-node-id="${fixture.targetId}"]`)
|
|
|
|
|
.click({ timeout: UI_TIMEOUT_MS });
|
|
|
|
|
}
|
|
|
|
|
await moveRequest;
|
|
|
|
|
} finally {
|
|
|
|
|
await page.unroute("**/api/search/documents").catch(() => undefined);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await keyboardDialog.waitFor({ state: "hidden", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
pickerSearch: true,
|
|
|
|
|
pickerEmpty: true,
|
|
|
|
|
pickerSelect: true,
|
2026-04-28 16:30:51 +08:00
|
|
|
pickerHostKind: keyboardUsesIframe ? "iframe" : "dom",
|
2026-04-26 04:29:23 +08:00
|
|
|
emptyStateText,
|
|
|
|
|
pickerRootVisible,
|
|
|
|
|
pickerExcludeCurrentDocument: true,
|
|
|
|
|
pickerSearchQuery: keyboardQuery,
|
|
|
|
|
pickerSearchTargetId: fixture.targetId,
|
|
|
|
|
};
|
|
|
|
|
} finally {
|
|
|
|
|
await page.close().catch(() => undefined);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function runStreamFallbackCheck(context, fixture) {
|
2026-04-29 12:24:44 +08:00
|
|
|
const response = await context.request.fetch(
|
|
|
|
|
`${BASE_URL}/api/tree/events?workspaceId=${encodeURIComponent(fixture.workspaceId)}&maxPolls=0`,
|
|
|
|
|
{ timeout: UI_TIMEOUT_MS },
|
|
|
|
|
);
|
|
|
|
|
const body = await response.text();
|
|
|
|
|
assert(response.ok(), `/api/tree/events 请求失败: ${response.status()} ${body.slice(0, 200)}`);
|
|
|
|
|
assert(
|
|
|
|
|
(response.headers()["content-type"] || "").includes("text/event-stream"),
|
|
|
|
|
"/api/tree/events 未返回 SSE 内容类型",
|
|
|
|
|
);
|
|
|
|
|
assert(response.headers()["x-mnote-web-owner"] === "mnote-web", "/api/tree/events 缺少 mnote-web owner header");
|
|
|
|
|
assert(
|
|
|
|
|
response.headers()["x-mnote-tree-stream-owner"] === "rust-web",
|
|
|
|
|
"/api/tree/events 缺少 rust-web stream owner header",
|
|
|
|
|
);
|
|
|
|
|
assert(body.includes("event: snapshot") || body.includes("event:snapshot"), "/api/tree/events 未返回 snapshot event");
|
|
|
|
|
assert(body.includes('"kind":"snapshot"'), "/api/tree/events snapshot payload 缺少 kind=snapshot");
|
2026-04-26 04:29:23 +08:00
|
|
|
|
2026-04-29 12:24:44 +08:00
|
|
|
return {
|
|
|
|
|
streamFallback: false,
|
|
|
|
|
rustTreeEvents: true,
|
|
|
|
|
streamRequestCount: 1,
|
|
|
|
|
};
|
2026-04-26 04:29:23 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function main() {
|
|
|
|
|
const headless =
|
|
|
|
|
process.env.MNOTE_SMOKE_HEADLESS === "1"
|
|
|
|
|
? true
|
|
|
|
|
: process.env.MNOTE_SMOKE_HEADLESS === "0"
|
|
|
|
|
? false
|
|
|
|
|
: !process.env.DISPLAY;
|
2026-04-29 12:24:44 +08:00
|
|
|
const browser = await chromium.launch({ headless, args: CHROMIUM_STABLE_ARGS });
|
|
|
|
|
const browserContextOptions = {
|
2026-04-26 04:29:23 +08:00
|
|
|
viewport: { width: 1440, height: 960 },
|
2026-04-29 12:24:44 +08:00
|
|
|
};
|
|
|
|
|
let context = await browser.newContext(browserContextOptions);
|
|
|
|
|
let page = await context.newPage();
|
2026-04-26 04:29:23 +08:00
|
|
|
|
|
|
|
|
let fixture = null;
|
|
|
|
|
let caughtError = null;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const viewer = await ensureAuthenticated(page, context.request);
|
|
|
|
|
fixture = await prepareFixture(context.request);
|
|
|
|
|
|
|
|
|
|
const pageTreeHost = await runPageTreeHostChecks(page, fixture);
|
2026-04-29 12:24:44 +08:00
|
|
|
const storageState = await context.storageState();
|
|
|
|
|
await page.close().catch(() => undefined);
|
|
|
|
|
await context.close().catch(() => undefined);
|
|
|
|
|
context = await browser.newContext({
|
|
|
|
|
...browserContextOptions,
|
|
|
|
|
storageState,
|
|
|
|
|
});
|
|
|
|
|
page = null;
|
2026-04-26 04:29:23 +08:00
|
|
|
const pageTreeShell = RUN_DIRECT_TREE_SHELL_CHECKS
|
|
|
|
|
? await runPageTreeShellChecks(context, fixture)
|
|
|
|
|
: { skipped: true, reason: "direct_tree_shell_debug_disabled" };
|
|
|
|
|
const fileTreeShell = RUN_DIRECT_TREE_SHELL_CHECKS
|
|
|
|
|
? await runFileTreeShellChecks(context, fixture)
|
|
|
|
|
: { skipped: true, reason: "direct_tree_shell_debug_disabled" };
|
2026-04-29 12:24:44 +08:00
|
|
|
let fileTreeHost;
|
|
|
|
|
try {
|
|
|
|
|
fileTreeHost = await runFileTreeHostDoubleClickCheck(context, fixture);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
if (!isPlaywrightTargetCrash(error)) {
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
await context.close().catch(() => undefined);
|
|
|
|
|
context = await browser.newContext({
|
|
|
|
|
...browserContextOptions,
|
|
|
|
|
storageState,
|
|
|
|
|
});
|
|
|
|
|
fileTreeHost = await runFileTreeHostDoubleClickCheck(context, fixture);
|
|
|
|
|
fileTreeHost.recoveredFromRendererCrash = true;
|
|
|
|
|
}
|
2026-04-26 04:29:23 +08:00
|
|
|
const picker = await runPickerDialogChecks(context, fixture);
|
|
|
|
|
const streamFallback = await runStreamFallbackCheck(context, fixture);
|
|
|
|
|
|
|
|
|
|
console.log(
|
|
|
|
|
JSON.stringify(
|
|
|
|
|
{
|
|
|
|
|
ok: true,
|
|
|
|
|
baseUrl: BASE_URL,
|
2026-04-29 12:24:44 +08:00
|
|
|
documentBaseUrl: DOCUMENT_UI_BASE_URL,
|
2026-04-26 04:29:23 +08:00
|
|
|
viewerUserId: viewer.userId,
|
|
|
|
|
workspaceId: fixture.workspaceId,
|
|
|
|
|
parentId: fixture.parentId,
|
|
|
|
|
childAId: fixture.childAId,
|
|
|
|
|
childBId: fixture.childBId,
|
|
|
|
|
targetId: fixture.targetId,
|
|
|
|
|
results: {
|
|
|
|
|
pageTreeHost,
|
|
|
|
|
pageTreeShell,
|
|
|
|
|
fileTreeShell,
|
|
|
|
|
fileTreeHost,
|
|
|
|
|
picker,
|
|
|
|
|
streamFallback,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
null,
|
|
|
|
|
2,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
caughtError = error;
|
|
|
|
|
} finally {
|
|
|
|
|
if (fixture) {
|
|
|
|
|
try {
|
|
|
|
|
await cleanupFixture(context.request, fixture);
|
|
|
|
|
} catch (cleanupError) {
|
|
|
|
|
if (!caughtError) {
|
|
|
|
|
caughtError = cleanupError;
|
|
|
|
|
} else {
|
|
|
|
|
console.error(
|
|
|
|
|
`清理临时页面失败:${
|
|
|
|
|
cleanupError instanceof Error
|
|
|
|
|
? cleanupError.stack || cleanupError.message
|
|
|
|
|
: String(cleanupError)
|
|
|
|
|
}`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 12:24:44 +08:00
|
|
|
if (page) {
|
|
|
|
|
await page.close().catch(() => undefined);
|
|
|
|
|
}
|
2026-04-26 04:29:23 +08:00
|
|
|
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);
|
|
|
|
|
});
|