1014 lines
47 KiB
JavaScript
1014 lines
47 KiB
JavaScript
#!/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);
|
||
}
|
||
|
||
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 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, "主编辑区上传附件后");
|
||
|
||
checks[checkId] = {
|
||
ok: true,
|
||
message: `上传文件落在资源目录 ${correctPath}`,
|
||
landedInResourceDir,
|
||
landedInRoot,
|
||
uploadAsset,
|
||
};
|
||
} 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(
|
||
() => {
|
||
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;
|
||
const brokenLink = page
|
||
.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror a', {
|
||
hasText: "resource-note.md",
|
||
})
|
||
.first();
|
||
await brokenLink.click({ timeout: UI_TIMEOUT_MS });
|
||
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;
|
||
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(() => "");
|
||
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 Heading(H1 内容)
|
||
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, "托管工作区(我的空间)默认不隐藏文件标题");
|
||
await hideTitleCheckbox.setChecked(true, { timeout: UI_TIMEOUT_MS });
|
||
const optionsPath = path.join(root, ".mnote", "page-options.json");
|
||
const optionsHidden = await waitForFileContent(
|
||
optionsPath,
|
||
(content) => {
|
||
try {
|
||
const payload = JSON.parse(content);
|
||
return payload?.pages?.["local-md:MyNotes.md"]?.hideTitleHeader === true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
},
|
||
UI_TIMEOUT_MS,
|
||
);
|
||
assert(optionsHidden.ok, `隐藏标题应持久化 hideTitleHeader=true,实际: ${optionsHidden.content}`);
|
||
await page.waitForTimeout(500);
|
||
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 });
|
||
const optionsShown = await waitForFileContent(
|
||
optionsPath,
|
||
(content) => {
|
||
try {
|
||
const payload = JSON.parse(content);
|
||
return payload?.pages?.["local-md:MyNotes.md"]?.hideTitleHeader === false;
|
||
} catch {
|
||
return false;
|
||
}
|
||
},
|
||
UI_TIMEOUT_MS,
|
||
);
|
||
assert(optionsShown.ok, `显示标题应持久化 hideTitleHeader=false,实际: ${optionsShown.content}`);
|
||
await page.waitForTimeout(500);
|
||
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);
|
||
});
|