#!/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); }); }