#!/usr/bin/env node "use strict"; const assert = require("node:assert"); const fs = require("node:fs"); const os = require("node:os"); const path = require("node: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", "task451-local-markdown-conflict-resolution-ui-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", "/usr/bin/chromium"] .find((candidate) => fs.existsSync(candidate)); const debug = {}; function fileUrl(localPath) { return `file://${localPath}`; } function localMdDocumentId(relativePath) { return `local-md:${relativePath.replaceAll("/", "~2F")}`; } function documentUrl(root, relativePath) { const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`); url.searchParams.set("sourceKind", "local_folder"); url.searchParams.set("rootUri", fileUrl(root)); return url.toString(); } function markdown(title, lines) { return [ "---", `title: ${title}`, "---", "", ...lines, "", ].join("\n"); } 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}:task451`, ownerId, createdAt: new Date().toISOString(), capabilities: ["local_files", "markdown_edit"], }, null, 2)}\n`, "utf8", ); } async function openDocument(page, root, relativePath) { await page.goto(documentUrl(root, relativePath), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); await page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]').first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS, }); await page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS, }); } async function waitForEditorText(page, text) { await page.waitForFunction( (expected) => { const editor = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror'); return (editor?.textContent || "").includes(expected); }, text, { timeout: UI_TIMEOUT_MS }, ); } async function waitForEditorStatus(page, status) { await page.waitForFunction( (expected) => { const root = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]'); return root?.getAttribute("data-runtime-editor-status") === expected; }, status, { timeout: UI_TIMEOUT_MS }, ); } async function typeDirtyText(page, text) { const editor = page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first(); await editor.click({ timeout: UI_TIMEOUT_MS }); await page.keyboard.type(text, { delay: 8 }); await waitForEditorText(page, text.trim()); } async function getConflictEnvelope(page, documentId) { return await page.evaluate((docId) => { const snapshot = window.__mnoteDebugDocumentSessions?.snapshot?.(); if (!snapshot) return null; const session = snapshot.sessions.find((s) => s.documentId === docId); return session?.lastExternalConflictEnvelope || null; }, documentId); } async function verifyConflictEnvelope(page, documentId, label) { const envelope = await getConflictEnvelope(page, documentId); assert(envelope, `${label}: 冲突信封应存在`); assert(typeof envelope.editorBaseVersion === 'string', `${label}: editorBaseVersion 应为字符串`); assert(typeof envelope.currentDiskVersion === 'string', `${label}: currentDiskVersion 应为字符串`); // BufferStore 字段应存在(可为 null 但必须声明) assert('externalActor' in envelope, `${label}: 冲突信封应包含 externalActor 字段(BufferStore 桥接)`); assert('dirtyState' in envelope, `${label}: 冲突信封应包含 dirtyState 字段(BufferStore 桥接)`); assert('bufferFileVersion' in envelope, `${label}: 冲突信封应包含 bufferFileVersion 字段(BufferStore 桥接)`); debug[`envelope_${label}`] = envelope; } async function waitForFileText(filePath, expected) { const deadline = Date.now() + UI_TIMEOUT_MS; while (Date.now() < deadline) { const content = fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : ""; if (content.includes(expected)) return content; await new Promise((resolve) => setTimeout(resolve, 120)); } throw new Error(`文件未出现期望内容: ${expected}`); } async function run() { fs.mkdirSync(OUTPUT_DIR, { recursive: true }); const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-local-conflict-ui-")); const acceptFile = "accept-disk.md"; const keepFile = "keep-current.md"; const mergeFile = "merge-result.md"; const agentFile = "agent-conflict.md"; writeWorkspaceManifest(root, "user_real"); fs.writeFileSync(path.join(root, acceptFile), markdown("Accept Disk", ["initial accept"]), "utf8"); fs.writeFileSync(path.join(root, keepFile), markdown("Keep Current", ["initial keep"]), "utf8"); fs.writeFileSync(path.join(root, mergeFile), markdown("Merge Result", ["initial merge"]), "utf8"); fs.writeFileSync(path.join(root, agentFile), markdown("Agent Conflict", ["initial agent"]), "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(); debug.network = []; page.on("response", async (response) => { const url = response.url(); if (!url.includes("/api/page-body/write") && !url.includes("/api/documents/save")) return; let body = ""; try { body = await response.text(); } catch (_) { body = ""; } debug.network.push({ url, status: response.status(), body: body.slice(0, 800) }); }); page.on("requestfailed", (request) => { const url = request.url(); if (!url.includes("/api/page-body/write") && !url.includes("/api/documents/save")) return; debug.network.push({ url, failed: request.failure()?.errorText || "request_failed" }); }); const steps = []; try { await openDocument(page, root, acceptFile); await waitForEditorText(page, "initial accept"); const localAcceptToken = `local-accept-${Date.now()}`; const diskAcceptToken = `disk-accept-${Date.now()}`; await typeDirtyText(page, ` ${localAcceptToken}`); fs.writeFileSync(path.join(root, acceptFile), markdown("Accept Disk", ["initial accept", diskAcceptToken]), "utf8"); await waitForEditorStatus(page, "external-change-conflict"); await page.locator('[data-testid="mnote-editor-conflict-panel"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await verifyConflictEnvelope(page, localMdDocumentId(acceptFile), "accept-disk"); await page.locator('[data-testid="mnote-conflict-open-diff"]').click({ timeout: UI_TIMEOUT_MS }); await page.locator('[data-testid="mnote-conflict-diff-panel"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await page.waitForFunction( (expected) => { const panel = document.querySelector('[data-testid="mnote-conflict-diff-panel"]'); return (panel?.textContent || "").includes(expected); }, diskAcceptToken, { timeout: UI_TIMEOUT_MS }, ); const diffText = await page.locator('[data-testid="mnote-conflict-diff-panel"]').innerText({ timeout: UI_TIMEOUT_MS }); debug.acceptDiffText = diffText; assert(diffText.includes(localAcceptToken), "diff 应显示当前编辑器版本"); assert(diffText.includes(diskAcceptToken), "diff 应显示磁盘版本"); await page.locator('[data-testid="mnote-conflict-accept-disk"]').click({ timeout: UI_TIMEOUT_MS }); await waitForEditorText(page, diskAcceptToken); steps.push({ label: "accept-disk", ok: true }); await openDocument(page, root, keepFile); await waitForEditorText(page, "initial keep"); const localKeepToken = `local-keep-${Date.now()}`; const diskKeepToken = `disk-keep-${Date.now()}`; await typeDirtyText(page, ` ${localKeepToken}`); fs.writeFileSync(path.join(root, keepFile), markdown("Keep Current", ["initial keep", diskKeepToken]), "utf8"); await waitForEditorStatus(page, "external-change-conflict"); await page.locator('[data-testid="mnote-editor-conflict-panel"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await verifyConflictEnvelope(page, localMdDocumentId(keepFile), "keep-current"); await page.locator('[data-testid="mnote-conflict-keep-current"]').click({ timeout: UI_TIMEOUT_MS }); await waitForFileText(path.join(root, keepFile), localKeepToken); const keepContent = fs.readFileSync(path.join(root, keepFile), "utf8"); assert(!keepContent.includes(diskKeepToken), "保留当前版本后磁盘版本内容不应覆盖当前编辑器内容"); steps.push({ label: "keep-current", ok: true }); await openDocument(page, root, mergeFile); await waitForEditorText(page, "initial merge"); const localMergeToken = `local-merge-${Date.now()}`; const diskMergeToken = `disk-merge-${Date.now()}`; const mergedToken = `merged-merge-${Date.now()}`; await typeDirtyText(page, ` ${localMergeToken}`); fs.writeFileSync(path.join(root, mergeFile), markdown("Merge Result", ["initial merge", diskMergeToken]), "utf8"); await waitForEditorStatus(page, "external-change-conflict"); await page.locator('[data-testid="mnote-editor-conflict-panel"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await verifyConflictEnvelope(page, localMdDocumentId(mergeFile), "merge"); await page.locator('[data-testid="mnote-conflict-open-diff"]').click({ timeout: UI_TIMEOUT_MS }); await page.locator('[data-testid="mnote-conflict-diff-panel"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await page.locator('[data-testid="mnote-conflict-merge-text"]').fill(`initial merge\n${localMergeToken}\n${diskMergeToken}\n${mergedToken}`); await page.locator('[data-testid="mnote-conflict-merge-save"]').click({ timeout: UI_TIMEOUT_MS }); await waitForFileText(path.join(root, mergeFile), mergedToken); const mergeContent = fs.readFileSync(path.join(root, mergeFile), "utf8"); assert(mergeContent.includes(localMergeToken), "合并结果应保留当前编辑器内容"); assert(mergeContent.includes(diskMergeToken), "合并结果应保留磁盘内容"); assert(mergeContent.includes(mergedToken), "合并结果应写入合并后的新内容"); steps.push({ label: "merge-save", ok: true }); await openDocument(page, root, agentFile); await waitForEditorText(page, "initial agent"); const localAgentToken = `local-agent-${Date.now()}`; const diskAgentToken = `disk-agent-${Date.now()}`; const agentRunId = `run-agent-conflict-${Date.now()}`; await typeDirtyText(page, ` ${localAgentToken}`); fs.writeFileSync(path.join(root, agentFile), markdown("Agent Conflict", ["initial agent", diskAgentToken]), "utf8"); await page.evaluate(({ documentId, runId }) => { window.dispatchEvent(new CustomEvent("mnote:page-ai-tool-write-completed", { detail: { toolName: "agent.changed_files", normalizedToolName: "agent.changed_files", documentId, runId, traceId: `trace-${runId}`, toolCallId: `${runId}:agent.changed_files`, }, })); }, { documentId: localMdDocumentId(agentFile), runId: agentRunId }); await waitForEditorStatus(page, "external-change-conflict"); await page.locator('[data-testid="mnote-editor-conflict-panel"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await verifyConflictEnvelope(page, localMdDocumentId(agentFile), "agent"); await page.waitForFunction( (expected) => { const panel = document.querySelector('[data-testid="mnote-editor-conflict-panel"]'); return (panel?.textContent || "").includes(expected); }, `agent run ${agentRunId}`, { timeout: UI_TIMEOUT_MS }, ); steps.push({ label: "agent-conflict-source", ok: true }); const result = { ok: true, baseUrl: BASE_URL, root, steps }; fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8"); console.log(`task451 local markdown conflict resolution UI smoke passed: ${RESULT_PATH}`); } finally { await browser.close().catch(() => {}); fs.rmSync(root, { recursive: true, force: true }); } } run().catch((error) => { fs.mkdirSync(OUTPUT_DIR, { recursive: true }); fs.writeFileSync(RESULT_PATH, `${JSON.stringify({ ok: false, error: String(error && error.stack || error), debug, }, null, 2)}\n`, "utf8"); console.error(error); process.exitCode = 1; });