#!/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 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 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)); 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}:task508`, ownerId, createdAt: new Date().toISOString(), capabilities: ["local_files", "markdown_edit", "asset_upload"], }, 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 typeEditorText(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.press(process.platform === "darwin" ? "Meta+End" : "Control+End").catch(() => undefined); await page.keyboard.type(`\n${text}`, { delay: 2 }); } async function waitForEditorText(page, text) { await page.waitForFunction( (needle) => { const editor = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror'); return (editor?.textContent || "").includes(needle); }, text, { timeout: UI_TIMEOUT_MS }, ); } async function waitForFileText(filePath, text) { const deadline = Date.now() + UI_TIMEOUT_MS; while (Date.now() < deadline) { const content = fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : ""; if (content.includes(text)) return content; await new Promise((resolve) => setTimeout(resolve, 120)); } throw new Error(`文件未出现期望内容: ${text}`); } async function withTimeout(promise, timeoutMs, diagnostics) { let timer = 0; try { return await Promise.race([ promise, new Promise((_, reject) => { timer = setTimeout(async () => { let detail = null; if (typeof diagnostics === "function") { try { detail = await diagnostics(); } catch (error) { detail = { diagnosticsError: error && error.message ? error.message : String(error) }; } } reject(new Error(`等待超时: ${JSON.stringify(detail)}`)); }, timeoutMs); }), ]); } finally { if (timer) clearTimeout(timer); } } async function installDelayedSave(page) { let release = null; const gate = { intercepted: false, status: 0, body: "", release: () => release && release(), interceptedPromise: null, }; gate.interceptedPromise = new Promise((resolve) => { gate.markIntercepted = resolve; }); const releasePromise = new Promise((resolve) => { release = resolve; }); await page.route("**/api/page-body/write", async (route) => { if (!gate.intercepted) { gate.intercepted = true; gate.markIntercepted(); await releasePromise; const response = await route.fetch(); gate.status = response.status(); gate.body = await response.text(); await route.fulfill({ status: gate.status, headers: response.headers(), body: gate.body, }); return; } await route.continue(); }); return gate; } async function main() { const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task508-multitab-")); const relativePath = "Concurrent.md"; const markdownPath = path.join(root, relativePath); writeWorkspaceManifest(root, "mnote-e2e"); fs.writeFileSync(markdownPath, "# Concurrent\n\n初始正文\n", "utf8"); const browser = await chromium.launch({ headless: process.env.HEADFUL !== "1", executablePath: CHROMIUM_EXECUTABLE_PATH, }); const contextA = await browser.newContext({ viewport: { width: 1280, height: 860 }, extraHTTPHeaders: { "x-mnote-actor-id": "mnote-e2e", "x-mnote-actor-type": "user" }, }); const contextB = await browser.newContext({ viewport: { width: 1280, height: 860 }, extraHTTPHeaders: { "x-mnote-actor-id": "mnote-e2e", "x-mnote-actor-type": "user" }, }); const pageA = await contextA.newPage(); const pageB = await contextB.newPage(); const pageBEvents = []; pageB.on("request", (request) => { const url = request.url(); if (url.includes("/api/page-body/write") || url.includes("/api/documents/save") || url.includes("/api/documents/buffer-state") || url.includes("/api/page-aggregate")) { pageBEvents.push({ type: "request", method: request.method(), url }); } }); pageB.on("response", (response) => { const url = response.url(); if (url.includes("/api/page-body/write") || url.includes("/api/documents/save") || url.includes("/api/documents/buffer-state") || url.includes("/api/page-aggregate")) { pageBEvents.push({ type: "response", status: response.status(), url }); } }); pageB.on("console", (message) => { const text = message.text(); if (text.includes("mnote") || text.includes("conflict")) { pageBEvents.push({ type: "console", level: message.type(), text }); } }); try { await Promise.all([ openDocument(pageA, root, relativePath), openDocument(pageB, root, relativePath), ]); const cleanToken = `A-clean-sync-${Date.now()}`; await typeEditorText(pageA, cleanToken); await waitForFileText(markdownPath, cleanToken); await waitForEditorText(pageB, cleanToken); const gate = await installDelayedSave(pageB); const dirtyToken = `B-dirty-pending-${Date.now()}`; await typeEditorText(pageB, dirtyToken); await withTimeout(gate.interceptedPromise, UI_TIMEOUT_MS, async () => { const state = await pageB.evaluate(() => ({ status: document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]')?.getAttribute("data-runtime-editor-status") || "", text: document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror')?.textContent || "", sessions: typeof window.__mnoteDebugDocumentSessions?.snapshot === "function" ? window.__mnoteDebugDocumentSessions.snapshot() : null, })); return { ...state, pageBEvents }; }); const winnerToken = `A-winner-${Date.now()}`; await typeEditorText(pageA, winnerToken); await waitForFileText(markdownPath, winnerToken); gate.release(); await pageB.waitForFunction( (needle) => { const root = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]'); const editor = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror'); return root?.getAttribute("data-runtime-editor-status") === "external-change-conflict" && (editor?.textContent || "").includes(needle); }, dirtyToken, { timeout: UI_TIMEOUT_MS }, ); assert.equal(gate.status, 409, `B tab stale save 应返回 409,实际 ${gate.status}: ${gate.body}`); const finalB = await pageB.evaluate(() => ({ status: document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]')?.getAttribute("data-runtime-editor-status") || "", text: document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror')?.textContent || "", conflictText: document.querySelector('[data-testid="mnote-editor-conflict-panel"]')?.textContent || "", sessions: typeof window.__mnoteDebugDocumentSessions?.snapshot === "function" ? window.__mnoteDebugDocumentSessions.snapshot() : null, })); console.log(JSON.stringify({ ok: true, root, states: [ { step: "clean-tab-auto-sync", cleanToken }, { step: "dirty-tab-save-409", dirtyToken, winnerToken, gateStatus: gate.status, finalB }, ], }, null, 2)); } finally { await contextA.close().catch(() => undefined); await contextB.close().catch(() => undefined); await browser.close().catch(() => undefined); } } main().catch((error) => { console.error(error && error.stack ? error.stack : error); process.exit(1); });