集成 OpenHub 与 WeKnora Page AI
This commit is contained in:
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user