mirror of
https://github.com/sinanyuntu/trade-message-center.git
synced 2026-09-17 13:22:11 +08:00
449 lines
14 KiB
TypeScript
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, "");
|
|
});
|