chore: retire openhub and pi ts runtime
This commit is contained in:
@@ -29,7 +29,7 @@ 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 打开/关闭、Page AI stop-run abort、slash menu、block handle menu 能在当前 leptos-tiptap 文档页工作;它使用 mock run 覆盖停止按钮,不要求真实模型返回,也不替代更重的页面设置、OpenHub/Page AI 或编辑器菜单专项脚本。
|
||||
其中 `task490-runtime-surfaces-smoke.js` 是轻量浏览器 smoke,验证页面设置、Page AI drawer 打开/关闭、Page AI stop-run abort、slash menu、block handle menu 能在当前 leptos-tiptap 文档页工作;它使用 mock run 覆盖停止按钮,不要求真实模型返回,也不替代更重的页面设置、Pi Lab/Page AI 或编辑器菜单专项脚本。
|
||||
|
||||
### 0.1 当前主链优先脚本
|
||||
|
||||
@@ -37,7 +37,7 @@ node scripts/task490-runtime-surfaces-smoke.js
|
||||
- Rust SSR 文档页 / Page Aggregate:local-first 默认优先 `task167-local-markdown-title-body-options-no-convex-smoke.js`;Page Aggregate browser conversion / compat fallback 改动补跑 `task522-page-aggregate-compat-fallback-contract.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`;WorkspacePath / ObjectIdentity runtime 消费统一改动补跑 `task524-workspace-object-identity-matrix-smoke.js`。`task452` 只覆盖普通本地搜索、settings API、tag/backlink API 和旧 local-index UI 不复活;资料库问答、PDF/Office/image source ingestion 与引用回跳改跑 WeKnora / provider-neutral knowledge-rag 脚本。
|
||||
- WeKnora / OpenHub 资料库:`task781-knowledge-bases-registry-smoke.js`、`task785-weknora-create-kb-folder-ingest-search-smoke.js`、`task787-weknora-default-lightrag-legacy-smoke.js`、`task772-openhub-weknora-kb-ui-smoke.js` 覆盖默认 provider、知识库列表、摄取、搜索、引用桥接与 legacy LightRAG guard。旧 `task529/530/531/534` LightRAG 脚本只作为 legacy fallback / 历史对照,不进入默认回归。旧 `task526-local-folder-ocr-api-smoke.js` / OCR sidecar 链路已退役并软归档;`/api/search/documents` 不作为 WeKnora、LightRAG legacy、LiteParse、OCR sidecar 或 evidence.sqlite 的资料库问答 fallback。
|
||||
- LightRAG / Knowledge RAG 资料库:`task781-knowledge-bases-registry-smoke.js`、`task785-weknora-create-kb-folder-ingest-search-smoke.js`、`task787-weknora-default-lightrag-legacy-smoke.js` 覆盖默认 provider、知识库列表、摄取、搜索、引用桥接与 legacy LightRAG guard。旧 `task529/530/531/534` LightRAG 脚本只作为 legacy fallback / 历史对照,不进入默认回归。旧 OpenHub 专项资料库 smoke 已软归档到 `recycle/20260712-pi-ts-openhub-retirement/`;旧 `task526-local-folder-ocr-api-smoke.js` / OCR sidecar 链路已退役并软归档;`/api/search/documents` 不作为 WeKnora、LightRAG legacy、LiteParse、OCR sidecar 或 evidence.sqlite 的资料库问答 fallback。
|
||||
- local Markdown conflict regression:`task510-local-markdown-conflict-regression-group.js` 串联真实冲突、连续上传、新建页面、外部恢复、多 tab CAS 与上传 409 孤儿策略;可用 `MNOTE_CONFLICT_REGRESSION_TASKS=a.js,b.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`;Page AI mindmap skill / 资源生成改动补跑 `task503-mindmap-skill-capability-smoke.js`,真实 mindmap resource tab / Page AI target 改动补跑 `task525-page-ai-mindmap-resource-target-smoke.js`
|
||||
@@ -123,7 +123,7 @@ node scripts/task490-runtime-surfaces-smoke.js
|
||||
- `task129-wolai-aline-baseline-smoke.js`
|
||||
- `task130` 到 `task137` 一系列 `wolai-aline-*`
|
||||
- `task160-wolai-page-settings-shell-smoke.js`
|
||||
- 页面 AI 旧 `/api/ai-agent/run` 视觉 smoke 已移入本机 gitignored `recycle/scripts/retired-ai-agent-run-smokes/`;当前页面 AI 验证优先使用 OpenHub / opencode / Page AI smoke 与 `task-page-block-ai-tools-smoke.js`。Hermes 相关 smoke 只作为 legacy guard / 历史对照。
|
||||
- 页面 AI 旧 `/api/ai-agent/run` 视觉 smoke 已移入本机 gitignored `recycle/scripts/retired-ai-agent-run-smokes/`;当前页面 AI 验证优先使用 Pi Lab / opencode / Page AI smoke 与 `task-page-block-ai-tools-smoke.js`。Hermes 相关 smoke 只作为 legacy guard / 历史对照。
|
||||
|
||||
这一层最重要的原则不是“文案命中”,而是:
|
||||
|
||||
@@ -193,7 +193,7 @@ node scripts/task097-homepage-entry-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`、`task455-local-folder-mindmap-clean-smoke.js`
|
||||
- 页面设置:`task160`、`task164-page-options-visible-effect-smoke.js`
|
||||
- 双栏:`task165`
|
||||
- AI:优先使用 OpenHub / opencode / Page AI 当前主线 smoke、`task-page-block-ai-tools-smoke.js` 和 `task-hermes-page-ai-retirement-guard.js` 这类 legacy guard;Hermes 页面 AI smoke 只作为历史对照。Page AI history / skills / ChatOnly 相关改动补跑 `task503-mindmap-skill-capability-smoke.js`、`task504-page-ai-history-agent-filter-smoke.js`、`task512-chatonly-doubao-sync-smoke.js`、`task513-chatonly-provider-sync-smoke.js`;OnlyOffice live bridge 改动补跑 `task515-onlyoffice-live-scope-http-smoke.js`、`task516-onlyoffice-bridge-multisession-browser-smoke.js`、`task517-onlyoffice-bridge-plugin-direct-smoke.js`、`task518-onlyoffice-real-iframe-session-scope-smoke.js`,其中 `task516/517` 覆盖 bridge session lifecycle 与 `docKey/pageOrigin` 元数据;Page AI 真实 target picker / Office live session 改动补跑 `task523-page-ai-onlyoffice-real-target-session-smoke.js`;`task155`、`task156`、`task161` 等旧 `/api/ai-agent/run` smoke 只保留在本机 gitignored `recycle/scripts/retired-ai-agent-run-smokes/` 做历史对照
|
||||
- AI:优先使用 Pi Lab / opencode / Page AI 当前主线 smoke、`task-page-block-ai-tools-smoke.js` 和 `task-hermes-page-ai-retirement-guard.js` 这类 legacy guard;Hermes 页面 AI smoke 只作为历史对照。Page AI history / skills / ChatOnly 相关改动补跑 `task503-mindmap-skill-capability-smoke.js`、`task504-page-ai-history-agent-filter-smoke.js`、`task512-chatonly-doubao-sync-smoke.js`、`task513-chatonly-provider-sync-smoke.js`;OnlyOffice live bridge 改动补跑 `task515-onlyoffice-live-scope-http-smoke.js`、`task516-onlyoffice-bridge-multisession-browser-smoke.js`、`task517-onlyoffice-bridge-plugin-direct-smoke.js`、`task518-onlyoffice-real-iframe-session-scope-smoke.js`,其中 `task516/517` 覆盖 bridge session lifecycle 与 `docKey/pageOrigin` 元数据;Page AI 真实 target picker / Office live session 改动补跑 `task523-page-ai-onlyoffice-real-target-session-smoke.js`;`task155`、`task156`、`task161` 等旧 `/api/ai-agent/run` smoke 只保留在本机 gitignored `recycle/scripts/retired-ai-agent-run-smokes/` 做历史对照
|
||||
|
||||
原则:
|
||||
|
||||
@@ -439,7 +439,7 @@ const controlPlaneEnv = buildControlPlaneTestEnv(dataRoot, process.env);
|
||||
- `rust/crates/` 下的 Rust 代码通过 `ControlPlaneStore` trait 访问数据库是正常路径。
|
||||
- `scripts/task-control-plane-admin-libsql-roundtrip-smoke.js` 使用 `cargo run --bin control-plane-admin`,走 Rust admin CLI。
|
||||
- `sqlite` fallback、admin CLI、legacy evidence/local_search 不在脚本纪律约束范围内。
|
||||
- OpenHub 自身 SQLite 不绑定本轮 control-plane Turso 切换;OpenHub 数据库迁移独立安排,不在当前首轮范围内。
|
||||
- 已退役 AI host 自身 SQLite 不绑定本轮 control-plane Turso 切换;历史迁移说明保留在 recycle 归档中。
|
||||
|
||||
### 7.4 违规后果
|
||||
|
||||
|
||||
+4
-158
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* 热启动 mnote-web 单入口,以及按需启用的 FastAPI。
|
||||
* 热启动 mnote-web 单入口,以及按需启用的 FastAPI / opencode。
|
||||
* 可使用以下环境变量调整行为:
|
||||
* - ENABLE_BACKEND:设为 "1" or "true" 时启用默认 FastAPI 后端
|
||||
* - BACKEND_CMD:覆盖 FastAPI 启动命令;设置后即视为显式启用后端
|
||||
@@ -9,16 +9,7 @@
|
||||
* - ENABLE_OPENCODE:设为 "1" or "true" 时启用 opencode serve
|
||||
* - OPENCODE_CMD:覆盖 opencode 启动命令;设置后即视为显式启用 opencode
|
||||
* - SKIP_OPENCODE:设为 "1" or "true" 可强制跳过 opencode
|
||||
* - ENABLE_OPENHUB:设为 "1" or "true" 时启用 OpenHub FastAPI
|
||||
* - OPENHUB_CMD / OPENHUB_BACKEND_CMD:覆盖 OpenHub FastAPI 启动命令;设置后即视为显式启用 OpenHub
|
||||
* - OPENHUB_HOST / OPENHUB_BIND_HOST:OpenHub FastAPI 监听地址,默认 0.0.0.0 便于局域网访问
|
||||
* - OPENHUB_PORT / OPENHUB_BACKEND_PORT:OpenHub FastAPI 端口,默认 18080
|
||||
* - SKIP_OPENHUB:设为 "1" or "true" 可强制跳过 OpenHub
|
||||
* - OPENHUB_REDIS_URL / OPENHUB_REDIS_DB:记录 OpenHub Redis 位置;OPENHUB_REDIS_HEALTH_URL 可选做 HTTP health 检查
|
||||
* - OPENHUB_REDIS_HEALTH_URL:可选 Redis health URL;dev-hot 只检查,不释放或结束 OpenHub 相关端口
|
||||
* - OPENHUB_OPENCODE_BASE_URL:OpenHub 侧 opencode serve base URL;默认复用 MNOTE_OPENCODE_BASE_URL
|
||||
* - SKIP_OPENHUB_HEALTH:设为 "1" or "true" 可跳过 OpenHub/Redis/opencode health 预检
|
||||
* - MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE:默认 "1",禁用 OpenHub Git snapshot/restore/revert 写链
|
||||
* - MNOTE_OPENCODE_XDG_ROOT / MNOTE_OPENCODE_HOME:opencode 专用运行目录
|
||||
* - MNOTE_CONTROL_PLANE_BACKEND:控制面后端,默认 libsql-local;可设 turso-remote / turso-local-replica / turso-synced
|
||||
* - MNOTE_TURSO_LOCAL_PATH:libsql-local 本地库路径,默认 /mnt/Data1T/Mnote_data/control-plane/control-plane-libsql.db
|
||||
* - MNOTE_PAGE_AI_PI_WARMUP:设为 "1" or "true" 时,在 mnote-web 可用后预启动 Pi Lab runtime / MCP cache
|
||||
@@ -64,7 +55,6 @@ function resolveBackendExecutable(envName, fallbackName) {
|
||||
const pythonBin = resolveBackendExecutable("PYTHON_BIN", "python");
|
||||
const backendPortFromEnv = Number(process.env.BACKEND_PORT || 8000);
|
||||
const opencodePortFromEnv = Number(process.env.OPENCODE_PORT || 4096);
|
||||
const openhubPortFromEnv = Number(process.env.OPENHUB_BACKEND_PORT || process.env.OPENHUB_PORT || 18080);
|
||||
const defaultControlPlaneDir = "/mnt/Data1T/Mnote_data/control-plane";
|
||||
|
||||
function hasCommand(command) {
|
||||
@@ -90,8 +80,8 @@ function buildDefaultBackendCommand(port) {
|
||||
}
|
||||
|
||||
function buildDefaultOpencodeCommand(port) {
|
||||
const opencodeXdgRoot = process.env.OPENHUB_OPENCODE_XDG_ROOT || "/mnt/Data1T/Mnote_data/openhub/opencode-runtime";
|
||||
const opencodeHome = process.env.OPENHUB_OPENCODE_HOME || "/mnt/Data1T/Mnote_data/openhub/opencode-home";
|
||||
const opencodeXdgRoot = process.env.MNOTE_OPENCODE_XDG_ROOT || "/mnt/Data1T/Mnote_data/opencode/runtime";
|
||||
const opencodeHome = process.env.MNOTE_OPENCODE_HOME || "/mnt/Data1T/Mnote_data/opencode/home";
|
||||
const modelEnvNames = [
|
||||
"OPENCODE_API_KEY",
|
||||
"OPENAI_API_KEY",
|
||||
@@ -124,19 +114,6 @@ function buildDefaultOpencodeCommand(port) {
|
||||
].join(" && ");
|
||||
}
|
||||
|
||||
function buildDefaultOpenHubCommand(port) {
|
||||
const openhubBackendDir = process.env.OPENHUB_BACKEND_DIR || "/mnt/Data1T/Mnote_data/openhub/OpenHub/smart-query-backend";
|
||||
const openhubHost = process.env.OPENHUB_HOST || process.env.OPENHUB_BIND_HOST || "0.0.0.0";
|
||||
const uvicornBin = fs.existsSync(path.join(openhubBackendDir, ".venv", "bin", "uvicorn"))
|
||||
? path.join(openhubBackendDir, ".venv", "bin", "uvicorn")
|
||||
: "uvicorn";
|
||||
const opencodeBaseUrl = process.env.OPENHUB_OPENCODE_BASE_URL || process.env.MNOTE_OPENCODE_BASE_URL || `http://127.0.0.1:${opencodePortFromEnv}`;
|
||||
return [
|
||||
`cd ${JSON.stringify(openhubBackendDir)}`,
|
||||
`OPENCODE_BASE_URL=${JSON.stringify(opencodeBaseUrl)} MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE=${JSON.stringify(process.env.MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE || "1")} exec ${JSON.stringify(uvicornBin)} app.main:app --host ${JSON.stringify(openhubHost)} --port ${port}`,
|
||||
].join(" && ");
|
||||
}
|
||||
|
||||
function isEnabledEnv(value) {
|
||||
const normalized = String(value || "").toLowerCase();
|
||||
return normalized === "1" || normalized === "true";
|
||||
@@ -154,54 +131,6 @@ function shouldStartOpencode(env = process.env) {
|
||||
return true;
|
||||
}
|
||||
|
||||
function shouldStartOpenHub(env = process.env) {
|
||||
if (isEnabledEnv(env.SKIP_OPENHUB)) return false;
|
||||
if (String(env.OPENHUB_BACKEND_CMD || env.OPENHUB_CMD || "").trim()) return true;
|
||||
return isEnabledEnv(env.ENABLE_OPENHUB);
|
||||
}
|
||||
|
||||
function shouldCheckOpenHubHealth(env = process.env) {
|
||||
if (isEnabledEnv(env.SKIP_OPENHUB_HEALTH)) return false;
|
||||
return shouldStartOpenHub(env) || isEnabledEnv(env.CHECK_OPENHUB_HEALTH);
|
||||
}
|
||||
|
||||
function resolveOpenHubHealthPlan(env = process.env) {
|
||||
const openhubPort = Number(env.OPENHUB_BACKEND_PORT || env.OPENHUB_PORT || openhubPortFromEnv);
|
||||
const opencodePort = Number(env.OPENCODE_PORT || opencodePortFromEnv);
|
||||
const baseUrl = String(env.MNOTE_OPENHUB_BASE_URL || env.OPENHUB_BASE_URL || `http://127.0.0.1:${openhubPort}`).replace(/\/+$/, "");
|
||||
const redisHealthUrl = String(env.OPENHUB_REDIS_HEALTH_URL || "").trim();
|
||||
const redisUrl = String(env.OPENHUB_REDIS_URL || "").trim();
|
||||
const redisDb = String(env.OPENHUB_REDIS_DB || "").trim();
|
||||
const opencodeBaseUrl = String(env.OPENHUB_OPENCODE_BASE_URL || env.MNOTE_OPENCODE_BASE_URL || `http://127.0.0.1:${opencodePort}`).replace(/\/+$/, "");
|
||||
const requireHealth = isEnabledEnv(env.REQUIRE_OPENHUB_HEALTH);
|
||||
return {
|
||||
enabled: shouldCheckOpenHubHealth(env),
|
||||
openhub: {
|
||||
label: "OpenHub FastAPI",
|
||||
url: env.OPENHUB_HEALTH_URL || `${baseUrl}/api/health`,
|
||||
required: requireHealth,
|
||||
},
|
||||
redis: {
|
||||
label: "OpenHub Redis",
|
||||
url: redisHealthUrl,
|
||||
redisUrl,
|
||||
redisDb,
|
||||
required: isEnabledEnv(env.REQUIRE_OPENHUB_REDIS_HEALTH),
|
||||
skipped: !redisHealthUrl,
|
||||
},
|
||||
opencode: {
|
||||
label: "opencode",
|
||||
url: env.OPENCODE_HEALTH_URL || `${opencodeBaseUrl}/global/health`,
|
||||
required: requireHealth,
|
||||
},
|
||||
gitSnapshotRestore: {
|
||||
env: "MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE",
|
||||
value: String(env.MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE || "1"),
|
||||
defaultDisabled: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function resolveRuntimePlan(env = process.env) {
|
||||
const frontendPort = Number(env.FRONTEND_PORT || 3000);
|
||||
const skipGateway = false;
|
||||
@@ -234,8 +163,6 @@ function resolveRuntimePlan(env = process.env) {
|
||||
MNOTE_WEB_PUBLIC_BIND: env.MNOTE_WEB_PUBLIC_BIND || `127.0.0.1:${publicPort}`,
|
||||
MNOTE_KNOWLEDGE_PROVIDER: env.MNOTE_KNOWLEDGE_PROVIDER || "lightrag_legacy",
|
||||
MNOTE_OPENCODE_BASE_URL: env.MNOTE_OPENCODE_BASE_URL || `http://127.0.0.1:${opencodePortFromEnv}`,
|
||||
MNOTE_OPENHUB_BASE_URL: env.MNOTE_OPENHUB_BASE_URL || `http://127.0.0.1:${openhubPortFromEnv}`,
|
||||
MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE: env.MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE || "1",
|
||||
...controlPlaneEnv,
|
||||
},
|
||||
};
|
||||
@@ -279,18 +206,6 @@ const tasks = [
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(shouldStartOpenHub(process.env)
|
||||
? [
|
||||
{
|
||||
name: "openhub",
|
||||
command:
|
||||
process.env.OPENHUB_BACKEND_CMD ||
|
||||
process.env.OPENHUB_CMD ||
|
||||
buildDefaultOpenHubCommand(openhubPortFromEnv),
|
||||
cwd: rootDir,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
function findTask(name) {
|
||||
@@ -613,32 +528,6 @@ async function ensurePortFree(port, nameForLog) {
|
||||
return false;
|
||||
}
|
||||
|
||||
async function checkHttpHealth(url, label, required) {
|
||||
if (!url) {
|
||||
logPrefix("openhub-health", `${label} health 未配置,已跳过。`);
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 1200);
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: { accept: "application/json,text/plain,*/*" },
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timer);
|
||||
if (response.ok) {
|
||||
logPrefix("openhub-health", `${label} 可达:${url}`);
|
||||
return true;
|
||||
}
|
||||
logPrefix("openhub-health", `${label} 返回 HTTP ${response.status}:${url}`);
|
||||
return !required;
|
||||
} catch (error) {
|
||||
logPrefix("openhub-health", `${label} 不可达:${url} (${error.message})`);
|
||||
return !required;
|
||||
}
|
||||
}
|
||||
|
||||
async function isHttpHealthy(url) {
|
||||
if (!url) return false;
|
||||
try {
|
||||
@@ -656,24 +545,6 @@ async function isHttpHealthy(url) {
|
||||
}
|
||||
}
|
||||
|
||||
async function checkOpenHubHealth(plan = resolveOpenHubHealthPlan(process.env)) {
|
||||
if (!plan.enabled) {
|
||||
return true;
|
||||
}
|
||||
logPrefix(
|
||||
"openhub-health",
|
||||
`Git snapshot/restore 默认关闭:${plan.gitSnapshotRestore.env}=${plan.gitSnapshotRestore.value || "1"}`,
|
||||
);
|
||||
const checks = [
|
||||
await checkHttpHealth(plan.openhub.url, plan.openhub.label, plan.openhub.required),
|
||||
plan.redis.skipped
|
||||
? (logPrefix("openhub-health", "OpenHub Redis health 未配置,已跳过非破坏性检查。"), true)
|
||||
: await checkHttpHealth(plan.redis.url, plan.redis.label, plan.redis.required),
|
||||
await checkHttpHealth(plan.opencode.url, plan.opencode.label, plan.opencode.required),
|
||||
];
|
||||
return checks.every(Boolean);
|
||||
}
|
||||
|
||||
function loadEnvFile(filePath) {
|
||||
if (!fs.existsSync(filePath)) return {};
|
||||
const content = fs.readFileSync(filePath, "utf8");
|
||||
@@ -854,27 +725,6 @@ async function main() {
|
||||
|
||||
}
|
||||
|
||||
if (shouldStartOpenHub(process.env) && !process.env.OPENHUB_BACKEND_CMD && !process.env.OPENHUB_CMD) {
|
||||
const openhubPortOk = await ensurePortFree(openhubPortFromEnv, "openhub");
|
||||
if (!openhubPortOk) {
|
||||
console.error(`OpenHub 端口 ${openhubPortFromEnv} 无法释放,已中止启动。`);
|
||||
process.exit(1);
|
||||
}
|
||||
const openhubTask = findTask("openhub");
|
||||
if (!openhubTask) {
|
||||
throw new Error("缺少 OpenHub 任务配置");
|
||||
}
|
||||
openhubTask.command = buildDefaultOpenHubCommand(openhubPortFromEnv);
|
||||
} else if (isEnabledEnv(process.env.SKIP_OPENHUB)) {
|
||||
logPrefix("openhub", "已跳过 OpenHub FastAPI(SKIP_OPENHUB=1)。");
|
||||
}
|
||||
|
||||
const healthOk = await checkOpenHubHealth(resolveOpenHubHealthPlan(process.env));
|
||||
if (!healthOk) {
|
||||
console.error("OpenHub health 预检失败,已中止启动。");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (tasks.length === 0) {
|
||||
console.error("未配置任何可运行的任务,检查环境变量设置。");
|
||||
process.exit(1);
|
||||
@@ -895,8 +745,6 @@ if (require.main === module) {
|
||||
|
||||
module.exports = {
|
||||
buildDefaultOpencodeCommand,
|
||||
buildDefaultOpenHubCommand,
|
||||
checkOpenHubHealth,
|
||||
collectStaleMnoteWebCargoPids,
|
||||
ensurePortFree,
|
||||
getListeningPidsByPort,
|
||||
@@ -905,12 +753,10 @@ module.exports = {
|
||||
getProcessNameByPid,
|
||||
isHttpHealthy,
|
||||
isPortFree,
|
||||
resolveOpenHubHealthPlan,
|
||||
resolveRuntimePlan,
|
||||
resolveBackendExecutable,
|
||||
schedulePiLabWarmup,
|
||||
shouldStartBackend,
|
||||
shouldStartOpenHub,
|
||||
stopStaleMnoteWebCargoProcesses,
|
||||
terminatePid,
|
||||
};
|
||||
|
||||
@@ -4,16 +4,13 @@ const net = require("node:net");
|
||||
const { test } = require("node:test");
|
||||
const {
|
||||
buildDefaultOpencodeCommand,
|
||||
buildDefaultOpenHubCommand,
|
||||
collectStaleMnoteWebCargoPids,
|
||||
resolveBackendExecutable,
|
||||
ensurePortFree,
|
||||
isHttpHealthy,
|
||||
isPortFree,
|
||||
resolveOpenHubHealthPlan,
|
||||
resolveRuntimePlan,
|
||||
shouldStartBackend,
|
||||
shouldStartOpenHub,
|
||||
stopStaleMnoteWebCargoProcesses,
|
||||
} = require("./desktop-hot.js");
|
||||
|
||||
@@ -145,8 +142,6 @@ test("默认热启动计划只使用 mnote-web 作为 3000 owner", () => {
|
||||
MNOTE_WEB_PUBLIC_BIND: "127.0.0.1:3000",
|
||||
MNOTE_KNOWLEDGE_PROVIDER: "lightrag_legacy",
|
||||
MNOTE_OPENCODE_BASE_URL: "http://127.0.0.1:4096",
|
||||
MNOTE_OPENHUB_BASE_URL: "http://127.0.0.1:18080",
|
||||
MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE: "1",
|
||||
MNOTE_CONTROL_PLANE_BACKEND: "libsql-local",
|
||||
MNOTE_TURSO_LOCAL_PATH: "/mnt/Data1T/Mnote_data/control-plane/control-plane-libsql.db",
|
||||
});
|
||||
@@ -193,50 +188,10 @@ test("默认跳过 FastAPI 后端,只有显式开启时才启动", () => {
|
||||
assert.equal(shouldStartBackend({ ENABLE_BACKEND: "1", SKIP_BACKEND: "1" }), false);
|
||||
});
|
||||
|
||||
test("OpenHub FastAPI 默认跳过,显式开启或命令覆盖时才启动", () => {
|
||||
assert.equal(shouldStartOpenHub({}), false);
|
||||
assert.equal(shouldStartOpenHub({ ENABLE_OPENHUB: "1" }), true);
|
||||
assert.equal(shouldStartOpenHub({ ENABLE_OPENHUB: "true" }), true);
|
||||
assert.equal(shouldStartOpenHub({ OPENHUB_CMD: "custom-openhub" }), true);
|
||||
assert.equal(shouldStartOpenHub({ ENABLE_OPENHUB: "1", SKIP_OPENHUB: "1" }), false);
|
||||
});
|
||||
|
||||
test("OpenHub health plan 包含 FastAPI、Redis、opencode 和默认关闭 Git snapshot/restore", () => {
|
||||
const plan = resolveOpenHubHealthPlan({
|
||||
ENABLE_OPENHUB: "1",
|
||||
REQUIRE_OPENHUB_HEALTH: "1",
|
||||
OPENHUB_PORT: "18081",
|
||||
OPENCODE_PORT: "4097",
|
||||
OPENHUB_REDIS_HEALTH_URL: "http://127.0.0.1:6379/health",
|
||||
});
|
||||
|
||||
assert.equal(plan.enabled, true);
|
||||
assert.equal(plan.openhub.url, "http://127.0.0.1:18081/api/health");
|
||||
assert.equal(plan.openhub.required, true);
|
||||
assert.equal(plan.redis.url, "http://127.0.0.1:6379/health");
|
||||
assert.equal(plan.redis.skipped, false);
|
||||
assert.equal(plan.opencode.url, "http://127.0.0.1:4097/global/health");
|
||||
assert.deepEqual(plan.gitSnapshotRestore, {
|
||||
env: "MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE",
|
||||
value: "1",
|
||||
defaultDisabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("OpenHub 默认命令只启动 FastAPI,不包含 snapshot/restore 写操作", () => {
|
||||
const command = buildDefaultOpenHubCommand(18082);
|
||||
assert.match(command, /app\.main:app/);
|
||||
assert.match(command, /--host "?0\.0\.0\.0"?/);
|
||||
assert.match(command, /--port 18082/);
|
||||
assert.match(command, /\/mnt\/Data1T\/Mnote_data\/openhub\/OpenHub\/smart-query-backend/);
|
||||
assert.match(command, /MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE="1"/);
|
||||
assert.doesNotMatch(command, /git\s+(snapshot|restore|revert)|kill-port/i);
|
||||
});
|
||||
|
||||
test("opencode 默认命令使用 OpenHub 专用运行目录", () => {
|
||||
test("opencode 默认命令使用 MNote 专用运行目录", () => {
|
||||
const command = buildDefaultOpencodeCommand(18085);
|
||||
assert.match(command, /\/mnt\/Data1T\/Mnote_data\/openhub\/opencode-runtime/);
|
||||
assert.match(command, /\/mnt\/Data1T\/Mnote_data\/openhub\/opencode-home/);
|
||||
assert.match(command, /\/mnt\/Data1T\/Mnote_data\/opencode\/runtime/);
|
||||
assert.match(command, /\/mnt\/Data1T\/Mnote_data\/opencode\/home/);
|
||||
assert.match(command, /HOME=/);
|
||||
assert.match(command, /XDG_CONFIG_HOME=/);
|
||||
assert.doesNotMatch(command, /已检测到现有 opencode/);
|
||||
|
||||
@@ -61,19 +61,7 @@ function devHotBindAddr(env = process.env) {
|
||||
|
||||
function buildDevHotEnv(baseEnv = process.env) {
|
||||
const opencodePort = String(baseEnv.OPENCODE_PORT || "4096").trim();
|
||||
const openhubPort = String(baseEnv.OPENHUB_BACKEND_PORT || baseEnv.OPENHUB_PORT || "18080").trim();
|
||||
const opencodeBaseUrl = String(baseEnv.MNOTE_OPENCODE_BASE_URL || `http://127.0.0.1:${opencodePort}`).trim();
|
||||
const openhubBaseUrl = String(baseEnv.MNOTE_OPENHUB_BASE_URL || `http://127.0.0.1:${openhubPort}`).trim();
|
||||
const openhubBackendDir = String(
|
||||
baseEnv.OPENHUB_BACKEND_DIR || "/mnt/Data1T/Mnote_data/openhub/OpenHub/smart-query-backend"
|
||||
).trim();
|
||||
const openhubOpencodeXdgRoot = String(
|
||||
baseEnv.OPENHUB_OPENCODE_XDG_ROOT || "/mnt/Data1T/Mnote_data/openhub/opencode-runtime"
|
||||
).trim();
|
||||
const openhubOpencodeHome = String(
|
||||
baseEnv.OPENHUB_OPENCODE_HOME || "/mnt/Data1T/Mnote_data/openhub/opencode-home"
|
||||
).trim();
|
||||
const skipOpenHub = String(baseEnv.SKIP_OPENHUB || "").trim();
|
||||
const controlPlaneBackend = String(baseEnv.MNOTE_CONTROL_PLANE_BACKEND || "libsql-local").trim() || "libsql-local";
|
||||
if (controlPlaneBackend === "sqlite") {
|
||||
throw new Error("dev:hot 不再支持 SQLite control-plane fallback;请使用 libsql-local/turso-local-replica/turso-remote/turso-synced");
|
||||
@@ -87,16 +75,7 @@ function buildDevHotEnv(baseEnv = process.env) {
|
||||
MNOTE_WEB_BIND: devHotBindAddr(baseEnv),
|
||||
MNOTE_WEB_CMD: String(baseEnv.MNOTE_WEB_CMD || "").trim() || cargoWatchCommand(baseEnv),
|
||||
MNOTE_KNOWLEDGE_PROVIDER: String(baseEnv.MNOTE_KNOWLEDGE_PROVIDER || "lightrag_legacy").trim(),
|
||||
ENABLE_OPENHUB: skipOpenHub ? String(baseEnv.ENABLE_OPENHUB || "") : "1",
|
||||
CHECK_OPENHUB_HEALTH: String(baseEnv.CHECK_OPENHUB_HEALTH || "1"),
|
||||
MNOTE_OPENCODE_BASE_URL: opencodeBaseUrl,
|
||||
OPENHUB_OPENCODE_BASE_URL: String(baseEnv.OPENHUB_OPENCODE_BASE_URL || opencodeBaseUrl).trim(),
|
||||
OPENHUB_BACKEND_DIR: openhubBackendDir,
|
||||
OPENHUB_OPENCODE_XDG_ROOT: openhubOpencodeXdgRoot,
|
||||
OPENHUB_OPENCODE_HOME: openhubOpencodeHome,
|
||||
MNOTE_OPENHUB_BASE_URL: openhubBaseUrl,
|
||||
OPENHUB_HEALTH_URL: String(baseEnv.OPENHUB_HEALTH_URL || `${openhubBaseUrl.replace(/\/+$/, "")}/api/health`).trim(),
|
||||
MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE: String(baseEnv.MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE || "1"),
|
||||
MNOTE_CONTROL_PLANE_BACKEND: controlPlaneBackend,
|
||||
MNOTE_PAGE_AI_PI_WARMUP: String(baseEnv.MNOTE_PAGE_AI_PI_WARMUP ?? "1").trim() || "1",
|
||||
MNOTE_PAGE_AI_PI_WARMUP_SEND: String(baseEnv.MNOTE_PAGE_AI_PI_WARMUP_SEND ?? "0").trim() || "0",
|
||||
|
||||
@@ -97,8 +97,8 @@ async function main() {
|
||||
|
||||
const openHubLink = page.locator('a[href="/page-ai/openhub/admin"]');
|
||||
assert(
|
||||
await openHubLink.count() >= 1,
|
||||
"OpenHub Admin 独立入口必须保留",
|
||||
await openHubLink.count() === 0,
|
||||
"OpenHub Admin 独立入口必须从当前 AI 管理页移除",
|
||||
);
|
||||
|
||||
await page.getByRole("link", { name: "模型配置" }).click();
|
||||
@@ -122,27 +122,37 @@ async function main() {
|
||||
await page.getByRole("button", { name: "保存策略" }).click();
|
||||
await page.locator("[data-ai-admin-tools-save-status]").filter({ hasText: /已保存/ }).waitFor();
|
||||
|
||||
await page.getByRole("link", { name: "技能 / MCP" }).click();
|
||||
await page.getByRole("link", { name: "Skills" }).click();
|
||||
await page.locator("#ai-admin-skills.is-active").waitFor();
|
||||
await page.getByRole("button", { name: "+ 添加 Skill" }).click();
|
||||
const skillRow = page.locator(".mnote-ai-admin-skill-row").last();
|
||||
await skillRow.locator('[name="skillName"]').fill(skillName);
|
||||
await skillRow.locator('[name="skillDescription"]').fill("浏览器验收技能");
|
||||
await skillRow.locator('[name="skillEnabled"]').check();
|
||||
const skillsPanel = page.locator("#ai-admin-skills");
|
||||
await skillsPanel.getByRole("button", { name: "保存 Skills" }).click();
|
||||
await assertEventually(
|
||||
async () => (await skillsPanel.locator("[data-ai-admin-skills-save-status]").textContent()) || "",
|
||||
(text) => text.includes("已保存"),
|
||||
"Skills 配置保存状态未变为已保存",
|
||||
);
|
||||
|
||||
await page.getByRole("link", { name: "MCP" }).click();
|
||||
await page.locator("#ai-admin-mcp.is-active").waitFor();
|
||||
await page.getByRole("button", { name: "+ 添加 MCP" }).click();
|
||||
const mcpRow = page.locator("[data-mcp-idx]").last();
|
||||
await mcpRow.locator("summary").click();
|
||||
await mcpRow.locator('[name="mcpName"]').fill(mcpName);
|
||||
await mcpRow.locator('[name="mcpTransport"]').selectOption("stdio");
|
||||
await mcpRow.locator('[name="mcpCommand"]').fill("scripts/lightrag-native-mcp.sh");
|
||||
await mcpRow.locator('[name="mcpSecretRefs"]').fill("env://LIGHTRAG_API_KEY");
|
||||
await mcpRow.locator('[name="mcpEnabled"]').check();
|
||||
const skillsPanel = page.locator("#ai-admin-skills");
|
||||
await skillsPanel.getByRole("button", { name: "保存配置" }).click();
|
||||
const mcpPanel = page.locator("#ai-admin-mcp");
|
||||
await mcpPanel.getByRole("button", { name: "保存 MCP" }).click();
|
||||
await assertEventually(
|
||||
async () => (await skillsPanel.locator("[data-ai-admin-skills-save-status]").textContent()) || "",
|
||||
async () => (await mcpPanel.locator("[data-ai-admin-mcp-save-status]").textContent()) || "",
|
||||
(text) => text.includes("已保存"),
|
||||
"技能/MCP 配置保存状态未变为已保存",
|
||||
"MCP 配置保存状态未变为已保存",
|
||||
);
|
||||
await page.screenshot({
|
||||
path: path.join(OUTPUT_DIR, "skills-mcp.png"),
|
||||
@@ -155,19 +165,55 @@ async function main() {
|
||||
await targetUser.waitFor();
|
||||
await targetUser.click();
|
||||
await page.locator('[data-ai-admin-user-settings] h3', { hasText: "ai-user" }).waitFor();
|
||||
await page.locator('[data-user-model="omniroute/freefirst-fast"]').evaluate((input) => {
|
||||
input.checked = false;
|
||||
input.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
const disabledModelId = await page.locator("[data-user-model]").evaluateAll((inputs) => {
|
||||
const checked = inputs
|
||||
.filter((input) => input.checked)
|
||||
.map((input) => input.getAttribute("data-user-model") || "")
|
||||
.filter(Boolean);
|
||||
return checked.length > 1 ? (checked.find((id) => id !== "omniroute/freefirst") || checked[1]) : "";
|
||||
});
|
||||
await page.locator('[data-action="save-user-settings"]').click();
|
||||
await page.locator("[data-ai-admin-user-save-status]").filter({ hasText: /已保存/ }).waitFor();
|
||||
if (disabledModelId) {
|
||||
await page.locator(`[data-user-model="${disabledModelId}"]`).evaluate((input) => {
|
||||
input.checked = false;
|
||||
input.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
const saveModelOverride = await Promise.all([
|
||||
page.waitForResponse((response) =>
|
||||
response.url().includes("/api/ai-admin/users/ai-user/settings") &&
|
||||
response.request().method() === "PUT"
|
||||
),
|
||||
page.locator('[data-action="save-user-settings"]').click(),
|
||||
]).then(([response]) => response);
|
||||
assert(
|
||||
saveModelOverride.ok(),
|
||||
`模型降权保存失败: ${saveModelOverride.status()} ${await saveModelOverride.text()}`,
|
||||
);
|
||||
}
|
||||
await page.locator('[data-user-tab="skills"]').click();
|
||||
await page.locator(`[data-user-skill="${skillName}"]`).evaluate((input) => {
|
||||
input.checked = false;
|
||||
input.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
const disabledSkillId = await page.locator("[data-user-skill]").evaluateAll((inputs) => {
|
||||
const checked = inputs
|
||||
.filter((input) => input.checked)
|
||||
.map((input) => input.getAttribute("data-user-skill") || "")
|
||||
.filter(Boolean);
|
||||
return checked[0] || "";
|
||||
});
|
||||
await page.locator('[data-action="save-user-settings"]').click();
|
||||
await page.locator("[data-ai-admin-user-save-status]").filter({ hasText: /已保存/ }).waitFor();
|
||||
if (disabledSkillId) {
|
||||
await page.locator(`[data-user-skill="${disabledSkillId}"]`).evaluate((input) => {
|
||||
input.checked = false;
|
||||
input.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
const saveSkillOverride = await Promise.all([
|
||||
page.waitForResponse((response) =>
|
||||
response.url().includes("/api/ai-admin/users/ai-user/settings") &&
|
||||
response.request().method() === "PUT"
|
||||
),
|
||||
page.locator('[data-action="save-user-settings"]').click(),
|
||||
]).then(([response]) => response);
|
||||
assert(
|
||||
saveSkillOverride.ok(),
|
||||
`Skill 降权保存失败: ${saveSkillOverride.status()} ${await saveSkillOverride.text()}`,
|
||||
);
|
||||
}
|
||||
await page.screenshot({
|
||||
path: path.join(OUTPUT_DIR, "users.png"),
|
||||
fullPage: false,
|
||||
@@ -178,17 +224,23 @@ async function main() {
|
||||
const drawer = document.querySelector("[data-ai-admin-user-drawer]");
|
||||
return !drawer || !drawer.classList.contains("is-open");
|
||||
});
|
||||
await page.getByRole("link", { name: "技能 / MCP" }).click();
|
||||
await page.getByRole("link", { name: "Skills" }).click();
|
||||
await page.reload({ waitUntil: "commit" });
|
||||
await page.locator("#ai-admin-skills.is-active").waitFor();
|
||||
assert.equal(
|
||||
await page.locator(`[name="skillName"][value="${skillName}"]`).count(),
|
||||
1,
|
||||
await assertEventually(
|
||||
async () => page.locator('[name="skillName"]').evaluateAll((inputs) =>
|
||||
inputs.map((input) => input.value || ""),
|
||||
),
|
||||
(values) => values.includes(skillName),
|
||||
"Skill 保存后刷新必须仍存在",
|
||||
);
|
||||
assert.equal(
|
||||
await page.locator(`[name="mcpName"][value="${mcpName}"]`).count(),
|
||||
1,
|
||||
await page.getByRole("link", { name: "MCP" }).click();
|
||||
await page.locator("#ai-admin-mcp.is-active").waitFor();
|
||||
await assertEventually(
|
||||
async () => page.locator('[name="mcpName"]').evaluateAll((inputs) =>
|
||||
inputs.map((input) => input.value || ""),
|
||||
),
|
||||
(values) => values.includes(mcpName),
|
||||
"MCP 保存后刷新必须仍存在",
|
||||
);
|
||||
|
||||
@@ -213,16 +265,20 @@ async function main() {
|
||||
const response = await fetch("/api/ai-admin/users/ai-user/settings", { credentials: "include" });
|
||||
return response.json();
|
||||
});
|
||||
assert.equal(
|
||||
(userSettings.allowedModels || []).some((model) => model.id === "omniroute/freefirst-fast"),
|
||||
false,
|
||||
"ai-user 刷新后不应包含被禁用模型",
|
||||
);
|
||||
assert.equal(
|
||||
(userSettings.skills || []).find((skill) => skill.id === skillName)?.enabled,
|
||||
false,
|
||||
"ai-user 刷新后应保留 Skill 禁用覆盖",
|
||||
);
|
||||
if (disabledModelId) {
|
||||
assert.equal(
|
||||
(userSettings.allowedModels || []).some((model) => model.id === disabledModelId),
|
||||
false,
|
||||
`ai-user 刷新后不应包含被禁用模型 ${disabledModelId}`,
|
||||
);
|
||||
}
|
||||
if (disabledSkillId) {
|
||||
assert.equal(
|
||||
(userSettings.skills || []).find((skill) => skill.id === disabledSkillId)?.enabled,
|
||||
false,
|
||||
`ai-user 刷新后应保留 Skill 禁用覆盖 ${disabledSkillId}`,
|
||||
);
|
||||
}
|
||||
|
||||
const userContext = await browser.newContext({ viewport: { width: 1280, height: 800 } });
|
||||
const userResponse = await userContext.request.post(`${BASE}/api/auth`, {
|
||||
@@ -244,16 +300,20 @@ async function main() {
|
||||
const userEffectiveResponse = await userContext.request.get(`${BASE}/api/ai-settings/effective`);
|
||||
assert(userEffectiveResponse.ok(), `ai-user effective 读取失败: ${userEffectiveResponse.status()}`);
|
||||
const userEffective = await userEffectiveResponse.json();
|
||||
assert.equal(
|
||||
(userEffective.models || []).some((model) => model.id === "omniroute/freefirst-fast"),
|
||||
false,
|
||||
"ai-user effective 不应包含被管理员取消的模型",
|
||||
);
|
||||
assert.equal(
|
||||
(userEffective.skills || []).some((skill) => skill.name === skillName),
|
||||
false,
|
||||
"ai-user effective 不应包含被禁用 Skill",
|
||||
);
|
||||
if (disabledModelId) {
|
||||
assert.equal(
|
||||
(userEffective.models || []).some((model) => model.id === disabledModelId),
|
||||
false,
|
||||
`ai-user effective 不应包含被管理员取消的模型 ${disabledModelId}`,
|
||||
);
|
||||
}
|
||||
if (disabledSkillId) {
|
||||
assert.equal(
|
||||
(userEffective.skills || []).some((skill) => skill.id === disabledSkillId || skill.name === disabledSkillId),
|
||||
false,
|
||||
`ai-user effective 不应包含被禁用 Skill ${disabledSkillId}`,
|
||||
);
|
||||
}
|
||||
await userContext.close();
|
||||
|
||||
console.log(JSON.stringify({
|
||||
|
||||
@@ -301,27 +301,16 @@ check(
|
||||
);
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Section 6: Pi Rust 主入口与 OpenHub 兼容边界
|
||||
// Section 6: Pi Rust 主入口与退役边界
|
||||
// --------------------------------------------------------------------------
|
||||
console.log("\n-- 6. Pi Rust 主入口与 OpenHub 兼容边界 --");
|
||||
console.log("\n-- 6. Pi Rust 主入口与退役边界 --");
|
||||
|
||||
check(
|
||||
"OpenHub agent route /page-ai/openhub/ai exists",
|
||||
routesMod.includes('/page-ai/openhub/ai", get(page_ai_openhub::ai_shell)'),
|
||||
"page_ai_openhub::ai_shell"
|
||||
);
|
||||
|
||||
check(
|
||||
"OpenHub admin routes still exist",
|
||||
routesMod.includes('/page-ai/openhub/admin"') &&
|
||||
routesMod.includes("page_ai_openhub::non_ai_route_guard"),
|
||||
"non_ai_route_guard for admin"
|
||||
);
|
||||
|
||||
check(
|
||||
"OpenHub status API still exists",
|
||||
routesMod.includes('/api/page-ai/openhub/status", get(page_ai_openhub::status)'),
|
||||
"GET page_ai_openhub::status"
|
||||
"OpenHub active routes are retired from mnote-web",
|
||||
!routesMod.includes("page_ai_openhub") &&
|
||||
!routesMod.includes("/page-ai/openhub") &&
|
||||
!routesMod.includes("/api/page-ai/openhub"),
|
||||
"OpenHub route module and public routes removed from active router"
|
||||
);
|
||||
|
||||
check(
|
||||
@@ -380,11 +369,11 @@ check(
|
||||
);
|
||||
|
||||
check(
|
||||
"OpenHub is described as compatibility/admin boundary, not default chat",
|
||||
aiAdminRs.includes("迁移期兼容 / admin 边界") &&
|
||||
aiAdminRs.includes("不再作为默认 Page AI chat") &&
|
||||
"AI management no longer exposes OpenHub admin entry",
|
||||
!aiAdminRs.includes("/page-ai/openhub") &&
|
||||
!aiAdminRs.includes("打开 OpenHub Admin") &&
|
||||
!aiAdminRs.includes("OpenHub 仍是默认 Page AI"),
|
||||
"OpenHub remains only as migration compatibility boundary"
|
||||
"OpenHub admin links removed from active AI management UI"
|
||||
);
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
@@ -18,16 +18,12 @@ assert.match(env.MNOTE_WEB_CMD, /crates\/mnote-web\/browser/);
|
||||
assert.equal(env.FRONTEND_PORT, "3200");
|
||||
assert.equal(env.MNOTE_WEB_BIND, "0.0.0.0:3200");
|
||||
assert.equal(env.MNOTE_KNOWLEDGE_PROVIDER, "lightrag_legacy");
|
||||
assert.equal(env.ENABLE_OPENHUB, "1");
|
||||
assert.equal(env.CHECK_OPENHUB_HEALTH, "1");
|
||||
assert.equal(env.MNOTE_OPENCODE_BASE_URL, "http://127.0.0.1:4096");
|
||||
assert.equal(env.OPENHUB_OPENCODE_BASE_URL, "http://127.0.0.1:4096");
|
||||
assert.equal(env.OPENHUB_BACKEND_DIR, "/mnt/Data1T/Mnote_data/openhub/OpenHub/smart-query-backend");
|
||||
assert.equal(env.OPENHUB_OPENCODE_XDG_ROOT, "/mnt/Data1T/Mnote_data/openhub/opencode-runtime");
|
||||
assert.equal(env.OPENHUB_OPENCODE_HOME, "/mnt/Data1T/Mnote_data/openhub/opencode-home");
|
||||
assert.equal(env.MNOTE_OPENHUB_BASE_URL, "http://127.0.0.1:18080");
|
||||
assert.equal(env.OPENHUB_HEALTH_URL, "http://127.0.0.1:18080/api/health");
|
||||
assert.equal(env.MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE, "1");
|
||||
assert.equal(env.ENABLE_OPENHUB, undefined);
|
||||
assert.equal(env.CHECK_OPENHUB_HEALTH, undefined);
|
||||
assert.equal(env.OPENHUB_OPENCODE_BASE_URL, undefined);
|
||||
assert.equal(env.OPENHUB_BACKEND_DIR, undefined);
|
||||
assert.equal(env.MNOTE_OPENHUB_BASE_URL, undefined);
|
||||
assert.equal(env.MNOTE_CONTROL_PLANE_BACKEND, "libsql-local");
|
||||
assert.equal(env.MNOTE_TURSO_LOCAL_PATH, "/mnt/Data1T/Mnote_data/control-plane/control-plane-dev-hot-libsql.db");
|
||||
assert.equal(env.MNOTE_CONTROL_PLANE_DB_PATH, undefined);
|
||||
@@ -44,11 +40,6 @@ const loopbackEnv = buildDevHotEnv({
|
||||
assert.equal(loopbackEnv.MNOTE_WEB_BIND, "0.0.0.0:3300");
|
||||
assert.equal(loopbackEnv.MNOTE_WEB_CMD, "custom");
|
||||
|
||||
const skipEnv = buildDevHotEnv({
|
||||
SKIP_OPENHUB: "1",
|
||||
});
|
||||
assert.equal(skipEnv.ENABLE_OPENHUB, "");
|
||||
|
||||
const fixturesDisabledEnv = buildDevHotEnv({
|
||||
MNOTE_WEB_ALLOW_DEV_FIXTURES: "0",
|
||||
});
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
require("./task779-openhub-file-edit-document-pane-refresh-smoke.js");
|
||||
@@ -199,8 +199,8 @@ async function main() {
|
||||
const text = await res.text();
|
||||
if (!text.includes('createSidebarPageAiPiLabRuntime')) return { passed: false, reason: 'missing expected export' };
|
||||
if (!text.includes('data-page-ai-pi-lab-drawer')) return { passed: false, reason: 'missing independent drawer marker' };
|
||||
if (text.includes('attachPanelToDrawer') || text.includes('setOpenHubVisible')) return { passed: false, reason: 'runtime still references OpenHub drawer integration' };
|
||||
if (text.includes('data-page-ai-pi-lab-openhub-tab')) return { passed: false, reason: 'runtime still exposes OpenHub tab inside Pi Lab' };
|
||||
if (text.includes('attachPanelToDrawer') || text.includes('setOpenHubVisible')) return { passed: false, reason: 'runtime still references retired host drawer integration' };
|
||||
if (text.includes('data-page-ai-pi-lab-openhub-tab')) return { passed: false, reason: 'runtime still exposes retired host tab inside Pi Lab' };
|
||||
if (/setInterval\s*\(/.test(text)) return { passed: false, reason: 'runtime still has setInterval call' };
|
||||
if (!text.includes('NO setInterval polling')) return { passed: false, reason: 'missing NO setInterval polling comment' };
|
||||
if (!text.includes('EventSource')) return { passed: false, reason: 'missing EventSource for SSE' };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
// Pi Lab browser smoke
|
||||
// 验证 Pi Lab 默认独立悬浮入口 + MNote-native drawer,不复用 OpenHub drawer/provider tab/iframe。
|
||||
// 验证 Pi Lab 默认独立悬浮入口 + MNote-native drawer,不复用已退役 host drawer/provider tab/iframe。
|
||||
// 需要 mnote-web 已在运行;MNOTE_PAGE_AI_PI_LAB 默认开启,设为 0 时才强制关闭。
|
||||
|
||||
"use strict";
|
||||
@@ -90,16 +90,16 @@ async function main() {
|
||||
console.log(" 3. Pi Lab independent drawer visible");
|
||||
|
||||
const drawerEvidence = await page.evaluate(() => {
|
||||
const openHubDrawer = document.querySelector('[data-testid="wolai-page-ai-drawer"]');
|
||||
const legacyDrawer = document.querySelector('[data-testid="wolai-page-ai-drawer"]');
|
||||
const piDrawer = document.querySelector('[data-page-ai-pi-lab="drawer"]');
|
||||
const piPanel = document.querySelector('[data-page-ai-pi-lab="panel"]');
|
||||
const diagnostics = document.querySelector("[data-page-ai-pi-lab-diagnostics]");
|
||||
return {
|
||||
piDrawerVisible: Boolean(piDrawer && getComputedStyle(piDrawer).display !== "none"),
|
||||
panelInPiDrawer: Boolean(piDrawer && piPanel && piDrawer.contains(piPanel)),
|
||||
panelInOpenHubDrawer: Boolean(openHubDrawer && piPanel && openHubDrawer.contains(piPanel)),
|
||||
panelInLegacyDrawer: Boolean(legacyDrawer && piPanel && legacyDrawer.contains(piPanel)),
|
||||
piDrawerHasIframe: Boolean(piDrawer && piDrawer.querySelector("iframe")),
|
||||
openHubTabInPi: Boolean(piDrawer && piDrawer.querySelector("[data-page-ai-pi-lab-openhub-tab]")),
|
||||
retiredHostTabInPi: Boolean(piDrawer && piDrawer.querySelector("[data-page-ai-pi-lab-openhub-tab]")),
|
||||
contextChips: document.querySelectorAll("[data-page-ai-pi-lab-context-strip] [data-page-ai-pi-lab-context]").length,
|
||||
hasCurrentPageContext: Boolean(document.querySelector("[data-page-ai-pi-lab-current-page]")),
|
||||
hasChangedFilesContext: Boolean(document.querySelector("[data-page-ai-pi-lab-changed-files]")),
|
||||
@@ -112,9 +112,9 @@ async function main() {
|
||||
|
||||
assert(drawerEvidence.piDrawerVisible, "Pi Lab independent drawer should be visible");
|
||||
assert(drawerEvidence.panelInPiDrawer, "Pi Lab panel should be mounted inside independent Pi drawer");
|
||||
assert.equal(drawerEvidence.panelInOpenHubDrawer, false, "Pi Lab panel must not be inside OpenHub drawer");
|
||||
assert.equal(drawerEvidence.panelInLegacyDrawer, false, "Pi Lab panel must not be inside retired host drawer");
|
||||
assert.equal(drawerEvidence.piDrawerHasIframe, false, "Pi Lab drawer must not iframe a second app");
|
||||
assert.equal(drawerEvidence.openHubTabInPi, false, "Pi Lab drawer must not expose OpenHub provider tab");
|
||||
assert.equal(drawerEvidence.retiredHostTabInPi, false, "Pi Lab drawer must not expose retired host provider tab");
|
||||
assert(drawerEvidence.contextChips >= 3, `expected context strip chips, got ${drawerEvidence.contextChips}`);
|
||||
assert(drawerEvidence.hasCurrentPageContext, "context strip should retain current page binding");
|
||||
assert(drawerEvidence.hasChangedFilesContext, "context strip should retain changed files count");
|
||||
@@ -298,30 +298,6 @@ async function main() {
|
||||
assert.notEqual(runtimeEvidence.changedFiles, "0", "changed files chip should be non-zero");
|
||||
console.log(" 6. Real Pi Rust send, abort, deny, citation, receipt and diff evidence visible");
|
||||
|
||||
const openHubEvidence = await page.evaluate(async () => {
|
||||
const api = window.__mnoteSidebarPageAiRuntime;
|
||||
if (api && typeof api.openPageAiDrawer === "function") {
|
||||
api.openPageAiDrawer();
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 400));
|
||||
const openHubDrawer = document.querySelector('[data-testid="wolai-page-ai-drawer"]');
|
||||
const openHubFrame = document.querySelector("[data-page-ai-openhub-iframe]");
|
||||
const piDrawer = document.querySelector('[data-page-ai-pi-lab="drawer"]');
|
||||
return {
|
||||
openHubApiExists: Boolean(api && typeof api.openPageAiDrawer === "function"),
|
||||
openHubDrawerExists: Boolean(openHubDrawer),
|
||||
openHubHost: openHubDrawer?.getAttribute("data-page-ai-openhub-host") || "",
|
||||
openHubFrameExists: Boolean(openHubFrame),
|
||||
piStillIndependent: Boolean(piDrawer && openHubDrawer && !openHubDrawer.contains(piDrawer)),
|
||||
};
|
||||
});
|
||||
assert(openHubEvidence.openHubApiExists, "OpenHub drawer API should still exist");
|
||||
assert(openHubEvidence.openHubDrawerExists, "OpenHub drawer should still open independently");
|
||||
assert.equal(openHubEvidence.openHubHost, "true", "OpenHub drawer should still be the default host");
|
||||
assert(openHubEvidence.openHubFrameExists, "OpenHub iframe should still exist outside Pi Lab");
|
||||
assert(openHubEvidence.piStillIndependent, "Pi Lab drawer should remain outside OpenHub drawer");
|
||||
console.log(" 7. OpenHub default drawer still works independently");
|
||||
|
||||
fs.mkdirSync(path.dirname(SCREENSHOT), { recursive: true });
|
||||
await page.locator('[data-page-ai-pi-lab="drawer"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await page.screenshot({ path: SCREENSHOT, fullPage: false });
|
||||
|
||||
@@ -350,18 +350,18 @@ async function main() {
|
||||
console.log(" ✅ abort reflected in UI state");
|
||||
|
||||
const domEvidence = await page.evaluate(() => {
|
||||
const openHubDrawer = document.querySelector('[data-testid="wolai-page-ai-drawer"]');
|
||||
const retiredHostDrawer = document.querySelector('[data-testid="wolai-page-ai-drawer"]');
|
||||
const piDrawer = document.querySelector('[data-page-ai-pi-lab="drawer"]');
|
||||
return {
|
||||
piDrawerVisible: Boolean(piDrawer && getComputedStyle(piDrawer).display !== "none"),
|
||||
piDrawerHasIframe: Boolean(piDrawer && piDrawer.querySelector("iframe")),
|
||||
piInsideOpenHub: Boolean(openHubDrawer && piDrawer && openHubDrawer.contains(piDrawer)),
|
||||
piInsideRetiredHost: Boolean(retiredHostDrawer && piDrawer && retiredHostDrawer.contains(piDrawer)),
|
||||
model: document.querySelector("[data-page-ai-pi-lab-model-chip]")?.textContent || "",
|
||||
};
|
||||
});
|
||||
assert(domEvidence.piDrawerVisible, "Pi drawer should remain visible");
|
||||
assert.equal(domEvidence.piDrawerHasIframe, false, "Pi drawer must not iframe a second app");
|
||||
assert.equal(domEvidence.piInsideOpenHub, false, "Pi drawer must not be inside OpenHub drawer");
|
||||
assert.equal(domEvidence.piInsideRetiredHost, false, "Pi drawer must not be inside retired host drawer");
|
||||
assert(domEvidence.model.includes("omniroute/gpt-5.4-mini"), `model chip mismatch: ${domEvidence.model}`);
|
||||
console.log(" ✅ Pi Lab remains native, independent and non-iframe");
|
||||
|
||||
|
||||
@@ -115,15 +115,15 @@ const checks = [
|
||||
['runtime checks enabled flag via status API but does not hide launcher behind it', runtime.includes('enabled') && runtime.includes('checkStatus')],
|
||||
['runtime renders independent Pi Lab drawer', runtime.includes('data-page-ai-pi-lab-drawer') && runtime.includes('data-page-ai-pi-lab') && runtime.includes('drawer')],
|
||||
['runtime creates drawer through ensureDrawer', runtime.includes('function ensureDrawer') && runtime.includes('setDrawerVisible')],
|
||||
['runtime does NOT mount inside OpenHub drawer', !runtime.includes('attachPanelToDrawer') && !runtime.includes('wolai-page-ai-drawer')],
|
||||
['runtime does NOT toggle OpenHub iframe visibility', !runtime.includes('setOpenHubVisible') && !runtime.includes('data-page-ai-openhub-frame-wrap')],
|
||||
['runtime has no OpenHub provider tab inside Pi Lab', !runtime.includes('data-page-ai-pi-lab-openhub-tab')],
|
||||
['runtime does NOT mount inside retired host drawer', !runtime.includes('attachPanelToDrawer') && !runtime.includes('wolai-page-ai-drawer')],
|
||||
['runtime does NOT toggle retired host iframe visibility', !runtime.includes('setOpenHubVisible') && !runtime.includes('data-page-ai-openhub-frame-wrap')],
|
||||
['runtime has no retired host provider tab inside Pi Lab', !runtime.includes('data-page-ai-pi-lab-openhub-tab')],
|
||||
['runtime has context strip chips', runtime.includes('data-page-ai-pi-lab-context-strip') && runtime.includes('data-page-ai-pi-lab-current-page') && runtime.includes('data-page-ai-pi-lab-allowed-roots') && runtime.includes('data-page-ai-pi-lab-lightrag')],
|
||||
['runtime collapses secondary context/settings like OpenHub chrome', runtime.includes('<details class="wolai-page-ai-pi-lab-settings"') && runtime.includes('<details class="wolai-page-ai-pi-lab-context-strip"')],
|
||||
['runtime collapses secondary context/settings like compact chat chrome', runtime.includes('<details class="wolai-page-ai-pi-lab-settings"') && runtime.includes('<details class="wolai-page-ai-pi-lab-context-strip"')],
|
||||
['runtime settings gear opens AI management page', runtime.includes('data-page-ai-pi-lab-open-settings title="AI 管理"') && runtime.includes("window.location.assign('/user/ai#ai-admin-access')")],
|
||||
['runtime keeps only meaningful top commandbar buttons', runtime.includes('data-page-ai-pi-lab-new') && runtime.includes('data-page-ai-pi-lab-history') && runtime.includes('data-page-ai-pi-lab-btn-clear') && runtime.includes('data-page-ai-pi-lab-open-settings')],
|
||||
['runtime removes no-op top commandbar buttons', !runtime.includes('data-page-ai-pi-lab-open title=') && !runtime.includes('data-page-ai-pi-lab-toggle-artifacts') && !runtime.includes('data-page-ai-pi-lab-clock') && !runtime.includes('data-page-ai-pi-lab-notify')],
|
||||
['runtime has OpenHub-style left history drawer markers', runtime.includes('data-page-ai-pi-lab-history-layer') && runtime.includes('wolai-page-ai-pi-lab-history-scrim') && runtime.includes('历史对话')],
|
||||
['runtime has left history drawer markers', runtime.includes('data-page-ai-pi-lab-history-layer') && runtime.includes('wolai-page-ai-pi-lab-history-scrim') && runtime.includes('历史对话')],
|
||||
['runtime history drawer supports refresh/delete/export/clear', runtime.includes('data-page-ai-pi-lab-history-refresh') && runtime.includes('data-page-ai-pi-lab-history-delete') && runtime.includes('data-page-ai-pi-lab-history-export') && runtime.includes('data-page-ai-pi-lab-history-clear')],
|
||||
['runtime does not expose manual Pi runtime start button', !runtime.includes('data-page-ai-pi-lab-btn-start') && !runtime.includes('启动 Pi runtime') && !runtime.includes('预启动 Pi 会话')],
|
||||
['runtime auto starts current page Pi session when drawer opens', showPiLabBody.includes('checkStatus().then(function ()') && showPiLabBody.includes('startRuntime().then')],
|
||||
@@ -192,7 +192,7 @@ const checks = [
|
||||
['route keeps OmniRoute streaming usage enabled', route.includes('"supportsUsageInStreaming": true') && !route.includes('"supportsUsageInStreaming": false')],
|
||||
['route sends Pi Rust directly to configured OmniRoute base URL', route.includes('"baseUrl": omniroute_base_url()') && !route.includes('omniroute_proxy_chat_completions') && !routesMod.includes('/api/page-ai/pi/omniroute-proxy/')],
|
||||
['route defaults to Pi Rust runtime implementation', route.includes('PI_LAB_RUNTIME_IMPL_RUST') && route.includes('"pi-rust"') && route.includes('MNOTE_PAGE_AI_PI_RUST_BIN')],
|
||||
['route keeps TS Pi as explicit fallback only', route.includes('PI_LAB_RUNTIME_IMPL_TS') && route.includes('MNOTE_PAGE_AI_PI_TS_BIN')],
|
||||
['route removes TS Pi fallback', !route.includes('PI_LAB_RUNTIME_IMPL_TS') && !route.includes('MNOTE_PAGE_AI_PI_TS_BIN') && !route.includes('legacy-ts') && !route.includes('pi-ts')],
|
||||
['route rejects arbitrary cookie_header auth bypass', route.includes('page_ai_pi_lab_invalid_session_cookie') && !route.includes('context.auth.cookie_header.is_some() {\n return Ok') ],
|
||||
['route stores Pi Lab session owner id', route.includes('mnote_user_id') && route.includes('ensure_session_owner')],
|
||||
['route validates session owner on send/abort/tool/events', route.includes('get_session_for_context')],
|
||||
|
||||
@@ -22,7 +22,7 @@ const ROOT_URI = process.env.MNOTE_PI_USER_EXACT_ROOT_URI || `file://${ROOT_PATH
|
||||
const PAGE_PATH = `pi-user-exact-${STAMP}.md`;
|
||||
const MODEL_PROVIDER = process.env.MNOTE_PI_USER_EXACT_MODEL_PROVIDER || "omniroute";
|
||||
const MODEL_ID = process.env.MNOTE_PI_USER_EXACT_MODEL_ID || "gpt-5.4-mini";
|
||||
const USER_PROMPT = "我们当前是从pi ts官方版,切换到了pi agdnt rust版,我希望你全面测试当前的skill/扩展/mcp/工具等是否正常,我当前已经是授权完全访问了。";
|
||||
const USER_PROMPT = "我们当前已经切换到 Pi Rust 官方版,我希望你全面测试当前的 skill/扩展/mcp/工具等是否正常,我当前已经是授权完全访问了。";
|
||||
const CHROMIUM_EXECUTABLE = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE
|
||||
|| (fs.existsSync("/usr/bin/chromium-browser") ? "/usr/bin/chromium-browser" : "")
|
||||
|| (fs.existsSync("/usr/bin/chromium") ? "/usr/bin/chromium" : "")
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const repoRoot = path.resolve(__dirname, '..');
|
||||
const runtimePath = path.join(repoRoot, 'rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js');
|
||||
const routePath = path.join(repoRoot, 'rust/crates/mnote-web/src/routes/page_ai_openhub.rs');
|
||||
const routesModPath = path.join(repoRoot, 'rust/crates/mnote-web/src/routes/mod.rs');
|
||||
|
||||
const runtime = fs.readFileSync(runtimePath, 'utf8');
|
||||
const route = fs.readFileSync(routePath, 'utf8');
|
||||
const routesMod = fs.readFileSync(routesModPath, 'utf8');
|
||||
|
||||
const checks = [
|
||||
['openhub host enabled by default', runtime.includes('function pageAiOpenHubHostEnabled()') && runtime.includes('return true;') && runtime.includes('data-page-ai-openhub-host')],
|
||||
['openhub bootstrap api', runtime.includes('/api/page-ai/openhub/bootstrap') && routesMod.includes('/api/page-ai/openhub/bootstrap')],
|
||||
['mnote auth truth copy', route.includes('"authTruth": "mnote_session"') && runtime.includes('拒绝 OpenHub JWT/localStorage')],
|
||||
['openhub user key derived', route.includes('"openhubUserKey"') && route.includes('stable_hash("openhub_user"')],
|
||||
['workspace session tool scope', route.includes('"openhubSessionScope"') && route.includes('"skillScope"') && route.includes('"mcpScope"') && route.includes('"toolPermissionScope"')],
|
||||
['proxy injects mnote scope headers', route.includes('add_mnote_scope_headers') && route.includes('x-mnote-user-key') && route.includes('x-mnote-user-id') && route.includes('x-mnote-display-name') && route.includes('x-mnote-workspace-key') && route.includes('x-mnote-session-scope') && route.includes('x-mnote-tool-permission-scope') && route.includes('x-mnote-weknora-tool-scope')],
|
||||
['proxy carries full mnote scope internally', route.includes('mnoteScope') && route.includes('mnote_scope_from_query') && route.includes('proxy_query_without_internal_scope')],
|
||||
['snake case bootstrap scope aliases', route.includes('"openhub_user_key"') && route.includes('"workspace_key"') && route.includes('"session_scope"') && route.includes('"tool_permission_scope"')],
|
||||
['weknora tool scope', route.includes('"weknoraToolScope"') && route.includes('"weknora_tool_scope"') && runtime.includes('weknora_tool_scope')],
|
||||
['layered status endpoint', route.includes('"openhub_fastapi"') && route.includes('"opencode"') && route.includes('"weknora"') && route.includes('"mnote_binding"')],
|
||||
['degraded external services are explicit', route.includes('"degraded"') && route.includes('reachable') && route.includes('upstream_http_')],
|
||||
['ai proxy boundary', route.includes('pub async fn ai_proxy') && routesMod.includes('/page-ai/openhub/ai/{*path}')],
|
||||
['no visible opencode fallback action', !runtime.includes('openhub-use-opencode-fallback')],
|
||||
['non ai route guard', route.includes('page_ai_openhub_non_ai_route_guarded') && routesMod.includes('/page-ai/openhub/knowledge/{*path}') && routesMod.includes('/page-ai/openhub/file/{*path}') && routesMod.includes('/page-ai/openhub/git/{*path}')],
|
||||
['static ai shell', route.includes('data-mnote-openhub-ai-shell="static-boundary"') && routesMod.includes('/page-ai/openhub/ai')],
|
||||
['openhub quick address actions are native openhub source not proxy overlay', !route.includes('data-mnote-openhub-ai-quick-actions') && !route.includes('data-mnote-openhub-send-current-tab') && !route.includes('data-mnote-openhub-send-current-folder')],
|
||||
['openhub quick address bridge uses active tab only', runtime.includes("message.source === 'openhub-ai'") && runtime.includes("message.type === 'mnote:get-active-tab-address'") && runtime.includes('pageAiCurrentActiveTabEditorTarget') && runtime.includes("type: 'mnote:active-tab-address'")],
|
||||
['openhub folder action sends folder address only', runtime.includes('pageAiCurrentActiveTabAddressPayload') && runtime.includes('folderUrl') && runtime.includes("kind === 'folder' ? addressPayload.folderUrl : addressPayload.tabUrl")],
|
||||
['openhub diagnostics are hidden from user chrome', runtime.includes('wolai-page-ai-openhub-diagnostics') && runtime.includes('data-page-ai-openhub-bootstrap-copy hidden aria-hidden="true"')],
|
||||
['no duplicate mnote openhub shell header', !runtime.includes('<h2 class="wolai-page-ai-title">OpenHub AI</h2>') && !runtime.includes('data-page-ai-openhub-runtime-status>OpenHub host boundary 静态占位')],
|
||||
['no visible openhub legacy fallback switch', !runtime.includes("localStorage.setItem('mnote.page_ai.openhub_host', '0')")],
|
||||
['openhub fallback payload has no legacy route', route.includes('"enabled": false') && !route.includes('"legacyRoute": "/page-ai/opencode"')],
|
||||
];
|
||||
|
||||
const failed = checks.filter(([, ok]) => !ok);
|
||||
if (failed.length) {
|
||||
console.error('Page AI OpenHub host static smoke failed:');
|
||||
for (const [name] of failed) console.error(`- ${name}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('Page AI OpenHub host static smoke passed.');
|
||||
@@ -1,98 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const openHubRoot =
|
||||
process.env.OPENHUB_RESEARCH_ROOT || '/mnt/Data1T/Mnote_data/openhub/OpenHub';
|
||||
const backendRoot = path.join(openHubRoot, 'smart-query-backend');
|
||||
const disableEnv = 'MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE';
|
||||
const legacyDisableEnv = 'OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE';
|
||||
|
||||
function read(relativePath) {
|
||||
return fs.readFileSync(path.join(backendRoot, relativePath), 'utf8');
|
||||
}
|
||||
|
||||
function assertCheck(name, passed) {
|
||||
if (!passed) failures.push(name);
|
||||
}
|
||||
|
||||
function includesAll(source, needles) {
|
||||
return needles.every((needle) => source.includes(needle));
|
||||
}
|
||||
|
||||
const failures = [];
|
||||
|
||||
const gitSnapshot = read('app/services/git_snapshot.py');
|
||||
const stream = read('app/services/stream.py');
|
||||
const taskExecutor = read('app/services/task_executor.py');
|
||||
const session = read('app/api/session.py');
|
||||
|
||||
assertCheck(
|
||||
'git_snapshot exposes MNote disable env and legacy equivalent',
|
||||
includesAll(gitSnapshot, [disableEnv, legacyDisableEnv, 'def is_snapshot_restore_disabled'])
|
||||
);
|
||||
|
||||
assertCheck(
|
||||
'git_snapshot low-level git write command guard covers destructive/write commands',
|
||||
includesAll(gitSnapshot, [
|
||||
'_GIT_WRITE_COMMANDS',
|
||||
'"init"',
|
||||
'"config"',
|
||||
'"add"',
|
||||
'"commit"',
|
||||
'"checkout"',
|
||||
'"restore"',
|
||||
'"reset"',
|
||||
'"revert"',
|
||||
'is_snapshot_restore_disabled() and _is_git_write(args)',
|
||||
])
|
||||
);
|
||||
|
||||
assertCheck(
|
||||
'git_snapshot high-level write APIs short-circuit when disabled',
|
||||
includesAll(gitSnapshot, [
|
||||
'def init_git_repo',
|
||||
'def create_snapshot',
|
||||
'def create_restore_snapshot',
|
||||
'def restore_all',
|
||||
'def restore_single_file',
|
||||
'disabled by {GIT_SNAPSHOT_RESTORE_DISABLE_ENV}',
|
||||
])
|
||||
);
|
||||
|
||||
assertCheck(
|
||||
'stream automatic snapshot path is guarded before init/create_snapshot',
|
||||
stream.includes('not git_snap.is_snapshot_restore_disabled()') &&
|
||||
stream.includes('git_snap.init_git_repo') &&
|
||||
stream.includes('git_snap.create_snapshot') &&
|
||||
stream.includes('Git snapshot skipped: disabled by MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE')
|
||||
);
|
||||
|
||||
assertCheck(
|
||||
'task_executor automatic snapshot path is guarded before init/create_snapshot',
|
||||
taskExecutor.includes('not git_snapshot.is_snapshot_restore_disabled()') &&
|
||||
taskExecutor.includes('git_snapshot.init_git_repo') &&
|
||||
taskExecutor.includes('git_snapshot.create_snapshot') &&
|
||||
taskExecutor.includes('Git snapshot skipped: disabled by MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE')
|
||||
);
|
||||
|
||||
const restoreRouteGuardCount = (
|
||||
session.match(/git_snapshot\.is_snapshot_restore_disabled\(\)/g) || []
|
||||
).length;
|
||||
assertCheck(
|
||||
'session restore routes reject when disabled',
|
||||
restoreRouteGuardCount >= 2 &&
|
||||
session.includes('Git snapshot/restore 写链已由 MNote 禁用') &&
|
||||
session.includes('git_snapshot.restore_all') &&
|
||||
session.includes('git_snapshot.restore_single_file') &&
|
||||
session.includes('git_snapshot.create_restore_snapshot')
|
||||
);
|
||||
|
||||
if (failures.length) {
|
||||
console.error('OpenHub git snapshot/restore guard static smoke failed:');
|
||||
for (const failure of failures) console.error(`- ${failure}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('OpenHub git snapshot/restore guard static smoke passed.');
|
||||
@@ -1,135 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const openHubRoot =
|
||||
process.env.OPENHUB_RESEARCH_ROOT || '/mnt/Data1T/Mnote_data/openhub/OpenHub';
|
||||
const backendRoot = path.join(openHubRoot, 'smart-query-backend');
|
||||
|
||||
function read(relativePath) {
|
||||
return fs.readFileSync(path.join(backendRoot, relativePath), 'utf8');
|
||||
}
|
||||
|
||||
function assertCheck(name, passed) {
|
||||
if (!passed) failures.push(name);
|
||||
}
|
||||
|
||||
function includesAll(source, needles) {
|
||||
return needles.every((needle) => source.includes(needle));
|
||||
}
|
||||
|
||||
const failures = [];
|
||||
|
||||
const mnoteScope = read('app/core/mnote_scope.py');
|
||||
const auth = read('app/core/auth.py');
|
||||
const query = read('app/api/query.py');
|
||||
const session = read('app/api/session.py');
|
||||
const stream = read('app/services/stream.py');
|
||||
|
||||
const requiredHeaders = [
|
||||
'X-MNote-User-Key',
|
||||
'X-MNote-Workspace-Key',
|
||||
'X-MNote-Session-Scope',
|
||||
'X-MNote-Root-Uri',
|
||||
'X-MNote-Page-Resource-Id',
|
||||
'X-MNote-Tool-Permission-Scope',
|
||||
'X-MNote-WeKnora-Tool-Scope',
|
||||
];
|
||||
|
||||
assertCheck(
|
||||
'mnote scope helper exists with all controlled headers',
|
||||
includesAll(mnoteScope, [
|
||||
'MNOTE_HOST_TRUTH = "mnote_controlled_headers"',
|
||||
'def derive_mnote_user',
|
||||
'def resolve_user_workspace',
|
||||
'def resolve_user_asset_workspace',
|
||||
'def get_mnote_scope_metadata',
|
||||
'X-MNote-Display-Name',
|
||||
...requiredHeaders,
|
||||
])
|
||||
);
|
||||
|
||||
assertCheck(
|
||||
'derived user is stable per MNote user and not tied to workspace scope',
|
||||
includesAll(mnoteScope, [
|
||||
'def _stable_openhub_user_id(user_key: str)',
|
||||
'mnote_openhub_user_id',
|
||||
'user_key',
|
||||
'workspace_key',
|
||||
'display_name',
|
||||
'get_user_by_username',
|
||||
'"openhub_user_id"',
|
||||
'"openhub_username"',
|
||||
]) &&
|
||||
!mnoteScope.includes('def _stable_openhub_user_id(user_key: str, workspace_key: str)') &&
|
||||
!mnoteScope.includes('_stable_openhub_user_id(user_key, workspace_key)') &&
|
||||
!mnoteScope.includes('mnote_shared_user')
|
||||
);
|
||||
|
||||
assertCheck(
|
||||
'session and workspace scope are derived from MNote scope',
|
||||
includesAll(mnoteScope, [
|
||||
'def _stable_openhub_session_id',
|
||||
'mnote_openhub_session',
|
||||
'openhub_workspace_scope',
|
||||
'session_scope',
|
||||
'root_uri',
|
||||
'workspace_path',
|
||||
'_root_uri_to_workspace_path',
|
||||
])
|
||||
);
|
||||
|
||||
assertCheck(
|
||||
'tool and weknora scopes are retained as MNote scope metadata',
|
||||
includesAll(mnoteScope, [
|
||||
'tool_permission_scope',
|
||||
'weknora_tool_scope',
|
||||
'provisioned_workspace_path',
|
||||
'_parse_scope_header',
|
||||
'"mnote_scope"',
|
||||
'"source_headers"',
|
||||
])
|
||||
);
|
||||
|
||||
assertCheck(
|
||||
'MNote host mode rejects frontend JWT/localStorage truth',
|
||||
includesAll(mnoteScope, [
|
||||
'REJECTED_MNOTE_HOST_TRUTHS',
|
||||
'localStorage',
|
||||
'OpenHub JWT',
|
||||
'frontend JWT',
|
||||
]) &&
|
||||
auth.includes('derive_mnote_user(request)') &&
|
||||
auth.includes('HTTPBearer(auto_error=False)') &&
|
||||
auth.indexOf('derive_mnote_user(request)') < auth.indexOf('validate_token(token)')
|
||||
);
|
||||
|
||||
assertCheck(
|
||||
'query/session entries consume derived workspace and scope',
|
||||
query.includes('resolve_user_workspace(current_user)') &&
|
||||
query.includes('not current_user.get("mnote_host_mode")') &&
|
||||
query.includes('mnote_scope = get_mnote_scope_metadata(current_user)') &&
|
||||
session.includes('resolve_user_workspace(current_user)') &&
|
||||
session.includes('mnote_scope=get_mnote_scope_metadata(current_user)')
|
||||
);
|
||||
|
||||
assertCheck(
|
||||
'stream persists MNote scope metadata with user message',
|
||||
includesAll(stream, [
|
||||
'mnote_scope: Optional[dict] = None',
|
||||
'metadata["mnote_scope"] = mnote_scope',
|
||||
'database.save_session',
|
||||
'user_id',
|
||||
'workspace_path',
|
||||
'not mnote_scope',
|
||||
])
|
||||
);
|
||||
|
||||
if (failures.length) {
|
||||
console.error('OpenHub MNote scope bridge static smoke failed:');
|
||||
for (const failure of failures) console.error(`- ${failure}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('OpenHub MNote scope bridge static smoke passed.');
|
||||
@@ -1,553 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { chromium } = require("playwright");
|
||||
const {
|
||||
BASE_URL,
|
||||
UI_TIMEOUT_MS,
|
||||
ensureAuthenticated,
|
||||
getViewerIdentity,
|
||||
} = require("./tree-shell-smoke-helpers");
|
||||
|
||||
const TASK = "task773-page-ai-openhub-browser-smoke";
|
||||
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "7-68-runtime");
|
||||
const SCREENSHOT_PATH = path.join(OUTPUT_DIR, "page-ai-openhub-browser.png");
|
||||
const RESULT_PATH = path.join(OUTPUT_DIR, "page-ai-openhub-browser-result.json");
|
||||
const TEST_EMAIL = "mnote.e2e@example.com";
|
||||
const TEST_PASSWORD = "MnoteE2E123!";
|
||||
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|
||||
|| ["/usr/bin/chromium-browser", "/usr/bin/chromium", "/usr/bin/google-chrome-stable", "/usr/bin/google-chrome"]
|
||||
.find((candidate) => fs.existsSync(candidate));
|
||||
|
||||
class SmokeFailure extends Error {
|
||||
constructor(kind, message, details = {}) {
|
||||
super(message);
|
||||
this.name = "SmokeFailure";
|
||||
this.kind = kind;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
function visibleSelectorScript(selectors) {
|
||||
return selectors.some((selector) => {
|
||||
const nodes = Array.from(document.querySelectorAll(selector));
|
||||
return nodes.some((node) => {
|
||||
if (!(node instanceof HTMLElement)) return false;
|
||||
const style = window.getComputedStyle(node);
|
||||
const rect = node.getBoundingClientRect();
|
||||
return !node.hidden && style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function assertServiceReachable(baseUrl) {
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(`${baseUrl}/health`, { redirect: "manual", signal: AbortSignal.timeout(6_000) });
|
||||
} catch (error) {
|
||||
throw new SmokeFailure("service_unreachable", `MNote 3000 服务不可达:${error.message}`, { baseUrl });
|
||||
}
|
||||
if (!response.ok && response.status !== 303) {
|
||||
throw new SmokeFailure("service_unreachable", `MNote /health 返回异常:HTTP ${response.status}`, { baseUrl });
|
||||
}
|
||||
}
|
||||
|
||||
async function loginWithUiFirst(page, requestContext) {
|
||||
await page.goto(`${BASE_URL}/auth`, { waitUntil: "commit", timeout: UI_TIMEOUT_MS });
|
||||
if (!page.url().includes("/auth")) {
|
||||
return getViewerIdentity(requestContext);
|
||||
}
|
||||
|
||||
const quickLogin = page.getByRole("button", { name: "测试账号快速登录" });
|
||||
if (await quickLogin.isVisible({ timeout: 8_000 }).catch(() => false)) {
|
||||
await quickLogin.click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForURL((url) => !url.toString().includes("/auth"), { timeout: UI_TIMEOUT_MS, waitUntil: "commit" }).catch(() => undefined);
|
||||
}
|
||||
|
||||
if (page.url().includes("/auth")) {
|
||||
const account = page.locator('input[name="account"], input[type="email"], input[data-auth-field="account"]').first();
|
||||
const password = page.locator('input[name="password"], input[type="password"]').first();
|
||||
const submit = page.getByRole("button", { name: /^登录$|账号登录|登录$/ }).first();
|
||||
if (!(await account.isVisible({ timeout: 2_000 }).catch(() => false)) || !(await password.isVisible({ timeout: 2_000 }).catch(() => false))) {
|
||||
throw new SmokeFailure("auth_failed", "认证页未出现快速登录,也找不到账号密码输入框", { url: page.url() });
|
||||
}
|
||||
await account.fill(TEST_EMAIL, { timeout: UI_TIMEOUT_MS });
|
||||
await password.fill(TEST_PASSWORD, { timeout: UI_TIMEOUT_MS });
|
||||
await submit.click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForURL((url) => !url.toString().includes("/auth"), { timeout: UI_TIMEOUT_MS, waitUntil: "commit" }).catch(() => undefined);
|
||||
}
|
||||
|
||||
if (page.url().includes("/auth")) {
|
||||
throw new SmokeFailure("auth_failed", "测试账号登录后仍停留在 /auth", { url: page.url() });
|
||||
}
|
||||
|
||||
try {
|
||||
return await getViewerIdentity(requestContext);
|
||||
} catch (error) {
|
||||
throw new SmokeFailure("auth_failed", `登录后 whoami 仍不可用:${error.message}`, { url: page.url() });
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForVisibleAny(page, selectors, label) {
|
||||
try {
|
||||
await page.waitForFunction(visibleSelectorScript, selectors, { timeout: UI_TIMEOUT_MS });
|
||||
} catch (error) {
|
||||
throw new SmokeFailure("selector_missing", `${label} 不可见。候选 selector: ${selectors.join(", ")}`, {
|
||||
selectors,
|
||||
cause: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function openPageAiDrawer(page) {
|
||||
await page.goto(BASE_URL, { waitUntil: "commit", timeout: UI_TIMEOUT_MS });
|
||||
if (page.url().includes("/auth")) {
|
||||
throw new SmokeFailure("auth_failed", "打开主页后被重定向到 /auth,登录态未生效", { url: page.url() });
|
||||
}
|
||||
|
||||
await page.evaluate(() => {
|
||||
try {
|
||||
localStorage.removeItem("mnote.page_ai.openhub_host");
|
||||
localStorage.setItem("mnote.page_ai.openhub_host", "1");
|
||||
} catch {}
|
||||
});
|
||||
await waitForVisibleAny(page, ["[data-testid='wolai-floating-ai']", "[data-testid='wolai-page-ai-drawer']"], "Page AI 入口");
|
||||
|
||||
const drawerVisible = await page.locator("[data-testid='wolai-page-ai-drawer']").first().isVisible().catch(() => false);
|
||||
if (!drawerVisible) {
|
||||
await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS });
|
||||
}
|
||||
await waitForVisibleAny(page, ["[data-testid='wolai-page-ai-drawer']"], "Page AI drawer");
|
||||
}
|
||||
|
||||
async function validateOpenHubQuickActions(page) {
|
||||
const frameHandle = await page.locator("iframe[data-page-ai-openhub-iframe]").elementHandle({ timeout: UI_TIMEOUT_MS });
|
||||
const frame = frameHandle ? await frameHandle.contentFrame() : null;
|
||||
if (!frame) {
|
||||
throw new SmokeFailure("quick_action_failed", "OpenHub iframe frame 不可用", { reason: "iframe_frame_missing" });
|
||||
}
|
||||
const tabButton = frame.locator("[data-mnote-openhub-current-tab-toggle]").first();
|
||||
const folderButton = frame.locator("[data-mnote-openhub-current-folder-toggle]").first();
|
||||
await tabButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await folderButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
const fillVisibleInput = async (value) => frame.evaluate((nextValue) => {
|
||||
const selectors = [
|
||||
"textarea[placeholder*='输入']",
|
||||
"textarea",
|
||||
"[contenteditable='true']",
|
||||
"input[type='text'][placeholder*='输入']",
|
||||
"input[type='text']",
|
||||
".ant-input",
|
||||
];
|
||||
const visible = (node) => {
|
||||
if (!(node instanceof HTMLElement)) return false;
|
||||
const style = window.getComputedStyle(node);
|
||||
const rect = node.getBoundingClientRect();
|
||||
return !node.hidden && style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0;
|
||||
};
|
||||
for (const selector of selectors) {
|
||||
const nodes = Array.from(document.querySelectorAll(selector)).filter(visible);
|
||||
if (!nodes.length) continue;
|
||||
const node = nodes[nodes.length - 1];
|
||||
node.focus();
|
||||
if (node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(node), "value");
|
||||
if (descriptor && typeof descriptor.set === "function") {
|
||||
descriptor.set.call(node, nextValue);
|
||||
} else {
|
||||
node.value = nextValue;
|
||||
}
|
||||
} else {
|
||||
node.textContent = nextValue;
|
||||
}
|
||||
node.dispatchEvent(new InputEvent("input", { bubbles: true, data: nextValue, inputType: "insertText" }));
|
||||
node.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}, value);
|
||||
const readVisibleInput = async () => frame.evaluate(() => {
|
||||
const selectors = [
|
||||
"textarea[placeholder*='输入']",
|
||||
"textarea",
|
||||
"[contenteditable='true']",
|
||||
"input[type='text'][placeholder*='输入']",
|
||||
"input[type='text']",
|
||||
".ant-input",
|
||||
];
|
||||
const visible = (node) => {
|
||||
if (!(node instanceof HTMLElement)) return false;
|
||||
const style = window.getComputedStyle(node);
|
||||
const rect = node.getBoundingClientRect();
|
||||
return !node.hidden && style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0;
|
||||
};
|
||||
for (const selector of selectors) {
|
||||
const nodes = Array.from(document.querySelectorAll(selector)).filter(visible);
|
||||
if (!nodes.length) continue;
|
||||
const node = nodes[nodes.length - 1];
|
||||
if (node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement) return node.value || "";
|
||||
return node.textContent || "";
|
||||
}
|
||||
return "";
|
||||
});
|
||||
const hasInput = await fillVisibleInput("");
|
||||
if (!hasInput) {
|
||||
throw new SmokeFailure("quick_action_failed", "OpenHub iframe 内未找到可见聊天输入框", {});
|
||||
}
|
||||
const prompt = `MNOTE_NATIVE_CONTEXT_SMOKE_${Date.now()}`;
|
||||
let capturedBody = null;
|
||||
await page.route("**/page-ai/openhub/ai/api/query/stream**", async (route) => {
|
||||
capturedBody = JSON.parse(route.request().postData() || "{}");
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "text/event-stream; charset=utf-8" },
|
||||
body: [
|
||||
`data: ${JSON.stringify({ type: "session", conversation_id: "task773-native-context", done: false })}`,
|
||||
"",
|
||||
`data: ${JSON.stringify({ type: "message_complete", done: true })}`,
|
||||
"",
|
||||
].join("\n"),
|
||||
});
|
||||
});
|
||||
await fillVisibleInput(prompt);
|
||||
await tabButton.click({ timeout: UI_TIMEOUT_MS });
|
||||
const inputTextAfterToggle = (await readVisibleInput()).trim();
|
||||
const tabSelected = await tabButton.evaluate((node) => node.classList.contains("ant-btn-primary") || node.getAttribute("type") === "button" && node.matches(".ant-btn-primary"));
|
||||
const folderSelectedAfterTab = await folderButton.evaluate((node) => node.classList.contains("ant-btn-primary"));
|
||||
await page.keyboard.press("Enter");
|
||||
await page.waitForFunction(() => window.__mnoteOpenHubTask773RequestCaptured === true, undefined, { timeout: 100 }).catch(() => undefined);
|
||||
const started = Date.now();
|
||||
while (!capturedBody && Date.now() - started < UI_TIMEOUT_MS) {
|
||||
await page.waitForTimeout(100);
|
||||
}
|
||||
const activeEditor = await page.evaluate(() => window.__mnoteDocumentPaneRuntime && typeof window.__mnoteDocumentPaneRuntime.getOpenEditorsSnapshot === "function"
|
||||
? window.__mnoteDocumentPaneRuntime.getOpenEditorsSnapshot()?.activeEditor || null
|
||||
: null);
|
||||
const result = {
|
||||
ok: Boolean(capturedBody && capturedBody.mnote_context && capturedBody.mnote_context.value),
|
||||
tabButtonVisible: await tabButton.isVisible().catch(() => false),
|
||||
folderButtonVisible: await folderButton.isVisible().catch(() => false),
|
||||
tabSelected,
|
||||
folderSelectedAfterTab,
|
||||
inputTextAfterToggle,
|
||||
requestQuestion: capturedBody && capturedBody.question,
|
||||
mnoteContext: capturedBody && capturedBody.mnote_context,
|
||||
activeEditor,
|
||||
};
|
||||
if (!result.ok) {
|
||||
throw new SmokeFailure("quick_action_failed", "OpenHub 当前 Tab/文件夹上下文未随发送请求进入后台", result);
|
||||
}
|
||||
if (!result.tabSelected || result.folderSelectedAfterTab) {
|
||||
throw new SmokeFailure("quick_action_selection_invalid", "当前 Tab 按钮没有呈现单选选中态", result);
|
||||
}
|
||||
if (result.inputTextAfterToggle !== prompt) {
|
||||
throw new SmokeFailure("quick_action_leaked_to_input", "当前 Tab/文件夹地址不应直接写入用户可见输入框", result);
|
||||
}
|
||||
if (result.mnoteContext.kind !== "tab" || !/^https?:\/\/.+\/documents\//.test(result.mnoteContext.value)) {
|
||||
throw new SmokeFailure("quick_action_tab_context_invalid", "当前 Tab 发送上下文不是 MNote 文档地址", result);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function collectState(page) {
|
||||
return page.evaluate(() => {
|
||||
const visible = (node) => {
|
||||
if (!node || node.nodeType !== 1) return false;
|
||||
const ownerWindow = node.ownerDocument && node.ownerDocument.defaultView ? node.ownerDocument.defaultView : window;
|
||||
const style = ownerWindow.getComputedStyle(node);
|
||||
const rect = node.getBoundingClientRect();
|
||||
return !node.hidden && style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0;
|
||||
};
|
||||
const rectOf = (node) => {
|
||||
if (!node || node.nodeType !== 1 || typeof node.getBoundingClientRect !== "function") return null;
|
||||
const rect = node.getBoundingClientRect();
|
||||
return {
|
||||
x: Math.round(rect.x),
|
||||
y: Math.round(rect.y),
|
||||
width: Math.round(rect.width),
|
||||
height: Math.round(rect.height),
|
||||
top: Math.round(rect.top),
|
||||
bottom: Math.round(rect.bottom),
|
||||
};
|
||||
};
|
||||
const visibleElements = (selector) => Array.from(document.querySelectorAll(selector)).filter(visible);
|
||||
const visibleAny = (selector) => visibleElements(selector).length > 0;
|
||||
const text = (selector) => (document.querySelector(selector)?.textContent || "").trim();
|
||||
const iframe = document.querySelector("iframe[data-page-ai-openhub-iframe]");
|
||||
const iframeDoc = iframe instanceof HTMLIFrameElement ? iframe.contentDocument : null;
|
||||
const iframeBodyText = (iframeDoc && iframeDoc.body ? iframeDoc.body.textContent || "" : "").trim();
|
||||
const iframeShellKind = iframeDoc && iframeDoc.body ? iframeDoc.body.getAttribute("data-mnote-openhub-ai-shell") || "" : "";
|
||||
const drawer = Array.from(document.querySelectorAll("[data-testid='wolai-page-ai-drawer']")).find(visible) || null;
|
||||
const openhubHost = document.querySelector("[data-page-ai-openhub-host='true']");
|
||||
const outerOpenHubHeaderVisible = visibleAny(".wolai-page-ai-opencode-header")
|
||||
|| visibleAny(".wolai-page-ai-header-copy")
|
||||
|| visibleAny("[data-page-ai-openhub-runtime-status]");
|
||||
const diagnostics = document.querySelector("[data-page-ai-openhub-bootstrap-copy]");
|
||||
const diagnosticsVisible = Boolean(diagnostics && visible(diagnostics));
|
||||
const diagnosticsRect = diagnosticsVisible ? rectOf(diagnostics) : null;
|
||||
const diagnosticsOpen = diagnostics instanceof HTMLDetailsElement ? diagnostics.open : false;
|
||||
const debugChromeRects = diagnosticsVisible ? [{
|
||||
selector: "[data-page-ai-openhub-bootstrap-copy]",
|
||||
text: (diagnostics.textContent || "").replace(/\s+/g, " ").trim().slice(0, 160),
|
||||
rect: diagnosticsRect,
|
||||
open: diagnosticsOpen,
|
||||
}] : [];
|
||||
const debugChromeTotalHeight = diagnosticsRect?.height || 0;
|
||||
const fallbackActionSelector = "[data-page-ai-action='openhub-use-opencode-fallback']";
|
||||
const fallbackActionVisibleInIframe = iframeDoc
|
||||
? Array.from(iframeDoc.querySelectorAll(fallbackActionSelector)).some(visible)
|
||||
: false;
|
||||
const pageText = (document.body.textContent || "").replace(/\s+/g, " ").trim();
|
||||
const loginTextPattern = /(登录\s*OpenHub|OpenHub\s*Login|WeKnora\s*登录|登录\s*WeKnora|Sign in to OpenHub|OpenHub account|WeKnora account)/i;
|
||||
const reactSelectorMarkers = [
|
||||
"[data-openhub-ai-panel]",
|
||||
"[data-testid='openhub-ai-panel']",
|
||||
"[data-testid='openhub-chat']",
|
||||
"[data-openhub-conversation]",
|
||||
"[data-openhub-session-history]",
|
||||
".openhub-ai-panel",
|
||||
".chat-message-list",
|
||||
".ant-layout",
|
||||
".ant-menu",
|
||||
".ant-input",
|
||||
].filter((selector) => Array.from(document.querySelectorAll(selector)).some(visible)
|
||||
|| (iframeDoc && Array.from(iframeDoc.querySelectorAll(selector)).some(visible)));
|
||||
const reactTextMarkers = [
|
||||
"OpenHub 平台",
|
||||
"开始对话",
|
||||
"历史记录",
|
||||
"技能管理",
|
||||
"选择模型",
|
||||
].filter((marker) => iframeBodyText.includes(marker));
|
||||
const conflictEntryPattern = /(文件管理|知识库|时光机|智能体|协作任务|团队状态)/;
|
||||
const quickActionTab = iframeDoc ? iframeDoc.querySelector("[data-mnote-openhub-current-tab-toggle]") : null;
|
||||
const quickActionFolder = iframeDoc ? iframeDoc.querySelector("[data-mnote-openhub-current-folder-toggle]") : null;
|
||||
const smokeInput = iframeDoc ? iframeDoc.querySelector("[data-mnote-openhub-smoke-input]") : null;
|
||||
return {
|
||||
url: location.href,
|
||||
title: document.title,
|
||||
bodySnippet: pageText.slice(0, 800),
|
||||
drawerVisible: Boolean(drawer),
|
||||
drawerRect: rectOf(drawer),
|
||||
openhubHost: Boolean(openhubHost),
|
||||
openhubHostRect: rectOf(openhubHost),
|
||||
outerOpenHubHeaderVisible,
|
||||
debugChromeVisible: debugChromeRects.length > 0,
|
||||
debugChromeRects,
|
||||
debugChromeTotalHeight,
|
||||
debugChromeSqueezesContent: diagnosticsOpen || debugChromeTotalHeight > 80,
|
||||
hostChromeVisible: Boolean(Array.from(document.querySelectorAll("[data-page-ai-openhub-bootstrap-copy]")).find(visible)),
|
||||
iframeVisible: iframe instanceof HTMLIFrameElement && visible(iframe),
|
||||
iframeRect: rectOf(iframe),
|
||||
iframeSrc: iframe instanceof HTMLIFrameElement ? iframe.getAttribute("src") || "" : "",
|
||||
iframeBodySnippet: iframeBodyText.slice(0, 800),
|
||||
iframeShellKind,
|
||||
runtimeStatus: text("[data-page-ai-openhub-runtime-status]"),
|
||||
authTruth: text("[data-page-ai-openhub-auth-truth]"),
|
||||
workspaceScope: text("[data-page-ai-openhub-workspace-scope]"),
|
||||
routeGuard: text("[data-page-ai-openhub-route-guard]"),
|
||||
fallback: text("[data-page-ai-openhub-fallback]"),
|
||||
loginPageVisible: loginTextPattern.test(pageText) || loginTextPattern.test(iframeBodyText),
|
||||
staticBoundaryVisible: iframeShellKind === "static-boundary" || /静态占位|static-boundary|最小 host\/bootstrap 占位/.test(iframeBodyText),
|
||||
fallbackActionVisible: visibleAny(fallbackActionSelector) || fallbackActionVisibleInIframe,
|
||||
fallbackActionCount: document.querySelectorAll(fallbackActionSelector).length
|
||||
+ (iframeDoc ? iframeDoc.querySelectorAll(fallbackActionSelector).length : 0),
|
||||
reactAiMarkers: [...reactSelectorMarkers, ...reactTextMarkers.map((marker) => `text:${marker}`)],
|
||||
reactTextMarkers,
|
||||
conflictEntryVisible: conflictEntryPattern.test(iframeBodyText),
|
||||
quickActionTabVisible: Boolean(quickActionTab && visible(quickActionTab)),
|
||||
quickActionFolderVisible: Boolean(quickActionFolder && visible(quickActionFolder)),
|
||||
quickActionTabText: quickActionTab ? (quickActionTab.textContent || "").trim() : "",
|
||||
quickActionFolderText: quickActionFolder ? (quickActionFolder.textContent || "").trim() : "",
|
||||
quickActionTabLabel: quickActionTab ? quickActionTab.getAttribute("aria-label") || "" : "",
|
||||
quickActionFolderLabel: quickActionFolder ? quickActionFolder.getAttribute("aria-label") || "" : "",
|
||||
quickActionTabSelected: quickActionTab ? quickActionTab.classList.contains("ant-btn-primary") : false,
|
||||
quickActionFolderSelected: quickActionFolder ? quickActionFolder.classList.contains("ant-btn-primary") : false,
|
||||
quickActionSmokeInputValue: smokeInput instanceof HTMLTextAreaElement ? smokeInput.value : "",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function assertOpenHubState(state) {
|
||||
if (!state.drawerVisible) {
|
||||
throw new SmokeFailure("selector_missing", "Page AI drawer 未保持可见", state);
|
||||
}
|
||||
if (!state.openhubHost) {
|
||||
throw new SmokeFailure("selector_missing", "Page AI drawer 未切到 OpenHub host", state);
|
||||
}
|
||||
if (!state.iframeVisible || !state.iframeSrc.includes("/page-ai/openhub/ai")) {
|
||||
throw new SmokeFailure("selector_missing", "OpenHub iframe 不可见或 src 未指向 /page-ai/openhub/ai", state);
|
||||
}
|
||||
if (state.outerOpenHubHeaderVisible) {
|
||||
throw new SmokeFailure("outer_header_visible", "OpenHub drawer 仍显示 MNote 外层 OpenHub AI 标题栏", state);
|
||||
}
|
||||
if (!state.iframeRect || state.iframeRect.height < 420) {
|
||||
throw new SmokeFailure("iframe_too_short", "OpenHub iframe 高度不足,可能被顶部 debug/status 区挤压", state);
|
||||
}
|
||||
if (state.debugChromeSqueezesContent) {
|
||||
throw new SmokeFailure("debug_chrome_visible", "OpenHub 顶部 debug/status chrome 仍可见且挤占空间", state);
|
||||
}
|
||||
if (state.fallbackActionVisible || state.fallbackActionCount > 0) {
|
||||
throw new SmokeFailure("legacy_fallback_visible", "OpenHub drawer 仍存在用户可见 opencode fallback 入口", state);
|
||||
}
|
||||
if (state.loginPageVisible) {
|
||||
throw new SmokeFailure("unexpected_upstream_login", "页面出现 OpenHub/WeKnora 登录入口", state);
|
||||
}
|
||||
if (!state.iframeBodySnippet && !state.reactAiMarkers.length) {
|
||||
throw new SmokeFailure("selector_missing", "OpenHub iframe 已出现,但 iframe 内 shell 内容不可见", state);
|
||||
}
|
||||
if (state.conflictEntryVisible) {
|
||||
throw new SmokeFailure("unexpected_conflict_entry", "OpenHub iframe 嵌入态仍显示 MNote 真相冲突入口", state);
|
||||
}
|
||||
if (!state.quickActionTabVisible || !state.quickActionFolderVisible) {
|
||||
throw new SmokeFailure("quick_action_missing", "OpenHub iframe 内缺少当前 Tab/文件夹快捷按钮", state);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
const consoleMessages = [];
|
||||
let browser;
|
||||
let context;
|
||||
let page;
|
||||
let result;
|
||||
let directHostRoute = null;
|
||||
let legacyOpencodeRoute = null;
|
||||
const networkEvents = [];
|
||||
|
||||
try {
|
||||
await assertServiceReachable(BASE_URL);
|
||||
browser = await chromium.launch({
|
||||
headless: process.env.HEADFUL !== "1",
|
||||
...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
|
||||
});
|
||||
context = await browser.newContext({ viewport: { width: 1440, height: 960 }, locale: "zh-CN" });
|
||||
page = await context.newPage();
|
||||
page.on("console", (message) => {
|
||||
if (["error", "warning"].includes(message.type())) {
|
||||
consoleMessages.push({ type: message.type(), text: message.text() });
|
||||
}
|
||||
});
|
||||
page.on("response", (response) => {
|
||||
const url = response.url();
|
||||
if (url.includes("/page-ai/openhub")) {
|
||||
networkEvents.push({ url, status: response.status(), contentType: response.headers()["content-type"] || "" });
|
||||
}
|
||||
});
|
||||
|
||||
const viewer = await loginWithUiFirst(page, context.request).catch(async (error) => {
|
||||
if (error instanceof SmokeFailure) throw error;
|
||||
await ensureAuthenticated(page, context.request);
|
||||
return getViewerIdentity(context.request);
|
||||
});
|
||||
legacyOpencodeRoute = await context.request.get(`${BASE_URL}/page-ai/opencode`, { timeout: UI_TIMEOUT_MS }).then(async (response) => ({
|
||||
url: `${BASE_URL}/page-ai/opencode`,
|
||||
status: response.status(),
|
||||
ok: response.ok(),
|
||||
contentType: response.headers()["content-type"] || "",
|
||||
bodySnippet: (await response.text()).slice(0, 240),
|
||||
})).catch((error) => ({
|
||||
url: `${BASE_URL}/page-ai/opencode`,
|
||||
status: 0,
|
||||
ok: false,
|
||||
error: error.message,
|
||||
}));
|
||||
if (legacyOpencodeRoute.status !== 410) {
|
||||
throw new SmokeFailure("legacy_fallback_route_enabled", "登录后 /page-ai/opencode legacy fallback 页面仍可访问", legacyOpencodeRoute);
|
||||
}
|
||||
directHostRoute = await context.request.get(`${BASE_URL}/page-ai/openhub/ai`, { timeout: UI_TIMEOUT_MS }).then(async (response) => ({
|
||||
url: `${BASE_URL}/page-ai/openhub/ai`,
|
||||
status: response.status(),
|
||||
ok: response.ok(),
|
||||
contentType: response.headers()["content-type"] || "",
|
||||
bodySnippet: (await response.text()).slice(0, 240),
|
||||
})).catch((error) => ({
|
||||
url: `${BASE_URL}/page-ai/openhub/ai`,
|
||||
status: 0,
|
||||
ok: false,
|
||||
error: error.message,
|
||||
}));
|
||||
await openPageAiDrawer(page);
|
||||
await waitForVisibleAny(
|
||||
page,
|
||||
[
|
||||
"[data-page-ai-openhub-host='true']",
|
||||
"[data-page-ai-openhub-bootstrap-copy]",
|
||||
"iframe[data-page-ai-openhub-iframe]",
|
||||
],
|
||||
"OpenHub host",
|
||||
);
|
||||
await page.waitForFunction(() => {
|
||||
const frame = document.querySelector("iframe[data-page-ai-openhub-iframe]");
|
||||
return frame instanceof HTMLIFrameElement && (frame.getAttribute("src") || "").includes("/page-ai/openhub/ai");
|
||||
}, undefined, { timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForFunction(() => {
|
||||
const frame = document.querySelector("iframe[data-page-ai-openhub-iframe]");
|
||||
return frame instanceof HTMLIFrameElement && (frame.getAttribute("src") || "").includes("mnoteScope=");
|
||||
}, undefined, { timeout: UI_TIMEOUT_MS }).catch(() => undefined);
|
||||
await page.waitForTimeout(800);
|
||||
await page.waitForFunction(() => {
|
||||
const iframe = document.querySelector("iframe[data-page-ai-openhub-iframe]");
|
||||
const doc = iframe instanceof HTMLIFrameElement ? iframe.contentDocument : null;
|
||||
return Boolean(doc && doc.querySelector("[data-mnote-openhub-current-tab-toggle]") && doc.querySelector("[data-mnote-openhub-current-folder-toggle]"));
|
||||
}, undefined, { timeout: UI_TIMEOUT_MS });
|
||||
|
||||
const quickActions = await validateOpenHubQuickActions(page);
|
||||
const state = await collectState(page);
|
||||
await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true });
|
||||
assertOpenHubState(state);
|
||||
const openhubReactConnected = state.reactAiMarkers.length > 0;
|
||||
|
||||
result = {
|
||||
ok: true,
|
||||
task: TASK,
|
||||
baseUrl: BASE_URL,
|
||||
viewer,
|
||||
screenshot: SCREENSHOT_PATH,
|
||||
directHostRoute,
|
||||
legacyOpencodeRoute,
|
||||
quickActions,
|
||||
state,
|
||||
openhubReactConnected,
|
||||
staticBoundaryOnly: state.staticBoundaryVisible && !openhubReactConnected,
|
||||
reactAiNote: openhubReactConnected
|
||||
? "OpenHub React UI marker 已出现"
|
||||
: "未发现 OpenHub React AI marker;本次只验证 MNote OpenHub host drawer/iframe/shell 边界,不把静态 shell 记为 React AI 已接入",
|
||||
networkEvents,
|
||||
consoleMessages,
|
||||
};
|
||||
} catch (error) {
|
||||
const state = page ? await collectState(page).catch(() => null) : null;
|
||||
if (page) {
|
||||
await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true }).catch(() => undefined);
|
||||
}
|
||||
result = {
|
||||
ok: false,
|
||||
task: TASK,
|
||||
baseUrl: BASE_URL,
|
||||
screenshot: fs.existsSync(SCREENSHOT_PATH) ? SCREENSHOT_PATH : null,
|
||||
failureKind: error instanceof SmokeFailure ? error.kind : "unexpected_error",
|
||||
error: error instanceof Error ? error.stack || error.message : String(error),
|
||||
errorDetails: error instanceof SmokeFailure ? error.details : null,
|
||||
directHostRoute,
|
||||
legacyOpencodeRoute,
|
||||
state,
|
||||
networkEvents,
|
||||
consoleMessages,
|
||||
};
|
||||
} finally {
|
||||
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
|
||||
if (context) await context.close().catch(() => undefined);
|
||||
if (browser) await browser.close().catch(() => undefined);
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
if (!result.ok) process.exit(1);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,294 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { chromium } = require("playwright");
|
||||
const {
|
||||
BASE_URL,
|
||||
UI_TIMEOUT_MS,
|
||||
getViewerIdentity,
|
||||
} = require("./tree-shell-smoke-helpers");
|
||||
|
||||
const TASK = "task774-openhub-mnote-send-and-file-edit-e2e";
|
||||
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "7-68-runtime");
|
||||
const RESULT_PATH = path.join(OUTPUT_DIR, "openhub-send-smoke-result.json");
|
||||
const WORKSPACE_ROOT = process.env.MNOTE_OPENHUB_SMOKE_ROOT
|
||||
|| "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space";
|
||||
const ROOT_URI = `file://${WORKSPACE_ROOT}`;
|
||||
const TEST_EMAIL = "mnote.e2e@example.com";
|
||||
const TEST_PASSWORD = "MnoteE2E123!";
|
||||
const ENABLE_FILE_EDIT = process.env.MNOTE_OPENHUB_FILE_EDIT === "1";
|
||||
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|
||||
|| ["/usr/bin/chromium-browser", "/usr/bin/chromium", "/usr/bin/google-chrome-stable", "/usr/bin/google-chrome"]
|
||||
.find((candidate) => fs.existsSync(candidate));
|
||||
|
||||
class SmokeFailure extends Error {
|
||||
constructor(kind, message, details = {}) {
|
||||
super(message);
|
||||
this.name = "SmokeFailure";
|
||||
this.kind = kind;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
async function assertServiceReachable(baseUrl) {
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(`${baseUrl}/health`, { redirect: "manual", signal: AbortSignal.timeout(6_000) });
|
||||
} catch (error) {
|
||||
throw new SmokeFailure("service_unreachable", `MNote 3000 服务不可达:${error.message}`, { baseUrl });
|
||||
}
|
||||
if (!response.ok && response.status !== 303) {
|
||||
throw new SmokeFailure("service_unreachable", `MNote /health 返回异常:HTTP ${response.status}`, { baseUrl });
|
||||
}
|
||||
}
|
||||
|
||||
async function loginWithUiFirst(page, requestContext) {
|
||||
await page.goto(`${BASE_URL}/auth`, { waitUntil: "commit", timeout: UI_TIMEOUT_MS });
|
||||
if (!page.url().includes("/auth")) {
|
||||
return getViewerIdentity(requestContext);
|
||||
}
|
||||
|
||||
const quickLogin = page.getByRole("button", { name: "测试账号快速登录" });
|
||||
if (await quickLogin.isVisible({ timeout: 8_000 }).catch(() => false)) {
|
||||
await quickLogin.click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForURL((url) => !url.toString().includes("/auth"), { timeout: UI_TIMEOUT_MS, waitUntil: "commit" }).catch(() => undefined);
|
||||
}
|
||||
|
||||
if (page.url().includes("/auth")) {
|
||||
const account = page.locator('input[name="account"], input[type="email"], input[data-auth-field="account"]').first();
|
||||
const password = page.locator('input[name="password"], input[type="password"]').first();
|
||||
const submit = page.getByRole("button", { name: /^登录$|账号登录|登录$/ }).first();
|
||||
if (!(await account.isVisible({ timeout: 2_000 }).catch(() => false)) || !(await password.isVisible({ timeout: 2_000 }).catch(() => false))) {
|
||||
throw new SmokeFailure("auth_failed", "认证页未出现快速登录,也找不到账号密码输入框", { url: page.url() });
|
||||
}
|
||||
await account.fill(TEST_EMAIL, { timeout: UI_TIMEOUT_MS });
|
||||
await password.fill(TEST_PASSWORD, { timeout: UI_TIMEOUT_MS });
|
||||
await submit.click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForURL((url) => !url.toString().includes("/auth"), { timeout: UI_TIMEOUT_MS, waitUntil: "commit" }).catch(() => undefined);
|
||||
}
|
||||
|
||||
if (page.url().includes("/auth")) {
|
||||
throw new SmokeFailure("auth_failed", "测试账号登录后仍停留在 /auth", { url: page.url() });
|
||||
}
|
||||
|
||||
return getViewerIdentity(requestContext);
|
||||
}
|
||||
|
||||
function mnoteScopeQuery(openhubIframeUrl) {
|
||||
const queryStart = openhubIframeUrl.indexOf("?");
|
||||
if (queryStart < 0) return "";
|
||||
return openhubIframeUrl.slice(queryStart);
|
||||
}
|
||||
|
||||
function parseStream(streamText) {
|
||||
let sessionId = "";
|
||||
let assistantText = "";
|
||||
const eventTypes = [];
|
||||
for (const line of streamText.split(/\r?\n/)) {
|
||||
if (!line.startsWith("data: ")) continue;
|
||||
try {
|
||||
const data = JSON.parse(line.slice(6));
|
||||
const payload = data.payload && data.payload.type
|
||||
? { type: data.payload.type, ...data.payload.properties }
|
||||
: data;
|
||||
if (payload.type) eventTypes.push(payload.type);
|
||||
if (payload.conversation_id) sessionId = payload.conversation_id;
|
||||
if (["text", "content", "assistant_message", "message"].includes(payload.type) && payload.content) {
|
||||
assistantText += payload.content;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
return {
|
||||
sessionId,
|
||||
eventTypes: [...new Set(eventTypes)],
|
||||
assistantTextSnippet: assistantText.slice(0, 700),
|
||||
};
|
||||
}
|
||||
|
||||
async function bootstrapOpenHub(requestContext) {
|
||||
const response = await requestContext.post(`${BASE_URL}/api/page-ai/openhub/bootstrap`, {
|
||||
data: {
|
||||
pageId: "task774-openhub-send-smoke",
|
||||
workspaceId: "local-ws:mnote-e2e:my-space",
|
||||
pageTitle: "OpenHub send smoke",
|
||||
rootUri: ROOT_URI,
|
||||
allowedRoots: [],
|
||||
},
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok() || !payload.openhubIframeUrl) {
|
||||
throw new SmokeFailure("bootstrap_failed", `OpenHub bootstrap 失败:HTTP ${response.status()}`, payload);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function fetchOpenHubModels(requestContext, scopeQuery) {
|
||||
const response = await requestContext.get(`${BASE_URL}/page-ai/openhub/ai/api/models${scopeQuery}`, { timeout: UI_TIMEOUT_MS });
|
||||
const payload = await response.json();
|
||||
const models = payload?.data?.models || [];
|
||||
const selected = models.find((model) => model.providerID === "opencodego" && model.modelID === "deepseek-v4-flash")
|
||||
|| models.find((model) => model.providerID === "opencode" && model.modelID === "deepseek-v4-flash-free")
|
||||
|| models.find((model) => model.providerID === "opencodego")
|
||||
|| models.find((model) => model.providerID === "opencode")
|
||||
|| payload?.data?.default
|
||||
|| models[0];
|
||||
if (!response.ok() || !models.length || !selected) {
|
||||
throw new SmokeFailure("models_empty", `OpenHub /api/models 未返回可用真实模型:HTTP ${response.status()}`, payload);
|
||||
}
|
||||
return {
|
||||
modelCount: models.length,
|
||||
default: payload.data.default,
|
||||
source: payload.data.source,
|
||||
selected,
|
||||
};
|
||||
}
|
||||
|
||||
async function sendPrompt(requestContext, scopeQuery, model, prompt) {
|
||||
const response = await requestContext.post(`${BASE_URL}/page-ai/openhub/ai/api/query/stream${scopeQuery}`, {
|
||||
data: {
|
||||
question: prompt,
|
||||
conversation_id: "",
|
||||
agent: "build",
|
||||
model: {
|
||||
providerID: model.providerID,
|
||||
modelID: model.modelID,
|
||||
currentUsage: model.currentUsage || 0,
|
||||
monthlyLimit: model.monthlyLimit || 0,
|
||||
},
|
||||
},
|
||||
headers: { "content-type": "application/json" },
|
||||
timeout: 180_000,
|
||||
});
|
||||
const streamText = await response.text();
|
||||
if (!response.ok()) {
|
||||
throw new SmokeFailure("query_stream_failed", `OpenHub query stream 失败:HTTP ${response.status()}`, {
|
||||
snippet: streamText.slice(0, 800),
|
||||
});
|
||||
}
|
||||
return {
|
||||
status: response.status(),
|
||||
length: streamText.length,
|
||||
snippet: streamText.slice(0, 1_200),
|
||||
...parseStream(streamText),
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchMessages(requestContext, scopeQuery, sessionId) {
|
||||
const response = await requestContext.get(`${BASE_URL}/page-ai/openhub/ai/api/sessions/${encodeURIComponent(sessionId)}/messages${scopeQuery}`, {
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
const payload = await response.json();
|
||||
const messages = payload?.data || [];
|
||||
if (!response.ok() || !messages.some((message) => message.role === "user")) {
|
||||
throw new SmokeFailure("messages_not_persisted", `OpenHub SQLite messages 未持久化或不可读:HTTP ${response.status()}`, payload);
|
||||
}
|
||||
return {
|
||||
status: response.status(),
|
||||
count: messages.length,
|
||||
roles: messages.map((message) => message.role),
|
||||
last: messages.slice(-2).map((message) => ({
|
||||
role: message.role,
|
||||
content: String(message.content || "").slice(0, 300),
|
||||
model: message.model,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async function runFileEditProbe(requestContext, scopeQuery, model) {
|
||||
const fixtureDir = path.join(WORKSPACE_ROOT, "knowledge-rag-fixtures-7-68");
|
||||
fs.mkdirSync(fixtureDir, { recursive: true });
|
||||
const fixturePath = path.join(fixtureDir, `task774-openhub-file-edit-${Date.now()}.md`);
|
||||
fs.writeFileSync(fixturePath, "# OpenHub File Edit Smoke\n\nstatus: pending\n", "utf8");
|
||||
const gitDir = path.join(WORKSPACE_ROOT, ".git");
|
||||
const gitExistedBefore = fs.existsSync(gitDir);
|
||||
const prompt = [
|
||||
`请直接修改这个文件:${fixturePath}`,
|
||||
"只把 `status: pending` 改成 `status: MNOTE_OPENHUB_FILE_EDIT_OK`。",
|
||||
"不要改其它文件。完成后只简短说明已修改。",
|
||||
].join("\n");
|
||||
const stream = await sendPrompt(requestContext, scopeQuery, model, prompt);
|
||||
const finalContent = fs.readFileSync(fixturePath, "utf8");
|
||||
const ok = finalContent.includes("status: MNOTE_OPENHUB_FILE_EDIT_OK");
|
||||
if (!ok) {
|
||||
throw new SmokeFailure("file_edit_not_applied", "OpenHub/opencode 未把 fixture 文件改到期望内容", {
|
||||
fixturePath,
|
||||
finalContent,
|
||||
stream,
|
||||
});
|
||||
}
|
||||
return {
|
||||
fixturePath,
|
||||
ok,
|
||||
finalContent,
|
||||
gitExistedBefore,
|
||||
gitExistsAfter: fs.existsSync(gitDir),
|
||||
stream,
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
let browser;
|
||||
let context;
|
||||
let result = { ok: false, task: TASK, baseUrl: BASE_URL };
|
||||
try {
|
||||
await assertServiceReachable(BASE_URL);
|
||||
browser = await chromium.launch({
|
||||
headless: process.env.HEADFUL !== "1",
|
||||
...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
|
||||
});
|
||||
context = await browser.newContext({ viewport: { width: 1440, height: 960 }, locale: "zh-CN" });
|
||||
const page = await context.newPage();
|
||||
const viewer = await loginWithUiFirst(page, context.request);
|
||||
const bootstrap = await bootstrapOpenHub(context.request);
|
||||
const scopeQuery = mnoteScopeQuery(bootstrap.openhubIframeUrl);
|
||||
const models = await fetchOpenHubModels(context.request, scopeQuery);
|
||||
const smokePrompt = `请只回复 MNOTE_OPENHUB_SMOKE_OK,不要解释。时间戳 ${Date.now()}`;
|
||||
const stream = await sendPrompt(context.request, scopeQuery, models.selected, smokePrompt);
|
||||
if (!stream.sessionId) {
|
||||
throw new SmokeFailure("query_stream_missing_session", "OpenHub query stream 未返回 conversation_id", stream);
|
||||
}
|
||||
const messages = await fetchMessages(context.request, scopeQuery, stream.sessionId);
|
||||
const fileEdit = ENABLE_FILE_EDIT
|
||||
? await runFileEditProbe(context.request, scopeQuery, models.selected)
|
||||
: { skipped: true, reason: "设置 MNOTE_OPENHUB_FILE_EDIT=1 后执行真实文件编辑验收" };
|
||||
result = {
|
||||
ok: true,
|
||||
task: TASK,
|
||||
baseUrl: BASE_URL,
|
||||
viewer,
|
||||
bootstrap: {
|
||||
ok: bootstrap.ok,
|
||||
authTruth: bootstrap.authTruth,
|
||||
iframeUrlPrefix: bootstrap.openhubIframeUrl.slice(0, 160),
|
||||
rootUri: bootstrap.scope?.workspaceScope?.rootUri,
|
||||
},
|
||||
models,
|
||||
stream,
|
||||
messages,
|
||||
fileEdit,
|
||||
};
|
||||
} catch (error) {
|
||||
result = {
|
||||
...result,
|
||||
ok: false,
|
||||
failureKind: error instanceof SmokeFailure ? error.kind : "unexpected_error",
|
||||
error: error instanceof Error ? error.stack || error.message : String(error),
|
||||
details: error instanceof SmokeFailure ? error.details : undefined,
|
||||
};
|
||||
} finally {
|
||||
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
|
||||
if (context) await context.close().catch(() => undefined);
|
||||
if (browser) await browser.close().catch(() => undefined);
|
||||
}
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
if (!result.ok) process.exit(1);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,255 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const TASK = "task775-openhub-mnote-scope-isolation-smoke";
|
||||
const OPENHUB_BASE_URL = (process.env.MNOTE_OPENHUB_BASE_URL || "http://127.0.0.1:18080").replace(/\/+$/, "");
|
||||
const WORKSPACE_ROOT = process.env.MNOTE_OPENHUB_SMOKE_ROOT
|
||||
|| "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space";
|
||||
const ROOT_URI = process.env.MNOTE_OPENHUB_SCOPE_ROOT_URI || `file://${WORKSPACE_ROOT}`;
|
||||
const WORKSPACE_KEY = process.env.MNOTE_OPENHUB_SCOPE_WORKSPACE_KEY || "local-ws:mnote-e2e:my-space";
|
||||
const REQUEST_TIMEOUT_MS = Number(process.env.MNOTE_OPENHUB_SCOPE_ISOLATION_TIMEOUT_MS || 15_000);
|
||||
const STREAM_PRIME_MS = Number(process.env.MNOTE_OPENHUB_SCOPE_ISOLATION_STREAM_PRIME_MS || 3_000);
|
||||
|
||||
class SmokeFailure extends Error {
|
||||
constructor(kind, message, details = {}) {
|
||||
super(message);
|
||||
this.name = "SmokeFailure";
|
||||
this.kind = kind;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
function delay(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function buildScopeHeaders(ownerLabel, sessionScope) {
|
||||
return {
|
||||
"content-type": "application/json",
|
||||
"X-MNote-User-Key": `task775:${ownerLabel}`,
|
||||
"X-MNote-Workspace-Key": WORKSPACE_KEY,
|
||||
"X-MNote-Session-Scope": sessionScope,
|
||||
"X-MNote-Root-Uri": ROOT_URI,
|
||||
"X-MNote-Page-Resource-Id": "task775-openhub-scope-isolation",
|
||||
"X-MNote-Tool-Permission-Scope": JSON.stringify({
|
||||
source: TASK,
|
||||
allowedRoots: [ROOT_URI],
|
||||
}),
|
||||
"X-MNote-WeKnora-Tool-Scope": JSON.stringify({
|
||||
source: TASK,
|
||||
enabled: false,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(url, init = {}, timeoutMs = REQUEST_TIMEOUT_MS) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
return await fetch(url, {
|
||||
...init,
|
||||
signal: controller.signal,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function readJsonResponse(response) {
|
||||
const text = await response.text();
|
||||
try {
|
||||
return text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
async function assertOpenHubReachable(headers) {
|
||||
const response = await fetchWithTimeout(`${OPENHUB_BASE_URL}/api/sessions?page=1&page_size=1`, {
|
||||
method: "GET",
|
||||
headers,
|
||||
});
|
||||
const payload = await readJsonResponse(response);
|
||||
if (!response.ok) {
|
||||
throw new SmokeFailure("openhub_unreachable", `OpenHub MNote scope API 不可用:HTTP ${response.status}`, {
|
||||
baseUrl: OPENHUB_BASE_URL,
|
||||
payload,
|
||||
});
|
||||
}
|
||||
return {
|
||||
status: response.status,
|
||||
success: payload?.success === true,
|
||||
};
|
||||
}
|
||||
|
||||
async function primeSessionWithOwnerA(headers, sessionId, marker) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), STREAM_PRIME_MS);
|
||||
let response;
|
||||
let streamSnippet = "";
|
||||
try {
|
||||
response = await fetch(`${OPENHUB_BASE_URL}/api/query/stream`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
question: `请只回复 ${marker},不要解释。`,
|
||||
conversation_id: sessionId,
|
||||
agent: "build",
|
||||
model: {
|
||||
providerID: process.env.MNOTE_OPENHUB_SCOPE_MODEL_PROVIDER || "opencodego",
|
||||
modelID: process.env.MNOTE_OPENHUB_SCOPE_MODEL_ID || "deepseek-v4-flash",
|
||||
currentUsage: 0,
|
||||
monthlyLimit: 0,
|
||||
},
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const payload = await readJsonResponse(response);
|
||||
throw new SmokeFailure("query_stream_failed", `Owner A 创建 session/message 失败:HTTP ${response.status}`, {
|
||||
payload,
|
||||
});
|
||||
}
|
||||
|
||||
if (response.body) {
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
try {
|
||||
while (streamSnippet.length < 2_000) {
|
||||
const readResult = await Promise.race([
|
||||
reader.read(),
|
||||
delay(250).then(() => ({ timedOut: true })),
|
||||
]);
|
||||
if (readResult.timedOut) break;
|
||||
if (readResult.done) break;
|
||||
streamSnippet += decoder.decode(readResult.value, { stream: true });
|
||||
if (streamSnippet.includes(marker) || streamSnippet.includes("\"type\"")) break;
|
||||
}
|
||||
} finally {
|
||||
await reader.cancel().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error && error.name !== "AbortError") {
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
return {
|
||||
status: response?.status || 0,
|
||||
streamSnippet: streamSnippet.slice(0, 500),
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchMessages(headers, sessionId) {
|
||||
const response = await fetchWithTimeout(
|
||||
`${OPENHUB_BASE_URL}/api/sessions/${encodeURIComponent(sessionId)}/messages`,
|
||||
{
|
||||
method: "GET",
|
||||
headers,
|
||||
},
|
||||
);
|
||||
const payload = await readJsonResponse(response);
|
||||
return {
|
||||
status: response.status,
|
||||
ok: response.ok,
|
||||
payload,
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForOwnerAMessages(headers, sessionId, marker) {
|
||||
let last = null;
|
||||
for (let attempt = 0; attempt < 12; attempt += 1) {
|
||||
last = await fetchMessages(headers, sessionId);
|
||||
const messages = Array.isArray(last.payload?.data) ? last.payload.data : [];
|
||||
const hasMarker = messages.some((message) => String(message.content || "").includes(marker));
|
||||
if (last.ok && hasMarker) {
|
||||
return {
|
||||
status: last.status,
|
||||
readable: true,
|
||||
count: messages.length,
|
||||
roles: messages.map((message) => message.role),
|
||||
markerFound: true,
|
||||
sample: messages.slice(-3).map((message) => ({
|
||||
role: message.role,
|
||||
content: String(message.content || "").slice(0, 240),
|
||||
})),
|
||||
};
|
||||
}
|
||||
await delay(500);
|
||||
}
|
||||
|
||||
throw new SmokeFailure("owner_a_messages_not_readable", "Owner A 未能读取到自己创建的 session/messages", {
|
||||
last,
|
||||
});
|
||||
}
|
||||
|
||||
function assertOwnerBBlocked(ownerBResult) {
|
||||
if (ownerBResult.status === 403 || ownerBResult.status === 404) {
|
||||
return {
|
||||
blocked: true,
|
||||
status: ownerBResult.status,
|
||||
detail: ownerBResult.payload?.detail || ownerBResult.payload,
|
||||
};
|
||||
}
|
||||
|
||||
throw new SmokeFailure("owner_b_not_blocked", "Owner B 读取到了或可访问 Owner A 的 session/messages", {
|
||||
status: ownerBResult.status,
|
||||
payload: ownerBResult.payload,
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const runId = `${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
|
||||
const sessionId = `task775-openhub-scope-${runId}`;
|
||||
const sessionScope = `task775-openhub-scope-isolation:${runId}`;
|
||||
const marker = `MNOTE_OPENHUB_SCOPE_ISOLATION_${runId}`;
|
||||
const headersA = buildScopeHeaders("owner-a", sessionScope);
|
||||
const headersB = buildScopeHeaders("owner-b", sessionScope);
|
||||
let result = {
|
||||
ok: false,
|
||||
task: TASK,
|
||||
mode: "direct-openhub-mnote-headers-backend-scope-isolation",
|
||||
openhubBaseUrl: OPENHUB_BASE_URL,
|
||||
sessionId,
|
||||
};
|
||||
|
||||
try {
|
||||
const health = await assertOpenHubReachable(headersA);
|
||||
const stream = await primeSessionWithOwnerA(headersA, sessionId, marker);
|
||||
const ownerA = await waitForOwnerAMessages(headersA, sessionId, marker);
|
||||
const ownerB = assertOwnerBBlocked(await fetchMessages(headersB, sessionId));
|
||||
result = {
|
||||
...result,
|
||||
ok: true,
|
||||
health,
|
||||
stream,
|
||||
ownerA,
|
||||
ownerB,
|
||||
summary: {
|
||||
sessionId,
|
||||
ownerAReadable: ownerA.readable,
|
||||
ownerBBlocked: ownerB.blocked,
|
||||
ownerBStatus: ownerB.status,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
result = {
|
||||
...result,
|
||||
ok: false,
|
||||
failureKind: error instanceof SmokeFailure ? error.kind : "unexpected_error",
|
||||
error: error instanceof Error ? error.stack || error.message : String(error),
|
||||
details: error instanceof SmokeFailure ? error.details : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
if (!result.ok) process.exit(1);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,234 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const repoRoot = path.resolve(__dirname, "..");
|
||||
const openHubRoot = process.env.OPENHUB_RESEARCH_ROOT || "/mnt/Data1T/Mnote_data/openhub/OpenHub";
|
||||
const backendSessionPath = path.join(openHubRoot, "smart-query-backend/app/api/session.py");
|
||||
const frontendApiPath = path.join(openHubRoot, "smart-query-frontend/src/services/api.js");
|
||||
const diffViewerPath = path.join(openHubRoot, "smart-query-frontend/src/components/DiffViewer.jsx");
|
||||
const embedPath = path.join(openHubRoot, "smart-query-frontend/src/mnoteEmbed.js");
|
||||
const smartQueryPath = path.join(openHubRoot, "smart-query-frontend/src/pages/SmartQueryPage.jsx");
|
||||
const mnoteRuntimePath = path.join(repoRoot, "rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js");
|
||||
const task774ResultPath = path.join(repoRoot, "tmp/7-68-runtime/openhub-send-smoke-result.json");
|
||||
const outputDir = path.join(repoRoot, "tmp/7-68-runtime");
|
||||
const resultPath = path.join(outputDir, "openhub-changed-files-bridge-smoke-result.json");
|
||||
const baseUrl = process.env.MNOTE_BASE_URL || "http://127.0.0.1:3000";
|
||||
const testAccount = process.env.MNOTE_E2E_ACCOUNT || "mnote-e2e";
|
||||
const testPassword = process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!";
|
||||
const workspaceRoot = process.env.MNOTE_OPENHUB_SMOKE_ROOT
|
||||
|| "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space";
|
||||
|
||||
function read(filePath) {
|
||||
return fs.readFileSync(filePath, "utf8");
|
||||
}
|
||||
|
||||
function assertCheck(failures, name, passed, details = undefined) {
|
||||
if (!passed) failures.push({ name, details });
|
||||
}
|
||||
|
||||
function loadTask774Probe() {
|
||||
if (!fs.existsSync(task774ResultPath)) return null;
|
||||
const payload = JSON.parse(fs.readFileSync(task774ResultPath, "utf8"));
|
||||
const sessionId = payload?.fileEdit?.stream?.sessionId;
|
||||
const fixturePath = payload?.fileEdit?.fixturePath;
|
||||
if (!payload?.ok || !sessionId || !fixturePath) return null;
|
||||
return { sessionId, fixturePath };
|
||||
}
|
||||
|
||||
async function signInCookie() {
|
||||
const response = await fetch(`${baseUrl}/api/auth`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", accept: "application/json" },
|
||||
body: JSON.stringify({
|
||||
action: "auth:signIn",
|
||||
args: {
|
||||
provider: "password",
|
||||
params: {
|
||||
account: testAccount,
|
||||
password: testPassword,
|
||||
flow: "signIn",
|
||||
},
|
||||
},
|
||||
}),
|
||||
signal: AbortSignal.timeout(12_000),
|
||||
});
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(`/api/auth 登录失败: HTTP ${response.status} ${text.slice(0, 400)}`);
|
||||
}
|
||||
const setCookie = response.headers.get("set-cookie") || "";
|
||||
const cookies = setCookie
|
||||
.split(/,(?=\s*[^;,\s]+=)/)
|
||||
.map((part) => part.split(";")[0].trim())
|
||||
.filter(Boolean);
|
||||
if (!cookies.some((cookie) => cookie.startsWith("mnote_session="))) {
|
||||
throw new Error(`/api/auth 未返回 mnote_session cookie: ${setCookie.slice(0, 400)}`);
|
||||
}
|
||||
return cookies.join("; ");
|
||||
}
|
||||
|
||||
async function bootstrapScopeQuery(cookieHeader) {
|
||||
const response = await fetch(`${baseUrl}/api/page-ai/openhub/bootstrap`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
accept: "application/json",
|
||||
cookie: cookieHeader,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
workspaceId: "local-ws:mnote-e2e:my-space",
|
||||
rootUri: `file://${workspaceRoot}`,
|
||||
pageResourceId: "task776-openhub-changed-files-bridge",
|
||||
pageTitle: "OpenHub changed files bridge smoke",
|
||||
allowedRoots: [`file://${workspaceRoot}`],
|
||||
}),
|
||||
signal: AbortSignal.timeout(12_000),
|
||||
});
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
throw new Error(`OpenHub bootstrap 失败: HTTP ${response.status} ${JSON.stringify(payload).slice(0, 500)}`);
|
||||
}
|
||||
const iframeUrl = String(payload?.openhubIframeUrl || "");
|
||||
const query = iframeUrl.includes("?") ? iframeUrl.slice(iframeUrl.indexOf("?")) : "";
|
||||
if (!query.includes("mnoteScope=")) {
|
||||
throw new Error(`OpenHub bootstrap 未返回完整 mnoteScope query: ${iframeUrl.slice(0, 200)}`);
|
||||
}
|
||||
return query;
|
||||
}
|
||||
|
||||
async function probeLiveEndpoint(probe) {
|
||||
if (!probe) {
|
||||
return { skipped: true, reason: "缺少 task774 真实文件编辑结果,先运行 MNOTE_OPENHUB_FILE_EDIT=1 node scripts/task774-openhub-mnote-send-and-file-edit-e2e.js" };
|
||||
}
|
||||
const cookieHeader = await signInCookie();
|
||||
const query = await bootstrapScopeQuery(cookieHeader);
|
||||
const url = `${baseUrl}/page-ai/openhub/ai/api/sessions/${encodeURIComponent(probe.sessionId)}/diff${query}`;
|
||||
const response = await fetch(url, {
|
||||
headers: { accept: "application/json", cookie: cookieHeader },
|
||||
signal: AbortSignal.timeout(12_000),
|
||||
});
|
||||
const text = await response.text();
|
||||
let payload = null;
|
||||
try {
|
||||
payload = JSON.parse(text);
|
||||
} catch {}
|
||||
const changed = Array.isArray(payload?.diffs) ? payload.diffs : [];
|
||||
const matched = changed.some((item) => item.path === probe.fixturePath);
|
||||
return {
|
||||
skipped: false,
|
||||
ok: response.ok && matched,
|
||||
status: response.status,
|
||||
matched,
|
||||
expectedPath: probe.fixturePath,
|
||||
queryFromFreshBootstrap: true,
|
||||
changedPaths: changed.map((item) => item.path),
|
||||
diffAvailable: payload?.diffAvailable,
|
||||
source: payload?.source,
|
||||
limitation: payload?.limitation,
|
||||
snippet: text.slice(0, 800),
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
const failures = [];
|
||||
const backendSession = read(backendSessionPath);
|
||||
const frontendApi = read(frontendApiPath);
|
||||
const diffViewer = read(diffViewerPath);
|
||||
const embed = read(embedPath);
|
||||
const smartQuery = read(smartQueryPath);
|
||||
const mnoteRuntime = read(mnoteRuntimePath);
|
||||
|
||||
assertCheck(
|
||||
failures,
|
||||
"OpenHub backend exposes session diff endpoint",
|
||||
backendSession.includes('@router.get("/api/sessions/{session_id}/diff")') &&
|
||||
backendSession.includes("_changed_files_from_messages") &&
|
||||
backendSession.includes("opencode_tool_events")
|
||||
);
|
||||
assertCheck(
|
||||
failures,
|
||||
"backend extracts path only from write-like opencode tool metadata",
|
||||
backendSession.includes("_is_write_tool") &&
|
||||
backendSession.includes("_iter_tool_path_values") &&
|
||||
backendSession.includes('"filePath"') &&
|
||||
backendSession.includes('"source": "opencode_tool_event"')
|
||||
);
|
||||
assertCheck(
|
||||
failures,
|
||||
"backend guards workspace path and does not synthesize diff content",
|
||||
backendSession.includes("os.path.commonpath") &&
|
||||
backendSession.includes('"diffAvailable": False') &&
|
||||
backendSession.includes('"content": ""') &&
|
||||
backendSession.includes("不生成或伪造 diff")
|
||||
);
|
||||
assertCheck(
|
||||
failures,
|
||||
"frontend service still calls session diff endpoint",
|
||||
frontendApi.includes("getSessionDiff") && frontendApi.includes("/sessions/${sessionId}/diff")
|
||||
);
|
||||
assertCheck(
|
||||
failures,
|
||||
"frontend postMessage bridge emits mnote open-file payload",
|
||||
embed.includes("postMNoteOpenFile") &&
|
||||
embed.includes("type: 'mnote:open-file'") &&
|
||||
embed.includes("source: payload.source || 'openhub-diff'")
|
||||
);
|
||||
assertCheck(
|
||||
failures,
|
||||
"DiffViewer exposes changed path open action",
|
||||
diffViewer.includes("postMNoteOpenFile") &&
|
||||
diffViewer.includes("rootRelativePath") &&
|
||||
diffViewer.includes("diffAvailable")
|
||||
);
|
||||
assertCheck(
|
||||
failures,
|
||||
"SmartQueryPage loads changed files after stream and exposes hidden bridge payload",
|
||||
smartQuery.includes("loadChangedFiles(finalConversationId)") &&
|
||||
smartQuery.includes("data-mnote-openhub-changed-file") &&
|
||||
smartQuery.includes("handleOpenChangedFile")
|
||||
);
|
||||
assertCheck(
|
||||
failures,
|
||||
"MNote host listens for OpenHub open-file bridge",
|
||||
mnoteRuntime.includes("pageAiInstallMNoteOpenFileBridge") &&
|
||||
mnoteRuntime.includes("message.type !== 'mnote:open-file'") &&
|
||||
mnoteRuntime.includes("openhub-diff") &&
|
||||
mnoteRuntime.includes("pageAiOpenOpencodeChangedFile")
|
||||
);
|
||||
|
||||
const liveProbeInput = loadTask774Probe();
|
||||
let liveProbe;
|
||||
try {
|
||||
liveProbe = await probeLiveEndpoint(liveProbeInput);
|
||||
if (!liveProbe.skipped) {
|
||||
assertCheck(failures, "live MNote proxy returns real changed path from task774 session", liveProbe.ok, liveProbe);
|
||||
}
|
||||
} catch (error) {
|
||||
liveProbe = {
|
||||
skipped: false,
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
reason: "MNote/OpenHub 服务不可达或 task774 session 已不可读",
|
||||
};
|
||||
assertCheck(failures, "live MNote proxy returns real changed path from task774 session", false, liveProbe);
|
||||
}
|
||||
|
||||
const result = {
|
||||
ok: failures.length === 0,
|
||||
task: "task776-openhub-changed-files-bridge-smoke",
|
||||
liveProbe,
|
||||
failures,
|
||||
};
|
||||
fs.writeFileSync(resultPath, `${JSON.stringify(result, null, 2)}\n`, "utf8");
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
if (!result.ok) process.exit(1);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,92 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const repoRoot = path.resolve(__dirname, "..");
|
||||
const routePath = path.join(repoRoot, "rust/crates/mnote-web/src/routes/page_ai_openhub.rs");
|
||||
const modPath = path.join(repoRoot, "rust/crates/mnote-web/src/routes/mod.rs");
|
||||
const designPath = path.join(
|
||||
repoRoot,
|
||||
"design/07-ai/process/7-68-openhub-weknora-mnote-deep-fusion-checklist-v1.md"
|
||||
);
|
||||
|
||||
function read(filePath) {
|
||||
return fs.readFileSync(filePath, "utf8");
|
||||
}
|
||||
|
||||
function assertCheck(failures, name, passed, details = undefined) {
|
||||
if (!passed) failures.push({ name, details });
|
||||
}
|
||||
|
||||
const route = read(routePath);
|
||||
const routesMod = read(modPath);
|
||||
const design = read(designPath);
|
||||
const failures = [];
|
||||
|
||||
assertCheck(
|
||||
failures,
|
||||
"MNote exposes artifact index API under Page AI OpenHub boundary",
|
||||
routesMod.includes('"/api/page-ai/openhub/artifact-index"') &&
|
||||
routesMod.includes("get(page_ai_openhub::artifact_index_get).post(page_ai_openhub::artifact_index_upsert)")
|
||||
);
|
||||
|
||||
assertCheck(
|
||||
failures,
|
||||
"artifact index schema stores only lightweight locator fields",
|
||||
route.includes("mnote.page_ai_openhub_artifact_index_record.v1") &&
|
||||
route.includes('"openhubSessionId"') &&
|
||||
route.includes('"kind"') &&
|
||||
route.includes('"providerId"') &&
|
||||
route.includes('"path"') &&
|
||||
route.includes('"citationPayload"')
|
||||
);
|
||||
|
||||
assertCheck(
|
||||
failures,
|
||||
"artifact index explicitly refuses OpenHub message fulltext fields",
|
||||
route.includes("reject_fulltext_message_fields") &&
|
||||
route.includes("page_ai_openhub_artifact_index_forbidden_fulltext_field") &&
|
||||
route.includes('"message"') &&
|
||||
route.includes('"conversationMessages"') &&
|
||||
route.includes('"assistantMessage"') &&
|
||||
route.includes('"userMessage"') &&
|
||||
route.includes('"content"') &&
|
||||
route.includes('"transcript"')
|
||||
);
|
||||
|
||||
assertCheck(
|
||||
failures,
|
||||
"artifact index response documents no message fulltext copy",
|
||||
route.includes('"messageFulltextCopied": false') &&
|
||||
route.includes('"openhub_message_fulltext"') &&
|
||||
route.includes('"openhub_conversation_message_rows"') &&
|
||||
route.includes('"assistant_text"') &&
|
||||
route.includes('"user_prompt"')
|
||||
);
|
||||
|
||||
assertCheck(
|
||||
failures,
|
||||
"minimal persistence stays inside MNote/root metadata or explicit env path",
|
||||
route.includes("MNOTE_OPENHUB_ARTIFACT_INDEX_PATH") &&
|
||||
route.includes('join(".mnote")') &&
|
||||
route.includes('join("page-ai-openhub-artifact-index.json")') &&
|
||||
route.includes("write_artifact_index_records")
|
||||
);
|
||||
|
||||
assertCheck(
|
||||
failures,
|
||||
"design checklist records completed artifact index boundary",
|
||||
design.includes("[x] MNote 只存 artifact index") &&
|
||||
design.includes("task778-openhub-artifact-index-static-smoke.js") &&
|
||||
design.includes("不是复制 OpenHub SQLite message 表")
|
||||
);
|
||||
|
||||
const result = {
|
||||
ok: failures.length === 0,
|
||||
task: "task778-openhub-artifact-index-static-smoke",
|
||||
failures,
|
||||
};
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
if (!result.ok) process.exit(1);
|
||||
@@ -1,230 +0,0 @@
|
||||
#!/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", "7-68-runtime");
|
||||
const RESULT_PATH = path.join(OUTPUT_DIR, "task779-openhub-file-edit-document-pane-refresh-result.json");
|
||||
const SCREENSHOT_PATH = path.join(OUTPUT_DIR, "task779-openhub-file-edit-document-pane-refresh.png");
|
||||
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|
||||
|| ["/usr/bin/chromium-browser", "/usr/bin/chromium", "/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, workspaceId) {
|
||||
const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`);
|
||||
url.searchParams.set("sourceKind", "local_folder");
|
||||
url.searchParams.set("rootUri", fileUrl(root));
|
||||
if (workspaceId) url.searchParams.set("workspaceId", workspaceId);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function markdown(title, lines) {
|
||||
return ["---", `title: ${title}`, "---", "", ...lines, ""].join("\n");
|
||||
}
|
||||
|
||||
function writeWorkspaceManifest(root, ownerId, workspaceId) {
|
||||
const metadataDir = path.join(root, ".mnote");
|
||||
fs.mkdirSync(metadataDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(metadataDir, "workspace.json"),
|
||||
`${JSON.stringify({
|
||||
workspaceId,
|
||||
ownerId,
|
||||
createdAt: new Date().toISOString(),
|
||||
capabilities: ["local_files", "markdown_edit"],
|
||||
}, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForEditorText(page, text) {
|
||||
await page.waitForFunction(
|
||||
(expected) => {
|
||||
const editor = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror');
|
||||
return (editor?.textContent || "").includes(expected);
|
||||
},
|
||||
text,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
}
|
||||
|
||||
async function readDocumentPaneState(page) {
|
||||
return page.evaluate(() => {
|
||||
const root = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"][data-pane-role="primary"]');
|
||||
const pane = document.querySelector('.document-pane[data-pane-role="primary"]');
|
||||
const editor = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror');
|
||||
const aggregateNode = document.getElementById("__MNOTE_PAGE_AGGREGATE__");
|
||||
let aggregate = null;
|
||||
try {
|
||||
aggregate = JSON.parse(aggregateNode?.textContent || "null");
|
||||
} catch {}
|
||||
return {
|
||||
paneDocumentId: pane?.getAttribute("data-pane-document-id") || "",
|
||||
runtimeStatus: root?.getAttribute("data-runtime-editor-status") || "",
|
||||
runtimeError: root?.getAttribute("data-runtime-editor-error") || "",
|
||||
editorText: editor?.textContent || "",
|
||||
aggregateText: JSON.stringify(aggregate?.body || aggregate || {}),
|
||||
syncedAt: aggregateNode?.getAttribute("data-mnote-page-aggregate-synced-at") || "",
|
||||
openhubRefreshMarker: document.documentElement.getAttribute("data-mnote-page-ai-openhub-document-pane-refresh") || "",
|
||||
eventBusSource: document.documentElement.getAttribute("data-mnote-local-folder-event-bus-last-source") || "",
|
||||
eventBusReason: document.documentElement.getAttribute("data-mnote-local-folder-event-bus-last-reason") || "",
|
||||
fileChangeSources: window.__mnoteTask779FileChangeSources || [],
|
||||
documentSessionDebug: window.__mnoteDebugDocumentSessions?.snapshot?.() || null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function openDocument(page, root, relativePath, workspaceId) {
|
||||
await page.goto(documentUrl(root, relativePath, workspaceId), { waitUntil: "commit", 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 waitForPageAiRuntime(page) {
|
||||
await page.waitForFunction(
|
||||
() => typeof window.__mnoteSidebarPageAiRuntime?.openPageAiDrawer === "function"
|
||||
&& typeof window.__mnoteDocumentPaneRuntime?.refreshPrimaryDocument === "function"
|
||||
&& typeof window.__mnoteDocumentPaneRuntime?.openPrimaryDocument === "function",
|
||||
null,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
}
|
||||
|
||||
async function installOpenHubChangedFileBridge(page) {
|
||||
await page.evaluate(() => {
|
||||
try {
|
||||
localStorage.setItem("mnote.page_ai.openhub_host", "1");
|
||||
} catch {}
|
||||
window.__mnoteSidebarPageAiRuntime.openPageAiDrawer();
|
||||
});
|
||||
await page.locator('[data-testid="wolai-page-ai-drawer"][data-page-ai-openhub-host="true"]').first().waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
|
||||
async function simulateOpenHubChangedFile(page, root, relativePath, workspaceId) {
|
||||
return page.evaluate(({ rootUri, path, workspaceId }) => {
|
||||
window.postMessage({
|
||||
type: "mnote:open-file",
|
||||
source: "openhub-changed-files",
|
||||
path,
|
||||
rootUri,
|
||||
workspaceId,
|
||||
documentId: `local-md:${path.replaceAll("/", "~2F")}`,
|
||||
}, window.location.origin);
|
||||
return true;
|
||||
}, { rootUri: fileUrl(root), path: relativePath, workspaceId });
|
||||
}
|
||||
|
||||
async function run() {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task779-openhub-pane-refresh-"));
|
||||
const workspaceId = `local-ws:user_real:task779:${Date.now()}`;
|
||||
const relativePath = "task779-openhub-refresh.md";
|
||||
const initialText = "task779 initial document pane text";
|
||||
const changedText = `task779 openhub changed file bridge ${Date.now()}`;
|
||||
writeWorkspaceManifest(root, "user_real", workspaceId);
|
||||
fs.writeFileSync(path.join(root, relativePath), markdown("Task 779 OpenHub Refresh", [initialText]), "utf8");
|
||||
|
||||
const browser = await chromium.launch({
|
||||
headless: process.env.HEADFUL !== "1",
|
||||
...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
|
||||
});
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1360, height: 900 },
|
||||
locale: "zh-CN",
|
||||
extraHTTPHeaders: {
|
||||
"x-mnote-actor-id": "user_real",
|
||||
"x-mnote-actor-type": "user",
|
||||
},
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const debug = { root, workspaceId, relativePath, initialText, changedText };
|
||||
|
||||
try {
|
||||
await openDocument(page, root, relativePath, workspaceId);
|
||||
await waitForEditorText(page, initialText);
|
||||
await waitForPageAiRuntime(page);
|
||||
await installOpenHubChangedFileBridge(page);
|
||||
await page.evaluate(() => {
|
||||
window.__mnoteTask779FileChangeSources = [];
|
||||
window.addEventListener("mnote:file-change-batch", (event) => {
|
||||
window.__mnoteTask779FileChangeSources.push(String(event?.detail?.source || ""));
|
||||
});
|
||||
});
|
||||
debug.before = await readDocumentPaneState(page);
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(root, relativePath),
|
||||
markdown("Task 779 OpenHub Refresh", [changedText]),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const opened = await simulateOpenHubChangedFile(page, root, relativePath, workspaceId);
|
||||
assert.equal(opened, true, "OpenHub changed-file bridge 应接受当前 Markdown path");
|
||||
await waitForEditorText(page, changedText);
|
||||
debug.after = await readDocumentPaneState(page);
|
||||
assert.equal(debug.after.paneDocumentId, localMdDocumentId(relativePath), "primary document pane 应仍打开测试 Markdown");
|
||||
assert(debug.after.editorText.includes(changedText), `document pane 应显示磁盘新内容: ${debug.after.editorText}`);
|
||||
assert(!debug.after.editorText.includes(initialText), `document pane 不应保留旧正文: ${debug.after.editorText}`);
|
||||
assert.equal(debug.after.openhubRefreshMarker, relativePath, "应记录 OpenHub document pane refresh marker");
|
||||
assert(
|
||||
debug.after.fileChangeSources.some((source) => source.includes("openhub_changed_file_bridge")),
|
||||
`应通过 FileChangeService 消费 OpenHub changedFiles adapter: ${JSON.stringify(debug.after.fileChangeSources)}`,
|
||||
);
|
||||
|
||||
await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true });
|
||||
const result = {
|
||||
ok: true,
|
||||
task: "task779-openhub-file-edit-document-pane-refresh-smoke",
|
||||
root,
|
||||
relativePath,
|
||||
documentId: localMdDocumentId(relativePath),
|
||||
changedText,
|
||||
beforeText: debug.before.editorText,
|
||||
afterText: debug.after.editorText,
|
||||
eventBusSource: debug.after.eventBusSource,
|
||||
eventBusReason: debug.after.eventBusReason,
|
||||
resultPath: RESULT_PATH,
|
||||
screenshotPath: SCREENSHOT_PATH,
|
||||
};
|
||||
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
|
||||
console.log(`ok task779-openhub-file-edit-document-pane-refresh-smoke ${RESULT_PATH}`);
|
||||
} catch (error) {
|
||||
fs.writeFileSync(RESULT_PATH, `${JSON.stringify({
|
||||
ok: false,
|
||||
error: String(error && error.stack || error),
|
||||
debug,
|
||||
}, null, 2)}\n`, "utf8");
|
||||
throw error;
|
||||
} finally {
|
||||
await browser.close().catch(() => {});
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
run().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -1,219 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const vm = require("node:vm");
|
||||
const { TextDecoder } = require("node:util");
|
||||
|
||||
const repoRoot = path.resolve(__dirname, "..");
|
||||
const openHubRoot = process.env.OPENHUB_RESEARCH_ROOT || "/mnt/Data1T/Mnote_data/openhub/OpenHub";
|
||||
const embedPath = path.join(openHubRoot, "smart-query-frontend/src/mnoteEmbed.js");
|
||||
const smartQueryPath = path.join(openHubRoot, "smart-query-frontend/src/pages/SmartQueryPage.jsx");
|
||||
const diffViewerPath = path.join(openHubRoot, "smart-query-frontend/src/components/DiffViewer.jsx");
|
||||
const designPath = path.join(
|
||||
repoRoot,
|
||||
"design/07-ai/process/7-68-openhub-weknora-mnote-deep-fusion-checklist-v1.md"
|
||||
);
|
||||
|
||||
const forbiddenFulltextKeys = [
|
||||
"message",
|
||||
"messages",
|
||||
"messageContent",
|
||||
"conversation",
|
||||
"conversationMessages",
|
||||
"assistantMessage",
|
||||
"userMessage",
|
||||
"content",
|
||||
"text",
|
||||
"transcript",
|
||||
];
|
||||
|
||||
function read(filePath) {
|
||||
return fs.readFileSync(filePath, "utf8");
|
||||
}
|
||||
|
||||
function assertCheck(failures, name, passed, details = undefined) {
|
||||
if (!passed) failures.push({ name, details });
|
||||
}
|
||||
|
||||
function base64UrlJson(value) {
|
||||
return Buffer.from(JSON.stringify(value), "utf8")
|
||||
.toString("base64")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/g, "");
|
||||
}
|
||||
|
||||
function hasForbiddenKey(value) {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
if (Array.isArray(value)) return value.some(hasForbiddenKey);
|
||||
return Object.entries(value).some(([key, entry]) => (
|
||||
forbiddenFulltextKeys.includes(key) || hasForbiddenKey(entry)
|
||||
));
|
||||
}
|
||||
|
||||
async function probeEmbedRuntime(embedSource) {
|
||||
const mnoteScope = {
|
||||
workspaceScope: {
|
||||
rootUri: "file:///tmp/mnote-artifact-root",
|
||||
workspaceId: "ws-task780",
|
||||
pageResourceId: "page-task780",
|
||||
},
|
||||
};
|
||||
const calls = [];
|
||||
const sandbox = {
|
||||
console,
|
||||
TextDecoder,
|
||||
URLSearchParams,
|
||||
Uint8Array,
|
||||
window: {
|
||||
location: {
|
||||
pathname: "/page-ai/openhub/ai",
|
||||
search: `?scope=session-task780&mnoteScope=${base64UrlJson(mnoteScope)}`,
|
||||
origin: "http://127.0.0.1:3000",
|
||||
},
|
||||
parent: {},
|
||||
atob: (value) => Buffer.from(value, "base64").toString("binary"),
|
||||
},
|
||||
fetch: async (url, options = {}) => {
|
||||
calls.push({ url, options });
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ ok: true }),
|
||||
};
|
||||
},
|
||||
module: { exports: {} },
|
||||
exports: {},
|
||||
};
|
||||
const transformed = embedSource
|
||||
.replace(/import\.meta\.env\.VITE_API_BASE_URL/g, "undefined")
|
||||
.replace(/\bexport const /g, "const ");
|
||||
vm.runInNewContext(
|
||||
`${transformed}\nmodule.exports = { getMNoteArtifactIndexContext, postMNoteArtifactIndex };`,
|
||||
sandbox,
|
||||
{ filename: embedPath }
|
||||
);
|
||||
const result = await sandbox.module.exports.postMNoteArtifactIndex({
|
||||
openhubSessionId: "ses-task780",
|
||||
kind: "changed_file",
|
||||
providerId: "opencode_tool_event",
|
||||
path: "/tmp/mnote-artifact-root/page.md",
|
||||
citationPayload: {
|
||||
schema: "openhub.changed_file_locator.v1",
|
||||
rootRelativePath: "page.md",
|
||||
diffAvailable: false,
|
||||
},
|
||||
});
|
||||
const body = calls[0] ? JSON.parse(calls[0].options.body) : null;
|
||||
return { result, calls, body };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const failures = [];
|
||||
const embed = read(embedPath);
|
||||
const smartQuery = read(smartQueryPath);
|
||||
const diffViewer = read(diffViewerPath);
|
||||
const design = read(designPath);
|
||||
|
||||
assertCheck(
|
||||
failures,
|
||||
"mnoteEmbed posts artifact index to MNote root API",
|
||||
embed.includes("postMNoteArtifactIndex") &&
|
||||
embed.includes("getMNoteArtifactIndexContext") &&
|
||||
embed.includes("fetch('/api/page-ai/openhub/artifact-index'") &&
|
||||
embed.includes("credentials: 'include'")
|
||||
);
|
||||
assertCheck(
|
||||
failures,
|
||||
"artifact index payload is limited to locator fields",
|
||||
["openhubSessionId", "kind", "providerId", "path", "citationPayload", "rootUri", "workspaceId", "pageResourceId"]
|
||||
.every((field) => embed.includes(field))
|
||||
);
|
||||
assertCheck(
|
||||
failures,
|
||||
"SmartQueryPage indexes changed files after diff metadata is loaded",
|
||||
smartQuery.includes("indexChangedFiles(sessionId, files)") &&
|
||||
smartQuery.includes("kind: 'changed_file'") &&
|
||||
smartQuery.includes("schema: 'openhub.changed_file_locator.v1'") &&
|
||||
smartQuery.includes("providerId: file?.source || 'opencode_tool_event'")
|
||||
);
|
||||
assertCheck(
|
||||
failures,
|
||||
"SmartQueryPage indexes WeKnora citation locator payloads",
|
||||
smartQuery.includes("indexCitationArtifacts") &&
|
||||
smartQuery.includes("kind: 'citation'") &&
|
||||
smartQuery.includes("schema: 'openhub.weknora_citation_locator.v1'") &&
|
||||
smartQuery.includes("citation?.sourceRootRelativePath")
|
||||
);
|
||||
assertCheck(
|
||||
failures,
|
||||
"citation payload sanitizer strips fulltext-like fields recursively",
|
||||
forbiddenFulltextKeys.every((key) => smartQuery.includes(`'${key}'`)) &&
|
||||
smartQuery.includes("sanitizeCitationPayload(entry)") &&
|
||||
smartQuery.includes(".filter(([key]) => !forbiddenKeys.has(key))")
|
||||
);
|
||||
assertCheck(
|
||||
failures,
|
||||
"changed file bridge remains connected to MNote open-file event",
|
||||
diffViewer.includes("postMNoteOpenFile") &&
|
||||
smartQuery.includes("data-mnote-openhub-changed-file") &&
|
||||
smartQuery.includes("handleOpenChangedFile")
|
||||
);
|
||||
assertCheck(
|
||||
failures,
|
||||
"design checklist records task780 runtime artifact index bridge",
|
||||
design.includes("task780-openhub-artifact-index-runtime-smoke.js") &&
|
||||
design.includes("changed_file / citation 轻量 artifact index")
|
||||
);
|
||||
|
||||
let runtimeProbe = null;
|
||||
try {
|
||||
runtimeProbe = await probeEmbedRuntime(embed);
|
||||
assertCheck(
|
||||
failures,
|
||||
"runtime request uses MNote artifact-index endpoint",
|
||||
runtimeProbe.calls.length === 1 &&
|
||||
runtimeProbe.calls[0].url === "/api/page-ai/openhub/artifact-index" &&
|
||||
runtimeProbe.calls[0].options.method === "POST",
|
||||
runtimeProbe
|
||||
);
|
||||
assertCheck(
|
||||
failures,
|
||||
"runtime request body contains required locator fields",
|
||||
runtimeProbe.body &&
|
||||
runtimeProbe.body.openhubSessionId === "ses-task780" &&
|
||||
runtimeProbe.body.kind === "changed_file" &&
|
||||
runtimeProbe.body.providerId === "opencode_tool_event" &&
|
||||
runtimeProbe.body.path === "/tmp/mnote-artifact-root/page.md" &&
|
||||
runtimeProbe.body.rootUri === "file:///tmp/mnote-artifact-root" &&
|
||||
runtimeProbe.body.workspaceId === "ws-task780" &&
|
||||
runtimeProbe.body.pageResourceId === "page-task780",
|
||||
runtimeProbe.body
|
||||
);
|
||||
assertCheck(
|
||||
failures,
|
||||
"runtime request body does not contain OpenHub message fulltext fields",
|
||||
runtimeProbe.body && !hasForbiddenKey(runtimeProbe.body),
|
||||
runtimeProbe.body
|
||||
);
|
||||
} catch (error) {
|
||||
runtimeProbe = { error: error instanceof Error ? error.stack || error.message : String(error) };
|
||||
assertCheck(failures, "runtime request shape probe executes", false, runtimeProbe);
|
||||
}
|
||||
|
||||
const result = {
|
||||
ok: failures.length === 0,
|
||||
task: "task780-openhub-artifact-index-runtime-smoke",
|
||||
runtimeProbe,
|
||||
failures,
|
||||
};
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
if (!result.ok) process.exit(1);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,188 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const vm = require("node:vm");
|
||||
|
||||
const repoRoot = path.resolve(__dirname, "..");
|
||||
const openHubRoot = process.env.OPENHUB_RESEARCH_ROOT || "/mnt/Data1T/Mnote_data/openhub/OpenHub";
|
||||
const backendBridgePath = path.join(openHubRoot, "smart-query-backend/app/services/mnote_weknora.py");
|
||||
const backendApiPath = path.join(openHubRoot, "smart-query-backend/app/api/mnote_tools.py");
|
||||
const backendStreamPath = path.join(openHubRoot, "smart-query-backend/app/services/stream.py");
|
||||
const backendScopePath = path.join(openHubRoot, "smart-query-backend/app/core/mnote_scope.py");
|
||||
const frontendEmbedPath = path.join(openHubRoot, "smart-query-frontend/src/mnoteEmbed.js");
|
||||
const assistantMessagePath = path.join(openHubRoot, "smart-query-frontend/src/components/AssistantMessage.jsx");
|
||||
const smartQueryPath = path.join(openHubRoot, "smart-query-frontend/src/pages/SmartQueryPage.jsx");
|
||||
const mnoteRuntimePath = path.join(repoRoot, "rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js");
|
||||
const checklistPath = path.join(repoRoot, "design/07-ai/process/7-68-openhub-weknora-mnote-deep-fusion-checklist-v1.md");
|
||||
const outputDir = path.join(repoRoot, "tmp", "7-68-runtime");
|
||||
const resultPath = path.join(outputDir, "openhub-weknora-tool-citation-bridge-smoke-result.json");
|
||||
|
||||
function read(filePath) {
|
||||
return fs.readFileSync(filePath, "utf8");
|
||||
}
|
||||
|
||||
function assertCheck(failures, name, passed, details = undefined) {
|
||||
if (!passed) failures.push({ name, details });
|
||||
}
|
||||
|
||||
function base64UrlJson(value) {
|
||||
return Buffer.from(JSON.stringify(value), "utf8")
|
||||
.toString("base64")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/g, "");
|
||||
}
|
||||
|
||||
function extractFunction(source, name) {
|
||||
const start = source.indexOf(`export const ${name}`);
|
||||
if (start < 0) throw new Error(`找不到 ${name}`);
|
||||
const next = source.indexOf("\nexport const ", start + 1);
|
||||
return source.slice(start, next > start ? next : undefined);
|
||||
}
|
||||
|
||||
async function probeEmbedOpenReference(embedSource) {
|
||||
const scope = {
|
||||
workspaceScope: {
|
||||
rootUri: "file:///tmp/mnote-openhub-task782",
|
||||
workspaceId: "ws-task782",
|
||||
pageResourceId: "page-task782",
|
||||
},
|
||||
};
|
||||
const posted = [];
|
||||
const sandbox = {
|
||||
console,
|
||||
TextDecoder,
|
||||
URLSearchParams,
|
||||
Uint8Array,
|
||||
window: {
|
||||
location: {
|
||||
pathname: "/page-ai/openhub/ai",
|
||||
search: `?scope=session-task782&mnoteScope=${base64UrlJson(scope)}`,
|
||||
origin: "http://127.0.0.1:3000",
|
||||
},
|
||||
parent: {
|
||||
postMessage: (message, origin) => posted.push({ message, origin }),
|
||||
},
|
||||
atob: (value) => Buffer.from(value, "base64").toString("binary"),
|
||||
},
|
||||
module: { exports: {} },
|
||||
exports: {},
|
||||
};
|
||||
const transformed = embedSource
|
||||
.slice(0, embedSource.indexOf("const compactObject"))
|
||||
.replace(/import\.meta\.env\.VITE_API_BASE_URL/g, "undefined")
|
||||
.replace(/\bexport const /g, "const ");
|
||||
vm.runInNewContext(
|
||||
`${transformed}\nmodule.exports = { postMNoteOpenReference };`,
|
||||
sandbox,
|
||||
{ filename: frontendEmbedPath }
|
||||
);
|
||||
const ok = sandbox.module.exports.postMNoteOpenReference({
|
||||
schema: "openhub.weknora_citation_locator.v1",
|
||||
citation: {
|
||||
provider: "weknora",
|
||||
sourceRootRelativePath: "knowledge-rag-fixtures-7-68/task782.md",
|
||||
citationLabel: "task782.md",
|
||||
},
|
||||
});
|
||||
return { ok, posted };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const failures = [];
|
||||
const backendBridge = read(backendBridgePath);
|
||||
const backendApi = read(backendApiPath);
|
||||
const backendStream = read(backendStreamPath);
|
||||
const backendScope = read(backendScopePath);
|
||||
const frontendEmbed = read(frontendEmbedPath);
|
||||
const assistantMessage = read(assistantMessagePath);
|
||||
const smartQuery = read(smartQueryPath);
|
||||
const mnoteRuntime = read(mnoteRuntimePath);
|
||||
const checklist = read(checklistPath);
|
||||
|
||||
assertCheck(
|
||||
failures,
|
||||
"OpenHub backend exposes MNote WeKnora readonly facade endpoint",
|
||||
backendApi.includes("/api/mnote/tools/call") &&
|
||||
backendApi.includes("call_mnote_weknora_tool") &&
|
||||
backendBridge.includes("MNOTE_TOOL_CALL_ENDPOINT = \"/api/mnote/tools/call\"") &&
|
||||
!backendBridge.includes("MNOTE_TOOL_CALL_ENDPOINT = \"/api/hermes/tools/mnote/call\"")
|
||||
);
|
||||
assertCheck(
|
||||
failures,
|
||||
"backend tool call carries scope and does not expose WeKnora API key",
|
||||
backendBridge.includes("capabilityScope") &&
|
||||
backendBridge.includes("knowledge_rag.read") &&
|
||||
backendBridge.includes("allowedRoots") &&
|
||||
backendBridge.includes("mnote_cookie") &&
|
||||
!backendBridge.includes("MNOTE_WEKNORA_API_KEY")
|
||||
);
|
||||
assertCheck(
|
||||
failures,
|
||||
"OpenHub stream auto-bridges KB queries through MNote WeKnora",
|
||||
backendStream.includes("auto_search_for_question") &&
|
||||
backendStream.includes("<mnote_weknora_tool_result>") &&
|
||||
backendStream.includes("_push_mnote_weknora_tool_event") &&
|
||||
backendStream.includes("mnote.weknora.search")
|
||||
);
|
||||
assertCheck(
|
||||
failures,
|
||||
"MNote session cookie is only forwarded as trusted server header",
|
||||
backendScope.includes("X-MNote-Session-Cookie") &&
|
||||
mnoteRuntime.includes("mnote:open-reference") &&
|
||||
!frontendEmbed.includes("mnote_session=")
|
||||
);
|
||||
assertCheck(
|
||||
failures,
|
||||
"OpenHub frontend renders clickable WeKnora citations",
|
||||
assistantMessage.includes("data-mnote-openhub-citation") &&
|
||||
assistantMessage.includes("openhub.weknora_citation_locator.v1") &&
|
||||
assistantMessage.includes("onOpenMNoteCitation") &&
|
||||
smartQuery.includes("postMNoteOpenReference")
|
||||
);
|
||||
assertCheck(
|
||||
failures,
|
||||
"MNote host accepts citation open-reference postMessage bridge",
|
||||
mnoteRuntime.includes("message.type !== 'mnote:open-file' && message.type !== 'mnote:open-reference'") &&
|
||||
mnoteRuntime.includes("openhub-citation") &&
|
||||
mnoteRuntime.includes("pageAiOpenOpencodeChangedFile")
|
||||
);
|
||||
|
||||
const runtimeProbe = await probeEmbedOpenReference(frontendEmbed);
|
||||
assertCheck(
|
||||
failures,
|
||||
"runtime postMNoteOpenReference posts mnote:open-reference",
|
||||
runtimeProbe.ok &&
|
||||
runtimeProbe.posted.length === 1 &&
|
||||
runtimeProbe.posted[0].message.type === "mnote:open-reference" &&
|
||||
runtimeProbe.posted[0].message.source === "openhub-citation" &&
|
||||
runtimeProbe.posted[0].message.path.endsWith("task782.md"),
|
||||
runtimeProbe
|
||||
);
|
||||
assertCheck(
|
||||
failures,
|
||||
"checklist has task782 completion note",
|
||||
checklist.includes("task782-openhub-weknora-tool-citation-bridge-smoke.js")
|
||||
);
|
||||
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
const result = {
|
||||
ok: failures.length === 0,
|
||||
task: "task782-openhub-weknora-tool-citation-bridge-smoke",
|
||||
runtimeProbe,
|
||||
failures,
|
||||
};
|
||||
fs.writeFileSync(resultPath, `${JSON.stringify(result, null, 2)}\n`, "utf8");
|
||||
if (failures.length) {
|
||||
console.error(JSON.stringify(result, null, 2));
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,555 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
const TASK = "task786-openhub-history-refresh-browser-smoke";
|
||||
const BASE_URL = (process.env.BASE_URL || process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
|
||||
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
|
||||
const STREAM_TIMEOUT_MS = Number(process.env.MNOTE_OPENHUB_STREAM_TIMEOUT_MS || 45_000);
|
||||
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "task786-openhub-history-refresh-browser-smoke");
|
||||
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
|
||||
const FAILURE_PATH = path.join(OUTPUT_DIR, "failure.json");
|
||||
const SCREENSHOT_PATH = path.join(OUTPUT_DIR, "openhub-history-refresh.png");
|
||||
const TEST_EMAIL = "mnote.e2e@example.com";
|
||||
const TEST_PASSWORD = "MnoteE2E123!";
|
||||
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|
||||
|| ["/usr/bin/chromium-browser", "/usr/bin/chromium", "/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium"]
|
||||
.find((candidate) => fs.existsSync(candidate));
|
||||
|
||||
class SmokeFailure extends Error {
|
||||
constructor(layer, message, details = {}) {
|
||||
super(message);
|
||||
this.name = "SmokeFailure";
|
||||
this.layer = layer;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
function jsonSnippet(value, limit = 800) {
|
||||
if (typeof value === "string") return value.slice(0, limit);
|
||||
return JSON.stringify(value, null, 2).slice(0, limit);
|
||||
}
|
||||
|
||||
function openHubProxyUrl(pathname, scopeQuery, extraParams = {}) {
|
||||
const url = new URL(`${BASE_URL}/page-ai/openhub/ai${pathname}`);
|
||||
const scopeParams = new URLSearchParams(String(scopeQuery || "").replace(/^\?/, ""));
|
||||
for (const [key, value] of scopeParams.entries()) {
|
||||
url.searchParams.set(key, value);
|
||||
}
|
||||
for (const [key, value] of Object.entries(extraParams)) {
|
||||
if (value !== undefined && value !== null) url.searchParams.set(key, String(value));
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
async function parseJsonResponse(response, layer, label) {
|
||||
const text = await response.text();
|
||||
let payload = null;
|
||||
try {
|
||||
payload = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
throw new SmokeFailure(layer, `${label} 返回非 JSON:HTTP ${response.status()}`, {
|
||||
status: response.status(),
|
||||
bodySnippet: text.slice(0, 800),
|
||||
});
|
||||
}
|
||||
if (!response.ok()) {
|
||||
throw new SmokeFailure(layer, `${label} 失败:HTTP ${response.status()}`, payload);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function assertMNoteReachable() {
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(`${BASE_URL}/health`, { redirect: "manual", signal: AbortSignal.timeout(6_000) });
|
||||
} catch (error) {
|
||||
throw new SmokeFailure("mnote_service", `MNote 3000 服务不可达:${error.message}`, { baseUrl: BASE_URL });
|
||||
}
|
||||
if (!response.ok && response.status !== 303) {
|
||||
throw new SmokeFailure("mnote_service", `MNote /health 返回异常:HTTP ${response.status}`, { baseUrl: BASE_URL });
|
||||
}
|
||||
}
|
||||
|
||||
async function signIn(requestContext) {
|
||||
const response = await requestContext.post(`${BASE_URL}/api/auth`, {
|
||||
data: {
|
||||
action: "auth:signIn",
|
||||
args: {
|
||||
provider: "password",
|
||||
params: {
|
||||
account: TEST_EMAIL,
|
||||
email: TEST_EMAIL,
|
||||
password: TEST_PASSWORD,
|
||||
flow: "signIn",
|
||||
},
|
||||
},
|
||||
},
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
const payload = await parseJsonResponse(response, "auth", "/api/auth 登录");
|
||||
const whoami = await requestContext.get(`${BASE_URL}/api/auth/whoami`, { timeout: UI_TIMEOUT_MS });
|
||||
const viewer = await parseJsonResponse(whoami, "auth", "/api/auth/whoami");
|
||||
if (!viewer || !viewer.userId) {
|
||||
throw new SmokeFailure("auth", "登录后 whoami 缺少 userId", { loginPayload: payload, whoami: viewer });
|
||||
}
|
||||
return viewer;
|
||||
}
|
||||
|
||||
async function visibleAny(page, selectors, label) {
|
||||
await page.waitForFunction((candidateSelectors) => {
|
||||
const visible = (node) => {
|
||||
if (!(node instanceof HTMLElement)) return false;
|
||||
const style = window.getComputedStyle(node);
|
||||
const rect = node.getBoundingClientRect();
|
||||
return !node.hidden && style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0;
|
||||
};
|
||||
return candidateSelectors.some((selector) => Array.from(document.querySelectorAll(selector)).some(visible));
|
||||
}, selectors, { timeout: UI_TIMEOUT_MS }).catch((error) => {
|
||||
throw new SmokeFailure("mnote_page_ai_ui", `${label} 不可见`, { selectors, cause: error.message });
|
||||
});
|
||||
}
|
||||
|
||||
async function openMNoteOpenHubDrawer(page) {
|
||||
await page.goto(BASE_URL, { waitUntil: "commit", timeout: UI_TIMEOUT_MS });
|
||||
if (page.url().includes("/auth")) {
|
||||
throw new SmokeFailure("auth", "打开 MNote 主页后仍被重定向到 /auth", { url: page.url() });
|
||||
}
|
||||
|
||||
await page.evaluate(() => {
|
||||
try {
|
||||
localStorage.setItem("mnote.page_ai.openhub_host", "1");
|
||||
} catch {}
|
||||
});
|
||||
|
||||
await page.waitForFunction(() => (
|
||||
typeof window.__mnoteSidebarPageAiRuntime?.openPageAiDrawer === "function"
|
||||
|| document.querySelector("[data-testid='wolai-floating-ai']")
|
||||
|| document.querySelector("[data-testid='wolai-page-ai-drawer']")
|
||||
), null, { timeout: UI_TIMEOUT_MS });
|
||||
|
||||
const openedByRuntime = await page.evaluate(() => {
|
||||
try {
|
||||
localStorage.setItem("mnote.page_ai.openhub_host", "1");
|
||||
if (typeof window.__mnoteSidebarPageAiRuntime?.openPageAiDrawer === "function") {
|
||||
window.__mnoteSidebarPageAiRuntime.openPageAiDrawer();
|
||||
return true;
|
||||
}
|
||||
} catch {}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (!openedByRuntime) {
|
||||
const floating = page.locator("[data-testid='wolai-floating-ai']").first();
|
||||
if (await floating.isVisible({ timeout: 5_000 }).catch(() => false)) {
|
||||
await floating.click({ timeout: UI_TIMEOUT_MS });
|
||||
}
|
||||
}
|
||||
|
||||
await visibleAny(page, ["[data-testid='wolai-page-ai-drawer']"], "Page AI drawer");
|
||||
await page.waitForFunction(() => {
|
||||
const drawer = document.querySelector("[data-testid='wolai-page-ai-drawer']");
|
||||
return drawer instanceof HTMLElement && drawer.getAttribute("data-page-ai-openhub-host") === "true";
|
||||
}, null, { timeout: UI_TIMEOUT_MS }).catch((error) => {
|
||||
throw new SmokeFailure("mnote_page_ai_ui", "Page AI drawer 未切到 OpenHub host", {
|
||||
cause: error.message,
|
||||
drawerText: "",
|
||||
});
|
||||
});
|
||||
|
||||
await page.waitForFunction(() => {
|
||||
const iframe = document.querySelector("iframe[data-page-ai-openhub-iframe]");
|
||||
return iframe instanceof HTMLIFrameElement && (iframe.getAttribute("src") || "").includes("/page-ai/openhub/ai");
|
||||
}, null, { timeout: UI_TIMEOUT_MS }).catch((error) => {
|
||||
throw new SmokeFailure("mnote_page_ai_ui", "OpenHub iframe 未挂载或 src 未指向 /page-ai/openhub/ai", { cause: error.message });
|
||||
});
|
||||
}
|
||||
|
||||
async function getOpenHubFrame(page) {
|
||||
const deadline = Date.now() + UI_TIMEOUT_MS;
|
||||
let lastState = null;
|
||||
while (Date.now() < deadline) {
|
||||
const handle = await page.locator("iframe[data-page-ai-openhub-iframe]").first().elementHandle().catch(() => null);
|
||||
if (handle) {
|
||||
const frame = await handle.contentFrame();
|
||||
lastState = {
|
||||
iframeSrc: await handle.getAttribute("src").catch(() => ""),
|
||||
frameUrl: frame ? frame.url() : "",
|
||||
};
|
||||
if (frame && frame.url().includes("/page-ai/openhub/ai")) {
|
||||
await frame.waitForLoadState("domcontentloaded", { timeout: 10_000 }).catch(() => undefined);
|
||||
return frame;
|
||||
}
|
||||
}
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
throw new SmokeFailure("openhub_iframe_ui", "无法取得 OpenHub iframe frame", lastState || {});
|
||||
}
|
||||
|
||||
async function collectFrameState(frame, marker) {
|
||||
return frame.evaluate((expectedMarker) => {
|
||||
const text = (selector) => (document.querySelector(selector)?.textContent || "").replace(/\s+/g, " ").trim();
|
||||
const bodyText = (document.body?.textContent || "").replace(/\s+/g, " ").trim();
|
||||
const drawerText = Array.from(document.querySelectorAll(".ant-drawer, [role='dialog']"))
|
||||
.map((node) => (node.textContent || "").replace(/\s+/g, " ").trim())
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
const messageAreaText = text(".messages-area");
|
||||
const historyButtons = Array.from(document.querySelectorAll("button"))
|
||||
.map((button) => (button.textContent || button.getAttribute("title") || button.getAttribute("aria-label") || "").replace(/\s+/g, " ").trim())
|
||||
.filter(Boolean);
|
||||
return {
|
||||
url: location.href,
|
||||
title: document.title,
|
||||
bodySnippet: bodyText.slice(0, 1200),
|
||||
iframeShellKind: document.body?.getAttribute("data-mnote-openhub-ai-shell") || "",
|
||||
hasReactRoot: Boolean(document.getElementById("root")),
|
||||
hasOpenHubTitle: bodyText.includes("OpenHub 平台"),
|
||||
hasHistoryButton: historyButtons.some((entry) => entry.includes("历史记录")),
|
||||
historyButtons,
|
||||
drawerTextSnippet: drawerText.slice(0, 1200),
|
||||
messageAreaSnippet: messageAreaText.slice(0, 1200),
|
||||
bodyHasMarker: bodyText.includes(expectedMarker),
|
||||
drawerHasMarker: drawerText.includes(expectedMarker),
|
||||
messageAreaHasMarker: messageAreaText.includes(expectedMarker),
|
||||
staticBoundaryVisible: /静态占位|static-boundary|最小 host\/bootstrap 占位/.test(bodyText),
|
||||
loginVisible: /(登录\s*OpenHub|OpenHub\s*Login|Sign in to OpenHub|WeKnora\s*登录)/i.test(bodyText),
|
||||
};
|
||||
}, marker);
|
||||
}
|
||||
|
||||
function scopeQueryFromFrameUrl(frameUrl) {
|
||||
const url = new URL(frameUrl, BASE_URL);
|
||||
const query = url.searchParams.toString();
|
||||
if (!query || !url.searchParams.get("scope") || !url.searchParams.get("mnoteScope")) {
|
||||
throw new SmokeFailure("openhub_scope", "OpenHub iframe URL 缺少 scope/mnoteScope", { frameUrl });
|
||||
}
|
||||
return `?${query}`;
|
||||
}
|
||||
|
||||
function parseStreamEvents(streamText) {
|
||||
const eventTypes = [];
|
||||
let returnedSessionId = "";
|
||||
let errorEvent = "";
|
||||
for (const line of String(streamText || "").split(/\r?\n/)) {
|
||||
if (!line.startsWith("data: ")) continue;
|
||||
try {
|
||||
const data = JSON.parse(line.slice(6));
|
||||
const payload = data.payload && data.payload.type
|
||||
? { type: data.payload.type, ...data.payload.properties }
|
||||
: data;
|
||||
if (payload.type) eventTypes.push(payload.type);
|
||||
if (payload.conversation_id) returnedSessionId = payload.conversation_id;
|
||||
if (payload.error) errorEvent = String(payload.error);
|
||||
} catch {}
|
||||
}
|
||||
return {
|
||||
eventTypes: [...new Set(eventTypes)],
|
||||
returnedSessionId,
|
||||
errorEvent,
|
||||
snippet: String(streamText || "").slice(0, 1200),
|
||||
};
|
||||
}
|
||||
|
||||
async function sendMarkerMessage(requestContext, scopeQuery, sessionId, marker) {
|
||||
const prompt = `${marker} Page AI OpenHub history refresh smoke. 请只回复 ${marker},不要解释。`;
|
||||
const body = {
|
||||
question: prompt,
|
||||
conversation_id: sessionId,
|
||||
agent: "build",
|
||||
};
|
||||
const url = openHubProxyUrl("/api/query/stream", scopeQuery);
|
||||
try {
|
||||
const response = await requestContext.post(url, {
|
||||
data: body,
|
||||
headers: { "content-type": "application/json" },
|
||||
timeout: STREAM_TIMEOUT_MS,
|
||||
});
|
||||
const text = await response.text();
|
||||
return {
|
||||
ok: response.ok(),
|
||||
status: response.status(),
|
||||
prompt,
|
||||
...parseStreamEvents(text),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 0,
|
||||
prompt,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchMessagesUntilMarker(requestContext, scopeQuery, sessionId, marker) {
|
||||
let lastPayload = null;
|
||||
let lastStatus = 0;
|
||||
for (let attempt = 0; attempt < 12; attempt += 1) {
|
||||
const response = await requestContext.get(openHubProxyUrl(`/api/sessions/${encodeURIComponent(sessionId)}/messages`, scopeQuery), {
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
lastStatus = response.status();
|
||||
const payload = await response.json().catch(async () => ({ nonJson: await response.text().catch(() => "") }));
|
||||
lastPayload = payload;
|
||||
const messages = Array.isArray(payload?.data) ? payload.data : [];
|
||||
const userMessage = messages.find((message) => message.role === "user" && String(message.content || "").includes(marker));
|
||||
if (response.ok() && userMessage) {
|
||||
return {
|
||||
ok: true,
|
||||
status: response.status(),
|
||||
count: messages.length,
|
||||
roles: messages.map((message) => message.role),
|
||||
userMessageContent: String(userMessage.content || ""),
|
||||
lastMessages: messages.slice(-3).map((message) => ({
|
||||
role: message.role,
|
||||
content: String(message.content || "").slice(0, 300),
|
||||
created_at: message.created_at,
|
||||
})),
|
||||
};
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
throw new SmokeFailure("openhub_api_messages", "OpenHub API 未读回带 marker 的 user message", {
|
||||
sessionId,
|
||||
marker,
|
||||
status: lastStatus,
|
||||
payloadSnippet: jsonSnippet(lastPayload),
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchSessionsUntilMarker(requestContext, scopeQuery, sessionId, marker) {
|
||||
let lastPayload = null;
|
||||
for (let attempt = 0; attempt < 8; attempt += 1) {
|
||||
const response = await requestContext.get(openHubProxyUrl("/api/sessions", scopeQuery, { page: 1, page_size: 10 }), {
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
const payload = await response.json().catch(async () => ({ nonJson: await response.text().catch(() => "") }));
|
||||
lastPayload = payload;
|
||||
const sessions = Array.isArray(payload?.data) ? payload.data : [];
|
||||
const found = sessions.find((session) => String(session.session_id || session.id || "") === sessionId);
|
||||
const markerTitle = sessions.find((session) => String(session.title || "").includes(marker));
|
||||
if (response.ok() && (found || markerTitle)) {
|
||||
return {
|
||||
ok: true,
|
||||
status: response.status(),
|
||||
total: payload?.pagination?.total,
|
||||
foundSession: found || markerTitle,
|
||||
markerInTitle: Boolean(markerTitle),
|
||||
firstSessions: sessions.slice(0, 5).map((session) => ({
|
||||
session_id: session.session_id || session.id || "",
|
||||
title: session.title || "",
|
||||
updated_at: session.updated_at || "",
|
||||
})),
|
||||
};
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
throw new SmokeFailure("openhub_api_history", "OpenHub sessions API 未读回对应 session/history", {
|
||||
sessionId,
|
||||
marker,
|
||||
payloadSnippet: jsonSnippet(lastPayload),
|
||||
});
|
||||
}
|
||||
|
||||
async function assertOpenHubReactUsable(frame, marker) {
|
||||
const state = await collectFrameState(frame, marker);
|
||||
if (state.staticBoundaryVisible) {
|
||||
throw new SmokeFailure("openhub_iframe_ui", "OpenHub iframe 仍是静态边界页,不能把 API-only 记为 UI 通过", state);
|
||||
}
|
||||
if (state.loginVisible) {
|
||||
throw new SmokeFailure("openhub_iframe_ui", "OpenHub iframe 出现独立登录入口", state);
|
||||
}
|
||||
if (!state.hasOpenHubTitle && !state.hasHistoryButton) {
|
||||
throw new SmokeFailure("openhub_iframe_ui", "OpenHub React AI 面板未渲染出历史入口", state);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
async function openHistoryAndVerify(frame, sessionId, marker) {
|
||||
let beforeHistoryState = await collectFrameState(frame, marker);
|
||||
if (beforeHistoryState.messageAreaHasMarker) {
|
||||
return {
|
||||
uiMarkerVerified: true,
|
||||
historyMarkerVerified: false,
|
||||
currentMessageMarkerVerified: true,
|
||||
beforeHistoryState,
|
||||
afterHistoryState: beforeHistoryState,
|
||||
afterSessionClickState: beforeHistoryState,
|
||||
};
|
||||
}
|
||||
|
||||
let historyButton = frame.getByRole("button", { name: /历史记录/ }).first();
|
||||
if (!(await historyButton.isVisible({ timeout: 3_000 }).catch(() => false))) {
|
||||
historyButton = frame.locator('button[title="历史记录"]').first();
|
||||
}
|
||||
if (!(await historyButton.isVisible({ timeout: 8_000 }).catch(() => false))) {
|
||||
throw new SmokeFailure("openhub_history_ui", "OpenHub iframe 内未找到历史记录按钮", beforeHistoryState);
|
||||
}
|
||||
await historyButton.click({ timeout: UI_TIMEOUT_MS });
|
||||
|
||||
let afterHistoryState = null;
|
||||
for (let attempt = 0; attempt < 12; attempt += 1) {
|
||||
afterHistoryState = await collectFrameState(frame, marker);
|
||||
if (afterHistoryState.drawerHasMarker || afterHistoryState.bodyHasMarker) break;
|
||||
if (attempt === 2) {
|
||||
const refreshButton = frame.getByRole("button", { name: /刷新/ }).first();
|
||||
if (await refreshButton.isVisible({ timeout: 1_000 }).catch(() => false)) {
|
||||
await refreshButton.click({ timeout: UI_TIMEOUT_MS }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
await frame.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
const historyMarkerVerified = Boolean(afterHistoryState && (afterHistoryState.drawerHasMarker || afterHistoryState.bodyHasMarker));
|
||||
if (!historyMarkerVerified) {
|
||||
throw new SmokeFailure("openhub_history_ui", "刷新后 OpenHub history/session UI 未显示 marker/session 标题", {
|
||||
sessionId,
|
||||
marker,
|
||||
beforeHistoryState,
|
||||
afterHistoryState,
|
||||
});
|
||||
}
|
||||
|
||||
const markerText = frame.getByText(marker, { exact: false }).first();
|
||||
if (await markerText.isVisible({ timeout: 5_000 }).catch(() => false)) {
|
||||
await markerText.click({ timeout: UI_TIMEOUT_MS }).catch(() => undefined);
|
||||
}
|
||||
|
||||
let afterSessionClickState = null;
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
afterSessionClickState = await collectFrameState(frame, marker);
|
||||
if (afterSessionClickState.messageAreaHasMarker) break;
|
||||
await frame.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
return {
|
||||
uiMarkerVerified: historyMarkerVerified || Boolean(afterSessionClickState?.messageAreaHasMarker),
|
||||
historyMarkerVerified,
|
||||
currentMessageMarkerVerified: Boolean(afterSessionClickState?.messageAreaHasMarker),
|
||||
beforeHistoryState,
|
||||
afterHistoryState,
|
||||
afterSessionClickState,
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const marker = `TASK786_MARKER_${suffix}`;
|
||||
const sessionId = `task786-openhub-history-${suffix}`;
|
||||
let browser;
|
||||
let context;
|
||||
let page;
|
||||
let result = {
|
||||
ok: false,
|
||||
task: TASK,
|
||||
baseUrl: BASE_URL,
|
||||
sessionId,
|
||||
marker,
|
||||
screenshotPath: SCREENSHOT_PATH,
|
||||
uiMarker: "",
|
||||
historyMarker: "",
|
||||
uiMarkerVerified: false,
|
||||
historyMarkerVerified: false,
|
||||
currentMessageMarkerVerified: false,
|
||||
};
|
||||
|
||||
try {
|
||||
await assertMNoteReachable();
|
||||
browser = await chromium.launch({
|
||||
headless: process.env.HEADFUL !== "1",
|
||||
...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
|
||||
});
|
||||
context = await browser.newContext({ viewport: { width: 1440, height: 960 }, locale: "zh-CN" });
|
||||
page = await context.newPage();
|
||||
|
||||
const viewer = await signIn(context.request);
|
||||
await openMNoteOpenHubDrawer(page);
|
||||
let frame = await getOpenHubFrame(page);
|
||||
const initialFrameState = await assertOpenHubReactUsable(frame, marker);
|
||||
const scopeQuery = scopeQueryFromFrameUrl(frame.url());
|
||||
|
||||
const sendStream = await sendMarkerMessage(context.request, scopeQuery, sessionId, marker);
|
||||
const apiMessages = await fetchMessagesUntilMarker(context.request, scopeQuery, sessionId, marker);
|
||||
const apiSessions = await fetchSessionsUntilMarker(context.request, scopeQuery, sessionId, marker);
|
||||
|
||||
await page.reload({ waitUntil: "commit", timeout: UI_TIMEOUT_MS });
|
||||
await openMNoteOpenHubDrawer(page);
|
||||
frame = await getOpenHubFrame(page);
|
||||
const refreshedFrameState = await assertOpenHubReactUsable(frame, marker);
|
||||
const ui = await openHistoryAndVerify(frame, sessionId, marker);
|
||||
|
||||
if (!ui.uiMarkerVerified) {
|
||||
throw new SmokeFailure("openhub_history_ui", "API 已读回 marker,但刷新后 UI 未恢复 marker/session", {
|
||||
sessionId,
|
||||
marker,
|
||||
ui,
|
||||
});
|
||||
}
|
||||
|
||||
await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true });
|
||||
result = {
|
||||
...result,
|
||||
ok: true,
|
||||
viewer,
|
||||
scopeQueryKeys: Array.from(new URLSearchParams(scopeQuery.slice(1)).keys()),
|
||||
sendStream,
|
||||
apiMessageVerified: true,
|
||||
apiHistoryVerified: true,
|
||||
apiMessages,
|
||||
apiSessions,
|
||||
initialFrameState,
|
||||
refreshedFrameState,
|
||||
uiMarkerVerified: ui.uiMarkerVerified,
|
||||
historyMarkerVerified: ui.historyMarkerVerified,
|
||||
currentMessageMarkerVerified: ui.currentMessageMarkerVerified,
|
||||
uiMarker: ui.currentMessageMarkerVerified ? marker : "",
|
||||
historyMarker: ui.historyMarkerVerified ? marker : "",
|
||||
beforeHistoryState: ui.beforeHistoryState,
|
||||
afterHistoryState: ui.afterHistoryState,
|
||||
afterSessionClickState: ui.afterSessionClickState,
|
||||
resultPath: RESULT_PATH,
|
||||
};
|
||||
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
|
||||
if (fs.existsSync(FAILURE_PATH)) fs.rmSync(FAILURE_PATH, { force: true });
|
||||
} catch (error) {
|
||||
if (page) {
|
||||
await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true }).catch(() => undefined);
|
||||
}
|
||||
result = {
|
||||
...result,
|
||||
ok: false,
|
||||
failureLayer: error instanceof SmokeFailure ? error.layer : "unexpected",
|
||||
error: error instanceof Error ? error.stack || error.message : String(error),
|
||||
details: error instanceof SmokeFailure ? error.details : undefined,
|
||||
failurePath: FAILURE_PATH,
|
||||
};
|
||||
fs.writeFileSync(FAILURE_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
|
||||
if (fs.existsSync(RESULT_PATH)) fs.rmSync(RESULT_PATH, { force: true });
|
||||
} finally {
|
||||
if (context) await context.close().catch(() => undefined);
|
||||
if (browser) await browser.close().catch(() => undefined);
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
if (!result.ok) process.exit(1);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
const failure = {
|
||||
ok: false,
|
||||
task: TASK,
|
||||
baseUrl: BASE_URL,
|
||||
failureLayer: "fatal",
|
||||
error: error instanceof Error ? error.stack || error.message : String(error),
|
||||
failurePath: FAILURE_PATH,
|
||||
};
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
fs.writeFileSync(FAILURE_PATH, `${JSON.stringify(failure, null, 2)}\n`, "utf8");
|
||||
console.error(JSON.stringify(failure, null, 2));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,101 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const repoRoot = path.resolve(__dirname, "..");
|
||||
const openHubRoot = process.env.OPENHUB_RESEARCH_ROOT || "/mnt/Data1T/Mnote_data/openhub/OpenHub";
|
||||
const files = {
|
||||
chatInput: path.join(openHubRoot, "smart-query-frontend/src/components/ChatInput.jsx"),
|
||||
embed: path.join(openHubRoot, "smart-query-frontend/src/mnoteEmbed.js"),
|
||||
api: path.join(openHubRoot, "smart-query-frontend/src/services/api.js"),
|
||||
queryModel: path.join(openHubRoot, "smart-query-backend/app/models/query.py"),
|
||||
queryApi: path.join(openHubRoot, "smart-query-backend/app/api/query.py"),
|
||||
stream: path.join(openHubRoot, "smart-query-backend/app/services/stream.py"),
|
||||
mnoteRoute: path.join(repoRoot, "rust/crates/mnote-web/src/routes/page_ai_openhub.rs"),
|
||||
};
|
||||
|
||||
function read(file) {
|
||||
return fs.readFileSync(file, "utf8");
|
||||
}
|
||||
|
||||
function assertCheck(failures, name, passed) {
|
||||
if (!passed) failures.push(name);
|
||||
}
|
||||
|
||||
const chatInput = read(files.chatInput);
|
||||
const embed = read(files.embed);
|
||||
const api = read(files.api);
|
||||
const queryModel = read(files.queryModel);
|
||||
const queryApi = read(files.queryApi);
|
||||
const stream = read(files.stream);
|
||||
const mnoteRoute = read(files.mnoteRoute);
|
||||
const failures = [];
|
||||
|
||||
assertCheck(
|
||||
failures,
|
||||
"OpenHub ChatInput natively owns MNote context toggles",
|
||||
chatInput.includes("requestMNoteActiveTabAddress") &&
|
||||
chatInput.includes("data-mnote-openhub-current-tab-toggle") &&
|
||||
chatInput.includes("data-mnote-openhub-current-folder-toggle") &&
|
||||
chatInput.includes("mnoteContextMode === 'tab'") &&
|
||||
chatInput.includes("mnoteContextMode === 'folder'") &&
|
||||
chatInput.includes("handleSendWithMNoteContext")
|
||||
);
|
||||
assertCheck(
|
||||
failures,
|
||||
"toggles live beside model quota controls and do not write textarea",
|
||||
chatInput.indexOf("model?.monthlyLimit") < chatInput.indexOf("data-mnote-openhub-current-tab-toggle") &&
|
||||
chatInput.includes("handleSend(undefined, context ?") &&
|
||||
!chatInput.includes("setQuestion(context.value") &&
|
||||
!chatInput.includes("setQuestion(mnoteContext")
|
||||
);
|
||||
assertCheck(
|
||||
failures,
|
||||
"OpenHub mnoteEmbed requests active tab/folder from MNote host",
|
||||
embed.includes("export const requestMNoteActiveTabAddress") &&
|
||||
embed.includes("mnote:get-active-tab-address") &&
|
||||
embed.includes("mnote:active-tab-address") &&
|
||||
embed.includes("kind === 'folder' ? 'folder' : 'tab'")
|
||||
);
|
||||
assertCheck(
|
||||
failures,
|
||||
"OpenHub request body carries hidden mnote_context",
|
||||
api.includes("mnoteContext = null") &&
|
||||
api.includes("requestBody.mnote_context = mnoteContext")
|
||||
);
|
||||
assertCheck(
|
||||
failures,
|
||||
"OpenHub backend accepts and forwards mnote_context",
|
||||
queryModel.includes("mnote_context: Optional[dict]") &&
|
||||
queryApi.includes("mnote_context=request.mnote_context") &&
|
||||
stream.includes("mnote_context: Optional[dict] = None")
|
||||
);
|
||||
assertCheck(
|
||||
failures,
|
||||
"OpenHub stream prepends hidden current page/folder context before prompt",
|
||||
stream.includes("<mnote_current_context>") &&
|
||||
stream.includes("当前文件夹") &&
|
||||
stream.includes("当前页面") &&
|
||||
stream.includes("context_parts.append") &&
|
||||
stream.includes("_build_mnote_context_block") &&
|
||||
stream.includes("_mnote_context_from_scope") &&
|
||||
stream.includes("Effective MNote context") &&
|
||||
stream.includes("Sent prompt preview")
|
||||
);
|
||||
assertCheck(
|
||||
failures,
|
||||
"MNote proxy no longer injects floating quick action overlay",
|
||||
!mnoteRoute.includes("data-mnote-openhub-ai-quick-actions") &&
|
||||
!mnoteRoute.includes("mnote_openhub_bridge_markup") &&
|
||||
!mnoteRoute.includes("insertAddress(")
|
||||
);
|
||||
|
||||
if (failures.length) {
|
||||
console.error("OpenHub native MNote context static smoke failed:");
|
||||
for (const failure of failures) console.error(`- ${failure}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("OpenHub native MNote context static smoke passed.");
|
||||
@@ -1,169 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const repoRoot = path.resolve(__dirname, "..");
|
||||
const openHubRoot =
|
||||
process.env.OPENHUB_RESEARCH_ROOT || "/mnt/Data1T/Mnote_data/openhub/OpenHub";
|
||||
|
||||
const files = {
|
||||
chatInput: path.join(openHubRoot, "smart-query-frontend/src/components/ChatInput.jsx"),
|
||||
smartQueryPage: path.join(openHubRoot, "smart-query-frontend/src/pages/SmartQueryPage.jsx"),
|
||||
api: path.join(openHubRoot, "smart-query-frontend/src/services/api.js"),
|
||||
openHubFrontendSrc: path.join(openHubRoot, "smart-query-frontend/src"),
|
||||
mnoteOpenHubRoute: path.join(repoRoot, "rust/crates/mnote-web/src/routes/page_ai_openhub.rs"),
|
||||
};
|
||||
|
||||
function read(file) {
|
||||
return fs.readFileSync(file, "utf8");
|
||||
}
|
||||
|
||||
function walkFiles(dir, result = []) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
walkFiles(fullPath, result);
|
||||
} else {
|
||||
result.push(fullPath);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function extractBetween(source, startNeedle, endNeedle) {
|
||||
const start = source.indexOf(startNeedle);
|
||||
if (start < 0) return "";
|
||||
const end = source.indexOf(endNeedle, start);
|
||||
return source.slice(start, end < 0 ? undefined : end);
|
||||
}
|
||||
|
||||
function check(failures, name, passed, detail = "") {
|
||||
if (!passed) {
|
||||
failures.push(detail ? `${name}: ${detail}` : name);
|
||||
}
|
||||
}
|
||||
|
||||
const chatInput = read(files.chatInput);
|
||||
const smartQueryPage = read(files.smartQueryPage);
|
||||
const api = read(files.api);
|
||||
const controlRow = extractBetween(chatInput, "<AgentModeToggle", "{currentTodos");
|
||||
const handleMNoteSend = extractBetween(chatInput, "const handleSendWithMNoteContext", "useEffect(() =>");
|
||||
const handleSend = extractBetween(smartQueryPage, "const handleSend = async", "const handleKeyPress");
|
||||
|
||||
const legacyFloatingSelectors = [
|
||||
"mnote-openhub-context-bar",
|
||||
"mnote-openhub-native-context",
|
||||
"data-mnote-openhub-ai-quick-actions",
|
||||
"mnote_openhub_bridge_markup",
|
||||
"insertAddress(",
|
||||
];
|
||||
|
||||
const frontendLegacyHits = [];
|
||||
for (const file of walkFiles(files.openHubFrontendSrc)) {
|
||||
const rel = path.relative(openHubRoot, file);
|
||||
const source = read(file);
|
||||
for (const selector of legacyFloatingSelectors) {
|
||||
if (source.includes(selector)) {
|
||||
frontendLegacyHits.push(`${rel} -> ${selector}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const mnoteRoute = fs.existsSync(files.mnoteOpenHubRoute) ? read(files.mnoteOpenHubRoute) : "";
|
||||
const mnoteRouteLegacyHits = legacyFloatingSelectors.filter((selector) =>
|
||||
mnoteRoute.includes(selector)
|
||||
);
|
||||
|
||||
const failures = [];
|
||||
|
||||
check(
|
||||
failures,
|
||||
"ChatInput owns two native MNote context buttons",
|
||||
chatInput.includes("isMNoteOpenHubEmbed()") &&
|
||||
chatInput.includes("requestMNoteActiveTabAddress") &&
|
||||
chatInput.includes("data-mnote-openhub-current-tab-toggle") &&
|
||||
chatInput.includes("data-mnote-openhub-current-folder-toggle"),
|
||||
"missing embed gate, context request helper, or native button data attributes"
|
||||
);
|
||||
|
||||
check(
|
||||
failures,
|
||||
"MNote context buttons are in the controls row beside agent/model controls",
|
||||
controlRow.includes("<AgentModeToggle") &&
|
||||
controlRow.includes("<ModelSelect") &&
|
||||
controlRow.includes("data-mnote-openhub-current-tab-toggle") &&
|
||||
controlRow.includes("data-mnote-openhub-current-folder-toggle") &&
|
||||
controlRow.indexOf("<AgentModeToggle") < controlRow.indexOf("data-mnote-openhub-current-tab-toggle") &&
|
||||
controlRow.indexOf("<ModelSelect") < controlRow.indexOf("data-mnote-openhub-current-folder-toggle"),
|
||||
"native buttons must stay in the same compact controls row as agent toggle/model select"
|
||||
);
|
||||
|
||||
check(
|
||||
failures,
|
||||
"Icon-only buttons use tooltips and aria labels for current page/tab and folder",
|
||||
/Tooltip\s+title=["{][^"'}]*(当前(?:页面|激活\s*Tab|Tab)|current\s*(?:page|tab))/i.test(controlRow) &&
|
||||
/Tooltip\s+title=["{][^"'}]*(当前(?:文件夹|激活\s*Tab\s*的文件夹)|current\s*folder|folder)/i.test(controlRow) &&
|
||||
/aria-label="当前页面"/.test(controlRow) &&
|
||||
/aria-label="文件夹"/.test(controlRow) &&
|
||||
/icon=\{<FileTextOutlined\s*\/>\}/.test(controlRow) &&
|
||||
/icon=\{<FolderOpenOutlined\s*\/>\}/.test(controlRow) &&
|
||||
!/>当前页面<\/Button>/.test(controlRow) &&
|
||||
!/>文件夹<\/Button>/.test(controlRow),
|
||||
"expected two icon-only buttons with distinct tooltip and aria labels"
|
||||
);
|
||||
|
||||
check(
|
||||
failures,
|
||||
"ChatInput sends mnote_context as hidden metadata instead of writing URL into textarea question",
|
||||
/handleSend\(undefined,\s*context\s*\?\s*\{/.test(handleMNoteSend) &&
|
||||
handleMNoteSend.includes("kind: mnoteContextMode") &&
|
||||
handleMNoteSend.includes("value: context.value") &&
|
||||
!/setQuestion\s*\(\s*context\./.test(handleMNoteSend) &&
|
||||
!/setQuestion\s*\(\s*mnoteContext/.test(handleMNoteSend) &&
|
||||
!/question\s*=\s*context\.value/.test(handleMNoteSend),
|
||||
"context must flow through handleSend second argument and must not mutate the visible question value"
|
||||
);
|
||||
|
||||
check(
|
||||
failures,
|
||||
"SmartQueryPage accepts mnote_context and forwards it to queryDataService.queryDataStream",
|
||||
/const\s+handleSend\s*=\s*async\s*\(\s*overrideQuestion\s*,\s*mnoteContext\s*=\s*null\s*\)/.test(
|
||||
smartQueryPage
|
||||
) &&
|
||||
handleSend.includes("queryDataService.queryDataStream(") &&
|
||||
/abortControllerRef\.current\.signal\s*,\s*mnoteContext/.test(handleSend),
|
||||
"handleSend must accept mnoteContext and pass it through the stream send call"
|
||||
);
|
||||
|
||||
check(
|
||||
failures,
|
||||
"OpenHub send body carries hidden mnote_context",
|
||||
/queryDataStream:\s*async\s*\([^)]*mnoteContext\s*=\s*null/.test(api) &&
|
||||
api.includes("requestBody.mnote_context = mnoteContext") &&
|
||||
!/question\s*:\s*.*mnoteContext/.test(api),
|
||||
"api.js must put mnoteContext into requestBody.mnote_context, not append it to question"
|
||||
);
|
||||
|
||||
check(
|
||||
failures,
|
||||
"OpenHub frontend no longer depends on legacy MNote floating button selectors",
|
||||
frontendLegacyHits.length === 0,
|
||||
frontendLegacyHits.join("; ")
|
||||
);
|
||||
|
||||
check(
|
||||
failures,
|
||||
"MNote OpenHub route no longer injects legacy floating context buttons",
|
||||
mnoteRouteLegacyHits.length === 0,
|
||||
mnoteRouteLegacyHits.join("; ")
|
||||
);
|
||||
|
||||
if (failures.length) {
|
||||
console.error("OpenHub native MNote context source smoke failed:");
|
||||
for (const failure of failures) console.error(`- ${failure}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("OpenHub native MNote context source smoke passed.");
|
||||
@@ -1,225 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const http = require("node:http");
|
||||
const { spawn } = require("node:child_process");
|
||||
|
||||
const OPENHUB_BACKEND_DIR = process.env.OPENHUB_BACKEND_DIR || "/mnt/Data1T/Mnote_data/openhub/OpenHub/smart-query-backend";
|
||||
const OPENCODE_PORT = Number(process.env.TASK793_OPENCODE_PORT || 19096);
|
||||
const OPENHUB_PORT = Number(process.env.TASK793_OPENHUB_PORT || 18181);
|
||||
const SESSION_ID = "ses_mnote_context_fullchain";
|
||||
|
||||
function wait(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function readBody(req) {
|
||||
return new Promise((resolve) => {
|
||||
let data = "";
|
||||
req.on("data", (chunk) => { data += chunk; });
|
||||
req.on("end", () => resolve(data));
|
||||
});
|
||||
}
|
||||
|
||||
function sseEvent(type, properties) {
|
||||
return `data: ${JSON.stringify({ payload: { type, properties } })}\n\n`;
|
||||
}
|
||||
|
||||
function startFakeOpencode() {
|
||||
const capturedPrompts = [];
|
||||
let eventResponse = null;
|
||||
function sendEvents() {
|
||||
if (!eventResponse) return false;
|
||||
eventResponse.write(sseEvent("message.updated", {
|
||||
sessionID: SESSION_ID,
|
||||
info: { id: "msg_assistant", role: "assistant", sessionID: SESSION_ID },
|
||||
}));
|
||||
eventResponse.write(sseEvent("message.part.updated", {
|
||||
sessionID: SESSION_ID,
|
||||
part: { id: "prt_text", messageID: "msg_assistant", sessionID: SESSION_ID, type: "text", text: "OK" },
|
||||
}));
|
||||
eventResponse.write(sseEvent("session.status", {
|
||||
sessionID: SESSION_ID,
|
||||
status: { type: "idle" },
|
||||
}));
|
||||
eventResponse.end();
|
||||
eventResponse = null;
|
||||
return true;
|
||||
}
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const url = new URL(req.url, `http://127.0.0.1:${OPENCODE_PORT}`);
|
||||
if (req.method === "GET" && url.pathname === "/global/health") {
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
return;
|
||||
}
|
||||
if (req.method === "POST" && url.pathname === "/session") {
|
||||
await readBody(req);
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify({ id: SESSION_ID }));
|
||||
return;
|
||||
}
|
||||
if (req.method === "GET" && url.pathname === "/global/event") {
|
||||
res.writeHead(200, {
|
||||
"content-type": "text/event-stream; charset=utf-8",
|
||||
"cache-control": "no-cache",
|
||||
connection: "keep-alive",
|
||||
});
|
||||
eventResponse = res;
|
||||
eventResponse.write(`data: ${JSON.stringify({ payload: { type: "ready", properties: { sessionID: SESSION_ID } } })}\n\n`);
|
||||
if (capturedPrompts.length) {
|
||||
setTimeout(sendEvents, 50);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (req.method === "POST" && url.pathname === `/session/${SESSION_ID}/prompt_async`) {
|
||||
const body = JSON.parse(await readBody(req) || "{}");
|
||||
capturedPrompts.push(body);
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
setTimeout(sendEvents, 50);
|
||||
return;
|
||||
}
|
||||
res.writeHead(404, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "not_found", path: url.pathname }));
|
||||
});
|
||||
return {
|
||||
capturedPrompts,
|
||||
listen: () => new Promise((resolve) => server.listen(OPENCODE_PORT, "127.0.0.1", resolve)),
|
||||
close: () => new Promise((resolve) => server.close(resolve)),
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForHealth(url, timeoutMs = 20_000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const response = await fetch(url, { signal: AbortSignal.timeout(2_000) });
|
||||
if (response.status < 500) return;
|
||||
} catch {}
|
||||
await wait(250);
|
||||
}
|
||||
throw new Error(`等待服务超时: ${url}`);
|
||||
}
|
||||
|
||||
async function sendOpenHub(payload, extraHeaders = {}) {
|
||||
const body = JSON.stringify(payload);
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request({
|
||||
hostname: "127.0.0.1",
|
||||
port: OPENHUB_PORT,
|
||||
path: "/api/query/stream",
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"content-length": Buffer.byteLength(body),
|
||||
"x-mnote-user-key": "openhub_user_test",
|
||||
"x-mnote-workspace-key": "workspace_test",
|
||||
"x-mnote-session-scope": `session_${Date.now()}_${Math.random().toString(16).slice(2)}`,
|
||||
"x-mnote-root-uri": "file:///tmp/mnote-fullchain-workspace",
|
||||
"x-mnote-page-resource-id": "local-md:Inbox~2FPage.md",
|
||||
...extraHeaders,
|
||||
},
|
||||
}, (res) => {
|
||||
let text = "";
|
||||
const timeout = setTimeout(() => {
|
||||
req.destroy();
|
||||
resolve(text);
|
||||
}, 10_000);
|
||||
res.setEncoding("utf8");
|
||||
res.on("data", (chunk) => {
|
||||
text += chunk;
|
||||
if (text.includes("message_complete")) {
|
||||
clearTimeout(timeout);
|
||||
req.destroy();
|
||||
resolve(text);
|
||||
}
|
||||
});
|
||||
res.on("end", () => {
|
||||
clearTimeout(timeout);
|
||||
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||
reject(new Error(`OpenHub HTTP ${res.statusCode}: ${text.slice(0, 500)}`));
|
||||
} else {
|
||||
resolve(text);
|
||||
}
|
||||
});
|
||||
});
|
||||
req.on("error", (error) => {
|
||||
if (error.code === "ECONNRESET") return;
|
||||
reject(error);
|
||||
});
|
||||
req.write(body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const fakeOpencode = startFakeOpencode();
|
||||
let uvicorn = null;
|
||||
let stderr = "";
|
||||
await fakeOpencode.listen();
|
||||
try {
|
||||
uvicorn = spawn(".venv/bin/uvicorn", ["app.main:app", "--host", "127.0.0.1", "--port", String(OPENHUB_PORT)], {
|
||||
cwd: OPENHUB_BACKEND_DIR,
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCODE_BASE_URL: `http://localhost:${OPENCODE_PORT}`,
|
||||
MNOTE_OPENHUB_TOOL_BRIDGE_AUTO: "0",
|
||||
SQLITE_DB_PATH: "/tmp/openhub-fullchain-smoke.db",
|
||||
NO_PROXY: "127.0.0.1,localhost",
|
||||
no_proxy: "127.0.0.1,localhost",
|
||||
},
|
||||
stdio: ["ignore", "ignore", "pipe"],
|
||||
});
|
||||
uvicorn.stderr.on("data", (chunk) => { stderr += String(chunk); });
|
||||
await waitForHealth(`http://127.0.0.1:${OPENHUB_PORT}/api/health`);
|
||||
|
||||
const tabUrl = "http://127.0.0.1:3000/documents/local-md:Inbox~2FPage.md?sourceKind=local_folder&rootUri=file:///tmp/mnote-fullchain-workspace";
|
||||
await sendOpenHub({
|
||||
question: "只回答 OK",
|
||||
conversation_id: "",
|
||||
agent: "build",
|
||||
mnote_context: {
|
||||
kind: "tab",
|
||||
value: tabUrl,
|
||||
tabUrl,
|
||||
rootUri: "file:///tmp/mnote-fullchain-workspace",
|
||||
documentId: "local-md:Inbox~2FPage.md",
|
||||
relativePath: "Inbox/Page.md",
|
||||
},
|
||||
});
|
||||
await wait(200);
|
||||
await sendOpenHub({ question: "只回答 OK fallback", conversation_id: "", agent: "build" });
|
||||
await wait(200);
|
||||
|
||||
const explicitPrompt = fakeOpencode.capturedPrompts[0]?.parts?.[0]?.text || "";
|
||||
const fallbackPrompt = fakeOpencode.capturedPrompts[1]?.parts?.[0]?.text || "";
|
||||
const result = {
|
||||
ok: false,
|
||||
promptCount: fakeOpencode.capturedPrompts.length,
|
||||
explicitContextOk: explicitPrompt.includes("<mnote_current_context>")
|
||||
&& explicitPrompt.includes("当前页面: http://127.0.0.1:3000/documents/")
|
||||
&& explicitPrompt.includes("relativePath: Inbox/Page.md"),
|
||||
fallbackContextOk: fallbackPrompt.includes("<mnote_current_context>")
|
||||
&& fallbackPrompt.includes("source: mnote_scope_fallback")
|
||||
&& fallbackPrompt.includes("file:///tmp/mnote-fullchain-workspace"),
|
||||
explicitPromptPreview: explicitPrompt.slice(0, 700),
|
||||
fallbackPromptPreview: fallbackPrompt.slice(0, 700),
|
||||
};
|
||||
result.ok = result.promptCount >= 2 && result.explicitContextOk && result.fallbackContextOk;
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
if (!result.ok) process.exitCode = 1;
|
||||
} finally {
|
||||
if (uvicorn) {
|
||||
uvicorn.kill("SIGTERM");
|
||||
await wait(400);
|
||||
}
|
||||
await fakeOpencode.close();
|
||||
if (process.exitCode) process.stderr.write(stderr.slice(-3000));
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error.stack || error.message);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user