Files
trade-message-center/apps/chrome-extension/test/onetalk-send-page.test.js
T
YBF 784bbec2a0 feat: 支持 OneTalk 文本引用回复协议 (#66)
* feat: add OneTalk quote reply support

* chore(task): archive 09-17-onetalk-quote-reply-protocol

* chore: record journal

* docs: format OneTalk DOM card collection plan

* chore: release 0.8.29

* chore: release 0.8.30
2026-09-17 11:56:35 +08:00

696 lines
23 KiB
JavaScript

// 验证 OneTalk SPA 当前会话身份和 PWA 出站入口
import assert from "node:assert/strict";
import test from "node:test";
import { handleOneTalkSendCommand } from "../src/onetalk/main-page/commands/send.ts";
import { handleOneTalkHistoryCommand } from "../src/onetalk/main-page/current-conversation-history/page-command.ts";
import {
readCurrentConversationId,
readSelectedConversationIds,
} from "../src/onetalk/main-page/page-context.ts";
import { createHistoryMessageBatch } from "../src/onetalk/main-page/message-observer/history.ts";
import { installCurrentConversationHistorySync } from "../src/onetalk/main-page/current-conversation-history/entry.ts";
import { createSendObservationCorrelator } from "../src/onetalk/main-page/message-observer/send-observation.ts";
import { createOneTalkQuoteSourceIndex } from "../src/onetalk/main-page/message-observer/quote-source-index.ts";
import { installOneTalkMainPageBridge } from "../src/onetalk/page-bridge/main.ts";
import {
createOneTalkPageObservedMessage,
decodeOneTalkPageMessage,
ONE_TALK_PAGE_BRIDGE_VERSION,
} from "../src/onetalk/page-bridge/model.ts";
const pageOrigin = "https://onetalk.alibaba.com";
const selectedElement = (conversationId) => ({
getAttribute: (name) => (name === "data-cid" ? conversationId : null),
});
const createSendPage = (selectedIds = ["conversation-1"]) => {
const calls = [];
const rejectedCalls = [];
const selectors = [];
const selection = { ids: [...selectedIds] };
const messageService = {
sendUIMessages: async (input) => {
calls.push(input);
return { opId: "local-acceptance" };
},
sendMessage: () => rejectedCalls.push("sendMessage"),
sendTextMessage: () => rejectedCalls.push("sendTextMessage"),
};
const pageWindow = {
location: {
href: `${pageOrigin}/?activeAccountId=account-1&conversationId=url-value`,
},
currentUserAccountId: "login-account-1",
document: {
querySelectorAll: (selector) => {
selectors.push(selector);
return selection.ids.map(selectedElement);
},
addEventListener: () => {},
},
IcbuIM: {
IMBaaSSDK: {
default: {
getMessageService: () => messageService,
},
},
},
};
return { pageWindow, selection, calls, rejectedCalls, selectors };
};
const sendCommand = (conversationId = "conversation-1", text = "hello") => ({
command: { action: "onetalk.send", conversationId, content: { kind: "text", text } },
});
const handleSendCommand = (pageWindow, message = sendCommand()) =>
handleOneTalkSendCommand(pageWindow, message, createSendObservationCorrelator(1));
const createHistorySyncPage = () => {
const conversation = {
cid: "buyer-seller#tenant@icbu",
lastContactTimeLong: 1_700_000_000_001,
accountId: "buyer-account",
accountIdEncrypt: "buyer-encrypt",
aliId: "buyer",
};
const flatItem = {
messageId: 123456,
conversationCode: conversation.cid,
sendTime: 1_700_000_000_000,
msgType: 101,
type: 1,
subType: 1,
messageType: "rec",
status: 1,
unread: 2,
sender: { targetId: "buyer" },
content: "SDK display text is not canonical history content",
originalData: { text: "Order draft notification" },
localizedUiText: "订单草稿通知",
dataOriginal: "not-json",
};
const posted = [];
const events = [];
let historyFailure = false;
const messageService = {
fetchMessagesWithoutUpdateToRead: async () => {
if (historyFailure) throw new Error("onetalk_history_message_request_failed");
return {
code: 200,
body: { list: [flatItem], hasMore: 0 },
};
},
};
const pageWindow = {
location: {
href: "https://onetalk.alibaba.com/?activeAccountId=buyer",
origin: "https://onetalk.alibaba.com",
},
currentUserAccountId: "login-account-1",
__conversationListFullData__: [
{ owner: { accountId: "login-account-1", aliId: "seller" } },
],
addEventListener: () => {},
postMessage: (message, targetOrigin) => {
posted.push({ message, targetOrigin });
events.push("progress");
},
IcbuIM: {
IMBaaSSDK: {
default: {
getConversationServiceV2: () => ({
getConversationListByPagination: async () => ({
list: [conversation],
hasMore: false,
}),
}),
getMessageService: () => messageService,
},
},
},
};
return {
flatItem,
pageWindow,
posted,
events,
failHistory: () => {
historyFailure = true;
},
};
};
test("reads selected SPA DOM data-cid instead of URL conversationId", () => {
const { pageWindow, selectors } = createSendPage(["dom-conversation"]);
assert.deepEqual(readSelectedConversationIds(pageWindow), ["dom-conversation"]);
assert.equal(readCurrentConversationId(pageWindow), "dom-conversation");
assert.deepEqual(selectors, [
".contact-item-container.selected[data-cid]",
".contact-item-container.selected[data-cid]",
]);
});
test("calls sendUIMessages once with the verified input and never treats opId as confirmed", async () => {
const { pageWindow, calls, rejectedCalls } = createSendPage();
const result = await handleSendCommand(pageWindow);
assert.deepEqual(result, {
status: "delivery_unknown",
reason: "send_state_lost",
});
assert.deepEqual(calls, [
{ cid: "conversation-1", conversationCode: "conversation-1", content: "hello" },
]);
assert.deepEqual(rejectedCalls, []);
});
test("passes a MAIN-only raw text quote as a top-level SDK referMessage", async () => {
const { pageWindow, calls } = createSendPage();
const quoteSources = createOneTalkQuoteSourceIndex();
quoteSources.register(
pageWindow,
{
messageId: 123456,
conversationCode: "conversation-1",
subType: 1,
messageType: "rec",
sender: { targetId: "buyer" },
contact: { name: "Buyer" },
originalData: { text: "quoted raw text" },
sendTime: 1_700_000_000_000,
},
{
messageType: "history",
upstreamType: 1,
messageId: "123456",
conversationId: "conversation-1",
senderId: "buyer@icbu",
direction: "received",
sentAtMs: 1_700_000_000_000,
content: { version: 1, kind: "text", text: "quoted raw text" },
participantIds: ["buyer@icbu", "seller@icbu"],
readStatus: 0,
messageStatus: 1,
unreadCount: 0,
},
);
assert.deepEqual(
await handleOneTalkSendCommand(
pageWindow,
{
command: {
action: "onetalk.send",
conversationId: "conversation-1",
content: { kind: "text", text: "reply" },
replyToMessageId: "123456",
},
},
createSendObservationCorrelator(1),
quoteSources,
),
{ status: "delivery_unknown", reason: "send_state_lost" },
);
assert.deepEqual(calls, [
{
cid: "conversation-1",
conversationCode: "conversation-1",
content: "reply",
referMessage: {
msgId: "123456.PNM",
subType: 1,
senderAliId: "buyer",
senderName: "Buyer",
contentAbstract: "quoted raw text",
originalData: { text: "quoted raw text" },
sendTime: 1_700_000_000_000,
},
},
]);
});
test("rejects missing and non-text quote sources before invoking the SDK", async () => {
const { pageWindow, calls } = createSendPage();
const quoteSources = createOneTalkQuoteSourceIndex();
for (const command of [
{
action: "onetalk.send",
conversationId: "conversation-1",
content: { kind: "text", text: "reply" },
replyToMessageId: "missing",
},
{
action: "onetalk.send",
conversationId: "conversation-1",
content: {
kind: "image",
source: {
downloadUrl: "https://mind.example.test/bridge/image-1",
fileName: "image.jpg",
mimeType: "image/jpeg",
},
},
replyToMessageId: "123456",
},
]) {
assert.deepEqual(
await handleOneTalkSendCommand(
pageWindow,
{ command },
createSendObservationCorrelator(1),
quoteSources,
),
{ status: "rejected_before_send", reason: "invalid_request" },
);
}
assert.deepEqual(calls, []);
});
test("does not use selected conversation as a send gate", async () => {
const empty = createSendPage([]);
assert.deepEqual(await handleSendCommand(empty.pageWindow), {
status: "delivery_unknown",
reason: "send_state_lost",
});
const ambiguous = createSendPage(["conversation-1", "conversation-2"]);
assert.deepEqual(await handleSendCommand(ambiguous.pageWindow), {
status: "delivery_unknown",
reason: "send_state_lost",
});
const mismatch = createSendPage(["conversation-2"]);
assert.deepEqual(await handleSendCommand(mismatch.pageWindow), {
status: "delivery_unknown",
reason: "send_state_lost",
});
const invalidContent = createSendPage();
assert.deepEqual(
await handleSendCommand(invalidContent.pageWindow, sendCommand("conversation-1", 7)),
{ status: "rejected_before_send", reason: "invalid_request" },
);
assert.deepEqual(empty.calls, [
{ cid: "conversation-1", conversationCode: "conversation-1", content: "hello" },
]);
assert.deepEqual(ambiguous.calls, [
{ cid: "conversation-1", conversationCode: "conversation-1", content: "hello" },
]);
assert.deepEqual(mismatch.calls, [
{ cid: "conversation-1", conversationCode: "conversation-1", content: "hello" },
]);
assert.deepEqual(invalidContent.calls, []);
});
test("fails closed for file commands when MAIN cannot resolve the native uploader", async () => {
const { pageWindow, calls } = createSendPage(["conversation-2"]);
const result = await handleOneTalkSendCommand(
pageWindow,
{
requestId: "file-send",
command: {
action: "onetalk.send",
conversationId: "conversation-1",
content: {
kind: "file",
source: {
downloadUrl: "https://mind.example.test/bridge/attachment-1",
fileName: "quotation.zip",
mimeType: "application/x-mind-approved-file",
},
},
},
},
createSendObservationCorrelator(1),
);
assert.deepEqual(result, { status: "delivery_unknown", reason: "send_state_lost" });
assert.deepEqual(calls, []);
});
test("requires sendUIMessages even when an unverified send API is present", async () => {
const { pageWindow, rejectedCalls } = createSendPage();
delete pageWindow.IcbuIM.IMBaaSSDK.default.getMessageService;
pageWindow.IcbuIM.IMBaaSSDK.default.getMessageService = () => ({
sendMessage: () => rejectedCalls.push("sendMessage"),
sendTextMessage: () => rejectedCalls.push("sendTextMessage"),
});
assert.deepEqual(await handleSendCommand(pageWindow), {
status: "rejected_before_send",
reason: "send_not_supported",
});
assert.deepEqual(rejectedCalls, []);
});
test("rejects primitive ext values before invoking the SDK", async () => {
const { pageWindow, calls } = createSendPage();
assert.deepEqual(
await handleSendCommand(pageWindow, {
command: {
action: "onetalk.send",
conversationId: "conversation-1",
content: "hello",
ext: 7,
},
}),
{ status: "rejected_before_send", reason: "invalid_request" },
);
assert.deepEqual(calls, []);
});
test("publishes flat SDK history as a normalized bridge batch", async () => {
const { flatItem, pageWindow, posted, events } = createHistorySyncPage();
const batches = [];
const result = await handleOneTalkHistoryCommand(
pageWindow,
{ command: { action: "onetalk.sync" } },
(batch) => {
batches.push(batch);
events.push("observation");
},
);
assert.equal(result.status, "completed");
assert.deepEqual(batches[0].messages, [
{
messageType: "history",
upstreamType: 1,
messageId: "123456",
conversationId: flatItem.conversationCode,
senderId: "buyer@icbu",
direction: "received",
sentAtMs: flatItem.sendTime,
content: { version: 1, kind: "text", text: "Order draft notification" },
participantIds: ["buyer@icbu", "seller@icbu"],
readStatus: 2,
messageStatus: 1,
unreadCount: 0,
},
]);
const observed = createOneTalkPageObservedMessage(
batches[0].messages,
undefined,
batches[0].diagnostics,
);
assert.deepEqual(decodeOneTalkPageMessage(observed), observed);
assert.equal(JSON.stringify(batches).includes("订单草稿通知"), false);
assert.equal(JSON.stringify(batches).includes("not-json"), false);
assert.equal(JSON.stringify(posted).includes("订单草稿通知"), false);
assert.deepEqual(events, ["observation", "progress"]);
});
test("global history sync uses the same optional history publisher", async () => {
const { flatItem, pageWindow } = createHistorySyncPage();
const batches = [];
installCurrentConversationHistorySync(pageWindow, {
onHistoryItems: (items) => batches.push(createHistoryMessageBatch(items, pageWindow)),
});
const result = await pageWindow.__tradeMessageCenterOneTalk.syncCurrentConversationHistory();
assert.equal(result.exit, "history_exhausted");
assert.equal(batches.length, 1);
assert.equal(batches[0].messages[0].messageId, "123456");
assert.equal(batches[0].messages[0].messageType, "history");
assert.equal(flatItem.messageId, 123456);
});
test("settles bootstrap tooltip progress only after a successful or final history attempt", async () => {
const { pageWindow, failHistory } = createHistorySyncPage();
const calls = [];
const progress = {
beginDiscovery: () => calls.push(["begin"]),
completeDiscovery: (entries, terminalConversationIds) =>
calls.push([
"discovery",
entries.map((entry) => entry.conversationId),
terminalConversationIds,
]),
failDiscovery: () => calls.push(["discovery-failed"]),
completeConversation: (conversationId) => calls.push(["conversation", conversationId]),
};
const discovery = await handleOneTalkHistoryCommand(
pageWindow,
{
command: {
action: "onetalk.discover-conversations",
terminalConversationIds: ["buyer-seller#tenant@icbu"],
},
},
undefined,
progress,
);
assert.deepEqual(discovery.status, "completed");
failHistory();
assert.deepEqual(
await handleOneTalkHistoryCommand(
pageWindow,
{
command: {
action: "onetalk.sync.conversation",
conversationId: "buyer-seller#tenant@icbu",
finalAttempt: false,
},
},
undefined,
progress,
),
{ status: "failed", reason: "onetalk_history_message_request_failed" },
);
assert.deepEqual(calls, [
["begin"],
["discovery", ["buyer-seller#tenant@icbu"], ["buyer-seller#tenant@icbu"]],
]);
assert.deepEqual(
await handleOneTalkHistoryCommand(
pageWindow,
{
command: {
action: "onetalk.sync.conversation",
conversationId: "buyer-seller#tenant@icbu",
finalAttempt: true,
},
},
undefined,
progress,
),
{ status: "failed", reason: "onetalk_history_message_request_failed" },
);
assert.deepEqual(calls.at(-1), ["conversation", "buyer-seller#tenant@icbu"]);
});
test("keeps history command results unchanged when the local tooltip boundary fails", async () => {
const { pageWindow, failHistory } = createHistorySyncPage();
const throwingProgress = {
beginDiscovery: () => {
throw new Error("tooltip_begin_failed");
},
completeDiscovery: () => {
throw new Error("tooltip_complete_failed");
},
failDiscovery: () => {
throw new Error("tooltip_fail_failed");
},
completeConversation: () => {
throw new Error("tooltip_settle_failed");
},
};
assert.equal(
(
await handleOneTalkHistoryCommand(
pageWindow,
{ command: { action: "onetalk.discover-conversations" } },
undefined,
throwingProgress,
)
).status,
"completed",
);
assert.equal(
(
await handleOneTalkHistoryCommand(
pageWindow,
{
command: {
action: "onetalk.sync.conversation",
conversationId: "buyer-seller#tenant@icbu",
finalAttempt: true,
},
},
undefined,
throwingProgress,
)
).status,
"completed",
);
failHistory();
assert.deepEqual(
await handleOneTalkHistoryCommand(
pageWindow,
{
command: {
action: "onetalk.sync.conversation",
conversationId: "buyer-seller#tenant@icbu",
finalAttempt: true,
},
},
undefined,
throwingProgress,
),
{ status: "failed", reason: "onetalk_history_message_request_failed" },
);
const failedDiscovery = createHistorySyncPage();
failedDiscovery.pageWindow.IcbuIM = undefined;
assert.equal(
(
await handleOneTalkHistoryCommand(
failedDiscovery.pageWindow,
{ command: { action: "onetalk.discover-conversations" } },
undefined,
throwingProgress,
)
).status,
"failed",
);
});
test("does not turn a successful history result into a failure when tooltip settlement throws", async () => {
const { pageWindow } = createHistorySyncPage();
let throwTooltipError = true;
const progress = {
beginDiscovery: () => {},
completeDiscovery: () => {},
failDiscovery: () => {},
completeConversation: () => {
if (throwTooltipError) {
throwTooltipError = false;
throw new Error("tooltip_dom_failed");
}
},
};
await handleOneTalkHistoryCommand(
pageWindow,
{ command: { action: "onetalk.discover-conversations" } },
undefined,
progress,
);
const result = await handleOneTalkHistoryCommand(
pageWindow,
{
command: {
action: "onetalk.sync.conversation",
conversationId: "buyer-seller#tenant@icbu",
},
},
undefined,
progress,
);
assert.equal(result.status, "completed");
});
test("maps an invalid page action to the shared invalid_request reason", async () => {
const { pageWindow, calls } = createSendPage();
assert.deepEqual(
await handleOneTalkHistoryCommand(pageWindow, {
command: { action: "unknown.action" },
}),
{ status: "rejected_before_send", reason: "invalid_request" },
);
assert.deepEqual(calls, []);
});
class BridgePageWindow {
constructor() {
this.location = {
href: `${pageOrigin}/?activeAccountId=account-1&conversationId=url-value`,
origin: pageOrigin,
};
this.currentUserAccountId = "login-account-1";
this.selectedIds = ["conversation-1"];
this.posted = [];
this.listeners = [];
this.documentListeners = [];
this.document = {
querySelectorAll: () => this.selectedIds.map(selectedElement),
addEventListener: (type, listener) => {
this.documentListeners.push({ type, listener });
},
};
}
addEventListener(type, listener) {
this.listeners.push({ type, listener });
}
postMessage(message, targetOrigin) {
this.posted.push({ message, targetOrigin });
}
dispatchDocumentClick() {
for (const { type, listener } of this.documentListeners) {
if (type === "click") listener();
}
}
}
test("refreshes hello with the selected SPA identity after a click without URL changes", () => {
const pageWindow = new BridgePageWindow();
installOneTalkMainPageBridge(pageWindow);
pageWindow.selectedIds = ["conversation-2"];
pageWindow.dispatchDocumentClick();
assert.deepEqual(
pageWindow.posted.map(({ message }) => message),
[
{
source: "trade-message-center.onetalk.page-bridge",
version: ONE_TALK_PAGE_BRIDGE_VERSION,
type: "onetalk.page.hello",
channelAccountId: "login-account-1",
conversationId: "conversation-1",
},
{
source: "trade-message-center.onetalk.page-bridge",
version: ONE_TALK_PAGE_BRIDGE_VERSION,
type: "onetalk.page.hello",
channelAccountId: "login-account-1",
conversationId: "conversation-2",
},
],
);
});
test("encodes none and multiple selected DOM states in hello without inventing an ID", () => {
for (const conversationSelection of ["none", "multiple"]) {
const pageWindow = new BridgePageWindow();
pageWindow.selectedIds =
conversationSelection === "none" ? [] : ["conversation-1", "conversation-2"];
installOneTalkMainPageBridge(pageWindow);
const hello = pageWindow.posted[0].message;
assert.equal(hello.conversationId, undefined);
assert.equal(hello.conversationSelection, conversationSelection);
assert.deepEqual(decodeOneTalkPageMessage(hello), hello);
}
});