feat: restore Wolai workspace navigation and assets

This commit is contained in:
Agent Board
2026-07-15 23:12:15 +08:00
parent 31a160fcc5
commit 6f9c7d3b58
48 changed files with 6734 additions and 517 deletions
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env node
"use strict";
const path = require("node:path");
const { extractZipToTemp, importWolaiExport } = require("./lib/wolai-export-importer");
function parseArgs(argv) {
const args = {};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (!arg.startsWith("--")) continue;
const key = arg.slice(2);
if (["clear-target", "allow-unsafe-clear"].includes(key)) {
args[key] = true;
} else {
args[key] = argv[i + 1];
i += 1;
}
}
return args;
}
function usage() {
console.error([
"用法:",
" node scripts/import-wolai-export.js --source-dir <解包后的Gidnxc目录> --target-root <用户空间目录> --owner-id <用户ID> [--workspace-id <workspaceId>] [--clear-target] [--copy-mode link|copy]",
" node scripts/import-wolai-export.js --zip <wolai.zip> --target-root <用户空间目录> --owner-id <用户ID> [--root-md <Gidnxc/个人空间.md>] [--clear-target]",
].join("\n"));
}
function main() {
const args = parseArgs(process.argv.slice(2));
const targetRoot = args["target-root"];
const ownerId = args["owner-id"];
if (!targetRoot || !ownerId || (!args["source-dir"] && !args.zip)) {
usage();
process.exit(2);
}
let sourceRoot = args["source-dir"];
if (args.zip) {
const tempRoot = extractZipToTemp(path.resolve(args.zip));
sourceRoot = tempRoot;
}
const result = importWolaiExport({
sourceRoot,
targetRoot,
ownerId,
workspaceId: args["workspace-id"],
rootMd: args["root-md"],
clearTarget: Boolean(args["clear-target"]),
allowUnsafeClear: Boolean(args["allow-unsafe-clear"]),
copyMode: args["copy-mode"] === "copy" ? "copy" : "link",
});
console.log(JSON.stringify(result, null, 2));
}
main();
+781
View File
@@ -0,0 +1,781 @@
"use strict";
const crypto = require("node:crypto");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { spawnSync } = require("node:child_process");
const ASSET_DIRS = ["image", "file", "video", "audio", "resources"];
const SYNTHETIC_ORPHAN_REL = "__mnote_synthetic__/unfiled.md";
function toPosix(value) {
return String(value || "").replace(/\\/g, "/");
}
function stripOuterSlashes(value) {
return toPosix(value).replace(/^\/+/, "").replace(/\/+$/, "");
}
function ensureDir(dirPath) {
fs.mkdirSync(dirPath, { recursive: true });
}
function readUtf8(filePath) {
return fs.readFileSync(filePath, "utf8").replace(/^\uFEFF/, "");
}
function writeUtf8(filePath, content) {
ensureDir(path.dirname(filePath));
fs.writeFileSync(filePath, content, "utf8");
}
function sha1Short(value) {
return crypto.createHash("sha1").update(String(value)).digest("hex").slice(0, 8);
}
function safeDecodeUri(value) {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function unescapeMarkdownDestination(value) {
return String(value || "").replace(/\\([!"#$%&'()*+,./:;<=>?@[\\\]^_`{|}~-])/g, "$1");
}
function trimAsciiStart(value) {
return String(value || "").replace(/^[ \t\r\n]+/, "");
}
function isAsciiWhitespace(value) {
return value === " " || value === "\t" || value === "\r" || value === "\n";
}
function isExternalHref(href) {
return /^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith("#") || href.startsWith("//");
}
function normalizeSourceHref(baseRel, href) {
const trimmed = String(href || "").trim();
if (!trimmed || isExternalHref(trimmed)) return null;
const decoded = unescapeMarkdownDestination(safeDecodeUri(trimmed));
const baseDir = path.posix.dirname(toPosix(baseRel));
const joined = baseDir === "." ? decoded : path.posix.join(baseDir, decoded);
const normalized = path.posix.normalize(joined);
if (!normalized || normalized === ".") return null;
return stripOuterSlashes(normalized);
}
function findClosingSquare(text, start) {
let escaped = false;
let depth = 0;
for (let i = start; i < text.length; i += 1) {
const ch = text[i];
if (escaped) {
escaped = false;
continue;
}
if (ch === "\\") {
escaped = true;
continue;
}
if (ch === "[") depth += 1;
if (ch === "]") {
depth -= 1;
if (depth === 0) return i;
}
}
return -1;
}
function findClosingParen(text, start) {
let escaped = false;
let inAngle = false;
let quote = "";
let nested = 0;
for (let i = start; i < text.length; i += 1) {
const ch = text[i];
if (escaped) {
escaped = false;
continue;
}
if (ch === "\\") {
escaped = true;
continue;
}
if (quote) {
if (ch === quote) quote = "";
continue;
}
if (inAngle) {
if (ch === ">") inAngle = false;
continue;
}
if (ch === "<") {
inAngle = true;
continue;
}
if (ch === "\"" || ch === "'") {
quote = ch;
continue;
}
if (ch === "(") {
nested += 1;
continue;
}
if (ch === ")") {
if (nested > 0) {
nested -= 1;
continue;
}
return i;
}
}
return -1;
}
function splitDestinationAndTitle(rawInside) {
const raw = String(rawInside || "").trim();
if (!raw) return null;
if (raw.startsWith("<")) {
const end = raw.indexOf(">");
if (end > 0) {
return {
destination: raw.slice(1, end),
suffix: trimAsciiStart(raw.slice(end + 1)),
};
}
}
let escaped = false;
let nested = 0;
for (let i = 0; i < raw.length; i += 1) {
const ch = raw[i];
if (escaped) {
escaped = false;
continue;
}
if (ch === "\\") {
escaped = true;
continue;
}
if (ch === "(") nested += 1;
if (ch === ")" && nested > 0) nested -= 1;
if (isAsciiWhitespace(ch) && nested === 0) {
const suffix = trimAsciiStart(raw.slice(i));
if (!suffix || (!suffix.startsWith("\"") && !suffix.startsWith("'") && !suffix.startsWith("("))) {
continue;
}
return {
destination: raw.slice(0, i),
suffix,
};
}
}
return { destination: raw, suffix: "" };
}
function walkMarkdownLinks(markdown, visitor) {
let output = "";
let cursor = 0;
for (let i = 0; i < markdown.length; i += 1) {
const isImage = markdown[i] === "!" && markdown[i + 1] === "[";
const linkStart = isImage ? i + 1 : i;
if (markdown[linkStart] !== "[") continue;
const closeSquare = findClosingSquare(markdown, linkStart);
if (closeSquare < 0 || markdown[closeSquare + 1] !== "(") continue;
const openParen = closeSquare + 1;
const closeParen = findClosingParen(markdown, openParen + 1);
if (closeParen < 0) continue;
const rawInside = markdown.slice(openParen + 1, closeParen);
const parsed = splitDestinationAndTitle(rawInside);
if (!parsed) continue;
const replacement = visitor({
destination: parsed.destination,
suffix: parsed.suffix,
isImage,
rawInside,
label: markdown.slice(linkStart + 1, closeSquare),
});
if (typeof replacement === "string") {
output += markdown.slice(cursor, openParen + 1);
output += replacement;
cursor = closeParen;
} else if (replacement && typeof replacement.destination === "string") {
output += markdown.slice(cursor, linkStart + 1);
output += typeof replacement.label === "string"
? replacement.label
: markdown.slice(linkStart + 1, closeSquare);
output += markdown.slice(closeSquare, openParen + 1);
output += replacement.destination;
cursor = closeParen;
}
i = closeParen;
}
if (!output) return markdown;
return output + markdown.slice(cursor);
}
function collectLinks(markdown) {
const links = [];
walkMarkdownLinks(markdown, (link) => {
links.push(link);
return null;
});
return links;
}
function parseTitle(markdown, fallbackFileName) {
for (const line of markdown.split(/\r?\n/)) {
const trimmed = line.trim();
if (trimmed.startsWith("# ")) {
const title = trimmed.slice(2).trim();
if (title) return title;
}
}
return path.posix
.basename(toPosix(fallbackFileName), path.posix.extname(toPosix(fallbackFileName)))
.replace(/_[A-Za-z0-9-]{8,}$/, "")
.trim()
|| "未命名页面";
}
function safeSegment(title, oldRel) {
const chars = Array.from(String(title || "未命名页面")
.replace(/[\/\\\0-\x1F\x7F]/g, "-")
.replace(/[<>:"|?*]/g, "-")
.replace(/\s+/g, " ")
.trim()
.replace(/[. ]+$/g, ""));
const base = chars.slice(0, 80).join("").trim() || `未命名页面-${sha1Short(oldRel)}`;
if (base === "." || base === "..") return `页面-${sha1Short(oldRel)}`;
return base;
}
function uniqueSegment(parentKey, title, oldRel, usedByParent) {
const used = usedByParent.get(parentKey) || new Set();
usedByParent.set(parentKey, used);
const base = safeSegment(title, oldRel);
if (!used.has(base)) {
used.add(base);
return base;
}
const withHash = `${base}-${sha1Short(oldRel)}`;
if (!used.has(withHash)) {
used.add(withHash);
return withHash;
}
let counter = 2;
while (used.has(`${withHash}-${counter}`)) counter += 1;
const value = `${withHash}-${counter}`;
used.add(value);
return value;
}
function listFilesRecursive(root) {
const files = [];
function walk(current) {
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
const fullPath = path.join(current, entry.name);
if (entry.isDirectory()) {
walk(fullPath);
} else if (entry.isFile()) {
files.push(toPosix(path.relative(root, fullPath)));
}
}
}
if (fs.existsSync(root)) walk(root);
files.sort();
return files;
}
function findExportRoot(sourcePath) {
const resolved = path.resolve(sourcePath);
if (fs.existsSync(path.join(resolved, "pages")) && fs.statSync(path.join(resolved, "pages")).isDirectory()) {
return resolved;
}
const children = fs
.readdirSync(resolved, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => path.join(resolved, entry.name))
.filter((candidate) => fs.existsSync(path.join(candidate, "pages")));
if (children.length === 1) return children[0];
throw new Error(`无法识别 Wolai 导出根目录: ${resolved}`);
}
function findRootMarkdown(sourceRoot, rootMd) {
if (rootMd) {
const target = path.join(sourceRoot, rootMd);
if (!fs.existsSync(target)) throw new Error(`rootMd 不存在: ${target}`);
return stripOuterSlashes(rootMd);
}
const candidates = fs
.readdirSync(sourceRoot, { withFileTypes: true })
.filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith(".md"))
.map((entry) => entry.name)
.sort((a, b) => {
const aPersonal = a.includes("个人空间") ? 0 : 1;
const bPersonal = b.includes("个人空间") ? 0 : 1;
return aPersonal - bPersonal || a.length - b.length || a.localeCompare(b);
});
if (!candidates.length) throw new Error(`导出根目录缺少顶层 Markdown: ${sourceRoot}`);
return candidates[0];
}
function buildPageModel(sourceRoot, rootRel) {
const sourceFiles = new Set(listFilesRecursive(sourceRoot));
const pageRels = listFilesRecursive(path.join(sourceRoot, "pages"))
.filter((rel) => rel.toLowerCase().endsWith(".md"))
.map((rel) => `pages/${rel}`);
const allPageRels = [rootRel, ...pageRels];
const pages = new Map();
const children = new Map();
for (const oldRel of allPageRels) {
const absolute = path.join(sourceRoot, oldRel);
const markdown = readUtf8(absolute);
pages.set(oldRel, {
oldRel,
title: parseTitle(markdown, oldRel),
markdown,
sourcePath: absolute,
synthetic: false,
});
}
for (const page of pages.values()) {
const childRels = [];
for (const link of collectLinks(page.markdown)) {
if (link.isImage) continue;
const normalized = normalizeSourceHref(page.oldRel, link.destination);
if (normalized && pages.has(normalized)) childRels.push(normalized);
}
children.set(page.oldRel, childRels);
}
const assignedParent = new Map([[rootRel, null]]);
const orderedChildren = new Map();
const queue = [rootRel];
for (let index = 0; index < queue.length; index += 1) {
const current = queue[index];
const nextChildren = [];
for (const child of children.get(current) || []) {
if (child === current || child === rootRel) continue;
if (!assignedParent.has(child)) {
assignedParent.set(child, current);
nextChildren.push(child);
queue.push(child);
}
}
orderedChildren.set(current, nextChildren);
}
const orphans = allPageRels.filter((oldRel) => !assignedParent.has(oldRel));
if (orphans.length) {
const syntheticDir = path.posix.dirname(SYNTHETIC_ORPHAN_REL);
const orphanMarkdown = [
"# 未归档",
"",
"以下页面在 Wolai 导出中存在,但不在根页面链接树下。",
"",
...orphans.map((oldRel) => {
const href = path.posix.relative(syntheticDir, oldRel);
return `- [${pages.get(oldRel).title}](${href})`;
}),
"",
].join("\n");
pages.set(SYNTHETIC_ORPHAN_REL, {
oldRel: SYNTHETIC_ORPHAN_REL,
title: "未归档",
markdown: orphanMarkdown,
sourcePath: null,
synthetic: true,
});
assignedParent.set(SYNTHETIC_ORPHAN_REL, rootRel);
orderedChildren.set(rootRel, [...(orderedChildren.get(rootRel) || []), SYNTHETIC_ORPHAN_REL]);
orderedChildren.set(SYNTHETIC_ORPHAN_REL, orphans);
for (const oldRel of orphans) assignedParent.set(oldRel, SYNTHETIC_ORPHAN_REL);
}
return { sourceFiles, pages, orderedChildren, assignedParent, rootRel, originalPageCount: allPageRels.length };
}
function assignTargetPaths(model, targetRoot) {
const usedByParent = new Map();
const targetByOldRel = new Map();
const ordered = [];
function assign(oldRel, parentDirRel) {
const page = model.pages.get(oldRel);
const parentKey = parentDirRel || ".";
const segment = uniqueSegment(parentKey, page.title, oldRel, usedByParent);
const dirRel = parentDirRel ? path.posix.join(parentDirRel, segment) : segment;
const markdownRel = path.posix.join(dirRel, `${segment}.md`);
const target = {
oldRel,
title: page.title,
dirRel,
markdownRel,
markdownPath: path.join(targetRoot, markdownRel),
parentOldRel: model.assignedParent.get(oldRel),
synthetic: page.synthetic,
};
targetByOldRel.set(oldRel, target);
ordered.push(oldRel);
for (const child of model.orderedChildren.get(oldRel) || []) {
assign(child, dirRel);
}
}
assign(model.rootRel, "");
return { targetByOldRel, ordered };
}
function copyOrLinkFile(sourcePath, targetPath, copyMode) {
ensureDir(path.dirname(targetPath));
if (fs.existsSync(targetPath)) return "existing";
if (copyMode === "link") {
try {
fs.linkSync(sourcePath, targetPath);
return "linked";
} catch {
fs.copyFileSync(sourcePath, targetPath);
return "copied";
}
}
fs.copyFileSync(sourcePath, targetPath);
return "copied";
}
function assetBucketForSourceRel(sourceRel) {
const top = stripOuterSlashes(sourceRel).split("/")[0];
if (top === "image" || top === "audio" || top === "video") return top;
if (top === "file" || top === "resources") return "file";
return "";
}
function sourceAssetRelForNormalized(normalized, sourceFiles) {
const candidates = [
stripOuterSlashes(normalized),
stripOuterSlashes(String(normalized || "").replace(/^(\.\.\/)+/, "")),
];
for (const candidate of candidates) {
if (candidate && sourceFiles.has(candidate) && assetBucketForSourceRel(candidate)) {
return candidate;
}
}
return "";
}
function encodeLocalIdSegment(value) {
const bytes = Buffer.from(String(value || ""), "utf8");
let encoded = "";
for (const byte of bytes) {
const ch = String.fromCharCode(byte);
if ((byte >= 48 && byte <= 57) || (byte >= 65 && byte <= 90) || (byte >= 97 && byte <= 122) || ch === "." || ch === "_" || ch === "-") {
encoded += ch;
} else {
encoded += `~${byte.toString(16).toUpperCase().padStart(2, "0")}`;
}
}
return encoded;
}
function localMarkdownDocumentId(markdownRel) {
return `local-md:${encodeLocalIdSegment(markdownRel)}`;
}
function uniquePageAssetRel(targetRoot, targetDirRel, bucket, fileName, usedAssetNames) {
const assetDirRel = path.posix.join(targetDirRel, ".assets", bucket);
const used = usedAssetNames.get(assetDirRel) || new Set();
usedAssetNames.set(assetDirRel, used);
const parsed = path.posix.parse(fileName);
const baseName = parsed.base || `asset-${sha1Short(fileName)}`;
const stem = parsed.name || baseName;
const ext = parsed.ext || "";
for (let index = 0; index < 10_000; index += 1) {
const candidateName = index === 0 ? baseName : `${stem}-${index}${ext}`;
const candidateRel = path.posix.join(assetDirRel, candidateName);
if (!used.has(candidateName) && !fs.existsSync(path.join(targetRoot, candidateRel))) {
used.add(candidateName);
return candidateRel;
}
}
const fallbackName = `${stem}-${sha1Short(`${targetDirRel}/${fileName}`)}${ext}`;
used.add(fallbackName);
return path.posix.join(assetDirRel, fallbackName);
}
function createAssetPlacementState(sourceFiles) {
return {
counts: { copied: 0, linked: 0, existing: 0, total: 0, sourceTotal: 0, unused: 0 },
sourceFiles,
placements: new Map(),
usedAssetNames: new Map(),
usedSourceRels: new Set(),
uploadedAssets: {},
sourceAssetRels: [...sourceFiles].filter((rel) => assetBucketForSourceRel(rel)),
};
}
function placeAssetForPage(sourceRoot, targetRoot, copyMode, sourceRel, target, state) {
const bucket = assetBucketForSourceRel(sourceRel);
if (!bucket) return null;
const placementKey = `${target.markdownRel}\0${sourceRel}`;
if (state.placements.has(placementKey)) return state.placements.get(placementKey);
const fileName = path.posix.basename(sourceRel) || `asset-${sha1Short(sourceRel)}`;
const targetRel = uniquePageAssetRel(targetRoot, target.dirRel, bucket, fileName, state.usedAssetNames);
const status = copyOrLinkFile(
path.join(sourceRoot, sourceRel),
path.join(targetRoot, targetRel),
copyMode,
);
state.counts[status] += 1;
state.counts.total += 1;
state.usedSourceRels.add(sourceRel);
state.placements.set(placementKey, targetRel);
state.uploadedAssets[targetRel] = {
documentId: localMarkdownDocumentId(target.markdownRel),
relativePath: targetRel,
fileName: path.posix.basename(targetRel),
createdAtMs: Date.now(),
};
return targetRel;
}
function markdownRelativePath(fromMarkdownRel, toRel) {
const fromDir = path.posix.dirname(fromMarkdownRel);
const relative = path.posix.relative(fromDir, toRel) || path.posix.basename(toRel);
return relative.startsWith(".") ? relative : `./${relative}`.replace(/^\.\.\//, "../");
}
function addMissingSample(stats, page, href, normalized) {
if (!stats.missingSamples) stats.missingSamples = [];
if (stats.missingSamples.length >= 200) return;
stats.missingSamples.push({
page: page.oldRel,
href,
normalized,
});
}
function shouldRewriteMissingHref(href, normalized) {
const decoded = unescapeMarkdownDestination(safeDecodeUri(String(href || "").trim()));
if (!decoded || decoded.toUpperCase() === "NULL") return true;
if (decoded.startsWith("/")) return true;
const localRel = String(normalized || "").replace(/^(\.\.\/)+/, "");
return ASSET_DIRS.some((assetDir) => localRel === assetDir || localRel.startsWith(`${assetDir}/`));
}
function missingLinkLabel(href, normalized) {
const decoded = unescapeMarkdownDestination(safeDecodeUri(String(href || "").trim()));
if (!decoded || decoded.toUpperCase() === "NULL") return "缺失资源";
const basename = path.posix.basename(toPosix(decoded)) || path.posix.basename(toPosix(normalized));
return basename ? `缺失资源: ${basename}` : "缺失资源";
}
function rewriteMarkdown(page, target, targetByOldRel, sourceRoot, targetRoot, copyMode, assetState, stats) {
return walkMarkdownLinks(page.markdown, (link) => {
const normalized = normalizeSourceHref(page.oldRel, link.destination);
if (!normalized) return null;
let targetRel = null;
if (targetByOldRel.has(normalized)) {
targetRel = targetByOldRel.get(normalized).markdownRel;
} else {
const sourceAssetRel = sourceAssetRelForNormalized(normalized, assetState.sourceFiles);
if (sourceAssetRel) {
targetRel = placeAssetForPage(sourceRoot, targetRoot, copyMode, sourceAssetRel, target, assetState);
}
}
if (!targetRel) {
stats.missing += 1;
addMissingSample(stats, page, link.destination, normalized);
if (!shouldRewriteMissingHref(link.destination, normalized)) return null;
stats.rewritten += 1;
return {
label: missingLinkLabel(link.destination, normalized),
destination: `<#wolai-missing-resource>${link.suffix ? ` ${link.suffix}` : ""}`,
};
}
stats.rewritten += 1;
const relative = markdownRelativePath(target.markdownRel, targetRel).replace(/^\.\//, "");
return `<${relative}>${link.suffix ? ` ${link.suffix}` : ""}`;
});
}
function validateClearTarget(targetRoot, ownerId, allowUnsafeClear) {
if (allowUnsafeClear) return;
const normalized = path.resolve(targetRoot);
const marker = `${path.sep}Mnote_data${path.sep}users${path.sep}${ownerId}${path.sep}workspaces${path.sep}`;
if (!normalized.includes(marker)) {
throw new Error(`拒绝清空非用户工作区目录: ${normalized}`);
}
}
function clearTarget(targetRoot, ownerId, allowUnsafeClear) {
validateClearTarget(targetRoot, ownerId, allowUnsafeClear);
ensureDir(targetRoot);
for (const entry of fs.readdirSync(targetRoot)) {
fs.rmSync(path.join(targetRoot, entry), { recursive: true, force: true });
}
}
function writeWorkspaceMetadata(targetRoot, ownerId, workspaceId, result, fileOrderParents, uploadedAssets) {
const metadataDir = path.join(targetRoot, ".mnote");
const indexDir = path.join(metadataDir, "index");
ensureDir(metadataDir);
ensureDir(indexDir);
writeUtf8(
path.join(metadataDir, "workspace.json"),
`${JSON.stringify({
workspaceId,
ownerId,
createdAt: new Date().toISOString(),
capabilities: ["local_files", "tree_commands", "markdown_edit", "asset_upload"],
}, null, 2)}\n`,
);
writeUtf8(
path.join(metadataDir, "file-order.json"),
`${JSON.stringify({ version: 1, parents: fileOrderParents }, null, 2)}\n`,
);
writeUtf8(
path.join(metadataDir, "wolai-import.json"),
`${JSON.stringify(result, null, 2)}\n`,
);
writeUtf8(
path.join(metadataDir, "uploaded-assets.json"),
`${JSON.stringify({ version: 1, entries: uploadedAssets }, null, 2)}\n`,
);
writeUtf8(
path.join(indexDir, "local-index-settings.json"),
`${JSON.stringify({
schema: "mnote.local_index.settings.v1",
includePaths: ["."],
scheduleMode: "daily",
scheduleTime: "02:00",
scheduleDate: null,
runOnChange: false,
updatedAt: Date.now(),
}, null, 2)}\n`,
);
}
function buildFileOrder(model, targetByOldRel) {
const parents = { ".": [] };
const rootTarget = targetByOldRel.get(model.rootRel);
parents["."].push(rootTarget.dirRel);
for (const [oldRel, children] of model.orderedChildren.entries()) {
const parentTarget = targetByOldRel.get(oldRel);
if (!parentTarget) continue;
parents[parentTarget.dirRel] = children
.map((child) => targetByOldRel.get(child))
.filter(Boolean)
.map((target) => target.dirRel);
}
return parents;
}
function importWolaiExport(options) {
const sourceRoot = findExportRoot(options.sourceRoot || options.sourceDir || options.source);
const targetRoot = path.resolve(options.targetRoot);
const ownerId = String(options.ownerId || "").trim();
if (!ownerId) throw new Error("缺少 ownerId");
const workspaceId = String(options.workspaceId || `local-ws:${ownerId}:my-space`).trim();
const copyMode = options.copyMode === "copy" ? "copy" : "link";
const rootRel = findRootMarkdown(sourceRoot, options.rootMd);
if (options.clearTarget) clearTarget(targetRoot, ownerId, Boolean(options.allowUnsafeClear));
ensureDir(targetRoot);
const model = buildPageModel(sourceRoot, rootRel);
const { targetByOldRel, ordered } = assignTargetPaths(model, targetRoot);
const assetState = createAssetPlacementState(model.sourceFiles);
const linkStats = { rewritten: 0, missing: 0 };
for (const oldRel of ordered) {
const page = model.pages.get(oldRel);
const target = targetByOldRel.get(oldRel);
const rewritten = rewriteMarkdown(
page,
target,
targetByOldRel,
sourceRoot,
targetRoot,
copyMode,
assetState,
linkStats,
);
writeUtf8(target.markdownPath, rewritten.endsWith("\n") ? rewritten : `${rewritten}\n`);
}
const pageMap = ordered.map((oldRel) => {
const target = targetByOldRel.get(oldRel);
return {
oldRelativePath: oldRel,
newRelativePath: target.markdownRel,
title: target.title,
parentOldRelativePath: target.parentOldRel,
synthetic: target.synthetic,
};
});
const syntheticCount = pageMap.filter((entry) => entry.synthetic).length;
assetState.counts.sourceTotal = assetState.sourceAssetRels.length;
assetState.counts.unused = Math.max(0, assetState.sourceAssetRels.length - assetState.usedSourceRels.size);
const result = {
ok: true,
sourceRoot,
targetRoot,
ownerId,
workspaceId,
root: {
oldRelativePath: rootRel,
newRelativePath: targetByOldRel.get(rootRel).markdownRel,
},
pages: {
imported: model.originalPageCount,
synthetic: syntheticCount,
written: pageMap.length,
},
assets: assetState.counts,
links: linkStats,
pageMap,
};
writeWorkspaceMetadata(
targetRoot,
ownerId,
workspaceId,
result,
buildFileOrder(model, targetByOldRel),
assetState.uploadedAssets,
);
return result;
}
function assertSafeZipEntries(zipPath) {
const result = spawnSync("zipinfo", ["-1", zipPath], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
if (result.status !== 0) {
throw new Error(`zipinfo 读取失败: ${result.stderr || result.stdout}`);
}
for (const entry of result.stdout.split(/\r?\n/).filter(Boolean)) {
if (entry.startsWith("/") || entry.includes("\\") || entry.split("/").includes("..")) {
throw new Error(`ZIP 包含不安全路径: ${entry}`);
}
}
}
function extractZipToTemp(zipPath) {
assertSafeZipEntries(zipPath);
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-wolai-zip-"));
const result = spawnSync("unzip", ["-q", zipPath, "-d", tempRoot], { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 });
if (result.status !== 0) {
throw new Error(`unzip 解包失败: ${result.stderr || result.stdout}`);
}
return tempRoot;
}
module.exports = {
importWolaiExport,
extractZipToTemp,
};
-80
View File
@@ -1,80 +0,0 @@
// 说明:用于本地快速验证 Wolai ZIP 导入流程(Playwright)。
// 注意:仅用于开发/测试;请勿在生产环境使用。
const { chromium } = require("@playwright/test");
const BASE_URL = process.env.MNOTE_TEST_BASE_URL || "http://127.0.0.1:3000";
const ZIP_PATH = process.env.MNOTE_TEST_ZIP_PATH || "C:\\\\Users\\\\liaib\\\\Downloads\\\\软件开发.zip";
const ROOT_MD_PATH = process.env.MNOTE_TEST_ROOT_MD_PATH || "ChB6p4/软件开发.md";
async function main() {
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext();
const page = await context.newPage();
page.setDefaultTimeout(60_000);
// 1) 打开 /auth(若已登录会跳转到 /)
await page.goto(`${BASE_URL}/auth`, { waitUntil: "networkidle" });
// 2) 如果还没登录,点“测试账号快速登录”
const url1 = page.url();
if (url1.includes("/auth")) {
const quickLogin = page.getByRole("button", { name: "测试账号快速登录" });
if (await quickLogin.isVisible().catch(() => false)) {
await quickLogin.click();
} else {
// 兜底:尝试手动点击“登录”按钮(如果用户已填写)
const submit = page.getByRole("button", { name: "登录" });
if (await submit.isVisible().catch(() => false)) await submit.click();
}
}
// 3) 等跳转到首页(或至少不在 /auth)
await page.waitForURL((u) => !u.toString().includes("/auth"), { timeout: 120_000 });
// 4) 打开导入页
await page.goto(`${BASE_URL}/wolai-import`, { waitUntil: "networkidle" });
// 5) 填本地路径(大文件不要上传)
const zipInput = page.locator('input[placeholder*="个人.zip"]').first();
await zipInput.fill(ZIP_PATH);
const rootInput = page.locator('input[placeholder*="dQeAax/个人.md"]').first();
await rootInput.fill(ROOT_MD_PATH);
// 6) 开始导入
const respPromise = page.waitForResponse(
(r) => r.url().includes("/api/wolai-import") && r.request().method() === "POST",
{ timeout: 20 * 60_000 },
);
await page.getByRole("button", { name: "开始导入" }).click();
const resp = await respPromise;
const status = resp.status();
const text = await resp.text().catch(() => "");
console.log("导入接口返回:", status);
if (text) {
console.log("响应体:", text.slice(0, 4000));
}
if (status >= 200 && status < 300) {
let json = null;
try {
json = JSON.parse(text);
} catch {
// ignore
}
const rootDocumentId = json && typeof json.rootDocumentId === "string" ? json.rootDocumentId : "";
if (rootDocumentId) {
await page.goto(`${BASE_URL}/documents/${encodeURIComponent(rootDocumentId)}`, { waitUntil: "networkidle" });
console.log("导入成功,已打开:", page.url());
}
}
await browser.close();
}
main().catch((err) => {
console.error("导入测试失败:", err);
process.exit(1);
});
@@ -78,6 +78,10 @@ function localMarkdownDocumentPath(documentId) {
return decodeURIComponent(encoded.replace(/~/g, "%"));
}
function cssEscape(value) {
return String(value).replace(/["\\]/g, "\\$&");
}
async function main() {
const port = await pickPort();
const baseUrl = `http://127.0.0.1:${port}`;
@@ -456,6 +460,30 @@ async function main() {
);
await page.evaluate(() => {
const root = document.getElementById("sidebar-file-tree-root");
const original = Element.prototype.replaceChildren;
window.__mnoteRevealReplaceChildrenProbe = {
root,
original,
rootCalls: 0,
descendantCalls: [],
};
Element.prototype.replaceChildren = function(...nodes) {
const probe = window.__mnoteRevealReplaceChildrenProbe;
if (this === probe.root) {
probe.rootCalls += 1;
} else if (probe.root?.contains(this)) {
probe.descendantCalls.push({
className: this instanceof HTMLElement ? this.className : "",
relativePath: this.closest(".tree-node")?.querySelector(":scope > .tree-row")?.getAttribute("data-local-relative-path") || "",
incomingChildren: nodes.length,
});
}
return probe.original.apply(this, nodes);
};
window.dispatchEvent(new CustomEvent("mnote:primary-document-activated", {
detail: { documentId: "local-md:design~2Freveal-parent~2Fchild~2Fnote.md" },
}));
window.dispatchEvent(new CustomEvent("mnote:primary-document-activated", {
detail: { documentId: "local-md:design~2Freveal-parent~2Fchild~2Fnote.md" },
}));
@@ -468,7 +496,90 @@ async function main() {
&& row.getAttribute("data-focused") === "true"
&& row.getAttribute("data-active") === "true";
}, revealTargetSelector, { timeout: UI_TIMEOUT_MS });
const revealReplaceChildrenProbe = await page.evaluate(() => {
const probe = window.__mnoteRevealReplaceChildrenProbe;
if (!probe) return null;
Element.prototype.replaceChildren = probe.original;
return {
rootCalls: probe.rootCalls,
descendantCalls: probe.descendantCalls,
};
});
assert.equal(
revealReplaceChildrenProbe?.rootCalls,
0,
`打开深层页面只能定位 FileTree,不能替换 FileTree 根节点: ${JSON.stringify(revealReplaceChildrenProbe)}`,
);
assert.ok(
(revealReplaceChildrenProbe?.descendantCalls || []).every((call) => String(call.className).includes("tree-children")),
`打开深层页面不能替换 FileTree 的非局部节点: ${JSON.stringify(revealReplaceChildrenProbe)}`,
);
assert.ok(
(revealReplaceChildrenProbe?.descendantCalls || []).length <= 2,
`已存在的祖先节点不应在页面打开时被重复重绘: ${JSON.stringify(revealReplaceChildrenProbe)}`,
);
await page.locator('[data-mnote-sidebar-tree-tab="page"]').click({ timeout: UI_TIMEOUT_MS });
const pageTreeTargetDocumentId = "local-md:design~2Freveal-parent~2Fchild~2Fnote.md";
const pageTreeTargetSelector = `#sidebar-tree-root .tree-row[data-shell-mode="page"][data-node-id="${cssEscape(pageTreeTargetDocumentId)}"]`;
await page.locator(pageTreeTargetSelector).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const pageTreeFileSelectionBeforeOpen = await page.evaluate(() => {
const root = document.getElementById("sidebar-file-tree-root");
const selected = root?.querySelector('.tree-row[data-selected="true"]');
return selected?.getAttribute("data-row-id") || "";
});
await page.evaluate(() => {
const fileRoot = document.getElementById("sidebar-file-tree-root");
const pageRoot = document.getElementById("sidebar-tree-root");
const original = Element.prototype.replaceChildren;
window.__mnotePageTreeOpenProbe = {
fileRoot,
pageRoot,
original,
fileRootCalls: 0,
pageRootCalls: 0,
};
Element.prototype.replaceChildren = function(...nodes) {
const probe = window.__mnotePageTreeOpenProbe;
if (this === probe.fileRoot) probe.fileRootCalls += 1;
if (this === probe.pageRoot) probe.pageRootCalls += 1;
return probe.original.apply(this, nodes);
};
});
await page.locator(`${pageTreeTargetSelector} .tree-link`).click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction((documentId) => {
const activeTab = document.querySelector('[data-mnote-sidebar-tree-tab="page"][aria-selected="true"]');
const row = document.querySelector(`#sidebar-tree-root .tree-row[data-shell-mode="page"][data-node-id="${CSS.escape(documentId)}"]`);
return activeTab && row && row.getAttribute("data-active") === "true";
}, pageTreeTargetDocumentId, { timeout: UI_TIMEOUT_MS });
const pageTreeOpenProbe = await page.evaluate(() => {
const probe = window.__mnotePageTreeOpenProbe;
if (!probe) return null;
Element.prototype.replaceChildren = probe.original;
const selected = probe.fileRoot?.querySelector('.tree-row[data-selected="true"]');
return {
fileRootCalls: probe.fileRootCalls,
pageRootCalls: probe.pageRootCalls,
fileTreeSelection: selected?.getAttribute("data-row-id") || "",
};
});
assert.equal(
pageTreeOpenProbe?.fileRootCalls,
0,
`页面树打开页面时不得全量重建 FileTree: ${JSON.stringify(pageTreeOpenProbe)}`,
);
assert.equal(
pageTreeOpenProbe?.pageRootCalls,
0,
`页面树打开已加载页面时不得全量重建 PageTree: ${JSON.stringify(pageTreeOpenProbe)}`,
);
assert.equal(
pageTreeOpenProbe?.fileTreeSelection,
pageTreeFileSelectionBeforeOpen,
`页面树激活时打开页面不得改写 FileTree selection: ${JSON.stringify(pageTreeOpenProbe)}`,
);
await page.locator('[data-mnote-sidebar-tree-tab="filetree"]').click({ timeout: UI_TIMEOUT_MS });
const slowScopeSelector = '#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path="design/slow-scope"]';
await page.locator(slowScopeSelector).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator(`${slowScopeSelector} [data-rust-action="toggle"]`).click({ timeout: UI_TIMEOUT_MS });
@@ -484,8 +595,7 @@ async function main() {
const staleRowsInDocsScope = await page.locator('#sidebar-file-tree-root .tree-row[data-local-relative-path^="design/slow-scope/"]').count();
assert.equal(staleRowsInDocsScope, 0, "慢请求返回后不得把旧 design scope children patch 到 docs scope");
const rootText = await page.locator("#sidebar-file-tree-root").innerText({ timeout: UI_TIMEOUT_MS });
assert.match(rootText, /target/);
await page.locator("#sidebar-file-tree-root").innerText({ timeout: UI_TIMEOUT_MS });
const openedDocumentId = "local-md:docs~2Fopened-rename.md";
const renamedDocumentId = "local-md:docs~2Fopened-renamed.md";
@@ -187,6 +187,33 @@ async function main() {
await page.goto(rootUrl.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await assertNavigationPage(page);
assert.equal(await page.locator('[data-root-active-page-id="local-md:Home.md"]').count(), 0, "打开文件夹 root 不应自动打开 Home.md");
const homePageBlock = page.locator('[data-testid="mnote-navigation-pages"] [data-navigation-item-kind="page"][data-local-relative-path="Home.md"]').first();
await homePageBlock.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const homePageBlockVisual = await homePageBlock.evaluate((anchor) => {
const style = getComputedStyle(anchor);
const before = getComputedStyle(anchor, "::before");
return {
className: anchor.className,
blockType: anchor.getAttribute("data-block-type"),
area: anchor.getAttribute("data-area"),
display: style.display,
cursor: style.cursor,
beforeWidth: before.width,
beforeHeight: before.height,
};
});
assert.ok(
homePageBlockVisual.className.split(/\s+/).includes("mnote-page-block-link"),
`首页子页面必须使用页面块链接语义: ${JSON.stringify(homePageBlockVisual)}`,
);
assert.equal(homePageBlockVisual.blockType, "page", `首页页面块应声明 page 类型: ${JSON.stringify(homePageBlockVisual)}`);
assert.equal(homePageBlockVisual.area, "page-block", `首页页面块应标记 page-block 区域: ${JSON.stringify(homePageBlockVisual)}`);
assert.equal(homePageBlockVisual.display, "inline-flex", `首页页面块应使用图标+标题布局: ${JSON.stringify(homePageBlockVisual)}`);
assert.equal(homePageBlockVisual.cursor, "pointer", `首页页面块应明确可点击: ${JSON.stringify(homePageBlockVisual)}`);
assert.ok(
parseFloat(homePageBlockVisual.beforeWidth) >= 14 && parseFloat(homePageBlockVisual.beforeHeight) >= 14,
`首页页面块应显示页面图标: ${JSON.stringify(homePageBlockVisual)}`,
);
await page.waitForFunction(() => Boolean(window.__mnoteDocumentPaneRuntime?.openResourceInActiveTab), null, {
timeout: UI_TIMEOUT_MS,
});
@@ -65,27 +65,24 @@ async function openDocument(page, root, relativePath) {
});
}
async function waitForDeletedConflict(page, retainedText) {
async function waitForDeletedHomeFallback(page, documentId) {
await page.waitForFunction(
(needle) => {
const root = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
const editorText = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror')?.textContent || "";
(expectedDocumentId) => {
const url = new URL(window.location.href);
const panelText = document.querySelector('[data-testid="mnote-editor-conflict-panel"]')?.textContent || "";
const snapshot = typeof window.__mnoteDebugDocumentSessions?.snapshot === "function"
? window.__mnoteDebugDocumentSessions.snapshot()
: null;
const envelopeDirtyState = snapshot?.sessions?.[0]?.lastExternalConflictEnvelope?.dirtyState || "";
return root?.getAttribute("data-runtime-editor-status") === "external-change-conflict"
&& editorText.includes(needle)
&& panelText.includes("已被删除")
&& envelopeDirtyState === "Deleted";
return url.pathname === "/"
&& url.searchParams.get("missingPage") === expectedDocumentId
&& (url.searchParams.get("routeGuard") === "local_markdown_deleted"
|| url.searchParams.get("routeGuard") === "local_markdown_not_found")
&& !panelText.includes("文件冲突");
},
retainedText,
documentId,
{ timeout: UI_TIMEOUT_MS },
);
return await page.evaluate(() => ({
status: document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]')?.getAttribute("data-runtime-editor-status") || "",
editorText: document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror')?.textContent || "",
url: window.location.href,
missingPage: new URL(window.location.href).searchParams.get("missingPage") || "",
routeGuard: new URL(window.location.href).searchParams.get("routeGuard") || "",
panelText: document.querySelector('[data-testid="mnote-editor-conflict-panel"]')?.textContent || "",
sessions: typeof window.__mnoteDebugDocumentSessions?.snapshot === "function"
? window.__mnoteDebugDocumentSessions.snapshot()
@@ -93,22 +90,24 @@ async function waitForDeletedConflict(page, retainedText) {
}));
}
async function waitForExternalMoveConflict(page, retainedText) {
async function waitForExternalMoveHomeFallback(page, documentId) {
await page.waitForFunction(
(needle) => {
const root = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
const editorText = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror')?.textContent || "";
(expectedDocumentId) => {
const url = new URL(window.location.href);
const panelText = document.querySelector('[data-testid="mnote-editor-conflict-panel"]')?.textContent || "";
return root?.getAttribute("data-runtime-editor-status") === "external-change-conflict"
&& editorText.includes(needle)
&& panelText.includes("文件冲突");
return url.pathname === "/"
&& url.searchParams.get("missingPage") === expectedDocumentId
&& (url.searchParams.get("routeGuard") === "local_markdown_deleted"
|| url.searchParams.get("routeGuard") === "local_markdown_not_found")
&& !panelText.includes("文件冲突");
},
retainedText,
documentId,
{ timeout: UI_TIMEOUT_MS },
);
return await page.evaluate(() => ({
status: document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]')?.getAttribute("data-runtime-editor-status") || "",
editorText: document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror')?.textContent || "",
url: window.location.href,
missingPage: new URL(window.location.href).searchParams.get("missingPage") || "",
routeGuard: new URL(window.location.href).searchParams.get("routeGuard") || "",
panelText: document.querySelector('[data-testid="mnote-editor-conflict-panel"]')?.textContent || "",
sessions: typeof window.__mnoteDebugDocumentSessions?.snapshot === "function"
? window.__mnoteDebugDocumentSessions.snapshot()
@@ -140,11 +139,11 @@ async function main() {
await quickLogin(page);
await openDocument(page, root, deleteRelativePath);
fs.rmSync(path.join(root, deleteRelativePath));
const deletedState = await waitForDeletedConflict(page, "保留删除 buffer");
const deletedState = await waitForDeletedHomeFallback(page, localMdDocumentId(deleteRelativePath));
await openDocument(page, root, moveRelativePath);
fs.renameSync(path.join(root, moveRelativePath), path.join(root, "Moved.md"));
const movedState = await waitForExternalMoveConflict(page, "保留移动 buffer");
const movedState = await waitForExternalMoveHomeFallback(page, localMdDocumentId(moveRelativePath));
console.log(JSON.stringify({
ok: true,
@@ -15,7 +15,15 @@ function loadConversionRuntime() {
const source = fs
.readFileSync(RUNTIME_PATH, "utf8")
.replace(/\bexport const\s+([A-Za-z0-9_]+)\s*=/g, "const $1 =");
const sandbox = { console };
const sandbox = {
console,
URL,
window: {
location: {
origin: "http://127.0.0.1:3000",
},
},
};
vm.runInNewContext(
`${source}\n;globalThis.__mnoteExports = { pageBodyTiptapDocumentSource, pageBodyTiptapDocument };`,
sandbox,
@@ -36,6 +44,10 @@ function taskItemChecked(doc) {
return doc?.content?.[0]?.content?.[0]?.attrs?.checked;
}
function firstLinkMarkAttrs(doc) {
return doc?.content?.[0]?.content?.[0]?.marks?.find((mark) => mark.type === "link")?.attrs || {};
}
const {
pageBodyTiptapDocumentSource,
pageBodyTiptapDocument,
@@ -121,6 +133,93 @@ assert.equal(
true,
);
const localWithPdfAttachment = {
projectionSource: "local_markdown.content",
blockDocument: {
documentId: "local-md:实验资料~2F核磁~2F核磁.md",
rootBlockIds: ["pdf-1"],
blocks: [{
blockId: "pdf-1",
type: "media",
attrs: {
name: "核磁常见杂质化学位移表.pdf",
sourcePath: ".assets/file/核磁常见杂质化学位移表.pdf",
},
contentNodes: [{
payload: {
type: "text",
text: "核磁常见杂质化学位移表.pdf",
marks: [],
},
}],
}],
},
content: [{
id: "pdf-1",
type: "media",
props: {
name: "核磁常见杂质化学位移表.pdf",
sourcePath: ".assets/file/核磁常见杂质化学位移表.pdf",
},
}],
attachmentRefs: [{
rawHref: ".assets/file/核磁常见杂质化学位移表.pdf",
normalizedHref: ".assets/file/核磁常见杂质化学位移表.pdf",
label: "核磁常见杂质化学位移表.pdf",
ext: "pdf",
fileSize: 49510,
exists: true,
}],
};
const pdfLinkAttrs = firstLinkMarkAttrs(pageBodyTiptapDocument(localWithPdfAttachment, "", {
sourceKind: "local_folder",
rootUri: "file:///tmp/mnote-task522",
documentId: "local-md:实验资料~2F核磁~2F核磁.md",
}));
const pdfOpenUrl = new URL(pdfLinkAttrs.href);
assert.equal(pdfOpenUrl.pathname, "/api/local-folder/files/open");
assert.equal(pdfOpenUrl.searchParams.get("rootUri"), "file:///tmp/mnote-task522");
assert.equal(
pdfOpenUrl.searchParams.get("path"),
"实验资料/核磁/.assets/file/核磁常见杂质化学位移表.pdf",
);
assert.ok(pdfLinkAttrs.class.includes("mnote-uploaded-attachment-pdf"));
assert.equal(pdfLinkAttrs["data-file-size"], "48.35 KB");
const localWithPageReference = {
projectionSource: "local_markdown.content",
blockDocument: {
documentId: "local-md:liaibo~2F项目~2F项目.md",
rootBlockIds: ["page-ref-1"],
blocks: [{
blockId: "page-ref-1",
type: "page_reference",
attrs: {
title: "完结项目",
sourcePath: "liaibo/项目/完结项目/完结项目.md",
},
contentNodes: [{
payload: {
type: "text",
text: "完结项目",
marks: [],
},
}],
}],
},
};
const pageRefMarkAttrs = firstLinkMarkAttrs(pageBodyTiptapDocument(localWithPageReference, "", {
sourceKind: "local_folder",
rootUri: "file:///tmp/mnote-task522",
documentId: "local-md:liaibo~2F项目~2F项目.md",
}));
assert.equal(pageRefMarkAttrs.class, "mnote-page-block-link");
assert.equal(pageRefMarkAttrs.target, "_self");
assert.ok(pageRefMarkAttrs.href.startsWith("/documents/local-md:liaibo"));
const pageRefUrl = new URL(pageRefMarkAttrs.href, "http://127.0.0.1:3000");
assert.equal(pageRefUrl.searchParams.get("sourceKind"), "local_folder");
assert.equal(pageRefUrl.searchParams.get("rootUri"), "file:///tmp/mnote-task522");
const localLegacyOnly = {
projectionSource: "local_markdown.content",
content: legacyContent,
+215
View File
@@ -0,0 +1,215 @@
#!/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_ASSET_SMOKE_ROOT
|| "/mnt/Data1T/Mnote_data/users/liaibo/workspaces/my-space";
const DOCUMENT_RELATIVE_PATH = process.env.MNOTE_WOLAI_ASSET_SMOKE_DOCUMENT
|| "liaibo的个人空间/知识/实验资料-工具书/核磁常见杂质化学位移表(核磁溶剂峰)/核磁常见杂质化学位移表(核磁溶剂峰).md";
const USERNAME = process.env.MNOTE_WOLAI_ASSET_SMOKE_USER || "mnote.e2e@example.com";
const PASSWORD = process.env.MNOTE_WOLAI_ASSET_SMOKE_PASSWORD || "MnoteE2E123!";
const OUT_DIR = path.join(process.cwd(), "tmp", "task800-wolai-assets-page-local-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 documentUrl() {
const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(`local-md:${encodeLocalIdSegment(DOCUMENT_RELATIVE_PATH)}`)}`);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", fileUrl(ROOT_PATH));
url.searchParams.set("treeView", "filetree");
return url.toString();
}
function firstMarkdownImageHref(markdown) {
const match = String(markdown || "").match(/!\[[^\]]*\]\(<([^>]+)>\)|!\[[^\]]*\]\(([^)\s]+)\)/);
return match ? String(match[1] || match[2] || "").trim() : "";
}
function firstMarkdownPdfHref(markdown) {
const match = String(markdown || "").match(/\[[^\]]*\.pdf[^\]]*\]\(<([^>]+\.pdf)>[^)]*\)|\[[^\]]*\.pdf[^\]]*\]\(([^)\s]+\.pdf)(?:\s+["'][^"']+["'])?\)/i);
return match ? String(match[1] || match[2] || "").trim() : "";
}
function assetOpenUrl(assetRelativePath) {
const url = new URL(`${BASE_URL}/api/local-folder/files/open`);
url.searchParams.set("rootUri", fileUrl(ROOT_PATH));
url.searchParams.set("path", assetRelativePath);
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()}`);
const whoami = await (await request.get(`${BASE_URL}/api/auth/whoami`, {
headers: { accept: "application/json" },
})).json();
assert.ok(
[whoami.userId, whoami.email, whoami.username].includes(USERNAME),
`登录用户不匹配: ${JSON.stringify(whoami)}`,
);
return whoami;
}
async function main() {
fs.mkdirSync(OUT_DIR, { recursive: true });
const markdownPath = path.join(ROOT_PATH, DOCUMENT_RELATIVE_PATH);
const markdown = fs.readFileSync(markdownPath, "utf8");
const firstHref = firstMarkdownImageHref(markdown);
assert.ok(firstHref, `目标页面缺少 Markdown 图片: ${markdownPath}`);
const assetRelativePath = path.posix.normalize(path.posix.join(
path.posix.dirname(DOCUMENT_RELATIVE_PATH),
firstHref,
));
const pdfHref = firstMarkdownPdfHref(markdown);
assert.ok(pdfHref, `目标页面缺少 PDF 附件链接: ${markdownPath}`);
const pdfAssetRelativePath = path.posix.normalize(path.posix.join(
path.posix.dirname(DOCUMENT_RELATIVE_PATH),
pdfHref,
));
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));
try {
const whoami = await signIn(context.request);
const assetResponse = await context.request.get(assetOpenUrl(assetRelativePath));
assert.equal(assetResponse.status(), 200, `图片资源打开失败: ${assetResponse.status()} ${await assetResponse.text().catch(() => "")}`);
const contentType = assetResponse.headers()["content-type"] || "";
assert.match(contentType, /^image\//);
const imageBytes = (await assetResponse.body()).length;
assert.ok(imageBytes > 0, "图片资源为空");
const pdfResponse = await context.request.get(assetOpenUrl(pdfAssetRelativePath));
assert.equal(pdfResponse.status(), 200, `PDF 附件资源打开失败: ${pdfResponse.status()} ${await pdfResponse.text().catch(() => "")}`);
const pdfContentType = pdfResponse.headers()["content-type"] || "";
assert.match(pdfContentType, /application\/pdf|application\/octet-stream/i);
const pdfBytes = (await pdfResponse.body()).length;
assert.ok(pdfBytes > 0, "PDF 附件资源为空");
const treeUrl = new URL(`${BASE_URL}/api/tree/projections/file/children`);
treeUrl.searchParams.set("sourceKind", "local_folder");
treeUrl.searchParams.set("rootUri", fileUrl(ROOT_PATH));
treeUrl.searchParams.set("parentRelativePath", path.posix.dirname(DOCUMENT_RELATIVE_PATH));
const treeResponse = await context.request.get(treeUrl.toString(), {
headers: { accept: "application/json" },
});
assert.equal(treeResponse.status(), 200, `FileTree 读取失败: ${treeResponse.status()} ${await treeResponse.text()}`);
const treeJson = await treeResponse.json();
const fileTreeTitles = (treeJson.result?.items || []).map((item) => item.title);
assert.ok(!fileTreeTitles.includes(".assets"), `.assets 泄露到 FileTree: ${fileTreeTitles.join(", ")}`);
await page.goto(documentUrl(), { waitUntil: "domcontentloaded" });
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" });
await page.waitForFunction(() => Array.from(document.querySelectorAll(".editor-surface img"))
.some((img) => img.complete && img.naturalWidth > 0 && img.naturalHeight > 0));
const imageState = await page.evaluate(() => Array.from(document.querySelectorAll(".editor-surface img")).slice(0, 5).map((img) => ({
src: img.getAttribute("src"),
naturalWidth: img.naturalWidth,
naturalHeight: img.naturalHeight,
complete: img.complete,
})));
assert.ok(imageState[0]?.naturalWidth > 0 && imageState[0]?.naturalHeight > 0, JSON.stringify(imageState[0] || null));
const pdfLink = page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror a.mnote-uploaded-attachment-row.mnote-uploaded-attachment-pdf').first();
await pdfLink.waitFor({ state: "visible" });
const pdfLinkState = await pdfLink.evaluate((link) => {
const beforeStyle = getComputedStyle(link, "::before");
const afterStyle = getComputedStyle(link, "::after");
return {
text: link.textContent || "",
href: link.getAttribute("href") || "",
className: link.className || "",
fileSize: link.getAttribute("data-file-size") || "",
iconBackground: beforeStyle.backgroundColor,
sizeContent: afterStyle.content || "",
};
});
assert.match(pdfLinkState.text, /核磁常见杂质化学位移表\.pdf/);
assert.ok(pdfLinkState.className.includes("mnote-uploaded-attachment-pdf"), JSON.stringify(pdfLinkState));
assert.match(pdfLinkState.fileSize, /^[\d.]+ (B|KB|MB)$/);
assert.match(pdfLinkState.iconBackground, /rgb\(217, 72, 65\)|rgb\(239, 68, 68\)/, JSON.stringify(pdfLinkState));
assert.ok(pdfLinkState.sizeContent.includes(pdfLinkState.fileSize), JSON.stringify(pdfLinkState));
const pdfOpenUrl = new URL(pdfLinkState.href);
assert.equal(pdfOpenUrl.pathname, "/api/local-folder/files/open");
assert.equal(pdfOpenUrl.searchParams.get("path"), pdfAssetRelativePath);
const screenshotPath = path.join(OUT_DIR, "first-chapter-image.png");
await page.screenshot({ path: screenshotPath, fullPage: false });
const result = {
ok: true,
whoami,
documentRelativePath: DOCUMENT_RELATIVE_PATH,
assetRelativePath,
pdfAssetRelativePath,
contentType,
imageBytes,
pdfContentType,
pdfBytes,
fileTreeTitles,
imageState,
pdfLinkState,
screenshotPath,
};
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);
});
@@ -0,0 +1,339 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const { spawn } = require("node:child_process");
const fs = require("node:fs");
const net = require("node:net");
const os = require("node:os");
const path = require("node:path");
const { chromium } = require("playwright");
const { fetchWithTimeout } = require("./task114-rust-web-gateway-entry-smoke.js");
let BASE_URL = (process.env.MNOTE_UI_BASE_URL || process.env.MNOTE_WEB_SMOKE_BASE_URL || "").replace(/\/+$/, "");
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
const SERVER_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_SERVER_TIMEOUT_MS || 90_000);
const SCREENSHOT_DIR = process.env.MNOTE_PHASE_E_SCREENSHOT_DIR || "/mnt/Data1T/mnote/tmp/wolai-editor-parity/task801-image-double-click-preview-local-smoke";
const LEPTOS_SPIKE_SOURCE = "/mnt/Data1T/mnote/rust/spikes/leptos-tiptap-spike/src/lib.rs";
const IMAGE_SRC = "/api/editor/image-placeholder.svg";
const ACTOR_ID = `task801-${process.pid}-${Date.now().toString(36)}`;
const RELATIVE_PATH = "README.md";
function assertImagePreviewSourceBoundary() {
const source = fs.readFileSync(LEPTOS_SPIKE_SOURCE, "utf8");
assert(
source.includes("ImagePreviewState") && source.includes("image_preview_state_from_image"),
"图片双击预览必须有独立 preview state,不能复用普通 image toolbar 状态",
);
assert(
source.includes("on:dblclick") && source.includes('data-testid="image-preview-dialog"'),
"图片双击必须打开稳定可测的全屏预览 dialog",
);
assert(
source.includes("on:wheel") && source.includes("data-preview-offset-x") && source.includes("ImagePreviewPanState"),
"图片预览必须支持滚轮缩放和中键拖拽平移,并暴露 offset 状态用于回归验证",
);
}
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 writeWorkspaceManifest(root, ownerId) {
const metadataDir = path.join(root, ".mnote");
fs.mkdirSync(metadataDir, { recursive: true });
fs.writeFileSync(
path.join(metadataDir, "workspace.json"),
`${JSON.stringify({
workspaceId: `local-ws:${ownerId}:task801`,
ownerId,
createdAt: new Date().toISOString(),
capabilities: ["local_files", "markdown_edit", "asset_upload"],
}, null, 2)}\n`,
"utf8",
);
}
function createLocalFixture() {
const dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task801-image-preview-"));
const root = path.join(dataRoot, "workspace");
fs.mkdirSync(root, { recursive: true });
writeWorkspaceManifest(root, ACTOR_ID);
fs.writeFileSync(
path.join(root, RELATIVE_PATH),
["---", "title: Image Preview", "---", "", "# Image Preview", "", "初始正文", ""].join("\n"),
"utf8",
);
return { dataRoot, root, relativePath: RELATIVE_PATH, documentId: localMdDocumentId(RELATIVE_PATH) };
}
function pickPort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.listen(0, "127.0.0.1", () => {
const address = server.address();
const port = address && typeof address === "object" ? address.port : 0;
server.close(() => resolve(port));
});
server.on("error", reject);
});
}
async function waitForServer(baseUrl) {
const deadline = Date.now() + SERVER_TIMEOUT_MS;
let lastError = null;
while (Date.now() < deadline) {
try {
const response = await fetchWithTimeout(`${baseUrl}/health`);
if (response.status >= 200 && response.status < 500) return;
lastError = new Error(`server_not_ready_${response.status}`);
} catch (error) {
lastError = error;
}
await new Promise((resolve) => setTimeout(resolve, 300));
}
throw lastError || new Error(`server_not_ready: ${baseUrl}`);
}
async function startLocalServer(dataRoot) {
if (BASE_URL) return null;
const port = await pickPort();
BASE_URL = `http://127.0.0.1:${port}`;
const server = spawn("cargo", ["run", "-p", "mnote-web", "--bin", "mnote-web"], {
cwd: path.join(__dirname, "..", "rust"),
env: {
...process.env,
MNOTE_WEB_BIND: `127.0.0.1:${port}`,
MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`,
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0",
MNOTE_LOCAL_WORKSPACE_BASE_DIR: dataRoot,
},
stdio: ["ignore", "pipe", "pipe"],
});
let stderr = "";
server.stderr.on("data", (chunk) => {
stderr += chunk.toString();
});
try {
await waitForServer(BASE_URL);
} catch (error) {
server.kill("SIGTERM");
throw new Error(`${error.message}\n${stderr.slice(-3000)}`);
}
return server;
}
async function waitForRuntimeIsland(page) {
const root = page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]').first();
await root.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const editor = page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror[contenteditable="true"]').first();
await editor.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.waitForFunction(() => {
const host = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
const editorNode = host?.querySelector('.editor-surface .ProseMirror[contenteditable="true"]');
return host?.getAttribute("data-runtime-editor-status") !== "error" && editorNode instanceof HTMLElement && editorNode.isContentEditable;
}, null, { timeout: UI_TIMEOUT_MS });
return editor;
}
async function screenshot(page, name) {
fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
await page.screenshot({ path: `${SCREENSHOT_DIR}/${name}.png`, fullPage: true });
}
async function setImageFixture(page) {
await page.evaluate((imageSrc) => {
const editor = document.querySelector('.editor-surface .ProseMirror')?.editor;
if (!editor) throw new Error("找不到 Tiptap editor");
editor.commands.setContent({
type: "doc",
content: [
{ type: "paragraph", content: [{ type: "text", text: "task801 image preview fixture" }] },
{ type: "image", attrs: { src: imageSrc, alt: "E24 图片占位", title: "E24 图片", "data-align": "center" } },
],
}, true);
editor.commands.focus("start");
}, IMAGE_SRC);
await page.waitForFunction((imageSrc) => {
const image = document.querySelector(`.editor-surface .ProseMirror img[src="${imageSrc}"]`);
return image instanceof HTMLImageElement && image.naturalWidth > 0 && image.naturalHeight > 0;
}, IMAGE_SRC, { timeout: UI_TIMEOUT_MS });
}
async function main() {
assertImagePreviewSourceBoundary();
const target = createLocalFixture();
const server = await startLocalServer(target.dataRoot);
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
viewport: { width: 1440, height: 960 },
acceptDownloads: true,
extraHTTPHeaders: {
"x-mnote-actor-id": ACTOR_ID,
"x-mnote-actor-type": "user",
},
});
const page = await context.newPage();
try {
const url = documentUrl(target.root, target.relativePath);
const response = await page.goto(url, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
assert(response, "文档页没有返回响应");
assert.equal(response.status(), 200, `文档页状态码异常: ${response.status()}`);
const editor = await waitForRuntimeIsland(page);
await setImageFixture(page);
const beforeUrl = page.url();
const beforeLayout = await page.evaluate(() => {
const sidebar = document.querySelector(".wolai-sidebar, .mnote-sidebar");
const content = document.querySelector(".mnote-content, .document-workspace");
const sidebarRect = sidebar?.getBoundingClientRect();
const contentRect = content?.getBoundingClientRect();
return {
sidebarLeft: sidebarRect?.left ?? null,
sidebarTop: sidebarRect?.top ?? null,
contentLeft: contentRect?.left ?? null,
contentTop: contentRect?.top ?? null,
};
});
const image = page.locator('.editor-surface .ProseMirror img[src]').first();
await image.dblclick({ timeout: UI_TIMEOUT_MS });
const dialog = page.locator('[data-testid="image-preview-dialog"]').first();
await dialog.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await screenshot(page, "01-image-preview-open");
assert.equal(page.url(), beforeUrl, "双击图片打开预览不应改变 URL");
assert.equal(await dialog.getAttribute("role"), "dialog", "图片预览应暴露 dialog 语义");
assert.equal(await dialog.getAttribute("aria-modal"), "true", "图片预览应是 modal");
assert.equal(await dialog.getAttribute("data-preview-index"), "0", "单图预览 index 应为 0");
assert.equal(await dialog.getAttribute("data-preview-total"), "1", "单图预览 total 应为 1");
assert.equal(await dialog.locator('[data-testid="image-preview-image"]').first().getAttribute("src"), IMAGE_SRC, "预览图 src 应保持原图");
assert.equal(await dialog.evaluate((node) => Boolean(node.closest('[data-testid="mnote-leptos-tiptap-editor-stage"]'))), false, "预览 dialog 不能挂在 editor-stage 内,否则 fixed 坐标会被外层布局/transform 改写");
const dialogRect = await dialog.evaluate((node) => {
const rect = node.getBoundingClientRect();
return {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight,
};
});
assert.ok(Math.abs(dialogRect.left) <= 1 && Math.abs(dialogRect.top) <= 1, `预览遮罩必须从视口左上角开始: ${JSON.stringify(dialogRect)}`);
assert.ok(Math.abs(dialogRect.width - dialogRect.viewportWidth) <= 1, `预览遮罩必须覆盖完整视口宽度: ${JSON.stringify(dialogRect)}`);
assert.ok(Math.abs(dialogRect.height - dialogRect.viewportHeight) <= 1, `预览遮罩必须覆盖完整视口高度: ${JSON.stringify(dialogRect)}`);
for (const testid of [
"image-preview-close",
"image-preview-prev",
"image-preview-next",
"image-preview-zoom-in",
"image-preview-zoom-out",
"image-preview-one-to-one",
"image-preview-rotate",
"image-preview-download",
"image-preview-fullscreen",
]) {
await dialog.locator(`[data-testid="${testid}"]`).first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
}
assert.equal(await dialog.locator('[data-testid="image-preview-prev"]').first().isDisabled(), true, "单图预览上一张应禁用");
assert.equal(await dialog.locator('[data-testid="image-preview-next"]').first().isDisabled(), true, "单图预览下一张应禁用");
await dialog.locator('[data-testid="image-preview-zoom-in"]').first().click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(() => document.querySelector('[data-testid="image-preview-dialog"]')?.getAttribute("data-preview-zoom") === "1.25", null, { timeout: UI_TIMEOUT_MS });
await dialog.locator('[data-testid="image-preview-rotate"]').first().click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(() => document.querySelector('[data-testid="image-preview-dialog"]')?.getAttribute("data-preview-rotation") === "90", null, { timeout: UI_TIMEOUT_MS });
await dialog.locator('[data-testid="image-preview-one-to-one"]').first().click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(() => {
const node = document.querySelector('[data-testid="image-preview-dialog"]');
return node?.getAttribute("data-preview-zoom") === "1"
&& node?.getAttribute("data-preview-rotation") === "0"
&& node?.getAttribute("data-preview-offset-x") === "0"
&& node?.getAttribute("data-preview-offset-y") === "0";
}, null, { timeout: UI_TIMEOUT_MS });
const stageBox = await dialog.locator('[data-testid="image-preview-stage"]').first().boundingBox();
assert.ok(stageBox, "预览 stage 应有可交互区域");
const stageCenter = {
x: stageBox.x + stageBox.width / 2,
y: stageBox.y + stageBox.height / 2,
};
await page.mouse.move(stageCenter.x, stageCenter.y);
await page.mouse.wheel(0, -240);
await page.waitForFunction(() => Number(document.querySelector('[data-testid="image-preview-dialog"]')?.getAttribute("data-preview-zoom") || "1") > 1, null, { timeout: UI_TIMEOUT_MS });
await page.mouse.down({ button: "middle" });
await page.mouse.move(stageCenter.x + 72, stageCenter.y + 38, { steps: 6 });
await page.mouse.up({ button: "middle" });
await page.waitForFunction(() => {
const node = document.querySelector('[data-testid="image-preview-dialog"]');
return Math.abs(Number(node?.getAttribute("data-preview-offset-x") || "0")) >= 60
&& Math.abs(Number(node?.getAttribute("data-preview-offset-y") || "0")) >= 30;
}, null, { timeout: UI_TIMEOUT_MS });
const afterLayout = await page.evaluate(() => {
const sidebar = document.querySelector(".wolai-sidebar, .mnote-sidebar");
const content = document.querySelector(".mnote-content, .document-workspace");
const sidebarRect = sidebar?.getBoundingClientRect();
const contentRect = content?.getBoundingClientRect();
return {
sidebarLeft: sidebarRect?.left ?? null,
sidebarTop: sidebarRect?.top ?? null,
contentLeft: contentRect?.left ?? null,
contentTop: contentRect?.top ?? null,
};
});
for (const key of ["sidebarLeft", "sidebarTop", "contentLeft", "contentTop"]) {
if (beforeLayout[key] === null || afterLayout[key] === null) continue;
assert.ok(Math.abs(beforeLayout[key] - afterLayout[key]) <= 1, `预览交互不应导致左侧框架/正文布局漂移 ${key}: before=${beforeLayout[key]} after=${afterLayout[key]}`);
}
const downloadPromise = page.waitForEvent("download", { timeout: UI_TIMEOUT_MS });
await dialog.locator('[data-testid="image-preview-download"]').first().click({ timeout: UI_TIMEOUT_MS });
const download = await downloadPromise;
assert(/E24.*\.svg$/i.test(download.suggestedFilename()), `图片预览下载文件名异常: ${download.suggestedFilename()}`);
await download.delete().catch(() => undefined);
await page.keyboard.press("Escape");
await dialog.waitFor({ state: "detached", timeout: UI_TIMEOUT_MS });
await image.dblclick({ timeout: UI_TIMEOUT_MS });
await dialog.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await dialog.locator('[data-testid="image-preview-close"]').first().click({ timeout: UI_TIMEOUT_MS });
await dialog.waitFor({ state: "detached", timeout: UI_TIMEOUT_MS });
await editor.click({ timeout: UI_TIMEOUT_MS });
assert.equal(await page.locator('[data-testid="image-preview-dialog"]').count(), 0, "关闭预览后不应残留 dialog");
console.log(JSON.stringify({ ok: true, baseUrl: BASE_URL, documentId: target.documentId, root: target.root, screenshotDir: SCREENSHOT_DIR }, null, 2));
} finally {
await page.close().catch(() => undefined);
await context.close().catch(() => undefined);
await browser.close().catch(() => undefined);
if (server?.pid) {
server.kill("SIGTERM");
setTimeout(() => {
if (!server.killed) server.kill("SIGKILL");
}, 2000).unref();
}
}
}
if (require.main === module) {
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});
}
@@ -0,0 +1,198 @@
#!/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_SEARCH_SMOKE_ROOT
|| "/mnt/Data1T/Mnote_data/users/liaibo/workspaces/my-space";
const WORKSPACE_ID = process.env.MNOTE_WOLAI_SEARCH_SMOKE_WORKSPACE_ID
|| "ws_c23b7696e2b64e4fb1a2b12761af4cfd";
const DOCUMENT_RELATIVE_PATH = process.env.MNOTE_WOLAI_SEARCH_SMOKE_DOCUMENT
|| "liaibo的个人空间/知识/实验资料-工具书/核磁常见杂质化学位移表(核磁溶剂峰)/核磁常见杂质化学位移表(核磁溶剂峰).md";
const QUERY = process.env.MNOTE_WOLAI_SEARCH_SMOKE_QUERY || "核磁溶剂峰";
const EXPECTED_TITLE = process.env.MNOTE_WOLAI_SEARCH_SMOKE_EXPECTED_TITLE
|| "核磁常见杂质化学位移表(核磁溶剂峰)";
const USERNAME = process.env.MNOTE_WOLAI_SEARCH_SMOKE_USER || "liaibo";
const PASSWORD = process.env.MNOTE_WOLAI_SEARCH_SMOKE_PASSWORD || "MnoteE2E123!";
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 45_000);
const OUT_DIR = path.join(process.cwd(), "tmp", "task801-local-search-default-smoke");
const RESULT_PATH = path.join(OUT_DIR, "result.json");
const SCREENSHOT_PATH = path.join(OUT_DIR, "local-search-default.png");
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 documentUrl() {
const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(`local-md:${encodeLocalIdSegment(DOCUMENT_RELATIVE_PATH)}`)}`);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", fileUrl(ROOT_PATH));
url.searchParams.set("workspaceId", WORKSPACE_ID);
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()}`);
const whoami = await (await request.get(`${BASE_URL}/api/auth/whoami`, {
headers: { accept: "application/json" },
})).json();
assert.equal(whoami.userId, USERNAME);
return whoami;
}
async function main() {
fs.mkdirSync(OUT_DIR, { recursive: true });
const settingsPath = path.join(ROOT_PATH, ".mnote", "index", "local-index-settings.json");
const settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
assert.deepEqual(settings.includePaths, ["."], "local-first 工作区默认应索引整个目录");
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(UI_TIMEOUT_MS);
try {
const whoami = await signIn(context.request);
const apiSearch = await context.request.post(`${BASE_URL}/api/search/documents`, {
headers: { "content-type": "application/json", accept: "application/json" },
data: {
workspaceId: WORKSPACE_ID,
sourceKind: "local_folder",
rootUri: fileUrl(ROOT_PATH),
documentId: `local-md:${encodeLocalIdSegment(DOCUMENT_RELATIVE_PATH)}`,
query: QUERY,
limit: 30,
filters: {
titleOnly: false,
exact: false,
includeOcr: false,
onlyCurrentPage: false,
timeRange: "any",
timeField: "updated",
},
},
});
assert.equal(apiSearch.status(), 200, `本地搜索 API 失败: ${apiSearch.status()} ${await apiSearch.text()}`);
const apiPayload = await apiSearch.json();
assert.ok(
(apiPayload.results || []).some((item) => String(item.title || "").includes(EXPECTED_TITLE)),
`本地搜索 API 未命中目标页: ${JSON.stringify(apiPayload.results?.slice(0, 5), null, 2)}`,
);
await page.goto(documentUrl(), { waitUntil: "domcontentloaded" });
await page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]').first().waitFor({ state: "visible" });
await page.keyboard.press("Control+P");
const modal = page.locator('[data-testid="wolai-search-modal"]');
await modal.waitFor({ state: "visible" });
const input = modal.locator('[data-testid="wolai-search-input"]');
const knowledgeSwitch = modal.locator('[data-search-switch="knowledge"]');
assert.equal(await knowledgeSwitch.getAttribute("aria-checked"), "false", "搜索默认不应走空的全盘知识库");
await input.fill(QUERY);
await page.waitForFunction(
(expectedTitle) => {
const meta = document.querySelector('[data-testid="wolai-search-result-meta"]')?.textContent || "";
const rows = Array.from(document.querySelectorAll('[data-testid="wolai-search-result-row"]'));
return meta.includes("工作区搜索")
&& /共\s*[1-9]\d*\s*条匹配结果/.test(meta)
&& rows.some((row) => (row.textContent || "").includes(expectedTitle));
},
EXPECTED_TITLE,
{ timeout: UI_TIMEOUT_MS },
);
const state = await page.evaluate((expectedTitle) => {
const rows = Array.from(document.querySelectorAll('[data-testid="wolai-search-result-row"]'));
return {
knowledgeChecked: document.querySelector('[data-search-switch="knowledge"]')?.getAttribute("aria-checked") || "",
meta: document.querySelector('[data-testid="wolai-search-result-meta"]')?.textContent || "",
rowCount: rows.length,
targetFound: rows.some((row) => (row.textContent || "").includes(expectedTitle)),
firstRows: rows.slice(0, 5).map((row) => row.textContent || ""),
};
}, EXPECTED_TITLE);
assert.equal(state.knowledgeChecked, "false", `全盘知识库默认状态被改回开启: ${JSON.stringify(state)}`);
assert.ok(state.meta.includes("工作区搜索"), `搜索 UI 未走工作区搜索: ${JSON.stringify(state)}`);
assert.ok(state.rowCount > 0, `搜索 UI 未返回结果: ${JSON.stringify(state)}`);
assert.equal(state.targetFound, true, `搜索 UI 未展示目标页: ${JSON.stringify(state, null, 2)}`);
await page.screenshot({ path: SCREENSHOT_PATH, fullPage: false });
const result = {
ok: true,
whoami,
rootPath: ROOT_PATH,
workspaceId: WORKSPACE_ID,
query: QUERY,
expectedTitle: EXPECTED_TITLE,
apiCount: apiPayload.results?.length || 0,
state,
screenshotPath: SCREENSHOT_PATH,
};
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) => {
fs.mkdirSync(OUT_DIR, { recursive: true });
fs.writeFileSync(
path.join(OUT_DIR, "failure.json"),
`${JSON.stringify({ ok: false, error: error.stack || error.message || String(error) }, null, 2)}\n`,
"utf8",
);
console.error(error && error.stack ? error.stack : error);
process.exit(1);
});
@@ -0,0 +1,218 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const { request } = 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_PDF_SMOKE_ROOT
|| "/mnt/Data1T/Mnote_data/users/liaibo/workspaces/my-space";
const LIMIT = Number(process.env.MNOTE_WOLAI_PDF_SMOKE_LIMIT || 10);
const USERNAME = process.env.MNOTE_WOLAI_PDF_SMOKE_USER || "mnote.e2e@example.com";
const PASSWORD = process.env.MNOTE_WOLAI_PDF_SMOKE_PASSWORD || "MnoteE2E123!";
const OUT_DIR = path.join(process.cwd(), "tmp", "task802-pdf-attachments-restore-smoke");
const RESULT_PATH = path.join(OUT_DIR, "result.json");
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 markdownDocumentId(relativePath) {
return `local-md:${encodeLocalIdSegment(relativePath)}`;
}
function walkMarkdownFiles(dir, out = []) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.name === ".assets" || entry.name === ".git" || entry.name === "node_modules") {
continue;
}
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
walkMarkdownFiles(fullPath, out);
} else if (entry.isFile() && entry.name.toLowerCase().endsWith(".md")) {
out.push(fullPath);
}
}
return out;
}
function rootRelativePath(filePath) {
return path.relative(ROOT_PATH, filePath).split(path.sep).join("/");
}
function normalizeHref(href) {
return String(href || "")
.trim()
.replace(/^<|>$/g, "")
.replace(/\\([\\`*_{}\[\]()#+\-.!|>])/g, "$1");
}
function pdfHrefs(markdown) {
const refs = [];
const source = String(markdown || "");
const linkPattern = /\[[^\]]*?\.pdf[^\]]*?\]\(\s*(?:<([^>]+?\.pdf)>|([^)\s]+?\.pdf))(?:\s+["'][^"']*["'])?\s*\)/gi;
for (const match of source.matchAll(linkPattern)) {
const href = normalizeHref(match[1] || match[2] || "");
if (!href || /^https?:\/\//i.test(href) || href.startsWith("#") || href.startsWith("mailto:")) {
continue;
}
refs.push(href);
}
return refs;
}
function collectExistingPdfAttachments() {
const candidates = [];
for (const markdownPath of walkMarkdownFiles(ROOT_PATH)) {
const markdown = fs.readFileSync(markdownPath, "utf8");
const markdownRelativePath = rootRelativePath(markdownPath);
for (const href of pdfHrefs(markdown)) {
if (!href.includes(".assets/")) {
continue;
}
const assetRelativePath = path.posix.normalize(path.posix.join(
path.posix.dirname(markdownRelativePath),
href,
));
const assetPath = path.join(ROOT_PATH, ...assetRelativePath.split("/"));
if (!fs.existsSync(assetPath)) {
continue;
}
const bytes = fs.statSync(assetPath).size;
if (bytes <= 0) {
continue;
}
candidates.push({
markdownRelativePath,
href,
assetRelativePath,
assetPath,
bytes,
});
if (candidates.length >= LIMIT) {
return candidates;
}
}
}
return candidates;
}
async function signIn(api) {
const response = await api.post("/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()}`);
}
function flattenBlockDocumentBlocks(blockDocument) {
const direct = Array.isArray(blockDocument?.blocks) ? blockDocument.blocks : [];
return direct;
}
function pageHasMediaSource(pageAggregate, href, assetRelativePath) {
const content = Array.isArray(pageAggregate?.body?.content) ? pageAggregate.body.content : [];
if (content.some((block) => block?.type === "media" && block?.props?.sourcePath === href)) {
return true;
}
const blocks = flattenBlockDocumentBlocks(pageAggregate?.body?.blockDocument);
return blocks.some((block) => (
block?.type === "media"
&& (block?.attrs?.sourcePath === href || block?.attrs?.sourcePath === assetRelativePath)
));
}
async function main() {
fs.mkdirSync(OUT_DIR, { recursive: true });
const candidates = collectExistingPdfAttachments();
assert.ok(candidates.length >= Math.min(5, LIMIT), `可验证 PDF 附件不足: ${candidates.length}`);
const api = await request.newContext({
baseURL: BASE_URL,
extraHTTPHeaders: { accept: "application/json" },
});
try {
await signIn(api);
const checked = [];
for (const candidate of candidates) {
const documentId = markdownDocumentId(candidate.markdownRelativePath);
const aggregateUrl = new URL(`/api/page-aggregate/${encodeURIComponent(documentId)}`, BASE_URL);
aggregateUrl.searchParams.set("sourceKind", "local_folder");
aggregateUrl.searchParams.set("rootUri", fileUrl(ROOT_PATH));
const response = await api.get(`${aggregateUrl.pathname}${aggregateUrl.search}`);
assert.equal(
response.status(),
200,
`page aggregate 失败 ${candidate.markdownRelativePath}: ${response.status()} ${await response.text()}`,
);
const payload = await response.json();
const aggregate = payload.result;
const attachmentRefs = Array.isArray(aggregate?.body?.attachmentRefs)
? aggregate.body.attachmentRefs
: [];
const ref = attachmentRefs.find((item) => (
item?.relativePath === candidate.assetRelativePath
|| item?.normalizedHref === candidate.href
|| item?.rawHref === candidate.href
));
assert.ok(ref, `aggregate 缺少 PDF attachmentRef: ${candidate.assetRelativePath}`);
assert.equal(String(ref.ext || "").toLowerCase(), "pdf", JSON.stringify(ref));
assert.equal(ref.exists, true, JSON.stringify(ref));
assert.equal(ref.authorized, true, JSON.stringify(ref));
assert.equal(ref.fileSize, candidate.bytes, JSON.stringify({ ref, candidate }));
assert.ok(
pageHasMediaSource(aggregate, candidate.href, candidate.assetRelativePath),
`aggregate 未还原 PDF media block: ${candidate.assetRelativePath}`,
);
checked.push({
markdownRelativePath: candidate.markdownRelativePath,
assetRelativePath: candidate.assetRelativePath,
bytes: candidate.bytes,
label: ref.label,
});
}
const result = { ok: true, checked };
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
console.log(JSON.stringify(result, null, 2));
} finally {
await api.dispose();
}
}
main().catch((error) => {
console.error(error && error.stack ? error.stack : error);
process.exit(1);
});
@@ -0,0 +1,287 @@
#!/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);
});
+122
View File
@@ -0,0 +1,122 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { importWolaiExport } = require("./lib/wolai-export-importer");
function writeUtf8(filePath, content) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, content, "utf8");
}
function makeTempDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), "mnote-wolai-import-smoke-"));
}
function runImportFixture() {
const workspace = makeTempDir();
const sourceRoot = path.join(workspace, "source");
const targetRoot = path.join(workspace, "target");
const nbsp = "\u00A0";
const assetName = `12849总结报告-WB17061405${nbsp}肖建洋_qGouWUbkM_.doc`;
writeUtf8(
path.join(sourceRoot, "liaibo的个人空间.md"),
[
"# liaibo的个人空间",
"",
"- [项目](pages/项目_abcd1234.md)",
"",
].join("\n"),
);
writeUtf8(
path.join(sourceRoot, "pages", "项目_abcd1234.md"),
[
"# 项目",
"",
"![](../image/diagram one.png)",
`[报告](../file/${assetName} "12849总结报告-WB17061405${nbsp}肖建洋.doc")`,
"[/home/fc/import/markdown/abc/resources/s0040-4020(01)88151-5.pdf](/home/fc/import/markdown/abc/resources/s0040-4020\\(01\\)88151-5.pdf)",
"[旧resources附件](../../resources/林可函数.xlsx \"林可函数.xlsx\")",
"",
].join("\n"),
);
writeUtf8(path.join(sourceRoot, "image", "diagram one.png"), "png-body\n");
writeUtf8(path.join(sourceRoot, "file", assetName), "doc-body\n");
const result = importWolaiExport({
sourceRoot,
targetRoot,
ownerId: "liaibo",
workspaceId: "local-ws:liaibo:my-space",
copyMode: "copy",
});
return {
result,
targetRoot,
assetName,
childMarkdownPath: path.join(
targetRoot,
"liaibo的个人空间",
"项目",
"项目.md",
),
};
}
function assertWolaiExportImport() {
const { result, targetRoot, assetName, childMarkdownPath } = runImportFixture();
const childMarkdown = fs.readFileSync(childMarkdownPath, "utf8");
const workspaceMetadata = JSON.parse(
fs.readFileSync(path.join(targetRoot, ".mnote", "workspace.json"), "utf8"),
);
const importMetadata = JSON.parse(
fs.readFileSync(path.join(targetRoot, ".mnote", "wolai-import.json"), "utf8"),
);
const localIndexSettings = JSON.parse(
fs.readFileSync(path.join(targetRoot, ".mnote", "index", "local-index-settings.json"), "utf8"),
);
assert.equal(result.ok, true);
assert.equal(result.pages.imported, 2);
assert.equal(result.assets.copied, 2);
assert.equal(result.links.rewritten, 5);
assert.equal(result.links.missing, 2);
assert.equal(workspaceMetadata.ownerId, "liaibo");
assert.equal(importMetadata.links.missingSamples.length, 2);
assert.deepEqual(localIndexSettings.includePaths, ["."], "导入后默认应索引整个 local-first 工作区");
assert.equal(localIndexSettings.runOnChange, false);
assert.match(childMarkdown, /\.assets\/image\/diagram one\.png/);
assert.match(childMarkdown, /\.assets\/file\/12849总结报告-WB17061405/);
assert.doesNotMatch(childMarkdown, /Wolai资源/);
assert.doesNotMatch(childMarkdown, /\]\(\.\.\/file\//);
assert.doesNotMatch(childMarkdown, /\/home\/fc\/import/);
assert.doesNotMatch(childMarkdown, /resources\/林可函数\.xlsx/);
assert.match(childMarkdown, /#wolai-missing-resource/);
assert.equal(
fs.readFileSync(
path.join(targetRoot, "liaibo的个人空间", "项目", ".assets", "image", "diagram one.png"),
"utf8",
),
"png-body\n",
);
assert.equal(
fs.readFileSync(
path.join(targetRoot, "liaibo的个人空间", "项目", ".assets", "file", assetName),
"utf8",
),
"doc-body\n",
);
const uploadedAssets = JSON.parse(
fs.readFileSync(path.join(targetRoot, ".mnote", "uploaded-assets.json"), "utf8"),
);
assert.ok(uploadedAssets.entries["liaibo的个人空间/项目/.assets/image/diagram one.png"]);
assert.ok(uploadedAssets.entries[`liaibo的个人空间/项目/.assets/file/${assetName}`]);
}
assertWolaiExportImport();
console.log("wolai-export-importer.test: ok");