Files
mnote/scripts/task479-local-folder-markdown-resource-lifecycle-smoke.js
T

879 lines
40 KiB
JavaScript
Raw Normal View History

2026-05-24 01:49:51 +08:00
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { chromium } = require("playwright");
const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|| ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium"]
.find((candidate) => fs.existsSync(candidate));
function fileUrl(localPath) {
return `file://${localPath}`;
}
function localMdDocumentId(relativePath) {
return `local-md:${relativePath.replaceAll("/", "~2F")}`;
}
function documentUrl(root, relativePath) {
const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", fileUrl(root));
url.searchParams.set("treeView", "filetree");
return url.toString();
}
function localFolderHomeUrl(root) {
const url = new URL(`${BASE_URL}/`);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", fileUrl(root));
url.searchParams.set("treeView", "filetree");
return url.toString();
}
function writeWorkspaceManifest(root, ownerId, taskId) {
const metadataDir = path.join(root, ".mnote");
fs.mkdirSync(metadataDir, { recursive: true });
fs.writeFileSync(
path.join(metadataDir, "workspace.json"),
`${JSON.stringify({
workspaceId: `local-ws:${ownerId}:${taskId}`,
ownerId,
createdAt: new Date().toISOString(),
capabilities: ["local_files", "markdown_edit", "asset_upload"],
}, null, 2)}\n`,
"utf8",
);
}
async function quickLogin(page) {
await page.goto(`${BASE_URL}/auth`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
const quickLoginButton = page.getByRole("button", { name: "测试账号快速登录" });
if (await quickLoginButton.count()) {
await quickLoginButton.click({ timeout: UI_TIMEOUT_MS });
await page.waitForURL((url) => url.pathname === "/", { timeout: UI_TIMEOUT_MS }).catch(() => undefined);
}
}
async function activatePrimaryPageTab(page) {
const pageTab = page.locator('[data-mnote-main-tab="page"][data-pane-role="primary"]').first();
if (await pageTab.count()) {
await pageTab.click({ timeout: UI_TIMEOUT_MS }).catch(() => undefined);
}
await page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
}
async function uploadAttachmentViaPrimarySlash(page, fileName, markdownContent) {
await activatePrimaryPageTab(page);
const editor = page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first();
await editor.click({ timeout: UI_TIMEOUT_MS });
await page.keyboard.press("End").catch(() => undefined);
await page.keyboard.type("/");
const item = page
.locator('.document-pane[data-pane-role="primary"] [data-testid="slash-item-upload-attachment"]')
.first();
await item.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const [fileChooser] = await Promise.all([
page.waitForEvent("filechooser", { timeout: UI_TIMEOUT_MS }),
item.click({ timeout: UI_TIMEOUT_MS }),
]);
await fileChooser.setFiles({
name: fileName,
mimeType: "text/markdown",
buffer: Buffer.from(markdownContent, "utf8"),
});
await page.waitForFunction(
(name) => {
const editor = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror');
return editor instanceof HTMLElement && (editor.textContent || "").includes(name);
},
fileName,
{ timeout: UI_TIMEOUT_MS },
);
}
async function waitForFileExists(filePath, timeoutMs) {
const startedAt = Date.now();
while (!fs.existsSync(filePath) && Date.now() - startedAt < timeoutMs) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
return fs.existsSync(filePath);
}
2026-05-24 02:34:52 +08:00
async function waitForUploadAsset(uploadResponses, startIndex, predicate, timeoutMs) {
const startedAt = Date.now();
let seen = [];
while (Date.now() - startedAt < timeoutMs) {
seen = uploadResponses.slice(startIndex);
for (const entry of seen) {
const asset = entry?.payload?.asset;
if (asset && predicate(asset, entry)) return asset;
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
throw new Error(`未捕获到符合条件的上传响应: ${JSON.stringify(seen).slice(0, 1000)}`);
}
async function waitForFileContent(filePath, predicate, timeoutMs) {
const startedAt = Date.now();
let lastContent = "";
while (Date.now() - startedAt < timeoutMs) {
if (fs.existsSync(filePath)) {
lastContent = fs.readFileSync(filePath, "utf8");
if (predicate(lastContent)) return { ok: true, content: lastContent };
}
await new Promise((resolve) => setTimeout(resolve, 150));
}
return { ok: false, content: lastContent };
}
async function waitForPrimaryAttachment(page, fileName) {
const link = page
.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror a[href*="/api/local-folder/files/open"]', {
hasText: fileName,
})
.first();
await link.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
(name) => {
const editor = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror');
const link = Array.from(editor?.querySelectorAll('a[href*="/api/local-folder/files/open"]') || [])
.find((node) => node instanceof HTMLAnchorElement && (node.textContent || "").includes(name));
return link instanceof HTMLAnchorElement
&& (link.classList.contains("mnote-uploaded-attachment-row") || getComputedStyle(link).display === "inline-flex");
},
fileName,
{ timeout: UI_TIMEOUT_MS },
);
return link;
}
async function selectPrimaryAttachmentLink(page, fileName) {
await page.evaluate((name) => {
const editor = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror');
if (!(editor instanceof HTMLElement)) throw new Error("editor_missing");
const link = Array.from(editor.querySelectorAll("a")).find((node) => (
node instanceof HTMLAnchorElement && (node.textContent || "").includes(name)
));
if (!(link instanceof HTMLAnchorElement)) throw new Error(`attachment_link_missing:${name}`);
const range = document.createRange();
range.selectNode(link);
const selection = window.getSelection();
if (!selection) throw new Error("selection_missing");
selection.removeAllRanges();
selection.addRange(range);
editor.focus();
}, fileName);
}
async function deleteAttachmentByKeyboard(page, fileName) {
await waitForPrimaryAttachment(page, fileName);
await selectPrimaryAttachmentLink(page, fileName);
await page.keyboard.press("Backspace");
}
async function deleteAttachmentByBlockHandle(page, fileName) {
const link = await waitForPrimaryAttachment(page, fileName);
const box = await link.boundingBox();
assert(box, `附件 ${fileName} 缺少可点击区域`);
await page.mouse.move(box.x + Math.min(12, box.width / 2), box.y + box.height / 2, { steps: 8 });
const trigger = page.locator('[data-testid="block-drag-handle-trigger"]').first();
await trigger.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await trigger.click({ timeout: UI_TIMEOUT_MS });
const menu = page.locator('[data-testid="block-drag-menu"]').first();
await menu.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="block-drag-menu-item-delete"]').first().click({ timeout: UI_TIMEOUT_MS });
}
function writeTwoAttachmentPage(root, relativePath, resourceDirName, heading) {
const resourceDir = path.join(root, resourceDirName);
fs.mkdirSync(resourceDir, { recursive: true });
fs.writeFileSync(path.join(resourceDir, "first.md"), "# First\n\n第一个附件\n", "utf8");
fs.writeFileSync(path.join(resourceDir, "second.md"), "# Second\n\n第二个附件\n", "utf8");
fs.writeFileSync(
path.join(root, relativePath),
[
`# ${heading}`,
"",
`[first.md](${resourceDirName}/first.md)`,
"",
`[second.md](${resourceDirName}/second.md)`,
"",
].join("\n"),
"utf8",
);
}
2026-05-24 01:49:51 +08:00
// ─── main ────────────────────────────────────────────────────────────────────
async function main() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task479-lifecycle-"));
const relativePath = "README.md";
const resourceDirName = "README";
const resourceDir = path.join(root, resourceDirName);
fs.mkdirSync(resourceDir, { recursive: true });
writeWorkspaceManifest(root, "user_real", "task479");
// 主正文:README.md,引用一个已存在的附件 resource-note.md
fs.writeFileSync(
path.join(root, relativePath),
[
"---",
"title: Lifecycle Smoke",
"---",
"",
"# Lifecycle Smoke",
"",
"正文内容不变。",
"",
"[resource-note.md](" + resourceDirName + "/resource-note.md)",
"",
].join("\n"),
"utf8",
);
fs.writeFileSync(path.join(root, resourceDirName, "resource-note.md"), "# Resource Note\n\n已有资源正文\n", "utf8");
// 为 Check 6 准备一个带 H1 的文件(文件名不含空格,避免空格编码干扰测试)
fs.writeFileSync(path.join(root, "MyNotes.md"), "# Body Heading\n\n正文内容\n", "utf8");
// 为 Check 7 准备两个删除附件引用的独立页面,避免不同删除路径互相污染。
writeTwoAttachmentPage(root, "DeleteKeyboard.md", "DeleteKeyboard", "Delete Keyboard");
writeTwoAttachmentPage(root, "DeleteHandle.md", "DeleteHandle", "Delete Handle");
2026-05-24 01:49:51 +08:00
// 为 Check 5 准备足够的文件树条目来产生可滚动区域
fs.mkdirSync(path.join(root, "docs"), { recursive: true });
for (let i = 0; i < 30; i++) {
fs.writeFileSync(path.join(root, "docs", `doc-${String(i).padStart(2, "0")}.md`), `# Doc ${i}\n`, "utf8");
}
// 为 Check 5 准备的附件 PDF
fs.writeFileSync(path.join(resourceDir, "report-a.pdf"), Buffer.from("%PDF-1.4\n% task479 report-a\n", "utf8"));
const browser = await chromium.launch({
headless: process.env.HEADFUL !== "1",
executablePath: CHROMIUM_EXECUTABLE_PATH,
});
const context = await browser.newContext({
viewport: { width: 1360, height: 900 },
extraHTTPHeaders: {
"x-mnote-actor-id": "user_real",
"x-mnote-actor-type": "user",
},
});
const page = await context.newPage();
const popups = [];
page.on("popup", (popup) => popups.push(popup));
2026-05-24 02:34:52 +08:00
const uploadResponses = [];
page.on("response", async (response) => {
if (!response.url().includes("/api/local-folder/assets/upload")) return;
const payload = await response.json().catch(() => null);
uploadResponses.push({ status: response.status(), payload });
});
2026-05-24 01:49:51 +08:00
const screenshots = [];
const checks = {};
let overallOk = true;
try {
// ── Login ──
await quickLogin(page);
// ──────────────────────────────────────────────────────────────────────────
// Check 1: 主编辑区上传 / drop 附件后,附件应进入当前 Markdown 页面的资源目录
// README/uploaded-one.md 不是 root 同级)
// ──────────────────────────────────────────────────────────────────────────
{
const checkId = "editor-upload-to-page-resource-dir";
try {
await page.goto(documentUrl(root, relativePath), {
waitUntil: "domcontentloaded",
timeout: UI_TIMEOUT_MS,
});
await page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
2026-05-24 02:34:52 +08:00
const uploadStart = uploadResponses.length;
2026-05-24 01:49:51 +08:00
// 通过主编辑区斜杠上传上传一个 MD 文件
await uploadAttachmentViaPrimarySlash(page, "uploaded-one.md", "# Uploaded One\n\n第一个上传附件\n");
2026-05-24 02:34:52 +08:00
const uploadAsset = await waitForUploadAsset(
uploadResponses,
uploadStart,
(asset) => asset.file_name === "uploaded-one.md",
UI_TIMEOUT_MS,
);
2026-05-24 01:49:51 +08:00
// 等待后端 write_local_markdown_asset() 落盘
const correctPath = path.join(resourceDir, "uploaded-one.md");
const wrongPath = path.join(root, "uploaded-one.md");
const landedInResourceDir = await waitForFileExists(correctPath, UI_TIMEOUT_MS);
const landedInRoot = fs.existsSync(wrongPath);
assert(
landedInResourceDir,
`上传文件应落在 page resource directory (${correctPath})。`
+ ` landedInRoot=${landedInRoot} correctPath=${correctPath}`,
);
2026-05-24 02:34:52 +08:00
assert.equal(uploadAsset.uploadIntent, "editor.markdown.attach", "主编辑区上传响应应返回 editor.markdown.attach intent");
assert.equal(uploadAsset.rootRelativePath, "README/uploaded-one.md", "主编辑区上传响应应返回 page resource rootRelativePath");
assert.equal(uploadAsset.markdownRelativePath, "README/uploaded-one.md", "主编辑区上传响应应返回 markdownRelativePath");
assert.equal(uploadAsset.ownerDocumentId, "local-md:README.md", "主编辑区上传响应应返回 ownerDocumentId");
2026-05-24 01:49:51 +08:00
checks[checkId] = {
ok: true,
message: `上传文件落在资源目录 ${correctPath}`,
landedInResourceDir,
landedInRoot,
2026-05-24 02:34:52 +08:00
uploadAsset,
2026-05-24 01:49:51 +08:00
};
} catch (err) {
overallOk = false;
checks[checkId] = {
ok: false,
message: `主编辑区上传落盘目录检查失败: ${err.message}`,
error: err.stack,
};
}
}
// ──────────────────────────────────────────────────────────────────────────
// Check 2: 文件树 folder row 外部 drop 到目标文件夹时,文件应进入该目标 folder
// 而不是当前 Markdown 页面的资源目录
// ──────────────────────────────────────────────────────────────────────────
{
const checkId = "filetree-folder-drop-to-target";
try {
await page.goto(documentUrl(root, relativePath), {
waitUntil: "domcontentloaded",
timeout: UI_TIMEOUT_MS,
});
// 等待文件树就绪
const docsFolderRow = page.locator(
'#sidebar-file-tree-root .tree-row[data-row-id="local:folder:docs"]',
).first();
await docsFolderRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const droppedFileName = "drag-dropped-note.md";
const droppedContent = "# Drag Dropped\n\n从外部拖入\n";
2026-05-24 02:34:52 +08:00
const uploadStart = uploadResponses.length;
2026-05-24 01:49:51 +08:00
// 使用 dispatchEvent 模拟外部文件拖入 docs 文件夹
await docsFolderRow.dispatchEvent("dragover", {
dataTransfer: await page.evaluateHandle(
({ fileName, fileContent }) => {
const dt = new DataTransfer();
dt.items.add(new File([fileContent], fileName, { type: "text/markdown" }));
return dt;
},
{ fileName: droppedFileName, fileContent: droppedContent },
),
});
await docsFolderRow.dispatchEvent("drop", {
dataTransfer: await page.evaluateHandle(
({ fileName, fileContent }) => {
const dt = new DataTransfer();
dt.items.add(new File([fileContent], fileName, { type: "text/markdown" }));
return dt;
},
{ fileName: droppedFileName, fileContent: droppedContent },
),
});
// 等待文件落盘
const droppedPath = path.join(root, "docs", droppedFileName);
const exists = await waitForFileExists(droppedPath, UI_TIMEOUT_MS);
2026-05-24 02:34:52 +08:00
const uploadAsset = await waitForUploadAsset(
uploadResponses,
uploadStart,
(asset) => asset.file_name === droppedFileName,
UI_TIMEOUT_MS,
);
2026-05-24 01:49:51 +08:00
assert(exists, `拖入 docs 文件夹后文件应出现在 ${droppedPath}`);
2026-05-24 02:34:52 +08:00
assert.equal(uploadAsset.uploadIntent, "filetree.folder.drop", "文件树 folder drop 响应应返回 filetree.folder.drop intent");
assert.equal(uploadAsset.rootRelativePath, "docs/drag-dropped-note.md", "文件树 folder drop 响应应返回目标目录 rootRelativePath");
assert.equal(uploadAsset.targetRelativePath, "docs", "文件树 folder drop 响应应返回 targetRelativePath");
assert.equal(uploadAsset.markdownRelativePath, null, "文件树 folder drop 不应返回 markdownRelativePath");
assert.equal(uploadAsset.ownerDocumentId, null, "文件树 folder drop 不应返回 ownerDocumentId");
2026-05-24 01:49:51 +08:00
// 确保没有出现在 root 或 README/ 下
const wrongPath = path.join(root, droppedFileName);
const wrongResourcePath = path.join(resourceDir, droppedFileName);
assert(
!fs.existsSync(wrongPath) && !fs.existsSync(wrongResourcePath),
`拖入 docs 文件夹的文件不应落在 root (${wrongPath}) 或 resourceDir (${wrongResourcePath}) 下`,
);
checks[checkId] = {
ok: true,
message: `外部拖入文件进入目标文件夹 docs/`,
droppedPath,
exists,
2026-05-24 02:34:52 +08:00
uploadAsset,
2026-05-24 01:49:51 +08:00
};
} catch (err) {
overallOk = false;
checks[checkId] = {
ok: false,
message: `文件树 folder drop 检查失败: ${err.message}`,
error: err.stack,
};
}
}
// ──────────────────────────────────────────────────────────────────────────
// Check 3: 删除真实附件文件后,刷新页面,正文中的 Markdown 链接仍应可见
// 如果当前实现失败,先记录当前 failure,但断言应表达预期行为
// ──────────────────────────────────────────────────────────────────────────
{
const checkId = "broken-link-after-real-file-delete";
try {
await page.goto(documentUrl(root, relativePath), {
waitUntil: "domcontentloaded",
timeout: UI_TIMEOUT_MS,
});
await page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
// 先确认正文中有附件链接
const linkTextBefore = await page.evaluate(() => {
const editor = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror');
return editor instanceof HTMLElement ? editor.textContent || "" : "";
});
assert(
linkTextBefore.includes("resource-note.md"),
`初始正文应包含附件链接 resource-note.md,实际内容: ${linkTextBefore.slice(0, 500)}`,
);
// 从磁盘删除真实附件文件
const assetPath = path.join(resourceDir, "resource-note.md");
assert(fs.existsSync(assetPath), "测试前置条件:附件文件应存在");
fs.unlinkSync(assetPath);
// 刷新页面
await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
// 给异步解析和渲染留时间
await page.waitForTimeout(1000);
// 检查正文是否仍包含附件链接文本
const linkTextAfter = await page.evaluate(() => {
const editor = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror');
return editor instanceof HTMLElement ? editor.textContent || "" : "";
});
// 预期行为:链接应保留
const linkPreserved = linkTextAfter.includes("resource-note.md");
checks[checkId] = {
ok: linkPreserved,
message: linkPreserved
? "删除真实附件后刷新,正文链接仍保留"
: "删除真实附件后刷新,正文链接消失(当前实现未保留 broken link",
assetPath,
deleted: true,
linkPreserved,
editorText: linkTextAfter.slice(0, 500),
};
if (!linkPreserved) {
overallOk = false;
}
} catch (err) {
overallOk = false;
checks[checkId] = {
ok: false,
message: `删除真实附件后正文链接保留检查失败: ${err.message}`,
error: err.stack,
};
}
}
// ──────────────────────────────────────────────────────────────────────────
// Check 4: 刚进入 local folder、未先打开任何 Markdown 文件时,
// 点击 PDF / Markdown 附件 row 应在主编辑区 tab 打开,不应产生 popup。
// 若当前 UI 无法直接进入这种状态,记录原因并把该 check 标记为 skipped
// ──────────────────────────────────────────────────────────────────────────
{
const checkId = "direct-attach-open-without-active-md";
try {
// 使用独立 asset-only 工作区,确保 local folder 首屏没有 active Markdown document。
const assetOnlyRoot = path.join(root, "asset-only-workspace");
fs.mkdirSync(path.join(assetOnlyRoot, "attachments"), { recursive: true });
writeWorkspaceManifest(assetOnlyRoot, "user_real", "task479-asset-only");
fs.writeFileSync(path.join(assetOnlyRoot, "attachments", "report-a.pdf"), Buffer.from("%PDF-1.4\n% task479 asset-only report-a\n", "utf8"));
const homeUrl = localFolderHomeUrl(assetOnlyRoot);
await page.goto(homeUrl, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
// 等待文件树就绪
const fileTreeRoot = page.locator("#sidebar-file-tree-root").first();
await fileTreeRoot.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const hasResourceHost = await page.locator('[data-mnote-resource-tab-host][data-pane-role="primary"]')
.first()
.count()
.then((count) => count > 0)
.catch(() => false);
const resourceFolderRow = page
.locator('#sidebar-file-tree-root .tree-row[data-node-id="local:node:attachments"], #sidebar-file-tree-root .tree-row[data-row-id="local:folder:attachments"]')
.first();
if (await resourceFolderRow.isVisible().catch(() => false)) {
const expanded = await resourceFolderRow.getAttribute("aria-expanded").catch(() => null);
const resourceFolderToggle = resourceFolderRow.locator('[data-testid="filetree-toggle"]').first();
if (expanded !== "true" && await resourceFolderToggle.isVisible().catch(() => false)) {
await resourceFolderToggle.click({ timeout: UI_TIMEOUT_MS }).catch(() => undefined);
}
}
// 找到附件 row (data-testid="filetree-asset-row" 或 data-row-kind="asset"/"attachment")
const pdfAssetRow = page.locator('[data-testid="filetree-asset-row"]', {
hasText: "report-a.pdf",
}).first();
await pdfAssetRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const hasPdfRow = await pdfAssetRow.isVisible().catch(() => false);
assert(hasResourceHost, "无 active Markdown document 时应渲染主编辑区 resource tab host");
assert(hasPdfRow, "无 active Markdown document 时应渲染 report-a.pdf 附件 row");
const popupCountBefore = popups.length;
await pdfAssetRow.locator(".tree-link").click({ timeout: UI_TIMEOUT_MS });
await page.waitForTimeout(2000);
const popupCountAfter = popups.length;
const resourceTabVisible = await page.locator('[data-testid="mnote-resource-tab-host"]:not([hidden])')
.isVisible().catch(() => false);
const resourceTabTitle = await page
.locator('[data-mnote-main-tab][data-pane-role="primary"] .mnote-main-tab-title', { hasText: "report-a.pdf" })
.first()
.textContent({ timeout: UI_TIMEOUT_MS })
.catch(() => "");
2026-05-24 02:40:15 +08:00
const rowState = await pdfAssetRow.evaluate((row) => ({
rowId: row.getAttribute("data-row-id") || "",
selected: row.getAttribute("data-selected") || "",
active: row.getAttribute("data-active") || "",
focused: row.getAttribute("data-focused") || "",
}));
const selectedRows = await page.locator('#sidebar-file-tree-root .tree-row[data-selected="true"]').evaluateAll((rows) => (
rows.map((row) => ({
rowId: row.getAttribute("data-row-id") || "",
text: row.textContent || "",
}))
));
const activeRows = await page.locator('#sidebar-file-tree-root .tree-row[data-active="true"]').evaluateAll((rows) => (
rows.map((row) => ({
rowId: row.getAttribute("data-row-id") || "",
text: row.textContent || "",
}))
));
2026-05-24 01:49:51 +08:00
assert(
popupCountAfter === popupCountBefore,
`点击附件 row 不应产生新浏览器窗口/标签页,popup 数:${popupCountBefore}${popupCountAfter}`,
);
assert(resourceTabVisible, "点击附件 row 后应显示主编辑区 resource tab host");
assert((resourceTabTitle || "").includes("report-a.pdf"), `资源 tab 标题应包含 report-a.pdf,实际: ${resourceTabTitle}`);
2026-05-24 02:40:15 +08:00
assert.equal(rowState.selected, "true", `点击附件 row 后应保持选中该 row,实际: ${JSON.stringify({ rowState, selectedRows })}`);
assert.equal(rowState.active, "true", `点击附件 row 后应保持 active 在该 row,实际: ${JSON.stringify({ rowState, activeRows })}`);
assert.deepEqual(selectedRows.map((row) => row.rowId), [rowState.rowId], `打开资源后不应跳选其他 row: ${JSON.stringify(selectedRows)}`);
assert.deepEqual(activeRows.map((row) => row.rowId), [rowState.rowId], `打开资源后 active row 不应跳到其他 row: ${JSON.stringify(activeRows)}`);
2026-05-24 01:49:51 +08:00
checks[checkId] = {
ok: true,
2026-05-24 02:40:15 +08:00
message: "无 active Markdown document 时,点击附件 row 后主编辑区 resource tab 打开,且 File Tree active/selected 保持在该附件 row",
2026-05-24 01:49:51 +08:00
popupCount: popupCountAfter - popupCountBefore,
resourceTabVisible,
resourceTabTitle,
2026-05-24 02:40:15 +08:00
rowState,
selectedRows,
activeRows,
2026-05-24 01:49:51 +08:00
};
} catch (err) {
overallOk = false;
checks[checkId] = {
ok: false,
message: `直接打开附件检查异常: ${err.message}`,
error: err.stack,
};
}
}
// ──────────────────────────────────────────────────────────────────────────
// Check 5: 文件树滚动到底和 resize handle 上滚轮后,
// document.scrollingElement.scrollTop 应保持 0 或不产生底部空白
// ──────────────────────────────────────────────────────────────────────────
{
const checkId = "filetree-scroll-no-body-scroll";
try {
await page.goto(documentUrl(root, relativePath), {
waitUntil: "domcontentloaded",
timeout: UI_TIMEOUT_MS,
});
await page.locator("#sidebar-file-tree-root").waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
// 先让文件树出现
await page.waitForTimeout(500);
// 获取滚动前的 body scrollTop
const scrollBefore = await page.evaluate(() => document.scrollingElement?.scrollTop ?? 0);
// 尝试让文件树滚动到底:在文件树上用鼠标滚轮
const fileTree = page.locator("#sidebar-file-tree-root").first();
await fileTree.hover({ timeout: UI_TIMEOUT_MS });
// 多次在文件树上滚轮模拟快速滚到底
for (let i = 0; i < 30; i++) {
await page.mouse.wheel(0, 120);
await new Promise((r) => setTimeout(r, 30));
}
// 再在文件树区域一次性大量滚轮
for (let i = 0; i < 50; i++) {
await page.mouse.wheel(0, 300);
await new Promise((r) => setTimeout(r, 20));
}
await page.waitForTimeout(500);
const scrollAfter = await page.evaluate(() => document.scrollingElement?.scrollTop ?? 0);
// 预期:body scrollTop 不应被文件树滚轮事件改变
const bodyNotScrolled = scrollAfter <= scrollBefore;
checks[checkId] = {
ok: bodyNotScrolled,
message: bodyNotScrolled
? "文件树内滚轮没有导致 body 滚动"
: "文件树滚轮导致 body 产生了滚动(出现底部空白)",
scrollBefore,
scrollAfter,
delta: scrollAfter - scrollBefore,
};
if (!bodyNotScrolled) {
overallOk = false;
}
} catch (err) {
overallOk = false;
checks[checkId] = {
ok: false,
message: `文件树滚动 body 空白检查失败: ${err.message}`,
error: err.stack,
};
}
}
// ──────────────────────────────────────────────────────────────────────────
// Check 6: 本地 Markdown 标题:File Name.md 且正文第一行 # Body Heading 时,
// File Tree 显示 "File Name.md",正文仍显示 "Body Heading"
// ──────────────────────────────────────────────────────────────────────────
{
const checkId = "markdown-title-from-filename-not-h1";
try {
const fileName = "MyNotes.md";
const fileRelPath = fileName;
// 确认文件已被写入
assert(fs.existsSync(path.join(root, fileName)), "File Name.md 应存在");
// 导航到该文件
await page.goto(documentUrl(root, fileRelPath), {
waitUntil: "domcontentloaded",
timeout: UI_TIMEOUT_MS,
});
await page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
// 等待文件树就绪
await page.locator("#sidebar-file-tree-root").waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
await page.waitForTimeout(500);
// 从文件树获知 MyNotes.md 的标题
const treeTitle = await page.evaluate(() => {
const rows = document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-row-id^="local:markdown:"]');
for (const row of rows) {
const text = row.querySelector(".tree-link-title")?.textContent?.trim() || row.textContent?.trim() || "";
if (text.includes("MyNotes")) return text;
}
return null;
});
// 获取正文第一行 / 标题
const bodyHeading = await page.evaluate(() => {
const editor = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror');
if (!(editor instanceof HTMLElement)) return null;
// 尝试获取第一个 h1
const h1 = editor.querySelector("h1");
if (h1 instanceof HTMLElement) return h1.textContent?.trim() || "";
// fallback: textContent 第一行
return (editor.textContent || "").split("\n").map((l) => l.trim()).filter(Boolean)[0] || "";
});
// 预期:文件树显示 MyNotes.md(文件名),正文显示 Body HeadingH1 内容)
const treeShowsFileName = treeTitle && treeTitle.includes("MyNotes.md");
const bodyShowsHeading = bodyHeading && bodyHeading.includes("Body Heading");
checks[checkId] = {
ok: !!(treeShowsFileName && bodyShowsHeading),
message: `treeTitle="${treeTitle}" bodyHeading="${bodyHeading}"`,
treeTitle,
bodyHeading,
treeShowsFileName,
bodyShowsHeading,
};
} catch (err) {
overallOk = false;
checks[checkId] = {
ok: false,
message: `标题来源检查失败: ${err.message}`,
error: err.stack,
};
}
}
// ──────────────────────────────────────────────────────────────────────────
// Check 7: 主编辑区删除一个附件引用时,只删除正文链接,不级联删除真实文件;
// 相邻附件仍保持可点击附件块,手柄删除刷新后不回来。
// ──────────────────────────────────────────────────────────────────────────
{
const checkId = "editor-delete-one-attachment-preserves-adjacent";
try {
const keyboardPath = "DeleteKeyboard.md";
const keyboardFile = path.join(root, keyboardPath);
await page.goto(documentUrl(root, keyboardPath), {
waitUntil: "domcontentloaded",
timeout: UI_TIMEOUT_MS,
});
await page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
await waitForPrimaryAttachment(page, "first.md");
await waitForPrimaryAttachment(page, "second.md");
await deleteAttachmentByKeyboard(page, "second.md");
const keyboardPersisted = await waitForFileContent(
keyboardFile,
(content) => content.includes("[first.md](DeleteKeyboard/first.md)") && !content.includes("second.md"),
UI_TIMEOUT_MS,
);
assert(
keyboardPersisted.ok,
`Backspace 删除第二个附件后,Markdown 应保留 first 链接且移除 second。实际内容:\n${keyboardPersisted.content}`,
);
assert(fs.existsSync(path.join(root, "DeleteKeyboard", "first.md")), "Backspace 删除引用不应删除 first.md 真实文件");
assert(fs.existsSync(path.join(root, "DeleteKeyboard", "second.md")), "Backspace 删除引用不应删除 second.md 真实文件");
await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await waitForPrimaryAttachment(page, "first.md");
const keyboardSecondVisible = await page
.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror a', { hasText: "second.md" })
.first()
.isVisible()
.catch(() => false);
assert(!keyboardSecondVisible, "Backspace 删除后刷新,second.md 引用不应回来");
const handlePath = "DeleteHandle.md";
const handleFile = path.join(root, handlePath);
await page.goto(documentUrl(root, handlePath), {
waitUntil: "domcontentloaded",
timeout: UI_TIMEOUT_MS,
});
await page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
await waitForPrimaryAttachment(page, "first.md");
await waitForPrimaryAttachment(page, "second.md");
await deleteAttachmentByBlockHandle(page, "second.md");
const handlePersisted = await waitForFileContent(
handleFile,
(content) => content.includes("[first.md](DeleteHandle/first.md)") && !content.includes("second.md"),
UI_TIMEOUT_MS,
);
assert(
handlePersisted.ok,
`手柄删除第二个附件后,Markdown 应保留 first 链接且移除 second。实际内容:\n${handlePersisted.content}`,
);
assert(fs.existsSync(path.join(root, "DeleteHandle", "first.md")), "手柄删除引用不应删除 first.md 真实文件");
assert(fs.existsSync(path.join(root, "DeleteHandle", "second.md")), "手柄删除引用不应删除 second.md 真实文件");
await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await waitForPrimaryAttachment(page, "first.md");
const handleSecondVisible = await page
.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror a', { hasText: "second.md" })
.first()
.isVisible()
.catch(() => false);
assert(!handleSecondVisible, "手柄删除后刷新,second.md 引用不应回来");
checks[checkId] = {
ok: true,
message: "Backspace 与手柄删除单个附件引用后,相邻附件仍是可点击附件块,真实文件保留,刷新后被删引用不回来",
keyboardContent: keyboardPersisted.content,
handleContent: handlePersisted.content,
};
} catch (err) {
overallOk = false;
checks[checkId] = {
ok: false,
message: `删除单个附件引用检查失败: ${err.message}`,
error: err.stack,
};
}
}
2026-05-24 01:49:51 +08:00
// ── 截图 ──
{
const shotTimestamp = Date.now();
const shotDir = path.join(root, `screenshots-${shotTimestamp}`);
fs.mkdirSync(shotDir, { recursive: true });
await page.screenshot({ path: path.join(shotDir, "final-state.png"), fullPage: false });
screenshots.push(path.join(shotDir, "final-state.png"));
}
// ── 结果 ──
const result = {
ok: overallOk,
root,
checks,
screenshots,
popups: popups.length,
};
console.log(JSON.stringify(result, null, 2));
} finally {
await context.close().catch(() => undefined);
await browser.close().catch(() => undefined);
// 清理临时目录,保留 result JSON 被 console.log 输出即可
// 如果用户需要查看留下的文件,可设置 KEEP_TEMP=1
if (!process.env.KEEP_TEMP) {
try { fs.rmSync(root, { recursive: true, force: true }); } catch (_) { /* ignore cleanup errors */ }
}
}
}
main().catch((error) => {
console.error(error && error.stack ? error.stack : error);
process.exit(1);
});