test: organize current smoke baseline

This commit is contained in:
lix-2026
2026-05-26 07:08:26 +08:00
parent 2210251c13
commit e1681331a3
12 changed files with 358 additions and 74 deletions
@@ -42,8 +42,8 @@ function parseArgs(argv) {
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>",
"用法:node recycle/scripts/retired-convex-export-smokes-20260526/export-convex-workspace-to-local.js --fixture <fixture.json> --out <dir> [--dry-run] [--manifest <file>] [--conflict-report <file>]",
"回滚:node recycle/scripts/retired-convex-export-smokes-20260526/export-convex-workspace-to-local.js --rollback <manifest.json>",
].join("\n"),
);
process.exit(0);
@@ -7,7 +7,7 @@ const os = require("node:os");
const path = require("node:path");
const { spawnSync } = require("node:child_process");
const repoRoot = path.resolve(__dirname, "..");
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");
@@ -68,7 +68,7 @@ fs.writeFileSync(fixturePath, JSON.stringify(fixture, null, 2), "utf8");
const result = spawnSync(
process.execPath,
[
path.join(repoRoot, "scripts", "export-convex-workspace-to-local.js"),
path.join(__dirname, "export-convex-workspace-to-local.js"),
"--fixture",
fixturePath,
"--out",
@@ -7,7 +7,7 @@ const os = require("node:os");
const path = require("node:path");
const { spawnSync } = require("node:child_process");
const repoRoot = path.resolve(__dirname, "..");
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");
@@ -43,7 +43,7 @@ const fixture = {
function runExport(args, expectedStatus = 0) {
const result = spawnSync(
process.execPath,
[path.join(repoRoot, "scripts", "export-convex-workspace-to-local.js"), ...args],
[path.join(__dirname, "export-convex-workspace-to-local.js"), ...args],
{ cwd: repoRoot, encoding: "utf8" },
);
assert.equal(result.status, expectedStatus, result.stderr || result.stdout);
@@ -9,6 +9,7 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => {
currentRootUri,
currentWorkspaceSourcePayload,
fileTreeIconKindForFileName,
healLegacyOfficeAttachmentParagraphs,
inferCodeAttachmentLanguage,
inferOnlyOfficeFileType,
isCodeAttachmentFileName,
@@ -350,6 +350,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
syncSidebarFileTreeSelection: (...args) => syncSidebarFileTreeSelection(...args),
});
const updateTitleEverywhere = (...args) => sidebarTreeLiveApply.updateTitleEverywhere(...args);
const commandDocumentId = (...args) => sidebarTreeLiveApply.commandDocumentId(...args);
sidebarPageTree = createSidebarPageTreeRuntime({
activeSidebarTreeMode,
commandDocumentId,
@@ -375,7 +376,6 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
const normalizeFileTreePageRenameTitle = (...args) => sidebarTreeLiveApply.normalizeFileTreePageRenameTitle(...args);
const validateFileTreeRename = (...args) => sidebarTreeLiveApply.validateFileTreeRename(...args);
const removeDocumentRowForMode = (...args) => sidebarTreeLiveApply.removeDocumentRowForMode(...args);
const commandDocumentId = (...args) => sidebarTreeLiveApply.commandDocumentId(...args);
const applyCreatedDocumentLocally = (...args) => sidebarTreeLiveApply.applyCreatedDocumentLocally(...args);
const localCommandNeedsProjectionRefresh = (...args) => sidebarTreeLiveApply.localCommandNeedsProjectionRefresh(...args);
const applyMoveDocumentDelta = (...args) => sidebarTreeLiveApply.applyMoveDocumentDelta(...args);
@@ -1557,10 +1557,10 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
localFilePathFromAssetId,
normalizeFileTreePageRenameTitle,
openConvexAssetFromFileTree,
openEditorAttachmentDetail,
openEditorAttachmentDownload,
openEditorAttachmentEditTab,
openEditorAttachmentNewWindow,
openEditorAttachmentDetail: (...args) => openEditorAttachmentDetail(...args),
openEditorAttachmentDownload: (...args) => openEditorAttachmentDownload(...args),
openEditorAttachmentEditTab: (...args) => openEditorAttachmentEditTab(...args),
openEditorAttachmentNewWindow: (...args) => openEditorAttachmentNewWindow(...args),
refreshLocalFolderSidebarSnapshot,
resolveWorkspaceId,
runtimeState: sidebarFileTreeCommandState,
@@ -1894,6 +1894,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
currentRootUri,
currentWorkspaceSourcePayload,
fileTreeIconKindForFileName,
healLegacyOfficeAttachmentParagraphs,
inferCodeAttachmentLanguage: (...args) => inferCodeAttachmentLanguage(...args),
inferOnlyOfficeFileType,
isCodeAttachmentFileName: (...args) => isCodeAttachmentFileName(...args),
+2 -2
View File
@@ -20,7 +20,7 @@ pub fn AuthPage() -> impl IntoView {
<h1 id="mnote-auth-title">"账号登录"</h1>
<p>"登录后进入你的工作区"</p>
</div>
<form class="mnote-auth-form" method="post" action="/api/auth" data-auth-mode="convex-password">
<form class="mnote-auth-form" method="post" action="/api/auth" data-auth-mode="sqlite-session">
<input type="hidden" name="action" value="auth:signIn" />
<input type="hidden" name="provider" value="password" />
<input type="hidden" name="flow" value="signIn" data-auth-flow />
@@ -95,7 +95,7 @@ const AUTH_SCRIPT: &str = r#"
(function () {
var root = document.querySelector('[data-testid="mnote-auth-page"]');
if (!root) return;
var form = root.querySelector('form[data-auth-mode="convex-password"]');
var form = root.querySelector('form[data-auth-mode="sqlite-session"]');
var flowInput = root.querySelector('[data-auth-flow]');
var accountInput = root.querySelector('#account');
var emailInput = root.querySelector('#email');
+42 -4
View File
@@ -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 Aggregatelocal-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`
+2 -2
View File
@@ -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");
+234
View File
@@ -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);
});
}