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