feat: centralize OneTalk DOM interactions

This commit is contained in:
YBF
2026-09-14 11:07:57 +08:00
parent 7012bf9547
commit 66d03fdbb1
24 changed files with 1085 additions and 496 deletions
@@ -24,7 +24,7 @@ class ConnectionStatusTooltip {
}
```
入口由 `src/onetalk/main-page/page-script-entry.ts` 安装;复制控件实现在 `src/onetalk/main-page/conversation-id-copy.ts`ID 读取复用 `readCurrentConversationId(pageWindow)`动作提示实现在 `src/onetalk/main-page/action-status-tooltip.ts`,并将 facade 安装到 `window.__tradeMessageCenterOneTalk.tooltip`
入口由 `src/onetalk/main-page/page-script-entry.ts` 安装;复制控件和动作提示的 DOM 实现分别位于 `src/onetalk/main-page/dom/conversation-id-copy.ts``src/onetalk/main-page/dom/action-status-tooltip.ts`。旧根路径仅保留兼容 re-export,不能再次拥有 DOM API。复制控件的 ID 读取复用 `readCurrentConversationId(pageWindow)`动作提示将 facade 安装到 `window.__tradeMessageCenterOneTalk.tooltip`
## 3. Contracts
@@ -33,6 +33,7 @@ class ConnectionStatusTooltip {
- 按钮直接追加到标题节点,使用 `inline-flex``justify-content: center` 保持同行及文字水平居中;文字为“复制会话 Id”,字体显式为 `12px`,不能依赖会话标题的继承字体。
- 点击处理在用户手势中调用 `navigator.clipboard.writeText(conversationId)`;成功显示“已复制”,不可用或 reject 显示“复制失败”,随后恢复按钮文案。不会以 `execCommand`、隐形文本框或其它方式降级复制。
- 页面 DOM 尚未就绪或 SPA 重绘时可由 `MutationObserver` 重新尝试挂载;同一页面只能存在一个该属性的按钮。
- 非观测页面 DOM 查询、控件创建和更新仅归 `main-page/dom/` 所有;调用方只能使用其语义 API,不能重新取得同一 DOM 权限。
- 动作提示以固定定位、`pointer-events: none` 的 extension-owned `data-tmc-action-status-*` DOM 节点呈现,不依赖 OneTalk 的业务 DOM,也不改变宿主布局。多个活动条目按照首次 `start` 的顺序纵向显示。
- `start` 对同一 `id` 幂等,不覆盖已有行;`update``close` 只作用于已存在的 `id`,找不到时返回 `false`,不得隐式创建行。颜色只接受 `neutral``info``warning``error`,任意 CSS 色值必须显式失败。
- 状态 facade 必须保留身份,并在再次安装、`start``update` 时重新挂载所有仍活动的行;宿主移除了 container 或某一行都不能改变状态顺序或丢失活动项。
@@ -0,0 +1,4 @@
{"file":".trellis/spec/project/source-file-conventions.md","reason":"检查新增和移动 TypeScript 文件的职责注释、bottom-up 和 main-last。"}
{"file":".trellis/spec/chrome-extension/frontend/quality-guidelines.md","reason":"检查包级依赖位置、Node test runner、typecheck 和 Vite 构建。"}
{"file":".trellis/spec/chrome-extension/frontend/onetalk/page-controls.md","reason":"验证重构后复制控件和提示浮层的回归边界。"}
{"file":".trellis/spec/chrome-extension/frontend/onetalk/page-bridge.md","reason":"验证页面身份、bridge 和自包含 MAIN 入口不发生行为漂移。"}
@@ -0,0 +1,44 @@
# 非观测 DOM 边界设计
## Boundary
`apps/chrome-extension/src/onetalk/main-page/dom/` 是本任务中 OneTalk MAIN-world 非观测 DOM 访问的唯一所有者。它是 OneTalk 专属的页面适配边界,不提升到 `src/lib/`,也不与 Popup 共用。
```text
main-page/
├── dom/
│ ├── selection.ts # selected 会话和标题节点的 DOM 读取
│ ├── selection-events.ts # document click 订阅
│ ├── react-file-uploader.ts # file input 到 React uploader 的窄适配
│ ├── action-status-tooltip.ts # extension-owned 状态浮层
│ └── conversation-id-copy.ts # extension-owned 复制按钮
├── page-context.ts # URL/页面运行时身份与 selection 领域投影
├── image-send.ts # 媒体发送状态机和结果语义
└── page-script-entry.ts # 组合入口
```
`buyer-fact-observer/``contact-observer/` 不移动、不改 import、不改测试;它们仍自行拥有被动观察和投影。
## Responsibilities
| 现有责任 | 新 DOM 所有者 | 保留的业务所有者 |
| --- | --- | --- |
| selected `[data-cid]`、标题定位 | `dom/selection.ts` | `page-context.ts``none/single/multiple/unavailable` 语义 |
| document click 刷新 identity | `dom/selection-events.ts` | `page-bridge/main.ts` 的 hello/retry/bridge 生命周期 |
| 找到 file input 并上溯 React uploader | `dom/react-file-uploader.ts` | `image-send.ts` 的请求截止、上传拦截和 delivery result |
| tooltip DOM 创建、重挂载和销毁 | `dom/action-status-tooltip.ts` | connection/history 组件对 tooltip facade 的调用 |
| 复制按钮 DOM 创建、更新和移除 | `dom/conversation-id-copy.ts` | 当前会话 ID 可信来源及 Clipboard 成功/失败语义 |
移动只改变 DOM 适配层归属。公开函数签名、stable error/reason、桥接 message、SDK 发送和页面可见行为必须不变。
## Testing Library decision
`@testing-library/dom``@testing-library/user-event` 是测试依赖,而不是 MAIN-world 自动化引擎。现有代码没有主动操作 OneTalk UI 的业务命令;把 `user-event` 打进生产包既不能产生 trusted event,也会增加无消费者的 bundle 依赖。
测试层新增由 `jsdom` 创建和销毁的真实 DOM fixture,以 Testing Library 的 role/name 查询和 `userEvent.setup().click` 覆盖 extension-owned 复制按钮。项目的 Node `>=22.22.2` 基线满足当前 jsdom 的 Node 要求;每个测试必须显式传入 fixture document,并在结束时恢复替换过的全局对象。现有手写 fixture 仍可保留给页面结构异常、MutationObserver 和边界错误的单元测试。未来新增真实页面动作时,另行定义动作契约、失败语义和运行时策略,不能把测试工具直接暴露给 Bridge command。
## Compatibility and rollback
- 入口仍从 `page-script-entry.ts` 安装同名控件,MAIN/ISOLATED 入口和 Manifest 不变。
- 所有新导入必须保持 MAIN entry 的 IIFE + `inlineDynamicImports` 构建约束;Testing Library 仅被 test 文件导入,不能进入该入口依赖图。
- 这是可逆目录/依赖重构:若语义回归,恢复原 import 路径和 DOM adapter 文件即可;不得通过并行旧/新实现作运行时 fallback。
@@ -0,0 +1,5 @@
{"file":".trellis/spec/project/module-organization.md","reason":"确定 OneTalk 专属 DOM 外部适配器的最近共同所有者和职责边界。"}
{"file":".trellis/spec/project/module-ownership.md","reason":"移动类型与导出时保持唯一所有者和 canonical import path。"}
{"file":".trellis/spec/chrome-extension/frontend/architecture.md","reason":"遵守渠道目录、MAIN world 和 Popup 独立入口边界。"}
{"file":".trellis/spec/chrome-extension/frontend/onetalk/page-controls.md","reason":"保留会话复制和状态提示的可见行为与错误契约。"}
{"file":".trellis/spec/chrome-extension/frontend/onetalk/page-bridge.md","reason":"保留 bridge identity 刷新和 MAIN IIFE 契约。"}
@@ -0,0 +1,33 @@
# 实施计划
## 1. 建立 DOM 适配目录
- 新建 `src/onetalk/main-page/dom/`,按设计中的五类 DOM 责任迁移实现。
- 在移动前,对每个被改动的导出符号执行 GitNexus upstream impact;风险为 HIGH 或 CRITICAL 时先报告并复核范围。
- 保持函数名与返回值稳定;只在 `page-context.ts``image-send.ts` 保留领域编排,改为调用 DOM adapter。
## 2. 更新组合与测试导入
- 更新 `page-script-entry.ts``page-bridge/main.ts``page-context.ts``image-send.ts` 的内部导入,确保没有残留的非 observer 直接 `document.querySelector*`、元素创建/挂载或 document click 订阅。
- 更新 `onetalk-action-status-tooltip``onetalk-conversation-id-copy``onetalk-page-context``onetalk-send-page``onetalk-page-bridge``onetalk-image-send``onetalk-file-send` 的必要导入与 fixture。
- 不修改 `main-page/buyer-fact-observer/``main-page/contact-observer/` 或其测试。
## 3. 引入并使用 Testing Library
-`apps/chrome-extension/package.json``devDependencies` 添加 `@testing-library/dom``@testing-library/user-event``jsdom`
- 新增最小测试 helper,负责创建/销毁真实 document 和恢复全局状态;不替换所有现有 fixture。
- 让复制控件测试通过 Testing Library 按 role/name 定位按钮,并用 `userEvent.click` 验证 Clipboard 调用与成功/失败反馈。
## 4. 验证与审查
- 运行受影响的 focused tests,单次 Node test 命令不超过 60 秒。
- 运行 `pnpm --filter @trade-message-center/chrome-extension typecheck`、扩展 build 和完整扩展 test。
- 搜索非 observer 的 MAIN-page source,确认 DOM API 只在 `main-page/dom/` 和明确保留的 Popup 中出现。
- 执行 GitNexus `detect_changes()`,确认只影响 DOM adapter、调用方、测试和依赖清单;审查没有改变 bridge、发送或观察语义。
## Risk points
- `readSelectedConversationIds` 供 page bridge、买家事实和联系人资料路径调用;只抽取 DOM 读取,不得改其零/多选结果。
- 复制控件自身使用 MutationObserver 来适配 SPA 重绘,但其文件不是被冻结的 observer 目录,仍随控件迁移。
- React file uploader 查找依赖宿主内部 fiber,迁移必须保持 null/多候选和发送失败语义。
- Node 内置 test runner 没有浏览器 DOM;新测试 runtime 必须局部化,不能污染其它测试的全局对象。
@@ -0,0 +1,36 @@
# 集中非观测 DOM 交互
## Goal
把 OneTalk 页面中非观测用途的直接 DOM 访问收敛到明确的 DOM 边界,并使用 `@testing-library/dom``@testing-library/user-event` 对该边界的用户交互进行验证。这样新增页面动作不再复制手写 fixture 事件序列,同时不改变现有发送、身份判断或桥接行为。
## Confirmed facts
- OneTalk 页面脚本在 `MAIN` world、`document_start` 注入,并且必须构建为自包含 IIFE。
- 当前没有 `element.click()``dispatchEvent()``InputEvent``user-event`;DOM 使用主要是读取页面状态,或注入扩展自有提示和复制控件。
- 非 observer 的 OneTalk DOM 触点位于:`action-status-tooltip.ts``conversation-id-copy.ts``page-context.ts``image-send.ts``page-bridge/main.ts`
- `main-page/buyer-fact-observer/``main-page/contact-observer/`(包括其中的 `MutationObserver` 和 DOM 读取)不在本任务范围内,保持原路径与行为不变。
- 扩展尚未引入 Testing Library;运行时只有浏览器原生 DOM,测试使用 Node 内置 test runner 和手写页面 fixture。
- Popup 是扩展自有页面,不纳入本任务;`popup/popup.ts` 保持原路径和行为。
## Requirements
- R1:把本任务范围内 OneTalk 页面 DOM 查询、页面自有控件创建/更新,以及未来动作的公共入口整理到一个专属目录;业务编排、协议解码和页面桥不因目录整理而获得新的 DOM 权限。
- R2:引入 `@testing-library/dom``@testing-library/user-event` 作为 DOM 边界的测试工具,并用真实 DOM fixture 验证受控页面按钮的定位和点击。它们不进入生产 MAIN-world bundle,也不被误当成能生成 trusted event 的运行时自动化方案。
- R3:保留当前的 OneTalk SDK 发送和媒体上传策略;本任务不把发送流程改成 DOM 点击,也不引入 `chrome.debugger`、CDP 或任意 selector/任意脚本的远程执行能力。
- R4:维持 `channelAccountId + conversationId` 的精确页面身份判断、MAIN/ISOLATED/SW 桥接契约,以及现有页面控件的可见行为。
- R5:新增依赖作为扩展测试依赖,并提供 Node test runner 可运行的浏览器 DOM 环境;生产 MAIN-world IIFE 不得引入测试运行时、暴露原始 payload、凭据或开放执行面。
## Acceptance criteria
- [x] AC1:所有纳入范围的 OneTalk 页面直接 DOM 访问均由新目录拥有;调用方只通过其语义 API 使用页面 DOM。
- [x] AC2`buyer-fact-observer/``contact-observer/` 的生产代码保持未修改。
- [x] AC3:至少一项受控页面按钮交互通过 `@testing-library/dom``@testing-library/user-event` 在真实 DOM fixture 中验证;生产入口不打包这些测试依赖。
- [x] AC4:会话 ID 复制控件、动作状态提示、当前会话识别、图片/文件上传器定位、桥接 identity 刷新与重构前行为一致。
- [x] AC5:相关 focused tests、`pnpm --filter @trade-message-center/chrome-extension typecheck` 和扩展构建通过;MAIN-world 入口没有产生 code-splitting 或顶层 import 问题。
## Out of scope
- 被动 DOM 观测:`main-page/buyer-fact-observer/``main-page/contact-observer/` 及其 `MutationObserver`
- OneTalk 页面实际用户动作的新增业务功能、发送语义、协议、授权、Bridge 合约和服务端改动。
- Playwright、`chrome.debugger`、CDP 或 Native Messaging 自动化。
@@ -0,0 +1,26 @@
{
"id": "centralize-dom-interactions",
"name": "centralize-dom-interactions",
"title": "集中非观测 DOM 交互",
"description": "将 Chrome 扩展中 observer 目录以外的页面 DOM 访问收敛到明确边界,并采用 Testing Library。",
"status": "in_progress",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "ybf",
"assignee": "ybf",
"createdAt": "2026-09-14",
"completedAt": null,
"branch": "09-14-centralize-dom-interactions",
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}
+3
View File
@@ -17,7 +17,10 @@
"@trade-message-center/onetalk-contract": "workspace:*"
},
"devDependencies": {
"@testing-library/dom": "^10.4.1",
"@testing-library/user-event": "^14.6.7",
"@types/node": "^22.10.2",
"jsdom": "^30.0.1",
"typescript": "^5.7.3",
"vite": "^6.0.7"
}
@@ -1,192 +1,7 @@
// 管理 OneTalk 页面进行中动作提示
import { isObjectRecord } from "../../lib/guards.ts";
export type TooltipColor = "neutral" | "info" | "warning" | "error";
export type OneTalkActionStatusTooltip = {
start: (id: string, text: string, color?: TooltipColor) => void;
update: (id: string, text: string, color?: TooltipColor) => boolean;
close: (id: string) => boolean;
};
type OneTalkActionStatusTooltipWindow = Pick<Window, "document"> & {
__tradeMessageCenterOneTalk?: unknown;
};
type ActionStatus = {
color: TooltipColor;
row: HTMLDivElement;
};
const STATUS_CONTAINER_ATTRIBUTE = "data-tmc-action-status-tooltip";
const STATUS_ROW_ATTRIBUTE = "data-tmc-action-status-row";
const STATUS_COLOR_ATTRIBUTE = "data-tmc-action-status-color";
const DEFAULT_TOOLTIP_COLOR: TooltipColor = "neutral";
const tooltipRefreshes = new WeakMap<OneTalkActionStatusTooltipWindow, () => void>();
const TOOLTIP_COLORS: Readonly<
Record<TooltipColor, { background: string; border: string; text: string }>
> = {
neutral: { background: "#f5f5f5", border: "#d9d9d9", text: "#595959" },
info: { background: "#e6f4ff", border: "#91caff", text: "#1677ff" },
warning: { background: "#fffbe6", border: "#ffe58f", text: "#d48806" },
error: { background: "#fff2f0", border: "#ffccc7", text: "#cf1322" },
};
const isTooltipColor = (value: unknown): value is TooltipColor => {
return typeof value === "string" && Object.hasOwn(TOOLTIP_COLORS, value);
};
const requireTooltipColor = (value: unknown): TooltipColor => {
if (isTooltipColor(value)) return value;
throw new TypeError("onetalk_action_status_tooltip_color_invalid");
};
const statusRowStyles = (color: TooltipColor): string => {
const theme = TOOLTIP_COLORS[color];
return [
"box-sizing: border-box",
"width: 100%",
"padding: 8px 12px",
"border: 1px solid " + theme.border,
"border-radius: 6px",
"background: " + theme.background,
"color: " + theme.text,
"font-size: 13px",
"line-height: 20px",
"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
"box-shadow: 0 2px 8px rgb(0 0 0 / 12%)",
].join(";");
};
const updateStatusRow = (status: ActionStatus, text: string, color: TooltipColor): void => {
status.color = color;
status.row.textContent = text;
status.row.setAttribute(STATUS_COLOR_ATTRIBUTE, color);
status.row.style.cssText = statusRowStyles(color);
};
const createStatusRow = (
document: Document,
id: string,
text: string,
color: TooltipColor,
): ActionStatus => {
const row = document.createElement("div");
row.setAttribute(STATUS_ROW_ATTRIBUTE, "");
row.dataset.tmcActionStatusId = id;
const status = { color, row };
updateStatusRow(status, text, color);
return status;
};
const createStatusContainer = (document: Document): HTMLDivElement => {
const container = document.createElement("div");
container.setAttribute(STATUS_CONTAINER_ATTRIBUTE, "");
container.setAttribute("aria-live", "polite");
container.setAttribute("role", "status");
container.style.cssText = [
"position: fixed",
"top: 0",
"left: 50%",
"transform: translateX(-50%)",
"z-index: 2147483647",
"display: flex",
"flex-direction: column",
"gap: 8px",
"width: min(600px, calc(100vw - 96px))",
"pointer-events: none",
].join(";");
return container;
};
const findStatusContainer = (document: Document): HTMLDivElement | null => {
return document.querySelector<HTMLDivElement>(`div[${STATUS_CONTAINER_ATTRIBUTE}]`);
};
const ensureStatusContainer = (
document: Document,
statuses: ReadonlyMap<string, ActionStatus>,
): HTMLDivElement => {
const existing = findStatusContainer(document);
const container = existing ?? createStatusContainer(document);
if (!existing) {
if (!document.body)
throw new Error("onetalk_action_status_tooltip_document_body_unavailable");
document.body.append(container);
}
for (const status of statuses.values()) container.append(status.row);
return container;
};
const isTooltipFacade = (value: unknown): value is OneTalkActionStatusTooltip => {
return (
isObjectRecord(value) &&
typeof value.start === "function" &&
typeof value.update === "function" &&
typeof value.close === "function"
);
};
const createTooltipFacade = (
pageWindow: OneTalkActionStatusTooltipWindow,
): OneTalkActionStatusTooltip => {
const statuses = new Map<string, ActionStatus>();
const refresh = (): void => {
if (statuses.size > 0) ensureStatusContainer(pageWindow.document, statuses);
};
tooltipRefreshes.set(pageWindow, refresh);
const start = (id: string, text: string, color: TooltipColor = DEFAULT_TOOLTIP_COLOR): void => {
if (statuses.has(id)) {
refresh();
return;
}
const resolvedColor = requireTooltipColor(color);
const status = createStatusRow(pageWindow.document, id, text, resolvedColor);
statuses.set(id, status);
const container = ensureStatusContainer(pageWindow.document, statuses);
if (status.row.parentElement !== container) container.append(status.row);
};
const update = (id: string, text: string, color?: TooltipColor): boolean => {
const status = statuses.get(id);
if (!status) return false;
const resolvedColor = color === undefined ? status.color : requireTooltipColor(color);
updateStatusRow(status, text, resolvedColor);
ensureStatusContainer(pageWindow.document, statuses);
return true;
};
const close = (id: string): boolean => {
const status = statuses.get(id);
if (!status) return false;
statuses.delete(id);
status.row.remove();
if (statuses.size === 0) findStatusContainer(pageWindow.document)?.remove();
return true;
};
return { close, start, update };
};
/** 读取已安装的页面动作提示 facade。 */
export const readOneTalkActionStatusTooltip = (
pageWindow: OneTalkActionStatusTooltipWindow,
): OneTalkActionStatusTooltip | null => {
const namespace = pageWindow.__tradeMessageCenterOneTalk;
return isObjectRecord(namespace) && isTooltipFacade(namespace.tooltip)
? namespace.tooltip
: null;
};
/** 将进行中动作提示注册到 OneTalk 页面命名空间。 */
export const installOneTalkActionStatusTooltip = (
pageWindow: OneTalkActionStatusTooltipWindow,
): void => {
const existing = pageWindow.__tradeMessageCenterOneTalk;
const namespace: Record<string, unknown> = isObjectRecord(existing) ? existing : {};
if (isTooltipFacade(namespace.tooltip)) {
tooltipRefreshes.get(pageWindow)?.();
return;
}
namespace.tooltip = createTooltipFacade(pageWindow);
pageWindow.__tradeMessageCenterOneTalk = namespace;
};
// 兼容现有控件消费者;DOM 实现在专属适配目录中。
export {
installOneTalkActionStatusTooltip,
readOneTalkActionStatusTooltip,
type OneTalkActionStatusTooltip,
type TooltipColor,
} from "./dom/action-status-tooltip.ts";
@@ -1,147 +1,2 @@
// 在当前会话标题旁提供复制会话 ID 控件
import { readCurrentConversationId } from "./page-context.ts";
import type { OneTalkPageWindow } from "./model.ts";
type OneTalkConversationIdCopyWindow = OneTalkPageWindow &
Pick<Window, "clearTimeout" | "document" | "navigator" | "setTimeout"> & {
MutationObserver?: typeof MutationObserver;
};
const COPY_BUTTON_ATTRIBUTE = "data-tmc-conversation-id-copy";
const HEADER_MAX_BOTTOM_PX = 72;
const BUTTON_LABEL = "复制会话 Id";
const COPIED_LABEL = "已复制";
const COPY_FAILED_LABEL = "复制失败";
const BUTTON_RESTORE_DELAY_MS = 1_500;
const selectedConversationElement = (document: Document): Element | null => {
const selected = document.querySelectorAll(".contact-item-container.selected[data-cid]");
return selected.length === 1 ? selected[0] : null;
};
const headerTitleElement = (document: Document): Element | null => {
const selected = selectedConversationElement(document);
if (!selected) return null;
const selectedRect = selected.getBoundingClientRect();
const maxLeft = document.defaultView?.innerWidth ?? Number.POSITIVE_INFINITY;
const candidates = Array.from(document.querySelectorAll("body *")).filter((element) => {
if (element.children.length > 0 || !element.textContent?.trim()) return false;
const rect = element.getBoundingClientRect();
return (
rect.width > 0 &&
rect.height > 0 &&
rect.top >= 0 &&
rect.bottom <= HEADER_MAX_BOTTOM_PX &&
rect.left >= selectedRect.right &&
rect.left < maxLeft * 0.75
);
});
return (
candidates.sort(
(left, right) =>
left.getBoundingClientRect().left - right.getBoundingClientRect().left ||
left.getBoundingClientRect().top - right.getBoundingClientRect().top,
)[0] ?? null
);
};
const updateButtonLabel = (
pageWindow: OneTalkConversationIdCopyWindow,
button: HTMLButtonElement,
label: string,
): void => {
button.textContent = label;
button.setAttribute("aria-label", `${label}会话 Id`);
pageWindow.clearTimeout(Number(button.dataset.restoreTimer));
button.dataset.restoreTimer = String(
pageWindow.setTimeout(() => {
button.textContent = BUTTON_LABEL;
button.setAttribute("aria-label", BUTTON_LABEL);
}, BUTTON_RESTORE_DELAY_MS),
);
};
const createCopyButton = (
pageWindow: OneTalkConversationIdCopyWindow,
conversationId: string,
): HTMLButtonElement => {
const button = pageWindow.document.createElement("button");
button.type = "button";
button.setAttribute(COPY_BUTTON_ATTRIBUTE, "");
button.setAttribute("aria-label", BUTTON_LABEL);
button.title = BUTTON_LABEL;
button.textContent = BUTTON_LABEL;
button.dataset.conversationId = conversationId;
button.style.cssText = [
"margin-left: 8px",
"padding: 2px 7px",
"display: inline-flex",
"align-items: center",
"justify-content: center",
"border: 1px solid #d8dce5",
"border-radius: 4px",
"background: #fff",
"color: #315efb",
"font-size: 12px",
"line-height: 18px",
"text-align: center",
"cursor: pointer",
"vertical-align: middle",
].join(";");
button.addEventListener("click", () => {
const currentId = button.dataset.conversationId;
if (!currentId) return;
const clipboard = pageWindow.navigator.clipboard;
if (!clipboard) {
updateButtonLabel(pageWindow, button, COPY_FAILED_LABEL);
return;
}
void clipboard
.writeText(currentId)
.then(() => updateButtonLabel(pageWindow, button, COPIED_LABEL))
.catch(() => updateButtonLabel(pageWindow, button, COPY_FAILED_LABEL));
});
return button;
};
const updateCopyControl = (pageWindow: OneTalkConversationIdCopyWindow): void => {
const existing = pageWindow.document.querySelector<HTMLButtonElement>(
`button[${COPY_BUTTON_ATTRIBUTE}]`,
);
const conversationId = readCurrentConversationId(pageWindow);
if (!conversationId) {
existing?.remove();
return;
}
if (existing) {
existing.dataset.conversationId = conversationId;
return;
}
const title = headerTitleElement(pageWindow.document);
if (!title) return;
title.append(createCopyButton(pageWindow, conversationId));
};
/** 将当前会话 ID 的复制控件安装到 OneTalk 页面标题旁。 */
export const installOneTalkConversationIdCopyControl = (
pageWindow: OneTalkConversationIdCopyWindow,
): void => {
let refreshScheduled = false;
const refresh = (): void => {
refreshScheduled = false;
updateCopyControl(pageWindow);
};
const scheduleRefresh = (): void => {
if (refreshScheduled) return;
refreshScheduled = true;
pageWindow.setTimeout(refresh, 0);
};
const observer = pageWindow.MutationObserver
? new pageWindow.MutationObserver(scheduleRefresh)
: null;
if (observer && pageWindow.document.documentElement) {
observer.observe(pageWindow.document.documentElement, { childList: true, subtree: true });
}
refresh();
};
// 兼容现有控件消费者;DOM 实现在专属适配目录中。
export { installOneTalkConversationIdCopyControl } from "./dom/conversation-id-copy.ts";
@@ -0,0 +1,192 @@
// 管理 OneTalk 页面进行中动作提示的 DOM 边界。
import { isObjectRecord } from "../../../lib/guards.ts";
export type TooltipColor = "neutral" | "info" | "warning" | "error";
export type OneTalkActionStatusTooltip = {
start: (id: string, text: string, color?: TooltipColor) => void;
update: (id: string, text: string, color?: TooltipColor) => boolean;
close: (id: string) => boolean;
};
type OneTalkActionStatusTooltipWindow = Pick<Window, "document"> & {
__tradeMessageCenterOneTalk?: unknown;
};
type ActionStatus = {
color: TooltipColor;
row: HTMLDivElement;
};
const STATUS_CONTAINER_ATTRIBUTE = "data-tmc-action-status-tooltip";
const STATUS_ROW_ATTRIBUTE = "data-tmc-action-status-row";
const STATUS_COLOR_ATTRIBUTE = "data-tmc-action-status-color";
const DEFAULT_TOOLTIP_COLOR: TooltipColor = "neutral";
const tooltipRefreshes = new WeakMap<OneTalkActionStatusTooltipWindow, () => void>();
const TOOLTIP_COLORS: Readonly<
Record<TooltipColor, { background: string; border: string; text: string }>
> = {
neutral: { background: "#f5f5f5", border: "#d9d9d9", text: "#595959" },
info: { background: "#e6f4ff", border: "#91caff", text: "#1677ff" },
warning: { background: "#fffbe6", border: "#ffe58f", text: "#d48806" },
error: { background: "#fff2f0", border: "#ffccc7", text: "#cf1322" },
};
const isTooltipColor = (value: unknown): value is TooltipColor => {
return typeof value === "string" && Object.hasOwn(TOOLTIP_COLORS, value);
};
const requireTooltipColor = (value: unknown): TooltipColor => {
if (isTooltipColor(value)) return value;
throw new TypeError("onetalk_action_status_tooltip_color_invalid");
};
const statusRowStyles = (color: TooltipColor): string => {
const theme = TOOLTIP_COLORS[color];
return [
"box-sizing: border-box",
"width: 100%",
"padding: 8px 12px",
"border: 1px solid " + theme.border,
"border-radius: 6px",
"background: " + theme.background,
"color: " + theme.text,
"font-size: 13px",
"line-height: 20px",
"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
"box-shadow: 0 2px 8px rgb(0 0 0 / 12%)",
].join(";");
};
const updateStatusRow = (status: ActionStatus, text: string, color: TooltipColor): void => {
status.color = color;
status.row.textContent = text;
status.row.setAttribute(STATUS_COLOR_ATTRIBUTE, color);
status.row.style.cssText = statusRowStyles(color);
};
const createStatusRow = (
document: Document,
id: string,
text: string,
color: TooltipColor,
): ActionStatus => {
const row = document.createElement("div");
row.setAttribute(STATUS_ROW_ATTRIBUTE, "");
row.dataset.tmcActionStatusId = id;
const status = { color, row };
updateStatusRow(status, text, color);
return status;
};
const createStatusContainer = (document: Document): HTMLDivElement => {
const container = document.createElement("div");
container.setAttribute(STATUS_CONTAINER_ATTRIBUTE, "");
container.setAttribute("aria-live", "polite");
container.setAttribute("role", "status");
container.style.cssText = [
"position: fixed",
"top: 0",
"left: 50%",
"transform: translateX(-50%)",
"z-index: 2147483647",
"display: flex",
"flex-direction: column",
"gap: 8px",
"width: min(600px, calc(100vw - 96px))",
"pointer-events: none",
].join(";");
return container;
};
const findStatusContainer = (document: Document): HTMLDivElement | null => {
return document.querySelector<HTMLDivElement>(`div[${STATUS_CONTAINER_ATTRIBUTE}]`);
};
const ensureStatusContainer = (
document: Document,
statuses: ReadonlyMap<string, ActionStatus>,
): HTMLDivElement => {
const existing = findStatusContainer(document);
const container = existing ?? createStatusContainer(document);
if (!existing) {
if (!document.body)
throw new Error("onetalk_action_status_tooltip_document_body_unavailable");
document.body.append(container);
}
for (const status of statuses.values()) container.append(status.row);
return container;
};
const isTooltipFacade = (value: unknown): value is OneTalkActionStatusTooltip => {
return (
isObjectRecord(value) &&
typeof value.start === "function" &&
typeof value.update === "function" &&
typeof value.close === "function"
);
};
const createTooltipFacade = (
pageWindow: OneTalkActionStatusTooltipWindow,
): OneTalkActionStatusTooltip => {
const statuses = new Map<string, ActionStatus>();
const refresh = (): void => {
if (statuses.size > 0) ensureStatusContainer(pageWindow.document, statuses);
};
tooltipRefreshes.set(pageWindow, refresh);
const start = (id: string, text: string, color: TooltipColor = DEFAULT_TOOLTIP_COLOR): void => {
if (statuses.has(id)) {
refresh();
return;
}
const resolvedColor = requireTooltipColor(color);
const status = createStatusRow(pageWindow.document, id, text, resolvedColor);
statuses.set(id, status);
const container = ensureStatusContainer(pageWindow.document, statuses);
if (status.row.parentElement !== container) container.append(status.row);
};
const update = (id: string, text: string, color?: TooltipColor): boolean => {
const status = statuses.get(id);
if (!status) return false;
const resolvedColor = color === undefined ? status.color : requireTooltipColor(color);
updateStatusRow(status, text, resolvedColor);
ensureStatusContainer(pageWindow.document, statuses);
return true;
};
const close = (id: string): boolean => {
const status = statuses.get(id);
if (!status) return false;
statuses.delete(id);
status.row.remove();
if (statuses.size === 0) findStatusContainer(pageWindow.document)?.remove();
return true;
};
return { close, start, update };
};
/** 读取已安装的页面动作提示 facade。 */
export const readOneTalkActionStatusTooltip = (
pageWindow: OneTalkActionStatusTooltipWindow,
): OneTalkActionStatusTooltip | null => {
const namespace = pageWindow.__tradeMessageCenterOneTalk;
return isObjectRecord(namespace) && isTooltipFacade(namespace.tooltip)
? namespace.tooltip
: null;
};
/** 将进行中动作提示注册到 OneTalk 页面命名空间。 */
export const installOneTalkActionStatusTooltip = (
pageWindow: OneTalkActionStatusTooltipWindow,
): void => {
const existing = pageWindow.__tradeMessageCenterOneTalk;
const namespace: Record<string, unknown> = isObjectRecord(existing) ? existing : {};
if (isTooltipFacade(namespace.tooltip)) {
tooltipRefreshes.get(pageWindow)?.();
return;
}
namespace.tooltip = createTooltipFacade(pageWindow);
pageWindow.__tradeMessageCenterOneTalk = namespace;
};
@@ -0,0 +1,147 @@
// 安装 OneTalk 会话 ID 复制控件。
import { readCurrentConversationId } from "../page-context.ts";
import type { OneTalkPageWindow } from "../model.ts";
type OneTalkConversationIdCopyWindow = OneTalkPageWindow &
Pick<Window, "clearTimeout" | "document" | "navigator" | "setTimeout"> & {
MutationObserver?: typeof MutationObserver;
};
const COPY_BUTTON_ATTRIBUTE = "data-tmc-conversation-id-copy";
const HEADER_MAX_BOTTOM_PX = 72;
const BUTTON_LABEL = "复制会话 Id";
const COPIED_LABEL = "已复制";
const COPY_FAILED_LABEL = "复制失败";
const BUTTON_RESTORE_DELAY_MS = 1_500;
const selectedConversationElement = (document: Document): Element | null => {
const selected = document.querySelectorAll(".contact-item-container.selected[data-cid]");
return selected.length === 1 ? selected[0] : null;
};
const headerTitleElement = (document: Document): Element | null => {
const selected = selectedConversationElement(document);
if (!selected) return null;
const selectedRect = selected.getBoundingClientRect();
const maxLeft = document.defaultView?.innerWidth ?? Number.POSITIVE_INFINITY;
const candidates = Array.from(document.querySelectorAll("body *")).filter((element) => {
if (element.children.length > 0 || !element.textContent?.trim()) return false;
const rect = element.getBoundingClientRect();
return (
rect.width > 0 &&
rect.height > 0 &&
rect.top >= 0 &&
rect.bottom <= HEADER_MAX_BOTTOM_PX &&
rect.left >= selectedRect.right &&
rect.left < maxLeft * 0.75
);
});
return (
candidates.sort(
(left, right) =>
left.getBoundingClientRect().left - right.getBoundingClientRect().left ||
left.getBoundingClientRect().top - right.getBoundingClientRect().top,
)[0] ?? null
);
};
const updateButtonLabel = (
pageWindow: OneTalkConversationIdCopyWindow,
button: HTMLButtonElement,
label: string,
): void => {
button.textContent = label;
button.setAttribute("aria-label", `${label}会话 Id`);
pageWindow.clearTimeout(Number(button.dataset.restoreTimer));
button.dataset.restoreTimer = String(
pageWindow.setTimeout(() => {
button.textContent = BUTTON_LABEL;
button.setAttribute("aria-label", BUTTON_LABEL);
}, BUTTON_RESTORE_DELAY_MS),
);
};
const createCopyButton = (
pageWindow: OneTalkConversationIdCopyWindow,
conversationId: string,
): HTMLButtonElement => {
const button = pageWindow.document.createElement("button");
button.type = "button";
button.setAttribute(COPY_BUTTON_ATTRIBUTE, "");
button.setAttribute("aria-label", BUTTON_LABEL);
button.title = BUTTON_LABEL;
button.textContent = BUTTON_LABEL;
button.dataset.conversationId = conversationId;
button.style.cssText = [
"margin-left: 8px",
"padding: 2px 7px",
"display: inline-flex",
"align-items: center",
"justify-content: center",
"border: 1px solid #d8dce5",
"border-radius: 4px",
"background: #fff",
"color: #315efb",
"font-size: 12px",
"line-height: 18px",
"text-align: center",
"cursor: pointer",
"vertical-align: middle",
].join(";");
button.addEventListener("click", () => {
const currentId = button.dataset.conversationId;
if (!currentId) return;
const clipboard = pageWindow.navigator.clipboard;
if (!clipboard) {
updateButtonLabel(pageWindow, button, COPY_FAILED_LABEL);
return;
}
void clipboard
.writeText(currentId)
.then(() => updateButtonLabel(pageWindow, button, COPIED_LABEL))
.catch(() => updateButtonLabel(pageWindow, button, COPY_FAILED_LABEL));
});
return button;
};
const updateCopyControl = (pageWindow: OneTalkConversationIdCopyWindow): void => {
const existing = pageWindow.document.querySelector<HTMLButtonElement>(
`button[${COPY_BUTTON_ATTRIBUTE}]`,
);
const conversationId = readCurrentConversationId(pageWindow);
if (!conversationId) {
existing?.remove();
return;
}
if (existing) {
existing.dataset.conversationId = conversationId;
return;
}
const title = headerTitleElement(pageWindow.document);
if (!title) return;
title.append(createCopyButton(pageWindow, conversationId));
};
/** 将当前会话 ID 的复制控件安装到 OneTalk 页面标题旁。 */
export const installOneTalkConversationIdCopyControl = (
pageWindow: OneTalkConversationIdCopyWindow,
): void => {
let refreshScheduled = false;
const refresh = (): void => {
refreshScheduled = false;
updateCopyControl(pageWindow);
};
const scheduleRefresh = (): void => {
if (refreshScheduled) return;
refreshScheduled = true;
pageWindow.setTimeout(refresh, 0);
};
const observer = pageWindow.MutationObserver
? new pageWindow.MutationObserver(scheduleRefresh)
: null;
if (observer && pageWindow.document.documentElement) {
observer.observe(pageWindow.document.documentElement, { childList: true, subtree: true });
}
refresh();
};
@@ -0,0 +1,53 @@
// 定位 OneTalk React 文件上传器。
import { isObjectRecord } from "../../../lib/guards.ts";
import type { OneTalkPageWindow } from "../model.ts";
export type OneTalkImageUploader = {
owner: Record<string, unknown>;
sendFileToOss: (input: Record<string, unknown>) => unknown;
sendFile: (...args: unknown[]) => unknown;
};
/** 查找页面 React file input 对应的上传器。 */
export const findReactFileUploader = (
pageWindow: OneTalkPageWindow,
): OneTalkImageUploader | null => {
const inputs = Array.from(pageWindow.document?.querySelectorAll("input[type=file]") ?? []);
for (const input of inputs) {
for (
let node: (Element & { parentElement: Element | null }) | null = input;
node;
node = node.parentElement
) {
const fiberKey = Object.keys(node).find((key) => key.startsWith("__reactFiber"));
const fiber = fiberKey ? (node as unknown as Record<string, unknown>)[fiberKey] : null;
for (
let current: { stateNode?: unknown; return?: unknown } | null = isObjectRecord(
fiber,
)
? fiber
: null;
current;
current = isObjectRecord(current.return) ? current.return : null
) {
const state = current.stateNode;
if (
isObjectRecord(state) &&
typeof state.sendFileToOss === "function" &&
typeof state.sendFile === "function"
) {
return {
owner: state,
sendFileToOss: state.sendFileToOss as (
input: Record<string, unknown>,
) => unknown,
sendFile: state.sendFile as (...args: unknown[]) => unknown,
};
}
}
}
}
return null;
};
@@ -0,0 +1,9 @@
// 订阅 OneTalk 当前会话选择点击。
/** 订阅当前页面的点击事件以刷新会话身份。 */
export const subscribeToDocumentClicks = (
document: Pick<Document, "addEventListener"> | undefined,
onClick: () => void,
): void => {
document?.addEventListener("click", onClick);
};
@@ -0,0 +1,17 @@
// 读取 OneTalk SPA 当前选中会话的 DOM 边界。
import type { OneTalkPageWindow } from "../model.ts";
/** 读取页面中当前选中会话的非空 ID 列表。 */
export const readSelectedConversationIdsFromDom = (pageWindow: OneTalkPageWindow): string[] => {
if (!pageWindow.document) return [];
try {
return Array.from(
pageWindow.document.querySelectorAll(".contact-item-container.selected[data-cid]"),
)
.map((element) => element.getAttribute("data-cid")?.trim() ?? "")
.filter((value) => value.length > 0);
} catch {
return [];
}
};
@@ -5,6 +5,7 @@ import type { OneTalkOutboundContent } from "@trade-message-center/onetalk-contr
import { isObjectRecord } from "../../lib/guards.ts";
import { traceOneTalkImageSend } from "../diagnostics/image-send-trace.ts";
import type { PageCommandResult } from "../page-bridge/model.ts";
import { findReactFileUploader, type OneTalkImageUploader } from "./dom/react-file-uploader.ts";
import { readChannelAccountId } from "./page-context.ts";
import type { OneTalkPageWindow } from "./model.ts";
import {
@@ -17,11 +18,7 @@ type MediaSendObservationCorrelator = Pick<
"executeImage" | "executeFile"
>;
export type OneTalkImageUploader = {
owner: Record<string, unknown>;
sendFileToOss: (input: Record<string, unknown>) => unknown;
sendFile: (...args: unknown[]) => unknown;
};
export type { OneTalkImageUploader } from "./dom/react-file-uploader.ts";
type FinalImageMetadata = {
sizeBytes: number;
@@ -149,45 +146,6 @@ const isCurrentImageTarget = (
);
};
const uploaderFromReactFileInput = (pageWindow: OneTalkPageWindow): OneTalkImageUploader | null => {
const inputs = Array.from(pageWindow.document?.querySelectorAll("input[type=file]") ?? []);
for (const input of inputs) {
for (
let node: (Element & { parentElement: Element | null }) | null = input;
node;
node = node.parentElement
) {
const fiberKey = Object.keys(node).find((key) => key.startsWith("__reactFiber"));
const fiber = fiberKey ? (node as unknown as Record<string, unknown>)[fiberKey] : null;
for (
let current: { stateNode?: unknown; return?: unknown } | null = isObjectRecord(
fiber,
)
? fiber
: null;
current;
current = isObjectRecord(current.return) ? current.return : null
) {
const state = current.stateNode;
if (
isObjectRecord(state) &&
typeof state.sendFileToOss === "function" &&
typeof state.sendFile === "function"
) {
return {
owner: state,
sendFileToOss: state.sendFileToOss as (
input: Record<string, unknown>,
) => unknown,
sendFile: state.sendFile as (...args: unknown[]) => unknown,
};
}
}
}
}
return null;
};
const metadataFrom = (value: unknown): FinalImageMetadata | null => {
if (!isObjectRecord(value)) return null;
const sizeBytes = value.nodeSize ?? value.size;
@@ -415,7 +373,7 @@ const sendOneTalkMedia = async (input: {
input.runtime?.cancel ?? ((timer: ImageSendTimer) => globalThis.clearTimeout(timer)),
};
const context = imageTargetContext(input.pageWindow, input.conversationId);
const uploader = (input.findUploader ?? uploaderFromReactFileInput)(input.pageWindow);
const uploader = (input.findUploader ?? findReactFileUploader)(input.pageWindow);
if (!context) {
traceOneTalkImageSend(input.requestId, {
stage: "target_context_resolved",
@@ -2,6 +2,7 @@
import { isObjectRecord } from "../../lib/guards.ts";
import { readSelectedConversationIdsFromDom } from "./dom/selection.ts";
import type { OneTalkPageWindow } from "./model.ts";
export type OneTalkConversationSelection =
@@ -143,14 +144,5 @@ export const readConversationSelection = (
};
export const readSelectedConversationIds = (pageWindow: OneTalkPageWindow): string[] => {
if (!pageWindow.document) return [];
try {
return Array.from(
pageWindow.document.querySelectorAll(".contact-item-container.selected[data-cid]"),
)
.map((element) => element.getAttribute("data-cid")?.trim() ?? "")
.filter((value) => value.length > 0);
} catch {
return [];
}
return readSelectedConversationIdsFromDom(pageWindow);
};
@@ -1,10 +1,10 @@
// 启动 OneTalk 页面监听与当前会话同步能力
import { installOneTalkConversationIdCopyControl } from "./conversation-id-copy.ts";
import { installOneTalkConversationIdCopyControl } from "./dom/conversation-id-copy.ts";
import {
installOneTalkActionStatusTooltip,
readOneTalkActionStatusTooltip,
} from "./action-status-tooltip.ts";
} from "./dom/action-status-tooltip.ts";
import { ConnectionStatusTooltip } from "./connection-status-tooltip.ts";
import { HistoryBootstrapProgressTooltip } from "./current-conversation-history/bootstrap-progress-tooltip.ts";
import { createSendObservationCorrelator } from "./message-observer/send-observation.ts";
@@ -1,6 +1,8 @@
// 连接 OneTalk MAIN 页面与可信桥接契约
import { readChannelAccountId, readConversationSelection } from "../main-page/page-context.ts";
import { subscribeToDocumentClicks } from "../main-page/dom/selection-events.ts";
import type { OneTalkPageWindow } from "../main-page/model.ts";
import { isPlainRecord } from "@trade-message-center/onetalk-contract";
import type { HistoryPageProgress } from "../main-page/current-conversation-history/model.ts";
import type { OneTalkObservedMessageSink } from "../main-page/message-observer/model.ts";
@@ -44,6 +46,8 @@ export type OneTalkPageHelloRetryOptions = {
maxAttempts?: number;
};
type OneTalkMainPageBridgeWindow = OneTalkPageBridgeWindow & OneTalkPageWindow;
const PAGE_HELLO_RETRY_DELAY_MS = 250;
const PAGE_HELLO_MAX_ATTEMPTS = 40;
@@ -73,7 +77,7 @@ const postPageMessage = (
}
};
const postPageHello = (pageWindow: OneTalkPageBridgeWindow, origin: string): boolean => {
const postPageHello = (pageWindow: OneTalkMainPageBridgeWindow, origin: string): boolean => {
const channelAccountId = readChannelAccountId(pageWindow);
if (!channelAccountId) return false;
const selection = readConversationSelection(pageWindow);
@@ -244,7 +248,7 @@ export const createOneTalkPageBuyerFactsObservedSink = (
/** 安装 MAIN 页面注册与可注入命令消费者。 */
export const installOneTalkMainPageBridge = (
pageWindow: OneTalkPageBridgeWindow,
pageWindow: OneTalkMainPageBridgeWindow,
onCommand?: OneTalkPageCommandHandler,
retryOptions: OneTalkPageHelloRetryOptions = {},
onConnectionStatus?: OneTalkPageConnectionStatusHandler,
@@ -299,7 +303,7 @@ export const installOneTalkMainPageBridge = (
};
pageWindow.addEventListener("popstate", refreshIdentity);
pageWindow.addEventListener("hashchange", refreshIdentity);
pageWindow.document?.addEventListener("click", refreshIdentity);
subscribeToDocumentClicks(pageWindow.document, refreshIdentity);
attemptHello();
} catch {
// Bridge setup failures must not affect OneTalk page execution.
@@ -123,7 +123,7 @@ export type OneTalkPageMessage =
export type OneTalkPageBridgeWindow = {
location: Pick<Location, "href" | "origin">;
document?: Pick<Document, "querySelectorAll" | "addEventListener">;
document?: Pick<Document, "addEventListener">;
postMessage(message: OneTalkPageMessage, targetOrigin: string): void;
addEventListener(type: "message", listener: (event: MessageEvent<unknown>) => void): void;
addEventListener(type: "pagehide" | "popstate" | "hashchange", listener: () => void): void;
+15
View File
@@ -0,0 +1,15 @@
// 创建可隔离的 OneTalk 页面 DOM 测试夹具。
import { JSDOM } from "jsdom";
/** 创建并返回带清理回调的真实 DOM 测试夹具。 */
export const createDomFixture = (html = "") => {
const dom = new JSDOM(`<!doctype html><html><body>${html}</body></html>`, {
url: "https://onetalk.alibaba.com/message/default.htm",
});
return {
document: dom.window.document,
window: dom.window,
cleanup: () => dom.window.close(),
};
};
@@ -1,6 +1,6 @@
import assert from "node:assert/strict";
import test from "node:test";
import { installOneTalkActionStatusTooltip } from "../src/onetalk/main-page/action-status-tooltip.ts";
import { installOneTalkActionStatusTooltip } from "../src/onetalk/main-page/dom/action-status-tooltip.ts";
import { ConnectionStatusTooltip } from "../src/onetalk/main-page/connection-status-tooltip.ts";
import { HistoryBootstrapProgressTooltip } from "../src/onetalk/main-page/current-conversation-history/bootstrap-progress-tooltip.ts";
@@ -1,6 +1,11 @@
import assert from "node:assert/strict";
import test from "node:test";
import { installOneTalkConversationIdCopyControl } from "../src/onetalk/main-page/conversation-id-copy.ts";
import { getByRole } from "@testing-library/dom";
import userEvent from "@testing-library/user-event";
import { installOneTalkConversationIdCopyControl } from "../src/onetalk/main-page/dom/conversation-id-copy.ts";
import { createDomFixture } from "./dom-fixture.js";
const rect = (left, top, width, height) => ({
left,
@@ -11,126 +16,73 @@ const rect = (left, top, width, height) => ({
bottom: top + height,
});
class FakeElement {
constructor({ conversationId, elementRect, textContent = "" } = {}) {
this.conversationId = conversationId;
this.elementRect = elementRect ?? rect(0, 0, 0, 0);
this.textContent = textContent;
this.children = [];
this.dataset = {};
this.listeners = new Map();
this.style = { cssText: "" };
this.attributes = new Map();
}
getAttribute(name) {
return name === "data-cid"
? (this.conversationId ?? null)
: (this.attributes.get(name) ?? null);
}
setAttribute(name, value) {
this.attributes.set(name, value);
}
getBoundingClientRect() {
return this.elementRect;
}
addEventListener(type, listener) {
this.listeners.set(type, listener);
}
append(element) {
this.inserted = element;
element.isConnected = true;
}
remove() {
this.isConnected = false;
}
}
const createPage = () => {
const selected = new FakeElement({
conversationId: "conversation-1",
elementRect: rect(80, 80, 340, 60),
});
const headerTitle = new FakeElement({
elementRect: rect(436, 16, 112, 24),
textContent: "Elliot Izzard",
});
const navigationTitle = new FakeElement({
elementRect: rect(1_500, 16, 160, 24),
textContent: "OKKI销售助手",
});
const document = {
documentElement: {},
defaultView: { innerWidth: 2_048 },
button: null,
querySelectorAll(selector) {
if (selector === ".contact-item-container.selected[data-cid]") return [selected];
if (selector === "body *") return [headerTitle, navigationTitle];
return [];
},
querySelector(selector) {
return selector === "button[data-tmc-conversation-id-copy]" ? this.button : null;
},
createElement() {
const button = new FakeElement();
button.type = "";
Object.defineProperty(button, "isConnected", { value: false, writable: true });
return button;
},
};
const fixture = createDomFixture(`
<div class="contact-item-container selected" data-cid="conversation-1"></div>
<span data-testid="conversation-title">Elliot Izzard</span>
<span>OKKI销售助手</span>
`);
const selected = fixture.document.querySelector(".contact-item-container");
const headerTitle = fixture.document.querySelector("[data-testid=conversation-title]");
const navigationTitle = fixture.document.querySelector("span:last-child");
selected.getBoundingClientRect = () => rect(80, 80, 340, 60);
headerTitle.getBoundingClientRect = () => rect(436, 16, 112, 24);
navigationTitle.getBoundingClientRect = () => rect(1_500, 16, 160, 24);
const clipboardWrites = [];
const navigator = { clipboard: { writeText: async (value) => clipboardWrites.push(value) } };
const pageWindow = {
document,
location: { href: "https://onetalk.alibaba.com/message/default.htm" },
navigator: { clipboard: { writeText: async (value) => clipboardWrites.push(value) } },
setTimeout: () => 1,
clearTimeout: () => {},
document: fixture.document,
location: fixture.window.location,
navigator,
setTimeout: fixture.window.setTimeout.bind(fixture.window),
clearTimeout: fixture.window.clearTimeout.bind(fixture.window),
};
return { clipboardWrites, document, headerTitle, pageWindow, selected };
return { ...fixture, clipboardWrites, pageWindow };
};
test("installs a copy control beside the current conversation title", async () => {
const { clipboardWrites, document, headerTitle, pageWindow } = createPage();
test("installs a copy control beside the current conversation title and copies through user-event", async (t) => {
const { cleanup, clipboardWrites, document, pageWindow } = createPage();
t.after(cleanup);
installOneTalkConversationIdCopyControl(pageWindow);
const button = headerTitle.inserted;
assert.ok(button);
assert.equal(button.textContent, "复制会话 Id");
const button = getByRole(document.body, "button", { name: "复制会话 Id" });
assert.equal(button.dataset.conversationId, "conversation-1");
assert.equal(button.getAttribute("aria-label"), "复制会话 Id");
assert.match(button.style.cssText, /font-size: 12px/u);
assert.match(button.style.cssText, /justify-content: center/u);
button.listeners.get("click")();
const user = userEvent.setup({ document });
await user.click(button);
await Promise.resolve();
assert.deepEqual(clipboardWrites, ["conversation-1"]);
assert.equal(button.textContent, "已复制");
});
test("does not install a control without one unambiguous selected conversation", () => {
const { document, pageWindow } = createPage();
document.querySelectorAll = () => [];
test("does not install a control without one unambiguous selected conversation", (t) => {
const { cleanup, document, pageWindow } = createPage();
t.after(cleanup);
document.querySelector(".contact-item-container").className = "contact-item-container";
installOneTalkConversationIdCopyControl(pageWindow);
assert.equal(document.button, null);
assert.equal(document.querySelector("button[data-tmc-conversation-id-copy]"), null);
});
test("shows a failure state when the page cannot write to the clipboard", async () => {
const { headerTitle, pageWindow } = createPage();
test("shows a failure state when the page cannot write to the clipboard", async (t) => {
const { cleanup, document, pageWindow } = createPage();
t.after(cleanup);
pageWindow.navigator.clipboard.writeText = async () => {
throw new Error("clipboard_denied");
};
installOneTalkConversationIdCopyControl(pageWindow);
headerTitle.inserted.listeners.get("click")();
const button = getByRole(document.body, "button", { name: "复制会话 Id" });
const user = userEvent.setup({ document });
await user.click(button);
await Promise.resolve();
await Promise.resolve();
assert.equal(headerTitle.inserted.textContent, "复制失败");
assert.equal(button.textContent, "复制失败");
});
+428
View File
@@ -27,9 +27,18 @@ importers:
specifier: workspace:*
version: link:../../packages/onetalk-contract
devDependencies:
'@testing-library/dom':
specifier: ^10.4.1
version: 10.4.1
'@testing-library/user-event':
specifier: ^14.6.7
version: 14.6.7(@testing-library/dom@10.4.1)
'@types/node':
specifier: ^22.10.2
version: 22.20.1
jsdom:
specifier: ^30.0.1
version: 30.0.1
typescript:
specifier: ^5.7.3
version: 5.7.3
@@ -89,6 +98,66 @@ importers:
packages:
'@asamuzakjp/css-color@6.0.7':
resolution: {integrity: sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==}
engines: {node: ^22.13.0 || >=24.0.0}
'@asamuzakjp/dom-selector@8.3.2':
resolution: {integrity: sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==}
engines: {node: ^22.13.0 || >=24.0.0}
'@babel/code-frame@7.29.7':
resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
engines: {node: '>=6.9.0'}
'@babel/helper-validator-identifier@7.29.7':
resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==}
engines: {node: '>=6.9.0'}
'@babel/runtime@7.29.7':
resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==}
engines: {node: '>=6.9.0'}
'@bramus/specificity@2.4.2':
resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==}
hasBin: true
'@csstools/color-helpers@6.1.1':
resolution: {integrity: sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==}
engines: {node: '>=20.19.0'}
'@csstools/css-calc@3.3.0':
resolution: {integrity: sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==}
engines: {node: '>=20.19.0'}
peerDependencies:
'@csstools/css-parser-algorithms': ^4.0.0
'@csstools/css-tokenizer': ^4.0.0
'@csstools/css-color-parser@4.2.2':
resolution: {integrity: sha512-3QKjR/vxyjcSXBLgb6lP0S3MGdvwbmqSsvLPbYdVORqPDc8FX1HAJ0Spk38bxaRXgvENTA47tlhhbb5Z2e8hEg==}
engines: {node: '>=20.19.0'}
peerDependencies:
'@csstools/css-parser-algorithms': ^4.0.0
'@csstools/css-tokenizer': ^4.0.0
'@csstools/css-parser-algorithms@4.0.0':
resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==}
engines: {node: '>=20.19.0'}
peerDependencies:
'@csstools/css-tokenizer': ^4.0.0
'@csstools/css-syntax-patches-for-csstree@1.1.13':
resolution: {integrity: sha512-i9ZylF5QNhmNfPA9l0vHAWK4kPrbIp6g9lKgaiIFsIBz2F/WNB7OLrzlNNcCOm+h42bkaSD2v1PG+IBPHhc3ZA==}
peerDependencies:
css-tree: ^3.2.1
peerDependenciesMeta:
css-tree:
optional: true
'@csstools/css-tokenizer@4.0.0':
resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
engines: {node: '>=20.19.0'}
'@drizzle-team/brocli@0.10.2':
resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==}
@@ -694,6 +763,15 @@ packages:
cpu: [x64]
os: [win32]
'@exodus/bytes@1.15.1':
resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
peerDependencies:
'@noble/hashes': ^1.8.0 || ^2.0.0
peerDependenciesMeta:
'@noble/hashes':
optional: true
'@fastify/ajv-compiler@4.0.6':
resolution: {integrity: sha512-NtuzM0SfaMJbGlnjr9LWQUN5LzgSrbB8tf/wRZNas+4E1O/Nmzl53e7ruT61HDZyRCJGC6FxIogmNZO1c5ETBA==}
@@ -985,6 +1063,19 @@ packages:
cpu: [x64]
os: [win32]
'@testing-library/dom@10.4.1':
resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
engines: {node: '>=18'}
'@testing-library/user-event@14.6.7':
resolution: {integrity: sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg==}
engines: {node: '>=12', npm: '>=6'}
peerDependencies:
'@testing-library/dom': '>=7.21.4'
'@types/aria-query@5.0.4':
resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
'@types/estree@1.0.9':
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
@@ -1005,6 +1096,17 @@ packages:
ajv@8.20.0:
resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==}
ansi-regex@5.0.1:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'}
ansi-styles@5.2.0:
resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==}
engines: {node: '>=10'}
aria-query@5.3.0:
resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==}
atomic-sleep@1.0.0:
resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
engines: {node: '>=8.0.0'}
@@ -1012,6 +1114,9 @@ packages:
avvio@9.3.0:
resolution: {integrity: sha512-g2tQ7LE7oOSqDfwEm3M+ZCMTJc7KiZCdJ4UwyZJb5ckTKyYu50OYmvv0mCFXPuYXoM4zkSt8zM9XQ9KCvxA74A==}
bidi-js@1.1.0:
resolution: {integrity: sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==}
buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
@@ -1019,10 +1124,24 @@ packages:
resolution: {integrity: sha512-Xd8lFX4LM9QEEwxQpF9J9NTUh8pmdJO0cyRJhFiDoLTk2eH8FXlRv2IFGYVadZpqI3j8fhNrSdKCeYPxiAhLXw==}
engines: {node: '>=18'}
css-tree@3.2.1:
resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==}
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
data-urls@7.0.0:
resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
decimal.js@10.6.0:
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
dequal@2.0.3:
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
engines: {node: '>=6'}
dom-accessibility-api@0.5.16:
resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==}
drizzle-kit@0.31.10:
resolution: {integrity: sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==}
hasBin: true
@@ -1125,6 +1244,10 @@ packages:
end-of-stream@1.4.5:
resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==}
entities@8.1.0:
resolution: {integrity: sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==}
engines: {node: '>=20.19.0'}
esbuild@0.18.20:
resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==}
engines: {node: '>=12'}
@@ -1193,6 +1316,10 @@ packages:
get-tsconfig@4.14.3:
resolution: {integrity: sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==}
html-encoding-sniffer@6.0.0:
resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
husky@9.1.7:
resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==}
engines: {node: '>=18'}
@@ -1205,6 +1332,21 @@ packages:
resolution: {integrity: sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==}
engines: {node: '>= 10'}
is-potential-custom-element-name@1.0.1:
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
jsdom@30.0.1:
resolution: {integrity: sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==}
engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
peerDependencies:
canvas: ^3.2.3
peerDependenciesMeta:
canvas:
optional: true
json-schema-ref-resolver@3.0.0:
resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==}
@@ -1219,6 +1361,17 @@ packages:
engines: {node: '>=22.22.1'}
hasBin: true
lru-cache@11.5.2:
resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==}
engines: {node: 20 || >=22}
lz-string@1.5.0:
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
hasBin: true
mdn-data@2.27.1:
resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==}
nanoid@3.3.18:
resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
@@ -1244,6 +1397,9 @@ packages:
vite-plus:
optional: true
parse5@8.0.1:
resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==}
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -1269,15 +1425,26 @@ packages:
resolution: {integrity: sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==}
engines: {node: '>=12'}
pretty-format@27.5.1:
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
process-warning@4.0.0:
resolution: {integrity: sha512-/MyYDxttz7DfGMMHiysAsFE4qF+pQYAA8ziO/3NcRVrQ5fSk+Mns4QZA/oRPFzvcqNoVJXQNWNAsdwBXLUkQKw==}
process-warning@5.1.0:
resolution: {integrity: sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==}
punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
quick-format-unescaped@4.0.4:
resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==}
react-is@17.0.2:
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
readable-stream@3.6.2:
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
engines: {node: '>= 6'}
@@ -1323,6 +1490,10 @@ packages:
resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==}
engines: {node: '>=10'}
saxes@6.0.0:
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
engines: {node: '>=v12.22.7'}
secure-json-parse@4.1.0:
resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==}
@@ -1362,6 +1533,9 @@ packages:
string_decoder@1.3.0:
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
symbol-tree@3.2.4:
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
thread-stream@4.2.0:
resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==}
engines: {node: '>=20'}
@@ -1378,10 +1552,25 @@ packages:
resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==}
engines: {node: ^20.0.0 || >=22.0.0}
tldts-core@7.4.12:
resolution: {integrity: sha512-nYNzS2WRf4QJmjzFFgAxLOBjyBxAGRbCy9PVBPaglcYyYajh40VBn+v5Ngr96ZMc7oM0+aCJdtQnNejvdBnXMQ==}
tldts@7.4.12:
resolution: {integrity: sha512-WylhSDKVeYnWXL3a+vKTaOxjnOeEGw938hImY8zoRWJjRRK/Jp1K+IihBzIONpUmW4e3WmXT6q5FW6vlESVZCA==}
hasBin: true
toad-cache@3.7.4:
resolution: {integrity: sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==}
engines: {node: '>=20'}
tough-cookie@6.0.2:
resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==}
engines: {node: '>=16'}
tr46@6.0.0:
resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==}
engines: {node: '>=20'}
tsx@4.23.12:
resolution: {integrity: sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==}
engines: {node: '>=18.0.0'}
@@ -1395,6 +1584,10 @@ packages:
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
undici@8.10.2:
resolution: {integrity: sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==}
engines: {node: '>=22.19.0'}
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
@@ -1438,6 +1631,26 @@ packages:
yaml:
optional: true
w3c-xmlserializer@5.0.0:
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
engines: {node: '>=18'}
webidl-conversions@8.0.1:
resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==}
engines: {node: '>=20'}
whatwg-mimetype@5.0.0:
resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==}
engines: {node: '>=20'}
whatwg-url@16.0.1:
resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
whatwg-url@17.1.1:
resolution: {integrity: sha512-ohjk1mdUebJVadRt3bAhQhx8lSnISq+GDttK79LFl8EHQkAPvzwctoasC4hs8tBt6kLAncBWWyq1N52qEfKvDw==}
engines: {node: ^22.14.0 || >=24.0.0}
wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
@@ -1453,6 +1666,13 @@ packages:
utf-8-validate:
optional: true
xml-name-validator@5.0.0:
resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
engines: {node: '>=18'}
xmlchars@2.2.0:
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
yaml@2.9.0:
resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==}
engines: {node: '>= 14.6'}
@@ -1460,6 +1680,59 @@ packages:
snapshots:
'@asamuzakjp/css-color@6.0.7':
dependencies:
'@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
'@csstools/css-color-parser': 4.2.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
'@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
'@csstools/css-tokenizer': 4.0.0
lru-cache: 11.5.2
'@asamuzakjp/dom-selector@8.3.2':
dependencies:
bidi-js: 1.1.0
css-tree: 3.2.1
is-potential-custom-element-name: 1.0.1
lru-cache: 11.5.2
'@babel/code-frame@7.29.7':
dependencies:
'@babel/helper-validator-identifier': 7.29.7
js-tokens: 4.0.0
picocolors: 1.1.1
'@babel/helper-validator-identifier@7.29.7': {}
'@babel/runtime@7.29.7': {}
'@bramus/specificity@2.4.2':
dependencies:
css-tree: 3.2.1
'@csstools/color-helpers@6.1.1': {}
'@csstools/css-calc@3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
dependencies:
'@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
'@csstools/css-tokenizer': 4.0.0
'@csstools/css-color-parser@4.2.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
dependencies:
'@csstools/color-helpers': 6.1.1
'@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
'@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
'@csstools/css-tokenizer': 4.0.0
'@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)':
dependencies:
'@csstools/css-tokenizer': 4.0.0
'@csstools/css-syntax-patches-for-csstree@1.1.13(css-tree@3.2.1)':
optionalDependencies:
css-tree: 3.2.1
'@csstools/css-tokenizer@4.0.0': {}
'@drizzle-team/brocli@0.10.2': {}
'@esbuild-kit/core-utils@3.3.2':
@@ -1769,6 +2042,8 @@ snapshots:
'@esbuild/win32-x64@0.28.2':
optional: true
'@exodus/bytes@1.15.1': {}
'@fastify/ajv-compiler@4.0.6':
dependencies:
ajv: 8.20.0
@@ -1938,6 +2213,23 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.62.5':
optional: true
'@testing-library/dom@10.4.1':
dependencies:
'@babel/code-frame': 7.29.7
'@babel/runtime': 7.29.7
'@types/aria-query': 5.0.4
aria-query: 5.3.0
dom-accessibility-api: 0.5.16
lz-string: 1.5.0
picocolors: 1.1.1
pretty-format: 27.5.1
'@testing-library/user-event@14.6.7(@testing-library/dom@10.4.1)':
dependencies:
'@testing-library/dom': 10.4.1
'@types/aria-query@5.0.4': {}
'@types/estree@1.0.9': {}
'@types/node@22.20.1':
@@ -1957,6 +2249,14 @@ snapshots:
json-schema-traverse: 1.0.0
require-from-string: 2.0.2
ansi-regex@5.0.1: {}
ansi-styles@5.2.0: {}
aria-query@5.3.0:
dependencies:
dequal: 2.0.3
atomic-sleep@1.0.0: {}
avvio@9.3.0:
@@ -1964,12 +2264,32 @@ snapshots:
'@fastify/error': 4.2.0
fastq: 1.20.1
bidi-js@1.1.0:
dependencies:
require-from-string: 2.0.2
buffer-from@1.1.2: {}
cookie@1.0.1: {}
css-tree@3.2.1:
dependencies:
mdn-data: 2.27.1
source-map-js: 1.2.1
data-urls@7.0.0:
dependencies:
whatwg-mimetype: 5.0.0
whatwg-url: 16.0.1
transitivePeerDependencies:
- '@noble/hashes'
decimal.js@10.6.0: {}
dequal@2.0.3: {}
dom-accessibility-api@0.5.16: {}
drizzle-kit@0.31.10:
dependencies:
'@drizzle-team/brocli': 0.10.2
@@ -1992,6 +2312,8 @@ snapshots:
dependencies:
once: 1.4.0
entities@8.1.0: {}
esbuild@0.18.20:
optionalDependencies:
'@esbuild/android-arm': 0.18.20
@@ -2165,12 +2487,48 @@ snapshots:
dependencies:
resolve-pkg-maps: 1.0.0
html-encoding-sniffer@6.0.0:
dependencies:
'@exodus/bytes': 1.15.1
transitivePeerDependencies:
- '@noble/hashes'
husky@9.1.7: {}
inherits@2.0.4: {}
ipaddr.js@2.5.0: {}
is-potential-custom-element-name@1.0.1: {}
js-tokens@4.0.0: {}
jsdom@30.0.1:
dependencies:
'@asamuzakjp/css-color': 6.0.7
'@asamuzakjp/dom-selector': 8.3.2
'@bramus/specificity': 2.4.2
'@csstools/css-syntax-patches-for-csstree': 1.1.13(css-tree@3.2.1)
'@exodus/bytes': 1.15.1
css-tree: 3.2.1
data-urls: 7.0.0
decimal.js: 10.6.0
html-encoding-sniffer: 6.0.0
is-potential-custom-element-name: 1.0.1
lru-cache: 11.5.2
parse5: 8.0.1
saxes: 6.0.0
symbol-tree: 3.2.4
tough-cookie: 6.0.2
undici: 8.10.2
w3c-xmlserializer: 5.0.0
webidl-conversions: 8.0.1
whatwg-mimetype: 5.0.0
whatwg-url: 17.1.1
xml-name-validator: 5.0.0
transitivePeerDependencies:
- '@noble/hashes'
json-schema-ref-resolver@3.0.0:
dependencies:
dequal: 2.0.3
@@ -2191,6 +2549,12 @@ snapshots:
optionalDependencies:
yaml: 2.9.0
lru-cache@11.5.2: {}
lz-string@1.5.0: {}
mdn-data@2.27.1: {}
nanoid@3.3.18: {}
on-exit-leak-free@2.1.2: {}
@@ -2223,6 +2587,10 @@ snapshots:
'@oxfmt/binding-win32-ia32-msvc': 0.64.0
'@oxfmt/binding-win32-x64-msvc': 0.64.0
parse5@8.0.1:
dependencies:
entities: 8.1.0
picocolors@1.1.1: {}
picomatch@4.0.5: {}
@@ -2255,12 +2623,22 @@ snapshots:
postgres@3.4.9: {}
pretty-format@27.5.1:
dependencies:
ansi-regex: 5.0.1
ansi-styles: 5.2.0
react-is: 17.0.2
process-warning@4.0.0: {}
process-warning@5.1.0: {}
punycode@2.3.1: {}
quick-format-unescaped@4.0.4: {}
react-is@17.0.2: {}
readable-stream@3.6.2:
dependencies:
inherits: 2.0.4
@@ -2321,6 +2699,10 @@ snapshots:
safe-stable-stringify@2.5.0: {}
saxes@6.0.0:
dependencies:
xmlchars: 2.2.0
secure-json-parse@4.1.0: {}
semver@7.8.5: {}
@@ -2350,6 +2732,8 @@ snapshots:
dependencies:
safe-buffer: 5.2.1
symbol-tree@3.2.4: {}
thread-stream@4.2.0:
dependencies:
real-require: 1.0.0
@@ -2363,8 +2747,22 @@ snapshots:
tinypool@2.1.0: {}
tldts-core@7.4.12: {}
tldts@7.4.12:
dependencies:
tldts-core: 7.4.12
toad-cache@3.7.4: {}
tough-cookie@6.0.2:
dependencies:
tldts: 7.4.12
tr46@6.0.0:
dependencies:
punycode: 2.3.1
tsx@4.23.12:
dependencies:
esbuild: 0.28.2
@@ -2375,6 +2773,8 @@ snapshots:
undici-types@6.21.0: {}
undici@8.10.2: {}
util-deprecate@1.0.2: {}
vite@6.4.3(@types/node@22.20.1)(tsx@4.23.12)(yaml@2.9.0):
@@ -2391,9 +2791,37 @@ snapshots:
tsx: 4.23.12
yaml: 2.9.0
w3c-xmlserializer@5.0.0:
dependencies:
xml-name-validator: 5.0.0
webidl-conversions@8.0.1: {}
whatwg-mimetype@5.0.0: {}
whatwg-url@16.0.1:
dependencies:
'@exodus/bytes': 1.15.1
tr46: 6.0.0
webidl-conversions: 8.0.1
transitivePeerDependencies:
- '@noble/hashes'
whatwg-url@17.1.1:
dependencies:
'@exodus/bytes': 1.15.1
tr46: 6.0.0
webidl-conversions: 8.0.1
transitivePeerDependencies:
- '@noble/hashes'
wrappy@1.0.2: {}
ws@8.21.3: {}
xml-name-validator@5.0.0: {}
xmlchars@2.2.0: {}
yaml@2.9.0:
optional: true