295 lines
12 KiB
JavaScript
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);
|
|
});
|