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

1101 lines
52 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
"use strict";
const { loginViaAuthForm } = require('./lib/browser-auth-login');
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) {
// 7-76 P0: 标准表单登录(无测试快速登录按钮)
const base =
(typeof BASE_URL !== "undefined" && BASE_URL) ||
(typeof baseUrl !== "undefined" && baseUrl) ||
process.env.MNOTE_UI_BASE_URL ||
"http://127.0.0.1:3000";
const timeout =
(typeof UI_TIMEOUT_MS !== "undefined" && UI_TIMEOUT_MS) ||
(typeof TIMEOUT !== "undefined" && TIMEOUT) ||
30_000;
if (!String(page.url() || "").includes("/auth")) {
await page.goto(String(base).replace(/\/+$/, "") + "/auth", {
waitUntil: "commit",
timeout,
});
}
await loginViaAuthForm(page, {
baseUrl: base,
timeoutMs: timeout,
gotoAuth: false,
});
await page
.waitForURL((url) => !String(url).includes("/auth"), { timeout })
.catch(() => {});
}
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);
}
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 assertNoPrimaryConflictPanel(page, label) {
await page.waitForTimeout(1000);
const conflict = await page.evaluate(() => {
const pane = document.querySelector('.document-pane[data-pane-role="primary"]');
const panel = pane?.querySelector('[data-testid="mnote-editor-conflict-panel"]') || null;
const status = pane?.querySelector('[data-runtime-editor-status]')?.getAttribute('data-runtime-editor-status')
|| document.querySelector('[data-runtime-editor-status]')?.getAttribute('data-runtime-editor-status')
|| "";
return {
visible: panel instanceof HTMLElement && panel.offsetParent !== null,
text: panel instanceof HTMLElement ? panel.textContent || "" : "",
status,
};
});
assert(
!conflict.visible && conflict.status !== "external-change-conflict",
`${label} 不应出现文件冲突: ${JSON.stringify(conflict)}`,
);
}
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 waitForPrimaryAttachmentClass(page, fileName, className) {
await page.waitForFunction(
({ name, className }) => {
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(className);
},
{ name: fileName, className },
{ timeout: UI_TIMEOUT_MS },
);
return await waitForPrimaryAttachment(page, fileName)
.then((link) => link.evaluate((node) => {
const badgeColor = getComputedStyle(node, "::before").backgroundColor;
return `${node.getAttribute("class") || ""} ${badgeColor}`.trim();
}));
}
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",
);
}
// ─── 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");
// 为 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));
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 });
});
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,
});
const uploadStart = uploadResponses.length;
// 通过主编辑区斜杠上传上传一个 MD 文件
await uploadAttachmentViaPrimarySlash(page, "uploaded-one.md", "# Uploaded One\n\n第一个上传附件\n");
const uploadAsset = await waitForUploadAsset(
uploadResponses,
uploadStart,
(asset) => asset.file_name === "uploaded-one.md",
UI_TIMEOUT_MS,
);
// 等待后端 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}`,
);
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");
await assertNoPrimaryConflictPanel(page, "主编辑区上传附件后");
const uploadedClassBeforeReload = await waitForPrimaryAttachmentClass(
page,
"uploaded-one.md",
"mnote-uploaded-attachment-code",
);
assert(
uploadedClassBeforeReload.includes("mnote-uploaded-attachment-code"),
`上传后的 md 附件应使用 code/markdown 黑色图标 class,实际 class=${uploadedClassBeforeReload}`,
);
await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
const uploadedClassAfterReload = await waitForPrimaryAttachmentClass(
page,
"uploaded-one.md",
"mnote-uploaded-attachment-code",
);
assert(
uploadedClassAfterReload.includes("mnote-uploaded-attachment-code"),
`刷新后的 md 附件应保持 code/markdown 黑色图标 class,实际 class=${uploadedClassAfterReload}`,
);
checks[checkId] = {
ok: true,
message: `上传文件落在资源目录 ${correctPath},刷新后仍保持 md 附件图标 class`,
landedInResourceDir,
landedInRoot,
uploadAsset,
uploadedClassBeforeReload,
uploadedClassAfterReload,
};
} 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";
const uploadStart = uploadResponses.length;
// 使用 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);
const uploadAsset = await waitForUploadAsset(
uploadResponses,
uploadStart,
(asset) => asset.file_name === droppedFileName,
UI_TIMEOUT_MS,
);
assert(exists, `拖入 docs 文件夹后文件应出现在 ${droppedPath}`);
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");
// 确保没有出现在 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}) 下`,
);
await assertNoPrimaryConflictPanel(page, "文件树文件夹拖入文件后");
checks[checkId] = {
ok: true,
message: `外部拖入文件进入目标文件夹 docs/`,
droppedPath,
exists,
uploadAsset,
};
} 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.waitForFunction(
async () => {
const now = Date.now();
if (
typeof window.__mnoteRefreshEditorLocalAttachmentExistence === "function"
&& (!window.__mnoteTask479LastAttachmentRefreshAt || now - window.__mnoteTask479LastAttachmentRefreshAt > 500)
) {
window.__mnoteTask479LastAttachmentRefreshAt = now;
window.__mnoteRefreshEditorLocalAttachmentExistence();
await new Promise((resolve) => setTimeout(resolve, 250));
}
const link = Array.from(document.querySelectorAll('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror a[href*="/api/local-folder/files/open"]'))
.find((node) => (node.textContent || "").includes("resource-note.md"));
return link instanceof HTMLAnchorElement && link.getAttribute("data-mnote-attachment-missing") === "true";
},
null,
{ timeout: UI_TIMEOUT_MS },
);
const missingMarkedBeforeReload = await page.evaluate(() => {
const link = Array.from(document.querySelectorAll('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror a[href*="/api/local-folder/files/open"]'))
.find((node) => (node.textContent || "").includes("resource-note.md"));
return link instanceof HTMLAnchorElement
? {
missing: link.getAttribute("data-mnote-attachment-missing"),
text: link.textContent || "",
}
: null;
});
const markdownAfterDeleteBeforeReload = fs.readFileSync(path.join(root, relativePath), "utf8");
assert(markdownAfterDeleteBeforeReload.includes("[resource-note.md](README/resource-note.md)"), "watcher 标记 missing 不应改写 Markdown 正文");
// 刷新页面
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");
const popupCountBefore = popups.length;
await page.evaluate(() => {
const brokenLink = Array.from(document.querySelectorAll('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror a'))
.find((node) => node instanceof HTMLAnchorElement && (node.textContent || "").includes("resource-note.md"));
if (!(brokenLink instanceof HTMLAnchorElement)) throw new Error("broken_link_missing");
brokenLink.dispatchEvent(new MouseEvent("click", {
bubbles: true,
cancelable: true,
view: window,
}));
});
await page.waitForTimeout(1500);
const popupCountAfter = popups.length;
const resourceTabErrorVisible = await page
.locator('[data-testid="mnote-resource-tab-host"]:not([hidden]) .mnote-resource-tab-error, [data-mnote-resource-panel][data-resource-kind="error"]')
.first()
.isVisible()
.catch(() => false);
const resourceTabTitle = await page
.locator('[data-mnote-main-tab][data-pane-role="primary"] .mnote-main-tab-title', { hasText: "resource-note.md" })
.first()
.textContent({ timeout: UI_TIMEOUT_MS })
.catch(() => "");
assert.equal(popupCountAfter, popupCountBefore, "点击缺失附件不应打开新浏览器窗口");
assert(resourceTabErrorVisible, "点击缺失附件应进入 resource tab missing/error state");
assert((resourceTabTitle || "").includes("resource-note.md"), `缺失资源 tab 标题应包含 resource-note.md,实际: ${resourceTabTitle}`);
checks[checkId] = {
ok: linkPreserved,
message: linkPreserved
? "删除真实附件后刷新,正文链接仍保留;点击缺失链接进入 resource tab missing/error state"
: "删除真实附件后刷新,正文链接消失(当前实现未保留 broken link",
assetPath,
deleted: true,
linkPreserved,
missingMarkedBeforeReload,
editorText: linkTextAfter.slice(0, 500),
popupCount: popupCountAfter - popupCountBefore,
resourceTabErrorVisible,
resourceTabTitle,
};
if (!linkPreserved) {
overallOk = false;
}
} catch (err) {
overallOk = false;
const debugState = await page.evaluate(async () => {
const collectLinks = () => Array.from(document.querySelectorAll('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror a'))
.map((node) => ({
text: node.textContent || "",
href: node instanceof HTMLAnchorElement ? node.getAttribute("href") || "" : "",
missing: node instanceof HTMLElement ? node.getAttribute("data-mnote-attachment-missing") : "",
className: node instanceof HTMLElement ? node.getAttribute("class") || "" : "",
}));
const beforeManualRefresh = collectLinks();
if (typeof window.__mnoteRefreshEditorLocalAttachmentExistence === "function") {
window.__mnoteRefreshEditorLocalAttachmentExistence();
await new Promise((resolve) => setTimeout(resolve, 1500));
}
const pane = document.querySelector('.document-pane[data-pane-role="primary"]');
const editor = pane?.querySelector('.editor-surface .ProseMirror');
return {
url: window.location.href,
hasManualRefresh: typeof window.__mnoteRefreshEditorLocalAttachmentExistence,
status: pane?.querySelector('[data-runtime-editor-status]')?.getAttribute("data-runtime-editor-status") || "",
conflictVisible: Boolean(pane?.querySelector('[data-testid="mnote-editor-conflict-panel"]')),
editorText: editor instanceof HTMLElement ? (editor.textContent || "").slice(0, 500) : "",
beforeManualRefresh,
afterManualRefresh: collectLinks(),
};
}).catch((debugErr) => ({ debugError: debugErr.message }));
checks[checkId] = {
ok: false,
message: `删除真实附件后正文链接保留检查失败: ${err.message}`,
error: err.stack,
debugState,
};
}
}
// ──────────────────────────────────────────────────────────────────────────
// 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(() => "");
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 || "",
}))
));
assert(
popupCountAfter === popupCountBefore,
`点击附件 row 不应产生新浏览器窗口/标签页,popup 数:${popupCountBefore}${popupCountAfter}`,
);
assert(resourceTabVisible, "点击附件 row 后应显示主编辑区 resource tab host");
assert((resourceTabTitle || "").includes("report-a.pdf"), `资源 tab 标题应包含 report-a.pdf,实际: ${resourceTabTitle}`);
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)}`);
checks[checkId] = {
ok: true,
message: "无 active Markdown document 时,点击附件 row 后主编辑区 resource tab 打开,且 File Tree active/selected 保持在该附件 row",
popupCount: popupCountAfter - popupCountBefore,
resourceTabVisible,
resourceTabTitle,
rowState,
selectedRows,
activeRows,
};
} 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");
// 托管工作区(我的空间)默认显示页头标题。
const titleHeaderInitiallyVisible = await page.evaluate(() => {
const header = document.querySelector('.document-pane[data-pane-role="primary"] .document-shell-header');
return header instanceof HTMLElement ? !header.hidden && header.getAttribute("data-page-title-hidden") === "false" : false;
});
await page.locator('[data-testid="wolai-page-settings-trigger"]').click({ timeout: UI_TIMEOUT_MS });
const hideTitleCheckbox = page.locator('[data-page-option-checkbox="hideTitleHeader"]').first();
await hideTitleCheckbox.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const hideTitleCheckedBefore = await hideTitleCheckbox.isChecked();
assert.equal(hideTitleCheckedBefore, false, "托管工作区(我的空间)默认不隐藏文件标题");
const optionsPath = path.join(root, ".mnote", "page-options.json");
await hideTitleCheckbox.setChecked(true, { timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => {
const header = document.querySelector('.document-pane[data-pane-role="primary"] .document-shell-header');
return header instanceof HTMLElement ? header.hidden || header.getAttribute("data-page-title-hidden") === "true" : false;
},
null,
{ timeout: UI_TIMEOUT_MS },
);
assert(!fs.existsSync(optionsPath), "隐藏标题不应继续写入 .mnote/page-options.json");
const titleHeaderHiddenAfterCheck = await page.evaluate(() => {
const header = document.querySelector('.document-pane[data-pane-role="primary"] .document-shell-header');
return header instanceof HTMLElement ? header.hidden || header.getAttribute("data-page-title-hidden") === "true" : false;
});
assert(titleHeaderHiddenAfterCheck, "勾选隐藏标题后标题应隐藏");
await hideTitleCheckbox.setChecked(false, { timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => {
const header = document.querySelector('.document-pane[data-pane-role="primary"] .document-shell-header');
return header instanceof HTMLElement ? !header.hidden && header.getAttribute("data-page-title-hidden") === "false" : false;
},
null,
{ timeout: UI_TIMEOUT_MS },
);
assert(!fs.existsSync(optionsPath), "显示标题不应继续写入 .mnote/page-options.json");
const titleHeaderVisibleAfterToggle = await page.evaluate(() => {
const header = document.querySelector('.document-pane[data-pane-role="primary"] .document-shell-header');
return header instanceof HTMLElement ? !header.hidden && header.getAttribute("data-page-title-hidden") === "false" : false;
});
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,
});
const titleHeaderVisibleAfterReload = await page.evaluate(() => {
const header = document.querySelector('.document-pane[data-pane-role="primary"] .document-shell-header');
return header instanceof HTMLElement ? !header.hidden && header.getAttribute("data-page-title-hidden") === "false" : false;
});
checks[checkId] = {
ok: !!(treeShowsFileName && bodyShowsHeading && titleHeaderInitiallyVisible && titleHeaderVisibleAfterToggle && titleHeaderVisibleAfterReload),
message: `treeTitle="${treeTitle}" bodyHeading="${bodyHeading}"`,
treeTitle,
bodyHeading,
treeShowsFileName,
bodyShowsHeading,
titleHeaderInitiallyVisible,
hideTitleCheckedBefore,
titleHeaderHiddenAfterCheck,
titleHeaderVisibleAfterToggle,
titleHeaderVisibleAfterReload,
};
} 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,
};
}
}
// ── 截图 ──
{
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);
});