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:
@@ -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(""), 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\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));
|
||||
Reference in New Issue
Block a user