feat: restore Wolai workspace navigation and assets
This commit is contained in:
@@ -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();
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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"),
|
||||
[
|
||||
"# 项目",
|
||||
"",
|
||||
"",
|
||||
`[报告](../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");
|
||||
Reference in New Issue
Block a user