chore: retire openhub and pi ts runtime

This commit is contained in:
Agent Board
2026-07-12 14:18:26 +08:00
parent 62959b0c4d
commit 2f5902e3e7
45 changed files with 367 additions and 925 deletions
+5 -5
View File
@@ -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 Aggregatelocal-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 guardHermes 页面 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 guardHermes 页面 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
View File
@@ -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_HOSTOpenHub FastAPI 监听地址,默认 0.0.0.0 便于局域网访问
* - OPENHUB_PORT / OPENHUB_BACKEND_PORTOpenHub 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 URLdev-hot 只检查,不释放或结束 OpenHub 相关端口
* - OPENHUB_OPENCODE_BASE_URLOpenHub 侧 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_HOMEopencode 专用运行目录
* - MNOTE_CONTROL_PLANE_BACKEND:控制面后端,默认 libsql-local;可设 turso-remote / turso-local-replica / turso-synced
* - MNOTE_TURSO_LOCAL_PATHlibsql-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 FastAPISKIP_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,
};
+3 -48
View File
@@ -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/);
-21
View File
@@ -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",
+104 -44
View File
@@ -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"
);
// --------------------------------------------------------------------------
+5 -14
View File
@@ -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");
+2 -2
View File
@@ -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' };
+6 -30
View File
@@ -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 });
+3 -3
View File
@@ -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");
+6 -6
View File
@@ -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')],
+1 -1
View File
@@ -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} 返回非 JSONHTTP ${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);
});