2026-04-26 04:29:23 +08:00
|
|
|
"use strict";
|
|
|
|
|
|
|
|
|
|
// 说明:
|
|
|
|
|
// - 这份 smoke 只验证 rust_family picker 的键盘高亮与 Enter 选中。
|
|
|
|
|
// - 这里刻意使用 fresh-open 的最短链路,避免把空态/多轮搜索切换的抖动混进同一条回归里。
|
|
|
|
|
|
|
|
|
|
const { chromium } = require("playwright");
|
|
|
|
|
const {
|
|
|
|
|
UI_TIMEOUT_MS,
|
|
|
|
|
createTempDocument,
|
|
|
|
|
ensureAuthenticated,
|
|
|
|
|
ensurePageOptionsVisible,
|
|
|
|
|
openDocument,
|
|
|
|
|
purgeDocument,
|
|
|
|
|
renameDocument,
|
|
|
|
|
} = require("./tree-shell-smoke-helpers");
|
|
|
|
|
|
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";
|
|
|
|
|
|
|
|
|
|
function treeHostImplementationUsesIframe(implementation) {
|
|
|
|
|
return (
|
|
|
|
|
implementation === "mnote_web_iframe_proxy" ||
|
|
|
|
|
implementation === "rust_inline_compat_host" ||
|
|
|
|
|
implementation === "rust_runtime_artifact_host"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function waitForPickerShellReady(page) {
|
|
|
|
|
await page.waitForFunction(
|
|
|
|
|
() => {
|
|
|
|
|
const host = document.querySelector('[data-testid="tree-picker-surface"]');
|
|
|
|
|
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="tree-picker-surface-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="tree-picker-surface-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 },
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function pickerUsesLegacyIframe(pickerSurface) {
|
|
|
|
|
const implementation = await pickerSurface.getAttribute("data-tree-host-implementation");
|
|
|
|
|
if (treeHostImplementationUsesIframe(implementation) && !ALLOW_LEGACY_TREE_SHELL_IFRAME_HOST) {
|
|
|
|
|
throw new Error(
|
|
|
|
|
`tree-picker-surface 默认主路径不能再使用 legacy iframe host: ${implementation || "unknown"}`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
return (
|
|
|
|
|
treeHostImplementationUsesIframe(implementation) &&
|
|
|
|
|
(await pickerSurface.locator('[data-testid="tree-picker-surface-rust-iframe"]').count()) > 0
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
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;
|
|
|
|
|
const browser = await chromium.launch({ headless });
|
|
|
|
|
const context = await browser.newContext({
|
|
|
|
|
viewport: { width: 1440, height: 960 },
|
|
|
|
|
});
|
|
|
|
|
const page = await context.newPage();
|
|
|
|
|
|
|
|
|
|
let fixture = null;
|
|
|
|
|
let caughtError = null;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await ensureAuthenticated(page, context.request);
|
|
|
|
|
const uniqueSuffix = Date.now().toString();
|
|
|
|
|
const parent = await createTempDocument(context.request, null);
|
|
|
|
|
const childA = await createTempDocument(context.request, parent.documentId);
|
|
|
|
|
const target = await createTempDocument(context.request, null);
|
|
|
|
|
|
|
|
|
|
fixture = {
|
|
|
|
|
workspaceId: parent.workspaceId,
|
|
|
|
|
parentId: parent.documentId,
|
|
|
|
|
childAId: childA.documentId,
|
|
|
|
|
targetId: target.documentId,
|
|
|
|
|
parentTitle: `task113-parent-${uniqueSuffix}`,
|
|
|
|
|
childATitle: `task113-child-a-${uniqueSuffix}`,
|
|
|
|
|
targetTitle: `task113-target-${uniqueSuffix}`,
|
|
|
|
|
createdIds: [parent.documentId, childA.documentId, target.documentId],
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
await renameDocument(context.request, fixture.workspaceId, fixture.parentId, fixture.parentTitle);
|
|
|
|
|
await renameDocument(context.request, fixture.workspaceId, fixture.childAId, fixture.childATitle);
|
|
|
|
|
await renameDocument(context.request, fixture.workspaceId, fixture.targetId, fixture.targetTitle);
|
|
|
|
|
|
|
|
|
|
await openDocument(page, fixture.workspaceId, fixture.childAId);
|
|
|
|
|
await ensurePageOptionsVisible(page);
|
|
|
|
|
const openButton = page.getByRole("button", { name: "移动/嵌入到..." });
|
|
|
|
|
await openButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await openButton.click({ timeout: UI_TIMEOUT_MS });
|
|
|
|
|
|
|
|
|
|
const dialog = page.getByRole("dialog");
|
|
|
|
|
await dialog.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
2026-04-28 16:30:51 +08:00
|
|
|
const pickerSurface = dialog.getByTestId("tree-picker-surface");
|
|
|
|
|
await pickerSurface.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
await waitForPickerShellReady(page);
|
|
|
|
|
const usesLegacyIframe = await pickerUsesLegacyIframe(pickerSurface);
|
2026-04-26 04:29:23 +08:00
|
|
|
const searchInput = dialog.getByPlaceholder("移动到...");
|
|
|
|
|
const keyboardQuery = `task113-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 searchInput.fill(keyboardQuery, { timeout: UI_TIMEOUT_MS });
|
2026-04-28 16:30:51 +08:00
|
|
|
if (usesLegacyIframe) {
|
|
|
|
|
await page.waitForFunction(
|
|
|
|
|
(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.targetId,
|
|
|
|
|
{ timeout: UI_TIMEOUT_MS },
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
await dialog
|
|
|
|
|
.locator(`[data-testid="tree-picker-row"][data-node-id="${fixture.targetId}"]`)
|
|
|
|
|
.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
}
|
2026-04-26 04:29:23 +08:00
|
|
|
|
|
|
|
|
await searchInput.focus();
|
|
|
|
|
await page.waitForFunction(
|
|
|
|
|
() => {
|
|
|
|
|
const active = document.activeElement;
|
|
|
|
|
return active instanceof HTMLInputElement && active.placeholder === "移动到...";
|
|
|
|
|
},
|
|
|
|
|
undefined,
|
|
|
|
|
{ timeout: UI_TIMEOUT_MS },
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const readFocusedPickerItemKey = async () =>
|
2026-04-28 16:30:51 +08:00
|
|
|
usesLegacyIframe
|
|
|
|
|
? 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) || !frame.contentDocument) {
|
|
|
|
|
return "";
|
|
|
|
|
}
|
|
|
|
|
const rootButton = frame.contentDocument.querySelector(
|
|
|
|
|
'[data-testid="tree-picker-root"][data-focused="true"]',
|
|
|
|
|
);
|
|
|
|
|
if (rootButton) {
|
|
|
|
|
return "__root__";
|
|
|
|
|
}
|
|
|
|
|
const row = frame.contentDocument.querySelector('.tree-row[data-focused="true"]');
|
|
|
|
|
return row ? row.getAttribute("data-node-id") || "" : "";
|
|
|
|
|
})
|
|
|
|
|
: dialog.evaluate((dialogElement) => {
|
|
|
|
|
const rootButton = dialogElement.querySelector('[data-testid="tree-picker-root"][data-focused="true"]');
|
|
|
|
|
if (rootButton) {
|
|
|
|
|
return "__root__";
|
|
|
|
|
}
|
|
|
|
|
const row = dialogElement.querySelector('.tree-row[data-focused="true"]');
|
|
|
|
|
return row ? row.getAttribute("data-node-id") || "" : "";
|
|
|
|
|
});
|
2026-04-26 04:29:23 +08:00
|
|
|
|
|
|
|
|
const moveHighlightTo = async (targetNodeId, maxSteps = 4) => {
|
2026-04-28 16:30:51 +08:00
|
|
|
if (usesLegacyIframe) {
|
|
|
|
|
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) || !frame.contentDocument) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
const rootButton = frame.contentDocument.querySelector(
|
|
|
|
|
'[data-testid="tree-picker-root"][data-focused="true"]',
|
|
|
|
|
);
|
|
|
|
|
const row = frame.contentDocument.querySelector('.tree-row[data-focused="true"]');
|
|
|
|
|
return Boolean(rootButton || row);
|
|
|
|
|
},
|
|
|
|
|
undefined,
|
|
|
|
|
{ timeout: UI_TIMEOUT_MS },
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
await dialog
|
|
|
|
|
.locator('[data-testid="tree-picker-root"][data-focused="true"], .tree-row[data-focused="true"]')
|
|
|
|
|
.first()
|
|
|
|
|
.waitFor({ state: "attached", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
}
|
2026-04-26 04:29:23 +08:00
|
|
|
|
|
|
|
|
for (let index = 0; index < maxSteps; index += 1) {
|
|
|
|
|
const currentKey = await readFocusedPickerItemKey();
|
|
|
|
|
if (currentKey === targetNodeId) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
await page.keyboard.press("ArrowDown");
|
|
|
|
|
await page.waitForFunction(
|
2026-04-28 16:30:51 +08:00
|
|
|
({ previousKey, legacyIframe }) => {
|
|
|
|
|
if (legacyIframe) {
|
|
|
|
|
const frame = Array.from(
|
|
|
|
|
document.querySelectorAll('[data-testid="tree-picker-surface-rust-iframe"]'),
|
|
|
|
|
)
|
|
|
|
|
.reverse()
|
|
|
|
|
.find((element) => element instanceof HTMLIFrameElement) ?? null;
|
|
|
|
|
if (!(frame instanceof HTMLIFrameElement) || !frame.contentDocument) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
const rootButton = frame.contentDocument.querySelector(
|
|
|
|
|
'[data-testid="tree-picker-root"][data-focused="true"]',
|
|
|
|
|
);
|
|
|
|
|
const row = frame.contentDocument.querySelector('.tree-row[data-focused="true"]');
|
|
|
|
|
const currentKey = rootButton
|
|
|
|
|
? "__root__"
|
|
|
|
|
: row
|
|
|
|
|
? row.getAttribute("data-node-id") || ""
|
|
|
|
|
: "";
|
|
|
|
|
return currentKey !== previousKey;
|
|
|
|
|
}
|
|
|
|
|
const dialogElement = document.querySelector('[role="dialog"]');
|
|
|
|
|
if (!(dialogElement instanceof HTMLElement)) {
|
2026-04-26 04:29:23 +08:00
|
|
|
return false;
|
|
|
|
|
}
|
2026-04-28 16:30:51 +08:00
|
|
|
const rootButton = dialogElement.querySelector('[data-testid="tree-picker-root"][data-focused="true"]');
|
|
|
|
|
const row = dialogElement.querySelector('.tree-row[data-focused="true"]');
|
2026-04-26 04:29:23 +08:00
|
|
|
const currentKey = rootButton
|
|
|
|
|
? "__root__"
|
|
|
|
|
: row
|
|
|
|
|
? row.getAttribute("data-node-id") || ""
|
|
|
|
|
: "";
|
|
|
|
|
return currentKey !== previousKey;
|
|
|
|
|
},
|
2026-04-28 16:30:51 +08:00
|
|
|
{ previousKey: currentKey, legacyIframe: usesLegacyIframe },
|
2026-04-26 04:29:23 +08:00
|
|
|
{ timeout: UI_TIMEOUT_MS },
|
|
|
|
|
);
|
|
|
|
|
await page.waitForFunction(
|
|
|
|
|
() => {
|
|
|
|
|
const active = document.activeElement;
|
|
|
|
|
return active instanceof HTMLInputElement && active.placeholder === "移动到...";
|
|
|
|
|
},
|
|
|
|
|
undefined,
|
|
|
|
|
{ timeout: UI_TIMEOUT_MS },
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const finalKey = await readFocusedPickerItemKey();
|
|
|
|
|
if (finalKey !== targetNodeId) {
|
|
|
|
|
throw new Error(`picker 键盘高亮未落到目标节点:当前=${finalKey || "<empty>"} 目标=${targetNodeId}`);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
await moveHighlightTo(fixture.targetId);
|
|
|
|
|
|
|
|
|
|
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 },
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await searchInput.focus();
|
|
|
|
|
await page.keyboard.press("Enter");
|
|
|
|
|
await moveRequest;
|
|
|
|
|
await dialog.waitFor({ state: "hidden", timeout: UI_TIMEOUT_MS });
|
|
|
|
|
} finally {
|
|
|
|
|
await page.unroute("**/api/search/documents").catch(() => undefined);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log(
|
|
|
|
|
JSON.stringify(
|
|
|
|
|
{
|
|
|
|
|
ok: true,
|
|
|
|
|
workspaceId: fixture.workspaceId,
|
|
|
|
|
sourceId: fixture.childAId,
|
|
|
|
|
targetId: fixture.targetId,
|
|
|
|
|
searchQuery: keyboardQuery,
|
|
|
|
|
pickerKeyboardHighlight: true,
|
|
|
|
|
pickerKeyboardSelect: true,
|
|
|
|
|
},
|
|
|
|
|
null,
|
|
|
|
|
2,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
caughtError = error;
|
|
|
|
|
} finally {
|
|
|
|
|
if (fixture) {
|
|
|
|
|
for (const documentId of [...fixture.createdIds].reverse()) {
|
|
|
|
|
try {
|
|
|
|
|
await purgeDocument(context.request, documentId);
|
|
|
|
|
} catch {
|
|
|
|
|
// 说明:移动后父级与目标级的清理顺序可能变化,这里忽略重复清理错误。
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
});
|