#!/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, UI_TIMEOUT_MS, ensureAuthenticated, } = require("./tree-shell-smoke-helpers"); const { setupWorkspaceAccess, findExternalConversationBinding, } = require("./lib/control-plane-dev-seed"); const TASK = "task512-chatonly-doubao-sync-smoke"; const OUTPUT_DIR = path.join(process.cwd(), "tmp", TASK); const RESULT_PATH = path.join(OUTPUT_DIR, "result.json"); const DOUBAO_CDP_URL = process.env.MNOTE_DOUBAO_CDP_URL || "http://127.0.0.1:9233"; 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", ); } async function firstDoubaoPage(cdpBrowser) { for (const context of cdpBrowser.contexts()) { const page = context.pages().find((candidate) => candidate.url().includes("doubao.com")); if (page) return page; } const context = cdpBrowser.contexts()[0] || await cdpBrowser.newContext(); const page = await context.newPage(); await page.goto("https://www.doubao.com/chat/", { waitUntil: "domcontentloaded" }); return page; } async function installDoubaoFetchProbe(page) { await page.evaluate(() => { const win = window; const shouldTrack = (url) => url.includes("/samantha/chat/completion") || url.includes("/im/conversation/batch_del_user_conv"); if (!win.__mnoteDoubaoOriginalFetch) { win.__mnoteDoubaoOriginalFetch = win.fetch.bind(win); } win.__mnoteDoubaoFetchLog = []; win.fetch = async function patchedMnoteDoubaoFetch(input, init) { const url = String((input && input.url) || input || ""); const method = String((init && init.method) || "GET").toUpperCase(); if (shouldTrack(url)) { win.__mnoteDoubaoFetchLog.push({ transport: "fetch", url, method, body: init && init.body ? String(init.body) : "", ts: Date.now(), }); } return win.__mnoteDoubaoOriginalFetch(input, init); }; if (!win.__mnoteDoubaoOriginalXHROpen && win.XMLHttpRequest) { win.__mnoteDoubaoOriginalXHROpen = win.XMLHttpRequest.prototype.open; win.__mnoteDoubaoOriginalXHRSend = win.XMLHttpRequest.prototype.send; win.XMLHttpRequest.prototype.open = function patchedMnoteDoubaoXHROpen(method, url, ...rest) { this.__mnoteDoubaoProbeMethod = String(method || "GET").toUpperCase(); this.__mnoteDoubaoProbeUrl = String(url || ""); return win.__mnoteDoubaoOriginalXHROpen.call(this, method, url, ...rest); }; win.XMLHttpRequest.prototype.send = function patchedMnoteDoubaoXHRSend(body) { const url = String(this.__mnoteDoubaoProbeUrl || ""); if (shouldTrack(url)) { win.__mnoteDoubaoFetchLog.push({ transport: "xhr", url, method: String(this.__mnoteDoubaoProbeMethod || "GET").toUpperCase(), body: body ? String(body) : "", ts: Date.now(), }); } return win.__mnoteDoubaoOriginalXHRSend.call(this, body); }; } }); } async function readDoubaoFetchLog(page) { return await page.evaluate(() => Array.isArray(window.__mnoteDoubaoFetchLog) ? window.__mnoteDoubaoFetchLog : []); } async function readDoubaoConversationStats(page, { marker, prompt }) { return await page.evaluate(({ marker, prompt }) => { const normalize = (value) => String(value || "").replace(/\s+/g, " ").trim(); const markerText = String(marker || ""); const promptText = String(prompt || ""); const userBubbleTexts = Array.from(document.querySelectorAll(".bg-g-send-msg-bubble-bg")) .map((node) => normalize(node.textContent)); const assistantMarkdownTexts = Array.from(document.querySelectorAll(".md-box-root")) .map((node) => normalize(node.textContent)); return { userPromptCount: userBubbleTexts.filter((text) => text === normalize(promptText)).length, userMarkerCount: userBubbleTexts.filter((text) => text.includes(markerText)).length, assistantMarkerCount: assistantMarkdownTexts.filter((text) => text.includes(markerText)).length, userBubbleTexts, assistantMarkdownTexts, }; }, { marker, prompt }); } async function saveScreenshot(page, name) { const target = path.join(OUTPUT_DIR, `${name}.png`); await page.screenshot({ path: target, fullPage: false }); return target; } function doubaoConversationUrl(remoteConversationId) { return `https://www.doubao.com/chat/${encodeURIComponent(remoteConversationId)}`; } async function captureDoubaoPage(page, { name, url, remoteConversationId = "" }) { let navigationError = ""; if (url) { await page.goto(url, { waitUntil: "domcontentloaded", timeout: 60_000 }).catch((error) => { navigationError = error instanceof Error ? error.message : String(error); }); } await page.bringToFront().catch(() => {}); await page.waitForTimeout(2500); const screenshot = await saveScreenshot(page, name); const title = await page.title().catch(() => ""); const pageUrl = page.url(); const visibleText = await page.locator("body").innerText({ timeout: 5000 }).catch(() => ""); const conversationRowCount = remoteConversationId ? await page.locator(`#conversation_${remoteConversationId}`).count().catch(() => -1) : null; return { screenshot, requestedUrl: url || "", pageUrl, title, navigationError, conversationRowCount, visibleText: visibleText.slice(0, 2000), }; } 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-task512-doubao-")); const rootUri = fileUrl(root); const workspaceId = `local-ws:${actorId}:task512-${suffix}`; const relativePath = "DoubaoChatOnlySync.md"; const marker = `MNOTE_DOUBAO_SYNC_${suffix.toUpperCase()}`; const prompt = `请只回复以下字符串,不要添加空格或其他内容:${marker}`; const screenshots = {}; const providerCaptures = {}; const runRequests = []; const deleteResponses = []; let caughtError = null; writeWorkspaceManifest(root, actorId, workspaceId); fs.writeFileSync(path.join(root, relativePath), ["# Doubao ChatOnly Sync", "", marker, ""].join("\n"), "utf8"); const doubaoBrowser = await chromium.connectOverCDP(DOUBAO_CDP_URL); const doubaoPage = await firstDoubaoPage(doubaoBrowser); await doubaoPage.goto("https://www.doubao.com/chat/", { waitUntil: "domcontentloaded", timeout: 60_000, }).catch(() => {}); await doubaoPage.keyboard.press("Escape").catch(() => {}); await installDoubaoFetchProbe(doubaoPage); 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); await setupWorkspaceAccess(context.request, BASE_URL, { actorId, workspaceId, workspaceName: "Task512 Doubao Smoke", rootPath: root, rootUri, capabilities: ["ai", "markdown_edit"], timeoutMs: UI_TIMEOUT_MS, }); 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="shared_doubao_chat"]').click({ timeout: UI_TIMEOUT_MS, }); await page.waitForFunction( () => document.querySelector("[data-page-ai-agent-chip]")?.textContent?.includes("ChatOnly / 豆包"), null, { timeout: UI_TIMEOUT_MS }, ); await page.locator("[data-page-ai-input]").fill(prompt, { timeout: UI_TIMEOUT_MS }); await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS }); const replyState = await page.waitForFunction( (expectedMarker) => { const assistantText = Array.from(document.querySelectorAll(".wolai-page-ai-message--assistant")) .map((node) => node.textContent || "") .join("\n"); if (assistantText.includes(expectedMarker)) return "marker"; if (assistantText.includes("豆包暂时无法回复")) return "provider_error"; return ""; }, marker, { timeout: 180_000 }, ).then((handle) => handle.jsonValue()); 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, "shared_doubao_chat", "应使用豆包 ChatOnly profile"); assert.strictEqual(run.acpRuntime, "hermes", "豆包 ChatOnly 应走 Hermes/OpenClaw runtime"); assert(run.sessionId, "run payload 应包含 MNote sessionId"); const logAfterMessage = await readDoubaoFetchLog(doubaoPage); const completionCalls = logAfterMessage.filter((entry) => entry.url.includes("/samantha/chat/completion")); assert.strictEqual(completionCalls.length, 0, "豆包 ChatOnly 应走真实 UI 发送,不应再直调 samantha completion"); assert.notStrictEqual(replyState, "provider_error", "豆包返回限流/风控错误,未产生本轮 marker 回复"); 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 可见豆包回复应只有一条"); const sessionId = String(run.sessionId); const bindingBefore = await findExternalConversationBinding(context.request, BASE_URL, { userId: actorId, workspaceId, mnoteSessionId: sessionId, provider: "doubao-web", timeoutMs: UI_TIMEOUT_MS, }); assert(bindingBefore, "control-plane 应保存豆包远端会话绑定"); assert.strictEqual(bindingBefore.status, "active", "删除前 binding 应为 active"); const remoteConversationId = bindingBefore.remoteConversationId; assert(remoteConversationId, "binding 应包含 remote_conversation_id"); providerCaptures.afterMessage = await captureDoubaoPage(doubaoPage, { name: "doubao-after-message", url: doubaoConversationUrl(remoteConversationId), remoteConversationId, }); const doubaoMessageStats = await readDoubaoConversationStats(doubaoPage, { marker, prompt }); screenshots.doubaoAfterMessage = providerCaptures.afterMessage.screenshot; assert( providerCaptures.afterMessage.pageUrl.includes(remoteConversationId), "豆包截图应定位到本轮远端 conversation_id", ); assert( providerCaptures.afterMessage.visibleText.includes(marker), "豆包本轮远端会话页面应显示 marker", ); assert.strictEqual( providerCaptures.afterMessage.conversationRowCount, 1, "豆包删除前左侧历史列表应存在本轮 conversation 行", ); assert.strictEqual( doubaoMessageStats.userPromptCount, 1, "豆包网页端本轮用户消息应只有一条", ); assert.strictEqual( doubaoMessageStats.userMarkerCount, 1, "豆包网页端不应把豆包回复再次作为用户消息发送", ); assert.strictEqual( doubaoMessageStats.assistantMarkerCount, 1, "豆包网页端本轮助手回复应只有一条", ); await installDoubaoFetchProbe(doubaoPage); 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"); await page.waitForTimeout(1000); const logAfterDelete = await readDoubaoFetchLog(doubaoPage); const deleteCalls = logAfterDelete.filter((entry) => entry.url.includes("/im/conversation/batch_del_user_conv")); assert.strictEqual(deleteCalls.length, 1, "豆包端本轮只能收到一次会话删除请求"); assert(deleteCalls[0].body.includes(remoteConversationId), "豆包删除请求应包含绑定的远端 conversation_id"); 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?.response?.result?.providerDeleteMode, "doubao_sidebar_menu", "豆包远端删除必须走左侧会话三点菜单 + 确认弹窗路径", ); providerCaptures.afterDelete = await captureDoubaoPage(doubaoPage, { name: "doubao-after-delete", url: "https://www.doubao.com/chat/", remoteConversationId, }); screenshots.doubaoAfterDelete = providerCaptures.afterDelete.screenshot; assert( !providerCaptures.afterDelete.visibleText.includes(marker), "豆包删除后聊天入口不应继续显示本轮 marker", ); assert.strictEqual( providerCaptures.afterDelete.conversationRowCount, 0, "豆包删除后左侧历史列表不应继续存在本轮 conversation 行", ); const bindingAfter = await findExternalConversationBinding(context.request, BASE_URL, { userId: actorId, workspaceId, mnoteSessionId: sessionId, provider: "doubao-web", timeoutMs: UI_TIMEOUT_MS, }); assert(bindingAfter, "删除后 binding 仍应可审计"); assert.strictEqual(bindingAfter.status, "remote_deleted", "删除后 binding 应标记 remote_deleted"); fs.writeFileSync( RESULT_PATH, `${JSON.stringify({ ok: true, marker, sessionId, remoteConversationId, samanthaCompletionCallCount: completionCalls.length, doubaoMessageStats, deleteCallCount: deleteCalls.length, deleteResponse: deleteResponses.at(-1), bindingBefore, bindingAfter, screenshots, providerCaptures, }, null, 2)}\n`, "utf8", ); } catch (error) { caughtError = error; screenshots.failure = await saveScreenshot(page, "failure").catch(() => ""); providerCaptures.failure = await captureDoubaoPage(doubaoPage, { name: "doubao-failure", url: "", }).catch((captureError) => ({ screenshot: "", requestedUrl: "", pageUrl: "", title: "", navigationError: captureError instanceof Error ? captureError.message : String(captureError), visibleText: "", })); if (providerCaptures.failure.screenshot) { screenshots.doubaoFailure = providerCaptures.failure.screenshot; } fs.writeFileSync( RESULT_PATH, `${JSON.stringify({ ok: false, marker, error: error instanceof Error ? error.stack || error.message : String(error), runRequests, deleteResponses, doubaoFetchLog: await readDoubaoFetchLog(doubaoPage).catch(() => []), screenshots, providerCaptures, }, null, 2)}\n`, "utf8", ); } finally { await browser.close().catch(() => {}); await doubaoBrowser.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); });