#!/usr/bin/env node "use strict"; const assert = require("node:assert"); const fs = require("node:fs"); const path = require("node:path"); const { chromium } = require("playwright"); const { BASE_URL, UI_TIMEOUT_MS, ensureAuthenticated, } = require("./tree-shell-smoke-helpers"); const TASK = "task763-page-ai-opencode-embed-smoke"; const OUTPUT_DIR = path.join(process.cwd(), "tmp", TASK); const RESULT_PATH = path.join(OUTPUT_DIR, "result.json"); const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/usr/bin/chromium-browser", "/usr/bin/chromium"] .find((candidate) => fs.existsSync(candidate)); async function saveScreenshot(page, name) { fs.mkdirSync(OUTPUT_DIR, { recursive: true }); const target = path.join(OUTPUT_DIR, `${name}.png`); await page.screenshot({ path: target, fullPage: true }); return target; } async function waitForVisibleAny(page, selectors, label) { await page.waitForFunction( (candidateSelectors) => candidateSelectors.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; }); }), selectors, { timeout: UI_TIMEOUT_MS }, ).catch((error) => { throw new Error(`${label} 不可见。候选选择器: ${selectors.join(", ")}\n${error.message}`); }); } async function collectOpencodeHooks(page) { return page.evaluate(() => { 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; }; const bySelector = (selectors) => selectors.flatMap((selector) => Array.from(document.querySelectorAll(selector)).map((node) => ({ selector, tag: node.tagName.toLowerCase(), text: (node.textContent || "").trim().slice(0, 120), visible: visible(node), href: node.getAttribute("href") || "", src: node.getAttribute("src") || "", action: node.getAttribute("data-page-ai-action") || "", testid: node.getAttribute("data-testid") || "", })), ); const hostSelectors = [ "[data-page-ai-opencode-host]", "[data-testid='page-ai-opencode-host']", "[data-page-ai-host-chrome]", "[data-page-ai-opencode-chrome]", ".wolai-page-ai-opencode-host", ".wolai-page-ai-opencode-chrome", ".wolai-page-ai-host-chrome", ]; const frameSelectors = [ "iframe[data-page-ai-opencode-iframe]", "iframe[data-page-ai-opencode-frame]", "iframe[data-testid='page-ai-opencode-frame']", "iframe[src*='/page-ai/opencode']", "iframe[src*='opencode']", ]; const changedFileSelectors = [ "[data-page-ai-opencode-changed-files]", "[data-page-ai-changed-file-chip]", "[data-page-ai-opencode-open-file]", "[data-page-ai-changed-file]", "[data-page-ai-action='open-changed-file']", "[data-page-ai-action='open-file']", "[data-page-ai-board-run-detail-card] .wolai-page-ai-tool-details", ".wolai-page-ai-changed-file-chip", ".wolai-page-ai-changed-files", ]; const openSelectors = [ "[data-page-ai-opencode-open-file]", "[data-page-ai-action='open-changed-file']", "[data-page-ai-action='open-current-file']", "[data-page-ai-action='open-current-page']", "[data-page-ai-action='open-file']", "[data-page-ai-open-file]", ]; const refreshSelectors = [ "[data-page-ai-action='opencode-refresh-current-page']", "[data-page-ai-refresh-file]", "[data-page-ai-action='refresh-current-file']", "[data-page-ai-action='refresh-current-page']", "[data-page-ai-action='refresh-file']", "[data-page-ai-refresh-file]", ]; return { title: document.title, url: location.href, drawerVisible: Boolean(Array.from(document.querySelectorAll("[data-testid='wolai-page-ai-drawer']")).find(visible)), hostChrome: bySelector(hostSelectors), frames: bySelector(frameSelectors), changedFiles: bySelector(changedFileSelectors), openHooks: bySelector(openSelectors), refreshHooks: bySelector(refreshSelectors), htmlFlags: { receiptCurrentRefresh: document.documentElement.getAttribute("data-mnote-page-ai-receipt-current-refresh") || "", receiptFiletreeRefresh: document.documentElement.getAttribute("data-mnote-page-ai-receipt-filetree-refresh") || "", }, }; }); } function assertAnyVisible(items, label) { assert( items.some((item) => item.visible), `${label} 缺失或不可见: ${JSON.stringify(items, null, 2)}`, ); } function assertAnyHook(items, label) { assert(items.length > 0, `${label} DOM hook 缺失`); } async function main() { fs.mkdirSync(OUTPUT_DIR, { recursive: true }); const screenshots = []; const consoleMessages = []; let result = null; const browser = await chromium.launch({ headless: process.env.HEADFUL !== "1", ...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}), }); const context = await browser.newContext({ viewport: { width: 1440, height: 960 }, locale: "zh-CN" }); const page = await context.newPage(); page.on("console", (message) => { if (["error", "warning"].includes(message.type())) { consoleMessages.push({ type: message.type(), text: message.text() }); } }); try { await page.goto(`${BASE_URL}/auth`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); const quickLogin = page.getByRole("button", { name: "测试账号快速登录" }); if (await quickLogin.count()) { await quickLogin.click({ timeout: UI_TIMEOUT_MS }); await page.waitForURL((url) => !url.toString().includes("/auth"), { timeout: UI_TIMEOUT_MS, waitUntil: "commit" }).catch(() => undefined); } else { await ensureAuthenticated(page, context.request); } await page.goto(BASE_URL, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); await page.waitForSelector('[data-testid="wolai-floating-ai"]', { timeout: UI_TIMEOUT_MS }); const firstVisibleMarkdown = page.locator('[data-document-id^="local-md:"] button.tree-link, button[data-document-id^="local-md:"]').filter({ visible: true }); if (await firstVisibleMarkdown.count()) { await firstVisibleMarkdown.first().click({ timeout: UI_TIMEOUT_MS }); await page.waitForTimeout(1200); } await waitForVisibleAny(page, ["[data-testid='wolai-floating-ai']", "[data-testid='wolai-page-ai-drawer']"], "Page AI 入口"); const drawerAlreadyOpen = await page.locator("[data-testid='wolai-page-ai-drawer']").count().then(async (count) => { if (!count) return false; return page.locator("[data-testid='wolai-page-ai-drawer']").first().isVisible().catch(() => false); }); if (!drawerAlreadyOpen) { await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS }); } await waitForVisibleAny(page, ["[data-testid='wolai-page-ai-drawer']"], "Page AI 抽屉"); await waitForVisibleAny( page, [ "[data-page-ai-opencode-host]", "[data-testid='page-ai-opencode-host']", "[data-page-ai-host-chrome]", "[data-page-ai-opencode-chrome]", ".wolai-page-ai-opencode-host", ".wolai-page-ai-opencode-chrome", ".wolai-page-ai-host-chrome", "iframe[data-page-ai-opencode-iframe]", "iframe[data-page-ai-opencode-frame]", "iframe[data-testid='page-ai-opencode-frame']", "iframe[src*='/page-ai/opencode']", "iframe[src*='opencode']", ], "opencode iframe 或 host chrome", ); await page.waitForFunction( () => { const frame = document.querySelector("iframe[data-page-ai-opencode-iframe]"); return frame instanceof HTMLIFrameElement && /\/session\/ses_/.test(frame.src || ""); }, { timeout: UI_TIMEOUT_MS }, ); const hooks = await collectOpencodeHooks(page); assert(hooks.drawerVisible, "Page AI 抽屉未保持可见"); assertAnyVisible([...hooks.hostChrome, ...hooks.frames], "opencode iframe/host chrome"); assert( hooks.frames.some((frame) => /\/session\/ses_/.test(frame.src || "")), `opencode iframe 未进入绑定 session URL: ${JSON.stringify(hooks.frames, null, 2)}`, ); assert(!hooks.hostChrome.some((item) => /Agent Board|ZCode|Hermes|Reasonix/.test(item.text || "")), "opencode host chrome 混入旧 Page AI provider 文案"); assertAnyHook(hooks.changedFiles, "changed files 容器或 hook"); assertAnyHook(hooks.refreshHooks, "refresh file hook"); screenshots.push(await saveScreenshot(page, "opencode-page-ai-open")); const firstSessionUrl = hooks.frames.find((frame) => /\/session\/ses_/.test(frame.src || ""))?.src || ""; const secondContext = await browser.newContext({ viewport: { width: 1440, height: 960 }, locale: "zh-CN" }); const secondPage = await secondContext.newPage(); secondPage.on("console", (message) => { if (["error", "warning"].includes(message.type())) { consoleMessages.push({ type: `second:${message.type()}`, text: message.text() }); } }); try { await secondPage.goto(`${BASE_URL}/auth`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); const secondQuickLogin = secondPage.getByRole("button", { name: "测试账号快速登录" }); if (await secondQuickLogin.count()) { await secondQuickLogin.click({ timeout: UI_TIMEOUT_MS }); await secondPage.waitForURL((url) => !url.toString().includes("/auth"), { timeout: UI_TIMEOUT_MS, waitUntil: "commit" }).catch(() => undefined); } else { await ensureAuthenticated(secondPage, secondContext.request); } await secondPage.goto(BASE_URL, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); await secondPage.waitForSelector('[data-testid="wolai-floating-ai"]', { timeout: UI_TIMEOUT_MS }); const secondFirstVisibleMarkdown = secondPage.locator('[data-document-id^="local-md:"] button.tree-link, button[data-document-id^="local-md:"]').filter({ visible: true }); if (await secondFirstVisibleMarkdown.count()) { await secondFirstVisibleMarkdown.first().click({ timeout: UI_TIMEOUT_MS }); await secondPage.waitForTimeout(1200); } await waitForVisibleAny(secondPage, ["[data-testid='wolai-floating-ai']", "[data-testid='wolai-page-ai-drawer']"], "第二浏览器 Page AI 入口"); const secondDrawerAlreadyOpen = await secondPage.locator("[data-testid='wolai-page-ai-drawer']").count().then(async (count) => { if (!count) return false; return secondPage.locator("[data-testid='wolai-page-ai-drawer']").first().isVisible().catch(() => false); }); if (!secondDrawerAlreadyOpen) { await secondPage.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS }); } await secondPage.waitForFunction( () => { const frame = document.querySelector("iframe[data-page-ai-opencode-iframe]"); return frame instanceof HTMLIFrameElement && /\/session\/ses_/.test(frame.src || ""); }, { timeout: UI_TIMEOUT_MS }, ); const secondHooks = await collectOpencodeHooks(secondPage); const secondSessionUrl = secondHooks.frames.find((frame) => /\/session\/ses_/.test(frame.src || ""))?.src || ""; assert.strictEqual(secondSessionUrl, firstSessionUrl, `跨浏览器 session binding 未复用: first=${firstSessionUrl} second=${secondSessionUrl}`); screenshots.push(await saveScreenshot(secondPage, "opencode-page-ai-second-browser")); hooks.secondBrowser = { frames: secondHooks.frames, sessionUrl: secondSessionUrl }; } finally { await secondContext.close().catch(() => undefined); } result = { ok: true, baseUrl: BASE_URL, hooks, screenshots, consoleMessages, }; fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8"); console.log(JSON.stringify(result, null, 2)); } catch (error) { screenshots.push(await saveScreenshot(page, "failure").catch(() => "")); result = { ok: false, baseUrl: BASE_URL, error: error instanceof Error ? error.stack || error.message : String(error), screenshots: screenshots.filter(Boolean), consoleMessages, hooks: await collectOpencodeHooks(page).catch(() => null), }; fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8"); throw error; } finally { await context.close().catch(() => undefined); await browser.close().catch(() => undefined); } } main().catch((error) => { console.error(error instanceof Error ? error.stack || error.message : String(error)); process.exit(1); });