#!/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 { execFileSync } = require("node:child_process"); const { chromium } = require("playwright"); const { BASE_URL, UI_TIMEOUT_MS, ensureAuthenticated, } = require("./tree-shell-smoke-helpers"); const PROVIDERS = { deepseek: { taskName: "task513-chatonly-deepseek-sync-smoke", title: "DeepSeek ChatOnly Sync", agentProfileId: "shared_deepseek_chat", chipText: "ChatOnly / DeepSeek", expectedProvider: "deepseek-web", gatewayLog: "/home/lix/.openclaw-mnote-deepseek-chat/logs/gateway.out", sendLogPattern: /\[DeepSeekWebClient\] Sending chat completion request/g, configPath: "/home/lix/.openclaw-mnote-deepseek-chat/openclaw.json", }, gemini: { taskName: "task513-chatonly-gemini-sync-smoke", title: "Gemini ChatOnly Sync", agentProfileId: "shared_gemini_chat", chipText: "ChatOnly / Gemini", expectedProvider: "gemini-web", gatewayLog: "/home/lix/.openclaw-mnote-gemini-chat/logs/gateway.out", sendLogPattern: /\[Gemini Web Browser\] DOM: typed message and pressed Enter/g, cdpUrl: process.env.MNOTE_GEMINI_CDP_URL || "http://127.0.0.1:9232", }, }; const providerKey = process.argv[2] || process.env.MNOTE_CHATONLY_PROVIDER || "deepseek"; const provider = PROVIDERS[providerKey]; if (!provider) { throw new Error(`未知 provider: ${providerKey}`); } const OUTPUT_DIR = path.join(process.cwd(), "tmp", provider.taskName); const RESULT_PATH = path.join(OUTPUT_DIR, "result.json"); const CONTROL_PLANE_DB = "/mnt/Data1T/Mnote_data/control-plane/control-plane.db"; 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)); 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, workspaceId) { fs.mkdirSync(path.join(root, ".mnote"), { recursive: true }); fs.writeFileSync( path.join(root, ".mnote", "workspace.json"), `${JSON.stringify({ workspaceId, ownerId, createdAt: new Date().toISOString(), capabilities: ["local_files", "ai_sessions", "markdown_edit"], }, null, 2)}\n`, "utf8", ); } function sqliteJson(sql, fallback = null) { try { const raw = execFileSync("sqlite3", ["-json", CONTROL_PLANE_DB, sql], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], }).trim(); return raw ? JSON.parse(raw) : fallback; } catch { return fallback; } } function sqlQuote(value) { return `'${String(value).replaceAll("'", "''")}'`; } function sqliteExec(sql) { execFileSync("sqlite3", [CONTROL_PLANE_DB, sql], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], }); } function grantWorkspaceAccess({ actorId, workspaceId, root, rootUri, grantId }) { const now = new Date().toISOString(); sqliteExec(` INSERT OR IGNORE INTO users (id, email, username, display_name, role, status, created_at, updated_at, revision) VALUES (${sqlQuote(actorId)}, ${sqlQuote(`${actorId}@example.com`)}, ${sqlQuote(actorId)}, ${sqlQuote(actorId)}, 'user', 'active', ${sqlQuote(now)}, ${sqlQuote(now)}, 1); INSERT OR REPLACE INTO workspaces (id, owner_user_id, name, kind, root_uri, root_path, source_kind, status, created_at, updated_at, revision) VALUES (${sqlQuote(workspaceId)}, ${sqlQuote(actorId)}, ${sqlQuote(actorId)}, 'personal', ${sqlQuote(rootUri)}, ${sqlQuote(root)}, 'local_folder', 'active', ${sqlQuote(now)}, ${sqlQuote(now)}, 1); INSERT OR REPLACE INTO directory_grants (id, user_id, workspace_id, root_uri, root_path, permission, recursive, capabilities_json, source, status, created_by, created_at, updated_at, revision) VALUES (${sqlQuote(grantId)}, ${sqlQuote(actorId)}, ${sqlQuote(workspaceId)}, ${sqlQuote(rootUri)}, ${sqlQuote(root)}, 'write', 1, '["ai","markdown_edit"]', 'smoke', 'active', ${sqlQuote(actorId)}, ${sqlQuote(now)}, ${sqlQuote(now)}, 1); `); } function logSize(logPath) { try { return fs.statSync(logPath).size; } catch { return 0; } } function readLogSince(logPath, offset) { try { const fd = fs.openSync(logPath, "r"); const stat = fs.fstatSync(fd); const start = offset > stat.size ? 0 : offset; const buffer = Buffer.alloc(stat.size - start); fs.readSync(fd, buffer, 0, buffer.length, start); fs.closeSync(fd); return buffer.toString("utf8"); } catch { return ""; } } function countMatches(text, pattern) { return Array.from(String(text || "").matchAll(pattern)).length; } function escapeRegExp(value) { return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } function parseCookieString(cookieString, domain) { return String(cookieString || "") .split(";") .filter((cookie) => cookie.trim().includes("=")) .map((cookie) => { const [name, ...valueParts] = cookie.trim().split("="); return { name: name.trim(), value: valueParts.join("=").trim(), domain, path: "/", }; }) .filter((cookie) => cookie.name); } function deepseekAuth() { const config = JSON.parse(fs.readFileSync(provider.configPath, "utf8")); return JSON.parse(config.models.providers["deepseek-web"].apiKey || "{}"); } async function saveScreenshot(page, name) { const target = path.join(OUTPUT_DIR, `${name}.png`); await page.screenshot({ path: target, fullPage: false }); return target; } async function captureDeepseek(remoteConversationId, name) { const auth = deepseekAuth(); const browser = await chromium.launch({ headless: true, ...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}), }); const context = await browser.newContext({ viewport: { width: 1440, height: 960 }, locale: "zh-CN", userAgent: auth.userAgent, }); await context.addCookies(parseCookieString(auth.cookie, ".deepseek.com")); if (auth.bearer) { await context.addInitScript((token) => { localStorage.setItem("userToken", JSON.stringify({ value: token, __version: "0" })); }, auth.bearer); } const page = await context.newPage(); if (auth.bearer) { await page.route("https://chat.deepseek.com/api/**", (route) => { route.continue({ headers: { ...route.request().headers(), authorization: `Bearer ${auth.bearer}`, }, }); }); } const url = `https://chat.deepseek.com/a/chat/s/${remoteConversationId}`; await page.goto(url, { waitUntil: "domcontentloaded", timeout: 60_000 }).catch(() => {}); await page.waitForTimeout(2500); const screenshot = await saveScreenshot(page, name); const title = await page.title().catch(() => ""); const visibleText = await page.locator("body").innerText({ timeout: 5000 }).catch(() => ""); const conversationLinkCount = await page .locator(`a[href*="/a/chat/s/${remoteConversationId}"]`) .count() .catch(() => -1); await browser.close().catch(() => {}); return { screenshot, url, title, conversationLinkCount, conversationMissingText: visibleText.includes("该对话不存在"), visibleText: visibleText.slice(0, 1000), }; } async function firstGeminiPage(cdpBrowser, remoteConversationId) { const targetUrl = String(remoteConversationId || ""); for (const context of cdpBrowser.contexts()) { const exact = context.pages().find((candidate) => candidate.url().split("#")[0] === targetUrl); if (exact) return exact; const gemini = context.pages().find((candidate) => candidate.url().includes("gemini.google.com")); if (gemini) return gemini; } const context = cdpBrowser.contexts()[0] || await cdpBrowser.newContext(); return await context.newPage(); } async function captureGemini(remoteConversationId, name) { const browser = await chromium.connectOverCDP(provider.cdpUrl); const page = await firstGeminiPage(browser, remoteConversationId); const targetUrl = String(remoteConversationId || ""); if (targetUrl && page.url().split("#")[0] !== targetUrl) { await page.goto(targetUrl, { waitUntil: "domcontentloaded", timeout: 60_000 }).catch(() => {}); await page.waitForTimeout(1500); } await page.bringToFront().catch(() => {}); await page.waitForTimeout(1000); const screenshot = await saveScreenshot(page, name); const title = await page.title().catch(() => ""); const visibleText = await page.locator("body").innerText({ timeout: 5000 }).catch(() => ""); const conversationLinkCount = await page.evaluate((target) => { let targetPath = ""; try { targetPath = new URL(target).pathname; } catch { return -1; } return Array.from(document.querySelectorAll("a[href]")).filter((anchor) => { try { return new URL(anchor.href, location.href).pathname === targetPath; } catch { return false; } }).length; }, targetUrl).catch(() => -1); await browser.close().catch(() => {}); return { screenshot, url: page.url(), title, conversationLinkCount, visibleText: visibleText.slice(0, 1000), }; } async function captureProvider(remoteConversationId, name) { if (providerKey === "deepseek") return await captureDeepseek(remoteConversationId, name); return await captureGemini(remoteConversationId, name); } async function main() { fs.mkdirSync(OUTPUT_DIR, { recursive: true }); const suffix = Date.now().toString(36); const actorId = "mnote-e2e"; const root = fs.mkdtempSync(path.join(os.tmpdir(), `mnote-task513-${providerKey}-`)); const rootUri = fileUrl(root); const workspaceId = `local-ws:${actorId}:task513-${providerKey}-${suffix}`; const relativePath = `${providerKey}-ChatOnlySync.md`; const marker = `MNOTE_CHATONLY_SYNC_${suffix}`; const screenshots = {}; const providerCaptures = {}; const runRequests = []; const deleteResponses = []; const gatewayLogOffset = logSize(provider.gatewayLog); let caughtError = null; writeWorkspaceManifest(root, actorId, workspaceId); fs.writeFileSync(path.join(root, relativePath), [`# ${provider.title}`, "", marker, ""].join("\n"), "utf8"); grantWorkspaceAccess({ actorId, workspaceId, root, rootUri, grantId: `grant_task513_${providerKey}_${suffix}`, }); const browser = await chromium.launch({ headless: true, ...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}), }); const context = await browser.newContext({ viewport: { width: 1440, height: 960 }, locale: "zh-CN", extraHTTPHeaders: { "x-mnote-actor-id": actorId, "x-mnote-actor-type": "user", }, }); const page = await context.newPage(); try { await page.route("**/api/hermes/client/runs", async (route) => { runRequests.push(JSON.parse(route.request().postData() || "{}")); await route.continue(); }); page.on("response", async (response) => { const url = response.url(); const request = response.request(); if (request.method() === "DELETE" && url.includes("/api/hermes/client/sessions/")) { deleteResponses.push({ url, status: response.status(), body: await response.text().catch(() => ""), }); } }); await ensureAuthenticated(page, context.request); const response = await page.goto(documentUrl(root, relativePath), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS, }); assert(response && response.status() === 200, `文档页状态码异常: ${response && response.status()}`); await page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"] .ProseMirror').first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS, }); await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS }); await page.locator('[data-testid="wolai-page-ai-drawer"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await page.locator('.wolai-page-ai-header-actions [data-page-ai-action="new-session"]').click({ timeout: UI_TIMEOUT_MS, }); await page.locator("[data-page-ai-agent-button]").click({ timeout: UI_TIMEOUT_MS }); await page.locator(`[data-page-ai-agent-id="chat_only"][data-page-ai-profile-id="${provider.agentProfileId}"]`).click({ timeout: UI_TIMEOUT_MS, }); await page.waitForFunction( (expected) => document.querySelector("[data-page-ai-agent-chip]")?.textContent?.includes(expected), provider.chipText, { timeout: UI_TIMEOUT_MS }, ); await page.locator("[data-page-ai-input]").fill(`请只回复:${marker}`, { timeout: UI_TIMEOUT_MS }); await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS }); await page.waitForFunction( (expectedMarker) => { const assistantText = Array.from(document.querySelectorAll(".wolai-page-ai-message--assistant")) .map((node) => node.textContent || "") .join("\n"); return assistantText.includes(expectedMarker); }, marker, { timeout: 180_000 }, ); await page.waitForFunction( () => (document.querySelector("[data-page-ai-run-status]")?.textContent || "").includes("完成"), null, { timeout: 180_000 }, ).catch(() => {}); screenshots.afterMessage = await saveScreenshot(page, "after-message"); assert.strictEqual(runRequests.length, 1, "MNote 本轮应只创建一个 run"); const run = runRequests[0]; assert.strictEqual(run.agentId, "chat_only", "应使用 ChatOnly agent"); assert.strictEqual(run.profileId, provider.agentProfileId, `应使用 ${provider.title} profile`); assert.strictEqual(run.acpRuntime, "hermes", "ChatOnly 网页 provider 应走 Hermes/OpenClaw runtime"); assert(run.sessionId, "run payload 应包含 MNote sessionId"); const visibleAssistantTexts = await page.locator(".wolai-page-ai-message--assistant").allTextContents(); const markerAssistantCount = visibleAssistantTexts.filter((text) => text.includes(marker)).length; assert.strictEqual(markerAssistantCount, 1, "MNote 可见 provider 回复应只有一条"); const sessionId = String(run.sessionId); const bindingRows = sqliteJson( `SELECT mnote_session_id, remote_conversation_id, status FROM ai_external_conversation_bindings WHERE user_id=${sqlQuote(actorId)} AND mnote_session_id=${sqlQuote(sessionId)} AND provider=${sqlQuote(provider.expectedProvider)} ORDER BY updated_at DESC LIMIT 1;`, [], ); assert(bindingRows && bindingRows.length === 1, "SQLite 应保存远端会话绑定"); assert.strictEqual(bindingRows[0].status, "active", "删除前 binding 应为 active"); const remoteConversationId = bindingRows[0].remote_conversation_id; assert(remoteConversationId, "binding 应包含 remote_conversation_id"); providerCaptures.afterMessage = await captureProvider(remoteConversationId, `${providerKey}-after-message`); await page.locator('.wolai-page-ai-header-actions [data-page-ai-action="history"]').click({ timeout: UI_TIMEOUT_MS }); await page.locator('[data-page-ai-panel="history"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await page.locator(`[data-page-ai-session-row="${sessionId}"]`).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); page.once("dialog", async (dialog) => { await dialog.accept(); }); await page.locator(`[data-page-ai-session-delete="${sessionId}"]`).click({ timeout: UI_TIMEOUT_MS }); await page.waitForFunction( (id) => !document.querySelector(`[data-page-ai-session-row="${CSS.escape(id)}"]`), sessionId, { timeout: UI_TIMEOUT_MS }, ); screenshots.afterDelete = await saveScreenshot(page, "after-delete"); assert(deleteResponses.length >= 1, "MNote 应发出历史会话 DELETE 请求"); assert.strictEqual(deleteResponses.at(-1).status, 200, "MNote 历史会话 DELETE 应成功"); const deleteBody = JSON.parse(deleteResponses.at(-1).body || "{}"); assert.strictEqual( deleteBody?.result?.providerConversationDelete?.status, "remote_deleted", "provider 删除应返回 remote_deleted", ); const providerDeleteMode = deleteBody?.result?.providerConversationDelete?.response?.result?.providerDeleteMode; if (providerKey === "deepseek") { assert.strictEqual( providerDeleteMode, "deepseek_chat_session_delete_api", "DeepSeek 删除应明确走 chat_session_delete_api 并由网页复核", ); } else { assert.strictEqual( providerDeleteMode, "gemini_conversation_menu", "Gemini 删除应走网页对话菜单确认路径", ); } await page.waitForTimeout(1500); providerCaptures.afterDelete = await captureProvider(remoteConversationId, `${providerKey}-after-delete`); assert.strictEqual( providerCaptures.afterDelete.conversationLinkCount, 0, "provider 删除后网页历史中不应继续存在本轮远端会话链接", ); const deletedRows = sqliteJson( `SELECT mnote_session_id, remote_conversation_id, status, metadata_json FROM ai_external_conversation_bindings WHERE user_id=${sqlQuote(actorId)} AND mnote_session_id=${sqlQuote(sessionId)} AND provider=${sqlQuote(provider.expectedProvider)} ORDER BY updated_at DESC LIMIT 1;`, [], ); assert(deletedRows && deletedRows.length === 1, "删除后 binding 仍应可审计"); assert.strictEqual(deletedRows[0].status, "remote_deleted", "删除后 binding 应标记 remote_deleted"); const providerLog = readLogSince(provider.gatewayLog, gatewayLogOffset); let providerSendCount = countMatches(providerLog, provider.sendLogPattern); if (providerKey === "gemini" && providerSendCount === 0) { const fullProviderLog = readLogSince(provider.gatewayLog, 0); providerSendCount = countMatches(fullProviderLog, new RegExp(escapeRegExp(marker), "g")); } assert.strictEqual(providerSendCount, 1, "provider 本轮只能收到一次发送动作"); fs.writeFileSync( RESULT_PATH, `${JSON.stringify({ ok: true, provider: providerKey, marker, sessionId, remoteConversationId, providerSendCount, deleteResponse: deleteResponses.at(-1), bindingBefore: bindingRows[0], bindingAfter: deletedRows[0], screenshots, providerCaptures, }, null, 2)}\n`, "utf8", ); } catch (error) { caughtError = error; screenshots.failure = await saveScreenshot(page, "failure").catch(() => ""); fs.writeFileSync( RESULT_PATH, `${JSON.stringify({ ok: false, provider: providerKey, marker, error: error instanceof Error ? error.stack || error.message : String(error), runRequests, deleteResponses, screenshots, gatewayLog: readLogSince(provider.gatewayLog, gatewayLogOffset).slice(-8000), }, null, 2)}\n`, "utf8", ); } finally { await browser.close().catch(() => {}); } if (caughtError) throw caughtError; console.log(JSON.stringify(JSON.parse(fs.readFileSync(RESULT_PATH, "utf8")), null, 2)); } main().catch((error) => { console.error(error && error.stack ? error.stack : error); process.exit(1); });