#!/usr/bin/env node "use strict"; /** * task487 — Local Folder Tree Live Consumer smoke * * STATUS: 🔴 RED (预期失败) * * 本 smoke 验证 local_folder 文档页的 Tree Live Consumer 收敛目标: * 1. data-mnote-tree-live-transport 不再等于 "local-folder-static"(应改为 * "local-folder-events" 或等价 tree live transport) * 2. 外部文件变化后,Sidebar / FileTree 刷新通过 tree live consumer 完成 * (data-mnote-tree-live-applied 为 "snapshot" / "resync") * 3. data-mnote-local-folder-watch-applied 不再是唯一刷新证据 * * 当前为 RED:Workers A(后端 local_folder tree live event stream)和 * Worker B(前端 tree live controller 接入 local_folder)尚未完成。 * 断言 1~3 当前均预期失败。——本 smoke 用于验收,直至三个断言全 PASS * 后收敛 checklist 才能归档。 */ const fs = require("fs"); const os = require("os"); const path = require("path"); const { chromium } = require("playwright"); const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, ""); const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000); const OUTPUT_DIR = path.join(process.cwd(), "tmp", "task487-local-folder-tree-live-consumer-smoke"); const RESULT_PATH = path.join(OUTPUT_DIR, "result.json"); const SCREENSHOT_BEFORE_PATH = path.join(OUTPUT_DIR, "page-tree-before-create.png"); const SCREENSHOT_AFTER_PATH = path.join(OUTPUT_DIR, "page-tree-after-create.png"); const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium"] .find((candidate) => fs.existsSync(candidate)); function fileUrl(localPath) { return `file://${localPath}`; } function treeUrl(root, mode) { const url = new URL(`${BASE_URL}/`); url.searchParams.set("treeView", mode); url.searchParams.set("sourceKind", "local_folder"); url.searchParams.set("rootUri", fileUrl(root)); return url.toString(); } function documentUrl(root, relativePath, mode) { const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`); url.searchParams.set("sourceKind", "local_folder"); url.searchParams.set("rootUri", fileUrl(root)); if (mode) url.searchParams.set("treeView", mode); return url.toString(); } function localMdDocumentId(relativePath) { return `local-md:${relativePath.replaceAll("/", "~2F")}`; } 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}:task487`, ownerId, createdAt: new Date().toISOString(), capabilities: ["local_files", "markdown_edit"], }, null, 2)}\n`, "utf8", ); } /** * 读取 上的所有 data-mnote-tree-live-* 属性和 * data-mnote-local-folder-watch-applied,返回一个状态对象。 */ async function readLiveDomState(page) { return page.evaluate(() => { const html = document.documentElement; return { treeLiveTransport: html.getAttribute("data-mnote-tree-live-transport") || "", treeLiveStatus: html.getAttribute("data-mnote-tree-live-status") || "", treeLiveApplied: html.getAttribute("data-mnote-tree-live-applied") || "", treeLiveRevision: html.getAttribute("data-mnote-tree-live-revision") || "", treeLiveApplyError: html.getAttribute("data-mnote-tree-live-apply-error") || "", localFolderWatchApplied: html.getAttribute("data-mnote-local-folder-watch-applied") || "", url: window.location.href, }; }); } /** * 断言 helper:检查条件,返回 { pass, expected, actual, label } */ function check(label, condition, expected, actual) { return { label, pass: !!condition, expected: String(expected), actual: String(actual), }; } /** * 等待 page tree row 出现(基于 task435 模式) */ async function waitForPageTreeNode(page, documentId) { await page.waitForFunction( (expectedDocumentId) => Array.from(document.querySelectorAll("#sidebar-tree-root .tree-row[data-node-id]")) .some((row) => row.getAttribute("data-node-id") === expectedDocumentId), documentId, { timeout: UI_TIMEOUT_MS }, ); } async function run() { fs.mkdirSync(OUTPUT_DIR, { recursive: true }); const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-tree-live-consumer-")); fs.mkdirSync(path.join(root, "docs"), { recursive: true }); writeWorkspaceManifest(root, "user_real"); fs.writeFileSync(path.join(root, "README.md"), "# Local Root\n", "utf8"); fs.writeFileSync(path.join(root, "docs", "stable.md"), "# Stable Page\n", "utf8"); const browser = await chromium.launch({ headless: true, ...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}), }); const context = await browser.newContext({ viewport: { width: 1280, height: 860 }, extraHTTPHeaders: { "x-mnote-actor-id": "user_real", "x-mnote-actor-type": "user", }, }); const page = await context.newPage(); const navigationEvents = []; page.on("framenavigated", (frame) => { if (frame === page.mainFrame()) { navigationEvents.push({ url: frame.url(), timestamp: Date.now() }); } }); const assertions = []; let domStateBefore = null; let domStateAfterCreate = null; let domStateAfterDelete = null; let fatalError = null; try { // ── 登录 ── await page.goto(`${BASE_URL}/auth`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); const quickLoginButton = page.getByRole("button", { name: "测试账号快速登录" }); if (await quickLoginButton.count()) { await quickLoginButton.click({ timeout: UI_TIMEOUT_MS }); await page.waitForURL((url) => url.pathname === "/", { timeout: UI_TIMEOUT_MS }); } // ── Phase 1: Page Tree 视图 ── await page.goto(documentUrl(root, "README.md", "page"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); // 等待页面稳定 await page.waitForFunction( () => { const html = document.documentElement; const transport = html.getAttribute("data-mnote-tree-live-transport") || ""; return transport !== ""; }, { timeout: UI_TIMEOUT_MS }, ).catch(() => {}); await page.waitForTimeout(300); navigationEvents.length = 0; // 记录加载后 DOM 状态 domStateBefore = await readLiveDomState(page); // 断言 1: transport 不应为 local-folder-static assertions.push(check( "data-mnote-tree-live-transport !== 'local-folder-static'", domStateBefore.treeLiveTransport !== "local-folder-static", '非 local-folder-static(期望 "local-folder-events" 或等价)', domStateBefore.treeLiveTransport, )); // 断言 2: status 不应为 static assertions.push(check( "data-mnote-tree-live-status 不为 static", domStateBefore.treeLiveStatus !== "static", "connected / connecting / disabled 等非 static 值", domStateBefore.treeLiveStatus, )); // 断言 3: 有 transport 属性(不是空) assertions.push(check( "data-mnote-tree-live-transport 非空", domStateBefore.treeLiveTransport !== "", "非空字符串", domStateBefore.treeLiveTransport, )); // 截图:page tree 加载后状态 await page.screenshot({ path: SCREENSHOT_BEFORE_PATH, fullPage: false }); // 断言 4: 外部文件创建后,tree live applied 应为 snapshot 或 resync // 先确认页面稳定 await page.waitForTimeout(200); domStateBefore = await readLiveDomState(page); // 创建外部文件 const createdFileRel = "docs/live-consumer-test.md"; fs.writeFileSync(path.join(root, createdFileRel), "# Live Consumer Test\n", "utf8"); // 等待 page tree row 出现(当前通过 polling 机制) await waitForPageTreeNode(page, localMdDocumentId(createdFileRel)).catch(() => {}); // 等待一小段时间让 tree live consumer 有机会触发 await page.waitForTimeout(400); domStateAfterCreate = await readLiveDomState(page); // 断言 4: applied 应为 snapshot 或 resync const appliedOk = domStateAfterCreate.treeLiveApplied === "snapshot" || domStateAfterCreate.treeLiveApplied === "resync"; assertions.push(check( "外部创建后 data-mnote-tree-live-applied 为 snapshot|resync", appliedOk, "snapshot 或 resync", domStateAfterCreate.treeLiveApplied, )); // 断言 5: watch-applied 不再是唯一证据 // 即 treeLiveApplied 存在(非空)或 transport 不是 local-folder-static // 如果 watch-applied 是唯一标记而 treeLiveApplied 为空,则不合格 const hasTreeLiveEvidence = domStateAfterCreate.treeLiveApplied !== "" || domStateAfterCreate.treeLiveTransport !== "local-folder-static"; assertions.push(check( "存在 tree live consumer 证据(非仅 local-folder-watch-applied)", hasTreeLiveEvidence, "treeLiveApplied 非空 或 transport !== local-folder-static", `treeLiveApplied="${domStateAfterCreate.treeLiveApplied}" transport="${domStateAfterCreate.treeLiveTransport}"`, )); // 断言 6: 外部文件删除后同样有 tree live applied 痕迹 fs.rmSync(path.join(root, createdFileRel)); await page.waitForFunction( (expectedId) => !Array.from(document.querySelectorAll("#sidebar-tree-root .tree-row[data-node-id]")) .some((row) => row.getAttribute("data-node-id") === expectedId), localMdDocumentId(createdFileRel), { timeout: UI_TIMEOUT_MS }, ).catch(() => {}); await page.waitForTimeout(400); domStateAfterDelete = await readLiveDomState(page); const appliedDelOk = domStateAfterDelete.treeLiveApplied === "snapshot" || domStateAfterDelete.treeLiveApplied === "resync"; assertions.push(check( "外部删除后 data-mnote-tree-live-applied 为 snapshot|resync", appliedDelOk, "snapshot 或 resync", domStateAfterDelete.treeLiveApplied, )); // 截图:外部创建后 await page.screenshot({ path: SCREENSHOT_AFTER_PATH, fullPage: false }); // ── Phase 2: FileTree 视图 ── await page.goto(documentUrl(root, "README.md", "filetree"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); await page.waitForFunction( () => { const html = document.documentElement; const transport = html.getAttribute("data-mnote-tree-live-transport") || ""; return transport !== ""; }, { timeout: UI_TIMEOUT_MS }, ).catch(() => {}); await page.waitForTimeout(300); navigationEvents.length = 0; const filetreeState = await readLiveDomState(page); assertions.push(check( "FileTree: transport !== 'local-folder-static'", filetreeState.treeLiveTransport !== "local-folder-static", "非 local-folder-static", filetreeState.treeLiveTransport, )); assertions.push(check( "FileTree: status 不为 static", filetreeState.treeLiveStatus !== "static", "非 static", filetreeState.treeLiveStatus, )); // FileTree 外部创建 fs.writeFileSync(path.join(root, "docs", "filetree-live-test.md"), "# FileTree Live\n", "utf8"); // 等待 filetree row 出现(与 task435 同模式) await page.locator(`.tree-row[data-row-id="local:markdown:docs/filetree-live-test.md"]`).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS, }).catch(() => {}); await page.waitForTimeout(400); const filetreeAfterCreate = await readLiveDomState(page); assertions.push(check( "FileTree 外部创建后 tree-live-applied 为 snapshot|resync", filetreeAfterCreate.treeLiveApplied === "snapshot" || filetreeAfterCreate.treeLiveApplied === "resync", "snapshot 或 resync", filetreeAfterCreate.treeLiveApplied, )); } catch (err) { fatalError = err && err.stack ? err.stack : String(err); } finally { // 不论是否异常,都输出结果 const allPassed = assertions.every((a) => a.pass); const result = { ok: allPassed, red: !allPassed, baseUrl: BASE_URL, outputDir: OUTPUT_DIR, summary: allPassed ? "✅ 所有断言通过 — local_folder 已收敛到 tree live consumer" : "🔴 RED — 一个或多个断言失败,local_folder 仍使用 local-folder-static / polling 主链", assertions, domStateBefore, domStateAfterCreate, domStateAfterDelete, navigationEvents: navigationEvents.slice(0, 20), screenshots: { pageTreeLoaded: SCREENSHOT_BEFORE_PATH, afterExternalCreate: SCREENSHOT_AFTER_PATH, }, fatalError, timestamp: new Date().toISOString(), }; fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8"); console.log(`task487 结果: ${RESULT_PATH}`); console.log(JSON.stringify({ ok: result.ok, red: result.red, assertionSummary: assertions.map((a) => ({ label: a.label, pass: a.pass, actual: a.actual, })) }, null, 2)); await browser.close().catch(() => {}); fs.rmSync(root, { recursive: true, force: true }); // 退出码:RED smoke 不 process.exit(1),让 CI 可收集失败证据 if (!allPassed) { console.error("🔴 RED smoke — 预期失败(Workers A+B 尚未完成)"); process.exit(0); // RED smoke 退出 0 以便 CI 管道记录而非阻断 } } } run().catch((error) => { const result = { ok: false, baseUrl: BASE_URL, error: error && error.stack ? error.stack : String(error), }; fs.mkdirSync(OUTPUT_DIR, { recursive: true }); fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8"); console.error(error); process.exit(0); // RED smoke 不阻断 CI });