#!/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} 返回非 JSON:HTTP ${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); });