"use strict"; // 说明: // - 这份 smoke 用来覆盖 tree rust family checklist 第 9 节的通用回归要求。 // - legacy React island 负责验证真实导航、上下文菜单、picker 对话框;3000 Rust gateway 负责 API 与 stream。 // - `/api/tree/shell` 直连页已退到显式 debug/internal 边界,默认不再进入这条链路。 const { chromium } = require("playwright"); const { BASE_URL, DOCUMENT_UI_BASE_URL, 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"; const ALLOW_LEGACY_TREE_SHELL_IFRAME_HOST = String(process.env.NEXT_PUBLIC_TREE_SHELL_LEGACY_IFRAME_HOST || "").trim() === "1"; const CHROMIUM_STABLE_ARGS = ["--disable-dev-shm-usage", "--disable-gpu"]; 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()}`; } function treeHostImplementationUsesIframe(implementation) { 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"}`, ); } } 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") || ""; 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("就绪"); } 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" ); }, surfaceTestId, { timeout: UI_TIMEOUT_MS }, ); } 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"); if ((await host.count()) === 0) { const currentHost = page.getByTestId("wolai-sidebar-page-tree-shell"); await currentHost.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await page .locator('#sidebar-tree-root .tree-row[data-shell-mode="page"]') .first() .waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); return { kind: "dom", host: currentHost, scope: page.locator("#sidebar-tree-root") }; } 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") || ""; if ( implementation === "mnote_web_iframe_proxy" || implementation === "rust_inline_compat_host" || implementation === "rust_runtime_artifact_host" ) { const iframe = element.querySelector('[data-testid="sidebar-page-tree-shell-rust-iframe"]'); return iframe instanceof HTMLIFrameElement && iframe.getClientRects().length > 0; } 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" ); }, undefined, { timeout: UI_TIMEOUT_MS }, ); const implementation = await host.getAttribute("data-tree-host-implementation"); assertDefaultHostIsDomWasm(implementation, "sidebar-page-tree-shell"); if (treeHostImplementationUsesIframe(implementation)) { await waitForInlineTreeShellReady(page, "sidebar-page-tree-shell"); return { kind: "iframe", host, scope: page.frameLocator('[data-testid="sidebar-page-tree-shell-rust-iframe"]'), }; } await waitForInlineTreeShellReady(page, "sidebar-page-tree-shell"); return { kind: "dom", host, scope: host }; } async function getFileTreeHostDriver(page) { const host = page.getByTestId("sidebar-file-tree-shell"); if ((await host.count()) === 0) { const currentHost = page.locator("#sidebar-file-tree-root"); await currentHost.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await page .locator('#sidebar-file-tree-root [data-testid="filetree-doc-row"], #sidebar-file-tree-root [data-testid="filetree-index-row"]') .first() .waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); return { kind: "dom", host: currentHost, scope: currentHost }; } 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") || ""; if ( implementation === "mnote_web_iframe_proxy" || implementation === "rust_inline_compat_host" || implementation === "rust_runtime_artifact_host" ) { const iframe = element.querySelector('[data-testid="sidebar-file-tree-shell-rust-iframe"]'); return iframe instanceof HTMLIFrameElement && iframe.getClientRects().length > 0; } 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" ); }, undefined, { timeout: UI_TIMEOUT_MS }, ); const implementation = await host.getAttribute("data-tree-host-implementation"); assertDefaultHostIsDomWasm(implementation, "sidebar-file-tree-shell"); if (treeHostImplementationUsesIframe(implementation)) { await waitForInlineTreeShellReady(page, "sidebar-file-tree-shell"); return { kind: "iframe", host, scope: page.frameLocator('[data-testid="sidebar-file-tree-shell-rust-iframe"]'), }; } await waitForInlineTreeShellReady(page, "sidebar-file-tree-shell"); return { kind: "dom", host, scope: host }; } 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 .locator(`.tree-row[data-shell-mode="page"][data-node-id="${documentId}"] [data-testid="tree-node-open"]`) .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 { const row = driver.scope.locator(`.tree-row[data-shell-mode="page"][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(); }); } await page.getByText("重命名", { exact: true }).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await page.evaluate(() => { document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); }); } 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 .locator(`[data-testid="filetree-doc-row"][data-document-id="${documentId}"], [data-testid="filetree-doc-row"][data-doc-id="${documentId}"]`) .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 primaryInput = page.locator('[data-page-title-input="true"][data-pane-role="primary"]').first(); if ((await primaryInput.count()) > 0) { await primaryInput.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); return primaryInput; } const input = page.getByLabel("页面标题").first(); 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 && payload?.commandName === "page.head.updateTitle" && 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 .locator(`.tree-row[data-shell-mode="page"][data-node-id="${documentId}"]`) .waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await page.waitForFunction( ({ nodeId, expectedTitle }) => { const row = document.querySelector(`.tree-row[data-shell-mode="page"][data-node-id="${nodeId}"]`); 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 .locator(`[data-testid="filetree-doc-row"][data-document-id="${documentId}"], [data-testid="filetree-doc-row"][data-doc-id="${documentId}"]`) .waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await page.waitForFunction( ({ docId, expectedTitle }) => { const row = document.querySelector( `[data-testid="filetree-doc-row"][data-document-id="${docId}"], [data-testid="filetree-doc-row"][data-doc-id="${docId}"]`, ); 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); } } function isPlaywrightTargetCrash(error) { const message = error instanceof Error ? error.message : String(error); return message.includes("Target crashed"); } 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).catch(() => undefined); await page.getByRole("button", { name: "页面选项", exact: true }).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS, }).catch(() => undefined); const openButton = page.getByRole("button", { name: "移动/嵌入到..." }); if ((await openButton.count()) === 0) { return { pickerSkipped: true, reason: "move_embed_entry_missing_in_current_page_options_shell", }; } try { await openButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); } catch { return { pickerSkipped: true, reason: "move_embed_entry_hidden_in_current_page_options_shell", }; } 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("移动到..."); 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; 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 { await keyboardDialog .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 不存在"); } const escapedNodeId = CSS.escape(nodeId); const button = frame.contentDocument?.querySelector( [ `.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(", "), ); 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, pickerHostKind: keyboardUsesIframe ? "iframe" : "dom", emptyStateText, pickerRootVisible, pickerExcludeCurrentDocument: true, pickerSearchQuery: keyboardQuery, pickerSearchTargetId: fixture.targetId, }; } finally { await page.close().catch(() => undefined); } } async function runStreamFallbackCheck(context, fixture) { 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"); return { streamFallback: false, rustTreeEvents: true, streamRequestCount: 1, }; } async function main() { const headless = process.env.MNOTE_SMOKE_HEADLESS === "1" ? true : process.env.MNOTE_SMOKE_HEADLESS === "0" ? false : !process.env.DISPLAY; const browser = await chromium.launch({ headless, args: CHROMIUM_STABLE_ARGS }); const browserContextOptions = { viewport: { width: 1440, height: 960 }, }; let context = await browser.newContext(browserContextOptions); let page = await context.newPage(); 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); const storageState = await context.storageState(); await page.close().catch(() => undefined); await context.close().catch(() => undefined); context = await browser.newContext({ ...browserContextOptions, storageState, }); page = null; 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" }; 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; } const picker = await runPickerDialogChecks(context, fixture); const streamFallback = await runStreamFallbackCheck(context, fixture); console.log( JSON.stringify( { ok: true, baseUrl: BASE_URL, documentBaseUrl: DOCUMENT_UI_BASE_URL, 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) }`, ); } } } if (page) { 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); });