Files
mnote/scripts/task774-openhub-mnote-send-and-file-edit-e2e.js
T

295 lines
12 KiB
JavaScript

#!/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);
});