Files
mnote/scripts/task490-runtime-surfaces-smoke.js
T

376 lines
15 KiB
JavaScript
Raw Normal View History

2026-05-26 07:08:26 +08:00
#!/usr/bin/env node
"use strict";
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { chromium } = require("playwright");
const OUTPUT_DIR = path.resolve(__dirname, "..", "tmp", "task490-runtime-surfaces-smoke");
const TASK = "task490-runtime-surfaces-smoke";
const TARGET_BLOCK_ID = "task490-runtime-surface-target";
function screenshotPath(name) {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
return path.join(OUTPUT_DIR, `${name}.png`);
}
function fileUrl(localPath) {
return `file://${localPath}`;
}
function localMdDocumentId(relativePath) {
return `local-md:${relativePath.replaceAll("/", "~2F")}`;
}
function documentUrl(baseUrl, root, relativePath) {
const url = new URL(`${baseUrl}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", fileUrl(root));
url.searchParams.set("treeView", "filetree");
return url.toString();
}
function writeWorkspaceManifest(root, ownerId) {
const metadataDir = path.join(root, ".mnote");
fs.mkdirSync(metadataDir, { recursive: true });
fs.writeFileSync(
path.join(metadataDir, "workspace.json"),
`${JSON.stringify({
workspaceId: `local-ws:${ownerId}:task490`,
ownerId,
createdAt: new Date().toISOString(),
capabilities: ["local_files", "markdown_edit", "asset_upload"],
}, null, 2)}\n`,
"utf8",
);
}
async function saveScreenshot(page, name) {
const target = screenshotPath(name);
await page.screenshot({ path: target, fullPage: false });
return target;
}
2026-06-25 21:08:17 +08:00
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(', ')}`);
}
}
2026-05-26 07:08:26 +08:00
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 });
const editor = page
.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror[contenteditable="true"]')
.first();
await editor.waitFor({ state: "visible", timeout: uiTimeoutMs });
await page.waitForFunction(
() => {
const host = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
const editorNode = host?.querySelector('.editor-surface .ProseMirror[contenteditable="true"]');
return host?.getAttribute("data-runtime-editor-status") !== "error"
&& editorNode instanceof HTMLElement
&& editorNode.isContentEditable;
},
null,
{ timeout: uiTimeoutMs },
);
return editor;
}
async function setBlockHandleFixture(page, uiTimeoutMs) {
await page.evaluate((targetBlockId) => {
const editor = document.querySelector(".editor-surface .ProseMirror")?.editor;
if (!editor) {
throw new Error("找不到 Tiptap editor");
}
editor.commands.setContent(
{
type: "doc",
content: [
{
type: "paragraph",
attrs: { blockId: targetBlockId },
content: [{ type: "text", text: "Task490 block handle target" }],
},
{
type: "paragraph",
attrs: { blockId: "task490-second-block" },
content: [{ type: "text", text: "Task490 slash target" }],
},
],
},
true,
);
editor.commands.focus("end");
}, TARGET_BLOCK_ID);
await page.waitForFunction(
(targetBlockId) => document.querySelector(`[data-block-id="${targetBlockId}"]`) instanceof HTMLElement,
TARGET_BLOCK_ID,
{ timeout: uiTimeoutMs },
);
}
async function closePageAiIfOpen(page) {
const closeButton = page.locator('[data-page-ai-action="close"]').first();
if (await closeButton.isVisible().catch(() => false)) {
2026-06-25 21:08:17 +08:00
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);
});
2026-05-26 07:08:26 +08:00
}
}
async function main() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
delete require.cache[require.resolve("./tree-shell-smoke-helpers")];
const {
BASE_URL,
UI_TIMEOUT_MS,
assert,
ensureAuthenticated,
} = require("./tree-shell-smoke-helpers");
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
viewport: { width: 1440, height: 960 },
locale: "zh-CN",
extraHTTPHeaders: {
"x-mnote-actor-id": "mnote-e2e",
"x-mnote-actor-type": "user",
},
});
const page = await context.newPage();
const screenshots = {};
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task490-"));
const relativePath = "RuntimeSurfaces.md";
2026-06-01 09:29:12 +08:00
const capturedPageAi = {
runs: [],
aborts: [],
};
2026-05-26 07:08:26 +08:00
writeWorkspaceManifest(root, "mnote-e2e");
fs.writeFileSync(
path.join(root, relativePath),
["# Runtime Surfaces", "", "Task490 initial paragraph", ""].join("\n"),
"utf8",
);
let caughtError = null;
try {
2026-06-01 09:29:12 +08:00
await page.route("**/api/hermes/client/gateway/health**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
gateway: { ok: true, status: "mocked" },
profile: { name: "reasonix", modelConfigured: true, apiKeyConfigured: true },
suggestions: [],
}),
});
});
await page.route("**/api/hermes/client/tools**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, tools: [] }),
});
});
await page.route("**/api/hermes/client/profiles**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
active: "mnoteai",
profiles: [{ name: "mnoteai", label: "MNote AI", modelConfigured: true, apiKeyConfigured: true }],
}),
});
});
await page.route("**/api/ai/agent-profiles**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
agentId: "reasonix",
profiles: [],
}),
});
});
await page.route("**/api/hermes/client/skills**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, categories: [], archived: [] }),
});
});
await page.route("**/api/hermes/client/capabilities**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, runtime: "mnote", categories: [], archived: [] }),
});
});
2026-06-01 09:29:12 +08:00
await page.route("**/api/hermes/client/sessions", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
sessionId: "mnote_task490",
title: "task490",
traceId: "trace_task490",
persistence: "local_ai_session_jsonl",
sessionStorage: "local_private",
}),
});
});
await page.route("**/api/hermes/client/runs", async (route) => {
capturedPageAi.runs.push(route.request().postData() || "");
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
sessionId: "mnote_task490",
runId: "run_task490_stop",
upstream: { run_id: "run_task490_stop", trace_id: "trace_task490_stop" },
traceId: "trace_task490_stop",
}),
});
});
await page.route("**/api/hermes/client/events/run_task490_stop", async (route) => {
2026-06-25 21:08:17 +08:00
await new Promise((resolve) => setTimeout(resolve, UI_TIMEOUT_MS * 2));
2026-06-01 09:29:12 +08:00
await route.fulfill({
status: 200,
headers: { "content-type": "text/event-stream; charset=utf-8" },
body: `data: ${JSON.stringify({ event: "message.delta", run_id: "run_task490_stop", delta: "Task490 streaming" })}\n\n`,
});
});
await page.route("**/api/hermes/client/runs/run_task490_stop/abort", async (route) => {
capturedPageAi.aborts.push(route.request().postData() || "");
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
status: "aborted",
runtime: { runId: "run_task490_stop", status: "aborted", queueLength: 0 },
events: [
{ event: "abort.started", runId: "run_task490_stop" },
{ event: "abort.completed", runId: "run_task490_stop" },
],
}),
});
});
2026-05-26 07:08:26 +08:00
await ensureAuthenticated(page, context.request);
const url = documentUrl(BASE_URL, root, relativePath);
const response = await page.goto(url, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
assert(response, "文档页没有返回响应");
assert(response.status() === 200, `文档页状态码异常: ${response.status()}`);
const editor = await waitForRuntimeIsland(page, UI_TIMEOUT_MS);
const moreButton = page.locator('[data-testid="wolai-page-settings-trigger"]').first();
await moreButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await moreButton.click({ timeout: UI_TIMEOUT_MS });
const settingsPopover = page.locator('[data-testid="wolai-page-settings-popover"]').first();
await settingsPopover.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const settingsText = (await settingsPopover.textContent()) || "";
assert(settingsText.includes("页面选项"), `页面设置 popover 未显示页面选项: ${settingsText}`);
screenshots.pageSettings = await saveScreenshot(page, "01-page-settings");
await page.keyboard.press("Escape").catch(() => undefined);
const aiButton = page.locator('[data-testid="wolai-floating-ai"]').first();
await aiButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
2026-07-13 09:00:19 +08:00
const floatingAiText = ((await aiButton.textContent()) || "").trim();
assert(floatingAiText === "π", `浮动 AI 入口应退役旧 OpenHub 前端并显示 Pi Lab π: ${floatingAiText}`);
2026-05-26 07:08:26 +08:00
await aiButton.click({ timeout: UI_TIMEOUT_MS });
2026-07-13 09:00:19 +08:00
const piDrawer = page.locator('[data-page-ai-pi-lab="drawer"]').first();
await piDrawer.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
assert(await page.locator("[data-page-ai-pi-lab-input]").first().isVisible(), "Pi Lab drawer 必须显示输入框");
const retiredFrontend = await page.evaluate(() => ({
legacyDrawerOpen: Boolean(document.querySelector('[data-testid="wolai-page-ai-drawer"]:not([hidden])')),
opencodeHost: Boolean(document.querySelector('[data-page-ai-opencode-host="true"]')),
opencodeIframe: Boolean(document.querySelector('[data-page-ai-opencode-iframe]')),
}));
assert(retiredFrontend.legacyDrawerOpen === false, "浮动 Pi 入口不能打开旧 Page AI/OpenHub drawer");
assert(retiredFrontend.opencodeHost === false, "浮动 Pi 入口不能打开 opencode/OpenHub host 前端");
assert(retiredFrontend.opencodeIframe === false, "浮动 Pi 入口不能保留 opencode/OpenHub iframe");
screenshots.pageAi = await saveScreenshot(page, "02-pi-lab");
await page.locator("[data-page-ai-pi-lab-close]").click({ timeout: UI_TIMEOUT_MS });
await piDrawer.waitFor({ state: "hidden", timeout: UI_TIMEOUT_MS });
2026-06-01 09:29:12 +08:00
await setBlockHandleFixture(page, UI_TIMEOUT_MS);
2026-05-26 07:08:26 +08:00
await editor.click({ timeout: UI_TIMEOUT_MS });
await page.keyboard.type("/");
const slashMenu = page.locator('[data-testid="mnote-leptos-tiptap-slash-menu"]').first();
await slashMenu.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const slashText = (await slashMenu.textContent()) || "";
assert(slashText.trim().length > 0, "slash menu 必须显示可选项");
2026-06-25 21:08:17 +08:00
await assertMaterialIconSurface(slashMenu, "slash menu", 4);
2026-05-26 07:08:26 +08:00
screenshots.slashMenu = await saveScreenshot(page, "03-slash-menu");
await page.keyboard.press("Escape");
const target = page.locator(`[data-block-id="${TARGET_BLOCK_ID}"]`).first();
await target.scrollIntoViewIfNeeded({ timeout: UI_TIMEOUT_MS });
const targetBox = await target.boundingBox();
assert(targetBox, "block handle 目标块缺少可测量区域");
await page.mouse.move(targetBox.x + 8, targetBox.y + Math.min(12, Math.max(4, targetBox.height / 2)));
const blockHandle = page.locator('[data-testid="block-drag-handle-trigger"]').first();
await blockHandle.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await blockHandle.click({ timeout: UI_TIMEOUT_MS });
const blockMenu = page.locator('[data-testid="block-drag-menu"]').first();
await blockMenu.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const blockMenuText = (await blockMenu.textContent()) || "";
assert(blockMenuText.includes("删除") || blockMenuText.includes("Delete"), `block handle menu 缺少基础操作: ${blockMenuText}`);
2026-06-25 21:08:17 +08:00
await assertMaterialIconSurface(blockMenu, "block handle menu", 8);
2026-05-26 07:08:26 +08:00
screenshots.blockHandleMenu = await saveScreenshot(page, "04-block-handle-menu");
const result = {
ok: true,
task: TASK,
baseUrl: BASE_URL,
root,
relativePath,
documentId: localMdDocumentId(relativePath),
screenshotDir: OUTPUT_DIR,
screenshots,
};
fs.writeFileSync(path.join(OUTPUT_DIR, "result.json"), `${JSON.stringify(result, null, 2)}\n`, "utf8");
console.log(JSON.stringify(result, null, 2));
} catch (error) {
caughtError = error;
await saveScreenshot(page, "failure").catch(() => undefined);
} finally {
fs.rmSync(root, { recursive: true, force: true });
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);
});
}