299 lines
13 KiB
JavaScript
299 lines
13 KiB
JavaScript
#!/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);
|
|
});
|