Files
mnote/scripts/task490-runtime-surfaces-smoke.js
T
2026-06-25 21:08:17 +08:00

417 lines
16 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 });
await aiButton.click({ timeout: UI_TIMEOUT_MS });
const aiDrawer = page.locator('[data-testid="wolai-page-ai-drawer"]').first();
await aiDrawer.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const aiText = (await aiDrawer.textContent()) || "";
assert(aiText.includes("Hermes"), `Page AI drawer 必须显示 Hermes 入口: ${aiText}`);
assert(await page.locator("[data-page-ai-input]").first().isVisible(), "Page AI drawer 必须显示输入框");
screenshots.pageAi = await saveScreenshot(page, "02-page-ai");
await closePageAiIfOpen(page);
await page.waitForFunction(
() => {
const drawer = document.querySelector('[data-testid="wolai-page-ai-drawer"]');
const trigger = document.querySelector('[data-testid="wolai-floating-ai"]');
return drawer instanceof HTMLElement
&& drawer.hidden === true
&& trigger instanceof HTMLElement
&& trigger.getAttribute("data-state") === "closed"
&& trigger.getAttribute("aria-expanded") === "false";
},
null,
{ timeout: UI_TIMEOUT_MS },
);
await aiButton.click({ timeout: UI_TIMEOUT_MS });
await aiDrawer.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator("[data-page-ai-input]").fill("Task490 stop smoke", { timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => document.documentElement.getAttribute("data-mnote-page-ai-run-status") === "running"
&& document.documentElement.getAttribute("data-mnote-page-ai-run-id") === "run_task490_stop",
null,
{ timeout: UI_TIMEOUT_MS },
);
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('.wolai-page-ai-stop[data-page-ai-action="stop-run"]');
return stop instanceof HTMLButtonElement && stop.disabled === false && stop.getAttribute("aria-disabled") === "false";
},
null,
{ timeout: UI_TIMEOUT_MS },
);
await stopButton.click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => document.documentElement.getAttribute("data-mnote-page-ai-run-status") === "aborted",
null,
{ timeout: UI_TIMEOUT_MS },
);
await page.locator('[data-page-ai-conversation]').getByText("已请求停止当前 AI run。").waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
assert(capturedPageAi.runs.length === 1, "Page AI stop smoke 应先发起一个 run");
assert(capturedPageAi.aborts.length === 1, "点击停止应调用 abort API 一次");
const abortBody = JSON.parse(capturedPageAi.aborts[0] || "{}");
assert(abortBody.reason === "page_ai_user_stop", "abort API 应记录用户停止原因");
screenshots.pageAiStopped = await saveScreenshot(page, "02b-page-ai-stopped");
await closePageAiIfOpen(page);
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);
});
}