Files
mnote/recycle/20260712-pi-ts-openhub-retirement/scripts/task786-openhub-history-refresh-browser-smoke.js
T

556 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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} 返回非 JSONHTTP ${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);
});