Files
mnote/scripts/task167-local-markdown-title-body-options-no-convex-smoke.js
T

219 lines
7.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
const assert = require("assert");
const fs = require("fs");
const http = require("http");
const net = require("net");
const os = require("os");
const path = require("path");
const { spawn } = require("child_process");
const TIMEOUT_MS = Number(process.env.UI_TIMEOUT_MS || 15_000);
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);
});
}
function waitForHttpOk(url, timeoutMs) {
const deadline = Date.now() + timeoutMs;
return new Promise((resolve, reject) => {
const tick = () => {
const request = http.get(url, (response) => {
response.resume();
if (response.statusCode >= 200 && response.statusCode < 500) {
resolve();
return;
}
retry();
});
request.on("error", retry);
request.setTimeout(1_000, () => {
request.destroy();
retry();
});
};
const retry = () => {
if (Date.now() > deadline) {
reject(new Error(`server_not_ready: ${url}`));
return;
}
setTimeout(tick, 250);
};
tick();
});
}
function actorHeaders(actorId) {
return {
"content-type": "application/json",
"x-mnote-actor-id": actorId,
"x-mnote-actor-type": "user",
};
}
async function requestJson(baseUrl, actorId, pathname, options = {}) {
const response = await fetch(`${baseUrl}${pathname}`, {
method: options.method || "GET",
headers: actorHeaders(actorId),
body: options.body == null ? undefined : JSON.stringify(options.body),
});
const payload = await response.json().catch(() => null);
assert(
response.ok,
`${options.method || "GET"} ${pathname} failed ${response.status}: ${JSON.stringify(payload)}`,
);
return payload;
}
async function loadAggregate(baseUrl, actorId, documentId, rootUri) {
const url = new URL(`/api/page-aggregate/${encodeURIComponent(documentId)}`, baseUrl);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", rootUri);
const payload = await requestJson(
baseUrl,
actorId,
`${url.pathname}${url.search}`,
);
return payload.result;
}
async function main() {
const port = await pickPort();
const baseUrl = `http://127.0.0.1:${port}`;
const dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-local-p3-"));
const actorId = `p3-smoke-${process.pid}-${Date.now()}`;
const managedRoot = path.join(dataRoot, "users", actorId, "workspaces", "my-space");
let documentId = "local-md:README.md";
let markdownPath = path.join(managedRoot, "README.md");
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_LOCAL_WORKSPACE_BASE_DIR: dataRoot,
},
stdio: ["ignore", "pipe", "pipe"],
});
let stderr = "";
server.stderr.on("data", (chunk) => {
stderr += chunk.toString();
});
try {
await waitForHttpOk(`${baseUrl}/health`, 60_000);
const created = await requestJson(baseUrl, actorId, "/api/local-folder/workspaces/default", {
method: "POST",
body: {},
});
const rootUri = created.workspace.rootUri;
assert(rootUri, "创建默认本地工作区应返回 rootUri");
fs.mkdirSync(path.dirname(markdownPath), { recursive: true });
fs.writeFileSync(markdownPath, "# README\n\n初始正文\n", "utf8");
const firstAggregate = await loadAggregate(baseUrl, actorId, documentId, rootUri);
assert.equal(firstAggregate.head.title, "README");
assert.equal(firstAggregate.body.projectionSource, "local_markdown.content", "local-first Page Aggregate 应声明 local markdown projection source");
assert(firstAggregate.body.blockDocument, "local-first Page Aggregate 应输出 blockDocumentruntime 不应只依赖 legacy content");
const titlePayload = await requestJson(baseUrl, actorId, "/api/documents/title", {
method: "POST",
body: {
documentId,
sourceKind: "local_folder",
rootUri,
title: "P3 标题",
},
});
documentId = titlePayload?.result?.documentId || titlePayload?.documentId || documentId;
markdownPath = path.join(managedRoot, "P3 标题.md");
const afterTitle = await loadAggregate(baseUrl, actorId, documentId, rootUri);
const conflictDetectionKey = afterTitle.body.conflictDetectionKey || afterTitle.body.conflict_detection_key;
assert(conflictDetectionKey, "标题更新后应能读取新的 conflictDetectionKey");
await requestJson(baseUrl, actorId, "/api/documents/save", {
method: "POST",
body: {
documentId,
sourceKind: "local_folder",
rootUri,
conflictDetectionKey,
content: [
{
type: "heading",
props: { level: 1 },
content: [{ type: "text", text: "正文标题" }],
},
{
type: "paragraph",
content: [{ type: "text", text: "正文已保存" }],
},
],
},
});
await requestJson(baseUrl, actorId, "/api/documents/options", {
method: "POST",
body: {
documentId,
sourceKind: "local_folder",
rootUri,
options: {
wideLayout: true,
showToc: true,
showHeadingNumbers: true,
},
},
});
const finalAggregate = await loadAggregate(baseUrl, actorId, documentId, rootUri);
assert.equal(finalAggregate.head.title, "P3 标题", "frontmatter title 应优先于正文 H1");
assert.equal(finalAggregate.body.projectionSource, "local_markdown.content", "保存后 Page Aggregate source 不应退回 legacy documents.content");
assert(finalAggregate.body.blockDocument, "保存后 Page Aggregate 应保留 blockDocument");
assert.equal(finalAggregate.layout.pageOptions.wideLayout, true);
assert.equal(finalAggregate.layout.pageOptions.showToc, true);
assert.equal(finalAggregate.layout.pageOptions.showHeadingNumbers, true);
assert(
JSON.stringify(finalAggregate.body.content).includes("正文已保存"),
"page aggregate 应从本地 markdown 恢复正文",
);
const markdown = fs.readFileSync(markdownPath, "utf8");
assert(
markdown.includes("title: P3 标题") || path.basename(markdownPath) === "P3 标题.md",
"标题应写入 frontmatter 或体现为本地 Markdown 文件名",
);
assert(markdown.includes("# 正文标题"), "正文 H1 应写回 markdown");
assert(markdown.includes("正文已保存"), "正文段落应写回 markdown");
assert(
!fs.existsSync(path.join(managedRoot, ".mnote", "page-options.json")),
"页面设置不应继续写入 .mnote/page-options.json",
);
console.log("task167 local markdown title/body/options no-convex smoke passed");
} finally {
server.kill("SIGINT");
fs.rmSync(dataRoot, { recursive: true, force: true });
if (server.exitCode == null) {
await new Promise((resolve) => server.once("exit", resolve));
}
if (server.exitCode && server.exitCode !== 130 && server.exitCode !== null) {
process.stderr.write(stderr);
}
}
}
main().catch((error) => {
console.error(error);
process.exit(1);
});