chore: retire openhub and pi ts runtime

This commit is contained in:
Agent Board
2026-07-12 14:18:26 +08:00
parent 62959b0c4d
commit 2f5902e3e7
45 changed files with 367 additions and 925 deletions
@@ -0,0 +1,4 @@
#!/usr/bin/env node
"use strict";
require("./task779-openhub-file-edit-document-pane-refresh-smoke.js");
@@ -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-user-id') && route.includes('x-mnote-display-name') && route.includes('x-mnote-workspace-key') && route.includes('x-mnote-session-scope') && route.includes('x-mnote-tool-permission-scope') && route.includes('x-mnote-weknora-tool-scope')],
['proxy carries full mnote scope internally', route.includes('mnoteScope') && route.includes('mnote_scope_from_query') && route.includes('proxy_query_without_internal_scope')],
['snake case bootstrap scope aliases', route.includes('"openhub_user_key"') && route.includes('"workspace_key"') && route.includes('"session_scope"') && route.includes('"tool_permission_scope"')],
['weknora tool scope', route.includes('"weknoraToolScope"') && route.includes('"weknora_tool_scope"') && runtime.includes('weknora_tool_scope')],
['layered status endpoint', route.includes('"openhub_fastapi"') && route.includes('"opencode"') && route.includes('"weknora"') && route.includes('"mnote_binding"')],
['degraded external services are explicit', route.includes('"degraded"') && route.includes('reachable') && route.includes('upstream_http_')],
['ai proxy boundary', route.includes('pub async fn ai_proxy') && routesMod.includes('/page-ai/openhub/ai/{*path}')],
['no visible opencode fallback action', !runtime.includes('openhub-use-opencode-fallback')],
['non ai route guard', route.includes('page_ai_openhub_non_ai_route_guarded') && routesMod.includes('/page-ai/openhub/knowledge/{*path}') && routesMod.includes('/page-ai/openhub/file/{*path}') && routesMod.includes('/page-ai/openhub/git/{*path}')],
['static ai shell', route.includes('data-mnote-openhub-ai-shell="static-boundary"') && routesMod.includes('/page-ai/openhub/ai')],
['openhub quick address actions are native openhub source not proxy overlay', !route.includes('data-mnote-openhub-ai-quick-actions') && !route.includes('data-mnote-openhub-send-current-tab') && !route.includes('data-mnote-openhub-send-current-folder')],
['openhub quick address bridge uses active tab only', runtime.includes("message.source === 'openhub-ai'") && runtime.includes("message.type === 'mnote:get-active-tab-address'") && runtime.includes('pageAiCurrentActiveTabEditorTarget') && runtime.includes("type: 'mnote:active-tab-address'")],
['openhub folder action sends folder address only', runtime.includes('pageAiCurrentActiveTabAddressPayload') && runtime.includes('folderUrl') && runtime.includes("kind === 'folder' ? addressPayload.folderUrl : addressPayload.tabUrl")],
['openhub diagnostics are hidden from user chrome', runtime.includes('wolai-page-ai-openhub-diagnostics') && runtime.includes('data-page-ai-openhub-bootstrap-copy hidden aria-hidden="true"')],
['no duplicate mnote openhub shell header', !runtime.includes('<h2 class="wolai-page-ai-title">OpenHub AI</h2>') && !runtime.includes('data-page-ai-openhub-runtime-status>OpenHub host boundary 静态占位')],
['no visible openhub legacy fallback switch', !runtime.includes("localStorage.setItem('mnote.page_ai.openhub_host', '0')")],
['openhub fallback payload has no legacy route', route.includes('"enabled": false') && !route.includes('"legacyRoute": "/page-ai/opencode"')],
];
const failed = checks.filter(([, ok]) => !ok);
if (failed.length) {
console.error('Page AI OpenHub host static smoke failed:');
for (const [name] of failed) console.error(`- ${name}`);
process.exit(1);
}
console.log('Page AI OpenHub host static smoke passed.');
@@ -0,0 +1,98 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const openHubRoot =
process.env.OPENHUB_RESEARCH_ROOT || '/mnt/Data1T/Mnote_data/openhub/OpenHub';
const backendRoot = path.join(openHubRoot, 'smart-query-backend');
const disableEnv = 'MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE';
const legacyDisableEnv = 'OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE';
function read(relativePath) {
return fs.readFileSync(path.join(backendRoot, relativePath), 'utf8');
}
function assertCheck(name, passed) {
if (!passed) failures.push(name);
}
function includesAll(source, needles) {
return needles.every((needle) => source.includes(needle));
}
const failures = [];
const gitSnapshot = read('app/services/git_snapshot.py');
const stream = read('app/services/stream.py');
const taskExecutor = read('app/services/task_executor.py');
const session = read('app/api/session.py');
assertCheck(
'git_snapshot exposes MNote disable env and legacy equivalent',
includesAll(gitSnapshot, [disableEnv, legacyDisableEnv, 'def is_snapshot_restore_disabled'])
);
assertCheck(
'git_snapshot low-level git write command guard covers destructive/write commands',
includesAll(gitSnapshot, [
'_GIT_WRITE_COMMANDS',
'"init"',
'"config"',
'"add"',
'"commit"',
'"checkout"',
'"restore"',
'"reset"',
'"revert"',
'is_snapshot_restore_disabled() and _is_git_write(args)',
])
);
assertCheck(
'git_snapshot high-level write APIs short-circuit when disabled',
includesAll(gitSnapshot, [
'def init_git_repo',
'def create_snapshot',
'def create_restore_snapshot',
'def restore_all',
'def restore_single_file',
'disabled by {GIT_SNAPSHOT_RESTORE_DISABLE_ENV}',
])
);
assertCheck(
'stream automatic snapshot path is guarded before init/create_snapshot',
stream.includes('not git_snap.is_snapshot_restore_disabled()') &&
stream.includes('git_snap.init_git_repo') &&
stream.includes('git_snap.create_snapshot') &&
stream.includes('Git snapshot skipped: disabled by MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE')
);
assertCheck(
'task_executor automatic snapshot path is guarded before init/create_snapshot',
taskExecutor.includes('not git_snapshot.is_snapshot_restore_disabled()') &&
taskExecutor.includes('git_snapshot.init_git_repo') &&
taskExecutor.includes('git_snapshot.create_snapshot') &&
taskExecutor.includes('Git snapshot skipped: disabled by MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE')
);
const restoreRouteGuardCount = (
session.match(/git_snapshot\.is_snapshot_restore_disabled\(\)/g) || []
).length;
assertCheck(
'session restore routes reject when disabled',
restoreRouteGuardCount >= 2 &&
session.includes('Git snapshot/restore 写链已由 MNote 禁用') &&
session.includes('git_snapshot.restore_all') &&
session.includes('git_snapshot.restore_single_file') &&
session.includes('git_snapshot.create_restore_snapshot')
);
if (failures.length) {
console.error('OpenHub git snapshot/restore guard static smoke failed:');
for (const failure of failures) console.error(`- ${failure}`);
process.exit(1);
}
console.log('OpenHub git snapshot/restore guard static smoke passed.');
@@ -0,0 +1,135 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const openHubRoot =
process.env.OPENHUB_RESEARCH_ROOT || '/mnt/Data1T/Mnote_data/openhub/OpenHub';
const backendRoot = path.join(openHubRoot, 'smart-query-backend');
function read(relativePath) {
return fs.readFileSync(path.join(backendRoot, relativePath), 'utf8');
}
function assertCheck(name, passed) {
if (!passed) failures.push(name);
}
function includesAll(source, needles) {
return needles.every((needle) => source.includes(needle));
}
const failures = [];
const mnoteScope = read('app/core/mnote_scope.py');
const auth = read('app/core/auth.py');
const query = read('app/api/query.py');
const session = read('app/api/session.py');
const stream = read('app/services/stream.py');
const requiredHeaders = [
'X-MNote-User-Key',
'X-MNote-Workspace-Key',
'X-MNote-Session-Scope',
'X-MNote-Root-Uri',
'X-MNote-Page-Resource-Id',
'X-MNote-Tool-Permission-Scope',
'X-MNote-WeKnora-Tool-Scope',
];
assertCheck(
'mnote scope helper exists with all controlled headers',
includesAll(mnoteScope, [
'MNOTE_HOST_TRUTH = "mnote_controlled_headers"',
'def derive_mnote_user',
'def resolve_user_workspace',
'def resolve_user_asset_workspace',
'def get_mnote_scope_metadata',
'X-MNote-Display-Name',
...requiredHeaders,
])
);
assertCheck(
'derived user is stable per MNote user and not tied to workspace scope',
includesAll(mnoteScope, [
'def _stable_openhub_user_id(user_key: str)',
'mnote_openhub_user_id',
'user_key',
'workspace_key',
'display_name',
'get_user_by_username',
'"openhub_user_id"',
'"openhub_username"',
]) &&
!mnoteScope.includes('def _stable_openhub_user_id(user_key: str, workspace_key: str)') &&
!mnoteScope.includes('_stable_openhub_user_id(user_key, workspace_key)') &&
!mnoteScope.includes('mnote_shared_user')
);
assertCheck(
'session and workspace scope are derived from MNote scope',
includesAll(mnoteScope, [
'def _stable_openhub_session_id',
'mnote_openhub_session',
'openhub_workspace_scope',
'session_scope',
'root_uri',
'workspace_path',
'_root_uri_to_workspace_path',
])
);
assertCheck(
'tool and weknora scopes are retained as MNote scope metadata',
includesAll(mnoteScope, [
'tool_permission_scope',
'weknora_tool_scope',
'provisioned_workspace_path',
'_parse_scope_header',
'"mnote_scope"',
'"source_headers"',
])
);
assertCheck(
'MNote host mode rejects frontend JWT/localStorage truth',
includesAll(mnoteScope, [
'REJECTED_MNOTE_HOST_TRUTHS',
'localStorage',
'OpenHub JWT',
'frontend JWT',
]) &&
auth.includes('derive_mnote_user(request)') &&
auth.includes('HTTPBearer(auto_error=False)') &&
auth.indexOf('derive_mnote_user(request)') < auth.indexOf('validate_token(token)')
);
assertCheck(
'query/session entries consume derived workspace and scope',
query.includes('resolve_user_workspace(current_user)') &&
query.includes('not current_user.get("mnote_host_mode")') &&
query.includes('mnote_scope = get_mnote_scope_metadata(current_user)') &&
session.includes('resolve_user_workspace(current_user)') &&
session.includes('mnote_scope=get_mnote_scope_metadata(current_user)')
);
assertCheck(
'stream persists MNote scope metadata with user message',
includesAll(stream, [
'mnote_scope: Optional[dict] = None',
'metadata["mnote_scope"] = mnote_scope',
'database.save_session',
'user_id',
'workspace_path',
'not mnote_scope',
])
);
if (failures.length) {
console.error('OpenHub MNote scope bridge static smoke failed:');
for (const failure of failures) console.error(`- ${failure}`);
process.exit(1);
}
console.log('OpenHub MNote scope bridge static smoke passed.');
@@ -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 || "/mnt/Data1T/Mnote_data/openhub/OpenHub";
const backendSessionPath = path.join(openHubRoot, "smart-query-backend/app/api/session.py");
const frontendApiPath = path.join(openHubRoot, "smart-query-frontend/src/services/api.js");
const diffViewerPath = path.join(openHubRoot, "smart-query-frontend/src/components/DiffViewer.jsx");
const embedPath = path.join(openHubRoot, "smart-query-frontend/src/mnoteEmbed.js");
const smartQueryPath = path.join(openHubRoot, "smart-query-frontend/src/pages/SmartQueryPage.jsx");
const mnoteRuntimePath = path.join(repoRoot, "rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js");
const task774ResultPath = path.join(repoRoot, "tmp/7-68-runtime/openhub-send-smoke-result.json");
const outputDir = path.join(repoRoot, "tmp/7-68-runtime");
const resultPath = path.join(outputDir, "openhub-changed-files-bridge-smoke-result.json");
const baseUrl = process.env.MNOTE_BASE_URL || "http://127.0.0.1:3000";
const testAccount = process.env.MNOTE_E2E_ACCOUNT || "mnote-e2e";
const testPassword = process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!";
const workspaceRoot = process.env.MNOTE_OPENHUB_SMOKE_ROOT
|| "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space";
function read(filePath) {
return fs.readFileSync(filePath, "utf8");
}
function assertCheck(failures, name, passed, details = undefined) {
if (!passed) failures.push({ name, details });
}
function loadTask774Probe() {
if (!fs.existsSync(task774ResultPath)) return null;
const payload = JSON.parse(fs.readFileSync(task774ResultPath, "utf8"));
const sessionId = payload?.fileEdit?.stream?.sessionId;
const fixturePath = payload?.fileEdit?.fixturePath;
if (!payload?.ok || !sessionId || !fixturePath) return null;
return { sessionId, fixturePath };
}
async function signInCookie() {
const response = await fetch(`${baseUrl}/api/auth`, {
method: "POST",
headers: { "content-type": "application/json", accept: "application/json" },
body: JSON.stringify({
action: "auth:signIn",
args: {
provider: "password",
params: {
account: testAccount,
password: testPassword,
flow: "signIn",
},
},
}),
signal: AbortSignal.timeout(12_000),
});
const text = await response.text();
if (!response.ok) {
throw new Error(`/api/auth 登录失败: HTTP ${response.status} ${text.slice(0, 400)}`);
}
const setCookie = response.headers.get("set-cookie") || "";
const cookies = setCookie
.split(/,(?=\s*[^;,\s]+=)/)
.map((part) => part.split(";")[0].trim())
.filter(Boolean);
if (!cookies.some((cookie) => cookie.startsWith("mnote_session="))) {
throw new Error(`/api/auth 未返回 mnote_session cookie: ${setCookie.slice(0, 400)}`);
}
return cookies.join("; ");
}
async function bootstrapScopeQuery(cookieHeader) {
const response = await fetch(`${baseUrl}/api/page-ai/openhub/bootstrap`, {
method: "POST",
headers: {
"content-type": "application/json",
accept: "application/json",
cookie: cookieHeader,
},
body: JSON.stringify({
workspaceId: "local-ws:mnote-e2e:my-space",
rootUri: `file://${workspaceRoot}`,
pageResourceId: "task776-openhub-changed-files-bridge",
pageTitle: "OpenHub changed files bridge smoke",
allowedRoots: [`file://${workspaceRoot}`],
}),
signal: AbortSignal.timeout(12_000),
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(`OpenHub bootstrap 失败: HTTP ${response.status} ${JSON.stringify(payload).slice(0, 500)}`);
}
const iframeUrl = String(payload?.openhubIframeUrl || "");
const query = iframeUrl.includes("?") ? iframeUrl.slice(iframeUrl.indexOf("?")) : "";
if (!query.includes("mnoteScope=")) {
throw new Error(`OpenHub bootstrap 未返回完整 mnoteScope query: ${iframeUrl.slice(0, 200)}`);
}
return query;
}
async function probeLiveEndpoint(probe) {
if (!probe) {
return { skipped: true, reason: "缺少 task774 真实文件编辑结果,先运行 MNOTE_OPENHUB_FILE_EDIT=1 node scripts/task774-openhub-mnote-send-and-file-edit-e2e.js" };
}
const cookieHeader = await signInCookie();
const query = await bootstrapScopeQuery(cookieHeader);
const url = `${baseUrl}/page-ai/openhub/ai/api/sessions/${encodeURIComponent(probe.sessionId)}/diff${query}`;
const response = await fetch(url, {
headers: { accept: "application/json", cookie: cookieHeader },
signal: AbortSignal.timeout(12_000),
});
const text = await response.text();
let payload = null;
try {
payload = JSON.parse(text);
} catch {}
const changed = Array.isArray(payload?.diffs) ? payload.diffs : [];
const matched = changed.some((item) => item.path === probe.fixturePath);
return {
skipped: false,
ok: response.ok && matched,
status: response.status,
matched,
expectedPath: probe.fixturePath,
queryFromFreshBootstrap: true,
changedPaths: changed.map((item) => item.path),
diffAvailable: payload?.diffAvailable,
source: payload?.source,
limitation: payload?.limitation,
snippet: text.slice(0, 800),
};
}
async function main() {
fs.mkdirSync(outputDir, { recursive: true });
const failures = [];
const backendSession = read(backendSessionPath);
const frontendApi = read(frontendApiPath);
const diffViewer = read(diffViewerPath);
const embed = read(embedPath);
const smartQuery = read(smartQueryPath);
const mnoteRuntime = read(mnoteRuntimePath);
assertCheck(
failures,
"OpenHub backend exposes session diff endpoint",
backendSession.includes('@router.get("/api/sessions/{session_id}/diff")') &&
backendSession.includes("_changed_files_from_messages") &&
backendSession.includes("opencode_tool_events")
);
assertCheck(
failures,
"backend extracts path only from write-like opencode tool metadata",
backendSession.includes("_is_write_tool") &&
backendSession.includes("_iter_tool_path_values") &&
backendSession.includes('"filePath"') &&
backendSession.includes('"source": "opencode_tool_event"')
);
assertCheck(
failures,
"backend guards workspace path and does not synthesize diff content",
backendSession.includes("os.path.commonpath") &&
backendSession.includes('"diffAvailable": False') &&
backendSession.includes('"content": ""') &&
backendSession.includes("不生成或伪造 diff")
);
assertCheck(
failures,
"frontend service still calls session diff endpoint",
frontendApi.includes("getSessionDiff") && frontendApi.includes("/sessions/${sessionId}/diff")
);
assertCheck(
failures,
"frontend postMessage bridge emits mnote open-file payload",
embed.includes("postMNoteOpenFile") &&
embed.includes("type: 'mnote:open-file'") &&
embed.includes("source: payload.source || 'openhub-diff'")
);
assertCheck(
failures,
"DiffViewer exposes changed path open action",
diffViewer.includes("postMNoteOpenFile") &&
diffViewer.includes("rootRelativePath") &&
diffViewer.includes("diffAvailable")
);
assertCheck(
failures,
"SmartQueryPage loads changed files after stream and exposes hidden bridge payload",
smartQuery.includes("loadChangedFiles(finalConversationId)") &&
smartQuery.includes("data-mnote-openhub-changed-file") &&
smartQuery.includes("handleOpenChangedFile")
);
assertCheck(
failures,
"MNote host listens for OpenHub open-file bridge",
mnoteRuntime.includes("pageAiInstallMNoteOpenFileBridge") &&
mnoteRuntime.includes("message.type !== 'mnote:open-file'") &&
mnoteRuntime.includes("openhub-diff") &&
mnoteRuntime.includes("pageAiOpenOpencodeChangedFile")
);
const liveProbeInput = loadTask774Probe();
let liveProbe;
try {
liveProbe = await probeLiveEndpoint(liveProbeInput);
if (!liveProbe.skipped) {
assertCheck(failures, "live MNote proxy returns real changed path from task774 session", liveProbe.ok, liveProbe);
}
} catch (error) {
liveProbe = {
skipped: false,
ok: false,
error: error instanceof Error ? error.message : String(error),
reason: "MNote/OpenHub 服务不可达或 task774 session 已不可读",
};
assertCheck(failures, "live MNote proxy returns real changed path from task774 session", false, liveProbe);
}
const result = {
ok: failures.length === 0,
task: "task776-openhub-changed-files-bridge-smoke",
liveProbe,
failures,
};
fs.writeFileSync(resultPath, `${JSON.stringify(result, null, 2)}\n`, "utf8");
console.log(JSON.stringify(result, null, 2));
if (!result.ok) process.exit(1);
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});
@@ -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,230 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { chromium } = require("playwright");
const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "7-68-runtime");
const RESULT_PATH = path.join(OUTPUT_DIR, "task779-openhub-file-edit-document-pane-refresh-result.json");
const SCREENSHOT_PATH = path.join(OUTPUT_DIR, "task779-openhub-file-edit-document-pane-refresh.png");
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|| ["/usr/bin/chromium-browser", "/usr/bin/chromium", "/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium"]
.find((candidate) => fs.existsSync(candidate));
function fileUrl(localPath) {
return `file://${localPath}`;
}
function localMdDocumentId(relativePath) {
return `local-md:${relativePath.replaceAll("/", "~2F")}`;
}
function documentUrl(root, relativePath, workspaceId) {
const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", fileUrl(root));
if (workspaceId) url.searchParams.set("workspaceId", workspaceId);
return url.toString();
}
function markdown(title, lines) {
return ["---", `title: ${title}`, "---", "", ...lines, ""].join("\n");
}
function writeWorkspaceManifest(root, ownerId, workspaceId) {
const metadataDir = path.join(root, ".mnote");
fs.mkdirSync(metadataDir, { recursive: true });
fs.writeFileSync(
path.join(metadataDir, "workspace.json"),
`${JSON.stringify({
workspaceId,
ownerId,
createdAt: new Date().toISOString(),
capabilities: ["local_files", "markdown_edit"],
}, null, 2)}\n`,
"utf8",
);
}
async function waitForEditorText(page, text) {
await page.waitForFunction(
(expected) => {
const editor = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror');
return (editor?.textContent || "").includes(expected);
},
text,
{ timeout: UI_TIMEOUT_MS },
);
}
async function readDocumentPaneState(page) {
return page.evaluate(() => {
const root = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"][data-pane-role="primary"]');
const pane = document.querySelector('.document-pane[data-pane-role="primary"]');
const editor = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror');
const aggregateNode = document.getElementById("__MNOTE_PAGE_AGGREGATE__");
let aggregate = null;
try {
aggregate = JSON.parse(aggregateNode?.textContent || "null");
} catch {}
return {
paneDocumentId: pane?.getAttribute("data-pane-document-id") || "",
runtimeStatus: root?.getAttribute("data-runtime-editor-status") || "",
runtimeError: root?.getAttribute("data-runtime-editor-error") || "",
editorText: editor?.textContent || "",
aggregateText: JSON.stringify(aggregate?.body || aggregate || {}),
syncedAt: aggregateNode?.getAttribute("data-mnote-page-aggregate-synced-at") || "",
openhubRefreshMarker: document.documentElement.getAttribute("data-mnote-page-ai-openhub-document-pane-refresh") || "",
eventBusSource: document.documentElement.getAttribute("data-mnote-local-folder-event-bus-last-source") || "",
eventBusReason: document.documentElement.getAttribute("data-mnote-local-folder-event-bus-last-reason") || "",
fileChangeSources: window.__mnoteTask779FileChangeSources || [],
documentSessionDebug: window.__mnoteDebugDocumentSessions?.snapshot?.() || null,
};
});
}
async function openDocument(page, root, relativePath, workspaceId) {
await page.goto(documentUrl(root, relativePath, workspaceId), { waitUntil: "commit", timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]').first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
await page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
}
async function waitForPageAiRuntime(page) {
await page.waitForFunction(
() => typeof window.__mnoteSidebarPageAiRuntime?.openPageAiDrawer === "function"
&& typeof window.__mnoteDocumentPaneRuntime?.refreshPrimaryDocument === "function"
&& typeof window.__mnoteDocumentPaneRuntime?.openPrimaryDocument === "function",
null,
{ timeout: UI_TIMEOUT_MS },
);
}
async function installOpenHubChangedFileBridge(page) {
await page.evaluate(() => {
try {
localStorage.setItem("mnote.page_ai.openhub_host", "1");
} catch {}
window.__mnoteSidebarPageAiRuntime.openPageAiDrawer();
});
await page.locator('[data-testid="wolai-page-ai-drawer"][data-page-ai-openhub-host="true"]').first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
}
async function simulateOpenHubChangedFile(page, root, relativePath, workspaceId) {
return page.evaluate(({ rootUri, path, workspaceId }) => {
window.postMessage({
type: "mnote:open-file",
source: "openhub-changed-files",
path,
rootUri,
workspaceId,
documentId: `local-md:${path.replaceAll("/", "~2F")}`,
}, window.location.origin);
return true;
}, { rootUri: fileUrl(root), path: relativePath, workspaceId });
}
async function run() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task779-openhub-pane-refresh-"));
const workspaceId = `local-ws:user_real:task779:${Date.now()}`;
const relativePath = "task779-openhub-refresh.md";
const initialText = "task779 initial document pane text";
const changedText = `task779 openhub changed file bridge ${Date.now()}`;
writeWorkspaceManifest(root, "user_real", workspaceId);
fs.writeFileSync(path.join(root, relativePath), markdown("Task 779 OpenHub Refresh", [initialText]), "utf8");
const browser = await chromium.launch({
headless: process.env.HEADFUL !== "1",
...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
});
const context = await browser.newContext({
viewport: { width: 1360, height: 900 },
locale: "zh-CN",
extraHTTPHeaders: {
"x-mnote-actor-id": "user_real",
"x-mnote-actor-type": "user",
},
});
const page = await context.newPage();
const debug = { root, workspaceId, relativePath, initialText, changedText };
try {
await openDocument(page, root, relativePath, workspaceId);
await waitForEditorText(page, initialText);
await waitForPageAiRuntime(page);
await installOpenHubChangedFileBridge(page);
await page.evaluate(() => {
window.__mnoteTask779FileChangeSources = [];
window.addEventListener("mnote:file-change-batch", (event) => {
window.__mnoteTask779FileChangeSources.push(String(event?.detail?.source || ""));
});
});
debug.before = await readDocumentPaneState(page);
fs.writeFileSync(
path.join(root, relativePath),
markdown("Task 779 OpenHub Refresh", [changedText]),
"utf8",
);
const opened = await simulateOpenHubChangedFile(page, root, relativePath, workspaceId);
assert.equal(opened, true, "OpenHub changed-file bridge 应接受当前 Markdown path");
await waitForEditorText(page, changedText);
debug.after = await readDocumentPaneState(page);
assert.equal(debug.after.paneDocumentId, localMdDocumentId(relativePath), "primary document pane 应仍打开测试 Markdown");
assert(debug.after.editorText.includes(changedText), `document pane 应显示磁盘新内容: ${debug.after.editorText}`);
assert(!debug.after.editorText.includes(initialText), `document pane 不应保留旧正文: ${debug.after.editorText}`);
assert.equal(debug.after.openhubRefreshMarker, relativePath, "应记录 OpenHub document pane refresh marker");
assert(
debug.after.fileChangeSources.some((source) => source.includes("openhub_changed_file_bridge")),
`应通过 FileChangeService 消费 OpenHub changedFiles adapter: ${JSON.stringify(debug.after.fileChangeSources)}`,
);
await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true });
const result = {
ok: true,
task: "task779-openhub-file-edit-document-pane-refresh-smoke",
root,
relativePath,
documentId: localMdDocumentId(relativePath),
changedText,
beforeText: debug.before.editorText,
afterText: debug.after.editorText,
eventBusSource: debug.after.eventBusSource,
eventBusReason: debug.after.eventBusReason,
resultPath: RESULT_PATH,
screenshotPath: SCREENSHOT_PATH,
};
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
console.log(`ok task779-openhub-file-edit-document-pane-refresh-smoke ${RESULT_PATH}`);
} catch (error) {
fs.writeFileSync(RESULT_PATH, `${JSON.stringify({
ok: false,
error: String(error && error.stack || error),
debug,
}, null, 2)}\n`, "utf8");
throw error;
} finally {
await browser.close().catch(() => {});
fs.rmSync(root, { recursive: true, force: true });
}
}
run().catch((error) => {
console.error(error);
process.exitCode = 1;
});
@@ -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 || "/mnt/Data1T/Mnote_data/openhub/OpenHub";
const embedPath = path.join(openHubRoot, "smart-query-frontend/src/mnoteEmbed.js");
const smartQueryPath = path.join(openHubRoot, "smart-query-frontend/src/pages/SmartQueryPage.jsx");
const diffViewerPath = path.join(openHubRoot, "smart-query-frontend/src/components/DiffViewer.jsx");
const designPath = path.join(
repoRoot,
"design/07-ai/process/7-68-openhub-weknora-mnote-deep-fusion-checklist-v1.md"
);
const forbiddenFulltextKeys = [
"message",
"messages",
"messageContent",
"conversation",
"conversationMessages",
"assistantMessage",
"userMessage",
"content",
"text",
"transcript",
];
function read(filePath) {
return fs.readFileSync(filePath, "utf8");
}
function assertCheck(failures, name, passed, details = undefined) {
if (!passed) failures.push({ name, details });
}
function base64UrlJson(value) {
return Buffer.from(JSON.stringify(value), "utf8")
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/g, "");
}
function hasForbiddenKey(value) {
if (!value || typeof value !== "object") return false;
if (Array.isArray(value)) return value.some(hasForbiddenKey);
return Object.entries(value).some(([key, entry]) => (
forbiddenFulltextKeys.includes(key) || hasForbiddenKey(entry)
));
}
async function probeEmbedRuntime(embedSource) {
const mnoteScope = {
workspaceScope: {
rootUri: "file:///tmp/mnote-artifact-root",
workspaceId: "ws-task780",
pageResourceId: "page-task780",
},
};
const calls = [];
const sandbox = {
console,
TextDecoder,
URLSearchParams,
Uint8Array,
window: {
location: {
pathname: "/page-ai/openhub/ai",
search: `?scope=session-task780&mnoteScope=${base64UrlJson(mnoteScope)}`,
origin: "http://127.0.0.1:3000",
},
parent: {},
atob: (value) => Buffer.from(value, "base64").toString("binary"),
},
fetch: async (url, options = {}) => {
calls.push({ url, options });
return {
ok: true,
status: 200,
json: async () => ({ ok: true }),
};
},
module: { exports: {} },
exports: {},
};
const transformed = embedSource
.replace(/import\.meta\.env\.VITE_API_BASE_URL/g, "undefined")
.replace(/\bexport const /g, "const ");
vm.runInNewContext(
`${transformed}\nmodule.exports = { getMNoteArtifactIndexContext, postMNoteArtifactIndex };`,
sandbox,
{ filename: embedPath }
);
const result = await sandbox.module.exports.postMNoteArtifactIndex({
openhubSessionId: "ses-task780",
kind: "changed_file",
providerId: "opencode_tool_event",
path: "/tmp/mnote-artifact-root/page.md",
citationPayload: {
schema: "openhub.changed_file_locator.v1",
rootRelativePath: "page.md",
diffAvailable: false,
},
});
const body = calls[0] ? JSON.parse(calls[0].options.body) : null;
return { result, calls, body };
}
async function main() {
const failures = [];
const embed = read(embedPath);
const smartQuery = read(smartQueryPath);
const diffViewer = read(diffViewerPath);
const design = read(designPath);
assertCheck(
failures,
"mnoteEmbed posts artifact index to MNote root API",
embed.includes("postMNoteArtifactIndex") &&
embed.includes("getMNoteArtifactIndexContext") &&
embed.includes("fetch('/api/page-ai/openhub/artifact-index'") &&
embed.includes("credentials: 'include'")
);
assertCheck(
failures,
"artifact index payload is limited to locator fields",
["openhubSessionId", "kind", "providerId", "path", "citationPayload", "rootUri", "workspaceId", "pageResourceId"]
.every((field) => embed.includes(field))
);
assertCheck(
failures,
"SmartQueryPage indexes changed files after diff metadata is loaded",
smartQuery.includes("indexChangedFiles(sessionId, files)") &&
smartQuery.includes("kind: 'changed_file'") &&
smartQuery.includes("schema: 'openhub.changed_file_locator.v1'") &&
smartQuery.includes("providerId: file?.source || 'opencode_tool_event'")
);
assertCheck(
failures,
"SmartQueryPage indexes WeKnora citation locator payloads",
smartQuery.includes("indexCitationArtifacts") &&
smartQuery.includes("kind: 'citation'") &&
smartQuery.includes("schema: 'openhub.weknora_citation_locator.v1'") &&
smartQuery.includes("citation?.sourceRootRelativePath")
);
assertCheck(
failures,
"citation payload sanitizer strips fulltext-like fields recursively",
forbiddenFulltextKeys.every((key) => smartQuery.includes(`'${key}'`)) &&
smartQuery.includes("sanitizeCitationPayload(entry)") &&
smartQuery.includes(".filter(([key]) => !forbiddenKeys.has(key))")
);
assertCheck(
failures,
"changed file bridge remains connected to MNote open-file event",
diffViewer.includes("postMNoteOpenFile") &&
smartQuery.includes("data-mnote-openhub-changed-file") &&
smartQuery.includes("handleOpenChangedFile")
);
assertCheck(
failures,
"design checklist records task780 runtime artifact index bridge",
design.includes("task780-openhub-artifact-index-runtime-smoke.js") &&
design.includes("changed_file / citation 轻量 artifact index")
);
let runtimeProbe = null;
try {
runtimeProbe = await probeEmbedRuntime(embed);
assertCheck(
failures,
"runtime request uses MNote artifact-index endpoint",
runtimeProbe.calls.length === 1 &&
runtimeProbe.calls[0].url === "/api/page-ai/openhub/artifact-index" &&
runtimeProbe.calls[0].options.method === "POST",
runtimeProbe
);
assertCheck(
failures,
"runtime request body contains required locator fields",
runtimeProbe.body &&
runtimeProbe.body.openhubSessionId === "ses-task780" &&
runtimeProbe.body.kind === "changed_file" &&
runtimeProbe.body.providerId === "opencode_tool_event" &&
runtimeProbe.body.path === "/tmp/mnote-artifact-root/page.md" &&
runtimeProbe.body.rootUri === "file:///tmp/mnote-artifact-root" &&
runtimeProbe.body.workspaceId === "ws-task780" &&
runtimeProbe.body.pageResourceId === "page-task780",
runtimeProbe.body
);
assertCheck(
failures,
"runtime request body does not contain OpenHub message fulltext fields",
runtimeProbe.body && !hasForbiddenKey(runtimeProbe.body),
runtimeProbe.body
);
} catch (error) {
runtimeProbe = { error: error instanceof Error ? error.stack || error.message : String(error) };
assertCheck(failures, "runtime request shape probe executes", false, runtimeProbe);
}
const result = {
ok: failures.length === 0,
task: "task780-openhub-artifact-index-runtime-smoke",
runtimeProbe,
failures,
};
console.log(JSON.stringify(result, null, 2));
if (!result.ok) process.exit(1);
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});
@@ -0,0 +1,188 @@
#!/usr/bin/env node
"use strict";
const fs = require("node:fs");
const path = require("node:path");
const vm = require("node:vm");
const repoRoot = path.resolve(__dirname, "..");
const openHubRoot = process.env.OPENHUB_RESEARCH_ROOT || "/mnt/Data1T/Mnote_data/openhub/OpenHub";
const backendBridgePath = path.join(openHubRoot, "smart-query-backend/app/services/mnote_weknora.py");
const backendApiPath = path.join(openHubRoot, "smart-query-backend/app/api/mnote_tools.py");
const backendStreamPath = path.join(openHubRoot, "smart-query-backend/app/services/stream.py");
const backendScopePath = path.join(openHubRoot, "smart-query-backend/app/core/mnote_scope.py");
const frontendEmbedPath = path.join(openHubRoot, "smart-query-frontend/src/mnoteEmbed.js");
const assistantMessagePath = path.join(openHubRoot, "smart-query-frontend/src/components/AssistantMessage.jsx");
const smartQueryPath = path.join(openHubRoot, "smart-query-frontend/src/pages/SmartQueryPage.jsx");
const mnoteRuntimePath = path.join(repoRoot, "rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js");
const checklistPath = path.join(repoRoot, "design/07-ai/process/7-68-openhub-weknora-mnote-deep-fusion-checklist-v1.md");
const outputDir = path.join(repoRoot, "tmp", "7-68-runtime");
const resultPath = path.join(outputDir, "openhub-weknora-tool-citation-bridge-smoke-result.json");
function read(filePath) {
return fs.readFileSync(filePath, "utf8");
}
function assertCheck(failures, name, passed, details = undefined) {
if (!passed) failures.push({ name, details });
}
function base64UrlJson(value) {
return Buffer.from(JSON.stringify(value), "utf8")
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/g, "");
}
function extractFunction(source, name) {
const start = source.indexOf(`export const ${name}`);
if (start < 0) throw new Error(`找不到 ${name}`);
const next = source.indexOf("\nexport const ", start + 1);
return source.slice(start, next > start ? next : undefined);
}
async function probeEmbedOpenReference(embedSource) {
const scope = {
workspaceScope: {
rootUri: "file:///tmp/mnote-openhub-task782",
workspaceId: "ws-task782",
pageResourceId: "page-task782",
},
};
const posted = [];
const sandbox = {
console,
TextDecoder,
URLSearchParams,
Uint8Array,
window: {
location: {
pathname: "/page-ai/openhub/ai",
search: `?scope=session-task782&mnoteScope=${base64UrlJson(scope)}`,
origin: "http://127.0.0.1:3000",
},
parent: {
postMessage: (message, origin) => posted.push({ message, origin }),
},
atob: (value) => Buffer.from(value, "base64").toString("binary"),
},
module: { exports: {} },
exports: {},
};
const transformed = embedSource
.slice(0, embedSource.indexOf("const compactObject"))
.replace(/import\.meta\.env\.VITE_API_BASE_URL/g, "undefined")
.replace(/\bexport const /g, "const ");
vm.runInNewContext(
`${transformed}\nmodule.exports = { postMNoteOpenReference };`,
sandbox,
{ filename: frontendEmbedPath }
);
const ok = sandbox.module.exports.postMNoteOpenReference({
schema: "openhub.weknora_citation_locator.v1",
citation: {
provider: "weknora",
sourceRootRelativePath: "knowledge-rag-fixtures-7-68/task782.md",
citationLabel: "task782.md",
},
});
return { ok, posted };
}
async function main() {
const failures = [];
const backendBridge = read(backendBridgePath);
const backendApi = read(backendApiPath);
const backendStream = read(backendStreamPath);
const backendScope = read(backendScopePath);
const frontendEmbed = read(frontendEmbedPath);
const assistantMessage = read(assistantMessagePath);
const smartQuery = read(smartQueryPath);
const mnoteRuntime = read(mnoteRuntimePath);
const checklist = read(checklistPath);
assertCheck(
failures,
"OpenHub backend exposes MNote WeKnora readonly facade endpoint",
backendApi.includes("/api/mnote/tools/call") &&
backendApi.includes("call_mnote_weknora_tool") &&
backendBridge.includes("MNOTE_TOOL_CALL_ENDPOINT = \"/api/mnote/tools/call\"") &&
!backendBridge.includes("MNOTE_TOOL_CALL_ENDPOINT = \"/api/hermes/tools/mnote/call\"")
);
assertCheck(
failures,
"backend tool call carries scope and does not expose WeKnora API key",
backendBridge.includes("capabilityScope") &&
backendBridge.includes("knowledge_rag.read") &&
backendBridge.includes("allowedRoots") &&
backendBridge.includes("mnote_cookie") &&
!backendBridge.includes("MNOTE_WEKNORA_API_KEY")
);
assertCheck(
failures,
"OpenHub stream auto-bridges KB queries through MNote WeKnora",
backendStream.includes("auto_search_for_question") &&
backendStream.includes("<mnote_weknora_tool_result>") &&
backendStream.includes("_push_mnote_weknora_tool_event") &&
backendStream.includes("mnote.weknora.search")
);
assertCheck(
failures,
"MNote session cookie is only forwarded as trusted server header",
backendScope.includes("X-MNote-Session-Cookie") &&
mnoteRuntime.includes("mnote:open-reference") &&
!frontendEmbed.includes("mnote_session=")
);
assertCheck(
failures,
"OpenHub frontend renders clickable WeKnora citations",
assistantMessage.includes("data-mnote-openhub-citation") &&
assistantMessage.includes("openhub.weknora_citation_locator.v1") &&
assistantMessage.includes("onOpenMNoteCitation") &&
smartQuery.includes("postMNoteOpenReference")
);
assertCheck(
failures,
"MNote host accepts citation open-reference postMessage bridge",
mnoteRuntime.includes("message.type !== 'mnote:open-file' && message.type !== 'mnote:open-reference'") &&
mnoteRuntime.includes("openhub-citation") &&
mnoteRuntime.includes("pageAiOpenOpencodeChangedFile")
);
const runtimeProbe = await probeEmbedOpenReference(frontendEmbed);
assertCheck(
failures,
"runtime postMNoteOpenReference posts mnote:open-reference",
runtimeProbe.ok &&
runtimeProbe.posted.length === 1 &&
runtimeProbe.posted[0].message.type === "mnote:open-reference" &&
runtimeProbe.posted[0].message.source === "openhub-citation" &&
runtimeProbe.posted[0].message.path.endsWith("task782.md"),
runtimeProbe
);
assertCheck(
failures,
"checklist has task782 completion note",
checklist.includes("task782-openhub-weknora-tool-citation-bridge-smoke.js")
);
fs.mkdirSync(outputDir, { recursive: true });
const result = {
ok: failures.length === 0,
task: "task782-openhub-weknora-tool-citation-bridge-smoke",
runtimeProbe,
failures,
};
fs.writeFileSync(resultPath, `${JSON.stringify(result, null, 2)}\n`, "utf8");
if (failures.length) {
console.error(JSON.stringify(result, null, 2));
process.exit(1);
}
console.log(JSON.stringify(result, null, 2));
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
@@ -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,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 || "/mnt/Data1T/Mnote_data/openhub/OpenHub";
const files = {
chatInput: path.join(openHubRoot, "smart-query-frontend/src/components/ChatInput.jsx"),
embed: path.join(openHubRoot, "smart-query-frontend/src/mnoteEmbed.js"),
api: path.join(openHubRoot, "smart-query-frontend/src/services/api.js"),
queryModel: path.join(openHubRoot, "smart-query-backend/app/models/query.py"),
queryApi: path.join(openHubRoot, "smart-query-backend/app/api/query.py"),
stream: path.join(openHubRoot, "smart-query-backend/app/services/stream.py"),
mnoteRoute: path.join(repoRoot, "rust/crates/mnote-web/src/routes/page_ai_openhub.rs"),
};
function read(file) {
return fs.readFileSync(file, "utf8");
}
function assertCheck(failures, name, passed) {
if (!passed) failures.push(name);
}
const chatInput = read(files.chatInput);
const embed = read(files.embed);
const api = read(files.api);
const queryModel = read(files.queryModel);
const queryApi = read(files.queryApi);
const stream = read(files.stream);
const mnoteRoute = read(files.mnoteRoute);
const failures = [];
assertCheck(
failures,
"OpenHub ChatInput natively owns MNote context toggles",
chatInput.includes("requestMNoteActiveTabAddress") &&
chatInput.includes("data-mnote-openhub-current-tab-toggle") &&
chatInput.includes("data-mnote-openhub-current-folder-toggle") &&
chatInput.includes("mnoteContextMode === 'tab'") &&
chatInput.includes("mnoteContextMode === 'folder'") &&
chatInput.includes("handleSendWithMNoteContext")
);
assertCheck(
failures,
"toggles live beside model quota controls and do not write textarea",
chatInput.indexOf("model?.monthlyLimit") < chatInput.indexOf("data-mnote-openhub-current-tab-toggle") &&
chatInput.includes("handleSend(undefined, context ?") &&
!chatInput.includes("setQuestion(context.value") &&
!chatInput.includes("setQuestion(mnoteContext")
);
assertCheck(
failures,
"OpenHub mnoteEmbed requests active tab/folder from MNote host",
embed.includes("export const requestMNoteActiveTabAddress") &&
embed.includes("mnote:get-active-tab-address") &&
embed.includes("mnote:active-tab-address") &&
embed.includes("kind === 'folder' ? 'folder' : 'tab'")
);
assertCheck(
failures,
"OpenHub request body carries hidden mnote_context",
api.includes("mnoteContext = null") &&
api.includes("requestBody.mnote_context = mnoteContext")
);
assertCheck(
failures,
"OpenHub backend accepts and forwards mnote_context",
queryModel.includes("mnote_context: Optional[dict]") &&
queryApi.includes("mnote_context=request.mnote_context") &&
stream.includes("mnote_context: Optional[dict] = None")
);
assertCheck(
failures,
"OpenHub stream prepends hidden current page/folder context before prompt",
stream.includes("<mnote_current_context>") &&
stream.includes("当前文件夹") &&
stream.includes("当前页面") &&
stream.includes("context_parts.append") &&
stream.includes("_build_mnote_context_block") &&
stream.includes("_mnote_context_from_scope") &&
stream.includes("Effective MNote context") &&
stream.includes("Sent prompt preview")
);
assertCheck(
failures,
"MNote proxy no longer injects floating quick action overlay",
!mnoteRoute.includes("data-mnote-openhub-ai-quick-actions") &&
!mnoteRoute.includes("mnote_openhub_bridge_markup") &&
!mnoteRoute.includes("insertAddress(")
);
if (failures.length) {
console.error("OpenHub native MNote context static smoke failed:");
for (const failure of failures) console.error(`- ${failure}`);
process.exit(1);
}
console.log("OpenHub native MNote context static smoke passed.");
@@ -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/Mnote_data/openhub/OpenHub";
const files = {
chatInput: path.join(openHubRoot, "smart-query-frontend/src/components/ChatInput.jsx"),
smartQueryPage: path.join(openHubRoot, "smart-query-frontend/src/pages/SmartQueryPage.jsx"),
api: path.join(openHubRoot, "smart-query-frontend/src/services/api.js"),
openHubFrontendSrc: path.join(openHubRoot, "smart-query-frontend/src"),
mnoteOpenHubRoute: path.join(repoRoot, "rust/crates/mnote-web/src/routes/page_ai_openhub.rs"),
};
function read(file) {
return fs.readFileSync(file, "utf8");
}
function walkFiles(dir, result = []) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
walkFiles(fullPath, result);
} else {
result.push(fullPath);
}
}
return result;
}
function extractBetween(source, startNeedle, endNeedle) {
const start = source.indexOf(startNeedle);
if (start < 0) return "";
const end = source.indexOf(endNeedle, start);
return source.slice(start, end < 0 ? undefined : end);
}
function check(failures, name, passed, detail = "") {
if (!passed) {
failures.push(detail ? `${name}: ${detail}` : name);
}
}
const chatInput = read(files.chatInput);
const smartQueryPage = read(files.smartQueryPage);
const api = read(files.api);
const controlRow = extractBetween(chatInput, "<AgentModeToggle", "{currentTodos");
const handleMNoteSend = extractBetween(chatInput, "const handleSendWithMNoteContext", "useEffect(() =>");
const handleSend = extractBetween(smartQueryPage, "const handleSend = async", "const handleKeyPress");
const legacyFloatingSelectors = [
"mnote-openhub-context-bar",
"mnote-openhub-native-context",
"data-mnote-openhub-ai-quick-actions",
"mnote_openhub_bridge_markup",
"insertAddress(",
];
const frontendLegacyHits = [];
for (const file of walkFiles(files.openHubFrontendSrc)) {
const rel = path.relative(openHubRoot, file);
const source = read(file);
for (const selector of legacyFloatingSelectors) {
if (source.includes(selector)) {
frontendLegacyHits.push(`${rel} -> ${selector}`);
}
}
}
const mnoteRoute = fs.existsSync(files.mnoteOpenHubRoute) ? read(files.mnoteOpenHubRoute) : "";
const mnoteRouteLegacyHits = legacyFloatingSelectors.filter((selector) =>
mnoteRoute.includes(selector)
);
const failures = [];
check(
failures,
"ChatInput owns two native MNote context buttons",
chatInput.includes("isMNoteOpenHubEmbed()") &&
chatInput.includes("requestMNoteActiveTabAddress") &&
chatInput.includes("data-mnote-openhub-current-tab-toggle") &&
chatInput.includes("data-mnote-openhub-current-folder-toggle"),
"missing embed gate, context request helper, or native button data attributes"
);
check(
failures,
"MNote context buttons are in the controls row beside agent/model controls",
controlRow.includes("<AgentModeToggle") &&
controlRow.includes("<ModelSelect") &&
controlRow.includes("data-mnote-openhub-current-tab-toggle") &&
controlRow.includes("data-mnote-openhub-current-folder-toggle") &&
controlRow.indexOf("<AgentModeToggle") < controlRow.indexOf("data-mnote-openhub-current-tab-toggle") &&
controlRow.indexOf("<ModelSelect") < controlRow.indexOf("data-mnote-openhub-current-folder-toggle"),
"native buttons must stay in the same compact controls row as agent toggle/model select"
);
check(
failures,
"Icon-only buttons use tooltips and aria labels for current page/tab and folder",
/Tooltip\s+title=["{][^"'}]*(当前(?:页面|激活\s*Tab|Tab)|current\s*(?:page|tab))/i.test(controlRow) &&
/Tooltip\s+title=["{][^"'}]*(当前(?:文件夹|激活\s*Tab\s*的文件夹)|current\s*folder|folder)/i.test(controlRow) &&
/aria-label="当前页面"/.test(controlRow) &&
/aria-label="文件夹"/.test(controlRow) &&
/icon=\{<FileTextOutlined\s*\/>\}/.test(controlRow) &&
/icon=\{<FolderOpenOutlined\s*\/>\}/.test(controlRow) &&
!/>当前页面<\/Button>/.test(controlRow) &&
!/>文件夹<\/Button>/.test(controlRow),
"expected two icon-only buttons with distinct tooltip and aria labels"
);
check(
failures,
"ChatInput sends mnote_context as hidden metadata instead of writing URL into textarea question",
/handleSend\(undefined,\s*context\s*\?\s*\{/.test(handleMNoteSend) &&
handleMNoteSend.includes("kind: mnoteContextMode") &&
handleMNoteSend.includes("value: context.value") &&
!/setQuestion\s*\(\s*context\./.test(handleMNoteSend) &&
!/setQuestion\s*\(\s*mnoteContext/.test(handleMNoteSend) &&
!/question\s*=\s*context\.value/.test(handleMNoteSend),
"context must flow through handleSend second argument and must not mutate the visible question value"
);
check(
failures,
"SmartQueryPage accepts mnote_context and forwards it to queryDataService.queryDataStream",
/const\s+handleSend\s*=\s*async\s*\(\s*overrideQuestion\s*,\s*mnoteContext\s*=\s*null\s*\)/.test(
smartQueryPage
) &&
handleSend.includes("queryDataService.queryDataStream(") &&
/abortControllerRef\.current\.signal\s*,\s*mnoteContext/.test(handleSend),
"handleSend must accept mnoteContext and pass it through the stream send call"
);
check(
failures,
"OpenHub send body carries hidden mnote_context",
/queryDataStream:\s*async\s*\([^)]*mnoteContext\s*=\s*null/.test(api) &&
api.includes("requestBody.mnote_context = mnoteContext") &&
!/question\s*:\s*.*mnoteContext/.test(api),
"api.js must put mnoteContext into requestBody.mnote_context, not append it to question"
);
check(
failures,
"OpenHub frontend no longer depends on legacy MNote floating button selectors",
frontendLegacyHits.length === 0,
frontendLegacyHits.join("; ")
);
check(
failures,
"MNote OpenHub route no longer injects legacy floating context buttons",
mnoteRouteLegacyHits.length === 0,
mnoteRouteLegacyHits.join("; ")
);
if (failures.length) {
console.error("OpenHub native MNote context source smoke failed:");
for (const failure of failures) console.error(`- ${failure}`);
process.exit(1);
}
console.log("OpenHub native MNote context source smoke passed.");
@@ -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 || "/mnt/Data1T/Mnote_data/openhub/OpenHub/smart-query-backend";
const OPENCODE_PORT = Number(process.env.TASK793_OPENCODE_PORT || 19096);
const OPENHUB_PORT = Number(process.env.TASK793_OPENHUB_PORT || 18181);
const SESSION_ID = "ses_mnote_context_fullchain";
function wait(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function readBody(req) {
return new Promise((resolve) => {
let data = "";
req.on("data", (chunk) => { data += chunk; });
req.on("end", () => resolve(data));
});
}
function sseEvent(type, properties) {
return `data: ${JSON.stringify({ payload: { type, properties } })}\n\n`;
}
function startFakeOpencode() {
const capturedPrompts = [];
let eventResponse = null;
function sendEvents() {
if (!eventResponse) return false;
eventResponse.write(sseEvent("message.updated", {
sessionID: SESSION_ID,
info: { id: "msg_assistant", role: "assistant", sessionID: SESSION_ID },
}));
eventResponse.write(sseEvent("message.part.updated", {
sessionID: SESSION_ID,
part: { id: "prt_text", messageID: "msg_assistant", sessionID: SESSION_ID, type: "text", text: "OK" },
}));
eventResponse.write(sseEvent("session.status", {
sessionID: SESSION_ID,
status: { type: "idle" },
}));
eventResponse.end();
eventResponse = null;
return true;
}
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, `http://127.0.0.1:${OPENCODE_PORT}`);
if (req.method === "GET" && url.pathname === "/global/health") {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ ok: true }));
return;
}
if (req.method === "POST" && url.pathname === "/session") {
await readBody(req);
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ id: SESSION_ID }));
return;
}
if (req.method === "GET" && url.pathname === "/global/event") {
res.writeHead(200, {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-cache",
connection: "keep-alive",
});
eventResponse = res;
eventResponse.write(`data: ${JSON.stringify({ payload: { type: "ready", properties: { sessionID: SESSION_ID } } })}\n\n`);
if (capturedPrompts.length) {
setTimeout(sendEvents, 50);
}
return;
}
if (req.method === "POST" && url.pathname === `/session/${SESSION_ID}/prompt_async`) {
const body = JSON.parse(await readBody(req) || "{}");
capturedPrompts.push(body);
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ ok: true }));
setTimeout(sendEvents, 50);
return;
}
res.writeHead(404, { "content-type": "application/json" });
res.end(JSON.stringify({ error: "not_found", path: url.pathname }));
});
return {
capturedPrompts,
listen: () => new Promise((resolve) => server.listen(OPENCODE_PORT, "127.0.0.1", resolve)),
close: () => new Promise((resolve) => server.close(resolve)),
};
}
async function waitForHealth(url, timeoutMs = 20_000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
const response = await fetch(url, { signal: AbortSignal.timeout(2_000) });
if (response.status < 500) return;
} catch {}
await wait(250);
}
throw new Error(`等待服务超时: ${url}`);
}
async function sendOpenHub(payload, extraHeaders = {}) {
const body = JSON.stringify(payload);
return new Promise((resolve, reject) => {
const req = http.request({
hostname: "127.0.0.1",
port: OPENHUB_PORT,
path: "/api/query/stream",
method: "POST",
headers: {
"content-type": "application/json",
"content-length": Buffer.byteLength(body),
"x-mnote-user-key": "openhub_user_test",
"x-mnote-workspace-key": "workspace_test",
"x-mnote-session-scope": `session_${Date.now()}_${Math.random().toString(16).slice(2)}`,
"x-mnote-root-uri": "file:///tmp/mnote-fullchain-workspace",
"x-mnote-page-resource-id": "local-md:Inbox~2FPage.md",
...extraHeaders,
},
}, (res) => {
let text = "";
const timeout = setTimeout(() => {
req.destroy();
resolve(text);
}, 10_000);
res.setEncoding("utf8");
res.on("data", (chunk) => {
text += chunk;
if (text.includes("message_complete")) {
clearTimeout(timeout);
req.destroy();
resolve(text);
}
});
res.on("end", () => {
clearTimeout(timeout);
if (res.statusCode < 200 || res.statusCode >= 300) {
reject(new Error(`OpenHub HTTP ${res.statusCode}: ${text.slice(0, 500)}`));
} else {
resolve(text);
}
});
});
req.on("error", (error) => {
if (error.code === "ECONNRESET") return;
reject(error);
});
req.write(body);
req.end();
});
}
async function main() {
const fakeOpencode = startFakeOpencode();
let uvicorn = null;
let stderr = "";
await fakeOpencode.listen();
try {
uvicorn = spawn(".venv/bin/uvicorn", ["app.main:app", "--host", "127.0.0.1", "--port", String(OPENHUB_PORT)], {
cwd: OPENHUB_BACKEND_DIR,
env: {
...process.env,
OPENCODE_BASE_URL: `http://localhost:${OPENCODE_PORT}`,
MNOTE_OPENHUB_TOOL_BRIDGE_AUTO: "0",
SQLITE_DB_PATH: "/tmp/openhub-fullchain-smoke.db",
NO_PROXY: "127.0.0.1,localhost",
no_proxy: "127.0.0.1,localhost",
},
stdio: ["ignore", "ignore", "pipe"],
});
uvicorn.stderr.on("data", (chunk) => { stderr += String(chunk); });
await waitForHealth(`http://127.0.0.1:${OPENHUB_PORT}/api/health`);
const tabUrl = "http://127.0.0.1:3000/documents/local-md:Inbox~2FPage.md?sourceKind=local_folder&rootUri=file:///tmp/mnote-fullchain-workspace";
await sendOpenHub({
question: "只回答 OK",
conversation_id: "",
agent: "build",
mnote_context: {
kind: "tab",
value: tabUrl,
tabUrl,
rootUri: "file:///tmp/mnote-fullchain-workspace",
documentId: "local-md:Inbox~2FPage.md",
relativePath: "Inbox/Page.md",
},
});
await wait(200);
await sendOpenHub({ question: "只回答 OK fallback", conversation_id: "", agent: "build" });
await wait(200);
const explicitPrompt = fakeOpencode.capturedPrompts[0]?.parts?.[0]?.text || "";
const fallbackPrompt = fakeOpencode.capturedPrompts[1]?.parts?.[0]?.text || "";
const result = {
ok: false,
promptCount: fakeOpencode.capturedPrompts.length,
explicitContextOk: explicitPrompt.includes("<mnote_current_context>")
&& explicitPrompt.includes("当前页面: http://127.0.0.1:3000/documents/")
&& explicitPrompt.includes("relativePath: Inbox/Page.md"),
fallbackContextOk: fallbackPrompt.includes("<mnote_current_context>")
&& fallbackPrompt.includes("source: mnote_scope_fallback")
&& fallbackPrompt.includes("file:///tmp/mnote-fullchain-workspace"),
explicitPromptPreview: explicitPrompt.slice(0, 700),
fallbackPromptPreview: fallbackPrompt.slice(0, 700),
};
result.ok = result.promptCount >= 2 && result.explicitContextOk && result.fallbackContextOk;
console.log(JSON.stringify(result, null, 2));
if (!result.ok) process.exitCode = 1;
} finally {
if (uvicorn) {
uvicorn.kill("SIGTERM");
await wait(400);
}
await fakeOpencode.close();
if (process.exitCode) process.stderr.write(stderr.slice(-3000));
}
}
main().catch((error) => {
console.error(error.stack || error.message);
process.exit(1);
});