test: organize current smoke baseline
This commit is contained in:
@@ -9,15 +9,32 @@
|
||||
- `/doko` 适合作为 smoke 之后的“真实页面直观复核层”,不是用来替代 smoke。
|
||||
- 2026-05-20 起,默认主入口固定按 `http://127.0.0.1:3000` 理解;历史记录里的 `3001` 只代表当时临时 mnote-web 实例,不再作为默认验收入口。
|
||||
- 历史 smoke 分三类维护:`current` 可直接作为当前主链证据,`compat` 只验证 Convex/control-plane/兼容链,`retired/debug` 不应进入默认回归,除非脚本自身要求显式环境变量。
|
||||
- 2026-05-26 起,默认 smoke 基线只覆盖 `3000 Rust SSR + leptos-tiptap + local-first` 主路径;Convex export、Next、3104、BlockNote 默认路径脚本不再放在默认候选里。
|
||||
|
||||
## 0. 当前 smoke 状态清单
|
||||
|
||||
这一节用于避免历史脚本被误当作当前架构主链验收。
|
||||
|
||||
### 0.0 2026-05-26 默认基线
|
||||
|
||||
建议日常 smoke 先跑这一组,确保当前主入口、认证、文档 island、Page Aggregate、local-first 和关键 runtime surface 没断:
|
||||
|
||||
```bash
|
||||
node scripts/task114-rust-web-gateway-entry-smoke.js
|
||||
node scripts/task159-auth-entry-smoke.js
|
||||
node scripts/task164-desktop-hot-local-folder-main-entry-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
|
||||
node scripts/task490-runtime-surfaces-smoke.js
|
||||
```
|
||||
|
||||
其中 `task490-runtime-surfaces-smoke.js` 是轻量浏览器 smoke,只验证页面设置、Page AI drawer、slash menu、block handle menu 能在当前 leptos-tiptap 文档页打开;它不要求真实模型返回,也不替代更重的页面设置、Hermes/Page AI 或编辑器菜单专项脚本。
|
||||
|
||||
### 0.1 当前主链优先脚本
|
||||
|
||||
- 入口与认证:`task114-rust-web-gateway-entry-smoke.js`、`task159-auth-entry-smoke.js`、`task097-homepage-entry-smoke.js`
|
||||
- Rust SSR 文档页 / Page Aggregate:`task110-page-title-single-truth-smoke.js`、`task-page-aggregate-body-sync-smoke.js`、`task-page-aggregate-options-sync-smoke.js`、`task-page-aggregate-refresh-persistence-smoke.js`
|
||||
- Rust SSR 文档页 / Page Aggregate:local-first 默认优先 `task167-local-markdown-title-body-options-no-convex-smoke.js`;cloud/control-plane 文档可补跑 `task110-page-title-single-truth-smoke.js`、`task-page-aggregate-body-sync-smoke.js`、`task-page-aggregate-options-sync-smoke.js`、`task-page-aggregate-refresh-persistence-smoke.js`
|
||||
- leptos-tiptap runtime surface:`task490-runtime-surfaces-smoke.js`;需要验证保存回读可补跑 `task121-rust-web-editor-island-hydration-smoke.js`,但它仍使用 `/api/tree/commands create` 准备文档,不作为无 Convex 默认基线;需要验证浮层互斥和更多菜单状态时再跑 `task158-e30-menu-state-smoke.js`
|
||||
- local-first 本地工作区:`task164-desktop-hot-local-folder-main-entry-smoke.js`、`task166-local-first-managed-workspace-no-convex-smoke.js`、`task167-local-markdown-title-body-options-no-convex-smoke.js`、`task436-local-markdown-open-document-external-change-smoke.js`、`task443-local-markdown-asset-upload-smoke.js`、`task451-local-markdown-conflict-resolution-ui-smoke.js`、`task452-local-search-index-browser-smoke.js`、`task453-local-folder-page-ai-changed-files-smoke.js`
|
||||
- tree realtime / live cache:`task446-tree-rename-dual-browser-live-smoke.js`、`task447-tree-move-order-dual-browser-live-smoke.js`、`task448-tree-resync-recovery-dual-browser-smoke.js`、`task449-tree-sse-reconnect-snapshot-recovery-smoke.js`
|
||||
- 资源对象与 mindmap:`task455-local-folder-mindmap-clean-smoke.js`、`task456-resource-object-shell-sync-smoke.js`、`task166-mindmap-phase6-block-smoke.js`、`task167-mindmap-kmind-parity-smoke.js`、`task168-mindmap-put-validator-smoke.js`
|
||||
@@ -29,21 +46,41 @@
|
||||
|
||||
- `task427-trash-empty-isolated-workspace-smoke.js`
|
||||
- `task428-filetree-bulk-delete-selection-smoke.js`
|
||||
- `task429-trash-restore-location-reveal-smoke.js`
|
||||
- `task430-vscode-explorer-stage7-smoke.js`
|
||||
- `task431-vscode-explorer-dnd-readonly-conflict-smoke.js`
|
||||
- `task432-filetree-trash-page-dual-browser-no-refresh-smoke.js`
|
||||
- `task433-filetree-trash-file-asset-dual-browser-no-refresh-smoke.js`
|
||||
- `task434-filetree-trash-mindmap-table-dual-browser-no-refresh-smoke.js`
|
||||
- `task444-convex-workspace-export-local-fixture-smoke.js`
|
||||
- `task455-convex-export-plan-rollback-smoke.js`
|
||||
- `task174-rust-onlyoffice-attachment-open-smoke.js`
|
||||
- `task175-rust-upload-entry-smoke.js`
|
||||
|
||||
注意:上述 427 / 428 / 432 / 433 / 434 脚本默认入口已改回 `3000`;如需对临时端口验证,显式设置 `MNOTE_UI_BASE_URL`。
|
||||
注意:上述 427 / 428 / 429 / 430 / 431 / 432 / 433 / 434 脚本默认入口已改回 `3000`;如需对临时端口验证,显式设置 `MNOTE_UI_BASE_URL`。`task174` / `task175` 仍验证 Convex media / OnlyOffice cloud upload 兼容入口,不属于 local-first 默认上传链。
|
||||
|
||||
### 0.3 需要退役或拆分的历史脚本
|
||||
|
||||
- `task444-convex-workspace-export-local-fixture-smoke.js`、`task455-convex-export-plan-rollback-smoke.js` 与 `export-convex-workspace-to-local.js`:Convex export 迁移验证已退出默认 smoke,已归档到 `recycle/scripts/retired-convex-export-smokes-20260526/`。
|
||||
- 注意编号复用:`scripts/task444-filetree-create-mindmap-title-cache-smoke.js` 与 `scripts/task455-local-folder-mindmap-clean-smoke.js` 仍是当前本地 filetree / mindmap 脚本;退役的是带 `convex-export` 名称的 task444 / task455。
|
||||
- `task179-tree-create-delete-no-reload-smoke.js`:覆盖 `/tree` debug shell。`/tree` 已是显式 debug/internal 边界,脚本默认阻断;只有设置 `MNOTE_ALLOW_DEBUG_TREE_SMOKE=1` 时才可执行。
|
||||
- `task163-local-folder-unified-tree-browser-smoke.js`:历史“大一统”脚本,同时混合 local folder、Convex、拖拽、复制粘贴、watcher 和文件树行为。后续不要作为默认回归入口;优先用 164 / 166 / 436 / 441 / 443 / 451 / 452 / 455 这些聚焦脚本替代。
|
||||
- `task123-rust-web-tree-live-stream-consumer-smoke.js`:只证明 `/api/tree/events` SSE fallback 仍可用。当前 realtime 主链是 WebSocket push + SSE fallback;验证主链时优先使用 446 / 447 / 448,只有排查 fallback 时再跑 123。
|
||||
- 旧 `task019`、`task021`、`task022` 这类早期 UI regression 脚本已软删除到 `recycle/scripts/retired-ui-regressions/`,只作为历史对照;后续默认不要用于当前 Rust SSR / local-first 主路径验收。
|
||||
|
||||
### 0.4 其他 current 候选脚本
|
||||
|
||||
这些脚本不是最小默认基线,但仍属于当前 Rust SSR / leptos-tiptap / local-first 维护面,按改动范围选择性运行:
|
||||
|
||||
- `task426-mnote-web-main-no-reload-smoke.js`
|
||||
- `task109-leptos-island-multipage-save-smoke.js`
|
||||
- `task122-rust-web-create-page-ui-smoke.js`
|
||||
- `task125-rust-web-search-server-first-smoke.js`
|
||||
- `task165-rust-web-dual-pane-smoke.js`
|
||||
- `task458-local-create-page-no-conflict-smoke.js`
|
||||
- `task459-local-markdown-attachment-tab-smoke.js`
|
||||
- `task484-local-folder-page-body-refresh-readback-smoke.js`
|
||||
- `task486-local-markdown-save-error-editor-preserves-content-smoke.js`
|
||||
- `task487-local-folder-tree-live-consumer-smoke.js`
|
||||
|
||||
## 1. 当前测试分层
|
||||
|
||||
按目录里的现状,脚本大致分成四层。
|
||||
@@ -76,6 +113,7 @@
|
||||
- `task120-rust-web-tree-integration-smoke.js`
|
||||
- `task122-rust-web-create-page-ui-smoke.js`
|
||||
- `task164-page-options-visible-effect-smoke.js`
|
||||
- `task490-runtime-surfaces-smoke.js`
|
||||
- `task165-rust-web-dual-pane-smoke.js`
|
||||
- `task164-desktop-hot-local-folder-main-entry-smoke.js`
|
||||
- `task166-local-first-managed-workspace-no-convex-smoke.js`
|
||||
|
||||
@@ -1,458 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {
|
||||
fixture: "",
|
||||
out: "",
|
||||
dryRun: false,
|
||||
manifest: "",
|
||||
conflictReport: "",
|
||||
rollback: "",
|
||||
};
|
||||
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 === "--dry-run") {
|
||||
args.dryRun = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--manifest") {
|
||||
args.manifest = argv[++index] || "";
|
||||
continue;
|
||||
}
|
||||
if (arg === "--conflict-report") {
|
||||
args.conflictReport = argv[++index] || "";
|
||||
continue;
|
||||
}
|
||||
if (arg === "--rollback") {
|
||||
args.rollback = argv[++index] || "";
|
||||
continue;
|
||||
}
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
console.log(
|
||||
[
|
||||
"用法:node scripts/export-convex-workspace-to-local.js --fixture <fixture.json> --out <dir> [--dry-run] [--manifest <file>] [--conflict-report <file>]",
|
||||
"回滚:node scripts/export-convex-workspace-to-local.js --rollback <manifest.json>",
|
||||
].join("\n"),
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
throw new Error(`未知参数:${arg}`);
|
||||
}
|
||||
if (args.rollback) return args;
|
||||
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 writeJson(filePath, value) {
|
||||
writeUtf8(filePath, `${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function sanitizeName(name) {
|
||||
return String(name || "untitled")
|
||||
.trim()
|
||||
.replace(/[<>:"/\\|?*\u0000-\u001f]/g, "_")
|
||||
.replace(/\s+/g, " ")
|
||||
.replace(/\.+$/g, "")
|
||||
.trim() || "untitled";
|
||||
}
|
||||
|
||||
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 fileUriForPath(localPath) {
|
||||
return `file://${path.resolve(localPath)}`;
|
||||
}
|
||||
|
||||
function nowId() {
|
||||
return new Date().toISOString().replace(/[-:.TZ]/g, "").slice(0, 14);
|
||||
}
|
||||
|
||||
function normalizeRelativePath(relativePath) {
|
||||
const normalized = String(relativePath || "").replace(/\\/g, "/").replace(/^\/+/, "");
|
||||
if (!normalized || normalized.split("/").some((part) => part === "..")) {
|
||||
throw new Error(`非法迁移相对路径:${relativePath}`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function pushFileOperation(operations, relativePath, content, contentEncoding = "utf8") {
|
||||
operations.push({
|
||||
action: "create",
|
||||
relativePath: normalizeRelativePath(relativePath),
|
||||
content,
|
||||
contentEncoding,
|
||||
});
|
||||
}
|
||||
|
||||
function buildMigrationPlan(fixture, out) {
|
||||
const documents = Array.isArray(fixture.documents) ? fixture.documents : [];
|
||||
const mediaAssets = Array.isArray(fixture.mediaAssets) ? fixture.mediaAssets : [];
|
||||
const aiSessions = Array.isArray(fixture.aiSessions) ? fixture.aiSessions : [];
|
||||
|
||||
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,
|
||||
};
|
||||
});
|
||||
|
||||
const operations = [];
|
||||
const indexedDocuments = [];
|
||||
const indexedResources = [];
|
||||
|
||||
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");
|
||||
pushFileOperation(operations, markdownPath, `${frontmatter}${markdownBody}`);
|
||||
indexedDocuments.push({
|
||||
documentId: `local-mdid:${doc.id}`,
|
||||
title: String(doc.title || doc.id || "untitled"),
|
||||
path: markdownPath,
|
||||
rawText: markdownBody,
|
||||
tags: [],
|
||||
backlinks: [],
|
||||
resourceRefs: [],
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
operations.push({
|
||||
action: "ensureDir",
|
||||
relativePath: normalizeRelativePath(assetRoot),
|
||||
});
|
||||
});
|
||||
|
||||
mediaAssets.forEach((asset) => {
|
||||
const relativePath = assetPathById.get(String(asset.id));
|
||||
if (!relativePath) return;
|
||||
const content = decodeAssetContent(asset).toString("base64");
|
||||
pushFileOperation(operations, relativePath, content, "base64");
|
||||
indexedResources.push({
|
||||
resourceId: String(asset.id),
|
||||
resourceType: "media",
|
||||
title: sanitizeName(asset.fileName || asset.name || asset.id),
|
||||
path: relativePath,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
});
|
||||
|
||||
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 relativePath = shareId || visibility === "shared"
|
||||
? path.posix.join("ai-sessions", "shared", sanitizeName(shareId || "share"), `${sessionId}.jsonl`)
|
||||
: path.posix.join("ai-sessions", "private", `${sessionId}.jsonl`);
|
||||
const events = Array.isArray(session.events) ? session.events : [];
|
||||
const lines = events.map((event) => JSON.stringify(event)).join("\n");
|
||||
pushFileOperation(operations, relativePath, lines ? `${lines}\n` : "");
|
||||
});
|
||||
|
||||
const workspaceId = fixture.workspace?.id || "exported-workspace";
|
||||
pushFileOperation(operations, ".mnote/page-ids.json", `${JSON.stringify({ version: 1, pages: pageIds }, null, 2)}\n`);
|
||||
pushFileOperation(operations, ".mnote/page-options.json", `${JSON.stringify({ version: 1, pages: pageOptions }, null, 2)}\n`);
|
||||
pushFileOperation(operations, ".mnote/resource-index.json", `${JSON.stringify(resourceIndex, null, 2)}\n`);
|
||||
pushFileOperation(
|
||||
operations,
|
||||
".mnote/workspace.json",
|
||||
`${JSON.stringify(
|
||||
{
|
||||
workspaceId,
|
||||
ownerId: fixture.workspace?.ownerId || fixture.workspace?.owner_id || "unknown",
|
||||
createdAt: new Date().toISOString(),
|
||||
capabilities: ["local_files", "ai_sessions", "exported_from_convex"],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
pushFileOperation(
|
||||
operations,
|
||||
".mnote/index/search-index.json",
|
||||
`${JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
builtAt: Date.now(),
|
||||
rootUri: fileUriForPath(out),
|
||||
workspaceId,
|
||||
documents: indexedDocuments,
|
||||
resources: indexedResources,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
|
||||
return {
|
||||
workspaceId,
|
||||
operations,
|
||||
documentCount: documents.length,
|
||||
resourceCount: mediaAssets.length,
|
||||
aiSessionCount: aiSessions.length,
|
||||
};
|
||||
}
|
||||
|
||||
function detectConflicts(out, operations) {
|
||||
return operations
|
||||
.filter((operation) => operation.action === "create")
|
||||
.filter((operation) => fs.existsSync(path.join(out, operation.relativePath)))
|
||||
.map((operation) => ({
|
||||
code: "target_exists",
|
||||
relativePath: operation.relativePath,
|
||||
targetPath: path.join(out, operation.relativePath),
|
||||
message: "目标文件已存在,迁移不会覆盖",
|
||||
}));
|
||||
}
|
||||
|
||||
function manifestForPlan(out, plan, dryRun, conflicts = []) {
|
||||
const migrationId = `convex-export-${nowId()}`;
|
||||
return {
|
||||
version: 1,
|
||||
migrationId,
|
||||
source: "convex_fixture",
|
||||
dryRun,
|
||||
out: path.resolve(out),
|
||||
createdAt: new Date().toISOString(),
|
||||
backupDir: path.join(path.resolve(out), ".mnote", "migration-backups", migrationId),
|
||||
workspaceId: plan.workspaceId,
|
||||
documentCount: plan.documentCount,
|
||||
resourceCount: plan.resourceCount,
|
||||
aiSessionCount: plan.aiSessionCount,
|
||||
operations: plan.operations.map((operation) => ({
|
||||
action: operation.action,
|
||||
relativePath: operation.relativePath,
|
||||
contentEncoding: operation.contentEncoding || null,
|
||||
})),
|
||||
createdFiles: [],
|
||||
backupFiles: [],
|
||||
conflicts,
|
||||
indexRefresh: {
|
||||
status: dryRun ? "planned" : "pending",
|
||||
path: ".mnote/index/search-index.json",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function writeConflictReport(filePath, out, conflicts) {
|
||||
if (!filePath) return;
|
||||
writeJson(filePath, {
|
||||
version: 1,
|
||||
code: conflicts.length ? "migration_conflict" : "ok",
|
||||
out: path.resolve(out),
|
||||
generatedAt: new Date().toISOString(),
|
||||
conflicts,
|
||||
});
|
||||
}
|
||||
|
||||
function applyPlan(out, plan, manifest) {
|
||||
ensureDir(out);
|
||||
ensureDir(manifest.backupDir);
|
||||
for (const operation of plan.operations) {
|
||||
const target = path.join(out, operation.relativePath);
|
||||
if (operation.action === "ensureDir") {
|
||||
ensureDir(target);
|
||||
continue;
|
||||
}
|
||||
if (operation.action !== "create") continue;
|
||||
ensureDir(path.dirname(target));
|
||||
const content = operation.contentEncoding === "base64"
|
||||
? Buffer.from(operation.content, "base64")
|
||||
: operation.content;
|
||||
fs.writeFileSync(target, content, operation.contentEncoding === "base64" ? undefined : "utf8");
|
||||
manifest.createdFiles.push(operation.relativePath);
|
||||
}
|
||||
manifest.indexRefresh.status = "written";
|
||||
}
|
||||
|
||||
function rollbackManifest(manifestPath) {
|
||||
const manifest = loadFixture(manifestPath);
|
||||
const out = manifest.out;
|
||||
if (!out) throw new Error("rollback manifest 缺少 out");
|
||||
const createdFiles = Array.isArray(manifest.createdFiles) ? manifest.createdFiles : [];
|
||||
createdFiles
|
||||
.slice()
|
||||
.reverse()
|
||||
.forEach((relativePath) => {
|
||||
const target = path.join(out, normalizeRelativePath(relativePath));
|
||||
if (fs.existsSync(target)) fs.rmSync(target, { force: true });
|
||||
});
|
||||
const backupFiles = Array.isArray(manifest.backupFiles) ? manifest.backupFiles : [];
|
||||
backupFiles.forEach((backup) => {
|
||||
if (!backup || !backup.relativePath || !backup.backupPath) return;
|
||||
const target = path.join(out, normalizeRelativePath(backup.relativePath));
|
||||
ensureDir(path.dirname(target));
|
||||
fs.copyFileSync(backup.backupPath, target);
|
||||
});
|
||||
console.log(JSON.stringify({ ok: true, rollback: manifestPath, removed: createdFiles.length }, null, 2));
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (args.rollback) {
|
||||
rollbackManifest(args.rollback);
|
||||
return;
|
||||
}
|
||||
const fixture = loadFixture(args.fixture);
|
||||
const plan = buildMigrationPlan(fixture, args.out);
|
||||
const conflicts = detectConflicts(args.out, plan.operations);
|
||||
const manifest = manifestForPlan(args.out, plan, args.dryRun, conflicts);
|
||||
const manifestPath = args.manifest || path.join(args.out, ".mnote", "migration-manifest.json");
|
||||
writeConflictReport(args.conflictReport, args.out, conflicts);
|
||||
if (args.dryRun) {
|
||||
manifest.indexRefresh.status = "planned";
|
||||
writeJson(manifestPath, manifest);
|
||||
console.log(JSON.stringify({ ok: true, dryRun: true, out: args.out, conflicts: conflicts.length }, null, 2));
|
||||
return;
|
||||
}
|
||||
if (conflicts.length) {
|
||||
writeJson(manifestPath, manifest);
|
||||
console.error(JSON.stringify({ ok: false, code: "migration_conflict", conflicts }, null, 2));
|
||||
process.exit(2);
|
||||
}
|
||||
applyPlan(args.out, plan, manifest);
|
||||
writeJson(manifestPath, manifest);
|
||||
if (path.resolve(manifestPath) !== path.resolve(path.join(args.out, ".mnote", "migration-manifest.json"))) {
|
||||
writeJson(path.join(args.out, ".mnote", "migration-manifest.json"), manifest);
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({ ok: true, out: args.out, manifest: manifestPath }, null, 2));
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -68,9 +68,9 @@ async function validateAuthEntry(baseUrl) {
|
||||
assert.match(authText, /账号登录/);
|
||||
assert.match(authText, /邮箱或用户名/);
|
||||
assert.match(authText, /name="account"[^>]*type="text"|type="text"[^>]*name="account"/);
|
||||
assert.doesNotMatch(authText, /name="username"/);
|
||||
assert.match(authText, /data-auth-field="username" hidden/);
|
||||
assert.match(authText, /name="password"[^>]*type="password"|type="password"[^>]*name="password"/);
|
||||
assert.match(authText, /data-auth-mode="convex-password"/);
|
||||
assert.match(authText, /data-auth-mode="sqlite-session"/);
|
||||
assert.match(authText, /没有账号?注册/);
|
||||
assert.match(authText, /测试账号快速登录/);
|
||||
assert.doesNotMatch(authText, /隐私政策|使用\s*Google|使用\s*GitHub|第三方快捷/iu);
|
||||
|
||||
@@ -74,33 +74,31 @@ 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 });
|
||||
const createButton = page.locator('[data-testid="mnote-create-default-local-workspace"]').first();
|
||||
if (await createButton.isVisible({ timeout: 2_000 }).catch(() => false)) {
|
||||
await page.locator('[data-testid="mnote-open-local-folder-empty"]').waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
await createButton.click({ timeout: UI_TIMEOUT_MS });
|
||||
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 });
|
||||
} else {
|
||||
await page.locator(".sidebar-workspace-name").first().waitFor({
|
||||
state: "visible",
|
||||
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({
|
||||
@@ -206,10 +204,11 @@ async function main() {
|
||||
}, { timeout: UI_TIMEOUT_MS });
|
||||
|
||||
await page.goto(BASE_URL, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForURL((url) => {
|
||||
return url.pathname === "/" &&
|
||||
(!url.searchParams.has("rootUri") || url.searchParams.get("rootUri") !== fileUrl(otherRoot));
|
||||
}, { timeout: UI_TIMEOUT_MS });
|
||||
const finalUrl = new URL(page.url());
|
||||
assert(
|
||||
finalUrl.pathname === "/" && !page.url().includes("legacy_next_compat_disabled"),
|
||||
"回到主入口后应仍由 Rust Web 主壳承载",
|
||||
);
|
||||
|
||||
console.log("task164 desktop hot local folder main entry smoke passed");
|
||||
} finally {
|
||||
|
||||
@@ -110,33 +110,37 @@ async function main() {
|
||||
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 });
|
||||
const createButton = page.locator('[data-testid="mnote-create-default-local-workspace"]').first();
|
||||
if (await createButton.isVisible({ timeout: 2_000 }).catch(() => false)) {
|
||||
await createButton.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 });
|
||||
} else {
|
||||
await page.locator(".sidebar-workspace-name").first().waitFor({
|
||||
state: "visible",
|
||||
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,
|
||||
});
|
||||
const defaultPagePath = path.join(managedRoot, "pages", "我的空间.md");
|
||||
const hasAggregate = await page.locator("#__MNOTE_PAGE_AGGREGATE__").count();
|
||||
assert(hasAggregate > 0 || fs.existsSync(defaultPagePath) === false, "默认首页存在时应注入 Page Aggregate");
|
||||
|
||||
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",
|
||||
);
|
||||
await page.locator(".sidebar-workspace-name").first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
const aggregateCount = await page.locator("#__MNOTE_PAGE_AGGREGATE__").count();
|
||||
if (aggregateCount > 0) {
|
||||
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 {
|
||||
|
||||
@@ -91,8 +91,8 @@ async function main() {
|
||||
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");
|
||||
let documentId = "local-md:README.md";
|
||||
let markdownPath = path.join(managedRoot, "README.md");
|
||||
const server = spawn("cargo", ["run", "-p", "mnote-web", "--bin", "mnote-web"], {
|
||||
cwd: path.join(__dirname, "..", "rust"),
|
||||
env: {
|
||||
@@ -119,11 +119,13 @@ async function main() {
|
||||
});
|
||||
const rootUri = created.workspace.rootUri;
|
||||
assert(rootUri, "创建默认本地工作区应返回 rootUri");
|
||||
fs.mkdirSync(path.dirname(markdownPath), { recursive: true });
|
||||
fs.writeFileSync(markdownPath, "# README\n\n初始正文\n", "utf8");
|
||||
|
||||
const firstAggregate = await loadAggregate(baseUrl, actorId, documentId, rootUri);
|
||||
assert.equal(firstAggregate.head.title, "我的空间");
|
||||
assert.equal(firstAggregate.head.title, "README");
|
||||
|
||||
await requestJson(baseUrl, actorId, "/api/documents/title", {
|
||||
const titlePayload = await requestJson(baseUrl, actorId, "/api/documents/title", {
|
||||
method: "POST",
|
||||
body: {
|
||||
documentId,
|
||||
@@ -132,6 +134,8 @@ async function main() {
|
||||
title: "P3 标题",
|
||||
},
|
||||
});
|
||||
documentId = titlePayload?.result?.documentId || titlePayload?.documentId || documentId;
|
||||
markdownPath = path.join(managedRoot, "P3 标题.md");
|
||||
|
||||
const afterTitle = await loadAggregate(baseUrl, actorId, documentId, rootUri);
|
||||
const conflictDetectionKey = afterTitle.body.conflictDetectionKey || afterTitle.body.conflict_detection_key;
|
||||
@@ -183,7 +187,10 @@ async function main() {
|
||||
);
|
||||
|
||||
const markdown = fs.readFileSync(markdownPath, "utf8");
|
||||
assert(markdown.includes("title: P3 标题"), "标题应写入 frontmatter");
|
||||
assert(
|
||||
markdown.includes("title: P3 标题") || path.basename(markdownPath) === "P3 标题.md",
|
||||
"标题应写入 frontmatter 或体现为本地 Markdown 文件名",
|
||||
);
|
||||
assert(markdown.includes("# 正文标题"), "正文 H1 应写回 markdown");
|
||||
assert(markdown.includes("正文已保存"), "正文段落应写回 markdown");
|
||||
const options = fs.readFileSync(path.join(managedRoot, ".mnote", "page-options.json"), "utf8");
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
#!/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));
|
||||
@@ -1,108 +0,0 @@
|
||||
#!/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-plan-"));
|
||||
const fixturePath = path.join(tmpRoot, "fixture.json");
|
||||
const outRoot = path.join(tmpRoot, "local-workspace");
|
||||
const manifestPath = path.join(tmpRoot, "migration-manifest.json");
|
||||
const conflictPath = path.join(tmpRoot, "conflicts.json");
|
||||
|
||||
const fixture = {
|
||||
workspace: {
|
||||
id: "ws_legacy_plan",
|
||||
name: "迁移计划空间",
|
||||
ownerId: "user_plan",
|
||||
},
|
||||
documents: [
|
||||
{
|
||||
id: "doc_root",
|
||||
title: "Project",
|
||||
parent_id: null,
|
||||
content: "# Project\n\n导出正文\n",
|
||||
wide_layout: true,
|
||||
},
|
||||
],
|
||||
mediaAssets: [
|
||||
{
|
||||
id: "asset_logo",
|
||||
fileName: "logo.txt",
|
||||
documentId: "doc_root",
|
||||
content: "LOGO-NEW",
|
||||
},
|
||||
],
|
||||
aiSessions: [],
|
||||
};
|
||||
|
||||
function runExport(args, expectedStatus = 0) {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[path.join(repoRoot, "scripts", "export-convex-workspace-to-local.js"), ...args],
|
||||
{ cwd: repoRoot, encoding: "utf8" },
|
||||
);
|
||||
assert.equal(result.status, expectedStatus, result.stderr || result.stdout);
|
||||
return result;
|
||||
}
|
||||
|
||||
try {
|
||||
fs.writeFileSync(fixturePath, JSON.stringify(fixture, null, 2), "utf8");
|
||||
|
||||
runExport([
|
||||
"--fixture",
|
||||
fixturePath,
|
||||
"--out",
|
||||
outRoot,
|
||||
"--dry-run",
|
||||
"--manifest",
|
||||
manifestPath,
|
||||
"--conflict-report",
|
||||
conflictPath,
|
||||
]);
|
||||
assert.equal(fs.existsSync(path.join(outRoot, "pages", "Project.md")), false, "dry run 不应写入页面文件");
|
||||
const dryRunManifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||
assert.equal(dryRunManifest.dryRun, true);
|
||||
assert.equal(dryRunManifest.operations.some((item) => item.action === "create" && item.relativePath === "pages/Project.md"), true);
|
||||
|
||||
fs.mkdirSync(path.join(outRoot, "pages"), { recursive: true });
|
||||
fs.writeFileSync(path.join(outRoot, "pages", "Project.md"), "原有内容\n", "utf8");
|
||||
const conflict = runExport([
|
||||
"--fixture",
|
||||
fixturePath,
|
||||
"--out",
|
||||
outRoot,
|
||||
"--manifest",
|
||||
manifestPath,
|
||||
"--conflict-report",
|
||||
conflictPath,
|
||||
], 2);
|
||||
assert.match(conflict.stderr + conflict.stdout, /migration_conflict/);
|
||||
assert.equal(fs.readFileSync(path.join(outRoot, "pages", "Project.md"), "utf8"), "原有内容\n");
|
||||
const conflictReport = JSON.parse(fs.readFileSync(conflictPath, "utf8"));
|
||||
assert.equal(conflictReport.conflicts.some((item) => item.relativePath === "pages/Project.md"), true);
|
||||
|
||||
fs.rmSync(outRoot, { recursive: true, force: true });
|
||||
runExport([
|
||||
"--fixture",
|
||||
fixturePath,
|
||||
"--out",
|
||||
outRoot,
|
||||
"--manifest",
|
||||
manifestPath,
|
||||
]);
|
||||
assert.match(fs.readFileSync(path.join(outRoot, "pages", "Project.md"), "utf8"), /导出正文/);
|
||||
assert.equal(fs.existsSync(path.join(outRoot, ".mnote", "migration-manifest.json")), true);
|
||||
|
||||
runExport(["--rollback", path.join(outRoot, ".mnote", "migration-manifest.json")]);
|
||||
assert.equal(fs.existsSync(path.join(outRoot, "pages", "Project.md")), false, "rollback 应删除本次新增页面");
|
||||
assert.equal(fs.existsSync(path.join(outRoot, "pages", "Project.assets", "logo.txt")), false, "rollback 应删除本次新增资源");
|
||||
|
||||
console.log(JSON.stringify({ ok: true, smoke: "task455-convex-export-plan-rollback" }, null, 2));
|
||||
} finally {
|
||||
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
const OUTPUT_DIR = path.resolve(__dirname, "..", "tmp", "task490-runtime-surfaces-smoke");
|
||||
const TASK = "task490-runtime-surfaces-smoke";
|
||||
const TARGET_BLOCK_ID = "task490-runtime-surface-target";
|
||||
|
||||
function screenshotPath(name) {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
return path.join(OUTPUT_DIR, `${name}.png`);
|
||||
}
|
||||
|
||||
function fileUrl(localPath) {
|
||||
return `file://${localPath}`;
|
||||
}
|
||||
|
||||
function localMdDocumentId(relativePath) {
|
||||
return `local-md:${relativePath.replaceAll("/", "~2F")}`;
|
||||
}
|
||||
|
||||
function documentUrl(baseUrl, root, relativePath) {
|
||||
const url = new URL(`${baseUrl}/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}:task490`,
|
||||
ownerId,
|
||||
createdAt: new Date().toISOString(),
|
||||
capabilities: ["local_files", "markdown_edit", "asset_upload"],
|
||||
}, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
async function saveScreenshot(page, name) {
|
||||
const target = screenshotPath(name);
|
||||
await page.screenshot({ path: target, fullPage: false });
|
||||
return target;
|
||||
}
|
||||
|
||||
async function waitForRuntimeIsland(page, uiTimeoutMs) {
|
||||
const root = page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]').first();
|
||||
await root.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
const editor = page
|
||||
.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror[contenteditable="true"]')
|
||||
.first();
|
||||
await editor.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const host = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
|
||||
const editorNode = host?.querySelector('.editor-surface .ProseMirror[contenteditable="true"]');
|
||||
return host?.getAttribute("data-runtime-editor-status") !== "error"
|
||||
&& editorNode instanceof HTMLElement
|
||||
&& editorNode.isContentEditable;
|
||||
},
|
||||
null,
|
||||
{ timeout: uiTimeoutMs },
|
||||
);
|
||||
return editor;
|
||||
}
|
||||
|
||||
async function setBlockHandleFixture(page, uiTimeoutMs) {
|
||||
await page.evaluate((targetBlockId) => {
|
||||
const editor = document.querySelector(".editor-surface .ProseMirror")?.editor;
|
||||
if (!editor) {
|
||||
throw new Error("找不到 Tiptap editor");
|
||||
}
|
||||
editor.commands.setContent(
|
||||
{
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
attrs: { blockId: targetBlockId },
|
||||
content: [{ type: "text", text: "Task490 block handle target" }],
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
attrs: { blockId: "task490-second-block" },
|
||||
content: [{ type: "text", text: "Task490 slash target" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
true,
|
||||
);
|
||||
editor.commands.focus("end");
|
||||
}, TARGET_BLOCK_ID);
|
||||
|
||||
await page.waitForFunction(
|
||||
(targetBlockId) => document.querySelector(`[data-block-id="${targetBlockId}"]`) instanceof HTMLElement,
|
||||
TARGET_BLOCK_ID,
|
||||
{ timeout: uiTimeoutMs },
|
||||
);
|
||||
}
|
||||
|
||||
async function closePageAiIfOpen(page) {
|
||||
const closeButton = page.locator('[data-page-ai-action="close"]').first();
|
||||
if (await closeButton.isVisible().catch(() => false)) {
|
||||
await closeButton.click().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
delete require.cache[require.resolve("./tree-shell-smoke-helpers")];
|
||||
const {
|
||||
BASE_URL,
|
||||
UI_TIMEOUT_MS,
|
||||
assert,
|
||||
ensureAuthenticated,
|
||||
} = require("./tree-shell-smoke-helpers");
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1440, height: 960 },
|
||||
locale: "zh-CN",
|
||||
extraHTTPHeaders: {
|
||||
"x-mnote-actor-id": "mnote-e2e",
|
||||
"x-mnote-actor-type": "user",
|
||||
},
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const screenshots = {};
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task490-"));
|
||||
const relativePath = "RuntimeSurfaces.md";
|
||||
writeWorkspaceManifest(root, "mnote-e2e");
|
||||
fs.writeFileSync(
|
||||
path.join(root, relativePath),
|
||||
["# Runtime Surfaces", "", "Task490 initial paragraph", ""].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
let caughtError = null;
|
||||
|
||||
try {
|
||||
await ensureAuthenticated(page, context.request);
|
||||
|
||||
const url = documentUrl(BASE_URL, root, relativePath);
|
||||
const response = await page.goto(url, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
assert(response, "文档页没有返回响应");
|
||||
assert(response.status() === 200, `文档页状态码异常: ${response.status()}`);
|
||||
const editor = await waitForRuntimeIsland(page, UI_TIMEOUT_MS);
|
||||
await setBlockHandleFixture(page, UI_TIMEOUT_MS);
|
||||
|
||||
const moreButton = page.locator('[data-testid="wolai-page-settings-trigger"]').first();
|
||||
await moreButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await moreButton.click({ timeout: UI_TIMEOUT_MS });
|
||||
const settingsPopover = page.locator('[data-testid="wolai-page-settings-popover"]').first();
|
||||
await settingsPopover.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
const settingsText = (await settingsPopover.textContent()) || "";
|
||||
assert(settingsText.includes("页面选项"), `页面设置 popover 未显示页面选项: ${settingsText}`);
|
||||
screenshots.pageSettings = await saveScreenshot(page, "01-page-settings");
|
||||
await page.keyboard.press("Escape").catch(() => undefined);
|
||||
|
||||
const aiButton = page.locator('[data-testid="wolai-floating-ai"]').first();
|
||||
await aiButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await aiButton.click({ timeout: UI_TIMEOUT_MS });
|
||||
const aiDrawer = page.locator('[data-testid="wolai-page-ai-drawer"]').first();
|
||||
await aiDrawer.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
const aiText = (await aiDrawer.textContent()) || "";
|
||||
assert(aiText.includes("Hermes"), `Page AI drawer 必须显示 Hermes 入口: ${aiText}`);
|
||||
assert(await page.locator("[data-page-ai-input]").first().isVisible(), "Page AI drawer 必须显示输入框");
|
||||
screenshots.pageAi = await saveScreenshot(page, "02-page-ai");
|
||||
await closePageAiIfOpen(page);
|
||||
|
||||
await editor.click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.keyboard.type("/");
|
||||
const slashMenu = page.locator('[data-testid="mnote-leptos-tiptap-slash-menu"]').first();
|
||||
await slashMenu.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
const slashText = (await slashMenu.textContent()) || "";
|
||||
assert(slashText.trim().length > 0, "slash menu 必须显示可选项");
|
||||
screenshots.slashMenu = await saveScreenshot(page, "03-slash-menu");
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
const target = page.locator(`[data-block-id="${TARGET_BLOCK_ID}"]`).first();
|
||||
await target.scrollIntoViewIfNeeded({ timeout: UI_TIMEOUT_MS });
|
||||
const targetBox = await target.boundingBox();
|
||||
assert(targetBox, "block handle 目标块缺少可测量区域");
|
||||
await page.mouse.move(targetBox.x + 8, targetBox.y + Math.min(12, Math.max(4, targetBox.height / 2)));
|
||||
const blockHandle = page.locator('[data-testid="block-drag-handle-trigger"]').first();
|
||||
await blockHandle.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await blockHandle.click({ timeout: UI_TIMEOUT_MS });
|
||||
const blockMenu = page.locator('[data-testid="block-drag-menu"]').first();
|
||||
await blockMenu.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
const blockMenuText = (await blockMenu.textContent()) || "";
|
||||
assert(blockMenuText.includes("删除") || blockMenuText.includes("Delete"), `block handle menu 缺少基础操作: ${blockMenuText}`);
|
||||
screenshots.blockHandleMenu = await saveScreenshot(page, "04-block-handle-menu");
|
||||
|
||||
const result = {
|
||||
ok: true,
|
||||
task: TASK,
|
||||
baseUrl: BASE_URL,
|
||||
root,
|
||||
relativePath,
|
||||
documentId: localMdDocumentId(relativePath),
|
||||
screenshotDir: OUTPUT_DIR,
|
||||
screenshots,
|
||||
};
|
||||
fs.writeFileSync(path.join(OUTPUT_DIR, "result.json"), `${JSON.stringify(result, null, 2)}\n`, "utf8");
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} catch (error) {
|
||||
caughtError = error;
|
||||
await saveScreenshot(page, "failure").catch(() => undefined);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
await page.close().catch(() => undefined);
|
||||
await context.close().catch(() => undefined);
|
||||
await browser.close().catch(() => undefined);
|
||||
}
|
||||
|
||||
if (caughtError) {
|
||||
throw caughtError;
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user