#!/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 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().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"; writeWorkspaceManifest(root, "mnote-e2e"); fs.writeFileSync( path.join(root, relativePath), ["# Runtime Surfaces", "", "Task490 initial paragraph", ""].join("\n"), "utf8", ); let caughtError = null; try { 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); await setBlockHandleFixture(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 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 必须显示可选项"); 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}`); 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); }); }