554 lines
26 KiB
JavaScript
554 lines
26 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,
|
|
ensureAuthenticated,
|
|
getViewerIdentity,
|
|
} = require("./tree-shell-smoke-helpers");
|
|
|
|
const TASK = "task773-page-ai-openhub-browser-smoke";
|
|
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "7-68-runtime");
|
|
const SCREENSHOT_PATH = path.join(OUTPUT_DIR, "page-ai-openhub-browser.png");
|
|
const RESULT_PATH = path.join(OUTPUT_DIR, "page-ai-openhub-browser-result.json");
|
|
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"]
|
|
.find((candidate) => fs.existsSync(candidate));
|
|
|
|
class SmokeFailure extends Error {
|
|
constructor(kind, message, details = {}) {
|
|
super(message);
|
|
this.name = "SmokeFailure";
|
|
this.kind = kind;
|
|
this.details = details;
|
|
}
|
|
}
|
|
|
|
function visibleSelectorScript(selectors) {
|
|
return selectors.some((selector) => {
|
|
const nodes = Array.from(document.querySelectorAll(selector));
|
|
return nodes.some((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;
|
|
});
|
|
});
|
|
}
|
|
|
|
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() });
|
|
}
|
|
|
|
try {
|
|
return await getViewerIdentity(requestContext);
|
|
} catch (error) {
|
|
throw new SmokeFailure("auth_failed", `登录后 whoami 仍不可用:${error.message}`, { url: page.url() });
|
|
}
|
|
}
|
|
|
|
async function waitForVisibleAny(page, selectors, label) {
|
|
try {
|
|
await page.waitForFunction(visibleSelectorScript, selectors, { timeout: UI_TIMEOUT_MS });
|
|
} catch (error) {
|
|
throw new SmokeFailure("selector_missing", `${label} 不可见。候选 selector: ${selectors.join(", ")}`, {
|
|
selectors,
|
|
cause: error.message,
|
|
});
|
|
}
|
|
}
|
|
|
|
async function openPageAiDrawer(page) {
|
|
await page.goto(BASE_URL, { waitUntil: "commit", timeout: UI_TIMEOUT_MS });
|
|
if (page.url().includes("/auth")) {
|
|
throw new SmokeFailure("auth_failed", "打开主页后被重定向到 /auth,登录态未生效", { url: page.url() });
|
|
}
|
|
|
|
await page.evaluate(() => {
|
|
try {
|
|
localStorage.removeItem("mnote.page_ai.openhub_host");
|
|
localStorage.setItem("mnote.page_ai.openhub_host", "1");
|
|
} catch {}
|
|
});
|
|
await waitForVisibleAny(page, ["[data-testid='wolai-floating-ai']", "[data-testid='wolai-page-ai-drawer']"], "Page AI 入口");
|
|
|
|
const drawerVisible = await page.locator("[data-testid='wolai-page-ai-drawer']").first().isVisible().catch(() => false);
|
|
if (!drawerVisible) {
|
|
await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS });
|
|
}
|
|
await waitForVisibleAny(page, ["[data-testid='wolai-page-ai-drawer']"], "Page AI drawer");
|
|
}
|
|
|
|
async function validateOpenHubQuickActions(page) {
|
|
const frameHandle = await page.locator("iframe[data-page-ai-openhub-iframe]").elementHandle({ timeout: UI_TIMEOUT_MS });
|
|
const frame = frameHandle ? await frameHandle.contentFrame() : null;
|
|
if (!frame) {
|
|
throw new SmokeFailure("quick_action_failed", "OpenHub iframe frame 不可用", { reason: "iframe_frame_missing" });
|
|
}
|
|
const tabButton = frame.locator("[data-mnote-openhub-current-tab-toggle]").first();
|
|
const folderButton = frame.locator("[data-mnote-openhub-current-folder-toggle]").first();
|
|
await tabButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
await folderButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
|
const fillVisibleInput = async (value) => frame.evaluate((nextValue) => {
|
|
const selectors = [
|
|
"textarea[placeholder*='输入']",
|
|
"textarea",
|
|
"[contenteditable='true']",
|
|
"input[type='text'][placeholder*='输入']",
|
|
"input[type='text']",
|
|
".ant-input",
|
|
];
|
|
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;
|
|
};
|
|
for (const selector of selectors) {
|
|
const nodes = Array.from(document.querySelectorAll(selector)).filter(visible);
|
|
if (!nodes.length) continue;
|
|
const node = nodes[nodes.length - 1];
|
|
node.focus();
|
|
if (node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement) {
|
|
const descriptor = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(node), "value");
|
|
if (descriptor && typeof descriptor.set === "function") {
|
|
descriptor.set.call(node, nextValue);
|
|
} else {
|
|
node.value = nextValue;
|
|
}
|
|
} else {
|
|
node.textContent = nextValue;
|
|
}
|
|
node.dispatchEvent(new InputEvent("input", { bubbles: true, data: nextValue, inputType: "insertText" }));
|
|
node.dispatchEvent(new Event("change", { bubbles: true }));
|
|
return true;
|
|
}
|
|
return false;
|
|
}, value);
|
|
const readVisibleInput = async () => frame.evaluate(() => {
|
|
const selectors = [
|
|
"textarea[placeholder*='输入']",
|
|
"textarea",
|
|
"[contenteditable='true']",
|
|
"input[type='text'][placeholder*='输入']",
|
|
"input[type='text']",
|
|
".ant-input",
|
|
];
|
|
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;
|
|
};
|
|
for (const selector of selectors) {
|
|
const nodes = Array.from(document.querySelectorAll(selector)).filter(visible);
|
|
if (!nodes.length) continue;
|
|
const node = nodes[nodes.length - 1];
|
|
if (node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement) return node.value || "";
|
|
return node.textContent || "";
|
|
}
|
|
return "";
|
|
});
|
|
const hasInput = await fillVisibleInput("");
|
|
if (!hasInput) {
|
|
throw new SmokeFailure("quick_action_failed", "OpenHub iframe 内未找到可见聊天输入框", {});
|
|
}
|
|
const prompt = `MNOTE_NATIVE_CONTEXT_SMOKE_${Date.now()}`;
|
|
let capturedBody = null;
|
|
await page.route("**/page-ai/openhub/ai/api/query/stream**", async (route) => {
|
|
capturedBody = JSON.parse(route.request().postData() || "{}");
|
|
await route.fulfill({
|
|
status: 200,
|
|
headers: { "content-type": "text/event-stream; charset=utf-8" },
|
|
body: [
|
|
`data: ${JSON.stringify({ type: "session", conversation_id: "task773-native-context", done: false })}`,
|
|
"",
|
|
`data: ${JSON.stringify({ type: "message_complete", done: true })}`,
|
|
"",
|
|
].join("\n"),
|
|
});
|
|
});
|
|
await fillVisibleInput(prompt);
|
|
await tabButton.click({ timeout: UI_TIMEOUT_MS });
|
|
const inputTextAfterToggle = (await readVisibleInput()).trim();
|
|
const tabSelected = await tabButton.evaluate((node) => node.classList.contains("ant-btn-primary") || node.getAttribute("type") === "button" && node.matches(".ant-btn-primary"));
|
|
const folderSelectedAfterTab = await folderButton.evaluate((node) => node.classList.contains("ant-btn-primary"));
|
|
await page.keyboard.press("Enter");
|
|
await page.waitForFunction(() => window.__mnoteOpenHubTask773RequestCaptured === true, undefined, { timeout: 100 }).catch(() => undefined);
|
|
const started = Date.now();
|
|
while (!capturedBody && Date.now() - started < UI_TIMEOUT_MS) {
|
|
await page.waitForTimeout(100);
|
|
}
|
|
const activeEditor = await page.evaluate(() => window.__mnoteDocumentPaneRuntime && typeof window.__mnoteDocumentPaneRuntime.getOpenEditorsSnapshot === "function"
|
|
? window.__mnoteDocumentPaneRuntime.getOpenEditorsSnapshot()?.activeEditor || null
|
|
: null);
|
|
const result = {
|
|
ok: Boolean(capturedBody && capturedBody.mnote_context && capturedBody.mnote_context.value),
|
|
tabButtonVisible: await tabButton.isVisible().catch(() => false),
|
|
folderButtonVisible: await folderButton.isVisible().catch(() => false),
|
|
tabSelected,
|
|
folderSelectedAfterTab,
|
|
inputTextAfterToggle,
|
|
requestQuestion: capturedBody && capturedBody.question,
|
|
mnoteContext: capturedBody && capturedBody.mnote_context,
|
|
activeEditor,
|
|
};
|
|
if (!result.ok) {
|
|
throw new SmokeFailure("quick_action_failed", "OpenHub 当前 Tab/文件夹上下文未随发送请求进入后台", result);
|
|
}
|
|
if (!result.tabSelected || result.folderSelectedAfterTab) {
|
|
throw new SmokeFailure("quick_action_selection_invalid", "当前 Tab 按钮没有呈现单选选中态", result);
|
|
}
|
|
if (result.inputTextAfterToggle !== prompt) {
|
|
throw new SmokeFailure("quick_action_leaked_to_input", "当前 Tab/文件夹地址不应直接写入用户可见输入框", result);
|
|
}
|
|
if (result.mnoteContext.kind !== "tab" || !/^https?:\/\/.+\/documents\//.test(result.mnoteContext.value)) {
|
|
throw new SmokeFailure("quick_action_tab_context_invalid", "当前 Tab 发送上下文不是 MNote 文档地址", result);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
async function collectState(page) {
|
|
return page.evaluate(() => {
|
|
const visible = (node) => {
|
|
if (!node || node.nodeType !== 1) return false;
|
|
const ownerWindow = node.ownerDocument && node.ownerDocument.defaultView ? node.ownerDocument.defaultView : window;
|
|
const style = ownerWindow.getComputedStyle(node);
|
|
const rect = node.getBoundingClientRect();
|
|
return !node.hidden && style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0;
|
|
};
|
|
const rectOf = (node) => {
|
|
if (!node || node.nodeType !== 1 || typeof node.getBoundingClientRect !== "function") return null;
|
|
const rect = node.getBoundingClientRect();
|
|
return {
|
|
x: Math.round(rect.x),
|
|
y: Math.round(rect.y),
|
|
width: Math.round(rect.width),
|
|
height: Math.round(rect.height),
|
|
top: Math.round(rect.top),
|
|
bottom: Math.round(rect.bottom),
|
|
};
|
|
};
|
|
const visibleElements = (selector) => Array.from(document.querySelectorAll(selector)).filter(visible);
|
|
const visibleAny = (selector) => visibleElements(selector).length > 0;
|
|
const text = (selector) => (document.querySelector(selector)?.textContent || "").trim();
|
|
const iframe = document.querySelector("iframe[data-page-ai-openhub-iframe]");
|
|
const iframeDoc = iframe instanceof HTMLIFrameElement ? iframe.contentDocument : null;
|
|
const iframeBodyText = (iframeDoc && iframeDoc.body ? iframeDoc.body.textContent || "" : "").trim();
|
|
const iframeShellKind = iframeDoc && iframeDoc.body ? iframeDoc.body.getAttribute("data-mnote-openhub-ai-shell") || "" : "";
|
|
const drawer = Array.from(document.querySelectorAll("[data-testid='wolai-page-ai-drawer']")).find(visible) || null;
|
|
const openhubHost = document.querySelector("[data-page-ai-openhub-host='true']");
|
|
const outerOpenHubHeaderVisible = visibleAny(".wolai-page-ai-opencode-header")
|
|
|| visibleAny(".wolai-page-ai-header-copy")
|
|
|| visibleAny("[data-page-ai-openhub-runtime-status]");
|
|
const diagnostics = document.querySelector("[data-page-ai-openhub-bootstrap-copy]");
|
|
const diagnosticsVisible = Boolean(diagnostics && visible(diagnostics));
|
|
const diagnosticsRect = diagnosticsVisible ? rectOf(diagnostics) : null;
|
|
const diagnosticsOpen = diagnostics instanceof HTMLDetailsElement ? diagnostics.open : false;
|
|
const debugChromeRects = diagnosticsVisible ? [{
|
|
selector: "[data-page-ai-openhub-bootstrap-copy]",
|
|
text: (diagnostics.textContent || "").replace(/\s+/g, " ").trim().slice(0, 160),
|
|
rect: diagnosticsRect,
|
|
open: diagnosticsOpen,
|
|
}] : [];
|
|
const debugChromeTotalHeight = diagnosticsRect?.height || 0;
|
|
const fallbackActionSelector = "[data-page-ai-action='openhub-use-opencode-fallback']";
|
|
const fallbackActionVisibleInIframe = iframeDoc
|
|
? Array.from(iframeDoc.querySelectorAll(fallbackActionSelector)).some(visible)
|
|
: false;
|
|
const pageText = (document.body.textContent || "").replace(/\s+/g, " ").trim();
|
|
const loginTextPattern = /(登录\s*OpenHub|OpenHub\s*Login|WeKnora\s*登录|登录\s*WeKnora|Sign in to OpenHub|OpenHub account|WeKnora account)/i;
|
|
const reactSelectorMarkers = [
|
|
"[data-openhub-ai-panel]",
|
|
"[data-testid='openhub-ai-panel']",
|
|
"[data-testid='openhub-chat']",
|
|
"[data-openhub-conversation]",
|
|
"[data-openhub-session-history]",
|
|
".openhub-ai-panel",
|
|
".chat-message-list",
|
|
".ant-layout",
|
|
".ant-menu",
|
|
".ant-input",
|
|
].filter((selector) => Array.from(document.querySelectorAll(selector)).some(visible)
|
|
|| (iframeDoc && Array.from(iframeDoc.querySelectorAll(selector)).some(visible)));
|
|
const reactTextMarkers = [
|
|
"OpenHub 平台",
|
|
"开始对话",
|
|
"历史记录",
|
|
"技能管理",
|
|
"选择模型",
|
|
].filter((marker) => iframeBodyText.includes(marker));
|
|
const conflictEntryPattern = /(文件管理|知识库|时光机|智能体|协作任务|团队状态)/;
|
|
const quickActionTab = iframeDoc ? iframeDoc.querySelector("[data-mnote-openhub-current-tab-toggle]") : null;
|
|
const quickActionFolder = iframeDoc ? iframeDoc.querySelector("[data-mnote-openhub-current-folder-toggle]") : null;
|
|
const smokeInput = iframeDoc ? iframeDoc.querySelector("[data-mnote-openhub-smoke-input]") : null;
|
|
return {
|
|
url: location.href,
|
|
title: document.title,
|
|
bodySnippet: pageText.slice(0, 800),
|
|
drawerVisible: Boolean(drawer),
|
|
drawerRect: rectOf(drawer),
|
|
openhubHost: Boolean(openhubHost),
|
|
openhubHostRect: rectOf(openhubHost),
|
|
outerOpenHubHeaderVisible,
|
|
debugChromeVisible: debugChromeRects.length > 0,
|
|
debugChromeRects,
|
|
debugChromeTotalHeight,
|
|
debugChromeSqueezesContent: diagnosticsOpen || debugChromeTotalHeight > 80,
|
|
hostChromeVisible: Boolean(Array.from(document.querySelectorAll("[data-page-ai-openhub-bootstrap-copy]")).find(visible)),
|
|
iframeVisible: iframe instanceof HTMLIFrameElement && visible(iframe),
|
|
iframeRect: rectOf(iframe),
|
|
iframeSrc: iframe instanceof HTMLIFrameElement ? iframe.getAttribute("src") || "" : "",
|
|
iframeBodySnippet: iframeBodyText.slice(0, 800),
|
|
iframeShellKind,
|
|
runtimeStatus: text("[data-page-ai-openhub-runtime-status]"),
|
|
authTruth: text("[data-page-ai-openhub-auth-truth]"),
|
|
workspaceScope: text("[data-page-ai-openhub-workspace-scope]"),
|
|
routeGuard: text("[data-page-ai-openhub-route-guard]"),
|
|
fallback: text("[data-page-ai-openhub-fallback]"),
|
|
loginPageVisible: loginTextPattern.test(pageText) || loginTextPattern.test(iframeBodyText),
|
|
staticBoundaryVisible: iframeShellKind === "static-boundary" || /静态占位|static-boundary|最小 host\/bootstrap 占位/.test(iframeBodyText),
|
|
fallbackActionVisible: visibleAny(fallbackActionSelector) || fallbackActionVisibleInIframe,
|
|
fallbackActionCount: document.querySelectorAll(fallbackActionSelector).length
|
|
+ (iframeDoc ? iframeDoc.querySelectorAll(fallbackActionSelector).length : 0),
|
|
reactAiMarkers: [...reactSelectorMarkers, ...reactTextMarkers.map((marker) => `text:${marker}`)],
|
|
reactTextMarkers,
|
|
conflictEntryVisible: conflictEntryPattern.test(iframeBodyText),
|
|
quickActionTabVisible: Boolean(quickActionTab && visible(quickActionTab)),
|
|
quickActionFolderVisible: Boolean(quickActionFolder && visible(quickActionFolder)),
|
|
quickActionTabText: quickActionTab ? (quickActionTab.textContent || "").trim() : "",
|
|
quickActionFolderText: quickActionFolder ? (quickActionFolder.textContent || "").trim() : "",
|
|
quickActionTabLabel: quickActionTab ? quickActionTab.getAttribute("aria-label") || "" : "",
|
|
quickActionFolderLabel: quickActionFolder ? quickActionFolder.getAttribute("aria-label") || "" : "",
|
|
quickActionTabSelected: quickActionTab ? quickActionTab.classList.contains("ant-btn-primary") : false,
|
|
quickActionFolderSelected: quickActionFolder ? quickActionFolder.classList.contains("ant-btn-primary") : false,
|
|
quickActionSmokeInputValue: smokeInput instanceof HTMLTextAreaElement ? smokeInput.value : "",
|
|
};
|
|
});
|
|
}
|
|
|
|
function assertOpenHubState(state) {
|
|
if (!state.drawerVisible) {
|
|
throw new SmokeFailure("selector_missing", "Page AI drawer 未保持可见", state);
|
|
}
|
|
if (!state.openhubHost) {
|
|
throw new SmokeFailure("selector_missing", "Page AI drawer 未切到 OpenHub host", state);
|
|
}
|
|
if (!state.iframeVisible || !state.iframeSrc.includes("/page-ai/openhub/ai")) {
|
|
throw new SmokeFailure("selector_missing", "OpenHub iframe 不可见或 src 未指向 /page-ai/openhub/ai", state);
|
|
}
|
|
if (state.outerOpenHubHeaderVisible) {
|
|
throw new SmokeFailure("outer_header_visible", "OpenHub drawer 仍显示 MNote 外层 OpenHub AI 标题栏", state);
|
|
}
|
|
if (!state.iframeRect || state.iframeRect.height < 420) {
|
|
throw new SmokeFailure("iframe_too_short", "OpenHub iframe 高度不足,可能被顶部 debug/status 区挤压", state);
|
|
}
|
|
if (state.debugChromeSqueezesContent) {
|
|
throw new SmokeFailure("debug_chrome_visible", "OpenHub 顶部 debug/status chrome 仍可见且挤占空间", state);
|
|
}
|
|
if (state.fallbackActionVisible || state.fallbackActionCount > 0) {
|
|
throw new SmokeFailure("legacy_fallback_visible", "OpenHub drawer 仍存在用户可见 opencode fallback 入口", state);
|
|
}
|
|
if (state.loginPageVisible) {
|
|
throw new SmokeFailure("unexpected_upstream_login", "页面出现 OpenHub/WeKnora 登录入口", state);
|
|
}
|
|
if (!state.iframeBodySnippet && !state.reactAiMarkers.length) {
|
|
throw new SmokeFailure("selector_missing", "OpenHub iframe 已出现,但 iframe 内 shell 内容不可见", state);
|
|
}
|
|
if (state.conflictEntryVisible) {
|
|
throw new SmokeFailure("unexpected_conflict_entry", "OpenHub iframe 嵌入态仍显示 MNote 真相冲突入口", state);
|
|
}
|
|
if (!state.quickActionTabVisible || !state.quickActionFolderVisible) {
|
|
throw new SmokeFailure("quick_action_missing", "OpenHub iframe 内缺少当前 Tab/文件夹快捷按钮", state);
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
|
const consoleMessages = [];
|
|
let browser;
|
|
let context;
|
|
let page;
|
|
let result;
|
|
let directHostRoute = null;
|
|
let legacyOpencodeRoute = null;
|
|
const networkEvents = [];
|
|
|
|
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" });
|
|
page = await context.newPage();
|
|
page.on("console", (message) => {
|
|
if (["error", "warning"].includes(message.type())) {
|
|
consoleMessages.push({ type: message.type(), text: message.text() });
|
|
}
|
|
});
|
|
page.on("response", (response) => {
|
|
const url = response.url();
|
|
if (url.includes("/page-ai/openhub")) {
|
|
networkEvents.push({ url, status: response.status(), contentType: response.headers()["content-type"] || "" });
|
|
}
|
|
});
|
|
|
|
const viewer = await loginWithUiFirst(page, context.request).catch(async (error) => {
|
|
if (error instanceof SmokeFailure) throw error;
|
|
await ensureAuthenticated(page, context.request);
|
|
return getViewerIdentity(context.request);
|
|
});
|
|
legacyOpencodeRoute = await context.request.get(`${BASE_URL}/page-ai/opencode`, { timeout: UI_TIMEOUT_MS }).then(async (response) => ({
|
|
url: `${BASE_URL}/page-ai/opencode`,
|
|
status: response.status(),
|
|
ok: response.ok(),
|
|
contentType: response.headers()["content-type"] || "",
|
|
bodySnippet: (await response.text()).slice(0, 240),
|
|
})).catch((error) => ({
|
|
url: `${BASE_URL}/page-ai/opencode`,
|
|
status: 0,
|
|
ok: false,
|
|
error: error.message,
|
|
}));
|
|
if (legacyOpencodeRoute.status !== 410) {
|
|
throw new SmokeFailure("legacy_fallback_route_enabled", "登录后 /page-ai/opencode legacy fallback 页面仍可访问", legacyOpencodeRoute);
|
|
}
|
|
directHostRoute = await context.request.get(`${BASE_URL}/page-ai/openhub/ai`, { timeout: UI_TIMEOUT_MS }).then(async (response) => ({
|
|
url: `${BASE_URL}/page-ai/openhub/ai`,
|
|
status: response.status(),
|
|
ok: response.ok(),
|
|
contentType: response.headers()["content-type"] || "",
|
|
bodySnippet: (await response.text()).slice(0, 240),
|
|
})).catch((error) => ({
|
|
url: `${BASE_URL}/page-ai/openhub/ai`,
|
|
status: 0,
|
|
ok: false,
|
|
error: error.message,
|
|
}));
|
|
await openPageAiDrawer(page);
|
|
await waitForVisibleAny(
|
|
page,
|
|
[
|
|
"[data-page-ai-openhub-host='true']",
|
|
"[data-page-ai-openhub-bootstrap-copy]",
|
|
"iframe[data-page-ai-openhub-iframe]",
|
|
],
|
|
"OpenHub host",
|
|
);
|
|
await page.waitForFunction(() => {
|
|
const frame = document.querySelector("iframe[data-page-ai-openhub-iframe]");
|
|
return frame instanceof HTMLIFrameElement && (frame.getAttribute("src") || "").includes("/page-ai/openhub/ai");
|
|
}, undefined, { timeout: UI_TIMEOUT_MS });
|
|
await page.waitForFunction(() => {
|
|
const frame = document.querySelector("iframe[data-page-ai-openhub-iframe]");
|
|
return frame instanceof HTMLIFrameElement && (frame.getAttribute("src") || "").includes("mnoteScope=");
|
|
}, undefined, { timeout: UI_TIMEOUT_MS }).catch(() => undefined);
|
|
await page.waitForTimeout(800);
|
|
await page.waitForFunction(() => {
|
|
const iframe = document.querySelector("iframe[data-page-ai-openhub-iframe]");
|
|
const doc = iframe instanceof HTMLIFrameElement ? iframe.contentDocument : null;
|
|
return Boolean(doc && doc.querySelector("[data-mnote-openhub-current-tab-toggle]") && doc.querySelector("[data-mnote-openhub-current-folder-toggle]"));
|
|
}, undefined, { timeout: UI_TIMEOUT_MS });
|
|
|
|
const quickActions = await validateOpenHubQuickActions(page);
|
|
const state = await collectState(page);
|
|
await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true });
|
|
assertOpenHubState(state);
|
|
const openhubReactConnected = state.reactAiMarkers.length > 0;
|
|
|
|
result = {
|
|
ok: true,
|
|
task: TASK,
|
|
baseUrl: BASE_URL,
|
|
viewer,
|
|
screenshot: SCREENSHOT_PATH,
|
|
directHostRoute,
|
|
legacyOpencodeRoute,
|
|
quickActions,
|
|
state,
|
|
openhubReactConnected,
|
|
staticBoundaryOnly: state.staticBoundaryVisible && !openhubReactConnected,
|
|
reactAiNote: openhubReactConnected
|
|
? "OpenHub React UI marker 已出现"
|
|
: "未发现 OpenHub React AI marker;本次只验证 MNote OpenHub host drawer/iframe/shell 边界,不把静态 shell 记为 React AI 已接入",
|
|
networkEvents,
|
|
consoleMessages,
|
|
};
|
|
} catch (error) {
|
|
const state = page ? await collectState(page).catch(() => null) : null;
|
|
if (page) {
|
|
await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true }).catch(() => undefined);
|
|
}
|
|
result = {
|
|
ok: false,
|
|
task: TASK,
|
|
baseUrl: BASE_URL,
|
|
screenshot: fs.existsSync(SCREENSHOT_PATH) ? SCREENSHOT_PATH : null,
|
|
failureKind: error instanceof SmokeFailure ? error.kind : "unexpected_error",
|
|
error: error instanceof Error ? error.stack || error.message : String(error),
|
|
errorDetails: error instanceof SmokeFailure ? error.details : null,
|
|
directHostRoute,
|
|
legacyOpencodeRoute,
|
|
state,
|
|
networkEvents,
|
|
consoleMessages,
|
|
};
|
|
} 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);
|
|
});
|