Files
trade-message-center/apps/server/test/harness-runtime.test.ts
T

449 lines
14 KiB
TypeScript

// 验证原生 harness 的异步状态边界
import assert from "node:assert/strict";
import vm from "node:vm";
import test from "node:test";
import { ONE_TALK_HARNESS_HTML } from "../src/http/harness.ts";
const HARNESS_SCRIPT = ONE_TALK_HARNESS_HTML.match(/<script>\n([\s\S]*?)\n <\/script>/)?.[1];
const elementIds = [
"account-id",
"conversation-query",
"conversation-id",
"read-purpose",
"from-sent-at-ms",
"to-sent-at-ms",
"mind-cookie",
"load-conversations",
"load-more-conversations",
"write-cookie",
"load-history",
"load-next",
"reconnect",
"send",
"send-content",
"history-status",
"connection-status",
"cookie-status",
"plugin-status",
"sync-status",
"messages",
"message-empty",
] as const;
type HarnessElement = {
value: string;
disabled: boolean;
hidden: boolean;
dataset: Record<string, string>;
innerHTML: string;
options: Array<{ value: string }>;
events: Map<string, () => void | Promise<void>>;
add: (option: { value: string }) => void;
addEventListener: (name: string, handler: () => void | Promise<void>) => void;
querySelector: () => { textContent: string };
};
const createHarnessElement = (): HarnessElement => {
const span = { textContent: "" };
const element: HarnessElement = {
value: "",
disabled: false,
hidden: false,
dataset: {},
innerHTML: "",
options: [],
events: new Map(),
add(option) {
this.options.push(option);
if (this.value === "") this.value = option.value;
},
addEventListener(name, handler) {
this.events.set(name, handler);
},
querySelector: () => span,
};
return element;
};
const createResponse = (body: unknown) => ({
ok: true,
json: async () => body,
headers: { get: () => null },
});
const scopeFor = (channelAccountId: string) => ({
mindUserId: `mind-${channelAccountId}`,
workspaceId: `workspace-${channelAccountId}`,
channelAccountId,
});
const conversationFor = (channelAccountId: string) => ({
channelAccountId,
conversationId: `conversation-${channelAccountId}`,
conversationType: "direct",
name: `Buyer ${channelAccountId}`,
avatarUrl: null,
participantIds: [],
latestMessageId: null,
latestMessageAtMs: null,
unreadCount: 0,
messageCount: 0,
historyComplete: true,
syncPhase: "initial",
syncResult: "succeeded",
});
test("does not let an old account list response reset the current account", async () => {
assert.ok(HARNESS_SCRIPT);
const elements = new Map<string, HarnessElement>();
for (const id of elementIds) elements.set(id, createHarnessElement());
const pending = new Map<string, { resolve: (response: unknown) => void }>();
const calls: string[] = [];
const context = {
document: {
cookie: "",
getElementById: (id: string) => elements.get(id),
},
fetch: (url: string) => {
calls.push(url);
return new Promise((resolve) => pending.set(url, { resolve }));
},
WebSocket: class {
static OPEN = 1;
},
Option: class {
readonly value: string;
constructor(_text: string, value: string) {
this.value = value;
}
},
URLSearchParams,
Map,
String,
JSON,
Number,
Math,
Date,
Error,
setTimeout,
clearTimeout,
location: { protocol: "http:", host: "bright.test" },
};
vm.runInNewContext(HARNESS_SCRIPT, context);
const account = elements.get("account-id");
const query = elements.get("conversation-query");
const load = elements.get("load-conversations");
const conversations = elements.get("conversation-id");
const loadMore = elements.get("load-more-conversations");
assert.ok(account && query && load && conversations && loadMore);
const input = (element: HarnessElement, value: string) => {
element.value = value;
element.events.get("input")?.();
};
const click = async (element: HarnessElement) => {
await element.events.get("click")?.();
};
const listResponse = (channelAccountId: string, cursor: string) =>
createResponse({
scope: scopeFor(channelAccountId),
plugin: { status: "offline" },
conversations: [conversationFor(channelAccountId)],
page: { hasMore: true, nextCursor: cursor },
});
const resolveFor = (channelAccountId: string, response: unknown) => {
const url = [...pending.keys()].find((value) =>
value.includes(`/accounts/${channelAccountId}/`),
);
assert.ok(url);
pending.get(url)?.resolve(response);
pending.delete(url);
};
input(account, "account-a");
input(query, "buyer-a");
const accountARequest = click(load);
input(account, "account-b");
input(query, "buyer-b");
const accountBRequest = click(load);
assert.equal(calls.length, 2);
await resolveFor("account-b", listResponse("account-b", "cursor-b"));
await accountBRequest;
await resolveFor("account-a", listResponse("account-a", "cursor-a"));
await accountARequest;
assert.equal(conversations.options[0]?.value, "conversation-account-b");
assert.equal(loadMore.disabled, false);
});
test("ignores plugin and sync events outside the active page scope", async () => {
assert.ok(HARNESS_SCRIPT);
const elements = new Map<string, HarnessElement>();
for (const id of elementIds) elements.set(id, createHarnessElement());
const sockets: Array<{
readyState: number;
onclose?: () => void;
onmessage?: (event: { data: string }) => void;
close: () => void;
send: () => void;
}> = [];
class HarnessSocket {
static OPEN = 1;
readyState = HarnessSocket.OPEN;
onclose?: () => void;
onmessage?: (event: { data: string }) => void;
constructor() {
sockets.push(this);
}
close = (): void => {
this.readyState = 3;
this.onclose?.();
};
send = (): void => {};
}
const channelAccountId = "account-a";
const context = {
document: {
cookie: "",
getElementById: (id: string) => elements.get(id),
},
fetch: (url: string) => {
if (url.includes("/messages")) {
return Promise.resolve(
createResponse({
scope: scopeFor(channelAccountId),
conversationId: conversationFor(channelAccountId).conversationId,
messages: [],
page: { hasMore: false, nextCursor: null },
}),
);
}
return Promise.resolve(
createResponse({
scope: scopeFor(channelAccountId),
plugin: { status: "offline" },
conversations: [conversationFor(channelAccountId)],
page: { hasMore: false, nextCursor: null },
}),
);
},
WebSocket: HarnessSocket,
Option: class {
readonly value: string;
constructor(_text: string, value: string) {
this.value = value;
}
},
URLSearchParams,
Map,
String,
JSON,
Number,
Math,
Date,
Error,
setTimeout,
clearTimeout,
location: { protocol: "http:", host: "bright.test" },
};
vm.runInNewContext(HARNESS_SCRIPT, context);
const account = elements.get("account-id");
const load = elements.get("load-conversations");
const loadHistory = elements.get("load-history");
const pluginStatus = elements.get("plugin-status");
const syncStatus = elements.get("sync-status");
assert.ok(account && load && loadHistory && pluginStatus && syncStatus);
account.value = channelAccountId;
account.events.get("input")?.();
await load.events.get("click")?.();
await loadHistory.events.get("click")?.();
assert.equal(sockets.length, 1);
const foreignScope = scopeFor("account-b");
sockets[0]?.onmessage?.({
data: JSON.stringify({
type: "plugin.status",
scope: foreignScope,
payload: { status: "online" },
}),
});
sockets[0]?.onmessage?.({
data: JSON.stringify({
type: "sync.status",
scope: foreignScope,
payload: {
conversationId: conversationFor(channelAccountId).conversationId,
mode: "full",
syncPhase: "initial",
syncResult: "succeeded",
latestMessageId: null,
historyComplete: true,
messageCount: 0,
anchorAdvanced: false,
},
}),
});
assert.equal(pluginStatus.dataset.state, "warn");
assert.equal(syncStatus.dataset.state, undefined);
});
test("does not add conversations whose nested account scope differs from the response scope", async () => {
assert.ok(HARNESS_SCRIPT);
const elements = new Map<string, HarnessElement>();
for (const id of elementIds) elements.set(id, createHarnessElement());
const context = {
document: {
cookie: "",
getElementById: (id: string) => elements.get(id),
},
fetch: () =>
Promise.resolve(
createResponse({
scope: scopeFor("account-a"),
plugin: { status: "offline" },
conversations: [conversationFor("account-b")],
page: { hasMore: false, nextCursor: null },
}),
),
WebSocket: class {
static OPEN = 1;
},
Option: class {
readonly value: string;
constructor(_text: string, value: string) {
this.value = value;
}
},
URLSearchParams,
Map,
String,
JSON,
Number,
Math,
Date,
Error,
setTimeout,
clearTimeout,
location: { protocol: "http:", host: "bright.test" },
};
vm.runInNewContext(HARNESS_SCRIPT, context);
const account = elements.get("account-id");
const conversations = elements.get("conversation-id");
const load = elements.get("load-conversations");
assert.ok(account && conversations && load);
account.value = "account-a";
await load.events.get("click")?.();
assert.equal(conversations.options[0]?.value, "");
});
test("does not apply an old history response after an account generation changes", async () => {
assert.ok(HARNESS_SCRIPT);
const elements = new Map<string, HarnessElement>();
for (const id of elementIds) elements.set(id, createHarnessElement());
let resolveHistory: ((response: unknown) => void) | undefined;
const context = {
document: {
cookie: "",
getElementById: (id: string) => elements.get(id),
},
fetch: (url: string) => {
if (url.includes("/messages")) {
return new Promise((resolve) => {
resolveHistory = resolve;
});
}
return Promise.resolve(
createResponse({
scope: scopeFor("account-a"),
plugin: { status: "offline" },
conversations: [conversationFor("account-a")],
page: { hasMore: false, nextCursor: null },
}),
);
},
WebSocket: class {
static OPEN = 1;
},
Option: class {
readonly value: string;
constructor(_text: string, value: string) {
this.value = value;
}
},
URLSearchParams,
Map,
String,
JSON,
Number,
Math,
Date,
Error,
setTimeout,
clearTimeout,
location: { protocol: "http:", host: "bright.test" },
};
vm.runInNewContext(HARNESS_SCRIPT, context);
const account = elements.get("account-id");
const load = elements.get("load-conversations");
const loadHistory = elements.get("load-history");
const messages = elements.get("messages");
assert.ok(account && load && loadHistory && messages);
account.value = "account-a";
account.events.get("input")?.();
await load.events.get("click")?.();
const oldHistoryRequest = loadHistory.events.get("click")?.();
account.value = "account-b";
account.events.get("input")?.();
account.value = "account-a";
account.events.get("input")?.();
await load.events.get("click")?.();
resolveHistory?.(
createResponse({
scope: scopeFor("account-a"),
conversationId: conversationFor("account-a").conversationId,
messages: [
{
messageId: "stale-message",
conversationId: conversationFor("account-a").conversationId,
senderId: "sender-a",
participantIds: ["sender-a", "account-a"],
direction: "received",
sentAtMs: 100,
readStatus: "read",
content: { version: 1, kind: "text", text: "stale" },
},
],
page: { hasMore: false, nextCursor: null },
}),
);
await oldHistoryRequest;
assert.equal(messages.innerHTML, "");
});