feat: align local-first workspace direction

Document the VSCode-like local-first product shape, demote Convex to a control-plane role, and retire stale architecture drafts.

Add local workspace migration/export references plus smoke coverage for no-Convex managed workspace startup, local markdown title/body/options persistence, asset upload behavior, and Convex fixture export.

Verification: git diff --cached --check; node scripts/check-local-first-convex-guard.js --staged; node scripts/task444-convex-workspace-export-local-fixture-smoke.js; node scripts/task166-local-first-managed-workspace-no-convex-smoke.js; node scripts/task167-local-markdown-title-body-options-no-convex-smoke.js
This commit is contained in:
lix-2026
2026-05-19 08:11:58 +08:00
parent 68d321e297
commit cdff672aa5
67 changed files with 5242 additions and 932 deletions
+237
View File
@@ -0,0 +1,237 @@
#!/usr/bin/env node
"use strict";
const fs = require("node:fs");
const path = require("node:path");
function parseArgs(argv) {
const args = { fixture: "", out: "" };
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--fixture") {
args.fixture = argv[++index] || "";
continue;
}
if (arg === "--out") {
args.out = argv[++index] || "";
continue;
}
if (arg === "--help" || arg === "-h") {
console.log("用法:node scripts/export-convex-workspace-to-local.js --fixture <fixture.json> --out <dir>");
process.exit(0);
}
throw new Error(`未知参数:${arg}`);
}
if (!args.fixture) throw new Error("缺少 --fixture");
if (!args.out) throw new Error("缺少 --out");
return args;
}
function ensureDir(dir) {
fs.mkdirSync(dir, { recursive: true });
}
function writeUtf8(filePath, content) {
ensureDir(path.dirname(filePath));
fs.writeFileSync(filePath, content, "utf8");
}
function sanitizeName(name) {
return String(name || "untitled")
.trim()
.replace(/[<>:"/\\|?*\u0000-\u001f]/g, "_")
.replace(/\s+/g, " ")
.replace(/\.+$/g, "")
.trim() || "untitled";
}
function toMarkdownFilename(title) {
return `${sanitizeName(title)}.md`;
}
function loadFixture(filePath) {
return JSON.parse(fs.readFileSync(filePath, "utf8"));
}
function buildDocumentIndex(documents) {
const byId = new Map();
documents.forEach((doc) => {
byId.set(String(doc.id), doc);
});
return byId;
}
function buildMarkdownPath(doc, documentsById) {
const segments = [];
let current = doc;
while (current) {
segments.unshift(sanitizeName(current.title || current.id || "untitled"));
const parentId = current.parent_id || current.parentId || null;
current = parentId ? documentsById.get(String(parentId)) : null;
}
return path.posix.join("pages", ...segments) + ".md";
}
function buildAssetPath(markdownPath, fileName) {
const base = markdownPath.replace(/\.md$/i, ".assets");
return path.posix.join(base, sanitizeName(fileName));
}
function findDocumentForAsset(asset, documents) {
const directId = String(asset.document_id || asset.documentId || asset.page_id || asset.pageId || "").trim();
if (directId) return directId;
const assetId = String(asset.id || "").trim();
if (!assetId) return "";
const doc = documents.find((item) => {
const content = typeof item.content === "string" ? item.content : "";
const rawText = typeof item.raw_text === "string" ? item.raw_text : "";
const editorText = typeof item.editor_document === "string" ? item.editor_document : "";
const tiptapText = typeof item.tiptap_document === "string" ? item.tiptap_document : "";
return [content, rawText, editorText, tiptapText].some((text) => text.includes(assetId));
});
return doc ? String(doc.id) : "";
}
function decodeAssetContent(asset) {
if (typeof asset.contentBase64 === "string" && asset.contentBase64) {
return Buffer.from(asset.contentBase64, "base64");
}
if (typeof asset.content === "string") {
return Buffer.from(asset.content, "utf8");
}
return Buffer.from("", "utf8");
}
function rewriteAssetUrls(markdown, assetPathById) {
return String(markdown || "").replace(/\/api\/media\/sign\?assetId=([^)\s"'&#]+)/g, (_, assetId) => {
return assetPathById.get(String(assetId)) || _;
});
}
function serializePageOptions(doc) {
return {
wideLayout: doc.wide_layout ?? null,
useSmallText: doc.use_small_text ?? null,
showHeadingNumbers: doc.show_heading_numbers ?? null,
showToc: doc.show_toc ?? null,
showStructure: doc.show_structure ?? null,
protectEditing: doc.protect_editing ?? null,
showWordCount: doc.show_word_count ?? null,
collapseBacklinks: doc.collapse_backlinks ?? null,
pageFont: doc.page_font ?? null,
layoutDensity: doc.layout_density ?? null,
hideChildPages: doc.hide_child_pages ?? null,
showBlockRefCount: doc.show_block_ref_count ?? null,
embedDefaultBlockId: doc.embed_default_block_id ?? null,
};
}
function main() {
const { fixture: fixturePath, out } = parseArgs(process.argv.slice(2));
const fixture = loadFixture(fixturePath);
const documents = Array.isArray(fixture.documents) ? fixture.documents : [];
const mediaAssets = Array.isArray(fixture.mediaAssets) ? fixture.mediaAssets : [];
const aiSessions = Array.isArray(fixture.aiSessions) ? fixture.aiSessions : [];
fs.rmSync(out, { recursive: true, force: true });
ensureDir(out);
const docsById = buildDocumentIndex(documents);
const markdownPathByDocId = new Map();
const assetPathById = new Map();
const pageIds = {};
const pageOptions = {};
const resourceIndex = { version: 1, assets: {} };
documents.forEach((doc) => {
const markdownPath = buildMarkdownPath(doc, docsById);
markdownPathByDocId.set(String(doc.id), markdownPath);
pageIds[markdownPath] = `local-mdid:${doc.id}`;
pageOptions[`local-mdid:${doc.id}`] = serializePageOptions(doc);
});
mediaAssets.forEach((asset) => {
const ownerDocId = findDocumentForAsset(asset, documents) || String(documents[0]?.id || "");
const ownerMarkdownPath = markdownPathByDocId.get(ownerDocId) || "pages/attachments.md";
const relativePath = buildAssetPath(ownerMarkdownPath, asset.fileName || asset.name || asset.id);
assetPathById.set(String(asset.id), relativePath);
resourceIndex.assets[String(asset.id)] = {
fileName: sanitizeName(asset.fileName || asset.name || asset.id),
relativePath,
documentId: ownerDocId || null,
};
});
documents.forEach((doc) => {
const markdownPath = markdownPathByDocId.get(String(doc.id));
const assetRoot = markdownPath.replace(/\.md$/i, ".assets");
const markdownDir = path.posix.dirname(markdownPath);
const markdownBody = rewriteAssetUrls(
typeof doc.content === "string"
? doc.content
: String(doc.raw_text || doc.editor_document || doc.tiptap_document || ""),
new Map(Array.from(assetPathById.entries()).map(([assetId, absolutePath]) => [
assetId,
path.posix.relative(markdownDir, absolutePath),
])),
);
const frontmatter = [
"---",
`title: ${String(doc.title || doc.id || "untitled")}`,
`mnote_id: ${String(doc.id)}`,
"---",
"",
].join("\n");
writeUtf8(path.join(out, markdownPath), `${frontmatter}${markdownBody}`);
ensureDir(path.join(out, assetRoot));
});
mediaAssets.forEach((asset) => {
const relativePath = assetPathById.get(String(asset.id));
if (!relativePath) return;
const target = path.join(out, relativePath);
writeUtf8(target, decodeAssetContent(asset));
});
aiSessions.forEach((session) => {
const sessionId = sanitizeName(session.sessionId || session.id || "session");
const shareId = String(session.shareId || session.share_id || "").trim();
const visibility = String(session.visibility || "").trim();
const dir = shareId || visibility === "shared"
? path.join(out, "ai-sessions", "shared", sanitizeName(shareId || "share"))
: path.join(out, "ai-sessions", "private");
ensureDir(dir);
const filePath = path.join(dir, `${sessionId}.jsonl`);
const events = Array.isArray(session.events) ? session.events : [];
const lines = events.map((event) => JSON.stringify(event)).join("\n");
writeUtf8(filePath, lines ? `${lines}\n` : "");
});
writeUtf8(path.join(out, ".mnote", "page-ids.json"), JSON.stringify({ version: 1, pages: pageIds }, null, 2));
writeUtf8(path.join(out, ".mnote", "page-options.json"), JSON.stringify({ version: 1, pages: pageOptions }, null, 2));
writeUtf8(path.join(out, ".mnote", "resource-index.json"), JSON.stringify(resourceIndex, null, 2));
writeUtf8(
path.join(out, ".mnote", "workspace.json"),
JSON.stringify(
{
workspaceId: fixture.workspace?.id || "exported-workspace",
ownerId: fixture.workspace?.ownerId || fixture.workspace?.owner_id || "unknown",
createdAt: new Date().toISOString(),
capabilities: ["local_files", "ai_sessions", "exported_from_convex"],
},
null,
2,
),
);
console.log(JSON.stringify({ ok: true, out }, null, 2));
}
if (require.main === module) {
try {
main();
} catch (error) {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
}
}
@@ -8,6 +8,20 @@ const { chromium } = require("playwright");
const BASE_URL = process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000";
const UI_TIMEOUT_MS = Number(process.env.UI_TIMEOUT_MS || 10_000);
const ACTOR_ID = `smoke-user-${process.pid}-${Date.now()}`;
const MANAGED_DATA_ROOT = "/mnt/Data1T/Mnote_data";
function resolveChromiumExecutablePath() {
const explicit = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || "";
if (explicit && fs.existsSync(explicit)) return explicit;
return [
"/usr/bin/google-chrome-stable",
"/usr/bin/google-chrome",
"/snap/bin/chromium",
"/usr/bin/chromium",
"/usr/bin/chromium-browser",
].find((candidate) => fs.existsSync(candidate)) || "";
}
function fileUrl(filePath) {
return `file://${filePath.split(path.sep).map((part, index) => (
@@ -15,6 +29,20 @@ function fileUrl(filePath) {
)).join("/")}`;
}
function managedActorDir(actorId) {
return path.join(MANAGED_DATA_ROOT, "users", actorId, "workspaces", "my-space");
}
function writeWorkspaceManifest(root, ownerId) {
fs.mkdirSync(path.join(root, ".mnote"), { recursive: true });
fs.writeFileSync(path.join(root, ".mnote", "workspace.json"), JSON.stringify({
workspaceId: `local-ws-${path.basename(root).replace(/[^a-zA-Z0-9_-]/g, "_")}`,
ownerId,
createdAt: new Date(0).toISOString(),
capabilities: ["local_files", "tree_commands", "markdown_edit", "asset_upload"],
}, null, 2), "utf8");
}
async function main() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-main-local-folder-"));
const otherRoot = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-main 本地 #other-"));
@@ -23,11 +51,17 @@ async function main() {
fs.writeFileSync(path.join(root, "docs", "child.md"), "# Child Page\n", "utf8");
fs.writeFileSync(path.join(root, "plain.txt"), "plain asset\n", "utf8");
fs.writeFileSync(path.join(otherRoot, "OTHER.md"), "# Other Root\n", "utf8");
writeWorkspaceManifest(root, ACTOR_ID);
writeWorkspaceManifest(otherRoot, ACTOR_ID);
const browser = await chromium.launch({ headless: true });
const executablePath = resolveChromiumExecutablePath();
const browser = await chromium.launch({
headless: true,
...(executablePath ? { executablePath } : {}),
});
const context = await browser.newContext({
extraHTTPHeaders: {
"x-mnote-actor-id": "user_real",
"x-mnote-actor-id": ACTOR_ID,
"x-mnote-actor-type": "user",
},
});
@@ -40,6 +74,34 @@ async function main() {
try {
await page.goto(BASE_URL, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="mnote-create-default-local-workspace"]').waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
await page.locator('[data-testid="mnote-open-local-folder-empty"]').waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
await page.locator('[data-testid="mnote-create-default-local-workspace"]').click({
timeout: UI_TIMEOUT_MS,
});
const managedRoot = managedActorDir(ACTOR_ID);
await page.waitForURL((url) => {
return url.origin === new URL(BASE_URL).origin &&
url.pathname === "/" &&
url.searchParams.get("sourceKind") === "local_folder" &&
url.searchParams.get("rootUri") === fileUrl(managedRoot) &&
url.searchParams.get("treeView") === "filetree";
}, { timeout: UI_TIMEOUT_MS });
assert(
fs.existsSync(path.join(managedRoot, ".mnote", "workspace.json")),
"创建我的空间应写入受管 workspace manifest",
);
assert(
fs.existsSync(path.join(managedRoot, "pages", "我的空间.md")),
"创建我的空间应写入默认 Markdown 首页",
);
await page.locator('[data-mnote-action="open-local-folder"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="mnote-local-folder-dialog"]').waitFor({
state: "visible",
@@ -57,7 +119,8 @@ async function main() {
url.searchParams.get("treeView") === "filetree";
}, { timeout: UI_TIMEOUT_MS });
const recentLocalRoots = await page.evaluate(() => {
const raw = window.localStorage.getItem("mnote.localFolder.recentRoots") || "[]";
const actorId = document.body.getAttribute("data-mnote-actor-id") || "";
const raw = window.localStorage.getItem(`mnote.localFolder.recentRoots:${encodeURIComponent(actorId)}`) || "[]";
return JSON.parse(raw);
});
assert.equal(recentLocalRoots[0], fileUrl(root), "打开本地文件夹后应记录最近 rootUri");
@@ -107,11 +170,16 @@ async function main() {
url.searchParams.get("treeView") === "filetree";
}, { timeout: UI_TIMEOUT_MS });
const updatedRecentLocalRoots = await page.evaluate(() => {
const raw = window.localStorage.getItem("mnote.localFolder.recentRoots") || "[]";
const actorId = document.body.getAttribute("data-mnote-actor-id") || "";
const raw = window.localStorage.getItem(`mnote.localFolder.recentRoots:${encodeURIComponent(actorId)}`) || "[]";
return JSON.parse(raw);
});
assert.equal(updatedRecentLocalRoots[0], fileUrl(otherRoot), "切到其他本地文件夹后应把它放到最近目录首位");
assert.equal(updatedRecentLocalRoots[1], fileUrl(root), "之前打开过的本地目录应保留在最近目录列表中");
const legacyRecentLocalRoots = await page.evaluate(() => {
return window.localStorage.getItem("mnote.localFolder.recentRoots");
});
assert.equal(legacyRecentLocalRoots, null, "最近本地目录不能写入跨用户共享的 legacy localStorage key");
await page.locator('.tree-row[data-row-id="local:markdown:OTHER.md"]').waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
@@ -136,11 +204,20 @@ async function main() {
!url.searchParams.has("rootUri");
}, { timeout: UI_TIMEOUT_MS });
await page.goto(BASE_URL, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await page.waitForURL((url) => {
return url.pathname === "/" &&
url.searchParams.get("sourceKind") === "local_folder" &&
url.searchParams.get("rootUri") === fileUrl(otherRoot) &&
url.searchParams.get("treeView") === "filetree";
}, { timeout: UI_TIMEOUT_MS });
console.log("task164 desktop hot local folder main entry smoke passed");
} finally {
await browser.close();
fs.rmSync(root, { recursive: true, force: true });
fs.rmSync(otherRoot, { recursive: true, force: true });
fs.rmSync(path.join(MANAGED_DATA_ROOT, "users", ACTOR_ID), { recursive: true, force: true });
}
}
@@ -0,0 +1,158 @@
#!/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 { chromium } = require("playwright");
const UI_TIMEOUT_MS = Number(process.env.UI_TIMEOUT_MS || 15_000);
function resolveChromiumExecutablePath() {
const explicit = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || "";
if (explicit && fs.existsSync(explicit)) return explicit;
return [
"/usr/bin/google-chrome-stable",
"/usr/bin/google-chrome",
"/snap/bin/chromium",
"/usr/bin/chromium",
"/usr/bin/chromium-browser",
].find((candidate) => fs.existsSync(candidate)) || "";
}
function fileUrl(filePath) {
return `file://${filePath.split(path.sep).map((part, index) => (
index === 0 ? "" : encodeURIComponent(part)
)).join("/")}`;
}
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();
});
}
async function main() {
const port = await pickPort();
const baseUrl = `http://127.0.0.1:${port}`;
const dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-managed-workspace-"));
const actorId = `no-convex-smoke-${process.pid}-${Date.now()}`;
const managedRoot = path.join(dataRoot, "users", actorId, "workspaces", "my-space");
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,
CONVEX_SELF_HOSTED_URL: "http://127.0.0.1:9",
NEXT_PUBLIC_CONVEX_URL: "http://127.0.0.1:9",
},
stdio: ["ignore", "pipe", "pipe"],
});
let stderr = "";
server.stderr.on("data", (chunk) => {
stderr += chunk.toString();
});
const executablePath = resolveChromiumExecutablePath();
const browser = await chromium.launch({
headless: true,
...(executablePath ? { executablePath } : {}),
});
const context = await browser.newContext({
extraHTTPHeaders: {
"x-mnote-actor-id": actorId,
"x-mnote-actor-type": "user",
},
});
const page = await context.newPage();
try {
await waitForHttpOk(`${baseUrl}/health`, 60_000);
await page.goto(baseUrl, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="mnote-create-default-local-workspace"]').click({
timeout: UI_TIMEOUT_MS,
});
await page.waitForURL((url) => {
return url.pathname === "/" &&
url.searchParams.get("sourceKind") === "local_folder" &&
url.searchParams.get("rootUri") === fileUrl(managedRoot);
}, { timeout: UI_TIMEOUT_MS });
assert(fs.existsSync(path.join(managedRoot, ".mnote", "workspace.json")), "manifest 应落盘");
assert(fs.existsSync(path.join(managedRoot, "pages", "我的空间.md")), "默认首页应落盘");
await page.locator("#__MNOTE_PAGE_AGGREGATE__").waitFor({
state: "attached",
timeout: UI_TIMEOUT_MS,
});
await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await page.locator("#__MNOTE_PAGE_AGGREGATE__").waitFor({
state: "attached",
timeout: UI_TIMEOUT_MS,
});
const aggregate = await page.locator("#__MNOTE_PAGE_AGGREGATE__").textContent({
timeout: UI_TIMEOUT_MS,
});
assert(
aggregate && aggregate.includes("local_markdown.content"),
"刷新后仍应读取本地 Markdown page aggregate",
);
console.log("task166 local-first managed workspace no-convex smoke passed");
} finally {
await browser.close();
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);
});
@@ -0,0 +1,208 @@
#!/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");
const documentId = "local-mdid:my-space-home";
const markdownPath = path.join(managedRoot, "pages", "我的空间.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_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0",
MNOTE_LOCAL_WORKSPACE_BASE_DIR: dataRoot,
CONVEX_SELF_HOSTED_URL: "http://127.0.0.1:9",
NEXT_PUBLIC_CONVEX_URL: "http://127.0.0.1:9",
},
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");
const firstAggregate = await loadAggregate(baseUrl, actorId, documentId, rootUri);
assert.equal(firstAggregate.head.title, "我的空间");
await requestJson(baseUrl, actorId, "/api/documents/title", {
method: "POST",
body: {
documentId,
sourceKind: "local_folder",
rootUri,
title: "P3 标题",
},
});
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.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 标题"), "标题应写入 frontmatter");
assert(markdown.includes("# 正文标题"), "正文 H1 应写回 markdown");
assert(markdown.includes("正文已保存"), "正文段落应写回 markdown");
const options = fs.readFileSync(path.join(managedRoot, ".mnote", "page-options.json"), "utf8");
assert(options.includes("showToc"), "页面设置应写入 .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);
});
@@ -19,6 +19,7 @@ const DOCX_MIME = "application/vnd.openxmlformats-officedocument.wordprocessingm
const PNG_MIME = "image/png";
const TEST_EMAIL = "mnote.e2e@example.com";
const TEST_PASSWORD = "MnoteE2E123!";
// 说明:该 smoke 仍在验证 Convex media / OnlyOffice 的 cloud upload 兼容入口,不是 local-first 默认上传路径。
const PROBE_DOCX_PATH =
process.env.MNOTE_ONLYOFFICE_PROBE_DOCX ||
"/tmp/mnote-onlyoffice-manual/mnote-onlyoffice-upload-test.docx";
@@ -6,6 +6,7 @@ const { chromium } = require("playwright");
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3001").replace(/\/+$/, "");
const CONVEX_URL = (process.env.NEXT_PUBLIC_CONVEX_URL || process.env.CONVEX_SELF_HOSTED_URL || "http://127.0.0.1:3210").replace(/\/+$/, "");
const e2ePassword = () => process.env.MNOTE_E2E_PASSWORD || ["Mnote", "E2E", "123!"].join("");
// 说明:该 smoke 覆盖 convex-source trash 兼容路径,不代表 local-first 默认文件回收策略。
async function requestJson(request, path, init = {}) {
const response = await request.fetch(`${BASE_URL}${path}`, {
@@ -10,6 +10,7 @@ const TASK = "task428-filetree-bulk-delete-selection-smoke";
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3001").replace(/\/+$/, "");
const CONVEX_URL = (process.env.NEXT_PUBLIC_CONVEX_URL || process.env.CONVEX_SELF_HOSTED_URL || "http://127.0.0.1:3210").replace(/\/+$/, "");
const e2ePassword = () => process.env.MNOTE_E2E_PASSWORD || ["Mnote", "E2E", "123!"].join("");
// 说明:该 smoke 覆盖 convex-source 批量删除兼容路径,不代表 local-first 默认 File Tree 主链。
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
const OUTPUT_DIR = path.join(process.cwd(), "tmp", TASK);
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
@@ -10,6 +10,7 @@ const TASK = "task430-vscode-explorer-stage7-smoke";
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const CONVEX_URL = (process.env.NEXT_PUBLIC_CONVEX_URL || process.env.CONVEX_SELF_HOSTED_URL || "http://127.0.0.1:3210").replace(/\/+$/, "");
const e2ePassword = () => process.env.MNOTE_E2E_PASSWORD || ["Mnote", "E2E", "123!"].join("");
// 说明:该 smoke 覆盖 convex-source Explorer 兼容路径;local-first 默认主链另有 local workspace smoke。
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
const OUTPUT_DIR = path.join(process.cwd(), "tmp", TASK);
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
@@ -10,6 +10,7 @@ const TASK = "task433-filetree-trash-file-asset-dual-browser-no-refresh-smoke";
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3001").replace(/\/+$/, "");
const AUTH_BASE_URL = (process.env.MNOTE_AUTH_BASE_URL || BASE_URL).replace(/\/+$/, "");
const e2ePassword = () => process.env.MNOTE_E2E_PASSWORD || ["Mnote", "E2E", "123!"].join("");
// 说明:该 smoke 仍在验证 convex-source 的 file asset trash 兼容链路,不是 local-first 默认资源流。
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 35_000);
const OUTPUT_DIR = path.join(process.cwd(), "tmp", TASK);
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
@@ -11,6 +11,7 @@ const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3001").repl
const AUTH_BASE_URL = (process.env.MNOTE_AUTH_BASE_URL || BASE_URL).replace(/\/+$/, "");
const CONVEX_URL = (process.env.NEXT_PUBLIC_CONVEX_URL || process.env.CONVEX_SELF_HOSTED_URL || "http://127.0.0.1:3210").replace(/\/+$/, "");
const e2ePassword = () => process.env.MNOTE_E2E_PASSWORD || ["Mnote", "E2E", "123!"].join("");
// 说明:该 smoke 覆盖 convex-source mindmap/table trash 兼容链路,不代表 local-first 默认资源真相。
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 35_000);
const OUTPUT_DIR = path.join(process.cwd(), "tmp", TASK);
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
@@ -9,6 +9,9 @@ const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "task435-local-folder-watch-no-reload-smoke");
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|| ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium"]
.find((candidate) => fs.existsSync(candidate));
function assert(condition, message) {
if (!condition) {
@@ -32,6 +35,21 @@ function localMdDocumentId(relativePath) {
return `local-md:${relativePath.replaceAll("/", "~2F")}`;
}
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}:task435`,
ownerId,
createdAt: new Date().toISOString(),
capabilities: ["local_files", "markdown_edit"],
}, null, 2)}\n`,
"utf8",
);
}
async function quickLogin(page) {
await page.goto(`${BASE_URL}/auth`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
const quickLoginButton = page.getByRole("button", { name: "测试账号快速登录" });
@@ -100,12 +118,15 @@ async function run() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-local-watch-no-reload-"));
fs.mkdirSync(path.join(root, "docs"), { recursive: true });
fs.mkdirSync(path.join(root, ".mnote"), { recursive: true });
writeWorkspaceManifest(root, "user_real");
fs.writeFileSync(path.join(root, "README.md"), "# Local Root\n", "utf8");
fs.writeFileSync(path.join(root, "docs", "stable.md"), "# Stable Page\n", "utf8");
fs.writeFileSync(path.join(root, "docs", "stable-asset.txt"), "stable asset", "utf8");
const browser = await chromium.launch({ headless: true });
const browser = await chromium.launch({
headless: true,
...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
});
const context = await browser.newContext({
viewport: { width: 1280, height: 860 },
extraHTTPHeaders: {
@@ -10,6 +10,9 @@ const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "task436-local-markdown-open-document-external-change-smoke");
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|| ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium"]
.find((candidate) => fs.existsSync(candidate));
function fileUrl(localPath) {
return `file://${localPath}`;
@@ -37,6 +40,21 @@ function markdown(title, lines) {
].join("\n");
}
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}:task436`,
ownerId,
createdAt: new Date().toISOString(),
capabilities: ["local_files", "markdown_edit"],
}, null, 2)}\n`,
"utf8",
);
}
async function quickLogin(page) {
await page.goto(`${BASE_URL}/auth`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
const quickLoginButton = page.getByRole("button", { name: "测试账号快速登录" });
@@ -99,6 +117,37 @@ async function typeDirtyText(page, text) {
await waitForEditorText(page, text.trim());
}
async function callMarkdownEdit(root, relativePath, search, replace) {
const response = await fetch(`${BASE_URL}/api/hermes/tools/mnote/call`, {
method: "POST",
headers: {
"content-type": "application/json",
"x-mnote-actor-id": "user_real",
"x-mnote-actor-type": "user",
},
body: JSON.stringify({
toolName: "mnote.doc.markdown_edit",
workspaceId: "local-ws:user_real:task436",
documentId: localMdDocumentId(relativePath),
sourceKind: "local_folder",
rootUri: fileUrl(root),
sessionId: `sess-task436-${Date.now()}`,
runId: `run-task436-${Date.now()}`,
toolCallId: `call-task436-${Date.now()}`,
traceId: `trace-task436-${Date.now()}`,
idempotencyKey: `idem-task436-${Date.now()}`,
dryRun: false,
args: {
operations: [{ search, replace }],
},
}),
});
const payload = await response.json().catch(() => null);
assert.equal(response.status, 200, `AI markdown_edit 应成功写入: ${JSON.stringify(payload)}`);
assert.equal(payload?.result?.source, "local_folder", `AI markdown_edit 应走 local_folder: ${JSON.stringify(payload)}`);
return payload;
}
async function runStep(label, navigationEvents, action) {
const before = navigationEvents.length;
await action();
@@ -117,15 +166,21 @@ async function run() {
const files = {
clean: "clean-sync.md",
dirty: "dirty-conflict.md",
aiDirty: "dirty-ai-conflict.md",
rename: "rename-open.md",
delete: "delete-open.md",
};
writeWorkspaceManifest(root, "user_real");
fs.writeFileSync(path.join(root, files.clean), markdown("Clean Sync", ["initial clean"]), "utf8");
fs.writeFileSync(path.join(root, files.dirty), markdown("Dirty Conflict", ["initial dirty"]), "utf8");
fs.writeFileSync(path.join(root, files.aiDirty), markdown("Dirty AI Conflict", ["initial ai dirty"]), "utf8");
fs.writeFileSync(path.join(root, files.rename), markdown("Rename Open", ["initial rename"]), "utf8");
fs.writeFileSync(path.join(root, files.delete), markdown("Delete Open", ["initial delete"]), "utf8");
const browser = await chromium.launch({ headless: true });
const browser = await chromium.launch({
headless: true,
...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
});
const context = await browser.newContext({
viewport: { width: 1280, height: 860 },
extraHTTPHeaders: {
@@ -171,6 +226,23 @@ async function run() {
assert(runtime.text.includes(localToken), "dirty 冲突时不应静默覆盖用户正在编辑的内容");
}));
await openDocument(page, root, files.aiDirty);
await waitForEditorText(page, "initial ai dirty");
await page.waitForTimeout(300);
navigationEvents.length = 0;
steps.push(await runStep("dirty 文档 AI 后台写入后进入冲突提示", navigationEvents, async () => {
const localToken = `local-ai-dirty-${Date.now()}`;
const aiToken = `ai-background-${Date.now()}`;
await typeDirtyText(page, ` ${localToken}`);
await callMarkdownEdit(root, files.aiDirty, "initial ai dirty", `initial ai dirty ${aiToken}`);
await waitForEditorStatus(page, "external-change-conflict");
const runtime = await readEditorRuntime(page);
assert(runtime.error.includes("本地 Markdown 文件已在外部更新"), `AI 写入冲突提示不正确: ${JSON.stringify(runtime)}`);
assert(runtime.text.includes(localToken), "AI 写入冲突时不应静默覆盖用户正在编辑的内容");
const saved = fs.readFileSync(path.join(root, files.aiDirty), "utf8");
assert(saved.includes(aiToken), "AI 后台写入应已落盘,供后续合并处理");
}));
await openDocument(page, root, files.rename);
await waitForEditorText(page, "initial rename");
await page.waitForTimeout(300);
@@ -0,0 +1,240 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { chromium } = require("playwright");
const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "task443-local-markdown-asset-upload-smoke");
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|| ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium"]
.find((candidate) => fs.existsSync(candidate));
function fileUrl(localPath) {
return `file://${localPath}`;
}
function localMdDocumentId(relativePath) {
return `local-md:${relativePath.replaceAll("/", "~2F")}`;
}
function documentUrl(root, relativePath) {
const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", fileUrl(root));
url.searchParams.set("treeView", "filetree");
return url.toString();
}
function 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}:task443`,
ownerId,
createdAt: new Date().toISOString(),
capabilities: ["local_files", "markdown_edit", "asset_upload"],
}, null, 2)}\n`,
"utf8",
);
}
async function quickLogin(page) {
await page.goto(`${BASE_URL}/auth`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
const quickLoginButton = page.getByRole("button", { name: "测试账号快速登录" });
if (await quickLoginButton.count()) {
await quickLoginButton.click({ timeout: UI_TIMEOUT_MS });
await page.waitForURL((url) => url.pathname === "/", { timeout: UI_TIMEOUT_MS });
}
}
async function openDocument(page, root, relativePath) {
await page.goto(documentUrl(root, relativePath), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]').first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
await page.locator(".document-pane[data-pane-role=\"primary\"] .editor-surface .ProseMirror").first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
}
async function uploadLocalAsset(page, root, documentId, fileName, mimeType, bytes, kind) {
return await page.evaluate(
async ({ rootUri, documentId, fileName, mimeType, bytes, kind }) => {
const form = new FormData();
form.append("rootUri", rootUri);
form.append("documentId", documentId);
form.append("kind", kind);
form.append("file", new File([new Uint8Array(bytes)], fileName, { type: mimeType }));
const response = await fetch("/api/local-folder/assets/upload", {
method: "POST",
body: form,
});
const payload = await response.json().catch(() => null);
if (!response.ok || !payload || payload.ok !== true) {
throw new Error(`upload_failed_${response.status}:${JSON.stringify(payload)}`);
}
return payload.asset;
},
{
rootUri: fileUrl(root),
documentId,
fileName,
mimeType,
bytes: Array.from(bytes),
kind,
},
);
}
async function fetchAggregate(page, root, documentId) {
return await page.evaluate(
async ({ rootUri, documentId }) => {
const url = new URL(`/api/page-aggregate/${encodeURIComponent(documentId)}`, window.location.origin);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", rootUri);
const response = await fetch(url.toString(), { cache: "no-store" });
const payload = await response.json().catch(() => null);
if (!response.ok || !payload || payload.ok !== true) {
throw new Error(`aggregate_failed_${response.status}:${JSON.stringify(payload)}`);
}
return payload.result;
},
{ rootUri: fileUrl(root), documentId },
);
}
async function saveBody(page, root, documentId, workspaceId, expectedFileVersion, imagePath, attachmentPath) {
return await page.evaluate(
async ({ rootUri, documentId, workspaceId, expectedFileVersion, imagePath, attachmentPath }) => {
const response = await fetch("/api/page-body/write", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
documentId,
workspaceId,
sourceKind: "local_folder",
rootUri,
expectedFileVersion,
contentFormat: "editorBlocks",
editorSource: "task443-smoke",
content: [
{ type: "heading", props: { level: 1 }, content: [{ type: "text", text: "Asset Smoke" }] },
{ type: "image", props: { src: imagePath, alt: "task443 图片", title: "task443 图片" } },
{ type: "media", props: { name: "task443-spec.pdf", sourcePath: attachmentPath } },
],
}),
});
const payload = await response.json().catch(() => null);
if (!response.ok || !payload || payload.ok !== true) {
throw new Error(`save_failed_${response.status}:${JSON.stringify(payload)}`);
}
return payload.result;
},
{ rootUri: fileUrl(root), documentId, workspaceId, expectedFileVersion, imagePath, attachmentPath },
);
}
async function main() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task443-local-assets-"));
const relativePath = "README.md";
const documentId = localMdDocumentId(relativePath);
writeWorkspaceManifest(root, "user_real");
fs.writeFileSync(
path.join(root, relativePath),
["---", "title: Asset Smoke", "---", "", "# Asset Smoke", "", "初始正文", ""].join("\n"),
"utf8",
);
const browser = await chromium.launch({
headless: true,
executablePath: CHROMIUM_EXECUTABLE_PATH,
});
const context = await browser.newContext({
viewport: { width: 1280, height: 860 },
extraHTTPHeaders: {
"x-mnote-actor-id": "user_real",
"x-mnote-actor-type": "user",
},
});
const page = await context.newPage();
try {
await quickLogin(page);
await openDocument(page, root, relativePath);
const imageAsset = await uploadLocalAsset(
page,
root,
documentId,
"task443-image.png",
"image/png",
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
"image",
);
const attachmentAsset = await uploadLocalAsset(
page,
root,
documentId,
"task443-spec.pdf",
"application/pdf",
Buffer.from("%PDF-1.4\n% task443\n", "utf8"),
"attachment",
);
const aggregate = await fetchAggregate(page, root, documentId);
await saveBody(
page,
root,
documentId,
aggregate.identity.workspaceId || aggregate.identity.workspace_id || "",
aggregate.body.fileVersion || aggregate.body.conflictDetectionKey || null,
imageAsset.sourcePath,
attachmentAsset.sourcePath,
);
await openDocument(page, root, relativePath);
await page.locator(".document-pane[data-pane-role=\"primary\"] .editor-surface .ProseMirror").first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
const restoredAggregate = await fetchAggregate(page, root, documentId);
const restoredBody = JSON.stringify(restoredAggregate.body && restoredAggregate.body.content || []);
assert(restoredBody.includes("README.assets/task443-image.png"), restoredBody);
assert(restoredBody.includes("README.assets/task443-spec.pdf"), restoredBody);
const markdown = fs.readFileSync(path.join(root, relativePath), "utf8");
assert(markdown.includes("![task443 图片](README.assets/task443-image.png)"), markdown);
assert(markdown.includes("[task443-spec.pdf](README.assets/task443-spec.pdf)"), markdown);
assert(!markdown.includes("/api/media/"), markdown);
assert(!markdown.includes("assetId="), markdown);
assert(fs.existsSync(path.join(root, "README.assets", "task443-image.png")));
assert(fs.existsSync(path.join(root, "README.assets", "task443-spec.pdf")));
await page.waitForFunction(() => {
const tree = document.getElementById("sidebar-file-tree-root");
const text = tree ? tree.textContent || "" : "";
return text.includes("task443-image.png") && text.includes("task443-spec.pdf");
}, null, { timeout: UI_TIMEOUT_MS });
fs.writeFileSync(RESULT_PATH, `${JSON.stringify({
ok: true,
root,
markdownPath: path.join(root, relativePath),
imageSourcePath: imageAsset.sourcePath,
attachmentSourcePath: attachmentAsset.sourcePath,
}, null, 2)}\n`, "utf8");
console.log(`task443 local markdown asset upload smoke passed: ${RESULT_PATH}`);
} finally {
await browser.close();
}
}
main().catch((error) => {
console.error(error && error.stack ? error.stack : error);
process.exit(1);
});
@@ -0,0 +1,101 @@
#!/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 { spawnSync } = require("node:child_process");
const repoRoot = path.resolve(__dirname, "..");
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-convex-export-"));
const fixturePath = path.join(tmpRoot, "fixture.json");
const outRoot = path.join(tmpRoot, "local-workspace");
const fixture = {
workspace: {
id: "ws_legacy_1",
name: "旧云端空间",
ownerId: "user_1",
},
documents: [
{
id: "doc_root",
title: "Project",
parent_id: null,
sort_order: 1,
content: "# Project\n\n![logo](/api/media/sign?assetId=asset_logo)\n",
wide_layout: true,
created_at: "2026-05-01T00:00:00.000Z",
updated_at: "2026-05-02T00:00:00.000Z",
},
{
id: "doc_child",
title: "Child Spec",
parent_id: "doc_root",
sort_order: 1,
content: "Child body with [file](/api/media/sign?assetId=asset_pdf).\n",
created_at: "2026-05-01T00:00:00.000Z",
updated_at: "2026-05-02T00:00:00.000Z",
},
],
mediaAssets: [
{
id: "asset_logo",
fileName: "logo.png",
contentBase64: Buffer.from("PNG-FIXTURE").toString("base64"),
},
{
id: "asset_pdf",
fileName: "spec.pdf",
content: "PDF-FIXTURE",
},
],
aiSessions: [
{
sessionId: "sess_1",
visibility: "private",
events: [
{ eventType: "session.created", sessionId: "sess_1", userId: "user_1" },
{ eventType: "run.completed", sessionId: "sess_1", status: "completed" },
],
},
],
};
fs.writeFileSync(fixturePath, JSON.stringify(fixture, null, 2), "utf8");
const result = spawnSync(
process.execPath,
[
path.join(repoRoot, "scripts", "export-convex-workspace-to-local.js"),
"--fixture",
fixturePath,
"--out",
outRoot,
],
{ cwd: repoRoot, encoding: "utf8" },
);
assert.equal(result.status, 0, result.stderr || result.stdout);
const projectMd = fs.readFileSync(path.join(outRoot, "pages", "Project.md"), "utf8");
const childMd = fs.readFileSync(path.join(outRoot, "pages", "Project", "Child Spec.md"), "utf8");
const pageIds = JSON.parse(fs.readFileSync(path.join(outRoot, ".mnote", "page-ids.json"), "utf8"));
const pageOptions = JSON.parse(fs.readFileSync(path.join(outRoot, ".mnote", "page-options.json"), "utf8"));
const resourceIndex = JSON.parse(fs.readFileSync(path.join(outRoot, ".mnote", "resource-index.json"), "utf8"));
const sessionJsonl = fs.readFileSync(path.join(outRoot, "ai-sessions", "private", "sess_1.jsonl"), "utf8");
assert.match(projectMd, /mnote_id: doc_root/);
assert.match(projectMd, /!\[logo\]\(Project\.assets\/logo\.png\)/);
assert.match(childMd, /\[file\]\(Child Spec\.assets\/spec\.pdf\)/);
assert.equal(pageIds.pages["pages/Project.md"], "local-mdid:doc_root");
assert.equal(pageIds.pages["pages/Project/Child Spec.md"], "local-mdid:doc_child");
assert.equal(pageOptions.pages["local-mdid:doc_root"].wideLayout, true);
assert.equal(resourceIndex.assets.asset_logo.relativePath, "pages/Project.assets/logo.png");
assert.match(sessionJsonl, /"eventType":"run.completed"/);
assert.equal(fs.readFileSync(path.join(outRoot, "pages", "Project.assets", "logo.png"), "utf8"), "PNG-FIXTURE");
assert.equal(fs.readFileSync(path.join(outRoot, "pages", "Project", "Child Spec.assets", "spec.pdf"), "utf8"), "PDF-FIXTURE");
fs.rmSync(tmpRoot, { recursive: true, force: true });
console.log(JSON.stringify({ ok: true, smoke: "task444-convex-workspace-export-local-fixture" }, null, 2));