288 lines
13 KiB
JavaScript
288 lines
13 KiB
JavaScript
#!/usr/bin/env node
|
|||
|
|
"use strict";
|
||
|
|
|
||
|
|
const assert = require("node:assert/strict");
|
||
|
|
const fs = require("node:fs");
|
||
|
|
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 ROOT_PATH = process.env.MNOTE_WOLAI_PAGE_REF_SMOKE_ROOT
|
||
|
|
|| "/mnt/Data1T/Mnote_data/users/liaibo/workspaces/my-space";
|
||
|
|
const PARENT_PAGE = process.env.MNOTE_WOLAI_PAGE_REF_PARENT
|
||
|
|
|| "liaibo的个人空间/项目/项目.md";
|
||
|
|
const CHILD_PAGE = process.env.MNOTE_WOLAI_PAGE_REF_CHILD
|
||
|
|
|| "liaibo的个人空间/项目/完结项目/完结项目.md";
|
||
|
|
const GRANDCHILD_PAGE = process.env.MNOTE_WOLAI_PAGE_REF_GRANDCHILD
|
||
|
|
|| "liaibo的个人空间/项目/完结项目/爱斯特完结项目/爱斯特完结项目.md";
|
||
|
|
const USERNAME = process.env.MNOTE_WOLAI_PAGE_REF_USER || "mnote.e2e@example.com";
|
||
|
|
const PASSWORD = process.env.MNOTE_WOLAI_PAGE_REF_PASSWORD || "MnoteE2E123!";
|
||
|
|
const OUT_DIR = path.join(process.cwd(), "tmp", "task803-local-page-reference-restore-smoke");
|
||
|
|
const RESULT_PATH = path.join(OUT_DIR, "result.json");
|
||
|
|
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|
||
|
|
|| ["/usr/bin/chromium-browser", "/usr/bin/chromium", "/usr/bin/google-chrome-stable", "/snap/bin/chromium"]
|
||
|
|
.find((candidate) => fs.existsSync(candidate));
|
||
|
|
|
||
|
|
function fileUrl(localPath) {
|
||
|
|
return `file://${localPath}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function encodeLocalIdSegment(value) {
|
||
|
|
const bytes = Buffer.from(String(value || ""), "utf8");
|
||
|
|
let encoded = "";
|
||
|
|
for (const byte of bytes) {
|
||
|
|
const character = String.fromCharCode(byte);
|
||
|
|
if (
|
||
|
|
(byte >= 48 && byte <= 57)
|
||
|
|
|| (byte >= 65 && byte <= 90)
|
||
|
|
|| (byte >= 97 && byte <= 122)
|
||
|
|
|| character === "."
|
||
|
|
|| character === "_"
|
||
|
|
|| character === "-"
|
||
|
|
) {
|
||
|
|
encoded += character;
|
||
|
|
} else {
|
||
|
|
encoded += `~${byte.toString(16).toUpperCase().padStart(2, "0")}`;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return encoded;
|
||
|
|
}
|
||
|
|
|
||
|
|
function localMdDocumentId(relativePath) {
|
||
|
|
return `local-md:${encodeLocalIdSegment(relativePath)}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function documentUrl(relativePath) {
|
||
|
|
const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`);
|
||
|
|
url.searchParams.set("sourceKind", "local_folder");
|
||
|
|
url.searchParams.set("rootUri", fileUrl(ROOT_PATH));
|
||
|
|
url.searchParams.set("treeView", "filetree");
|
||
|
|
return url.toString();
|
||
|
|
}
|
||
|
|
|
||
|
|
async function signIn(request) {
|
||
|
|
const response = await request.post(`${BASE_URL}/api/auth`, {
|
||
|
|
headers: { "content-type": "application/json", accept: "application/json" },
|
||
|
|
data: {
|
||
|
|
action: "auth:signIn",
|
||
|
|
args: {
|
||
|
|
provider: "password",
|
||
|
|
params: {
|
||
|
|
account: USERNAME,
|
||
|
|
password: PASSWORD,
|
||
|
|
flow: "signIn",
|
||
|
|
},
|
||
|
|
},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
assert.equal(response.status(), 200, `登录失败: ${response.status()} ${await response.text()}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
async function waitForEditor(page) {
|
||
|
|
await page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]').first()
|
||
|
|
.waitFor({ state: "visible" });
|
||
|
|
await page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first()
|
||
|
|
.waitFor({ state: "visible" });
|
||
|
|
}
|
||
|
|
|
||
|
|
async function pageReferenceTexts(page) {
|
||
|
|
return page.evaluate(() => Array.from(document.querySelectorAll(".editor-surface .ProseMirror a.mnote-page-block-link"))
|
||
|
|
.map((anchor) => ({
|
||
|
|
text: (anchor.textContent || "").trim(),
|
||
|
|
href: anchor.getAttribute("href") || "",
|
||
|
|
className: anchor.className || "",
|
||
|
|
target: anchor.getAttribute("target") || "",
|
||
|
|
})));
|
||
|
|
}
|
||
|
|
|
||
|
|
async function pageBlockVisual(page, title) {
|
||
|
|
return page.locator(".editor-surface .ProseMirror a.mnote-page-block-link", { hasText: title }).first().evaluate((anchor) => {
|
||
|
|
const style = window.getComputedStyle(anchor);
|
||
|
|
const before = window.getComputedStyle(anchor, "::before");
|
||
|
|
const row = anchor.closest("p");
|
||
|
|
const rowStyle = row ? window.getComputedStyle(row) : null;
|
||
|
|
return {
|
||
|
|
text: anchor.textContent || "",
|
||
|
|
display: style.display,
|
||
|
|
color: style.color,
|
||
|
|
fontWeight: style.fontWeight,
|
||
|
|
textDecorationLine: style.textDecorationLine,
|
||
|
|
beforeWidth: before.width,
|
||
|
|
beforeHeight: before.height,
|
||
|
|
rowBackground: rowStyle ? rowStyle.backgroundColor : "",
|
||
|
|
};
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function assertWolaiPageBlockVisual(visual) {
|
||
|
|
assert.equal(visual.display, "inline-flex", `页面块应保持图标+标题的 inline-flex 形态: ${JSON.stringify(visual)}`);
|
||
|
|
assert.notEqual(visual.color, "rgb(0, 0, 238)", `页面块不能退化为浏览器默认蓝色链接: ${JSON.stringify(visual)}`);
|
||
|
|
assert.ok(visual.textDecorationLine.includes("underline"), `页面块标题应保留 Wolai 风格下划线: ${JSON.stringify(visual)}`);
|
||
|
|
assert.ok(parseFloat(visual.beforeWidth) >= 14 && parseFloat(visual.beforeHeight) >= 14, `页面块必须有独立页面图标: ${JSON.stringify(visual)}`);
|
||
|
|
assert.ok(
|
||
|
|
["", "rgba(0, 0, 0, 0)", "transparent"].includes(visual.rowBackground),
|
||
|
|
`页面块常态不能渲染整行浅红背景: ${JSON.stringify(visual)}`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function installMutationProbe(page) {
|
||
|
|
return page.evaluate(() => {
|
||
|
|
if (window.__mnoteTask803MutationObserver) {
|
||
|
|
window.__mnoteTask803MutationObserver.disconnect();
|
||
|
|
}
|
||
|
|
const countElementNodes = (node) => {
|
||
|
|
if (!(node instanceof Element)) return 0;
|
||
|
|
return 1 + node.querySelectorAll("*").length;
|
||
|
|
};
|
||
|
|
const roots = [
|
||
|
|
["sidebar", document.querySelector(".mnote-sidebar, #sidebar-file-tree-root, #sidebar-tree-root")],
|
||
|
|
["workspace", document.querySelector("[data-testid='mnote-document-workspace'], .document-workspace")],
|
||
|
|
].filter((entry) => entry[1] instanceof Element);
|
||
|
|
window.__mnoteTask803MutationProbe = {
|
||
|
|
navigationEntries: performance.getEntriesByType("navigation").length,
|
||
|
|
records: [],
|
||
|
|
};
|
||
|
|
const observer = new MutationObserver((records) => {
|
||
|
|
for (const record of records) {
|
||
|
|
const owner = roots.find((entry) => entry[1].contains(record.target));
|
||
|
|
window.__mnoteTask803MutationProbe.records.push({
|
||
|
|
owner: owner ? owner[0] : "unknown",
|
||
|
|
added: Array.from(record.addedNodes).reduce((sum, node) => sum + countElementNodes(node), 0),
|
||
|
|
removed: Array.from(record.removedNodes).reduce((sum, node) => sum + countElementNodes(node), 0),
|
||
|
|
});
|
||
|
|
}
|
||
|
|
});
|
||
|
|
roots.forEach((entry) => observer.observe(entry[1], { childList: true, subtree: true }));
|
||
|
|
window.__mnoteTask803MutationObserver = observer;
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function readMutationProbe(page) {
|
||
|
|
return page.evaluate(() => {
|
||
|
|
if (window.__mnoteTask803MutationObserver) {
|
||
|
|
window.__mnoteTask803MutationObserver.disconnect();
|
||
|
|
}
|
||
|
|
const probe = window.__mnoteTask803MutationProbe || { navigationEntries: 0, records: [] };
|
||
|
|
const summary = {
|
||
|
|
navigationEntriesBefore: probe.navigationEntries,
|
||
|
|
navigationEntriesAfter: performance.getEntriesByType("navigation").length,
|
||
|
|
sidebarAdded: 0,
|
||
|
|
sidebarRemoved: 0,
|
||
|
|
workspaceAdded: 0,
|
||
|
|
workspaceRemoved: 0,
|
||
|
|
};
|
||
|
|
for (const record of probe.records || []) {
|
||
|
|
if (record.owner === "sidebar") {
|
||
|
|
summary.sidebarAdded += record.added || 0;
|
||
|
|
summary.sidebarRemoved += record.removed || 0;
|
||
|
|
}
|
||
|
|
if (record.owner === "workspace") {
|
||
|
|
summary.workspaceAdded += record.added || 0;
|
||
|
|
summary.workspaceRemoved += record.removed || 0;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return summary;
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function assertLocalPageHref(item, expectedRelativePath) {
|
||
|
|
const url = new URL(item.href, BASE_URL);
|
||
|
|
assert.ok(
|
||
|
|
decodeURIComponent(url.pathname).includes(localMdDocumentId(expectedRelativePath)),
|
||
|
|
`页面块 href 未指向目标页面: ${JSON.stringify({ item, expectedRelativePath })}`,
|
||
|
|
);
|
||
|
|
assert.equal(url.searchParams.get("sourceKind"), "local_folder", JSON.stringify(item));
|
||
|
|
assert.equal(url.searchParams.get("rootUri"), fileUrl(ROOT_PATH), JSON.stringify(item));
|
||
|
|
assert.equal(item.target, "_self", JSON.stringify(item));
|
||
|
|
}
|
||
|
|
|
||
|
|
async function main() {
|
||
|
|
fs.mkdirSync(OUT_DIR, { recursive: true });
|
||
|
|
const browser = await chromium.launch({
|
||
|
|
headless: process.env.HEADFUL !== "1",
|
||
|
|
...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
|
||
|
|
});
|
||
|
|
const context = await browser.newContext({
|
||
|
|
viewport: { width: 1440, height: 960 },
|
||
|
|
locale: "zh-CN",
|
||
|
|
});
|
||
|
|
const page = await context.newPage();
|
||
|
|
page.setDefaultTimeout(Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 45_000));
|
||
|
|
const clickRequests = [];
|
||
|
|
let collectClickRequests = false;
|
||
|
|
page.on("request", (request) => {
|
||
|
|
if (!collectClickRequests) return;
|
||
|
|
clickRequests.push({
|
||
|
|
method: request.method(),
|
||
|
|
resourceType: request.resourceType(),
|
||
|
|
url: request.url(),
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
try {
|
||
|
|
await signIn(context.request);
|
||
|
|
|
||
|
|
await page.goto(documentUrl(PARENT_PAGE), { waitUntil: "domcontentloaded" });
|
||
|
|
await waitForEditor(page);
|
||
|
|
const parentRefs = await pageReferenceTexts(page);
|
||
|
|
const completeProjectRef = parentRefs.find((item) => item.text === "完结项目");
|
||
|
|
assert.ok(completeProjectRef, `父页面没有把 Markdown 子页面链接还原为页面块: ${JSON.stringify(parentRefs)}`);
|
||
|
|
assertLocalPageHref(completeProjectRef, CHILD_PAGE);
|
||
|
|
const parentPageBlockVisual = await pageBlockVisual(page, "完结项目");
|
||
|
|
assertWolaiPageBlockVisual(parentPageBlockVisual);
|
||
|
|
await page.screenshot({ path: path.join(OUT_DIR, "01-parent-page-reference.png"), fullPage: false });
|
||
|
|
|
||
|
|
await installMutationProbe(page);
|
||
|
|
collectClickRequests = true;
|
||
|
|
await page.locator(".editor-surface .ProseMirror a.mnote-page-block-link", { hasText: "完结项目" }).first().click();
|
||
|
|
await page.waitForURL((url) => decodeURIComponent(url.pathname).includes(localMdDocumentId(CHILD_PAGE)));
|
||
|
|
await waitForEditor(page);
|
||
|
|
collectClickRequests = false;
|
||
|
|
const firstClickProbe = await readMutationProbe(page);
|
||
|
|
const firstClickDocumentRequests = clickRequests.filter((request) => request.resourceType === "document");
|
||
|
|
const firstClickTreeProjectionRequests = clickRequests.filter((request) => request.url.includes("/api/tree/projections/"));
|
||
|
|
assert.equal(firstClickDocumentRequests.length, 0, `页面块点击不能触发整页 document 导航: ${JSON.stringify(firstClickDocumentRequests)}`);
|
||
|
|
assert.equal(firstClickTreeProjectionRequests.length, 0, `页面块点击不应重拉树 projection: ${JSON.stringify(firstClickTreeProjectionRequests)}`);
|
||
|
|
assert.equal(firstClickProbe.navigationEntriesAfter, firstClickProbe.navigationEntriesBefore, `页面块点击不能新增浏览器 navigation entry: ${JSON.stringify(firstClickProbe)}`);
|
||
|
|
assert.ok(firstClickProbe.sidebarAdded + firstClickProbe.sidebarRemoved <= 4, `页面块点击不应重建左侧 Sidebar DOM: ${JSON.stringify(firstClickProbe)}`);
|
||
|
|
const childRefs = await pageReferenceTexts(page);
|
||
|
|
const childTitles = childRefs.map((item) => item.text);
|
||
|
|
assert.ok(childTitles.includes("爱斯特完结项目"), `子页面纯文本行没有推断为页面块: ${JSON.stringify(childRefs)}`);
|
||
|
|
assert.ok(childTitles.includes("药友完结项目"), `子页面缺少其它同级页面块: ${JSON.stringify(childRefs)}`);
|
||
|
|
assert.ok(childTitles.includes("倍特完结项目"), `子页面缺少其它同级页面块: ${JSON.stringify(childRefs)}`);
|
||
|
|
assert.ok(!childTitles.includes("22"), `普通文本不应被误还原为页面块: ${JSON.stringify(childRefs)}`);
|
||
|
|
const grandchildRef = childRefs.find((item) => item.text === "爱斯特完结项目");
|
||
|
|
assertLocalPageHref(grandchildRef, GRANDCHILD_PAGE);
|
||
|
|
const childPageBlockVisual = await pageBlockVisual(page, "爱斯特完结项目");
|
||
|
|
assertWolaiPageBlockVisual(childPageBlockVisual);
|
||
|
|
await page.screenshot({ path: path.join(OUT_DIR, "02-child-inferred-page-references.png"), fullPage: false });
|
||
|
|
|
||
|
|
await page.locator(".editor-surface .ProseMirror a.mnote-page-block-link", { hasText: "爱斯特完结项目" }).first().click();
|
||
|
|
await page.waitForURL((url) => decodeURIComponent(url.pathname).includes(localMdDocumentId(GRANDCHILD_PAGE)));
|
||
|
|
await waitForEditor(page);
|
||
|
|
await page.screenshot({ path: path.join(OUT_DIR, "03-click-opened-grandchild.png"), fullPage: false });
|
||
|
|
|
||
|
|
const result = {
|
||
|
|
ok: true,
|
||
|
|
parentDocumentId: localMdDocumentId(PARENT_PAGE),
|
||
|
|
childDocumentId: localMdDocumentId(CHILD_PAGE),
|
||
|
|
grandchildDocumentId: localMdDocumentId(GRANDCHILD_PAGE),
|
||
|
|
firstClickProbe,
|
||
|
|
screenshots: [
|
||
|
|
path.join(OUT_DIR, "01-parent-page-reference.png"),
|
||
|
|
path.join(OUT_DIR, "02-child-inferred-page-references.png"),
|
||
|
|
path.join(OUT_DIR, "03-click-opened-grandchild.png"),
|
||
|
|
],
|
||
|
|
};
|
||
|
|
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
|
||
|
|
console.log(JSON.stringify(result, null, 2));
|
||
|
|
} finally {
|
||
|
|
await browser.close();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
main().catch((error) => {
|
||
|
|
console.error(error && error.stack ? error.stack : error);
|
||
|
|
process.exit(1);
|
||
|
|
});
|