475 lines
19 KiB
JavaScript
475 lines
19 KiB
JavaScript
#!/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);
|
|
});
|