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

376 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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;
}
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 });
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)) {
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);
});
}
}
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";
const capturedPageAi = {
runs: [],
aborts: [],
};
writeWorkspaceManifest(root, "mnote-e2e");
fs.writeFileSync(
path.join(root, relativePath),
["# Runtime Surfaces", "", "Task490 initial paragraph", ""].join("\n"),
"utf8",
);
let caughtError = null;
try {
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: [] }),
});
});
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) => {
await new Promise((resolve) => setTimeout(resolve, UI_TIMEOUT_MS * 2));
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" },
],
}),
});
});
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 });
const floatingAiText = ((await aiButton.textContent()) || "").trim();
assert(floatingAiText === "π", `浮动 AI 入口应退役旧 OpenHub 前端并显示 Pi Lab π: ${floatingAiText}`);
await aiButton.click({ timeout: UI_TIMEOUT_MS });
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 });
await setBlockHandleFixture(page, UI_TIMEOUT_MS);
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 必须显示可选项");
await assertMaterialIconSurface(slashMenu, "slash menu", 4);
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}`);
await assertMaterialIconSurface(blockMenu, "block handle menu", 8);
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);
});
}