#!/usr/bin/env node "use strict"; const assert = require("node:assert/strict"); const fs = require("node:fs/promises"); const path = require("node:path"); const { chromium } = require("playwright"); const TASK = "task448-tree-resync-recovery-dual-browser-smoke"; const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, ""); const AUTH_BASE_URL = (process.env.MNOTE_AUTH_BASE_URL || BASE_URL).replace(/\/+$/, ""); const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 40_000); const OUTPUT_DIR = path.join(process.cwd(), "tmp", "tree-live-cache-smoke", "20260516-task448-resync"); const RESULT_PATH = path.join(OUTPUT_DIR, "result.json"); const SCREENSHOT_B_DOCUMENT = path.join(OUTPUT_DIR, "b-document-after-resync.png"); const SCREENSHOT_B_FILETREE = path.join(OUTPUT_DIR, "b-filetree-after-resync.png"); const e2ePassword = () => process.env.MNOTE_E2E_PASSWORD || ["Mnote", "E2E", "123!"].join(""); function cssString(value) { return String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"'); } async function writeResult(payload) { await fs.mkdir(OUTPUT_DIR, { recursive: true }); await fs.writeFile(RESULT_PATH, `${JSON.stringify({ ...payload, resultPath: RESULT_PATH }, null, 2)}\n`, "utf8"); } async function requestJson(request, baseUrl, requestPath, init = {}) { const response = await request.fetch(`${baseUrl}${requestPath}`, { ...init, headers: { ...(init.data !== undefined ? { "content-type": "application/json" } : {}), ...(init.headers || {}), }, timeout: 20_000, }); const text = await response.text(); let payload = null; try { payload = text ? JSON.parse(text) : null; } catch { payload = text; } if (!response.ok()) { throw new Error(`${requestPath} 请求失败: ${response.status()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`); } return payload; } async function authenticate(request, email, name) { await requestJson(request, AUTH_BASE_URL, "/api/auth", { method: "POST", data: { action: "auth:signIn", args: { provider: "password", params: { email, password: e2ePassword(), flow: "signUp", name }, }, }, }); } async function createPage(request, workspaceId, title, parentId = null) { const payload = await requestJson(request, BASE_URL, "/api/tree/commands", { method: "POST", data: { action: "create", workspaceId, parentId, title }, }); const result = payload.result || payload; const documentId = result.documentId || payload.documentId || result.id || ""; const resolvedWorkspaceId = result.workspaceId || payload.workspaceId || payload.workspace_id || workspaceId || ""; assert(documentId, `创建页面失败: ${JSON.stringify(payload)}`); assert(resolvedWorkspaceId, `创建页面缺少 workspaceId: ${JSON.stringify(payload)}`); return { documentId, workspaceId: resolvedWorkspaceId, title, payload }; } async function treeCommand(request, workspaceId, action, documentId, extra = {}) { return await requestJson(request, BASE_URL, "/api/tree/commands", { method: "POST", data: { action, workspaceId, documentId, ...extra }, }); } async function emptyDocumentTrash(request, workspaceId) { return await requestJson(request, BASE_URL, "/api/documents/empty-trash", { method: "POST", data: { workspaceId }, }); } async function cleanup(request, workspaceId, documentIds) { if (!workspaceId) return; for (const documentId of documentIds.filter(Boolean)) { await treeCommand(request, workspaceId, "archive", documentId).catch(() => null); } await emptyDocumentTrash(request, workspaceId).catch(() => null); } async function installSlowTreeEventSource(page, pollMs) { await page.addInitScript((value) => { const OriginalEventSource = window.EventSource; if (typeof OriginalEventSource !== "function" || window.__MNOTE_TASK448_PATCHED_EVENTSOURCE__) return; window.__MNOTE_TASK448_PATCHED_EVENTSOURCE__ = true; window.EventSource = function patchedEventSource(input, init) { try { const url = new URL(String(input), window.location.href); if (url.pathname === "/api/tree/events") { url.searchParams.set("pollMs", String(value)); return new OriginalEventSource(url.toString(), init); } } catch (_) { } return new OriginalEventSource(input, init); }; window.EventSource.prototype = OriginalEventSource.prototype; }, pollMs); } async function installTreeEventRecorder(page, label, requests) { await page.addInitScript(() => { window.__MNOTE_TASK448_TREE_EVENTS__ = []; const record = (name, event) => { const detail = event && event.detail ? event.detail : {}; const payload = detail.payload || detail || {}; let raw = ""; try { raw = JSON.stringify(payload); } catch { raw = ""; } window.__MNOTE_TASK448_TREE_EVENTS__.push({ name, at: Date.now(), revision: detail.revision || payload.revision || payload.cursor || "", kind: payload.kind || "", op: payload && payload.data && payload.data.op ? payload.data.op : payload.op || "", raw: raw.slice(0, 2400), }); }; window.addEventListener("tree:snapshot", (event) => record("tree:snapshot", event)); window.addEventListener("tree:delta", (event) => record("tree:delta", event)); window.addEventListener("tree:resync", (event) => record("tree:resync", event)); }); page.on("request", (request) => { const url = request.url(); if (url.includes("/api/tree/events")) { requests.push({ label, type: "request", method: request.method(), url, at: Date.now() }); } }); page.on("requestfailed", (request) => { const url = request.url(); if (url.includes("/api/tree/events")) { requests.push({ label, type: "requestfailed", method: request.method(), url, failure: request.failure()?.errorText || "", at: Date.now(), }); } }); } function recordNavigation(page, label, records) { page.on("framenavigated", (frame) => { if (frame === page.mainFrame()) { records.push({ label, url: frame.url(), at: Date.now() }); } }); } async function openDocument(page, workspaceId, documentId) { await page.goto(`${BASE_URL}/documents/${encodeURIComponent(documentId)}?workspaceId=${encodeURIComponent(workspaceId)}`, { waitUntil: "commit", timeout: UI_TIMEOUT_MS, }); await page.locator(`[data-page-title-input="true"][data-document-id="${cssString(documentId)}"]`).first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS, }); } async function openFileTree(page, workspaceId, documentId) { await openDocument(page, workspaceId, documentId); await page.evaluate(() => { const visible = (node) => node instanceof HTMLElement && !node.hidden && getComputedStyle(node).display !== "none" && getComputedStyle(node).visibility !== "hidden" && node.getClientRects().length > 0; const fileRoot = document.getElementById("sidebar-file-tree-root"); if (visible(fileRoot)) return; const tab = document.querySelector('[data-mnote-sidebar-tree-tab="filetree"], button[aria-label="文件"]'); if (tab instanceof HTMLElement) tab.click(); }); await page.locator("#sidebar-file-tree-root").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); } async function waitForRows(page, documentIds) { await page.waitForFunction( (ids) => ids.every( (id) => document.querySelector(`#sidebar-tree-root .tree-row[data-shell-mode="page"][data-node-id="${CSS.escape(id)}"]`) && document.querySelector(`#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="${CSS.escape(`doc:${id}`)}"]`), ), documentIds, { timeout: UI_TIMEOUT_MS }, ); } async function waitForResyncWithRows(page, documentIds) { await page.waitForFunction( (ids) => { const events = Array.isArray(window.__MNOTE_TASK448_TREE_EVENTS__) ? window.__MNOTE_TASK448_TREE_EVENTS__ : []; const hasResync = events.some((event) => event && event.name === "tree:resync"); if (!hasResync) return false; const applied = document.documentElement.getAttribute("data-mnote-tree-live-applied") || ""; const error = document.documentElement.getAttribute("data-mnote-tree-live-apply-error") || ""; if (applied !== "resync" || error) return false; return ids.every( (id) => document.querySelector(`#sidebar-tree-root .tree-row[data-shell-mode="page"][data-node-id="${CSS.escape(id)}"]`) && document.querySelector(`#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="${CSS.escape(`doc:${id}`)}"]`), ); }, documentIds, { timeout: UI_TIMEOUT_MS }, ); } async function readState(page, rootDocumentId) { return await page.evaluate((rootId) => { const readDirectChildren = (mode) => { const rootSelector = mode === "filetree" ? `#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="doc:${CSS.escape(rootId)}"]` : `#sidebar-tree-root .tree-row[data-shell-mode="page"][data-node-id="${CSS.escape(rootId)}"]`; const rootRow = document.querySelector(rootSelector); const list = rootRow?.closest(".tree-node")?.querySelector(":scope > .tree-children"); if (!list) return []; return Array.from(list.querySelectorAll(":scope > .tree-node > .tree-row")).map((row) => ({ documentId: row instanceof HTMLElement ? row.dataset.documentId || row.dataset.docId || row.dataset.nodeId || "" : "", rowId: row instanceof HTMLElement ? row.dataset.rowId || "" : "", title: row instanceof HTMLElement ? row.querySelector(".tree-link-title")?.textContent?.trim() || "" : "", })); }; return { url: window.location.href, liveStatus: document.documentElement.getAttribute("data-mnote-tree-live-status") || "", liveApplied: document.documentElement.getAttribute("data-mnote-tree-live-applied") || "", liveRevision: document.documentElement.getAttribute("data-mnote-tree-live-revision") || "", liveError: document.documentElement.getAttribute("data-mnote-tree-live-apply-error") || "", pageOrder: readDirectChildren("page"), fileTreeOrder: readDirectChildren("filetree"), treeEvents: window.__MNOTE_TASK448_TREE_EVENTS__ || [], }; }, rootDocumentId); } (async () => { const stamp = Date.now(); const email = `mnote.stage8.resync.${stamp}@example.com`; const prefix = `TEST-10REVIEW-08-RESYNC-${stamp}`; const result = { ok: false, task: TASK, baseUrl: BASE_URL, authBaseUrl: AUTH_BASE_URL, email, prefix, pollMs: 5000, fixture: {}, navigationEvents: [], treeEventRequests: [], states: {}, screenshots: { bDocument: SCREENSHOT_B_DOCUMENT, bFileTree: SCREENSHOT_B_FILETREE, }, }; const browser = await chromium.launch({ headless: process.env.HEADFUL !== "1" }); const contextA = await browser.newContext(); const contextB = await browser.newContext(); const documentB = await contextB.newPage(); const fileTreeB = await contextB.newPage(); const requestA = contextA.request; let workspaceId = ""; const cleanupIds = []; for (const page of [documentB, fileTreeB]) { await installSlowTreeEventSource(page, result.pollMs); await installTreeEventRecorder(page, page === documentB ? "B-document" : "B-filetree", result.treeEventRequests); recordNavigation(page, page === documentB ? "B-document" : "B-filetree", result.navigationEvents); } try { for (const requestContext of [requestA, contextB.request]) { await authenticate(requestContext, email, `stage8-resync-${stamp}`); } const root = await createPage(requestA, null, `${prefix}-root`); workspaceId = root.workspaceId; cleanupIds.push(root.documentId); const childA = await createPage(requestA, workspaceId, `${prefix}-a`, root.documentId); cleanupIds.push(childA.documentId); result.fixture = { workspaceId, rootId: root.documentId, initialChildId: childA.documentId, }; await openDocument(documentB, workspaceId, root.documentId); await openFileTree(fileTreeB, workspaceId, root.documentId); await waitForRows(documentB, [childA.documentId]); await waitForRows(fileTreeB, [childA.documentId]); await Promise.all([ documentB.waitForFunction(() => document.documentElement.getAttribute("data-mnote-tree-live-status") === "connected", null, { timeout: UI_TIMEOUT_MS }), fileTreeB.waitForFunction(() => document.documentElement.getAttribute("data-mnote-tree-live-status") === "connected", null, { timeout: UI_TIMEOUT_MS }), ]); result.states.before = { document: await readState(documentB, root.documentId), fileTree: await readState(fileTreeB, root.documentId), }; const navigationStart = result.navigationEvents.length; const childB = await createPage(requestA, workspaceId, `${prefix}-b`, root.documentId); const childC = await createPage(requestA, workspaceId, `${prefix}-c`, root.documentId); cleanupIds.push(childB.documentId, childC.documentId); result.fixture.resyncedChildIds = [childB.documentId, childC.documentId]; await waitForResyncWithRows(documentB, [childB.documentId, childC.documentId]); await waitForResyncWithRows(fileTreeB, [childB.documentId, childC.documentId]); await documentB.waitForTimeout(500); await fileTreeB.waitForTimeout(500); result.states.after = { document: await readState(documentB, root.documentId), fileTree: await readState(fileTreeB, root.documentId), navigationEvents: result.navigationEvents.slice(navigationStart), }; assert.equal(result.states.after.document.liveApplied, "resync", "B 文档页应通过 resync 应用最新树快照"); assert.equal(result.states.after.fileTree.liveApplied, "resync", "B File Tree 应通过 resync 应用最新树快照"); assert(!result.states.after.document.liveError, `B 文档页存在 live apply error: ${result.states.after.document.liveError}`); assert(!result.states.after.fileTree.liveError, `B File Tree 存在 live apply error: ${result.states.after.fileTree.liveError}`); assert.equal(result.states.after.navigationEvents.length, 0, `B 浏览器发生了刷新/导航: ${JSON.stringify(result.states.after.navigationEvents)}`); await documentB.screenshot({ path: SCREENSHOT_B_DOCUMENT, fullPage: true }); await fileTreeB.screenshot({ path: SCREENSHOT_B_FILETREE, fullPage: true }); result.ok = true; } catch (error) { result.error = error instanceof Error ? error.stack || error.message : String(error); result.failure = { document: await readState(documentB, result.fixture.rootId || "").catch(() => null), fileTree: await readState(fileTreeB, result.fixture.rootId || "").catch(() => null), }; process.exitCode = 1; } finally { await cleanup(requestA, workspaceId, cleanupIds).catch((error) => { result.cleanupError = error instanceof Error ? error.message : String(error); }); await browser.close().catch(() => undefined); await writeResult(result); } })();