集成 OpenHub 与 WeKnora Page AI

This commit is contained in:
Agent Board
2026-06-26 20:01:02 +08:00
parent dabaf03bd7
commit e433c07061
63 changed files with 12678 additions and 332 deletions
+141
View File
@@ -9,6 +9,15 @@
* - 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_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 写链
* - PYTHON_BIN:只在 BACKEND_CMD 未覆盖时,设置 Python 可执行文件,默认 "python"
*/
@@ -49,6 +58,7 @@ 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);
function hasCommand(command) {
try {
@@ -76,6 +86,11 @@ function buildDefaultOpencodeCommand(port) {
return `while true; do script -qfec "opencode serve --hostname=127.0.0.1 --port ${port} --print-logs" /dev/null; sleep 1; done`;
}
function buildDefaultOpenHubCommand(port) {
const openhubBackendDir = process.env.OPENHUB_BACKEND_DIR || "/tmp/mnote-openhub-research/OpenHub/smart-query-backend";
return `cd ${JSON.stringify(openhubBackendDir)} && uvicorn app.main:app --host 127.0.0.1 --port ${port}`;
}
function isEnabledEnv(value) {
const normalized = String(value || "").toLowerCase();
return normalized === "1" || normalized === "true";
@@ -93,6 +108,54 @@ 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}/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;
@@ -107,6 +170,8 @@ function resolveRuntimePlan(env = process.env) {
MNOTE_WEB_BIND: env.MNOTE_WEB_BIND || `0.0.0.0:${publicPort}`,
MNOTE_WEB_PUBLIC_BIND: env.MNOTE_WEB_PUBLIC_BIND || `127.0.0.1:${publicPort}`,
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",
},
};
}
@@ -148,6 +213,18 @@ const tasks = [
},
]
: []),
...(shouldStartOpenHub(process.env)
? [
{
name: "openhub",
command:
process.env.OPENHUB_BACKEND_CMD ||
process.env.OPENHUB_CMD ||
buildDefaultOpenHubCommand(openhubPortFromEnv),
cwd: rootDir,
},
]
: []),
];
function findTask(name) {
@@ -363,6 +440,50 @@ 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 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");
@@ -519,6 +640,22 @@ async function main() {
}
if (shouldStartOpenHub(process.env) && !process.env.OPENHUB_BACKEND_CMD && !process.env.OPENHUB_CMD) {
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);
@@ -537,12 +674,16 @@ if (require.main === module) {
}
module.exports = {
buildDefaultOpenHubCommand,
checkOpenHubHealth,
ensurePortFree,
getListeningPidsByPort,
getProcessNameByPid,
isPortFree,
resolveOpenHubHealthPlan,
resolveRuntimePlan,
resolveBackendExecutable,
shouldStartBackend,
shouldStartOpenHub,
terminatePid,
};
+42
View File
@@ -3,11 +3,14 @@ const { spawn } = require("node:child_process");
const net = require("node:net");
const { test } = require("node:test");
const {
buildDefaultOpenHubCommand,
resolveBackendExecutable,
ensurePortFree,
isPortFree,
resolveOpenHubHealthPlan,
resolveRuntimePlan,
shouldStartBackend,
shouldStartOpenHub,
} = require("./desktop-hot.js");
function findFreePort() {
@@ -127,6 +130,8 @@ test("默认热启动计划只使用 mnote-web 作为 3000 owner", () => {
MNOTE_WEB_BIND: "0.0.0.0:3000",
MNOTE_WEB_PUBLIC_BIND: "127.0.0.1:3000",
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",
});
});
@@ -137,3 +142,40 @@ test("默认跳过 FastAPI 后端,只有显式开启时才启动", () => {
assert.equal(shouldStartBackend({ BACKEND_CMD: "custom-backend" }), true);
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/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, /uvicorn app\.main:app/);
assert.match(command, /--port 18082/);
assert.doesNotMatch(command, /snapshot|restore|revert|kill-port/i);
});
@@ -55,7 +55,7 @@ function ensureOwnerPage() {
if (!fs.existsSync(ownerPath)) {
fs.writeFileSync(
ownerPath,
["# Page AI Knowledge RAG Smoke", "", "This page is a stable Page AI smoke target for LightRAG retrieval.", ""].join("\n"),
["# Page AI Knowledge RAG Smoke", "", "This page is a stable Page AI smoke target for WeKnora retrieval.", ""].join("\n"),
"utf8",
);
}
@@ -75,9 +75,9 @@ async function main() {
});
assert(knowledgeSearch.ok(), `资料库短 query 检索失败: ${knowledgeSearch.status()} ${await knowledgeSearch.text()}`);
const knowledgePayload = await knowledgeSearch.json();
assert((knowledgePayload.results?.length || 0) >= 8, `资料库 2 字 query 应返回段落去重后的 LightRAG 检索命中: ${JSON.stringify(knowledgePayload, null, 2).slice(0, 2000)}`);
assert((knowledgePayload.results?.length || 0) >= 8, `资料库 2 字 query 应返回段落去重后的 WeKnora 检索命中: ${JSON.stringify(knowledgePayload, null, 2).slice(0, 2000)}`);
assert(String(knowledgePayload.results?.[0]?.snippet || "").includes(SHORT_QUERY), `资料库结果未包含 ${SHORT_QUERY}: ${JSON.stringify(knowledgePayload.results?.[0], null, 2)}`);
assert(knowledgePayload.references?.some((reference) => reference.matchSource === "lightrag_search"), `2 字 query 应走 LightRAG search provider: ${JSON.stringify(knowledgePayload.references?.slice(0, 3), null, 2)}`);
assert(knowledgePayload.references?.some((reference) => reference.provider === "weknora" || reference.matchSource === "weknora_search"), `2 字 query 应走 WeKnora search provider: ${JSON.stringify(knowledgePayload.references?.slice(0, 3), null, 2)}`);
const firstKnowledgeResult = knowledgePayload.results?.[0] || {};
const firstKnowledgeCitation = knowledgePayload.citations?.[0] || {};
assert(firstKnowledgeResult.displayQuote && firstKnowledgeResult.locatorEvidenceText, `搜索结果缺少 displayQuote/locatorEvidenceText: ${JSON.stringify(firstKnowledgeResult, null, 2)}`);
@@ -0,0 +1,122 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const fs = require("node:fs");
const path = require("node:path");
const ROOT = process.cwd();
const OUTPUT_DIR = path.join(ROOT, "tmp", "task544-weknora-provider-bridge-static-smoke");
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
function read(relativePath) {
return fs.readFileSync(path.join(ROOT, relativePath), "utf8");
}
function assertContains(content, needle, label) {
assert(content.includes(needle), `${label} 缺少: ${needle}`);
}
function assertNotContains(content, needle, label) {
assert(!content.includes(needle), `${label} 不应包含: ${needle}`);
}
function main() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const route = read("rust/crates/mnote-web/src/routes/knowledge_rag.rs");
const hermesKnowledge = read("rust/crates/mnote-web/src/hermes_tools/knowledge_rag.rs");
const manifest = read("rust/crates/mnote-web/src/hermes_tools/manifest.rs");
const toolRoute = read("rust/crates/mnote-web/src/routes/hermes_tools.rs");
const ui = read("rust/crates/mnote-web/browser/sidebar-page-settings-runtime.js");
assertContains(route, 'const DEFAULT_KNOWLEDGE_PROVIDER: &str = "weknora";', "provider boundary");
assertContains(route, 'const LEGACY_KNOWLEDGE_PROVIDER: &str = "lightrag_legacy";', "legacy provider boundary");
assertContains(route, '"/knowledge-search"', "WeKnora endpoint");
assertContains(route, '"knowledge_base_ids"', "WeKnora request shape");
assertContains(route, '"knowledge_ids"', "WeKnora request shape");
assertContains(route, '"match_count"', "WeKnora request shape");
assertContains(route, '"providerConfig": knowledge_provider_config()', "status provider config");
assertContains(route, '"health": weknora_health(&context).await', "WeKnora health");
assertContains(route, '"registry": registry', "status registry");
assertContains(route, '"providerIds"', "WeKnora reference mapping");
assertContains(route, '"providerFilenameIsNotLocalPath": true', "WeKnora locator diagnostics");
assertContains(route, '"locatorDegraded": locator_degraded', "WeKnora degraded locator");
assertContains(route, '"reference": chunk', "WeKnora raw reference retention");
assertContains(route, 'Value::Null', "WeKnora filePath null guard");
assertContains(route, '"chunkMetadata"', "WeKnora metadata retention");
assertContains(route, '"imageInfo"', "WeKnora image info retention");
assertContains(route, '"parentChunk"', "WeKnora parent chunk retention");
assertContains(route, '"subChunks"', "WeKnora sub chunk retention");
assertContains(route, "ingest_weknora_registry_only", "WeKnora ingest branch");
assertContains(route, "upload_weknora_file", "WeKnora provider upload");
assertContains(route, '/knowledge-bases/{}/knowledge/file', "WeKnora file ingest endpoint");
assertContains(route, 'reqwest::multipart::Form::new()', "WeKnora multipart upload");
assertContains(route, '"fileName"', "WeKnora preserves root relative path");
assertContains(route, '"mnote_source_root_relative_path"', "WeKnora source metadata");
assertContains(route, '"scanSkipped": false', "WeKnora ingest uses provider upload");
assertContains(route, '"scanSkipReason": "weknora_provider_direct_upload"', "WeKnora ingest no LightRAG scan");
assertContains(route, '"pending_provider_ingest_missing_kb"', "WeKnora ingest missing kb pending");
assertContains(route, '"pending_provider_ingest"', "WeKnora ingest provider pending");
assertContains(route, '"registry_only"', "WeKnora ingest registry mapping");
assertContains(route, '"provider_mapped"', "WeKnora provider id mapping");
assertContains(route, "delete_weknora_registry_source", "WeKnora delete branch");
assertContains(route, '"providerDeleteAttempted": false', "WeKnora delete no provider call");
assertContains(route, '"localFileDeleted": false', "WeKnora delete keeps local file");
assertContains(route, '"pending_provider_delete"', "WeKnora delete pending provider marker");
assertContains(route, "weknora_section_context_payload", "WeKnora section-context branch");
assertContains(route, '"sidecarRead": false', "WeKnora section-context no LightRAG sidecar");
assertContains(route, '"degradedReason": "weknora_local_source_context_without_provider_chunks"', "WeKnora section-context degraded local fallback");
assertContains(route, 'if active_knowledge_provider() == DEFAULT_KNOWLEDGE_PROVIDER {\n return ingest_weknora_registry_only', "WeKnora ingest bypasses LightRAG staging");
assertContains(route, 'if active_knowledge_provider() == DEFAULT_KNOWLEDGE_PROVIDER {\n return delete_weknora_registry_source', "WeKnora delete bypasses LightRAG delete");
assertContains(route, "let payload = weknora_section_context_payload", "WeKnora section-context bypasses sidecar");
for (const toolName of [
"mnote.weknora.search",
"mnote.weknora.list_sources",
"mnote.weknora.get_source_status",
"mnote.weknora.open_reference",
]) {
assertContains(manifest, `"${toolName}"`, "manifest");
assertContains(toolRoute, `"${toolName}"`, "tool dispatch");
}
assertContains(manifest, '"scope"', "WeKnora tool scope field");
assertContains(manifest, '"allowlist"', "WeKnora tool allowlist field");
assertContains(manifest, '"allowedRoots"', "WeKnora tool allowedRoots field");
assertContains(manifest, '"aiAccessScope"', "WeKnora tool aiAccessScope field");
assertContains(manifest, '"sourcePaths"', "WeKnora tool sourcePaths field");
assertContains(hermesKnowledge, "mnote_weknora_scope_required", "WeKnora tool scope guard");
assertNotContains(ui, "打开 LightRAG", "UI default provider copy");
assertNotContains(ui, "正在读取 LightRAG 状态", "UI default provider copy");
assertNotContains(ui, "无法连接 LightRAG 服务", "UI default provider copy");
assertContains(ui, "WeKnora", "UI provider label");
assertContains(ui, "知识库 provider", "UI neutral copy");
const result = {
ok: true,
checkedFiles: [
"rust/crates/mnote-web/src/routes/knowledge_rag.rs",
"rust/crates/mnote-web/src/hermes_tools/knowledge_rag.rs",
"rust/crates/mnote-web/src/hermes_tools/manifest.rs",
"rust/crates/mnote-web/src/routes/hermes_tools.rs",
"rust/crates/mnote-web/browser/sidebar-page-settings-runtime.js",
],
provider: "weknora",
assertions: {
providerBoundary: true,
providerIds: true,
locatorDegraded: true,
manifestFacade: true,
scopeFields: true,
uiNoDefaultLightRagCopy: true,
ingestNoLightRagScan: true,
deleteNoLightRagDelete: true,
sectionContextNoSidecar: true,
},
};
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
console.log(JSON.stringify(result, null, 2));
}
main();
@@ -0,0 +1,46 @@
#!/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-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.');
@@ -0,0 +1,117 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const fs = require("node:fs");
const path = require("node:path");
const ROOT = process.cwd();
const OUTPUT_DIR = path.join(ROOT, "tmp", "task769-weknora-knowledge-settings-ui-static-smoke");
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
const UI_PATH = "rust/crates/mnote-web/browser/sidebar-page-settings-runtime.js";
function read(relativePath) {
return fs.readFileSync(path.join(ROOT, relativePath), "utf8");
}
function assertContains(content, needle, label) {
assert(content.includes(needle), `${label} 缺少: ${needle}`);
}
function assertNotContains(content, needle, label) {
assert(!content.includes(needle), `${label} 不应包含: ${needle}`);
}
function assertOrdered(content, first, second, label) {
const firstIndex = content.indexOf(first);
const secondIndex = content.indexOf(second);
assert(firstIndex >= 0, `${label} 缺少: ${first}`);
assert(secondIndex >= 0, `${label} 缺少: ${second}`);
assert(firstIndex < secondIndex, `${label} 顺序错误: ${first} 应优先于 ${second}`);
}
function main() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const ui = read(UI_PATH);
for (const forbidden of [
"打开 LightRAG",
"相似度阈值",
"相关度:",
"本地 (Local)",
"全局 (Global)",
"朴素 (Naive)",
"混合模式 (Mix)",
"正在读取 LightRAG 状态",
"无法连接 LightRAG 服务",
]) {
assertNotContains(ui, forbidden, "WeKnora knowledge settings UI");
}
for (const required of [
"WeKnora",
"WeKnora hybrid",
"data-testid=\"mnote-weknora-knowledge-settings-panel\"",
"data-knowledge-rag-default-provider=\"weknora\"",
"data-knowledge-rag-legacy-fallback-provider=\"lightrag\"",
"data-knowledge-rag-source-registry=\"mnote\"",
"data-knowledge-rag-provider-index=\"weknora-kb-chunk-index\"",
"data-knowledge-rag-active-provider",
"data-knowledge-rag-reference-boundary",
"data-knowledge-rag-provider=\"weknora\"",
"data-knowledge-rag-provider-id",
"data-knowledge-rag-provider-ids",
"data-knowledge-rag-knowledge-id",
"data-knowledge-rag-chunk-count",
"data-knowledge-rag-processing-status",
"data-knowledge-rag-open-reference-mode",
"data-knowledge-rag-delete-local-file-policy=\"preserve-local-file\"",
"data-kb-rag-score-label=\"provider-ranking-score\"",
"data-kb-rag-score-is-similarity-percent=\"false\"",
"provider 排序分",
"MNote source registry",
"WeKnora KB chunk index",
"provider=weknora",
"provider ids",
"knowledgeId",
"chunkCount",
"processingStatus",
"定位降级",
"删除 provider index 与 registry 映射,不删除本地文件",
"打开知识库",
"Provider 检索策略",
"兼容阈值字段保留在请求层,不作为默认主控",
]) {
assertContains(ui, required, "WeKnora knowledge settings UI");
}
assertOrdered(
ui,
"(weknora && weknora.dashboardUrl)",
"status && (status.dashboardUrl || status.endpoint)",
"dashboardUrl 优先级"
);
const result = {
ok: true,
checkedFiles: [UI_PATH],
assertions: {
noDefaultLightRagCopy: true,
noSimilarityThresholdPrimaryControl: true,
noRelevancePercentCopy: true,
weknoraHybridSearchCopy: true,
providerSelectorCoverage: true,
sourceRegistrySelectorCoverage: true,
processingSelectorCoverage: true,
citationOpenReferenceSelectorCoverage: true,
providerRankingScoreCopy: true,
sourceRegistryBoundaryCopy: true,
deleteIndexDoesNotDeleteLocalFileCopy: true,
dashboardUrlPriority: true,
},
};
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
console.log(JSON.stringify(result, null, 2));
}
main();
@@ -0,0 +1,98 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const openHubRoot =
process.env.OPENHUB_RESEARCH_ROOT || '/tmp/mnote-openhub-research/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.');
@@ -0,0 +1,127 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const openHubRoot =
process.env.OPENHUB_RESEARCH_ROOT || '/tmp/mnote-openhub-research/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 get_mnote_scope_metadata',
...requiredHeaders,
])
);
assertCheck(
'derived user is stable and not a shared singleton',
includesAll(mnoteScope, [
'def _stable_openhub_user_id',
'mnote_openhub_user_id',
'user_key',
'workspace_key',
'"openhub_user_id"',
'"openhub_username"',
]) && !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',
'_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.');
@@ -0,0 +1,371 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const fs = require("node:fs");
const path = require("node:path");
const { request } = require("playwright");
const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const ROOT_PATH = process.env.MNOTE_KNOWLEDGE_RAG_ROOT_PATH || "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space";
const ROOT_URI = process.env.MNOTE_KNOWLEDGE_RAG_ROOT_URI || `file://${ROOT_PATH}`;
const WORKSPACE_ID = process.env.MNOTE_KNOWLEDGE_RAG_WORKSPACE_ID || "local-ws:mnote-e2e:my-space";
const FIXTURE_DIR = process.env.MNOTE_TASK772_FIXTURE_DIR || "knowledge-rag-fixtures-7-68";
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "task772-weknora-ingest-search-open-reference-e2e");
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
const SKIP_PATH = path.join(OUTPUT_DIR, "skip.json");
const FAILURE_PATH = path.join(OUTPUT_DIR, "failure.json");
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
const POLL_TIMEOUT_MS = Number(process.env.MNOTE_TASK772_WEKNORA_TIMEOUT_MS || 240_000);
const POLL_INTERVAL_MS = Number(process.env.MNOTE_TASK772_WEKNORA_POLL_MS || 5_000);
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function envList(name) {
return String(process.env[name] || "")
.split(",")
.map((value) => value.trim())
.filter(Boolean);
}
function configuredKnowledgeBaseIds() {
return envList("MNOTE_WEKNORA_KNOWLEDGE_BASE_IDS").concat(envList("MNOTE_WEKNORA_KNOWLEDGE_BASE_ID"));
}
function hasWeKnoraApiKey() {
return Boolean(String(process.env.MNOTE_WEKNORA_API_KEY || process.env.WEKNORA_API_KEY || "").trim());
}
function writeJson(filePath, payload) {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
}
function skip(reason, details = {}) {
const payload = {
ok: false,
skipped: true,
reason,
baseUrl: BASE_URL,
rootUri: ROOT_URI,
workspaceId: WORKSPACE_ID,
...details,
};
writeJson(SKIP_PATH, payload);
console.log(JSON.stringify(payload, null, 2));
}
function failLayer(layer, message, details = {}) {
const error = new Error(message);
error.layer = layer;
error.details = details;
return error;
}
async function apiJson(context, method, url, data) {
const response = await context.fetch(url, {
method,
data,
headers: {
"x-mnote-actor-id": "mnote-e2e",
"x-mnote-actor-type": "user",
"x-mnote-workspace-id": WORKSPACE_ID,
accept: "application/json",
},
timeout: UI_TIMEOUT_MS,
});
const text = await response.text();
let payload = null;
try {
payload = text ? JSON.parse(text) : null;
} catch (_) {
payload = { rawText: text };
}
return { ok: response.ok(), status: response.status(), payload, text };
}
async function signIn(context) {
const response = await context.post(`${BASE_URL}/api/auth`, {
data: {
action: "auth:signIn",
args: {
provider: "password",
params: {
email: "mnote.e2e@example.com",
password: process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!",
flow: "signIn",
},
},
},
timeout: UI_TIMEOUT_MS,
});
assert(response.ok(), `登录失败: ${response.status()} ${await response.text()}`);
}
async function status(context) {
const params = new URLSearchParams({ rootUri: ROOT_URI, workspaceId: WORKSPACE_ID });
const result = await apiJson(context, "GET", `${BASE_URL}/api/knowledge-rag/status?${params.toString()}`);
if (!result.ok) {
throw failLayer("mnote_status", `knowledge-rag status 失败: ${result.status}`, {
response: result.payload || result.text,
});
}
return result.payload;
}
function assertWeKnoraStatus(statusPayload, expectedKbIds) {
const activeProvider = statusPayload?.providerConfig?.active || statusPayload?.provider;
assert.equal(activeProvider, "weknora", `MNote knowledge provider 不是 weknora: ${JSON.stringify(statusPayload?.providerConfig || statusPayload, null, 2)}`);
const serverKbIds = statusPayload?.providerConfig?.weknora?.knowledgeBaseIds || [];
assert(serverKbIds.length > 0, `MNote 3000 未加载 WeKnora KB id: ${JSON.stringify(statusPayload?.providerConfig || statusPayload, null, 2)}`);
for (const kbId of expectedKbIds) {
assert(serverKbIds.includes(kbId), `MNote 3000 未加载当前 WeKnora KB id ${kbId}: ${JSON.stringify(serverKbIds)}`);
}
}
async function ingest(context, sourcePath) {
const result = await apiJson(context, "POST", `${BASE_URL}/api/knowledge-rag/ingest`, {
rootUri: ROOT_URI,
workspaceId: WORKSPACE_ID,
force: true,
sources: [{ sourcePath }],
});
if (!result.ok) {
throw failLayer("mnote_ingest_http", `knowledge-rag ingest HTTP 失败: ${result.status}`, {
response: result.payload || result.text,
});
}
const payload = result.payload;
assert.equal(payload?.provider, "weknora", `ingest provider 应为 weknora: ${JSON.stringify(payload, null, 2).slice(0, 3000)}`);
if (payload?.ok !== true || payload?.retryRequired) {
throw failLayer("weknora_ingest_provider", "WeKnora ingest 未成功完成 provider upload", {
response: payload,
});
}
const configured = Array.isArray(payload.configuredSources) ? payload.configuredSources : [];
const item = configured.find((source) => source.sourceRootRelativePath === sourcePath);
assert(item, `ingest 结果缺少目标 source: ${JSON.stringify(configured, null, 2).slice(0, 3000)}`);
assert.equal(item.provider, "weknora", `ingest source provider 应为 weknora: ${JSON.stringify(item, null, 2)}`);
assert.equal(item.mappingStatus, "provider_mapped", `ingest 应写回 provider_mapped: ${JSON.stringify(item, null, 2)}`);
assert(item.providerKnowledgeBaseId, `ingest 缺少 providerKnowledgeBaseId: ${JSON.stringify(item, null, 2)}`);
assert(item.providerKnowledgeId, `ingest 缺少 providerKnowledgeId: ${JSON.stringify(item, null, 2)}`);
assert(item.upload?.attempted === true, `ingest 应真实调用 WeKnora upload: ${JSON.stringify(item, null, 2)}`);
return { payload, item };
}
async function search(context, query, sourcePath) {
const result = await apiJson(context, "POST", `${BASE_URL}/api/knowledge-rag/search`, {
rootUri: ROOT_URI,
workspaceId: WORKSPACE_ID,
query,
mode: "hybrid",
topK: 10,
chunkTopK: 10,
includeChunkContent: true,
sourcePaths: [sourcePath],
});
if (!result.ok) {
throw failLayer("mnote_search_http", `knowledge-rag search HTTP 失败: ${result.status}`, {
response: result.payload || result.text,
});
}
return result.payload;
}
function referenceProviderIds(reference) {
return {
knowledgeBaseId: reference?.providerIds?.knowledgeBaseId || reference?.providerKnowledgeBaseId || "",
knowledgeId: reference?.providerIds?.knowledgeId || reference?.providerKnowledgeId || "",
chunkId: reference?.providerIds?.chunkId || reference?.providerChunkId || reference?.chunkId || "",
};
}
function findTargetReference(searchPayload, ingestItem, marker, sourcePath) {
const references = Array.isArray(searchPayload?.references) ? searchPayload.references : [];
return references.find((reference) => {
const ids = referenceProviderIds(reference);
const quote = `${reference.displayQuote || ""}\n${reference.rawQuote || ""}\n${reference.quote || ""}`;
return reference.provider === "weknora"
&& ids.knowledgeId === ingestItem.providerKnowledgeId
&& (reference.sourceRootRelativePath === sourcePath || quote.includes(marker));
}) || null;
}
async function waitForSearchHit(context, marker, sourcePath, ingestItem) {
const startedAt = Date.now();
let lastPayload = null;
let lastError = null;
while (Date.now() - startedAt < POLL_TIMEOUT_MS) {
try {
lastPayload = await search(context, marker, sourcePath);
const reference = findTargetReference(lastPayload, ingestItem, marker, sourcePath);
if (reference) return { payload: lastPayload, reference };
} catch (error) {
lastError = {
layer: error.layer || "search_poll",
message: error.message,
details: error.details,
};
}
await sleep(POLL_INTERVAL_MS);
}
throw failLayer("weknora_search_timeout", "等待 WeKnora search 返回目标 chunk 超时", {
marker,
sourcePath,
providerKnowledgeId: ingestItem.providerKnowledgeId,
lastError,
lastPayload,
});
}
function rawWeKnoraChunkFromReference(reference) {
const raw = reference?.reference;
assert(raw && typeof raw === "object" && !Array.isArray(raw), `search reference 缺少原始 WeKnora chunk: ${JSON.stringify(reference, null, 2).slice(0, 3000)}`);
return { provider: "weknora", ...raw };
}
function assertMappedReference(reference, ingestItem, sourcePath, label) {
assert.equal(reference?.provider, "weknora", `${label} provider 应为 weknora: ${JSON.stringify(reference, null, 2).slice(0, 3000)}`);
const ids = referenceProviderIds(reference);
assert(ids.knowledgeBaseId, `${label} 缺少 provider knowledgeBaseId: ${JSON.stringify(reference, null, 2).slice(0, 3000)}`);
assert(ids.knowledgeId, `${label} 缺少 provider knowledgeId: ${JSON.stringify(reference, null, 2).slice(0, 3000)}`);
assert(ids.chunkId, `${label} 缺少 provider chunkId: ${JSON.stringify(reference, null, 2).slice(0, 3000)}`);
assert.equal(ids.knowledgeId, ingestItem.providerKnowledgeId, `${label} provider knowledgeId 未保留 ingest 映射: ${JSON.stringify(ids)}`);
assert.equal(reference.filePath ?? null, null, `${label} 不应把 WeKnora provider filename 伪造成 filePath: ${JSON.stringify(reference, null, 2).slice(0, 3000)}`);
assert.equal(reference.sourceRootRelativePath, sourcePath, `${label} 应通过 registry 映射回 sourceRootRelativePath: ${JSON.stringify(reference, null, 2).slice(0, 3000)}`);
assert.equal(reference.citationDiagnostics?.providerFilenameIsNotLocalPath, true, `${label} 应声明 provider filename 不是本地路径真相: ${JSON.stringify(reference.citationDiagnostics, null, 2)}`);
assert.equal(reference.openAction?.params?.path, sourcePath, `${label} openAction 应使用 registry path,不使用 provider filename: ${JSON.stringify(reference.openAction, null, 2)}`);
}
async function openReference(context, rawChunk) {
const result = await apiJson(context, "POST", `${BASE_URL}/api/knowledge-rag/open-reference`, {
rootUri: ROOT_URI,
workspaceId: WORKSPACE_ID,
reference: rawChunk,
});
if (!result.ok) {
throw failLayer("mnote_open_reference_http", `knowledge-rag open-reference HTTP 失败: ${result.status}`, {
response: result.payload || result.text,
});
}
const payload = result.payload;
assert.equal(payload?.ok, true, `open-reference ok 应为 true: ${JSON.stringify(payload, null, 2).slice(0, 3000)}`);
assert.equal(payload?.provider, "weknora", `open-reference provider 应为 weknora: ${JSON.stringify(payload, null, 2).slice(0, 3000)}`);
return payload;
}
async function main() {
const expectedKbIds = configuredKnowledgeBaseIds();
if (expectedKbIds.length === 0 || !hasWeKnoraApiKey()) {
console.warn(JSON.stringify({
warning: "local_weknora_env_not_visible",
message: "当前 smoke shell 未看到 WeKnora env;继续以 MNote 3000 status 和真实 API 调用为准。",
requiredAny: {
knowledgeBaseId: ["MNOTE_WEKNORA_KNOWLEDGE_BASE_ID", "MNOTE_WEKNORA_KNOWLEDGE_BASE_IDS"],
apiKey: ["MNOTE_WEKNORA_API_KEY", "WEKNORA_API_KEY"],
},
hasKnowledgeBaseId: expectedKbIds.length > 0,
hasApiKey: hasWeKnoraApiKey(),
}, null, 2));
}
if (!fs.existsSync(ROOT_PATH)) {
throw failLayer("allowed_root_missing", `allowed root 不存在: ${ROOT_PATH}`);
}
const context = await request.newContext({ baseURL: BASE_URL });
try {
await signIn(context);
const statusPayload = await status(context);
try {
assertWeKnoraStatus(statusPayload, expectedKbIds);
} catch (error) {
skip("mnote_weknora_provider_or_kb_not_configured", {
assertion: error.message,
providerConfig: statusPayload?.providerConfig || null,
health: statusPayload?.health || null,
});
return;
}
const startedAt = Date.now();
const marker = `task772 weknora e2e marker ${startedAt}`;
const sourceName = `task772-weknora-e2e-${startedAt}.md`;
const sourcePath = `${FIXTURE_DIR}/${sourceName}`;
const absoluteSourcePath = path.join(ROOT_PATH, sourcePath);
fs.mkdirSync(path.dirname(absoluteSourcePath), { recursive: true });
fs.writeFileSync(
absoluteSourcePath,
[
"# Task 772 WeKnora E2E",
"",
marker,
"",
"This markdown file is created by the MNote task772 smoke to verify real WeKnora ingest, search, and open-reference mapping.",
"The provider filename must not become a forged local file path; MNote should map back through its source registry.",
"",
].join("\n"),
"utf8",
);
const ingestResult = await ingest(context, sourcePath);
const searchResult = await waitForSearchHit(context, marker, sourcePath, ingestResult.item);
assert.equal(searchResult.payload?.provider, "weknora", `search provider 应为 weknora: ${JSON.stringify(searchResult.payload, null, 2).slice(0, 3000)}`);
assertMappedReference(searchResult.reference, ingestResult.item, sourcePath, "search reference");
const rawChunk = rawWeKnoraChunkFromReference(searchResult.reference);
const openPayload = await openReference(context, rawChunk);
assertMappedReference(openPayload.reference, ingestResult.item, sourcePath, "open-reference");
const result = {
ok: true,
skipped: false,
baseUrl: BASE_URL,
rootUri: ROOT_URI,
workspaceId: WORKSPACE_ID,
sourcePath,
absoluteSourcePath,
marker,
provider: "weknora",
providerKnowledgeBaseId: ingestResult.item.providerKnowledgeBaseId,
providerKnowledgeId: ingestResult.item.providerKnowledgeId,
search: {
resultCount: Array.isArray(searchResult.payload.results) ? searchResult.payload.results.length : 0,
referenceCount: Array.isArray(searchResult.payload.references) ? searchResult.payload.references.length : 0,
providerIds: referenceProviderIds(searchResult.reference),
citationUrl: searchResult.reference.citationUrl || null,
filePath: searchResult.reference.filePath ?? null,
sourceRootRelativePath: searchResult.reference.sourceRootRelativePath || null,
},
openReference: {
providerIds: referenceProviderIds(openPayload.reference),
citationUrl: openPayload.reference?.citationUrl || null,
filePath: openPayload.reference?.filePath ?? null,
sourceRootRelativePath: openPayload.reference?.sourceRootRelativePath || null,
openAction: openPayload.reference?.openAction || null,
},
};
writeJson(RESULT_PATH, result);
console.log(JSON.stringify(result, null, 2));
} finally {
await context.dispose().catch(() => undefined);
}
}
main().catch((error) => {
const payload = {
ok: false,
skipped: false,
layer: error.layer || "unexpected",
error: error.stack || error.message || String(error),
details: error.details || null,
baseUrl: BASE_URL,
rootUri: ROOT_URI,
workspaceId: WORKSPACE_ID,
};
writeJson(FAILURE_PATH, payload);
console.error(JSON.stringify(payload, null, 2));
process.exit(1);
});
@@ -0,0 +1,553 @@
#!/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);
});
@@ -0,0 +1,294 @@
#!/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);
});
@@ -0,0 +1,255 @@
#!/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);
});
@@ -0,0 +1,234 @@
#!/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 || "/tmp/mnote-openhub-research/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);
});
@@ -0,0 +1,152 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const fs = require("node:fs");
const path = require("node:path");
const { request } = require("playwright");
const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const ROOT_PATH = process.env.MNOTE_KNOWLEDGE_RAG_ROOT_PATH || "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space";
const ROOT_URI = process.env.MNOTE_KNOWLEDGE_RAG_ROOT_URI || `file://${ROOT_PATH}`;
const WORKSPACE_ID = process.env.MNOTE_KNOWLEDGE_RAG_WORKSPACE_ID || "local-ws:mnote-e2e:my-space";
const TASK772_RESULT_PATH = path.join(process.cwd(), "tmp", "task772-weknora-ingest-search-open-reference-e2e", "result.json");
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "7-68-runtime");
const RESULT_PATH = path.join(OUTPUT_DIR, "weknora-section-context-smoke-result.json");
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
function readTask772Result() {
if (!fs.existsSync(TASK772_RESULT_PATH)) {
throw new Error(`缺少 task772 结果,请先运行 node scripts/task772-weknora-ingest-search-open-reference-e2e.js: ${TASK772_RESULT_PATH}`);
}
const payload = JSON.parse(fs.readFileSync(TASK772_RESULT_PATH, "utf8"));
assert.equal(payload.ok, true, `task772 结果不是 ok=true: ${JSON.stringify(payload, null, 2).slice(0, 1000)}`);
const sourcePath = payload.sourcePath;
const providerKnowledgeId = payload.providerKnowledgeId;
const providerKnowledgeBaseId = payload.providerKnowledgeBaseId;
const providerChunkId = payload.search?.providerIds?.chunkId || payload.openReference?.providerIds?.chunkId;
assert(sourcePath, "task772 结果缺少 sourcePath");
assert(providerKnowledgeId, "task772 结果缺少 providerKnowledgeId");
assert(providerChunkId, "task772 结果缺少 provider chunkId");
return {
sourcePath,
providerKnowledgeId,
providerKnowledgeBaseId,
providerChunkId,
marker: payload.marker,
};
}
async function signIn(context) {
const response = await context.post(`${BASE_URL}/api/auth`, {
data: {
action: "auth:signIn",
args: {
provider: "password",
params: {
account: "mnote-e2e",
password: process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!",
flow: "signIn",
},
},
},
timeout: UI_TIMEOUT_MS,
});
assert(response.ok(), `登录失败: ${response.status()} ${await response.text()}`);
}
async function sectionContext(context, body) {
const response = await context.post(`${BASE_URL}/api/knowledge-rag/section-context`, {
data: {
rootUri: ROOT_URI,
workspaceId: WORKSPACE_ID,
maxChars: 4000,
...body,
},
headers: {
"x-mnote-actor-id": "mnote-e2e",
"x-mnote-actor-type": "user",
"x-mnote-workspace-id": WORKSPACE_ID,
accept: "application/json",
},
timeout: UI_TIMEOUT_MS,
});
const text = await response.text();
let payload = null;
try {
payload = JSON.parse(text);
} catch {
payload = { rawText: text };
}
assert(response.ok(), `section-context HTTP ${response.status()}: ${text.slice(0, 1000)}`);
return payload;
}
function assertProviderChunkPayload(payload, expected) {
assert.equal(payload.provider, "weknora", `provider 应为 weknora: ${JSON.stringify(payload, null, 2).slice(0, 1200)}`);
assert.equal(payload.sidecarRead, false, "WeKnora section-context 不应读取 LightRAG sidecar");
assert.equal(payload.providerChunkFetch?.attempted, true, "应尝试 WeKnora provider chunk fetch");
assert.equal(payload.providerChunkFetch?.ok, true, `provider chunk fetch 应成功: ${JSON.stringify(payload.providerChunkFetch, null, 2)}`);
assert(Array.isArray(payload.blocks) && payload.blocks.length > 0, "section-context 应返回 provider chunk blocks");
assert(Array.isArray(payload.chunks) && payload.chunks.length > 0, "section-context 应返回 agent chunks");
assert(String(payload.text || "").includes(expected.marker), "section-context text 应包含 task772 marker");
const first = payload.blocks[0];
assert(first.providerChunk, `block 应保留 providerChunk metadata: ${JSON.stringify(first, null, 2).slice(0, 1000)}`);
assert.equal(first.providerChunk.knowledgeId, expected.providerKnowledgeId, "providerChunk knowledgeId 应保留");
}
async function main() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const expected = readTask772Result();
const context = await request.newContext({ baseURL: BASE_URL });
let result = { ok: false, task: "task777-weknora-section-context-smoke", expected };
try {
await signIn(context);
const byChunk = await sectionContext(context, {
sourcePath: expected.sourcePath,
providerKnowledgeId: expected.providerKnowledgeId,
providerKnowledgeBaseId: expected.providerKnowledgeBaseId,
providerChunkId: expected.providerChunkId,
});
assertProviderChunkPayload(byChunk, expected);
const byKnowledge = await sectionContext(context, {
sourcePath: expected.sourcePath,
providerKnowledgeId: expected.providerKnowledgeId,
providerKnowledgeBaseId: expected.providerKnowledgeBaseId,
});
assertProviderChunkPayload(byKnowledge, expected);
result = {
...result,
ok: true,
byChunk: {
providerChunkFetch: byChunk.providerChunkFetch,
providerChunkId: byChunk.providerChunkId,
returnedBlocks: byChunk.blocks.length,
returnedChunks: byChunk.chunks.length,
degradedReason: byChunk.degradedReason,
},
byKnowledge: {
providerChunkFetch: byKnowledge.providerChunkFetch,
returnedBlocks: byKnowledge.blocks.length,
returnedChunks: byKnowledge.chunks.length,
degradedReason: byKnowledge.degradedReason,
},
};
} catch (error) {
result = {
...result,
ok: false,
error: error instanceof Error ? error.stack || error.message : String(error),
};
} finally {
await context.dispose().catch(() => undefined);
fs.writeFileSync(RESULT_PATH, `${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);
});
@@ -0,0 +1,92 @@
#!/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);
@@ -0,0 +1,220 @@
#!/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") || "",
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);
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.equal(debug.after.eventBusSource, "openhub_changed_file_bridge", "应复用 local-folder event bus synthetic watch batch");
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;
});
@@ -0,0 +1,219 @@
#!/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 || "/tmp/mnote-openhub-research/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);
});
@@ -0,0 +1,256 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const fs = require("node:fs");
const path = require("node:path");
const { request } = require("playwright");
const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const ROOT_PATH = process.env.MNOTE_KNOWLEDGE_RAG_ROOT_PATH || "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space";
const ROOT_URI = process.env.MNOTE_KNOWLEDGE_RAG_ROOT_URI || `file://${ROOT_PATH}`;
const WORKSPACE_ID = process.env.MNOTE_KNOWLEDGE_RAG_WORKSPACE_ID || "local-ws:mnote-e2e:my-space";
const FIXTURE_DIR = process.env.MNOTE_TASK781_FIXTURE_DIR || "knowledge-rag-fixtures-7-68";
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "task781-knowledge-bases-registry-smoke");
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
const FAILURE_PATH = path.join(OUTPUT_DIR, "failure.json");
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
const POLL_TIMEOUT_MS = Number(process.env.MNOTE_TASK781_WEKNORA_TIMEOUT_MS || 240_000);
const POLL_INTERVAL_MS = Number(process.env.MNOTE_TASK781_WEKNORA_POLL_MS || 5_000);
function writeJson(filePath, payload) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function failLayer(layer, message, details = {}) {
const error = new Error(message);
error.layer = layer;
error.details = details;
return error;
}
async function signIn(context) {
const response = await context.post(`${BASE_URL}/api/auth`, {
data: {
action: "auth:signIn",
args: {
provider: "password",
params: {
email: "mnote.e2e@example.com",
password: process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!",
flow: "signIn",
},
},
},
timeout: UI_TIMEOUT_MS,
});
assert(response.ok(), `登录失败: ${response.status()} ${await response.text()}`);
}
async function apiJson(context, method, url, data) {
const response = await context.fetch(url, {
method,
data,
headers: {
"x-mnote-actor-id": "mnote-e2e",
"x-mnote-actor-type": "user",
"x-mnote-workspace-id": WORKSPACE_ID,
accept: "application/json",
},
timeout: UI_TIMEOUT_MS,
});
const text = await response.text();
let payload = null;
try {
payload = text ? JSON.parse(text) : null;
} catch {
payload = { rawText: text };
}
return { ok: response.ok(), status: response.status(), payload, text };
}
async function status(context) {
const params = new URLSearchParams({ rootUri: ROOT_URI, workspaceId: WORKSPACE_ID });
const result = await apiJson(context, "GET", `${BASE_URL}/api/knowledge-rag/status?${params.toString()}`);
assert(result.ok, `status HTTP ${result.status}: ${JSON.stringify(result.payload || result.text).slice(0, 1000)}`);
return result.payload;
}
function assertKnowledgeBases(payload) {
assert.equal(payload?.provider, "weknora", `status provider 应为 weknora: ${JSON.stringify(payload?.providerConfig || payload, null, 2).slice(0, 1000)}`);
assert.equal(payload?.knowledgeBases?.schema, "mnote.knowledge_bases.registry.v1", "status 应返回 mnote_knowledge_bases registry schema");
assert.equal(payload?.registry?.schema, "mnote.knowledge_rag.source_registry.v1", "status 应保留旧 source registry schema");
assert.equal(payload?.providerConfig?.weknora?.knowledgeBaseRegistry, "mnote_knowledge_bases", "providerConfig 应暴露 KB registry 边界");
assert.equal(payload?.providerConfig?.weknora?.sourceRegistry, "mnote_knowledge_sources", "providerConfig 应暴露 source registry 边界");
const bases = payload?.knowledgeBases?.bases || [];
assert(Array.isArray(bases) && bases.length > 0, `knowledgeBases.bases 应至少包含默认 WeKnora KB: ${JSON.stringify(payload?.knowledgeBases, null, 2)}`);
const base = bases.find((item) => item.provider === "weknora" && item.providerKbId);
assert(base, `缺少 provider-neutral WeKnora KB: ${JSON.stringify(bases, null, 2)}`);
assert.equal(base.defaultToolEnabled, true, "默认 WeKnora KB 应开启 tool 可见性");
return base;
}
async function ingest(context, sourcePath) {
const result = await apiJson(context, "POST", `${BASE_URL}/api/knowledge-rag/ingest`, {
rootUri: ROOT_URI,
workspaceId: WORKSPACE_ID,
force: true,
sources: [{ sourcePath }],
});
if (!result.ok) {
throw failLayer("mnote_ingest_http", `ingest HTTP ${result.status}`, { response: result.payload || result.text });
}
const payload = result.payload;
assert.equal(payload?.provider, "weknora", "ingest provider 应为 weknora");
assert.equal(payload?.knowledgeBases?.schema, "mnote.knowledge_bases.registry.v1", "ingest 应返回 knowledgeBases registry");
const item = (payload.configuredSources || []).find((source) => source.sourceRootRelativePath === sourcePath);
assert(item, `ingest 结果缺少目标 source: ${JSON.stringify(payload.configuredSources, null, 2).slice(0, 1000)}`);
assert.equal(item.mappingStatus, "provider_mapped", `ingest 应 provider_mapped: ${JSON.stringify(item, null, 2)}`);
assert(item.providerKnowledgeBaseId, `ingest 缺少 providerKnowledgeBaseId: ${JSON.stringify(item, null, 2)}`);
assert(item.providerKnowledgeId, `ingest 缺少 providerKnowledgeId: ${JSON.stringify(item, null, 2)}`);
const registryEntry = (payload.registry?.entries || []).find((entry) => entry.sourceRootRelativePath === sourcePath);
assert(registryEntry, "source registry 应写入目标 source");
assert.equal(registryEntry.provider, "weknora", `source registry 应写 provider: ${JSON.stringify(registryEntry, null, 2)}`);
assert.equal(registryEntry.providerKnowledgeBaseId, item.providerKnowledgeBaseId, "source registry 应写 providerKnowledgeBaseId");
assert.equal(registryEntry.providerKnowledgeId, item.providerKnowledgeId, "source registry 应写 providerKnowledgeId");
assert.equal(registryEntry.providerSourceId, item.providerKnowledgeId, "source registry 应写 providerSourceId");
assert(registryEntry.lightRagDocId, "旧 lightRagDocId 兼容字段仍应保留 provider knowledge id");
return { payload, item, registryEntry };
}
async function search(context, marker, sourcePath) {
const startedAt = Date.now();
let lastPayload = null;
while (Date.now() - startedAt < POLL_TIMEOUT_MS) {
const result = await apiJson(context, "POST", `${BASE_URL}/api/knowledge-rag/search`, {
rootUri: ROOT_URI,
workspaceId: WORKSPACE_ID,
query: marker,
mode: "hybrid",
topK: 10,
chunkTopK: 10,
includeChunkContent: true,
sourcePaths: [sourcePath],
});
assert(result.ok, `search HTTP ${result.status}: ${JSON.stringify(result.payload || result.text).slice(0, 1000)}`);
lastPayload = result.payload;
const hit = (lastPayload.references || []).find((reference) => {
const quote = `${reference.rawQuote || ""}\n${reference.displayQuote || ""}\n${reference.quote || ""}`;
return reference.provider === "weknora" && (reference.sourceRootRelativePath === sourcePath || quote.includes(marker));
});
if (hit) return { payload: lastPayload, reference: hit };
await sleep(POLL_INTERVAL_MS);
}
throw failLayer("weknora_search_timeout", "等待 WeKnora search 返回 task781 source 超时", { marker, sourcePath, lastPayload });
}
async function main() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
if (!fs.existsSync(ROOT_PATH)) {
throw failLayer("allowed_root_missing", `allowed root 不存在: ${ROOT_PATH}`);
}
const context = await request.newContext({ baseURL: BASE_URL });
try {
await signIn(context);
const beforeStatus = await status(context);
const baseBefore = assertKnowledgeBases(beforeStatus);
const startedAt = Date.now();
const marker = `task781 knowledge bases registry marker ${startedAt}`;
const sourcePath = `${FIXTURE_DIR}/task781-knowledge-base-registry-${startedAt}.md`;
const absoluteSourcePath = path.join(ROOT_PATH, sourcePath);
fs.mkdirSync(path.dirname(absoluteSourcePath), { recursive: true });
fs.writeFileSync(
absoluteSourcePath,
[
"# Task 781 Knowledge Base Registry",
"",
marker,
"",
"This file verifies MNote provider-neutral knowledge base and source registry boundaries.",
"",
].join("\n"),
"utf8",
);
const ingestResult = await ingest(context, sourcePath);
const searchResult = await search(context, marker, sourcePath);
const afterStatus = await status(context);
const baseAfter = assertKnowledgeBases(afterStatus);
const persistedRegistryPath = path.join(ROOT_PATH, ".mnote", "index", "mnote-knowledge-bases.json");
assert(fs.existsSync(persistedRegistryPath), `应持久化 mnote_knowledge_bases registry: ${persistedRegistryPath}`);
const persistedRegistry = JSON.parse(fs.readFileSync(persistedRegistryPath, "utf8"));
assert.equal(persistedRegistry.schema, "mnote.knowledge_bases.registry.v1", "持久化 KB registry schema 不正确");
assert((persistedRegistry.bases || []).some((base) => base.provider === "weknora" && base.providerKbId), "持久化 KB registry 缺少 WeKnora base");
const result = {
ok: true,
task: "task781-knowledge-bases-registry-smoke",
baseUrl: BASE_URL,
rootUri: ROOT_URI,
workspaceId: WORKSPACE_ID,
sourcePath,
absoluteSourcePath,
marker,
knowledgeBase: {
before: {
baseId: baseBefore.baseId,
provider: baseBefore.provider,
providerKbId: baseBefore.providerKbId,
defaultToolEnabled: baseBefore.defaultToolEnabled,
},
after: {
baseId: baseAfter.baseId,
provider: baseAfter.provider,
providerKbId: baseAfter.providerKbId,
defaultToolEnabled: baseAfter.defaultToolEnabled,
sourceCount: baseAfter.sourceCount,
},
persistedRegistryPath,
},
sourceRegistry: {
provider: ingestResult.registryEntry.provider,
providerStatus: ingestResult.registryEntry.providerStatus,
providerKnowledgeBaseId: ingestResult.registryEntry.providerKnowledgeBaseId,
providerKnowledgeId: ingestResult.registryEntry.providerKnowledgeId,
providerSourceId: ingestResult.registryEntry.providerSourceId,
lightRagDocIdCompat: ingestResult.registryEntry.lightRagDocId,
},
search: {
provider: searchResult.payload.provider,
referenceProvider: searchResult.reference.provider,
sourceRootRelativePath: searchResult.reference.sourceRootRelativePath,
providerKnowledgeBaseId: searchResult.reference.providerKnowledgeBaseId,
providerKnowledgeId: searchResult.reference.providerKnowledgeId,
providerChunkId: searchResult.reference.providerChunkId,
},
};
writeJson(RESULT_PATH, result);
console.log(JSON.stringify(result, null, 2));
} finally {
await context.dispose().catch(() => undefined);
}
}
main().catch((error) => {
const payload = {
ok: false,
task: "task781-knowledge-bases-registry-smoke",
layer: error.layer || "unexpected",
error: error.stack || error.message || String(error),
details: error.details || null,
baseUrl: BASE_URL,
rootUri: ROOT_URI,
workspaceId: WORKSPACE_ID,
};
writeJson(FAILURE_PATH, payload);
console.error(JSON.stringify(payload, null, 2));
process.exit(1);
});
@@ -0,0 +1,187 @@
#!/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 || "/tmp/mnote-openhub-research/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/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);
});
@@ -0,0 +1,111 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const fs = require("node:fs");
const path = require("node:path");
const ROOT = process.cwd();
const OUTPUT_DIR = path.join(ROOT, "tmp", "task783-weknora-kb-settings-ui-static-smoke");
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
const SETTINGS_RUNTIME = "rust/crates/mnote-web/browser/sidebar-page-settings-runtime.js";
const SIDEBAR_RUNTIME = "rust/crates/mnote-web/browser/sidebar-tree-runtime.js";
function read(relativePath) {
return fs.readFileSync(path.join(ROOT, relativePath), "utf8");
}
function assertContains(content, needle, label) {
assert(content.includes(needle), `${label} 缺少: ${needle}`);
}
function assertOrdered(content, first, second, label) {
const firstIndex = content.indexOf(first);
const secondIndex = content.indexOf(second);
assert(firstIndex >= 0, `${label} 缺少: ${first}`);
assert(secondIndex >= 0, `${label} 缺少: ${second}`);
assert(firstIndex < secondIndex, `${label} 顺序错误: ${first} 应早于 ${second}`);
}
function main() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const settings = read(SETTINGS_RUNTIME);
const sidebar = read(SIDEBAR_RUNTIME);
for (const required of [
"data-mnote-weknora-page-replacement=\"true\"",
"mnote-weknora-kb-page",
"data-testid=\"mnote-weknora-kb-list-pane\"",
"data-testid=\"mnote-weknora-kb-card-list\"",
"data-knowledge-rag-action=\"select-kb\"",
"data-testid=\"mnote-weknora-kb-detail-pane\"",
"data-testid=\"mnote-weknora-kb-detail-hero\"",
"data-weknora-document-list-view=\"true\"",
"知识库列表",
"创建知识库",
"上传/索引到 WeKnora",
"data-testid=\"mnote-weknora-kb-summary\"",
"data-knowledge-rag-kb-provider=\"weknora\"",
"data-knowledge-rag-kb-id",
"data-knowledge-rag-kb-ids",
"data-knowledge-rag-processing-count",
"data-knowledge-rag-source-picker=\"mnote-filetree\"",
"data-knowledge-rag-source-kind=\"local-file-or-folder\"",
"本地文件或文件夹,例如 docs/book.pdf / docs",
"providerKnowledgeBaseId",
"providerKnowledgeId",
"provider_mapped",
"data-knowledge-rag-kb-id",
"data-knowledge-rag-chunk-id",
"data-kb-rag-search-result=\"true\"",
"data-knowledge-rag-action=\"open-source-reference\"",
"data-knowledge-rag-open-reference-mode=\"search-result-open-reference\"",
"data-mnote-knowledge-rag-open-reference-mode",
"source-registry-reference",
"openPrimaryDocument",
"resourcePath",
"删除 provider index 与 registry 映射,不删除本地文件",
]) {
assertContains(settings, required, SETTINGS_RUNTIME);
}
assertContains(sidebar, "openKnowledgeRagSourceReference", SIDEBAR_RUNTIME);
assertContains(sidebar, "knowledgeRagActionName === 'open-source-reference'", SIDEBAR_RUNTIME);
assertContains(sidebar, "knowledgeRagActionName === 'select-kb'", SIDEBAR_RUNTIME);
assertContains(sidebar, "knowledgeRagActionName === 'focus-create-kb'", SIDEBAR_RUNTIME);
assertOrdered(
settings,
"knowledgeRagKbSummaryHtml(status, entries, providerDocs)",
"<div><span>Provider</span><code>",
"KB summary 应显示在 provider 详情前",
);
assertOrdered(
settings,
"data-knowledge-rag-action=\"open-source-reference\"",
"data-knowledge-rag-action=\"delete-source\"",
"source row 应先提供打开引用,再提供删除索引",
);
const result = {
ok: true,
checkedFiles: [SETTINGS_RUNTIME, SIDEBAR_RUNTIME],
assertions: {
kbSummaryVisible: true,
weknoraPageReplacementVisible: true,
weknoraKbListPaneVisible: true,
weknoraKbDetailPaneVisible: true,
weknoraDocumentListViewVisible: true,
fileTreePickerBoundary: true,
processingStateVisible: true,
searchResultOpenReference: true,
sourceRowOpenReference: true,
deletePreservesLocalFileCopy: true,
noBackendKnowledgeRagEditRequired: true,
},
};
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
console.log(JSON.stringify(result, null, 2));
}
main();
@@ -0,0 +1,118 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const fs = require("node:fs");
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 TASK772_RESULT_PATH = path.join(process.cwd(), "tmp", "task772-weknora-ingest-search-open-reference-e2e", "result.json");
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "task784-weknora-kb-settings-browser-smoke");
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
const SCREENSHOT_PATH = path.join(OUTPUT_DIR, "weknora-kb-settings.png");
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|| ["/usr/bin/chromium-browser", "/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium", "/usr/bin/chromium"]
.find((candidate) => fs.existsSync(candidate));
function loadTask772Result() {
assert(fs.existsSync(TASK772_RESULT_PATH), `缺少 task772 真实入库结果: ${TASK772_RESULT_PATH}`);
return JSON.parse(fs.readFileSync(TASK772_RESULT_PATH, "utf8"));
}
async function signIn(context) {
const response = await context.request.post(`${BASE_URL}/api/auth`, {
data: {
action: "auth:signIn",
args: {
provider: "password",
params: {
email: "mnote.e2e@example.com",
password: process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!",
flow: "signIn",
},
},
},
timeout: UI_TIMEOUT_MS,
});
assert(response.ok(), `登录失败: ${response.status()} ${await response.text()}`);
}
async function main() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const fixture = loadTask772Result();
assert.equal(fixture.provider, "weknora", `task772 provider 不是 weknora: ${JSON.stringify(fixture, null, 2).slice(0, 1200)}`);
assert(fixture.rootUri && fixture.workspaceId && fixture.sourcePath && fixture.marker, "task772 结果缺少 rootUri/workspaceId/sourcePath/marker");
const browser = await chromium.launch({
headless: true,
...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
});
const context = await browser.newContext({ viewport: { width: 1360, height: 900 } });
const page = await context.newPage();
try {
await signIn(context);
const url = new URL(`${BASE_URL}/`);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", fixture.rootUri);
url.searchParams.set("workspaceId", fixture.workspaceId);
url.searchParams.set("treeView", "filetree");
await page.goto(url.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="mnote-knowledge-rag-settings-toggle"]').click({ timeout: UI_TIMEOUT_MS });
const panel = page.locator('[data-testid="mnote-weknora-knowledge-settings-panel"]');
await panel.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator('[data-knowledge-rag-action="filter-sources"][data-knowledge-rag-filter="all"]').click({ timeout: UI_TIMEOUT_MS });
const sourceRow = page.locator(`[data-knowledge-rag-source-row="true"][data-knowledge-rag-source-path="${fixture.sourcePath}"]`).first();
await sourceRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await sourceRow.locator('[data-knowledge-rag-action="open-source-reference"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await sourceRow.locator('[data-knowledge-rag-action="delete-source"][data-knowledge-rag-delete-local-file-policy="preserve-local-file"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const kbSummary = panel.locator('[data-testid="mnote-weknora-kb-summary"]');
await kbSummary.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const kbIds = await kbSummary.getAttribute("data-knowledge-rag-kb-ids");
assert(String(kbIds || "").includes(fixture.providerKnowledgeBaseId), `KB summary 未包含 task772 KB id: ${kbIds}`);
await panel.locator('[data-kb-rag-tab="search"]').click({ timeout: UI_TIMEOUT_MS });
await panel.locator('[data-kb-rag-search-input]').fill(fixture.marker, { timeout: UI_TIMEOUT_MS });
await panel.locator('[data-kb-rag-action="search"]').click({ timeout: UI_TIMEOUT_MS });
const searchResult = panel.locator(`[data-kb-rag-search-result="true"][data-knowledge-rag-source-path="${fixture.sourcePath}"]`).first();
await searchResult.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await searchResult.locator('[data-knowledge-rag-action="open-source-reference"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true });
const result = {
ok: true,
baseUrl: BASE_URL,
rootUri: fixture.rootUri,
workspaceId: fixture.workspaceId,
sourcePath: fixture.sourcePath,
providerKnowledgeBaseId: fixture.providerKnowledgeBaseId,
providerKnowledgeId: fixture.providerKnowledgeId,
kbSummaryVisible: true,
sourceRowVisible: true,
searchResultVisible: true,
openReferenceControlsVisible: true,
deletePreserveLocalFileControlVisible: true,
screenshot: SCREENSHOT_PATH,
};
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
console.log(JSON.stringify(result, null, 2));
} finally {
await browser.close().catch(() => undefined);
}
}
main().catch((error) => {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
fs.writeFileSync(
path.join(OUTPUT_DIR, "failure.json"),
`${JSON.stringify({ ok: false, error: error.stack || error.message || String(error) }, null, 2)}\n`,
"utf8",
);
console.error(error.stack || error.message || String(error));
process.exit(1);
});
@@ -0,0 +1,248 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const fs = require("node:fs");
const path = require("node:path");
const { request } = require("playwright");
const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const ROOT_PATH = process.env.MNOTE_KNOWLEDGE_RAG_ROOT_PATH || "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space";
const ROOT_URI = process.env.MNOTE_KNOWLEDGE_RAG_ROOT_URI || `file://${ROOT_PATH}`;
const WORKSPACE_ID = process.env.MNOTE_KNOWLEDGE_RAG_WORKSPACE_ID || "local-ws:mnote-e2e:my-space";
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "task785-weknora-create-kb-folder-ingest-search-smoke");
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
const FAILURE_PATH = path.join(OUTPUT_DIR, "failure.json");
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
const POLL_TIMEOUT_MS = Number(process.env.MNOTE_TASK785_WEKNORA_TIMEOUT_MS || 240_000);
const POLL_INTERVAL_MS = Number(process.env.MNOTE_TASK785_WEKNORA_POLL_MS || 5_000);
function writeJson(filePath, payload) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function failLayer(layer, message, details = {}) {
const error = new Error(message);
error.layer = layer;
error.details = details;
return error;
}
async function signIn(context) {
const response = await context.post(`${BASE_URL}/api/auth`, {
data: {
action: "auth:signIn",
args: {
provider: "password",
params: {
email: "mnote.e2e@example.com",
password: process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!",
flow: "signIn",
},
},
},
timeout: UI_TIMEOUT_MS,
});
assert(response.ok(), `登录失败: ${response.status()} ${await response.text()}`);
}
async function apiJson(context, method, url, data) {
const response = await context.fetch(url, {
method,
data,
headers: {
"x-mnote-actor-id": "mnote-e2e",
"x-mnote-actor-type": "user",
"x-mnote-workspace-id": WORKSPACE_ID,
accept: "application/json",
},
timeout: UI_TIMEOUT_MS,
});
const text = await response.text();
let payload = null;
try {
payload = text ? JSON.parse(text) : null;
} catch {
payload = { rawText: text };
}
return { ok: response.ok(), status: response.status(), payload, text };
}
async function status(context) {
const params = new URLSearchParams({ rootUri: ROOT_URI, workspaceId: WORKSPACE_ID });
const result = await apiJson(context, "GET", `${BASE_URL}/api/knowledge-rag/status?${params.toString()}`);
assert(result.ok, `status HTTP ${result.status}: ${JSON.stringify(result.payload || result.text).slice(0, 1000)}`);
return result.payload;
}
async function createKnowledgeBase(context, name, description) {
const result = await apiJson(context, "POST", `${BASE_URL}/api/knowledge-rag/knowledge-bases`, {
rootUri: ROOT_URI,
workspaceId: WORKSPACE_ID,
name,
description,
defaultToolEnabled: true,
});
if (!result.ok) {
throw failLayer("mnote_create_kb_http", `create KB HTTP ${result.status}`, { response: result.payload || result.text });
}
const payload = result.payload;
assert.equal(payload?.provider, "weknora", "create KB provider 应为 weknora");
assert.equal(payload?.schema, "mnote.knowledge_rag.create_knowledge_base_result.v1", "create KB schema 不正确");
assert(payload?.providerKnowledgeBaseId, `create KB 缺少 providerKnowledgeBaseId: ${JSON.stringify(payload, null, 2).slice(0, 1200)}`);
assert.equal(payload?.knowledgeBase?.provider, "weknora", "knowledgeBase provider 应为 weknora");
assert.equal(payload?.knowledgeBase?.providerKbId, payload.providerKnowledgeBaseId, "knowledgeBase.providerKbId 应匹配 providerKnowledgeBaseId");
assert.equal(payload?.knowledgeBase?.defaultToolEnabled, true, "新 KB 应默认启用 tool");
return payload;
}
async function ingestFolder(context, folderPath, providerKnowledgeBaseId) {
const result = await apiJson(context, "POST", `${BASE_URL}/api/knowledge-rag/ingest`, {
rootUri: ROOT_URI,
workspaceId: WORKSPACE_ID,
force: true,
providerKnowledgeBaseId,
sources: [{ sourcePath: folderPath }],
});
if (!result.ok) {
throw failLayer("mnote_ingest_folder_http", `ingest folder HTTP ${result.status}`, { response: result.payload || result.text });
}
const payload = result.payload;
assert.equal(payload?.provider, "weknora", "ingest provider 应为 weknora");
const configured = payload.configuredSources || [];
assert(configured.length >= 2, `文件夹入库应至少展开两个文件: ${JSON.stringify(configured, null, 2).slice(0, 1200)}`);
const mapped = configured.filter((item) => item.mappingStatus === "provider_mapped" && item.providerKnowledgeBaseId === providerKnowledgeBaseId);
assert(mapped.length >= 2, `文件夹入库未全部映射到新 KB: ${JSON.stringify(configured, null, 2).slice(0, 1600)}`);
const registryEntries = payload.registry?.entries || [];
const folderEntries = registryEntries.filter((entry) => String(entry.sourceRootRelativePath || "").startsWith(`${folderPath}/`));
assert(folderEntries.length >= 2, "source registry 应写入文件夹下的 source");
assert(folderEntries.every((entry) => entry.provider === "weknora"), "source registry provider 应为 weknora");
assert(folderEntries.every((entry) => entry.providerKnowledgeBaseId === providerKnowledgeBaseId), "source registry 应写入新 KB id");
return { payload, configured, mapped, folderEntries };
}
async function searchUntilHit(context, query, folderPath, providerKnowledgeBaseId) {
const startedAt = Date.now();
let lastPayload = null;
while (Date.now() - startedAt < POLL_TIMEOUT_MS) {
const result = await apiJson(context, "POST", `${BASE_URL}/api/knowledge-rag/search`, {
rootUri: ROOT_URI,
workspaceId: WORKSPACE_ID,
query,
mode: "hybrid",
topK: 10,
chunkTopK: 10,
includeChunkContent: true,
providerKnowledgeBaseId,
sourcePaths: [folderPath],
});
assert(result.ok, `search HTTP ${result.status}: ${JSON.stringify(result.payload || result.text).slice(0, 1000)}`);
lastPayload = result.payload;
const hit = (lastPayload.references || []).find((reference) => {
const pathText = String(reference.sourceRootRelativePath || "");
const quote = `${reference.rawQuote || ""}\n${reference.displayQuote || ""}\n${reference.quote || ""}`;
return reference.provider === "weknora"
&& reference.providerKnowledgeBaseId === providerKnowledgeBaseId
&& (pathText.startsWith(`${folderPath}/`) || quote.includes(query));
});
if (hit) return { payload: lastPayload, reference: hit };
await sleep(POLL_INTERVAL_MS);
}
throw failLayer("weknora_new_kb_folder_search_timeout", "等待新 KB 文件夹入库内容可搜索超时", {
query,
folderPath,
providerKnowledgeBaseId,
lastPayload,
});
}
async function main() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
if (!fs.existsSync(ROOT_PATH)) {
throw failLayer("allowed_root_missing", `allowed root 不存在: ${ROOT_PATH}`);
}
const context = await request.newContext({ baseURL: BASE_URL });
try {
await signIn(context);
const startedAt = Date.now();
const markerA = `task785 create kb folder marker alpha ${startedAt}`;
const markerB = `task785 create kb folder marker beta ${startedAt}`;
const folderPath = `knowledge-rag-fixtures-7-68/task785-folder-${startedAt}`;
const absoluteFolderPath = path.join(ROOT_PATH, folderPath);
fs.mkdirSync(absoluteFolderPath, { recursive: true });
fs.writeFileSync(path.join(absoluteFolderPath, "alpha.md"), `# Task 785 Alpha\n\n${markerA}\n`, "utf8");
fs.writeFileSync(path.join(absoluteFolderPath, "beta.md"), `# Task 785 Beta\n\n${markerB}\n`, "utf8");
const name = `task785-weknora-kb-${startedAt}`;
const createResult = await createKnowledgeBase(context, name, "MNote task785 temporary KB created through MNote facade");
const providerKnowledgeBaseId = createResult.providerKnowledgeBaseId;
const ingestResult = await ingestFolder(context, folderPath, providerKnowledgeBaseId);
const searchResult = await searchUntilHit(context, markerA, folderPath, providerKnowledgeBaseId);
const afterStatus = await status(context);
const statusBase = (afterStatus.knowledgeBases?.bases || []).find((base) => base.providerKbId === providerKnowledgeBaseId);
assert(statusBase, "status knowledgeBases 应包含新建 KB");
assert(statusBase.sourceCount >= 2, `新建 KB sourceCount 应 >= 2: ${JSON.stringify(statusBase, null, 2)}`);
const persistedRegistryPath = path.join(ROOT_PATH, ".mnote", "index", "mnote-knowledge-bases.json");
const persistedRegistry = JSON.parse(fs.readFileSync(persistedRegistryPath, "utf8"));
assert((persistedRegistry.bases || []).some((base) => base.providerKbId === providerKnowledgeBaseId), "持久化 KB registry 应包含新建 KB");
const result = {
ok: true,
task: "task785-weknora-create-kb-folder-ingest-search-smoke",
baseUrl: BASE_URL,
rootUri: ROOT_URI,
workspaceId: WORKSPACE_ID,
folderPath,
absoluteFolderPath,
markers: [markerA, markerB],
knowledgeBase: {
name,
providerKnowledgeBaseId,
baseId: createResult.knowledgeBase?.baseId,
defaultToolEnabled: createResult.knowledgeBase?.defaultToolEnabled,
sourceCount: statusBase.sourceCount,
status: statusBase.status,
},
ingest: {
configuredCount: ingestResult.configured.length,
mappedCount: ingestResult.mapped.length,
sourcePaths: ingestResult.mapped.map((item) => item.sourceRootRelativePath),
},
search: {
provider: searchResult.payload.provider,
referenceProvider: searchResult.reference.provider,
sourceRootRelativePath: searchResult.reference.sourceRootRelativePath,
providerKnowledgeBaseId: searchResult.reference.providerKnowledgeBaseId,
providerKnowledgeId: searchResult.reference.providerKnowledgeId,
providerChunkId: searchResult.reference.providerChunkId,
},
persistedRegistryPath,
};
writeJson(RESULT_PATH, result);
console.log(JSON.stringify(result, null, 2));
} finally {
await context.dispose().catch(() => undefined);
}
}
main().catch((error) => {
const payload = {
ok: false,
task: "task785-weknora-create-kb-folder-ingest-search-smoke",
layer: error.layer || "unexpected",
error: error.stack || error.message || String(error),
details: error.details || null,
baseUrl: BASE_URL,
rootUri: ROOT_URI,
workspaceId: WORKSPACE_ID,
};
writeJson(FAILURE_PATH, payload);
console.error(JSON.stringify(payload, null, 2));
process.exit(1);
});
@@ -0,0 +1,555 @@
#!/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);
});
@@ -0,0 +1,272 @@
#!/usr/bin/env node
"use strict";
const fs = require("node:fs");
const path = require("node:path");
const ROOT = process.cwd();
const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const OUTPUT_DIR = path.join(ROOT, "tmp", "task787-weknora-default-lightrag-legacy-smoke");
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
const FAILURE_PATH = path.join(OUTPUT_DIR, "failure.json");
const REQUEST_TIMEOUT_MS = Number(process.env.MNOTE_TASK787_STATUS_TIMEOUT_MS || 15_000);
const REQUIRED_STATIC_FILES = [
"rust/crates/mnote-web/browser/sidebar-page-settings-runtime.js",
"rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js",
"scripts/TESTING_REFERENCE.md",
];
const TASK_SMOKE_PATTERN = /^task(?:53|54|76|78)\d.*\.js$/;
const SELF_PATH = "scripts/task787-weknora-default-lightrag-legacy-smoke.js";
function writeJson(filePath, payload) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
}
function listTaskSmokeFiles() {
return fs.readdirSync(path.join(ROOT, "scripts"), { withFileTypes: true })
.filter((entry) => entry.isFile() && TASK_SMOKE_PATTERN.test(entry.name))
.map((entry) => `scripts/${entry.name}`)
.sort((left, right) => left.localeCompare(right));
}
function uniqueSorted(values) {
return Array.from(new Set(values)).sort((left, right) => left.localeCompare(right));
}
function extractWindow(line, start, length) {
const from = Math.max(0, start - 90);
const to = Math.min(line.length, start + length + 90);
return line.slice(from, to).trim();
}
function allowedLegacyContext(contextBlock, occurrenceWindow) {
const lowerBlock = contextBlock.toLowerCase();
const lowerWindow = occurrenceWindow.toLowerCase();
const legacyMarkers = [
"legacy",
"fallback",
"history",
"historical",
"debug",
"compat",
"retired",
"assertnotcontains",
"assertnotincludes",
"forbidden",
"notcontains",
"notincludes",
"not contain",
"does not include",
"no lightrag",
"nolightrag",
"nodefaultlightrag",
"without lightrag",
"bypass",
"bypasses",
"skip",
"旧",
"历史",
"兼容",
"调试",
"退役",
"软归档",
"不应包含",
"不作为",
"不再",
"不直接",
"不删除",
"不走",
"禁止",
"绕过",
"跳过",
"移除",
"缺少",
];
return legacyMarkers.some((marker) => lowerWindow.includes(marker) || lowerBlock.includes(marker));
}
function ignoredTechnicalField(line) {
return /\blightRag(?:DocId|FilePath|Status)\b/.test(line)
|| /lightrag-source-registry\.json/.test(line);
}
function ignoredLegacyAssetName(line) {
return /task\d+-lightrag-[\w-]+/.test(line);
}
function ignoredNegativeAssertionName(line) {
return /\b(?:uiNoDefault|ingestNo|deleteNo|sectionContextNo|noDefault)LightRag[A-Za-z0-9_]*\b/.test(line);
}
function classifyActiveLightRagContext(contextBlock, occurrenceWindow) {
const normalized = `${contextBlock}\n${occurrenceWindow}`.toLowerCase();
const reasons = [];
const activeDefaultPatterns = [
/默认\s*provider/i,
/唯一默认/i,
/主线\s*provider/i,
/default\s*provider/i,
/primary\s*provider/i,
/mainline\s*provider/i,
/only\s*default/i,
];
if (activeDefaultPatterns.some((pattern) => pattern.test(contextBlock) || pattern.test(occurrenceWindow))) {
reasons.push("forbidden_default_or_mainline_provider_copy");
}
const activeSurfacePatterns = [
/资料库/,
/知识库/,
/问答/,
/检索/,
/搜索/,
/引用/,
/入库/,
/dashboard/,
/health/,
/status/,
/source[-_ ]?registry/,
/search\s*provider/,
/references?/,
/retrieval/,
/smoke/,
];
if (activeSurfacePatterns.some((pattern) => pattern.test(normalized))) {
reasons.push("active_lightrag_surface_without_legacy_marker");
}
if (reasons.length === 0) {
reasons.push("unqualified_lightrag_reference_without_legacy_marker");
}
return reasons;
}
function scanStaticFiles(files) {
const violations = [];
const scannedFiles = [];
for (const relativePath of files) {
const absolutePath = path.join(ROOT, relativePath);
if (!fs.existsSync(absolutePath)) {
violations.push({
file: relativePath,
line: 0,
reason: "static_file_missing",
context: "required static/smoke baseline file is missing",
});
continue;
}
scannedFiles.push(relativePath);
const content = fs.readFileSync(absolutePath, "utf8");
const lines = content.split(/\r?\n/);
lines.forEach((line, index) => {
const matches = Array.from(line.matchAll(/lightrag/gi));
for (const match of matches) {
if (ignoredTechnicalField(line) || ignoredLegacyAssetName(line) || ignoredNegativeAssertionName(line)) {
continue;
}
const occurrenceWindow = extractWindow(line, match.index || 0, match[0].length);
const contextBlock = lines.slice(Math.max(0, index - 8), Math.min(lines.length, index + 5)).join("\n");
if (allowedLegacyContext(contextBlock, occurrenceWindow)) {
continue;
}
violations.push({
file: relativePath,
line: index + 1,
reason: classifyActiveLightRagContext(contextBlock, occurrenceWindow),
context: occurrenceWindow,
});
}
});
}
return { scannedFiles, violations };
}
async function fetchStatusProviderConfig() {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
const url = `${BASE_URL}/api/knowledge-rag/status`;
try {
const response = await fetch(url, {
headers: { accept: "application/json" },
signal: controller.signal,
});
const text = await response.text();
let payload = null;
try {
payload = text ? JSON.parse(text) : null;
} catch (error) {
return {
ok: false,
layer: "status_api_invalid_json",
url,
status: response.status,
error: error.message,
rawText: text.slice(0, 1000),
providerConfig: null,
};
}
const providerConfig = payload && typeof payload.providerConfig === "object" ? payload.providerConfig : null;
const active = providerConfig && providerConfig.active;
const defaultProvider = providerConfig && providerConfig.default;
const provider = payload && payload.provider;
const ok = response.ok && providerConfig && active === "weknora" && defaultProvider === "weknora";
return {
ok,
layer: response.ok ? (ok ? "status_provider_config_ok" : "status_provider_config_mismatch") : "status_api_http_error",
url,
status: response.status,
provider,
providerConfig,
responseSample: ok ? undefined : payload,
};
} catch (error) {
return {
ok: false,
layer: error && error.name === "AbortError" ? "status_api_timeout" : "status_api_unavailable",
url,
error: error && (error.stack || error.message) ? (error.stack || error.message) : String(error),
providerConfig: null,
};
} finally {
clearTimeout(timeout);
}
}
async function main() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const staticFiles = uniqueSorted([...REQUIRED_STATIC_FILES, ...listTaskSmokeFiles()]);
const staticScan = scanStaticFiles(staticFiles.filter((relativePath) => relativePath !== SELF_PATH));
const statusCheck = await fetchStatusProviderConfig();
const ok = staticScan.violations.length === 0 && statusCheck.ok;
const payload = {
ok,
task: "task787-weknora-default-lightrag-legacy-smoke",
baseUrl: BASE_URL,
checkedAt: new Date().toISOString(),
scannedFiles: staticScan.scannedFiles,
violations: staticScan.violations,
statusProviderConfig: statusCheck,
};
if (ok) {
writeJson(RESULT_PATH, payload);
console.log(JSON.stringify(payload, null, 2));
return;
}
writeJson(FAILURE_PATH, payload);
console.error(JSON.stringify(payload, null, 2));
process.exit(1);
}
main().catch((error) => {
const payload = {
ok: false,
task: "task787-weknora-default-lightrag-legacy-smoke",
layer: "unexpected",
error: error && (error.stack || error.message) ? (error.stack || error.message) : String(error),
baseUrl: BASE_URL,
};
writeJson(FAILURE_PATH, payload);
console.error(JSON.stringify(payload, null, 2));
process.exit(1);
});
@@ -0,0 +1,382 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const fs = require("node:fs");
const path = require("node:path");
const { chromium, request } = require("playwright");
const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const ROOT_PATH = process.env.MNOTE_KNOWLEDGE_RAG_ROOT_PATH || "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space";
const ROOT_URI = process.env.MNOTE_KNOWLEDGE_RAG_ROOT_URI || `file://${ROOT_PATH}`;
const WORKSPACE_ID = process.env.MNOTE_KNOWLEDGE_RAG_WORKSPACE_ID || "local-ws:mnote-e2e:my-space";
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "task788-weknora-kb-folder-browser-flow-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, "weknora-kb-folder-browser-flow.png");
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
const POLL_TIMEOUT_MS = Number(process.env.MNOTE_TASK788_WEKNORA_TIMEOUT_MS || 240_000);
const POLL_INTERVAL_MS = Number(process.env.MNOTE_TASK788_WEKNORA_POLL_MS || 5_000);
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|| ["/usr/bin/chromium-browser", "/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium", "/usr/bin/chromium"]
.find((candidate) => fs.existsSync(candidate));
function writeJson(filePath, payload) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function failLayer(layer, message, details = {}) {
const error = new Error(message);
error.layer = layer;
error.details = details;
return error;
}
async function signInRequest(context) {
const response = await context.post(`${BASE_URL}/api/auth`, {
data: {
action: "auth:signIn",
args: {
provider: "password",
params: {
email: "mnote.e2e@example.com",
password: process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!",
flow: "signIn",
},
},
},
timeout: UI_TIMEOUT_MS,
});
assert(response.ok(), `登录失败: ${response.status()} ${await response.text()}`);
}
async function signInBrowser(context) {
const response = await context.request.post(`${BASE_URL}/api/auth`, {
data: {
action: "auth:signIn",
args: {
provider: "password",
params: {
email: "mnote.e2e@example.com",
password: process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!",
flow: "signIn",
},
},
},
timeout: UI_TIMEOUT_MS,
});
assert(response.ok(), `浏览器上下文登录失败: ${response.status()} ${await response.text()}`);
}
async function apiJson(context, method, url, data) {
const response = await context.fetch(url, {
method,
data,
headers: {
"x-mnote-actor-id": "mnote-e2e",
"x-mnote-actor-type": "user",
"x-mnote-workspace-id": WORKSPACE_ID,
accept: "application/json",
},
timeout: UI_TIMEOUT_MS,
});
const text = await response.text();
let payload = null;
try {
payload = text ? JSON.parse(text) : null;
} catch {
payload = { rawText: text };
}
return { ok: response.ok(), status: response.status(), payload, text };
}
async function status(context) {
const params = new URLSearchParams({ rootUri: ROOT_URI, workspaceId: WORKSPACE_ID });
const result = await apiJson(context, "GET", `${BASE_URL}/api/knowledge-rag/status?${params.toString()}`);
if (!result.ok) {
throw failLayer("mnote_status_http", `status HTTP ${result.status}`, { response: result.payload || result.text });
}
return result.payload;
}
async function createKnowledgeBase(context, name, description) {
const result = await apiJson(context, "POST", `${BASE_URL}/api/knowledge-rag/knowledge-bases`, {
rootUri: ROOT_URI,
workspaceId: WORKSPACE_ID,
name,
description,
defaultToolEnabled: true,
});
if (!result.ok) {
throw failLayer("mnote_create_kb_http", `create KB HTTP ${result.status}`, { response: result.payload || result.text });
}
const payload = result.payload;
assert.equal(payload?.provider, "weknora", "create KB provider 应为 weknora");
assert(payload?.providerKnowledgeBaseId, `create KB 缺少 providerKnowledgeBaseId: ${JSON.stringify(payload, null, 2).slice(0, 1200)}`);
assert.equal(payload?.knowledgeBase?.provider, "weknora", "knowledgeBase provider 应为 weknora");
assert.equal(payload?.knowledgeBase?.providerKbId, payload.providerKnowledgeBaseId, "knowledgeBase.providerKbId 应匹配 providerKnowledgeBaseId");
return payload;
}
async function ingestFolder(context, folderPath, providerKnowledgeBaseId) {
const result = await apiJson(context, "POST", `${BASE_URL}/api/knowledge-rag/ingest`, {
rootUri: ROOT_URI,
workspaceId: WORKSPACE_ID,
force: true,
providerKnowledgeBaseId,
sources: [{ sourcePath: folderPath }],
});
if (!result.ok) {
throw failLayer("mnote_ingest_folder_http", `ingest folder HTTP ${result.status}`, { response: result.payload || result.text });
}
const payload = result.payload;
assert.equal(payload?.provider, "weknora", "ingest provider 应为 weknora");
const configured = Array.isArray(payload.configuredSources) ? payload.configuredSources : [];
assert(configured.length >= 2, `文件夹入库应至少展开两个文件: ${JSON.stringify(configured, null, 2).slice(0, 1600)}`);
const mapped = configured.filter((item) => item.mappingStatus === "provider_mapped" && item.providerKnowledgeBaseId === providerKnowledgeBaseId);
assert(mapped.length >= 2, `文件夹入库未全部映射到新 KB: ${JSON.stringify(configured, null, 2).slice(0, 2000)}`);
const registryEntries = payload.registry?.entries || [];
const folderEntries = registryEntries.filter((entry) => String(entry.sourceRootRelativePath || "").startsWith(`${folderPath}/`));
assert(folderEntries.length >= 2, "source registry 应写入文件夹下的 source");
assert(folderEntries.every((entry) => entry.provider === "weknora"), "source registry provider 应为 weknora");
assert(folderEntries.every((entry) => entry.providerKnowledgeBaseId === providerKnowledgeBaseId), "source registry 应写入新 KB id");
return { payload, configured, mapped, folderEntries };
}
async function searchUntilHit(context, query, folderPath, providerKnowledgeBaseId) {
const startedAt = Date.now();
let lastPayload = null;
while (Date.now() - startedAt < POLL_TIMEOUT_MS) {
const result = await apiJson(context, "POST", `${BASE_URL}/api/knowledge-rag/search`, {
rootUri: ROOT_URI,
workspaceId: WORKSPACE_ID,
query,
mode: "hybrid",
topK: 10,
chunkTopK: 10,
includeChunkContent: true,
providerKnowledgeBaseId,
sourcePaths: [folderPath],
});
if (!result.ok) {
throw failLayer("mnote_search_http", `search HTTP ${result.status}`, { response: result.payload || result.text });
}
lastPayload = result.payload;
const hit = (lastPayload.references || []).find((reference) => {
const pathText = String(reference.sourceRootRelativePath || "");
const quote = `${reference.rawQuote || ""}\n${reference.displayQuote || ""}\n${reference.quote || ""}`;
return reference.provider === "weknora"
&& reference.providerKnowledgeBaseId === providerKnowledgeBaseId
&& (pathText.startsWith(`${folderPath}/`) || quote.includes(query));
});
if (hit) return { payload: lastPayload, reference: hit };
await sleep(POLL_INTERVAL_MS);
}
throw failLayer("weknora_new_kb_folder_search_timeout", "等待新 KB 文件夹入库内容可搜索超时", {
query,
folderPath,
providerKnowledgeBaseId,
lastPayload,
});
}
function createFolderFixture(startedAt) {
if (!fs.existsSync(ROOT_PATH)) {
throw failLayer("allowed_root_missing", `allowed root 不存在: ${ROOT_PATH}`);
}
const marker = `task788 weknora kb folder browser marker ${startedAt}`;
const markers = [`${marker} alpha`, `${marker} beta`];
const folderPath = `knowledge-rag-fixtures-7-68/task788-folder-${startedAt}`;
const absoluteFolderPath = path.join(ROOT_PATH, folderPath);
const sourcePaths = [`${folderPath}/alpha.md`, `${folderPath}/beta.md`];
fs.mkdirSync(absoluteFolderPath, { recursive: true });
fs.writeFileSync(
path.join(absoluteFolderPath, "alpha.md"),
`# Task 788 Alpha\n\n${markers[0]}\n\nWeKnora folder browser flow alpha note.\n`,
"utf8",
);
fs.writeFileSync(
path.join(absoluteFolderPath, "beta.md"),
`# Task 788 Beta\n\n${markers[1]}\n\nWeKnora folder browser flow beta note.\n`,
"utf8",
);
return { folderPath, absoluteFolderPath, sourcePaths, marker, markers };
}
async function verifyInBrowser(fixture, name) {
const browser = await chromium.launch({
headless: true,
...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
});
const context = await browser.newContext({ viewport: { width: 1360, height: 900 } });
const page = await context.newPage();
try {
await signInBrowser(context);
const url = new URL(`${BASE_URL}/`);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", ROOT_URI);
url.searchParams.set("workspaceId", WORKSPACE_ID);
url.searchParams.set("treeView", "filetree");
await page.goto(url.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="mnote-knowledge-rag-settings-toggle"]').click({ timeout: UI_TIMEOUT_MS });
const panel = page.locator('[data-testid="mnote-weknora-knowledge-settings-panel"]');
await panel.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
assert.equal(await panel.getAttribute("data-knowledge-rag-default-provider"), "weknora", "设置面板应声明 WeKnora provider");
await panel.locator('[data-testid="mnote-weknora-create-kb-name"]').fill(name);
await panel.locator('[data-testid="mnote-weknora-create-kb-description"]').fill("MNote task788 temporary KB created through browser settings UI");
await panel.locator('[data-knowledge-rag-action="create-kb"]').click({ timeout: UI_TIMEOUT_MS });
const kbSelect = panel.locator('[data-testid="mnote-weknora-kb-select"]');
await page.waitForFunction(
(expectedName) => {
const status = document.querySelector('[data-testid="mnote-weknora-create-kb-status"]');
if (!(status instanceof HTMLElement) || status.getAttribute("data-status") !== "done") return false;
const select = document.querySelector('[data-testid="mnote-weknora-kb-select"]');
if (!(select instanceof HTMLSelectElement) || !select.value.trim()) return false;
const selected = select.options[select.selectedIndex];
return selected && selected.textContent.includes(expectedName);
},
name,
{ timeout: UI_TIMEOUT_MS },
);
const providerKnowledgeBaseId = await kbSelect.inputValue();
assert(providerKnowledgeBaseId, "UI 创建 KB 后应选中新 providerKnowledgeBaseId");
const firstSourceInput = panel.locator('[data-knowledge-rag-source-input]').first();
await firstSourceInput.fill(fixture.folderPath);
await panel.locator('[data-knowledge-rag-action="ingest"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-knowledge-rag-action="filter-sources"][data-knowledge-rag-filter="all"]').click({ timeout: UI_TIMEOUT_MS });
const sourceRows = await Promise.all(fixture.sourcePaths.map(async (sourcePath) => {
const row = page.locator(`[data-knowledge-rag-source-row="true"][data-knowledge-rag-source-path="${sourcePath}"]`).first();
await row.waitFor({ state: "visible", timeout: POLL_TIMEOUT_MS });
await row.locator('[data-knowledge-rag-action="open-source-reference"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
return sourcePath;
}));
const kbSummary = panel.locator('[data-testid="mnote-weknora-kb-summary"]');
await kbSummary.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const kbIds = await kbSummary.getAttribute("data-knowledge-rag-kb-ids");
const sourceRowsForKb = await panel.locator(`[data-knowledge-rag-source-row="true"][data-knowledge-rag-kb-id="${providerKnowledgeBaseId}"]`).count();
assert(sourceRowsForKb >= 2, `source rows 未反映新建 KB id: ${providerKnowledgeBaseId}`);
await panel.locator('[data-kb-rag-tab="search"]').click({ timeout: UI_TIMEOUT_MS });
await panel.locator('[data-kb-rag-search-input]').fill(fixture.markers[0]);
let searchResultSourcePath = null;
const startedAt = Date.now();
while (Date.now() - startedAt < POLL_TIMEOUT_MS) {
await panel.locator('[data-kb-rag-action="search"]').click({ timeout: UI_TIMEOUT_MS });
const result = panel.locator(`[data-kb-rag-search-result="true"][data-knowledge-rag-kb-id="${providerKnowledgeBaseId}"]`).first();
try {
await result.waitFor({ state: "visible", timeout: POLL_INTERVAL_MS });
searchResultSourcePath = await result.getAttribute("data-knowledge-rag-source-path");
break;
} catch {
await sleep(POLL_INTERVAL_MS);
}
}
assert(searchResultSourcePath, `UI 检索未展示新 KB ${providerKnowledgeBaseId} 的结果`);
await panel.locator('[data-kb-rag-search-result="true"] [data-knowledge-rag-action="open-source-reference"]').first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true });
return {
providerKnowledgeBaseId,
kbSummaryVisible: true,
kbSummaryConfiguredKbIds: String(kbIds || "").split(",").filter(Boolean),
sourceRowsForKb,
sourceRowsVisible: sourceRows,
searchResultVisible: true,
searchResultSourcePath,
searchVerifiedByApi: false,
searchUiProviderKbScopeMissing: false,
openReferenceControlsVisible: true,
screenshot: SCREENSHOT_PATH,
};
} catch (error) {
await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true }).catch(() => undefined);
throw failLayer("browser_ui_verify", error.message || String(error), { screenshot: SCREENSHOT_PATH });
} finally {
await browser.close().catch(() => undefined);
}
}
async function main() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const context = await request.newContext({ baseURL: BASE_URL });
try {
await signInRequest(context);
const startedAt = Date.now();
const fixture = createFolderFixture(startedAt);
const name = `task788-weknora-kb-${startedAt}`;
const browserResult = await verifyInBrowser(fixture, name);
const providerKnowledgeBaseId = browserResult.providerKnowledgeBaseId;
const searchResult = await searchUntilHit(context, fixture.markers[0], fixture.folderPath, providerKnowledgeBaseId);
const afterStatus = await status(context);
const statusBase = (afterStatus.knowledgeBases?.bases || []).find((base) => base.providerKbId === providerKnowledgeBaseId);
assert(statusBase, "status knowledgeBases 应包含新建 KB");
assert(statusBase.sourceCount >= 2, `新建 KB sourceCount 应 >= 2: ${JSON.stringify(statusBase, null, 2)}`);
const result = {
ok: true,
task: "task788-weknora-kb-folder-browser-flow-smoke",
baseUrl: BASE_URL,
rootUri: ROOT_URI,
workspaceId: WORKSPACE_ID,
uiCreatesKb: true,
uiAddsFolder: true,
browserVerifiesKb: true,
browserVerifiesSearch: true,
browserVerifiesOpenReferenceControls: true,
apiVerifiesSearch: true,
folderPath: fixture.folderPath,
absoluteFolderPath: fixture.absoluteFolderPath,
markers: fixture.markers,
knowledgeBase: {
name,
providerKnowledgeBaseId,
baseId: statusBase.baseId,
defaultToolEnabled: statusBase.defaultToolEnabled,
sourceCount: statusBase.sourceCount,
status: statusBase.status,
},
ingest: {
configuredCount: fixture.sourcePaths.length,
mappedCount: fixture.sourcePaths.length,
sourcePaths: fixture.sourcePaths,
},
apiSearch: {
provider: searchResult.payload.provider,
sourceRootRelativePath: searchResult.reference.sourceRootRelativePath,
providerKnowledgeBaseId: searchResult.reference.providerKnowledgeBaseId,
providerKnowledgeId: searchResult.reference.providerKnowledgeId,
providerChunkId: searchResult.reference.providerChunkId,
},
browser: browserResult,
};
writeJson(RESULT_PATH, result);
console.log(JSON.stringify(result, null, 2));
} finally {
await context.dispose().catch(() => undefined);
}
}
main().catch((error) => {
const payload = {
ok: false,
task: "task788-weknora-kb-folder-browser-flow-smoke",
layer: error.layer || "unexpected",
error: error.stack || error.message || String(error),
details: error.details || null,
baseUrl: BASE_URL,
rootUri: ROOT_URI,
workspaceId: WORKSPACE_ID,
};
writeJson(FAILURE_PATH, payload);
console.error(JSON.stringify(payload, null, 2));
process.exit(1);
});
@@ -0,0 +1,58 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const fs = require("node:fs");
const path = require("node:path");
const ROOT = process.cwd();
const OUTPUT_DIR = path.join(ROOT, "tmp", "task789-weknora-kb-page-ui-reference-static-smoke");
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
const REFERENCE_DOC = "design/07-ai/reference/7-68-weknora-kb-page-ui-reference-v1.md";
function read(relativePath) {
return fs.readFileSync(path.join(ROOT, relativePath), "utf8");
}
function assertContains(content, needle) {
assert(content.includes(needle), `reference doc 缺少: ${needle}`);
}
function main() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
assert(fs.existsSync(path.join(ROOT, REFERENCE_DOC)), `${REFERENCE_DOC} 不存在`);
const doc = read(REFERENCE_DOC);
for (const required of [
"/mnt/Data1T/Mnote_data/weknora/WeKnora/frontend/src/views/knowledge/KnowledgeBaseList.vue",
"/mnt/Data1T/Mnote_data/weknora/WeKnora/frontend/src/views/knowledge/KnowledgeBase.vue",
"/mnt/Data1T/Mnote_data/weknora/WeKnora/frontend/src/views/knowledge/components/DocumentListView.vue",
"/mnt/Data1T/Mnote_data/weknora/WeKnora/frontend/src/views/knowledge/components/KbUploadSourceDropdown.vue",
"/mnt/Data1T/Mnote_data/weknora/WeKnora/frontend/src/components/knowledge-processing-timeline.vue",
"providerKnowledgeId",
"不能直接接管的边界",
"MNote 最小呈现建议",
]) {
assertContains(doc, required);
}
const result = {
ok: true,
checkedFiles: [REFERENCE_DOC],
assertions: {
referenceDocExists: true,
weknoraKbListPathRecorded: true,
weknoraKbDetailPathRecorded: true,
documentListPathRecorded: true,
uploadDropdownPathRecorded: true,
processingTimelinePathRecorded: true,
mnoteMappingRecorded: true,
boundariesRecorded: true,
minimumStructureRecorded: true,
},
};
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
console.log(JSON.stringify(result, null, 2));
}
main();
@@ -0,0 +1,88 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const fs = require("node:fs");
const path = require("node:path");
const ROOT = process.cwd();
const OUTPUT_DIR = path.join(ROOT, "tmp", "task790-weknora-kb-page-experience-static-smoke");
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
const SETTINGS_RUNTIME = "rust/crates/mnote-web/browser/sidebar-page-settings-runtime.js";
const MAIN_CSS = "rust/crates/mnote-web/src/ssr/styles/components/main.css";
function read(relativePath) {
return fs.readFileSync(path.join(ROOT, relativePath), "utf8");
}
function assertContains(content, needle, label) {
assert(content.includes(needle), `${label} 缺少: ${needle}`);
}
function main() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const runtime = read(SETTINGS_RUNTIME);
const css = read(MAIN_CSS);
for (const required of [
"data-mnote-weknora-page-replacement=\"true\"",
"data-testid=\"mnote-weknora-kb-list-pane\"",
"data-testid=\"mnote-weknora-kb-card-list\"",
"data-testid=\"mnote-weknora-kb-breadcrumb\"",
"data-testid=\"mnote-weknora-kb-detail-pane\"",
"data-testid=\"mnote-weknora-tab-documents\"",
"data-testid=\"mnote-weknora-tab-wiki\"",
"data-testid=\"mnote-weknora-tab-graph\"",
"data-testid=\"mnote-weknora-doc-source-sidebar\"",
"data-testid=\"mnote-weknora-doc-source-tags\"",
"data-testid=\"mnote-weknora-doc-tags\"",
"data-testid=\"mnote-weknora-doc-filter-input\"",
"data-testid=\"mnote-weknora-view-switch\"",
"data-knowledge-rag-view=\"grid\"",
"添加文件夹",
"添加文档",
"data-weknora-document-list-view=\"true\"",
"data-knowledge-rag-processing-status",
"data-knowledge-rag-action=\"open-source-reference\"",
"data-knowledge-rag-open-reference-mode=\"source-registry-reference\"",
"data-testid=\"mnote-weknora-wiki-panel\"",
"data-testid=\"mnote-weknora-graph-panel\"",
"MNote source registry / WeKnora KB chunk index",
]) {
assertContains(runtime, required, SETTINGS_RUNTIME);
}
for (const required of [
".mnote-weknora-kb-breadcrumb",
".mnote-weknora-doc-layout",
".mnote-weknora-doc-sidebar",
".mnote-weknora-doc-source-tags",
".mnote-weknora-view-switch",
".mnote-weknora-doc-table[data-weknora-document-view=\"grid\"]",
".mnote-weknora-placeholder-panel",
".mnote-weknora-source-aux",
]) {
assertContains(css, required, MAIN_CSS);
}
const result = {
ok: true,
checkedFiles: [SETTINGS_RUNTIME, MAIN_CSS],
assertions: {
kbListPageVisible: true,
kbDetailBreadcrumbVisible: true,
documentsWikiGraphTabsVisible: true,
sourceAndTagSidebarVisible: true,
searchAndFilterVisible: true,
addDocumentAndFolderEntrypointsVisible: true,
gridListSwitchVisible: true,
processingStatusVisible: true,
sourceRowsKeepCitationOpenReference: true,
mnoteWeKnoraBoundaryPreserved: true,
},
};
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
console.log(JSON.stringify(result, null, 2));
}
main();
@@ -0,0 +1,101 @@
#!/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 || "/tmp/mnote-openhub-research/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.");
@@ -0,0 +1,169 @@
#!/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/tmp/mnote-openhub-research/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, "<Segmented", "<TextArea");
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("<Segmented") &&
controlRow.includes("<ModelSelect") &&
controlRow.includes("data-mnote-openhub-current-tab-toggle") &&
controlRow.includes("data-mnote-openhub-current-folder-toggle") &&
controlRow.indexOf("<Segmented") < controlRow.indexOf("data-mnote-openhub-current-tab-toggle") &&
controlRow.indexOf("<ModelSelect") < controlRow.indexOf("data-mnote-openhub-current-folder-toggle"),
"native buttons must be before TextArea and in the same row as agent segmented/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.");
@@ -0,0 +1,225 @@
#!/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 || "/tmp/mnote-openhub-research/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);
});
@@ -0,0 +1,212 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const fs = require("node:fs");
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 ROOT_PATH = process.env.MNOTE_KNOWLEDGE_RAG_ROOT_PATH || "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space";
const ROOT_URI = process.env.MNOTE_KNOWLEDGE_RAG_ROOT_URI || `file://${ROOT_PATH}`;
const WORKSPACE_ID = process.env.MNOTE_KNOWLEDGE_RAG_WORKSPACE_ID || "local-ws:mnote-e2e:my-space";
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "task79x-weknora-kb-page-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, "weknora-kb-page-browser-smoke.png");
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|| ["/usr/bin/chromium-browser", "/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium", "/usr/bin/chromium"]
.find((candidate) => fs.existsSync(candidate));
function writeJson(filePath, payload) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
}
function locatorCount(page, selector) {
return page.locator(selector).count();
}
async function signIn(context) {
const response = await context.request.post(`${BASE_URL}/api/auth`, {
data: {
action: "auth:signIn",
args: {
provider: "password",
params: {
email: "mnote.e2e@example.com",
password: process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!",
flow: "signIn",
},
},
},
timeout: UI_TIMEOUT_MS,
});
assert(response.ok(), `登录失败: ${response.status()} ${await response.text()}`);
}
async function visible(page, selector) {
return page.locator(selector).first().isVisible().catch(() => false);
}
async function visibleCount(page, selector) {
return page.locator(selector).evaluateAll((nodes) => nodes.filter((node) => {
if (!(node instanceof HTMLElement)) return false;
const style = window.getComputedStyle(node);
const rect = node.getBoundingClientRect();
return style.visibility !== "hidden" && style.display !== "none" && rect.width > 0 && rect.height > 0;
}).length).catch(() => 0);
}
async function collectPageState(page) {
const panelSelector = '[data-testid="mnote-weknora-knowledge-settings-panel"]';
const panel = page.locator(panelSelector);
const panelText = await panel.innerText({ timeout: UI_TIMEOUT_MS }).catch(() => "");
const activeTab = await page.locator('[data-kb-rag-tab][aria-selected="true"]').first().getAttribute("data-kb-rag-tab").catch(() => "");
const metaAttrs = await page.locator('[data-testid="mnote-knowledge-rag-meta"]').evaluate((node) => ({
activeProvider: node.getAttribute("data-knowledge-rag-active-provider") || "",
legacyFallbackProvider: node.getAttribute("data-knowledge-rag-legacy-fallback-provider") || "",
referenceBoundary: node.getAttribute("data-knowledge-rag-reference-boundary") || "",
})).catch(() => ({ activeProvider: "", legacyFallbackProvider: "", referenceBoundary: "" }));
const firstCard = page.locator('[data-testid="mnote-weknora-kb-card-list"] [data-knowledge-rag-action="select-kb"]').first();
const firstSourceRow = page.locator('[data-knowledge-rag-source-row="true"]').first();
const firstSearchResult = page.locator('[data-kb-rag-search-result="true"]').first();
return {
url: page.url(),
title: await page.title().catch(() => ""),
replacementMarker: await visible(page, '[data-mnote-weknora-page-replacement="true"]'),
defaultProvider: await panel.getAttribute("data-knowledge-rag-default-provider").catch(() => ""),
providerIndex: await panel.getAttribute("data-knowledge-rag-provider-index").catch(() => ""),
sourceRegistry: await panel.getAttribute("data-knowledge-rag-source-registry").catch(() => ""),
panelTextSample: panelText.slice(0, 1200),
activeTab,
metaAttrs,
counts: {
kbCards: await locatorCount(page, '[data-testid="mnote-weknora-kb-card-list"] [data-knowledge-rag-action="select-kb"]'),
createKbControls: await visibleCount(page, '[data-testid="mnote-weknora-kb-controls"], [data-knowledge-rag-action="create-kb"]'),
detailHeroes: await visibleCount(page, '[data-testid="mnote-weknora-kb-detail-hero"]'),
tabs: await locatorCount(page, '[data-kb-rag-tab]'),
sourcePlaceholdersOrRows: await locatorCount(page, '[data-testid="mnote-knowledge-rag-sources"] .wolai-page-settings-index-empty, [data-knowledge-rag-source-row="true"]'),
sourceFilters: await locatorCount(page, '[data-testid="mnote-knowledge-rag-source-filters"] [data-knowledge-rag-action="filter-sources"]'),
addDocumentEntries: await visibleCount(page, '[data-knowledge-rag-action="add-source"], [data-knowledge-rag-action="ingest"], [data-knowledge-rag-source-input]'),
processingStatusControls: await locatorCount(page, '[data-knowledge-rag-processing-count], [data-knowledge-rag-processing-status], [role="progressbar"], [data-testid="mnote-knowledge-rag-status"]'),
openReferenceControls: await locatorCount(page, '[data-knowledge-rag-action="open-source-reference"], [data-kb-rag-search-result="true"] [data-knowledge-rag-action="open-source-reference"]'),
searchControls: await visibleCount(page, '[data-testid="mnote-weknora-doc-filter-input"], [data-kb-rag-search-input], [data-kb-rag-action="search"]'),
oldSimplePanelSignals: await locatorCount(page, '.mnote-knowledge-rag-settings-panel:not(.mnote-weknora-kb-page), [data-testid="mnote-knowledge-rag-settings-panel"]:not([data-mnote-weknora-page-replacement="true"])'),
},
firstCard: {
kbId: await firstCard.getAttribute("data-knowledge-rag-kb-id").catch(() => ""),
processingCount: await firstCard.getAttribute("data-knowledge-rag-processing-count").catch(() => ""),
text: await firstCard.innerText().catch(() => ""),
},
firstSourceRow: {
sourcePath: await firstSourceRow.getAttribute("data-knowledge-rag-source-path").catch(() => ""),
kbId: await firstSourceRow.getAttribute("data-knowledge-rag-kb-id").catch(() => ""),
processingStatus: await firstSourceRow.getAttribute("data-knowledge-rag-processing-status").catch(() => ""),
openReferenceMode: await firstSourceRow.getAttribute("data-knowledge-rag-open-reference-mode").catch(() => ""),
text: await firstSourceRow.innerText().catch(() => ""),
},
firstSearchResult: {
sourcePath: await firstSearchResult.getAttribute("data-knowledge-rag-source-path").catch(() => ""),
kbId: await firstSearchResult.getAttribute("data-knowledge-rag-kb-id").catch(() => ""),
text: await firstSearchResult.innerText().catch(() => ""),
},
};
}
function evaluateAssertions(state) {
const assertions = {
openedReplacementPanel: state.replacementMarker === true,
providerIsWeKnora: state.defaultProvider === "weknora" && state.metaAttrs.activeProvider === "weknora",
notOldSimpleSettingsPanel: state.counts.oldSimplePanelSignals === 0,
hasKnowledgeBaseListCards: state.counts.kbCards > 0 || Boolean(state.firstCard.kbId),
hasCreateKbControl: state.counts.createKbControls >= 2,
hasDetailStructure: state.counts.detailHeroes > 0,
hasDocumentTabs: state.counts.tabs >= 3 && /(Documents|Wiki|Graph|文档)/.test(state.panelTextSample),
hasSourceOrTagSidebarPlaceholder: state.counts.sourcePlaceholdersOrRows > 0 && state.counts.sourceFilters >= 4,
hasSearchAndFilter: state.counts.searchControls >= 1 && state.counts.sourceFilters >= 4,
hasAddDocumentEntry: state.counts.addDocumentEntries >= 3,
hasProcessingOrStatus: state.counts.processingStatusControls > 0 && /Processing|索引|provider 状态|就绪|未就绪/.test(state.panelTextSample),
hasOpenReferenceOrCitationControl: state.counts.openReferenceControls > 0
|| state.metaAttrs.referenceBoundary === "mnote-source-registry-weknora-kb-chunk-index"
|| /引用|citation|open-reference|打开引用/i.test(state.panelTextSample),
};
const missing = Object.entries(assertions)
.filter(([, ok]) => !ok)
.map(([name]) => name);
return { assertions, missing, ok: missing.length === 0 };
}
async function main() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const browser = await chromium.launch({
headless: true,
...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
});
const context = await browser.newContext({ viewport: { width: 1440, height: 980 } });
const page = await context.newPage();
try {
await signIn(context);
const url = new URL(`${BASE_URL}/`);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", ROOT_URI);
url.searchParams.set("workspaceId", WORKSPACE_ID);
url.searchParams.set("treeView", "filetree");
await page.goto(url.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="mnote-knowledge-rag-settings-toggle"]').click({ timeout: UI_TIMEOUT_MS });
const panel = page.locator('[data-testid="mnote-weknora-knowledge-settings-panel"]');
await panel.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.waitForFunction(() => {
const panelNode = document.querySelector('[data-testid="mnote-weknora-knowledge-settings-panel"]');
if (!(panelNode instanceof HTMLElement)) return false;
const text = panelNode.textContent || "";
const hasCard = Boolean(panelNode.querySelector('[data-testid="mnote-weknora-kb-card-list"] [data-knowledge-rag-action="select-kb"]'));
const statusSettled = !/正在读取知识库 provider 状态/.test(text);
return hasCard || statusSettled;
}, { timeout: UI_TIMEOUT_MS }).catch(() => undefined);
await page.locator('[data-kb-rag-tab="sources"]').click({ timeout: UI_TIMEOUT_MS }).catch(() => undefined);
await page.locator('[data-knowledge-rag-action="filter-sources"][data-knowledge-rag-filter="all"]').click({ timeout: UI_TIMEOUT_MS }).catch(() => undefined);
await page.locator('[data-kb-rag-tab="search"]').click({ timeout: UI_TIMEOUT_MS }).catch(() => undefined);
await page.locator('[data-kb-rag-tab="sources"]').click({ timeout: UI_TIMEOUT_MS }).catch(() => undefined);
await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true });
const state = await collectPageState(page);
const verdict = evaluateAssertions(state);
const result = {
ok: verdict.ok,
task: "task79x-weknora-kb-page-browser-smoke",
baseUrl: BASE_URL,
rootUri: ROOT_URI,
workspaceId: WORKSPACE_ID,
screenshot: SCREENSHOT_PATH,
resultPath: RESULT_PATH,
...verdict,
state,
};
writeJson(RESULT_PATH, result);
console.log(JSON.stringify(result, null, 2));
if (!result.ok) process.exit(1);
} catch (error) {
await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true }).catch(() => undefined);
const failure = {
ok: false,
task: "task79x-weknora-kb-page-browser-smoke",
baseUrl: BASE_URL,
rootUri: ROOT_URI,
workspaceId: WORKSPACE_ID,
screenshot: SCREENSHOT_PATH,
error: error.stack || error.message || String(error),
};
writeJson(FAILURE_PATH, failure);
console.error(JSON.stringify(failure, null, 2));
process.exit(1);
} finally {
await browser.close().catch(() => undefined);
}
}
main();