重构基线
This commit is contained in:
@@ -40,7 +40,7 @@ node scripts/task490-runtime-surfaces-smoke.js
|
||||
- local Markdown conflict regression:`task510-local-markdown-conflict-regression-group.js` 串联真实冲突、连续上传、新建页面、外部恢复、多 tab CAS 与上传 409 孤儿策略;可用 `MNOTE_CONFLICT_REGRESSION_TASKS=a.js,b.js` 做定点子集。
|
||||
- tree realtime / live cache:`task446-tree-rename-dual-browser-live-smoke.js`、`task447-tree-move-order-dual-browser-live-smoke.js`、`task448-tree-resync-recovery-dual-browser-smoke.js`、`task449-tree-sse-reconnect-snapshot-recovery-smoke.js`
|
||||
- 资源对象与 mindmap:`task455-local-folder-mindmap-clean-smoke.js`、`task456-resource-object-shell-sync-smoke.js`、`task166-mindmap-phase6-block-smoke.js`、`task167-mindmap-kmind-parity-smoke.js`、`task168-mindmap-put-validator-smoke.js`;Page AI mindmap skill / 资源生成改动补跑 `task503-mindmap-skill-capability-smoke.js`,真实 mindmap resource tab / Page AI target 改动补跑 `task525-page-ai-mindmap-resource-target-smoke.js`
|
||||
- Page AI / agent history / ChatOnly:`task502-page-ai-agent-selector-context-smoke.js` 覆盖 Page AI agent/context/target picker、skills source 和 run payload;raw local resource target 改动补跑 `task520-page-ai-raw-resource-target-smoke.js`;`task504-page-ai-history-agent-filter-smoke.js` 覆盖 Page AI 历史按 agent 过滤;ChatOnly / provider session 绑定改动优先跑 `task512-chatonly-doubao-sync-smoke.js`,跨 provider 同步补跑 `task513-chatonly-provider-sync-smoke.js`
|
||||
- Page AI / agent history / ChatOnly:`task502-page-ai-agent-selector-context-smoke.js` 覆盖 Page AI agent/context/target picker、skills source 和 run payload;opencode WebUI embed 改动补跑 `task763-page-ai-opencode-embed-smoke.js`,只验证真实登录、Page AI 抽屉、opencode iframe/host chrome 和 changed file/open/refresh DOM hook,不 mock 聊天成功;raw local resource target 改动补跑 `task520-page-ai-raw-resource-target-smoke.js`;`task504-page-ai-history-agent-filter-smoke.js` 覆盖 Page AI 历史按 agent 过滤;ChatOnly / provider session 绑定改动优先跑 `task512-chatonly-doubao-sync-smoke.js`,跨 provider 同步补跑 `task513-chatonly-provider-sync-smoke.js`
|
||||
- Dev hot / OnlyOffice live bridge:Sidebar dev hot reload 入口改动补跑 `task514-sidebar-dev-hot-reload-gating-smoke.js`;OnlyOffice live bridge session / scope / HTTP 工具边界改动补跑 `task515-onlyoffice-live-scope-http-smoke.js`;bridge session/token/queue/current/close 和 `docKey/pageOrigin` 元数据改动补跑 `task516-onlyoffice-bridge-multisession-browser-smoke.js`;bridge plugin index / `Asc.plugin` direct loop / plugin 元数据透传改动补跑 `task517-onlyoffice-bridge-plugin-direct-smoke.js`;真实 ONLYOFFICE iframe / DocumentServer session、同文档双 tab、resource scope 和非 dry-run 写入落点改动补跑 `task518-onlyoffice-real-iframe-session-scope-smoke.js`;Page AI 真实 UI 选择 Office target 并冻结 live bridge session 改动补跑 `task523-page-ai-onlyoffice-real-target-session-smoke.js`
|
||||
- local resource lifecycle API:`task437-local-folder-asset-trash-lifecycle-smoke.js`,现在只验证 `local-file:*` delete / restore / purge,不再依赖普通 `.txt` 是否出现在 UI 文件树。
|
||||
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
* - ENABLE_BACKEND:设为 "1" or "true" 时启用默认 FastAPI 后端
|
||||
* - BACKEND_CMD:覆盖 FastAPI 启动命令;设置后即视为显式启用后端
|
||||
* - SKIP_BACKEND:设为 "1" or "true" 可强制跳过 FastAPI 后端
|
||||
* - ENABLE_OPENCODE:设为 "1" or "true" 时启用 opencode serve
|
||||
* - OPENCODE_CMD:覆盖 opencode 启动命令;设置后即视为显式启用 opencode
|
||||
* - SKIP_OPENCODE:设为 "1" or "true" 可强制跳过 opencode
|
||||
* - PYTHON_BIN:只在 BACKEND_CMD 未覆盖时,设置 Python 可执行文件,默认 "python"
|
||||
*/
|
||||
|
||||
@@ -45,6 +48,7 @@ function resolveBackendExecutable(envName, fallbackName) {
|
||||
|
||||
const pythonBin = resolveBackendExecutable("PYTHON_BIN", "python");
|
||||
const backendPortFromEnv = Number(process.env.BACKEND_PORT || 8000);
|
||||
const opencodePortFromEnv = Number(process.env.OPENCODE_PORT || 4096);
|
||||
|
||||
function hasCommand(command) {
|
||||
try {
|
||||
@@ -68,6 +72,10 @@ function buildDefaultBackendCommand(port) {
|
||||
return `${pythonBin} -m uvicorn app.main:app --reload --port ${port}`;
|
||||
}
|
||||
|
||||
function buildDefaultOpencodeCommand(port) {
|
||||
return `while true; do script -qfec "opencode serve --hostname=127.0.0.1 --port ${port} --print-logs" /dev/null; sleep 1; done`;
|
||||
}
|
||||
|
||||
function isEnabledEnv(value) {
|
||||
const normalized = String(value || "").toLowerCase();
|
||||
return normalized === "1" || normalized === "true";
|
||||
@@ -79,6 +87,12 @@ function shouldStartBackend(env = process.env) {
|
||||
return isEnabledEnv(env.ENABLE_BACKEND);
|
||||
}
|
||||
|
||||
function shouldStartOpencode(env = process.env) {
|
||||
if (isEnabledEnv(env.SKIP_OPENCODE)) return false;
|
||||
if (String(env.OPENCODE_CMD || "").trim()) return true;
|
||||
return true;
|
||||
}
|
||||
|
||||
function resolveRuntimePlan(env = process.env) {
|
||||
const frontendPort = Number(env.FRONTEND_PORT || 3000);
|
||||
const skipGateway = false;
|
||||
@@ -92,6 +106,7 @@ function resolveRuntimePlan(env = process.env) {
|
||||
mnoteWebEnv: {
|
||||
MNOTE_WEB_BIND: env.MNOTE_WEB_BIND || `0.0.0.0:${publicPort}`,
|
||||
MNOTE_WEB_PUBLIC_BIND: env.MNOTE_WEB_PUBLIC_BIND || `127.0.0.1:${publicPort}`,
|
||||
MNOTE_OPENCODE_BASE_URL: env.MNOTE_OPENCODE_BASE_URL || `http://127.0.0.1:${opencodePortFromEnv}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -122,6 +137,17 @@ const tasks = [
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(shouldStartOpencode(process.env)
|
||||
? [
|
||||
{
|
||||
name: "opencode",
|
||||
command:
|
||||
process.env.OPENCODE_CMD ||
|
||||
buildDefaultOpencodeCommand(opencodePortFromEnv),
|
||||
cwd: rootDir,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
function findTask(name) {
|
||||
@@ -475,6 +501,24 @@ async function main() {
|
||||
|
||||
}
|
||||
|
||||
const desiredOpencodePort = opencodePortFromEnv;
|
||||
if (shouldStartOpencode(process.env) && !process.env.OPENCODE_CMD) {
|
||||
const opencodePortOk = await ensurePortFree(desiredOpencodePort, "opencode");
|
||||
if (!opencodePortOk) {
|
||||
console.error(`opencode 端口 ${desiredOpencodePort} 无法释放,已中止启动。`);
|
||||
process.exit(1);
|
||||
}
|
||||
const opencodeTask = findTask("opencode");
|
||||
if (!opencodeTask) {
|
||||
throw new Error("缺少 opencode 任务配置");
|
||||
}
|
||||
opencodeTask.command = buildDefaultOpencodeCommand(desiredOpencodePort);
|
||||
} else if (isEnabledEnv(process.env.SKIP_OPENCODE)) {
|
||||
logPrefix("opencode", "已跳过 opencode(SKIP_OPENCODE=1)。");
|
||||
} else if (!shouldStartOpencode(process.env)) {
|
||||
|
||||
}
|
||||
|
||||
if (tasks.length === 0) {
|
||||
console.error("未配置任何可运行的任务,检查环境变量设置。");
|
||||
process.exit(1);
|
||||
|
||||
@@ -126,6 +126,7 @@ test("默认热启动计划只使用 mnote-web 作为 3000 owner", () => {
|
||||
assert.deepEqual(plan.mnoteWebEnv, {
|
||||
MNOTE_WEB_BIND: "0.0.0.0:3000",
|
||||
MNOTE_WEB_PUBLIC_BIND: "127.0.0.1:3000",
|
||||
MNOTE_OPENCODE_BASE_URL: "http://127.0.0.1:4096",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -52,6 +52,19 @@ async function saveScreenshot(page, name) {
|
||||
return target;
|
||||
}
|
||||
|
||||
async function assertMaterialIconSurface(locator, label, minCount) {
|
||||
const iconCount = await locator.locator('.material-symbols-outlined, .slash-item-icon, .block-drag-menu-icon').count();
|
||||
if (iconCount < minCount) {
|
||||
throw new Error(`${label} 至少应渲染 ${minCount} 个图标容器,实际: ${iconCount}`);
|
||||
}
|
||||
const text = ((await locator.textContent()) || '').trim();
|
||||
const legacyGlyphs = ['⚙', '×', '✕', '⋮', '◣', '↻'];
|
||||
const leaked = legacyGlyphs.filter((glyph) => text.includes(glyph));
|
||||
if (leaked.length > 0) {
|
||||
throw new Error(`${label} 不应泄漏旧 Unicode 图标: ${leaked.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForRuntimeIsland(page, uiTimeoutMs) {
|
||||
const root = page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]').first();
|
||||
await root.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
@@ -110,7 +123,12 @@ async function setBlockHandleFixture(page, uiTimeoutMs) {
|
||||
async function closePageAiIfOpen(page) {
|
||||
const closeButton = page.locator('[data-page-ai-action="close"]').first();
|
||||
if (await closeButton.isVisible().catch(() => false)) {
|
||||
await closeButton.click().catch(() => undefined);
|
||||
await closeButton.click({ force: true }).catch(async () => {
|
||||
await page.evaluate(() => {
|
||||
const button = document.querySelector('[data-page-ai-action="close"]');
|
||||
if (button instanceof HTMLElement) button.click();
|
||||
}).catch(() => undefined);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,6 +252,7 @@ async function main() {
|
||||
});
|
||||
});
|
||||
await page.route("**/api/hermes/client/events/run_task490_stop", async (route) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, UI_TIMEOUT_MS * 2));
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "text/event-stream; charset=utf-8" },
|
||||
@@ -309,11 +328,11 @@ async function main() {
|
||||
null,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
const stopButton = page.locator('[data-page-ai-action="stop-run"]').first();
|
||||
const stopButton = page.locator('.wolai-page-ai-stop[data-page-ai-action="stop-run"]').first();
|
||||
await stopButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const stop = document.querySelector('[data-page-ai-action="stop-run"]');
|
||||
const stop = document.querySelector('.wolai-page-ai-stop[data-page-ai-action="stop-run"]');
|
||||
return stop instanceof HTMLButtonElement && stop.disabled === false && stop.getAttribute("aria-disabled") === "false";
|
||||
},
|
||||
null,
|
||||
@@ -343,6 +362,7 @@ async function main() {
|
||||
await slashMenu.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
const slashText = (await slashMenu.textContent()) || "";
|
||||
assert(slashText.trim().length > 0, "slash menu 必须显示可选项");
|
||||
await assertMaterialIconSurface(slashMenu, "slash menu", 4);
|
||||
screenshots.slashMenu = await saveScreenshot(page, "03-slash-menu");
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
@@ -358,6 +378,7 @@ async function main() {
|
||||
await blockMenu.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
const blockMenuText = (await blockMenu.textContent()) || "";
|
||||
assert(blockMenuText.includes("删除") || blockMenuText.includes("Delete"), `block handle menu 缺少基础操作: ${blockMenuText}`);
|
||||
await assertMaterialIconSurface(blockMenu, "block handle menu", 8);
|
||||
screenshots.blockHandleMenu = await saveScreenshot(page, "04-block-handle-menu");
|
||||
|
||||
const result = {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { chromium } = require("playwright");
|
||||
const { BASE_URL, UI_TIMEOUT_MS, assert, ensureAuthenticated } = require("./tree-shell-smoke-helpers");
|
||||
|
||||
@@ -12,9 +14,11 @@ if (MNOTE_WEB_ENABLE_DEBUG_SHELL_ROUTES !== "1") {
|
||||
}
|
||||
|
||||
const PAGE_URL = `${BASE_URL}/ui-debug/components`;
|
||||
const OUT_DIR = process.env.MNOTE_UI_COMPONENTS_OUTPUT_DIR || path.resolve(__dirname, "..", "tmp", "ui-components");
|
||||
|
||||
async function main() {
|
||||
console.log("[task500] 启动 UI Debug 组件矩阵 smoke …");
|
||||
fs.mkdirSync(OUT_DIR, { recursive: true });
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
@@ -131,12 +135,17 @@ async function main() {
|
||||
assert(cssVar.length > 0, `--ui-debug-primary 应有值,实际: "${cssVar}"`);
|
||||
console.log("[task500] ✓ CSS 变量 --ui-debug-primary: %s", cssVar);
|
||||
|
||||
const screenshotPath = path.join(OUT_DIR, "ui-debug-components.png");
|
||||
await page.screenshot({ path: screenshotPath, fullPage: true });
|
||||
console.log("[task500] ✓ 截图输出: %s", screenshotPath);
|
||||
|
||||
console.log("[task500] ✅ 全部断言通过");
|
||||
} catch (error) {
|
||||
console.error("[task500] ✗ 失败:", error.message);
|
||||
try {
|
||||
await page.screenshot({ path: "tmp/task500-failure.png", fullPage: true });
|
||||
console.log("[task500] 失败截图: tmp/task500-failure.png");
|
||||
const failurePath = path.join(OUT_DIR, "task500-failure.png");
|
||||
await page.screenshot({ path: failurePath, fullPage: true });
|
||||
console.log("[task500] 失败截图: %s", failurePath);
|
||||
} catch (_) {}
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,433 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { chromium } = require("playwright");
|
||||
const {
|
||||
BASE_URL,
|
||||
UI_TIMEOUT_MS,
|
||||
ensureAuthenticated,
|
||||
} = require("./tree-shell-smoke-helpers");
|
||||
|
||||
const TASK = "task762-page-ai-board-first-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", "/snap/bin/chromium", "/usr/bin/chromium"]
|
||||
.find((candidate) => fs.existsSync(candidate));
|
||||
|
||||
function fileUrl(localPath) {
|
||||
return `file://${localPath}`;
|
||||
}
|
||||
|
||||
function localMdDocumentId(relativePath) {
|
||||
return `local-md:${relativePath.replaceAll("/", "~2F")}`;
|
||||
}
|
||||
|
||||
function writeWorkspaceManifest(root, ownerId, workspaceId) {
|
||||
fs.mkdirSync(path.join(root, ".mnote"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(root, ".mnote", "workspace.json"),
|
||||
`${JSON.stringify({
|
||||
workspaceId,
|
||||
ownerId,
|
||||
createdAt: new Date().toISOString(),
|
||||
capabilities: ["local_files", "ai_sessions", "markdown_edit"],
|
||||
}, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
function documentUrl(root, workspaceId, relativePath) {
|
||||
const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`);
|
||||
url.searchParams.set("sourceKind", "local_folder");
|
||||
url.searchParams.set("rootUri", fileUrl(root));
|
||||
url.searchParams.set("workspaceId", workspaceId);
|
||||
url.searchParams.set("treeView", "filetree");
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
async function waitForEditorText(page, expected) {
|
||||
await page.waitForFunction(
|
||||
(text) => {
|
||||
const editor = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"] .ProseMirror');
|
||||
return (editor?.textContent || "").includes(text);
|
||||
},
|
||||
expected,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
}
|
||||
|
||||
async function saveScreenshot(page, name) {
|
||||
const target = path.join(OUTPUT_DIR, `${name}.png`);
|
||||
await page.screenshot({ path: target, fullPage: true });
|
||||
return target;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
const suffix = Date.now().toString(36);
|
||||
const actorId = "mnote-e2e";
|
||||
const workspaceId = `local-ws:${actorId}:task762-${suffix}`;
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task762-board-ai-"));
|
||||
const rootUri = fileUrl(root);
|
||||
const relativePath = "BoardFirstPage.md";
|
||||
const documentId = localMdDocumentId(relativePath);
|
||||
const filePath = path.join(root, relativePath);
|
||||
const beforeToken = `BOARD_UI_EDIT_BEFORE_${suffix}`;
|
||||
const afterToken = `BOARD_UI_EDIT_AFTER_${suffix}`;
|
||||
const capturedBoardRuns = [];
|
||||
const capturedBoardRunResponses = [];
|
||||
const consoleErrors = [];
|
||||
let caughtError = null;
|
||||
|
||||
writeWorkspaceManifest(root, actorId, workspaceId);
|
||||
fs.writeFileSync(
|
||||
filePath,
|
||||
["# Board First Page AI", "", `页面段落:${beforeToken}`, ""].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
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",
|
||||
extraHTTPHeaders: {
|
||||
"x-mnote-actor-id": actorId,
|
||||
"x-mnote-actor-type": "user",
|
||||
},
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
page.on("console", (message) => {
|
||||
if (["error", "warning"].includes(message.type())) {
|
||||
consoleErrors.push({ type: message.type(), text: message.text() });
|
||||
}
|
||||
});
|
||||
page.on("request", (request) => {
|
||||
if (!request.url().includes("/api/page-ai/board/runs") || request.method() !== "POST") return;
|
||||
let body = null;
|
||||
try {
|
||||
body = JSON.parse(request.postData() || "{}");
|
||||
} catch {
|
||||
body = request.postData() || "";
|
||||
}
|
||||
capturedBoardRuns.push({ url: request.url(), method: request.method(), body });
|
||||
});
|
||||
|
||||
await page.route("**/api/page-ai/board/workers", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
schema: "agent_board.page_ai_route.v2",
|
||||
surface: "mnote-page-ai",
|
||||
workerPresetId: "mnote-page-ai-zcode",
|
||||
workerName: "MNote 页面 AI · ZCode",
|
||||
allowedWorkerPresetIds: ["mnote-page-ai-zcode"],
|
||||
modelOverride: "zcode-default",
|
||||
modelOptions: [
|
||||
{ id: "zcode-default", label: "默认", default: true },
|
||||
{ id: "zcode-fast", label: "快速" },
|
||||
{ id: "zcode-strong", label: "强力" },
|
||||
],
|
||||
workers: [{
|
||||
id: "mnote-page-ai-zcode",
|
||||
name: "MNote 页面 AI · ZCode",
|
||||
surface: "mnote-page-ai",
|
||||
agentType: "zcode",
|
||||
modelOptions: [
|
||||
{ id: "zcode-default", label: "默认", default: true },
|
||||
{ id: "zcode-fast", label: "快速" },
|
||||
{ id: "zcode-strong", label: "强力" },
|
||||
],
|
||||
}, {
|
||||
id: "qa-browser-worker",
|
||||
name: "QA Browser Worker",
|
||||
surface: "agent-board-console",
|
||||
role: "qa",
|
||||
}],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/page-ai/board/workflows", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
schema: "agent_board.page_ai_route.v2",
|
||||
surface: "mnote-page-ai",
|
||||
workflowId: "builtin-mnote-page-ai-chat",
|
||||
workflowName: "MNote 页面 AI",
|
||||
allowedWorkflowIds: ["builtin-mnote-page-ai-chat"],
|
||||
workflows: [{
|
||||
id: "builtin-mnote-page-ai-chat",
|
||||
name: "MNote 页面 AI",
|
||||
surface: "mnote-page-ai",
|
||||
}, {
|
||||
id: "general-hotfix-workflow",
|
||||
name: "通用 hotfix workflow",
|
||||
surface: "agent-board-console",
|
||||
}],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/page-ai/board/runs", async (route) => {
|
||||
if (route.request().method() !== "POST") return route.continue();
|
||||
const body = JSON.parse(route.request().postData() || "{}");
|
||||
capturedBoardRunResponses.push({ kind: "create", body });
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
schema: "mnote.page_ai_board_run.v1",
|
||||
surface: "mnote-page-ai",
|
||||
runId: `board-run-${suffix}`,
|
||||
workflowId: body.workflowId,
|
||||
workerPresetId: body.workerPresetId,
|
||||
modelOverride: body.modelOverride,
|
||||
board: { run: { id: `board-run-${suffix}`, status: "running" } },
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route(`**/api/page-ai/board/runs/board-run-${suffix}`, async (route) => {
|
||||
capturedBoardRunResponses.push({ kind: "get" });
|
||||
fs.writeFileSync(filePath, ["# Board First Page AI", "", `页面段落:${afterToken}`, ""].join("\n"), "utf8");
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
schema: "mnote.page_ai_board_run_status.v1",
|
||||
runId: `board-run-${suffix}`,
|
||||
board: {
|
||||
run: { id: `board-run-${suffix}`, status: "complete" },
|
||||
events: [
|
||||
{ type: "workflow.started", message: "run started", createdAt: new Date().toISOString() },
|
||||
{ type: "worker.completed", message: "file edited", createdAt: new Date().toISOString() },
|
||||
],
|
||||
},
|
||||
receipt: {
|
||||
schema: "agent_board.workflow_run_receipt.v2",
|
||||
runId: `board-run-${suffix}`,
|
||||
surface: "mnote-page-ai",
|
||||
workflowId: "builtin-mnote-page-ai-chat",
|
||||
workerPresetId: "mnote-page-ai-zcode",
|
||||
modelOverride: "zcode-fast",
|
||||
finalAnswer: `已把当前页面中的 ${beforeToken} 替换为 ${afterToken}。`,
|
||||
changedFiles: [{ path: filePath, status: "modified" }],
|
||||
verification: [{ command: "read file", status: "passed", output: afterToken }],
|
||||
remaining: [],
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
await page.route("**/api/user/access-policy**", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
controlPlane: "sqlite",
|
||||
grants: [{
|
||||
id: `grant_task762_${suffix}`,
|
||||
userId: actorId,
|
||||
workspaceId,
|
||||
rootUri,
|
||||
rootPath: root,
|
||||
permission: "write",
|
||||
recursive: true,
|
||||
capabilities: ["ai", "markdown_edit"],
|
||||
source: "smoke",
|
||||
status: "active",
|
||||
}],
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/ui/preferences**", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ ok: true, owner: "mnote-web", result: {} }),
|
||||
});
|
||||
});
|
||||
|
||||
await ensureAuthenticated(page, context.request);
|
||||
const response = await page.goto(documentUrl(root, workspaceId, relativePath), {
|
||||
waitUntil: "domcontentloaded",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
assert(response && response.status() === 200, `文档页状态码异常: ${response && response.status()}`);
|
||||
await page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"] .ProseMirror').first().waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
await waitForEditorText(page, beforeToken);
|
||||
await page.evaluate(() => localStorage.removeItem("mnote.page_ai.legacy_provider_mode"));
|
||||
|
||||
await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-testid="wolai-page-ai-drawer"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await page.locator("[data-page-ai-agent-button]").click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const drawerText = document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "";
|
||||
return drawerText.includes("MNote 页面 AI · ZCode") && !drawerText.includes("QA Browser Worker") && !drawerText.includes("通用 hotfix workflow");
|
||||
},
|
||||
null,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
const modelSelect = page.locator("[data-page-ai-board-model]");
|
||||
await modelSelect.selectOption("zcode-fast", { timeout: UI_TIMEOUT_MS });
|
||||
await page.keyboard.press("Escape").catch(() => undefined);
|
||||
await page.locator("[data-page-ai-input]").fill(
|
||||
`请编辑当前页面真实 Markdown 文件,把 ${beforeToken} 替换为 ${afterToken}。完成后说明 MNote capability 已加载。`,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
|
||||
await page.waitForFunction(
|
||||
(expected) => {
|
||||
const text = document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "";
|
||||
return text.includes("正在处理") || text.includes(expected);
|
||||
},
|
||||
afterToken,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const drawer = document.querySelector('[data-testid="wolai-page-ai-drawer"]');
|
||||
const subtitle = drawer?.querySelector('.wolai-page-ai-subtitle')?.textContent || "";
|
||||
const agentChip = drawer?.querySelector('[data-page-ai-agent-chip]')?.textContent || "";
|
||||
const hiddenLegacyTabs = Array.from(drawer?.querySelectorAll('[data-page-ai-tab="agent"], [data-page-ai-tab="reasonix-settings"], [data-page-ai-tab="hermes-settings"]') || [])
|
||||
.every((node) => node instanceof HTMLElement && node.hidden === true);
|
||||
return subtitle.includes("Agent Board") && agentChip.includes("MNote 页面 AI") && hiddenLegacyTabs;
|
||||
},
|
||||
null,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() => document.documentElement.getAttribute("data-mnote-page-ai-run-status") === "completed",
|
||||
null,
|
||||
{ timeout: Math.max(UI_TIMEOUT_MS, 180_000) },
|
||||
);
|
||||
await page.waitForFunction(
|
||||
(expected) => (document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "").includes(expected),
|
||||
afterToken,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
|
||||
const finalDiskContent = fs.readFileSync(filePath, "utf8");
|
||||
assert(finalDiskContent.includes(afterToken), "磁盘文件应包含 Board worker 写入后的标记");
|
||||
assert(!finalDiskContent.includes(beforeToken), "磁盘文件不应再包含旧标记");
|
||||
await waitForEditorText(page, afterToken);
|
||||
assert.strictEqual(capturedBoardRuns.length, 1, `应只创建一个 Board Page AI run,实际 ${capturedBoardRuns.length}`);
|
||||
const runBody = capturedBoardRuns[0].body;
|
||||
assert.strictEqual(runBody.workflowId, "builtin-mnote-page-ai-chat", "Page AI 应默认走 MNote 专用 Board workflow");
|
||||
assert.strictEqual(runBody.workerPresetId, "mnote-page-ai-zcode", "Page AI 应默认走 MNote 专用 ZCode worker");
|
||||
assert.strictEqual(runBody.modelOverride, "zcode-fast", "Page AI 应传递 worker 内模型档位");
|
||||
assert.strictEqual(runBody.envelope?.schema, "mnote.page_ai.board_task.v1", "run payload 应携带 Board envelope");
|
||||
assert.strictEqual(runBody.envelope?.modelOverride, "zcode-fast", "envelope 应记录模型档位");
|
||||
assert.strictEqual(runBody.envelope?.primaryTarget?.absolutePath, filePath, "primaryTarget 应指向当前真实 Markdown 文件");
|
||||
assert(Array.isArray(runBody.envelope?.capabilities), "envelope 应携带 MNote capabilities");
|
||||
assert(runBody.envelope.capabilities.includes("mnote.current_page.read"), "capabilities 应包含当前页读取能力");
|
||||
assert(runBody.envelope.capabilities.includes("mnote.local_file.receipt"), "capabilities 应包含本地文件收据能力");
|
||||
|
||||
const drawerText = await page.locator('[data-testid="wolai-page-ai-drawer"]').textContent({ timeout: UI_TIMEOUT_MS });
|
||||
assert(!drawerText.includes("Agent Board run 已创建"), "默认聊天面不应把 Board run 创建日志作为 assistant 气泡显示");
|
||||
assert(drawerText.includes(`已把当前页面中的 ${beforeToken} 替换为 ${afterToken}。`), "主聊天应展示 receipt.finalAnswer 自然回复");
|
||||
assert(!drawerText.includes("## Completed"), "主聊天不应展示 Board Completed 报告标题");
|
||||
await page.locator(`[data-page-ai-board-run-detail="board-run-${suffix}"]`).click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForFunction(
|
||||
(expectedPath) => {
|
||||
const text = document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "";
|
||||
return text.includes("Run detail") && text.includes("changed files") && text.includes(expectedPath) && text.includes("timeline");
|
||||
},
|
||||
filePath,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS });
|
||||
const restoredStorage = await page.evaluate(() => {
|
||||
const keys = Object.keys(window.localStorage).filter((key) => key.startsWith("hermes_page_ai_session:"));
|
||||
return JSON.stringify(keys.map((key) => ({ key, value: window.localStorage.getItem(key) || "" })));
|
||||
});
|
||||
if (!restoredStorage.includes(afterToken)) {
|
||||
throw new assert.AssertionError({
|
||||
message: `刷新后 localStorage 应保留 Board-first session/history: ${restoredStorage.slice(0, 1000)}`,
|
||||
});
|
||||
}
|
||||
await page.waitForFunction(
|
||||
(expected) => (document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "").includes(expected),
|
||||
afterToken,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
const screenshot = await saveScreenshot(page, "01-board-first-edit");
|
||||
const result = {
|
||||
ok: true,
|
||||
task: TASK,
|
||||
baseUrl: BASE_URL,
|
||||
root,
|
||||
rootUri,
|
||||
workspaceId,
|
||||
documentId,
|
||||
relativePath,
|
||||
filePath,
|
||||
beforeToken,
|
||||
afterToken,
|
||||
screenshot,
|
||||
capturedBoardRuns,
|
||||
capturedBoardRunResponses,
|
||||
drawerText,
|
||||
consoleErrors,
|
||||
finalDiskContent,
|
||||
};
|
||||
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} catch (error) {
|
||||
caughtError = error;
|
||||
await saveScreenshot(page, "failure").catch(() => undefined);
|
||||
fs.writeFileSync(
|
||||
path.join(OUTPUT_DIR, "failure.json"),
|
||||
`${JSON.stringify({
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.stack || error.message : String(error),
|
||||
capturedBoardRuns,
|
||||
consoleErrors,
|
||||
root,
|
||||
rootUri,
|
||||
filePath,
|
||||
diskContent: fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : "",
|
||||
}, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
} finally {
|
||||
await page.close().catch(() => undefined);
|
||||
await context.close().catch(() => undefined);
|
||||
await browser.close().catch(() => undefined);
|
||||
}
|
||||
|
||||
if (caughtError) {
|
||||
throw caughtError;
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
#!/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);
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env node
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const repoRoot = path.resolve(__dirname, '..');
|
||||
const runtimePath = path.join(repoRoot, 'rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js');
|
||||
const cssPath = path.join(repoRoot, 'rust/crates/mnote-web/src/ssr/styles/components/page-ai.css');
|
||||
const runtime = fs.readFileSync(runtimePath, 'utf8');
|
||||
const css = fs.readFileSync(cssPath, 'utf8');
|
||||
|
||||
const checks = [
|
||||
['opencode host switch', runtime.includes('mnote.page_ai.opencode_host')],
|
||||
['opencode status api', runtime.includes('/api/page-ai/opencode/status')],
|
||||
['opencode diff api', runtime.includes('/api/page-ai/opencode/diff?sessionId=')],
|
||||
['opencode event api', runtime.includes('/api/page-ai/opencode/events') && runtime.includes('new EventSource')],
|
||||
['opencode iframe route', runtime.includes('/page-ai/opencode/') && runtime.includes('src="about:blank"')],
|
||||
['persistent binding source', runtime.includes('/api/page-ai/opencode/session') && !runtime.includes('sessionStorage.setItem(storageKey')],
|
||||
['changed file opener', runtime.includes('__mnoteDocumentPaneRuntime.openResourceInActiveTab({ path: targetPath })')],
|
||||
['current page refresh', runtime.includes('__mnoteDocumentPaneRuntime.refreshPrimaryDocument({ reason: \'page-ai-opencode-host\' })')],
|
||||
['no interval polling', !/setInterval\s*\(/.test(runtime)],
|
||||
['opencode css scope', css.includes('[data-page-ai-opencode-host="true"]')],
|
||||
['opencode iframe css', css.includes('.wolai-page-ai-opencode-iframe')],
|
||||
];
|
||||
|
||||
const failed = checks.filter(([, ok]) => !ok);
|
||||
if (failed.length) {
|
||||
console.error('Page AI opencode host static smoke failed:');
|
||||
for (const [name] of failed) console.error(`- ${name}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('Page AI opencode host static smoke passed.');
|
||||
Reference in New Issue
Block a user