"use strict"; const { loginViaAuthForm } = require('./lib/browser-auth-login'); 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", "task435-local-folder-watch-no-reload-smoke"); const RESULT_PATH = path.join(OUTPUT_DIR, "result.json"); 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 assert(condition, message) { if (!condition) { throw new Error(message); } } 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 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}:task435`, ownerId, createdAt: new Date().toISOString(), capabilities: ["local_files", "markdown_edit"], }, null, 2)}\n`, "utf8", ); } async function quickLogin(page) { // 7-76 P0: 标准表单登录(无测试快速登录按钮) const base = (typeof BASE_URL !== "undefined" && BASE_URL) || (typeof baseUrl !== "undefined" && baseUrl) || process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000"; const timeout = (typeof UI_TIMEOUT_MS !== "undefined" && UI_TIMEOUT_MS) || (typeof TIMEOUT !== "undefined" && TIMEOUT) || 30_000; if (!String(page.url() || "").includes("/auth")) { await page.goto(String(base).replace(/\/+$/, "") + "/auth", { waitUntil: "commit", timeout, }); } await loginViaAuthForm(page, { baseUrl: base, timeoutMs: timeout, gotoAuth: false, }); await page .waitForURL((url) => !String(url).includes("/auth"), { timeout }) .catch(() => {}); } async function waitForVisibleText(page, text) { await page.waitForFunction( (expectedText) => Array.from(document.querySelectorAll("body *")).some((element) => { const textContent = element.textContent ? element.textContent.trim() : ""; if (textContent !== expectedText) return false; const style = window.getComputedStyle(element); const rect = element.getBoundingClientRect(); return style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0; }), text, { timeout: UI_TIMEOUT_MS }, ); } async function waitForAnyPageTreeNode(page, documentIdPrefix) { await page.waitForFunction( (prefix) => Array.from(document.querySelectorAll("#sidebar-tree-root .tree-row[data-node-id]")) .some((row) => { const id = row.getAttribute("data-node-id") || ""; return id.startsWith(prefix); }), documentIdPrefix, { timeout: UI_TIMEOUT_MS }, ); } async function waitForGone(page, selector) { await page.locator(selector).waitFor({ state: "detached", timeout: UI_TIMEOUT_MS }); } async function waitForFileTreeRow(page, rowId) { await page.locator(`.tree-row[data-row-id="${rowId}"]`).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS, }); } async function expandFileTreeFolder(page, rowId) { const rowSelector = `.tree-row[data-row-id="${rowId}"]`; await page.locator(rowSelector).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await page.waitForFunction( (selector) => { const row = document.querySelector(selector); return !!row; }, rowSelector, { timeout: UI_TIMEOUT_MS }, ); const expanded = await page.locator(rowSelector).getAttribute("aria-expanded").catch(() => null); if (expanded === "true") return; await page.locator(`${rowSelector} [data-rust-action="toggle"]`).click({ timeout: UI_TIMEOUT_MS }); await page.waitForFunction( (selector) => { const row = document.querySelector(selector); return row && row.getAttribute("aria-expanded") === "true"; }, rowSelector, { timeout: UI_TIMEOUT_MS }, ); } async function waitForFileTreeRowGone(page, rowId) { await waitForGone(page, `.tree-row[data-row-id="${rowId}"]`); } 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 waitForPageTreeNodeGone(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 waitForLocalFolderWatchProjectionApplied(page) { await page.waitForFunction( () => { const value = document.documentElement.getAttribute("data-mnote-local-folder-watch-applied") || ""; return value === "projection" || value === "watch_batch"; }, { timeout: UI_TIMEOUT_MS }, ); const appliedValue = await page.evaluate( () => document.documentElement.getAttribute("data-mnote-local-folder-watch-applied"), ); assert( appliedValue === "projection" || appliedValue === "watch_batch", `local_folder 变更应通过事件驱动 projection/watch_batch 应用,实际 data-mnote-local-folder-watch-applied=${appliedValue}`, ); } async function runStep(page, label, navigationEvents, action) { const before = navigationEvents.length; await page.evaluate(() => { document.documentElement.removeAttribute("data-mnote-local-folder-watch-applied"); }); await action(); const after = navigationEvents.length; assert(after === before, `${label} 不应触发浏览器导航或 reload,before=${before} after=${after}`); return { label, navigationEventsBefore: before, navigationEventsAfter: after }; } async function run() { fs.mkdirSync(OUTPUT_DIR, { recursive: true }); const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-local-watch-no-reload-")); 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"); fs.writeFileSync(path.join(root, "docs", "stable-asset.txt"), "stable asset", "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 steps = []; try { await quickLogin(page); await page.goto(treeUrl(root, "page"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); await waitForAnyPageTreeNode(page, "local-md:"); await waitForPageTreeNode(page, localMdDocumentId("docs/stable.md")); await page.waitForFunction( () => { const transport = document.documentElement.getAttribute("data-mnote-tree-live-transport") || ""; return transport === "local-folder-events"; }, { timeout: UI_TIMEOUT_MS }, ); await page.waitForTimeout(100); navigationEvents.length = 0; steps.push(await runStep(page, "Markdown 外部创建后 page tree 原地更新", navigationEvents, async () => { fs.writeFileSync(path.join(root, "docs", "watcher-create.md"), "# Watcher Create\n", "utf8"); await waitForPageTreeNode(page, localMdDocumentId("docs/watcher-create.md")); await waitForLocalFolderWatchProjectionApplied(page); })); steps.push(await runStep(page, "Markdown 外部重命名后 page tree 原地更新", navigationEvents, async () => { fs.renameSync( path.join(root, "docs", "watcher-create.md"), path.join(root, "docs", "watcher-renamed.md"), ); await waitForPageTreeNode(page, localMdDocumentId("docs/watcher-renamed.md")); await waitForPageTreeNodeGone(page, localMdDocumentId("docs/watcher-create.md")); await waitForLocalFolderWatchProjectionApplied(page); })); steps.push(await runStep(page, "Markdown 外部删除后 page tree 原地更新", navigationEvents, async () => { fs.rmSync(path.join(root, "docs", "watcher-renamed.md")); await waitForPageTreeNodeGone(page, localMdDocumentId("docs/watcher-renamed.md")); await waitForLocalFolderWatchProjectionApplied(page); })); await page.goto(treeUrl(root, "filetree"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); await waitForFileTreeRow(page, "local:folder:docs"); await expandFileTreeFolder(page, "local:folder:docs"); await waitForFileTreeRow(page, "local:asset:docs/stable-asset.txt"); await page.waitForFunction( () => { const transport = document.documentElement.getAttribute("data-mnote-tree-live-transport") || ""; return transport === "local-folder-events"; }, { timeout: UI_TIMEOUT_MS }, ); await page.waitForTimeout(100); navigationEvents.length = 0; steps.push(await runStep(page, "Markdown 外部创建后 filetree 原地更新", navigationEvents, async () => { fs.writeFileSync(path.join(root, "docs", "watcher-filetree-md.md"), "# Watcher Filetree Markdown\n", "utf8"); await waitForFileTreeRow(page, "local:markdown:docs/watcher-filetree-md.md"); await waitForLocalFolderWatchProjectionApplied(page); })); steps.push(await runStep(page, "Markdown 外部重命名后 filetree 原地更新", navigationEvents, async () => { fs.renameSync( path.join(root, "docs", "watcher-filetree-md.md"), path.join(root, "docs", "watcher-filetree-md-renamed.md"), ); await waitForFileTreeRow(page, "local:markdown:docs/watcher-filetree-md-renamed.md"); await waitForFileTreeRowGone(page, "local:markdown:docs/watcher-filetree-md.md"); await waitForLocalFolderWatchProjectionApplied(page); })); steps.push(await runStep(page, "Markdown 外部删除后 filetree 原地更新", navigationEvents, async () => { fs.rmSync(path.join(root, "docs", "watcher-filetree-md-renamed.md")); await waitForFileTreeRowGone(page, "local:markdown:docs/watcher-filetree-md-renamed.md"); await waitForLocalFolderWatchProjectionApplied(page); })); steps.push(await runStep(page, "非 md 资源外部创建后 filetree 原地更新", navigationEvents, async () => { fs.writeFileSync(path.join(root, "docs", "watcher-asset.txt"), "watcher asset", "utf8"); await waitForFileTreeRow(page, "local:asset:docs/watcher-asset.txt"); await waitForLocalFolderWatchProjectionApplied(page); })); steps.push(await runStep(page, "非 md 资源外部重命名后 filetree 原地更新", navigationEvents, async () => { fs.renameSync( path.join(root, "docs", "watcher-asset.txt"), path.join(root, "docs", "watcher-asset-renamed.txt"), ); await waitForFileTreeRow(page, "local:asset:docs/watcher-asset-renamed.txt"); await waitForFileTreeRowGone(page, "local:asset:docs/watcher-asset.txt"); await waitForLocalFolderWatchProjectionApplied(page); })); steps.push(await runStep(page, "非 md 资源外部删除后 filetree 原地更新", navigationEvents, async () => { fs.rmSync(path.join(root, "docs", "watcher-asset-renamed.txt")); await waitForFileTreeRowGone(page, "local:asset:docs/watcher-asset-renamed.txt"); await waitForLocalFolderWatchProjectionApplied(page); })); steps.push(await runStep(page, "第二类非 md 资源外部创建后 filetree 原地更新", navigationEvents, async () => { fs.writeFileSync(path.join(root, "docs", "watcher-image.png"), "png", "utf8"); await waitForFileTreeRow(page, "local:asset:docs/watcher-image.png"); await waitForLocalFolderWatchProjectionApplied(page); })); steps.push(await runStep(page, "第二类非 md 资源外部重命名后 filetree 原地更新", navigationEvents, async () => { fs.renameSync( path.join(root, "docs", "watcher-image.png"), path.join(root, "docs", "watcher-image-renamed.png"), ); await waitForFileTreeRow(page, "local:asset:docs/watcher-image-renamed.png"); await waitForFileTreeRowGone(page, "local:asset:docs/watcher-image.png"); await waitForLocalFolderWatchProjectionApplied(page); })); steps.push(await runStep(page, "第二类非 md 资源外部删除后 filetree 原地更新", navigationEvents, async () => { fs.rmSync(path.join(root, "docs", "watcher-image-renamed.png")); await waitForFileTreeRowGone(page, "local:asset:docs/watcher-image-renamed.png"); await waitForLocalFolderWatchProjectionApplied(page); })); const result = { ok: true, baseUrl: BASE_URL, root, steps, navigationEvents, }; fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8"); console.log(`task435 local folder watcher no-reload smoke passed: ${RESULT_PATH}`); } finally { await browser.close().catch(() => {}); fs.rmSync(root, { recursive: true, force: true }); } } 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(1); });