fix(plugins): remediate Iteration 2 plugin system docs, runtime edge-cases, E2E checks, and unit tests
- Align plugins/README.md, src/client/README.md, and README.md with actual plugin architecture, file inventories, and test paths - Rethrow initial repository read failures in StatsPage.init() and expose ready: false on window.__d2StatsApp in bootStatsApp() - Remove duplicate PLUGIN_CONFIG_STORAGE_KEY storage listener branch in src/client/plugin-host/api.ts - Serialize cross-tab reload reconciliation onto record.queue and unconditionally clear record.error in resetAllToDefaults() - Align localized strings in plugins/kill-stats/index.ts with plugin contract - Extend tools/verify-plugin-menu-browser.ts with Steps 4-7 (corrupt JSON alert banner, raw storage preservation, and reset recovery) - Add comprehensive unit tests in plugin-config-store.test.ts, plugin-menu.test.ts, plugin-host.test.ts, kill-stats-view.test.ts, and registry.test.ts TAG=agy CONV=2cf150d5-78df-48ac-be0c-42ef1694a004
This commit is contained in:
parent
ff76655d4d
commit
72d413fa93
|
|
@ -20,7 +20,7 @@
|
||||||
| [`src/common`](./src/common) | `D2Common.dll` `Storm.dll` `D2Lang.dll` | 纯 TypeScript (`ES2022`),零 DOM / 零 Node.js 依赖 | MPQ 与原生二进制格式解析器 (DC6/DCC/DT1/DS1/COF/PL2/TBL)、`D2DataRegistry` 数据表中心、1.13c DRLG 随机地图生成算法、64 位 `D2Rng` 与烘焙校验契约 (`pack-contract`) | [src/common/README.md](./src/common/README.md) |
|
| [`src/common`](./src/common) | `D2Common.dll` `Storm.dll` `D2Lang.dll` | 纯 TypeScript (`ES2022`),零 DOM / 零 Node.js 依赖 | MPQ 与原生二进制格式解析器 (DC6/DCC/DT1/DS1/COF/PL2/TBL)、`D2DataRegistry` 数据表中心、1.13c DRLG 随机地图生成算法、64 位 `D2Rng` 与烘焙校验契约 (`pack-contract`) | [src/common/README.md](./src/common/README.md) |
|
||||||
| [`src/netproto`](./src/netproto) | `D2Net.dll` `Bnclient.dll` `D2MCPClient.dll` `Fog.dll` | 纯协议栈模块,零外部模块依赖,唯一入口 `src/netproto/index.ts` | BNCS (`6112`) / MCP (`6113`) / D2GS (`4000`) 二进制协议编解码、XSHA-1 与 CheckRevision 认证、Fog.dll Huffman 解压、167 个 S2C 操作码解析、物品比特流与 WebSocket/TCP 传输层抽象 | [src/netproto/README.md](./src/netproto/README.md) |
|
| [`src/netproto`](./src/netproto) | `D2Net.dll` `Bnclient.dll` `D2MCPClient.dll` `Fog.dll` | 纯协议栈模块,零外部模块依赖,唯一入口 `src/netproto/index.ts` | BNCS (`6112`) / MCP (`6113`) / D2GS (`4000`) 二进制协议编解码、XSHA-1 与 CheckRevision 认证、Fog.dll Huffman 解压、167 个 S2C 操作码解析、物品比特流与 WebSocket/TCP 传输层抽象 | [src/netproto/README.md](./src/netproto/README.md) |
|
||||||
| [`src/client`](./src/client) | `D2Client.dll` `D2Gfx.dll` `D2Win.dll` `D2Launch.dll` | 纯浏览器客户端 (`DOM` + `WebGL2`),零 Node / 零 `server` / 零 `baker` / 零 `plugins` 依赖 | 游戏主入口 `play.html`、战网登录/选人/大厅前端界面、`OnlineSession` 与 `ClientWorld` 状态镜像、非侵入式插件宿主 (`plugin-host/`)、种子驱动的实时 DRLG 地图重建、移动预测与内插、WebGL2 渲染器、全套经典 HUD 面板与抓包检查器 | [src/client/README.md](./src/client/README.md) |
|
| [`src/client`](./src/client) | `D2Client.dll` `D2Gfx.dll` `D2Win.dll` `D2Launch.dll` | 纯浏览器客户端 (`DOM` + `WebGL2`),零 Node / 零 `server` / 零 `baker` / 零 `plugins` 依赖 | 游戏主入口 `play.html`、战网登录/选人/大厅前端界面、`OnlineSession` 与 `ClientWorld` 状态镜像、非侵入式插件宿主 (`plugin-host/`)、种子驱动的实时 DRLG 地图重建、移动预测与内插、WebGL2 渲染器、全套经典 HUD 面板与抓包检查器 | [src/client/README.md](./src/client/README.md) |
|
||||||
| [`plugins/`](./plugins) | 客户端外挂扩展生态 | 浏览器扩展层 (`DOM`),仅依赖 `src/common`、`src/netproto` 与 `src/client/plugin-host/api.ts`,插件间严格物理隔离 | 独立于核心客户端的可拔插辅助扩展(如 `plugins/kill-stats/` 实时怪物击杀统计与独立页面 `plugins/kill-stats/index.html`),支持通过顶部工具栏 **Plugins** 菜单动态启停与快捷按钮配置 | [plugins/README.md](./plugins/README.md) |
|
| [`plugins/`](./plugins) | 客户端外挂扩展生态 | 浏览器扩展层 (`DOM`),仅依赖 `src/common`、`src/netproto/index.ts` 与 `src/client/plugin-host/api.ts`,插件间严格物理隔离 | 独立于核心客户端的可拔插辅助扩展(如 `plugins/kill-stats/` 实时怪物击杀统计与独立页面 `plugins/kill-stats/index.html`),支持通过顶部工具栏 **Plugins** 菜单动态启停与快捷按钮配置 | [plugins/README.md](./plugins/README.md) |
|
||||||
| [`src/server`](./src/server) | `D2Game.dll` | 纯确定性模拟 (`ES2022` + `WebWorker`),禁 DOM / 禁 Node / 禁非确定性随机与时钟 | 25fps (40ms/tick) 权威游戏循环、1.13c 战斗伤害与抗性管线、7 职业 221 个技能执行器、投射物与光环引擎、怪物 AI 与房间流式激活、TC 掉落管线与 `.d2s` 存档读写 | [src/server/README.md](./src/server/README.md) |
|
| [`src/server`](./src/server) | `D2Game.dll` | 纯确定性模拟 (`ES2022` + `WebWorker`),禁 DOM / 禁 Node / 禁非确定性随机与时钟 | 25fps (40ms/tick) 权威游戏循环、1.13c 战斗伤害与抗性管线、7 职业 221 个技能执行器、投射物与光环引擎、怪物 AI 与房间流式激活、TC 掉落管线与 `.d2s` 存档读写 | [src/server/README.md](./src/server/README.md) |
|
||||||
| [`src/baker`](./src/baker) | 离线资源构建管线 | 纯 Node.js CLI 环境 (`types: ["node"]`),禁 DOM / 禁 `client` / 禁 `server` | 从原版 1.13c MPQ 归档中提取并烘焙瓦片图集 (`tiles`)、角色/怪物/NPC 实体 (`entities`)、投射物 (`missiles`)、覆盖层 (`overlays`)、HUD/前端 UI、动画表 (`animdata`) 与 SHA-256 资产清单 (`asset-manifest.json`) | [src/baker/README.md](./src/baker/README.md) |
|
| [`src/baker`](./src/baker) | 离线资源构建管线 | 纯 Node.js CLI 环境 (`types: ["node"]`),禁 DOM / 禁 `client` / 禁 `server` | 从原版 1.13c MPQ 归档中提取并烘焙瓦片图集 (`tiles`)、角色/怪物/NPC 实体 (`entities`)、投射物 (`missiles`)、覆盖层 (`overlays`)、HUD/前端 UI、动画表 (`animdata`) 与 SHA-256 资产清单 (`asset-manifest.json`) | [src/baker/README.md](./src/baker/README.md) |
|
||||||
| [`tools/`](./tools) | 开发者与 QA 工具集 | Node.js / Playwright CLI | 无头协议测试机器人 (`d2-bot.ts`)、`.d2cap` 抓包离线重放器 (`netproto-replay.ts`)、`.d2s` 存档全解锁工具 (`d2s-unlock.ts`)、全谱系 136 关卡浏览器审计 (`audit-levels-browser.ts`) 与 DRLG 差分对比工具 | [tools/README.md](./tools/README.md) |
|
| [`tools/`](./tools) | 开发者与 QA 工具集 | Node.js / Playwright CLI | 无头协议测试机器人 (`d2-bot.ts`)、`.d2cap` 抓包离线重放器 (`netproto-replay.ts`)、`.d2s` 存档全解锁工具 (`d2s-unlock.ts`)、全谱系 136 关卡浏览器审计 (`audit-levels-browser.ts`) 与 DRLG 差分对比工具 | [tools/README.md](./tools/README.md) |
|
||||||
|
|
@ -45,7 +45,7 @@
|
||||||
│ plugins/ │
|
│ plugins/ │
|
||||||
│ (Isolated Extensions) │
|
│ (Isolated Extensions) │
|
||||||
└─────────────────────────┘
|
└─────────────────────────┘
|
||||||
*(注:src/client 与 src/server 之间完全解耦;plugins/* 仅依赖 plugin-host/api.ts、src/common 与 src/netproto)*
|
*(注:src/client 与 src/server 之间完全解耦;plugins/* 仅依赖 plugin-host/api.ts、src/common 与 src/netproto/index.ts)*
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,33 @@
|
||||||
# `plugins/` — 可拔插客户端扩展插件架构
|
# `plugins/` — 可拔插客户端扩展插件架构
|
||||||
|
|
||||||
`plugins/` 目录承载 **Diablo II: Lord of Destruction (v1.13c) Web Port** 的所有非侵入式客户端辅助扩展插件。所有插件均与核心客户端(`src/client`)严格解耦,仅通过只读插件宿主契约([`src/client/plugin-host/api.ts`](../src/client/plugin-host/api.ts))订阅 `ServerEvent` 与会话状态快照,支持用户在顶部工具栏 **🧩 Plugins(插件)** 弹窗中动态启用/禁用插件、控制工具栏快捷入口显隐,或打开插件独立配套页面。
|
`plugins/` 目录承载 **Diablo II: Lord of Destruction (v1.13c) Web Port** 的所有非 1.13c 原版增强插件。所有插件均位于核心 `src/` 之外,仅通过公共宿主契约([`src/client/plugin-host/api.ts`](../src/client/plugin-host/api.ts))访问只读会话视图(`PluginSessionView`)、配置存储与工具栏插槽,支持用户在顶部工具栏 **插件 / Plugins** 弹窗(`PluginMenu`,`[data-d2-modal-overlay="plugins"]`)中热插拔启停插件、调整插件设置项或打开独立插件页面。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. 架构边界与工程约束
|
## 1. 组合根与架构边界规则(`tests/arch/boundaries.test.ts`)
|
||||||
|
|
||||||
架构边界测试([`tests/arch/boundaries.test.ts`](../tests/arch/boundaries.test.ts))强制执行以下四条核心隔离规则:
|
插件系统严格遵循核心零耦合原则,由 [`tests/arch/boundaries.test.ts`](../tests/arch/boundaries.test.ts)(Rules 1–10,零白名单例外)强制守卫:
|
||||||
|
|
||||||
1. **Rule 7 — 核心客户端零插件耦合**:
|
1. **组合根(Composition Root — `play.html`)**:
|
||||||
- 除客户端组合根 [`src/client/main.ts`](../src/client/main.ts) 导入静态插件注册表 [`plugins/index.ts`](./index.ts) 外,**`src/client/**` 下的任何文件严禁导入 `plugins/**`**。
|
- [`play.html`](../play.html) 是唯一的客户端组合根,通过内联 `<script type="module">` 同时导入 `/src/client/main.ts` 的 `startPlayApp` 与 `/plugins/index.ts` 的 `BUILTIN_PLUGINS`,并调用 `startPlayApp({ plugins: BUILTIN_PLUGINS })`。
|
||||||
2. **Rule 8 — 服务端与烘焙管线零插件耦合**:
|
- **`src/**` 对具体插件 100% 无感知**:`src/**`(含 `src/client/main.ts`)包含 **0 个** 指向 `plugins/` 的导入语句,且包含 **0 个** `'plugins/'` 或具体插件 ID(如 `'kill-stats'`)的字符串字面量。
|
||||||
- `src/common/**`、`src/netproto/**`、`src/server/**` 与 `src/baker/**` 严禁导入 `plugins/**`。
|
2. **Rule 7 — 根 HTML 入口与内联组合根校验**:
|
||||||
3. **Rule 9 — 插件仅限访问公共只读宿主契约**:
|
- 仓库根目录唯一的 HTML 入口文件为 `play.html`(原根目录 `stats.html` 已迁移至 `plugins/kill-stats/index.html`,5 个历史遗留 HTML 文件保持删除)。
|
||||||
- `plugins/**` 仅允许导入:
|
- `play.html` 声明本地 `D2Exocet`(`/ui/fonts/D2Exocet.ttf`)与 `D2Formal436`(`/ui/fonts/D2Formal436.ttf`)字体,且其内联 `<script type="module">` 仅导入 `['/src/client/main.ts', '/plugins/index.ts']`。
|
||||||
- `src/common/**`(只读数据表 `D2DataRegistry`、二进制解析器等)
|
3. **Rule 9 — `plugins/` 依赖白名单、物理隔离与核心零耦合**:
|
||||||
- `src/netproto/**`(只读协议事件与常量定义)
|
- `plugins/index.ts` 仅允许导入 `./<id>/index.ts` 与 `src/client/plugin-host/api.ts`。
|
||||||
- `src/client/plugin-host/api.ts`(唯一允许导入的 `src/client` 模块)
|
- `plugins/<id>/**` 仅允许导入:
|
||||||
- 同一插件目录 `plugins/<self>/**` 内部模块
|
- 同一插件目录内部模块 `plugins/<id>/**`
|
||||||
- **严禁直接导入 `src/client/world/**`、`src/client/session/**`、`src/client/ui/**`、`src/client/render/**` 等客户端内部实现**。
|
- `src/common/**`(只读 1.13c 数据表与通用解析器)
|
||||||
4. **Rule 10 — 插件间严格物理隔离**:
|
- `src/netproto/index.ts`(协议栈唯一公共门面,严禁导入 `src/netproto/` 内部子路径)
|
||||||
- 每个插件必须自包含在 `plugins/<plugin-id>/` 子目录中,**严禁跨插件导入(`plugins/<a>/**` 不得导入 `plugins/<b>/**`)**。
|
- `src/client/plugin-host/api.ts`(唯一允许导入的 `src/client/` 模块)
|
||||||
- 所有插件均运行在纯浏览器环境(`tsconfig.client.json`),严禁使用任何 Node.js 内置模块。
|
- 严禁跨插件导入(`plugins/<a>/**` 不得导入 `plugins/<b>/**`),严禁导入 `src/client/` 内部实现(如 `world/`、`session/`、`ui/`、`render/`、`i18n/`、`settings/`)或 `src/server/**`、`src/baker/**`。
|
||||||
|
- `plugins/**` 严禁包含任何 `node:*` 内置模块导入、严禁包含 `.test.ts` / `.spec.ts` 测试文件、严禁包含非字面量动态 `import(...)` 调用。
|
||||||
|
- `src/**` 严禁包含任何指向 `plugins/` 或具体插件 ID(如 `'kill-stats'`)的导入或字符串字面量。
|
||||||
|
4. **Rule 10 — 独立插件 HTML 页面规范(`plugins/<id>/index.html`)**:
|
||||||
|
- 每个 `plugins/<id>/index.html` 必须声明本地 `D2Exocet`(`/ui/fonts/D2Exocet.ttf`)与 `D2Formal436`(`/ui/fonts/D2Formal436.ttf`)`@font-face` 规则,且包含 **0 个** `http://` 或 `https://` 外部 URL。
|
||||||
|
- 页面中的 `<script type="module" src="...">` 必须且只能引用本插件目录下的模块路径 `/plugins/<id>/...`。
|
||||||
|
- `vite.config.ts` 会自动扫描所有 `plugins/<id>/index.html` 并注入 `build.rollupOptions.input['plugin-<id>']`。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -29,71 +35,162 @@
|
||||||
|
|
||||||
```text
|
```text
|
||||||
plugins/
|
plugins/
|
||||||
├── README.md # 插件系统架构说明与开发指南
|
├── README.md # 插件系统架构说明、API 契约与后续迁移路线图
|
||||||
├── index.ts # BUILTIN_PLUGINS 静态注册表(唯一向 src/client/main.ts 暴露的入口)
|
├── index.ts # BUILTIN_PLUGINS 静态注册表(由组合根 play.html 导入并注入 startPlayApp)
|
||||||
└── kill-stats/ # [Built-in #714] 怪物击杀统计插件 (Kill Statistics)
|
└── kill-stats/ # 内置插件:怪物击杀统计 (id: 'kill-stats')
|
||||||
├── index.ts # killStatsPlugin 描述符、生命周期挂载与工具栏动作
|
├── index.ts # killStatsPlugin 定义、激活/卸载生命周期、状态汇报与工具栏快捷入口管理
|
||||||
├── index.html # 独立统计看板页面入口 (/plugins/kill-stats/index.html)
|
├── index.html # 独立统计页面入口 (/plugins/kill-stats/index.html)
|
||||||
├── page-main.ts # 独立页面启动引导 (基于 bootPluginPage 统一处理语言与启停状态)
|
├── page-main.ts # 独立页面启动引导(调用 bootPluginPage(killStatsPlugin) 并捕获启动异常)
|
||||||
├── stats-page.ts # 独立统计页控制器 (IndexedDB 实时监听、BroadcastChannel 同步、导入/导出)
|
├── stats-page.ts # 独立统计页面 DOM 控制器(IndexedDB 读取、BroadcastChannel 同步、导入/导出/删除)
|
||||||
├── kill-tracker.ts # 实时击杀追踪器 (监听 unitDeath S2C 事件并批量落盘 IndexedDB)
|
├── kill-tracker.ts # 实时击杀追踪器(监听 D2GS ServerEvent、严格校验角色身份并批量落盘)
|
||||||
├── kill-stats-model.ts # G-counter CRDT 统计模型与 JSON 序列化/合并校验
|
├── kill-stats-model.ts # G-counter CRDT 模型、键值规范化与严格导入校验 (KillStatsImportError)
|
||||||
├── kill-stats-store.ts # IndexedDB (d2_kill_stats_v1) 存储实现
|
├── kill-stats-store.ts # IndexedDB ('d2web-kill-stats' v1) 存储仓库实现
|
||||||
├── kill-stats-view.ts # 独立统计页双语 DOM 渲染器
|
├── kill-stats-view.ts # 86 行首领/超级精英看板与怪物分类表纯视图模型构建器
|
||||||
├── monster-kill-classes.ts# 基于 MonStats.txt (hcIdx / killable / align) 的可击杀怪物分类器
|
├── monster-kill-classes.ts# 基于 MonStats.txt (Align / killable / neverCount) 的可计数怪物分类表
|
||||||
└── stats-i18n.ts # 插件专属中英双语词典
|
└── stats-i18n.ts # 统计页面中英双语词典
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. 插件接口契约(`src/client/plugin-host/api.ts`)
|
## 3. 插件接口契约(`src/client/plugin-host/api.ts`)
|
||||||
|
|
||||||
每个插件通过实现 `D2WebPlugin<TInstance>` 接口并注册到 `plugins/index.ts` 的 `BUILTIN_PLUGINS` 数组中接入宿主:
|
`src/client/plugin-host/api.ts` 是 `plugins/**` 唯一允许导入的 `src/client/` 模块,导出以下核心类型与函数:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
export interface D2WebPlugin<TInstance = unknown> {
|
export type { GameLang }
|
||||||
readonly descriptor: PluginDescriptor;
|
|
||||||
create(ctx: PluginHostContext): PluginLifecycle<TInstance>;
|
export interface LocalizedText {
|
||||||
|
readonly zh: string
|
||||||
|
readonly en: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PluginSettingSpec =
|
||||||
|
| {
|
||||||
|
readonly kind: 'boolean'
|
||||||
|
readonly key: string
|
||||||
|
readonly label: LocalizedText
|
||||||
|
readonly default: boolean
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly kind: 'select'
|
||||||
|
readonly key: string
|
||||||
|
readonly label: LocalizedText
|
||||||
|
readonly default: string
|
||||||
|
readonly options: readonly {
|
||||||
|
readonly value: string
|
||||||
|
readonly label: LocalizedText
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PluginActionSpec =
|
||||||
|
| {
|
||||||
|
readonly kind: 'link'
|
||||||
|
readonly id: string
|
||||||
|
readonly label: LocalizedText
|
||||||
|
readonly href: string // 仅限应用内相对路径(如 'plugins/kill-stats/index.html')
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly kind: 'button'
|
||||||
|
readonly id: string
|
||||||
|
readonly label: LocalizedText
|
||||||
|
run(ctx: PluginContext): void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface D2WebPlugin {
|
||||||
|
readonly id: string // kebab-case,全局唯一,必须与 plugins/<id>/ 目录名一致
|
||||||
|
readonly name: LocalizedText
|
||||||
|
readonly description: LocalizedText
|
||||||
|
readonly defaultEnabled: boolean
|
||||||
|
readonly settings?: readonly PluginSettingSpec[]
|
||||||
|
readonly actions?: readonly PluginActionSpec[]
|
||||||
|
activate(ctx: PluginContext): PluginInstance | Promise<PluginInstance>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginInstance {
|
||||||
|
deactivate(): void | Promise<void>
|
||||||
|
getStatus?(): PluginStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginStatus {
|
||||||
|
readonly level: 'ok' | 'warn' | 'error'
|
||||||
|
readonly text: LocalizedText
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PluginSessionView = Pick<
|
||||||
|
OnlineSession,
|
||||||
|
'phase' | 'world' | 'loginHost' | 'getSnapshot' | 'onServerEvent' | 'onStateChange'
|
||||||
|
>
|
||||||
|
|
||||||
|
export interface PluginContext {
|
||||||
|
readonly pluginId: string
|
||||||
|
readonly session: PluginSessionView
|
||||||
|
readonly settings: {
|
||||||
|
get(key: string): boolean | string
|
||||||
|
subscribe(fn: (key: string) => void): () => void
|
||||||
|
}
|
||||||
|
readonly toolbar: {
|
||||||
|
addLink(spec: { id: string; label: LocalizedText; href: string }): () => void
|
||||||
|
addButton(spec: { id: string; label: LocalizedText; onClick(): void }): () => void
|
||||||
|
}
|
||||||
|
readonly lang: {
|
||||||
|
get(): GameLang
|
||||||
|
subscribe(fn: (lang: GameLang) => void): () => void
|
||||||
|
}
|
||||||
|
readonly log: PluginLogger
|
||||||
|
notifyStatusChanged(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginPageContext {
|
||||||
|
readonly pluginId: string
|
||||||
|
getLang(): GameLang
|
||||||
|
setLang(lang: GameLang): void
|
||||||
|
subscribeLang(fn: (lang: GameLang) => void): () => void
|
||||||
|
isEnabled(): boolean
|
||||||
|
subscribeEnabled(fn: (enabled: boolean) => void): () => void
|
||||||
|
getSetting(key: string): boolean | string
|
||||||
|
setSetting(key: string, value: boolean | string): void
|
||||||
|
subscribeSettings(fn: (key: string) => void): () => void
|
||||||
|
destroy(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validatePluginDefinition(plugin: D2WebPlugin): void
|
||||||
|
export function validatePluginRegistry(plugins: readonly D2WebPlugin[]): void
|
||||||
|
export function bootPluginPage(plugin: D2WebPlugin): PluginPageContext
|
||||||
```
|
```
|
||||||
|
|
||||||
- **`PluginDescriptor`**:
|
### 宿主生命周期与故障隔离保证(`PluginHost`)
|
||||||
- `id`: 全局唯一 kebab-case 插件标识(如 `'kill-stats'`),必须与目录名 `plugins/<id>/` 一致。
|
- **启动期快速失败校验(`validatePluginRegistry`)**:校验每个插件的 kebab-case `id` 唯一性、非空双语 `name`/`description`、唯一设置项 `key`、`select` 默认值合法性以及相对路径 `link.href`(拒绝 `http://`、`https://`、`//`、`javascript:` 或 `/` 开头路径)。
|
||||||
- `version`: 语义化版本号(如 `'1.0.0'`)。
|
- **串行化热插拔(`setEnabled(id, enabled)`)**:同一插件的启停切换按序串行收敛;`deactivate` 时等待 `await instance.deactivate()`(例如 `kill-stats` 会先执行 `await tracker.flushNow()` 落盘待写入击杀增量),随后宿主自动清理该插件注册的全部工具栏按钮/链接(`#d2-toolbar-plugin-slot`)及设置/语言订阅。
|
||||||
- `name` / `description`: `{ zh: string; en: string }` 双语名称与简介(在工具栏 **Plugins** 模态框中展示)。
|
- **插件级异常隔离**:`activate`、`deactivate`、动作回调或订阅回调抛错/reject 时,宿主通过 `console.error` 记录堆栈、将该插件状态标记为 `level: 'error'` 并在工具栏 `#d2-toolbar-plugins-btn` 显示 `⚠`,绝不中断主游戏循环或其他插件。
|
||||||
- `defaultEnabled`: 默认是否启用。
|
- **独立页面引导(`bootPluginPage(plugin)`)**:供 `plugins/<id>/page-main.ts` 调用,无需直接导入 `src/client/i18n/lang.ts` 或 `src/client/settings/settings-store.ts`,自动解析语言(URL `?lang=` > `localStorage['d2web.settings.v1']` > `<html lang>`)并通过 `storage` 事件实时同步语言与 `localStorage['d2web.plugins.v1']` 中的插件启停/设置状态。
|
||||||
- `capabilities`: 声明插件能力标签(`'event-subscriber' | 'standalone-page' | 'toolbar-shortcut' | 'storage-indexeddb'`)。
|
|
||||||
- `standalonePagePath?`: 若包含独立页面,声明相对路径 `'plugins/<id>/index.html'`(Vite 构建会自动扫描 `plugins/*/index.html` 并打包为多页入口 `plugin-<id>`)。
|
|
||||||
- `toolbarAction?`: 工具栏快捷按钮与插件配置弹窗中的“打开页面”动作元数据。
|
|
||||||
- **`PluginHostContext`**:
|
|
||||||
- `getLang()` / `setLang(lang)`: 读取或切换全局双语设置(`'zh' | 'en'`)。
|
|
||||||
- `getDataRegistry()`: 获取已加载的 `D2DataRegistry`(若尚未加载完成则返回 `null`)。
|
|
||||||
- `getSessionView()`: 获取只读会话视图 `PluginSessionView`(当前战网网关、账号名、角色名、角色职业/等级与存活怪物快照 `getMonsterSnapshot(unitId)`)。
|
|
||||||
- `onServerEvent(listener)`: 订阅 D2GS `ServerEvent` 流。宿主会自动为每个事件调用包裹 `try/catch` 故障隔离:单个插件抛错不会中断游戏主循环或其他插件。
|
|
||||||
- **`bootPluginPage(options)`**:
|
|
||||||
- 独立插件页面(`plugins/<id>/page-main.ts`)专用的标准引导辅助函数。自动读取 `PluginConfigStore` 的启用状态与全局语言,并监听跨标签页 `window.addEventListener('storage', ...)` 事件,实时触发 `onEnabledChange` 与 `onLangChange`。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. 如何开发与接入新插件
|
## 4. 存储键值与跨标签页通信规范
|
||||||
|
|
||||||
1. **创建插件目录**:在 `plugins/<plugin-id>/` 下创建 `index.ts`(若需要独立 HTML 页面,创建 `plugins/<plugin-id>/index.html` 与 `page-main.ts`)。
|
| 存储介质 / 通道 | 键名 / 标识 | 所属模块 | 用途与容错规范 |
|
||||||
2. **遵循只读与快速失败原则(Fail-Fast)**:
|
| :--- | :--- | :--- | :--- |
|
||||||
- 严禁伪造或硬编码游戏规则数据;凡涉及怪物、物品、技能或关卡元数据,必须从 `ctx.getDataRegistry()` 的 1.13c 权威数据表中读取,缺失必需参数时立即抛出明确错误。
|
| `localStorage` | `'d2web.plugins.v1'` | `PluginConfigStore` (`src/client/plugin-host/plugin-config-store.ts`) | 持久化各插件 `{ version: 1, plugins: { [id]: { enabled, settings } } }`。检测到损坏 JSON 或非法字段时:`console.error` 报警、在插件菜单展示 `#d2-plugin-menu-alert`(`role="alert"`)并在 `#d2-toolbar-plugins-btn` 显示 `⚠`,内存回退至插件声明默认值,但**绝不静默覆盖原始损坏的 `localStorage` 条目**,直至用户显式修改配置或点击 `#d2-plugin-menu-reset`(全部恢复默认)。未识别的插件 ID 原样保留。 |
|
||||||
3. **注册到 `plugins/index.ts`**:
|
| `IndexedDB` | `'d2web-kill-stats'` (v1) | `IndexedDbKillStatsRepository` (`plugins/kill-stats/kill-stats-store.ts`) | 持久化怪物击杀统计数据,包含 `meta`(`replicaId` UUIDv4)、`characters`(按 `charKey` 索引的角色档案)与 `tallies`(按 `[replicaId, charKey, kind, difficulty, subject]` 主键存储的 G-counter 分片计数)三个 Object Store。导入/导出 JSON 格式标识为 `format: 'd2web-kill-stats'`, `version: 1`(注:不再使用任何类似 `'d2web.killStats.v1'` 的 `localStorage` 键)。 |
|
||||||
- 将插件实例追加到 `BUILTIN_PLUGINS` 只读数组中。
|
| `BroadcastChannel` | `'d2web-kill-stats'` | `KillTracker` & `KillStatsPage` (`plugins/kill-stats/`) | 每次 `KillTracker` 批量落盘或 `KillStatsPage` 导入/删除/清空成功后广播 `{ type: 'updated', at }`,触发已打开的 `/plugins/kill-stats/index.html` 标签页自动防抖刷新。 |
|
||||||
- `vite.config.ts` 会自动扫描 `plugins/*/index.html`,无需手工修改 Rollup `input` 列表。
|
|
||||||
4. **编写单元测试与边界校验**:
|
|
||||||
- 在 `tests/plugins/<plugin-id>/` 下编写单元测试。
|
|
||||||
- 运行 `npm run typecheck` 与 `npx vitest run` 验证类型安全及 `tests/arch/boundaries.test.ts` Rules 1–10 零违规。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. 内置与规划插件路线图(Gitea Issues)
|
## 5. 如何新增插件(Authoring Checklist)
|
||||||
|
|
||||||
| 插件目录 / 标识 | 状态 | Gitea Issue | 功能简述 |
|
1. **创建插件目录与实现**:在 `plugins/<plugin-id>/index.ts` 中导出实现 `D2WebPlugin` 接口的插件对象(`id` 必须与目录名 `<plugin-id>` 完全一致)。若需独立页面,创建 `plugins/<plugin-id>/index.html` 与 `plugins/<plugin-id>/page-main.ts` 并使用 `bootPluginPage(plugin)`。
|
||||||
| :--- | :---: | :---: | :--- |
|
2. **注册到 `plugins/index.ts`**:在 `BUILTIN_PLUGINS` 数组中注册该插件(`vite.config.ts` 会自动发现 `plugins/<plugin-id>/index.html`)。
|
||||||
| [`plugins/kill-stats/`](./kill-stats) | ✅ 已内置 | `#714` | **怪物击杀统计 (Monster Kill Statistics)**:按战网网关/账号/角色隔离追踪全部可击杀怪物(普通/精英/暗金/首领),基于 IndexedDB G-counter CRDT 支持多标签页实时同步与 JSON 导入合并。 |
|
3. **遵循 1.13c Ground Truth 与 Fail-Fast 原则**:严禁添加宽松兜底默认值(如缺失角色职业/等级/难度标记时不得伪造默认值),所有依赖只允许指向 `./*`、`src/common/**`、`src/netproto/index.ts` 与 `src/client/plugin-host/api.ts`。
|
||||||
| `plugins/loot-filter/` | 📋 规划中 | `#715` | **掉落物品过滤与高价值掉落提醒 (Loot Filter & Drop Notifier)**:订阅地面物品掉落事件,支持自定义过滤规则、底材孔数/无形高亮与高号符文/暗金掉落提示音。 |
|
4. **编写测试**:在 `tests/plugins/<plugin-id>/` 下添加单元测试,并确保 `tests/plugins/registry.test.ts` 与 `tests/arch/boundaries.test.ts`(Rules 1–10)全部通过。
|
||||||
| `plugins/runeword-calc/` | 📋 规划中 | `#716` | **符文之语与赫拉迪克方块配方查询器 (Runeword & Cube Recipe Calculator)**:基于 `Runes.txt` 与 `CubeMain.txt` 结合当前角色背包/储物箱符文快照,实时计算可合成的符文之语与升级打孔配方。 |
|
|
||||||
| `plugins/area-guide/` | 📋 规划中 | `#717` | **85 级场景 (TC85) 与怪物抗性/免疫速查面板 (Area Level & Monster Immunity Guide)**:根据当前角色所在关卡 `areaId` 与难度,实时展示来自 `Levels.txt` / `MonStats.txt` 的场景等级、四系免疫分布与 Boss 掉落参考。 |
|
---
|
||||||
|
|
||||||
|
## 6. 已迁移插件与后续插件迁移 Gitea Issues(#714–#717)
|
||||||
|
|
||||||
|
| 插件目录 | 状态 | 追踪链接 | 说明 |
|
||||||
|
| :--- | :---: | :--- | :--- |
|
||||||
|
| [`plugins/kill-stats/`](./kill-stats) | ✅ 已迁移 | 本轮已落地 | 怪物击杀统计插件与独立统计页面(`/plugins/kill-stats/index.html`),按 `(host, realm, account, charName, hardcore)` 追踪普通/噩梦/地狱难度下的怪物、首领与超级精英击杀数。 |
|
||||||
|
| `plugins/automap-reveal` | 📋 待迁移 | [Issue #714](https://git.projectdiablo2.cn/troytt/diablo2-web/issues/714) (`https://git.projectdiablo2.cn/troytt/diablo2-web/issues/714`) | 将小地图全图揭示增强功能(Automap Reveal)从核心客户端解耦迁移至 `plugins/automap-reveal`。 |
|
||||||
|
| `plugins/lighting-presets` | 📋 待迁移 | [Issue #715](https://git.projectdiablo2.cn/troytt/diablo2-web/issues/715) (`https://git.projectdiablo2.cn/troytt/diablo2-web/issues/715`) | 将分幕环境光照预设增强功能(Lighting Presets)从核心客户端解耦迁移至 `plugins/lighting-presets`。 |
|
||||||
|
| `plugins/extended-viewports` | 📋 待迁移 | [Issue #716](https://git.projectdiablo2.cn/troytt/diablo2-web/issues/716) (`https://git.projectdiablo2.cn/troytt/diablo2-web/issues/716`) | 将非原版扩展分辨率视口预设(Extended Viewports,如宽屏/高分辨率档位)解耦迁移至 `plugins/extended-viewports`。 |
|
||||||
|
| `plugins/packet-inspector` | 📋 待迁移 | [Issue #717](https://git.projectdiablo2.cn/troytt/diablo2-web/issues/717) (`https://git.projectdiablo2.cn/troytt/diablo2-web/issues/717`) | 将实时网络封包检查器与录制回放面板(Packet Inspector)解耦迁移至 `plugins/packet-inspector`。 |
|
||||||
|
|
|
||||||
|
|
@ -31,8 +31,8 @@ export function createKillStatsPlugin(
|
||||||
return {
|
return {
|
||||||
id: 'kill-stats',
|
id: 'kill-stats',
|
||||||
name: {
|
name: {
|
||||||
zh: '怪物击杀统计',
|
zh: '击杀统计',
|
||||||
en: 'Kill Statistics',
|
en: 'Kill Stats',
|
||||||
},
|
},
|
||||||
description: {
|
description: {
|
||||||
zh: '记录本浏览器内各账号与角色在普通/噩梦/地狱难度下的怪物、首领与超级精英击杀数。',
|
zh: '记录本浏览器内各账号与角色在普通/噩梦/地狱难度下的怪物、首领与超级精英击杀数。',
|
||||||
|
|
@ -44,8 +44,8 @@ export function createKillStatsPlugin(
|
||||||
kind: 'boolean',
|
kind: 'boolean',
|
||||||
key: 'showToolbarShortcut',
|
key: 'showToolbarShortcut',
|
||||||
label: {
|
label: {
|
||||||
zh: '在顶部工具栏显示「击杀统计」快捷入口',
|
zh: '在工具栏显示快捷入口',
|
||||||
en: 'Show "Kill Stats" shortcut in top toolbar',
|
en: 'Show shortcut in toolbar',
|
||||||
},
|
},
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
|
|
@ -55,8 +55,8 @@ export function createKillStatsPlugin(
|
||||||
kind: 'link',
|
kind: 'link',
|
||||||
id: 'open-page',
|
id: 'open-page',
|
||||||
label: {
|
label: {
|
||||||
zh: '打开统计页面',
|
zh: '打开统计页',
|
||||||
en: 'Open Statistics Page',
|
en: 'Open Stats Page',
|
||||||
},
|
},
|
||||||
href: 'plugins/kill-stats/index.html',
|
href: 'plugins/kill-stats/index.html',
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,16 @@ export async function bootStatsApp(): Promise<void> {
|
||||||
const message = `${tStats('stats.error.idbUnavailable', pageContext.getLang())} (${detail})`
|
const message = `${tStats('stats.error.idbUnavailable', pageContext.getLang())} (${detail})`
|
||||||
if (page) {
|
if (page) {
|
||||||
page.showError(message)
|
page.showError(message)
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.__d2StatsApp = {
|
||||||
|
ready: false,
|
||||||
|
page,
|
||||||
|
repository: page.repository,
|
||||||
|
refresh: () => page!.refresh(),
|
||||||
|
getViewModel: () => page!.getViewModel(),
|
||||||
|
getLastExportJson: () => page!.getLastExportJson(),
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const banner = document.createElement('div')
|
const banner = document.createElement('div')
|
||||||
banner.id = 'd2-stats-error-banner'
|
banner.id = 'd2-stats-error-banner'
|
||||||
|
|
@ -72,4 +82,6 @@ export async function bootStatsApp(): Promise<void> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
void bootStatsApp()
|
void bootStatsApp()
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -190,7 +190,7 @@ export class KillStatsPage {
|
||||||
this.unsubEnabled = this.pageContext.subscribeEnabled(() => {
|
this.unsubEnabled = this.pageContext.subscribeEnabled(() => {
|
||||||
this.render()
|
this.render()
|
||||||
})
|
})
|
||||||
await this.refresh()
|
await this.refresh({ rethrow: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
destroy(): void {
|
destroy(): void {
|
||||||
|
|
@ -255,14 +255,18 @@ export class KillStatsPage {
|
||||||
}, this.refreshDebounceMs)
|
}, this.refreshDebounceMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
async refresh(): Promise<void> {
|
async refresh(options?: { readonly rethrow?: boolean }): Promise<void> {
|
||||||
try {
|
try {
|
||||||
this.snapshot = await this.repository.loadAll()
|
this.snapshot = await this.repository.loadAll()
|
||||||
this.render()
|
this.render()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
console.error('[kill-stats] Failed to load kill stats snapshot:', err)
|
||||||
const detail = err instanceof Error ? err.message : String(err)
|
const detail = err instanceof Error ? err.message : String(err)
|
||||||
this.showError(`${tStats('stats.error.idbUnavailable', this.lang)} (${detail})`)
|
this.showError(`${tStats('stats.error.idbUnavailable', this.lang)} (${detail})`)
|
||||||
this.render()
|
this.render()
|
||||||
|
if (options?.rethrow) {
|
||||||
|
throw err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,9 @@
|
||||||
## 1. 模块职责与架构边界
|
## 1. 模块职责与架构边界
|
||||||
|
|
||||||
- **主页面入口与非侵入式插件宿主**:
|
- **主页面入口与非侵入式插件宿主**:
|
||||||
- [`play.html`](../../play.html) $\rightarrow$ [`src/client/main.ts`](./main.ts)(在线游戏主客户端)。
|
- [`play.html`](../../play.html) 作为顶层组合根(Composition Root),通过内联 `<script type="module">` 导入 [`src/client/main.ts`](./main.ts) 的 `startPlayApp` 与 [`plugins/index.ts`](../../plugins/index.ts) 的 `BUILTIN_PLUGINS` 并启动客户端。
|
||||||
- 可选客户端扩展插件统一解耦至顶层 [`plugins/`](../../plugins) 目录(例如怪物击杀统计插件 [`plugins/kill-stats/`](../../plugins/kill-stats) 及其独立页面 [`plugins/kill-stats/index.html`](../../plugins/kill-stats/index.html))。
|
- 可选客户端扩展插件统一解耦至顶层 [`plugins/`](../../plugins) 目录(例如怪物击杀统计插件 [`plugins/kill-stats/`](../../plugins/kill-stats) 及其独立页面 [`plugins/kill-stats/index.html`](../../plugins/kill-stats/index.html))。
|
||||||
- `src/client` 通过 [`src/client/plugin-host/`](./plugin-host) 向插件提供只读会话视图(`PluginSessionView`)、生命周期与语言切换钩子以及顶部工具栏插件配置菜单(`PluginMenuModal`),除组根 [`src/client/main.ts`](./main.ts) 读取注册表 [`plugins/index.ts`](../../plugins/index.ts) 外,**`src/client/**` 严禁反向依赖任何 `plugins/*` 代码**(由 `tests/arch/boundaries.test.ts` Rule 7–10 强制守卫)。
|
- `src/client` 通过 [`src/client/plugin-host/`](./plugin-host) 向插件提供只读会话视图(`PluginSessionView`)、生命周期与语言切换钩子以及顶部工具栏插件配置菜单(`PluginMenu`,挂载 `data-d2-modal-overlay="plugins"` 并配合 `ui/dom-overlay.ts` 的 `isModalDomOverlayOpen()` 实现键盘输入隔离)。组合根位于 [`play.html`](../../play.html),**`src/client/**` 具有零个来自 `plugins/**` 的导入且严禁包含任何具体插件 ID 引用**(由 `tests/arch/boundaries.test.ts` Rule 2 与 Rule 7–10 强制守卫)。
|
||||||
- **TypeScript 工程约束(`tsconfig.client.json`)**:
|
- **TypeScript 工程约束(`tsconfig.client.json`)**:
|
||||||
- `lib: ["ES2022", "DOM", "DOM.Iterable"]`, `types: []`。
|
- `lib: ["ES2022", "DOM", "DOM.Iterable"]`, `types: []`。
|
||||||
- **严禁依赖 `src/server` 或 `src/baker`**,严禁导入任何 Node.js 内置模块(`fs`, `path`, `net`, `Buffer` 等)。
|
- **严禁依赖 `src/server` 或 `src/baker`**,严禁导入任何 Node.js 内置模块(`fs`, `path`, `net`, `Buffer` 等)。
|
||||||
|
|
@ -32,7 +32,7 @@ src/client/
|
||||||
├── ui-model/ # UI 纯视图状态模型 (HudModel, 属性加点/技能树/背包网格/商店/赫拉迪克方块交互逻辑)
|
├── ui-model/ # UI 纯视图状态模型 (HudModel, 属性加点/技能树/背包网格/商店/赫拉迪克方块交互逻辑)
|
||||||
├── frontend/ # 战网前置界面 (FrontendRenderer:Gateway 选择、账号登录/注册、8 角色选择、建房/加房大厅)
|
├── frontend/ # 战网前置界面 (FrontendRenderer:Gateway 选择、账号登录/注册、8 角色选择、建房/加房大厅)
|
||||||
├── toolbar/ # 顶部 32px 窄条工具栏 (FPS/Ping、网络抓包开关、插件快捷按钮插槽、Plugins 配置菜单入口、设置弹窗、全屏与音量控制)
|
├── toolbar/ # 顶部 32px 窄条工具栏 (FPS/Ping、网络抓包开关、插件快捷按钮插槽、Plugins 配置菜单入口、设置弹窗、全屏与音量控制)
|
||||||
├── plugin-host/ # 非侵入式插件宿主与配置中心 (PluginHost, PluginConfigStore, PluginMenuModal 与只读契约 api.ts)
|
├── plugin-host/ # 非侵入式插件宿主与配置中心 (PluginHost, PluginConfigStore, PluginMenu [data-d2-modal-overlay="plugins"] 与只读契约 api.ts)
|
||||||
├── settings/ # 客户端持久化设置存储 (SettingsStore / client-settings.ts,管理 WS Bridge 地址与显示偏好)
|
├── settings/ # 客户端持久化设置存储 (SettingsStore / client-settings.ts,管理 WS Bridge 地址与显示偏好)
|
||||||
├── inspector/ # 实时网络封包检查器 (PacketInspector) 与 .d2cap 录制/回放面板
|
├── inspector/ # 实时网络封包检查器 (PacketInspector) 与 .d2cap 录制/回放面板
|
||||||
├── sw/ # Service Worker (sw.ts) + CacheUpdater:基于 SHA-256 清单的离线资产增量缓存
|
├── sw/ # Service Worker (sw.ts) + CacheUpdater:基于 SHA-256 清单的离线资产增量缓存
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@ import {
|
||||||
import type { OnlineSession } from '../session/online-session.ts'
|
import type { OnlineSession } from '../session/online-session.ts'
|
||||||
import { getGlobalSettingsStore } from '../settings/settings-store.ts'
|
import { getGlobalSettingsStore } from '../settings/settings-store.ts'
|
||||||
import {
|
import {
|
||||||
PLUGIN_CONFIG_STORAGE_KEY,
|
|
||||||
PluginConfigStore,
|
PluginConfigStore,
|
||||||
} from './plugin-config-store.ts'
|
} from './plugin-config-store.ts'
|
||||||
|
|
||||||
|
|
@ -388,8 +387,6 @@ export function bootPluginPage(plugin: D2WebPlugin): PluginPageContext {
|
||||||
if (getGameLang() !== reloaded.lang) {
|
if (getGameLang() !== reloaded.lang) {
|
||||||
setLang(reloaded.lang)
|
setLang(reloaded.lang)
|
||||||
}
|
}
|
||||||
} else if (ev.key === PLUGIN_CONFIG_STORAGE_KEY) {
|
|
||||||
configStore.reloadFromStorage(true)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -129,14 +129,24 @@ export class PluginHost {
|
||||||
this.notifyListeners()
|
this.notifyListeners()
|
||||||
} else if (event.kind === 'reload') {
|
} else if (event.kind === 'reload') {
|
||||||
for (const [pluginId, record] of this.records.entries()) {
|
for (const [pluginId, record] of this.records.entries()) {
|
||||||
|
const next = record.queue.then(async () => {
|
||||||
|
if (this.destroyed) return
|
||||||
const desired = this.configStore.isEnabled(pluginId)
|
const desired = this.configStore.isEnabled(pluginId)
|
||||||
if (desired !== record.active) {
|
if (desired !== record.active) {
|
||||||
void this.enqueueTransition(record, desired)
|
if (desired) {
|
||||||
|
await this.activateRecord(record)
|
||||||
|
} else {
|
||||||
|
await this.deactivateRecord(record)
|
||||||
|
}
|
||||||
} else if (record.active) {
|
} else if (record.active) {
|
||||||
for (const spec of record.plugin.settings ?? []) {
|
for (const spec of record.plugin.settings ?? []) {
|
||||||
this.notifySettingSubscribers(record, spec.key)
|
this.notifySettingSubscribers(record, spec.key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
record.queue = next.catch(() => {
|
||||||
|
// Errors are isolated inside activateRecord / deactivateRecord
|
||||||
|
})
|
||||||
}
|
}
|
||||||
this.syncToolbarState()
|
this.syncToolbarState()
|
||||||
this.notifyListeners()
|
this.notifyListeners()
|
||||||
|
|
@ -341,9 +351,7 @@ export class PluginHost {
|
||||||
const tasks: Promise<void>[] = []
|
const tasks: Promise<void>[] = []
|
||||||
for (const [pluginId, record] of this.records.entries()) {
|
for (const [pluginId, record] of this.records.entries()) {
|
||||||
const targetEnabled = this.configStore.isEnabled(pluginId)
|
const targetEnabled = this.configStore.isEnabled(pluginId)
|
||||||
if (!targetEnabled) {
|
|
||||||
record.error = null
|
record.error = null
|
||||||
}
|
|
||||||
tasks.push(this.enqueueTransition(record, targetEnabled))
|
tasks.push(this.enqueueTransition(record, targetEnabled))
|
||||||
if (targetEnabled && record.active) {
|
if (targetEnabled && record.active) {
|
||||||
for (const spec of record.plugin.settings ?? []) {
|
for (const spec of record.plugin.settings ?? []) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,320 @@
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import type { D2WebPlugin } from '../../../src/client/plugin-host/api.ts'
|
||||||
|
import {
|
||||||
|
PLUGIN_CONFIG_STORAGE_KEY,
|
||||||
|
PluginConfigStore,
|
||||||
|
type PluginConfigChangeEvent,
|
||||||
|
} from '../../../src/client/plugin-host/plugin-config-store.ts'
|
||||||
|
|
||||||
|
class MemoryStorage implements Pick<Storage, 'getItem' | 'setItem' | 'removeItem'> {
|
||||||
|
readonly data = new Map<string, string>()
|
||||||
|
setCalls = 0
|
||||||
|
removeCalls = 0
|
||||||
|
throwOnGet = false
|
||||||
|
throwOnSet = false
|
||||||
|
|
||||||
|
getItem(key: string): string | null {
|
||||||
|
if (this.throwOnGet) {
|
||||||
|
throw new Error('QuotaExceeded or SecurityError on getItem')
|
||||||
|
}
|
||||||
|
return this.data.get(key) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
setItem(key: string, value: string): void {
|
||||||
|
if (this.throwOnSet) {
|
||||||
|
throw new Error('QuotaExceeded or SecurityError on setItem')
|
||||||
|
}
|
||||||
|
this.setCalls++
|
||||||
|
this.data.set(key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
removeItem(key: string): void {
|
||||||
|
this.removeCalls++
|
||||||
|
this.data.delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeTestPlugin(overrides: Partial<D2WebPlugin> = {}): D2WebPlugin {
|
||||||
|
return {
|
||||||
|
id: overrides.id ?? 'kill-stats',
|
||||||
|
name: overrides.name ?? { zh: '击杀统计', en: 'Kill Stats' },
|
||||||
|
description: overrides.description ?? {
|
||||||
|
zh: '记录并展示角色击杀统计',
|
||||||
|
en: 'Record and display character kill statistics',
|
||||||
|
},
|
||||||
|
defaultEnabled: overrides.defaultEnabled ?? true,
|
||||||
|
settings: overrides.settings ?? [
|
||||||
|
{
|
||||||
|
kind: 'boolean',
|
||||||
|
key: 'showToolbarShortcut',
|
||||||
|
label: { zh: '在工具栏显示快捷入口', en: 'Show shortcut in toolbar' },
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'select',
|
||||||
|
key: 'density',
|
||||||
|
label: { zh: '密度', en: 'Density' },
|
||||||
|
default: 'normal',
|
||||||
|
options: [
|
||||||
|
{ value: 'compact', label: { zh: '紧凑', en: 'Compact' } },
|
||||||
|
{ value: 'normal', label: { zh: '标准', en: 'Normal' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
actions: overrides.actions ?? [],
|
||||||
|
activate:
|
||||||
|
overrides.activate ??
|
||||||
|
(() => ({
|
||||||
|
deactivate() {},
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('PluginConfigStore (d2web.plugins.v1 Persistence & Corruption Resilience)', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses manifest defaults when localStorage is empty without writing to storage', () => {
|
||||||
|
const storage = new MemoryStorage()
|
||||||
|
const pluginA = makeTestPlugin({ id: 'kill-stats', defaultEnabled: true })
|
||||||
|
const pluginB = makeTestPlugin({
|
||||||
|
id: 'map-overlay',
|
||||||
|
defaultEnabled: false,
|
||||||
|
settings: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
const store = new PluginConfigStore([pluginA, pluginB], { storage })
|
||||||
|
|
||||||
|
expect(store.hasCorruptionError()).toBe(false)
|
||||||
|
expect(store.getCorruptionMessage()).toBeNull()
|
||||||
|
expect(store.isEnabled('kill-stats')).toBe(true)
|
||||||
|
expect(store.isEnabled('map-overlay')).toBe(false)
|
||||||
|
expect(store.getSetting('kill-stats', 'showToolbarShortcut')).toBe(true)
|
||||||
|
expect(store.getSetting('kill-stats', 'density')).toBe('normal')
|
||||||
|
expect(store.getAllSettings('kill-stats')).toEqual({
|
||||||
|
showToolbarShortcut: true,
|
||||||
|
density: 'normal',
|
||||||
|
})
|
||||||
|
expect(storage.setCalls).toBe(0)
|
||||||
|
store.detachStorageListener()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('persists and round-trips v1 schema across store instances and notifies subscribers', () => {
|
||||||
|
const storage = new MemoryStorage()
|
||||||
|
const plugin = makeTestPlugin()
|
||||||
|
const store1 = new PluginConfigStore([plugin], { storage })
|
||||||
|
const events: PluginConfigChangeEvent[] = []
|
||||||
|
const unsub = store1.subscribe((e) => events.push(e))
|
||||||
|
|
||||||
|
store1.setEnabled('kill-stats', false)
|
||||||
|
store1.setSetting('kill-stats', 'showToolbarShortcut', false)
|
||||||
|
store1.setSetting('kill-stats', 'density', 'compact')
|
||||||
|
|
||||||
|
expect(events).toEqual([
|
||||||
|
{ kind: 'enabled', pluginId: 'kill-stats' },
|
||||||
|
{ kind: 'setting', pluginId: 'kill-stats', settingKey: 'showToolbarShortcut' },
|
||||||
|
{ kind: 'setting', pluginId: 'kill-stats', settingKey: 'density' },
|
||||||
|
])
|
||||||
|
|
||||||
|
const raw = storage.getItem(PLUGIN_CONFIG_STORAGE_KEY)
|
||||||
|
expect(raw).not.toBeNull()
|
||||||
|
const parsed = JSON.parse(raw!) as {
|
||||||
|
version: number
|
||||||
|
plugins: Record<string, { enabled: boolean; settings: Record<string, unknown> }>
|
||||||
|
}
|
||||||
|
expect(parsed).toEqual({
|
||||||
|
version: 1,
|
||||||
|
plugins: {
|
||||||
|
'kill-stats': {
|
||||||
|
enabled: false,
|
||||||
|
settings: {
|
||||||
|
showToolbarShortcut: false,
|
||||||
|
density: 'compact',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const store2 = new PluginConfigStore([plugin], { storage })
|
||||||
|
expect(store2.hasCorruptionError()).toBe(false)
|
||||||
|
expect(store2.isEnabled('kill-stats')).toBe(false)
|
||||||
|
expect(store2.getSetting('kill-stats', 'showToolbarShortcut')).toBe(false)
|
||||||
|
expect(store2.getSetting('kill-stats', 'density')).toBe('compact')
|
||||||
|
|
||||||
|
unsub()
|
||||||
|
store1.detachStorageListener()
|
||||||
|
store2.detachStorageListener()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('flags corrupt JSON via console.error, falls back to defaults in memory, and preserves raw localStorage until explicit user write', () => {
|
||||||
|
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
const storage = new MemoryStorage()
|
||||||
|
const corruptRaw = '{not-valid-json'
|
||||||
|
storage.data.set(PLUGIN_CONFIG_STORAGE_KEY, corruptRaw)
|
||||||
|
|
||||||
|
const plugin = makeTestPlugin()
|
||||||
|
const store = new PluginConfigStore([plugin], { storage })
|
||||||
|
|
||||||
|
expect(errSpy).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('[PluginConfigStore] Corrupt JSON in d2web.plugins.v1:'),
|
||||||
|
)
|
||||||
|
expect(store.hasCorruptionError()).toBe(true)
|
||||||
|
expect(store.getCorruptionMessage()).toContain('Corrupt JSON in d2web.plugins.v1:')
|
||||||
|
expect(store.isEnabled('kill-stats')).toBe(true)
|
||||||
|
expect(store.getSetting('kill-stats', 'showToolbarShortcut')).toBe(true)
|
||||||
|
// Raw corrupt entry MUST NOT be overwritten during initialization
|
||||||
|
expect(storage.setCalls).toBe(0)
|
||||||
|
expect(storage.getItem(PLUGIN_CONFIG_STORAGE_KEY)).toBe(corruptRaw)
|
||||||
|
|
||||||
|
// Explicit user action clears corruption state and writes valid v1 JSON
|
||||||
|
store.setSetting('kill-stats', 'density', 'compact')
|
||||||
|
expect(store.hasCorruptionError()).toBe(false)
|
||||||
|
expect(store.getCorruptionMessage()).toBeNull()
|
||||||
|
expect(storage.setCalls).toBe(1)
|
||||||
|
expect(JSON.parse(storage.getItem(PLUGIN_CONFIG_STORAGE_KEY)!).version).toBe(1)
|
||||||
|
|
||||||
|
store.detachStorageListener()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects unsupported schema version or missing plugins object without overwriting storage', () => {
|
||||||
|
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
const storage = new MemoryStorage()
|
||||||
|
const futureRaw = JSON.stringify({ version: 99, plugins: {} })
|
||||||
|
storage.data.set(PLUGIN_CONFIG_STORAGE_KEY, futureRaw)
|
||||||
|
|
||||||
|
const store = new PluginConfigStore([makeTestPlugin()], { storage })
|
||||||
|
expect(errSpy).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('[PluginConfigStore] Invalid schema in d2web.plugins.v1'),
|
||||||
|
)
|
||||||
|
expect(store.hasCorruptionError()).toBe(true)
|
||||||
|
expect(store.getCorruptionMessage()).toContain('Invalid schema in d2web.plugins.v1')
|
||||||
|
expect(storage.setCalls).toBe(0)
|
||||||
|
expect(storage.getItem(PLUGIN_CONFIG_STORAGE_KEY)).toBe(futureRaw)
|
||||||
|
|
||||||
|
store.detachStorageListener()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('validates per-plugin enabled and setting values, preserves unknown plugin entries, and supports resetAllToDefaults()', () => {
|
||||||
|
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
const storage = new MemoryStorage()
|
||||||
|
storage.data.set(
|
||||||
|
PLUGIN_CONFIG_STORAGE_KEY,
|
||||||
|
JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
plugins: {
|
||||||
|
'kill-stats': {
|
||||||
|
enabled: 'not-a-boolean',
|
||||||
|
settings: {
|
||||||
|
showToolbarShortcut: false,
|
||||||
|
density: 'invalid-option',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'future-plugin': {
|
||||||
|
enabled: false,
|
||||||
|
settings: { customKey: 'customVal' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const store = new PluginConfigStore([makeTestPlugin()], { storage })
|
||||||
|
expect(errSpy).toHaveBeenCalled()
|
||||||
|
expect(store.hasCorruptionError()).toBe(true)
|
||||||
|
// Invalid enabled & density fall back to defaults; valid showToolbarShortcut=false is retained
|
||||||
|
expect(store.isEnabled('kill-stats')).toBe(true)
|
||||||
|
expect(store.getSetting('kill-stats', 'showToolbarShortcut')).toBe(false)
|
||||||
|
expect(store.getSetting('kill-stats', 'density')).toBe('normal')
|
||||||
|
expect(storage.setCalls).toBe(0)
|
||||||
|
|
||||||
|
// resetAllToDefaults clears corruption error, restores manifest defaults, and preserves 'future-plugin'
|
||||||
|
store.resetAllToDefaults()
|
||||||
|
expect(store.hasCorruptionError()).toBe(false)
|
||||||
|
expect(store.getCorruptionMessage()).toBeNull()
|
||||||
|
expect(store.isEnabled('kill-stats')).toBe(true)
|
||||||
|
expect(store.getSetting('kill-stats', 'showToolbarShortcut')).toBe(true)
|
||||||
|
expect(store.getSetting('kill-stats', 'density')).toBe('normal')
|
||||||
|
|
||||||
|
const persisted = JSON.parse(storage.getItem(PLUGIN_CONFIG_STORAGE_KEY)!) as {
|
||||||
|
version: number
|
||||||
|
plugins: Record<string, unknown>
|
||||||
|
}
|
||||||
|
expect(persisted.plugins['future-plugin']).toEqual({
|
||||||
|
enabled: false,
|
||||||
|
settings: { customKey: 'customVal' },
|
||||||
|
})
|
||||||
|
|
||||||
|
store.detachStorageListener()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reloads configuration when window dispatches a storage event for d2web.plugins.v1', () => {
|
||||||
|
const prevWindow = globalThis.window
|
||||||
|
const storageListeners = new Set<(ev: StorageEvent) => void>()
|
||||||
|
const fakeWindow = {
|
||||||
|
addEventListener(type: string, listener: (ev: StorageEvent) => void) {
|
||||||
|
if (type === 'storage') storageListeners.add(listener)
|
||||||
|
},
|
||||||
|
removeEventListener(type: string, listener: (ev: StorageEvent) => void) {
|
||||||
|
if (type === 'storage') storageListeners.delete(listener)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
;(globalThis as unknown as { window: unknown }).window = fakeWindow
|
||||||
|
|
||||||
|
try {
|
||||||
|
const storage = new MemoryStorage()
|
||||||
|
const store = new PluginConfigStore([makeTestPlugin()], { storage })
|
||||||
|
store.attachStorageListener()
|
||||||
|
const events: PluginConfigChangeEvent[] = []
|
||||||
|
store.subscribe((e) => events.push(e))
|
||||||
|
|
||||||
|
expect(store.isEnabled('kill-stats')).toBe(true)
|
||||||
|
|
||||||
|
storage.data.set(
|
||||||
|
PLUGIN_CONFIG_STORAGE_KEY,
|
||||||
|
JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
plugins: {
|
||||||
|
'kill-stats': {
|
||||||
|
enabled: false,
|
||||||
|
settings: { showToolbarShortcut: false, density: 'compact' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const listener of storageListeners) {
|
||||||
|
listener({ key: PLUGIN_CONFIG_STORAGE_KEY } as StorageEvent)
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(store.isEnabled('kill-stats')).toBe(false)
|
||||||
|
expect(store.getSetting('kill-stats', 'showToolbarShortcut')).toBe(false)
|
||||||
|
expect(store.getSetting('kill-stats', 'density')).toBe('compact')
|
||||||
|
expect(events).toEqual([{ kind: 'reload', pluginId: null }])
|
||||||
|
|
||||||
|
store.detachStorageListener()
|
||||||
|
expect(storageListeners.size).toBe(0)
|
||||||
|
} finally {
|
||||||
|
;(globalThis as unknown as { window: unknown }).window = prevWindow
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles storage getItem/setItem exceptions gracefully by logging console.error and flagging corruption', () => {
|
||||||
|
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
const storage = new MemoryStorage()
|
||||||
|
storage.throwOnGet = true
|
||||||
|
|
||||||
|
const store = new PluginConfigStore([makeTestPlugin()], { storage })
|
||||||
|
expect(store.hasCorruptionError()).toBe(true)
|
||||||
|
expect(store.getCorruptionMessage()).toContain('Failed to read d2web.plugins.v1:')
|
||||||
|
|
||||||
|
storage.throwOnGet = false
|
||||||
|
storage.throwOnSet = true
|
||||||
|
store.setEnabled('kill-stats', false)
|
||||||
|
expect(errSpy).toHaveBeenCalledWith(
|
||||||
|
'[PluginConfigStore] Failed to persist plugin configuration:',
|
||||||
|
expect.any(Error),
|
||||||
|
)
|
||||||
|
|
||||||
|
store.detachStorageListener()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -366,21 +366,178 @@ describe('PluginHost (Lifecycle, Serialized Toggles, Resource Cleanup & Error Is
|
||||||
errSpy.mockRestore()
|
errSpy.mockRestore()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('bootPluginPage reads language and enabled status and cleans up on destroy', () => {
|
it('serializes cross-tab reload events onto record.queue when async activate() is in-flight and clears sticky errors on resetAllToDefaults()', async () => {
|
||||||
|
const prevWindow = (globalThis as { window?: unknown }).window
|
||||||
|
const storageListeners = new Set<(ev: StorageEvent) => void>()
|
||||||
|
;(globalThis as unknown as { window: unknown }).window = {
|
||||||
|
addEventListener(type: string, cb: (ev: StorageEvent) => void) {
|
||||||
|
if (type === 'storage') storageListeners.add(cb)
|
||||||
|
},
|
||||||
|
removeEventListener(type: string, cb: (ev: StorageEvent) => void) {
|
||||||
|
if (type === 'storage') storageListeners.delete(cb)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
try {
|
||||||
|
const storage = new MemoryPluginStorage()
|
||||||
|
let resolveInFlightActivate: (() => void) | null = null
|
||||||
|
let blockNextActivate = true
|
||||||
|
let deactivated = false
|
||||||
|
|
||||||
|
const asyncPlugin = makeValidPlugin({
|
||||||
|
id: 'in-flight-plugin',
|
||||||
|
defaultEnabled: true,
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
kind: 'button',
|
||||||
|
id: 'boom-action',
|
||||||
|
label: { zh: '报错动作', en: 'Boom Action' },
|
||||||
|
run() {
|
||||||
|
throw new Error('Action failure while active')
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
async activate() {
|
||||||
|
if (blockNextActivate) {
|
||||||
|
blockNextActivate = false
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
resolveInFlightActivate = resolve
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
deactivate() {
|
||||||
|
deactivated = true
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const configStore = new PluginConfigStore([asyncPlugin], { storage })
|
||||||
|
const host = new PluginHost({
|
||||||
|
plugins: [asyncPlugin],
|
||||||
|
configStore,
|
||||||
|
session: makeMockSession(),
|
||||||
|
autoStart: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const startPromise = host.start()
|
||||||
|
await Promise.resolve()
|
||||||
|
|
||||||
|
// Simulate another tab disabling 'in-flight-plugin' while activate() is still awaiting
|
||||||
|
storage.data.set(
|
||||||
|
PLUGIN_CONFIG_STORAGE_KEY,
|
||||||
|
JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
plugins: {
|
||||||
|
'in-flight-plugin': {
|
||||||
|
enabled: false,
|
||||||
|
settings: { showShortcut: true, mode: 'compact' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
for (const cb of storageListeners) {
|
||||||
|
cb({ key: PLUGIN_CONFIG_STORAGE_KEY } as StorageEvent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now let the in-flight activate() resolve; the queued reload handler must deactivate it
|
||||||
|
resolveInFlightActivate!()
|
||||||
|
await startPromise
|
||||||
|
await Promise.resolve()
|
||||||
|
await Promise.resolve()
|
||||||
|
await Promise.resolve()
|
||||||
|
|
||||||
|
expect(deactivated).toBe(true)
|
||||||
|
expect(host.isEnabled('in-flight-plugin')).toBe(false)
|
||||||
|
|
||||||
|
// Re-enable the plugin, trigger an action error while active, then verify resetAllToDefaults() clears record.error
|
||||||
|
await host.setEnabled('in-flight-plugin', true)
|
||||||
|
|
||||||
|
host.runAction('in-flight-plugin', 'boom-action')
|
||||||
|
expect(host.hasErrorOrCorruption()).toBe(true)
|
||||||
|
expect(host.list()[0]?.status.level).toBe('error')
|
||||||
|
|
||||||
|
await host.resetAllToDefaults()
|
||||||
|
expect(host.hasErrorOrCorruption()).toBe(false)
|
||||||
|
expect(host.list()[0]?.status.level).not.toBe('error')
|
||||||
|
|
||||||
|
await host.destroy()
|
||||||
|
} finally {
|
||||||
|
errSpy.mockRestore()
|
||||||
|
;(globalThis as unknown as { window: unknown }).window = prevWindow
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('bootPluginPage reads language and enabled status, fires subscribers once per storage event, and cleans up on destroy', () => {
|
||||||
|
const prevWindow = (globalThis as { window?: unknown }).window
|
||||||
|
const prevLocalStorage = (globalThis as { localStorage?: unknown }).localStorage
|
||||||
|
const storageListeners = new Set<(ev: StorageEvent) => void>()
|
||||||
|
const memoryStorage = new MemoryPluginStorage()
|
||||||
|
|
||||||
|
;(globalThis as unknown as { window: unknown }).window = {
|
||||||
|
addEventListener(type: string, cb: (ev: StorageEvent) => void) {
|
||||||
|
if (type === 'storage') storageListeners.add(cb)
|
||||||
|
},
|
||||||
|
removeEventListener(type: string, cb: (ev: StorageEvent) => void) {
|
||||||
|
if (type === 'storage') storageListeners.delete(cb)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
;(globalThis as unknown as { localStorage: unknown }).localStorage = memoryStorage
|
||||||
|
|
||||||
|
try {
|
||||||
const plugin = makeValidPlugin({ id: 'page-plugin', defaultEnabled: true })
|
const plugin = makeValidPlugin({ id: 'page-plugin', defaultEnabled: true })
|
||||||
const pageCtx = bootPluginPage(plugin)
|
const pageCtx = bootPluginPage(plugin)
|
||||||
|
|
||||||
|
let enabledEvents = 0
|
||||||
|
const notifiedSettingKeys: string[] = []
|
||||||
|
pageCtx.subscribeEnabled(() => {
|
||||||
|
enabledEvents++
|
||||||
|
})
|
||||||
|
pageCtx.subscribeSettings((key) => {
|
||||||
|
notifiedSettingKeys.push(key)
|
||||||
|
})
|
||||||
|
|
||||||
expect(pageCtx.pluginId).toBe('page-plugin')
|
expect(pageCtx.pluginId).toBe('page-plugin')
|
||||||
expect(pageCtx.isEnabled()).toBe(true)
|
expect(pageCtx.isEnabled()).toBe(true)
|
||||||
|
|
||||||
|
// Simulate a cross-tab localStorage update to d2web.plugins.v1
|
||||||
|
memoryStorage.data.set(
|
||||||
|
PLUGIN_CONFIG_STORAGE_KEY,
|
||||||
|
JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
plugins: {
|
||||||
|
'page-plugin': {
|
||||||
|
enabled: false,
|
||||||
|
settings: { showShortcut: false, mode: 'full' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
for (const cb of storageListeners) {
|
||||||
|
cb({ key: PLUGIN_CONFIG_STORAGE_KEY } as StorageEvent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each subscriber must fire once per setting/enabled state (no duplicate window storage firing)
|
||||||
|
expect(enabledEvents).toBe(1)
|
||||||
|
expect(notifiedSettingKeys).toEqual(['showShortcut', 'mode'])
|
||||||
|
expect(pageCtx.isEnabled()).toBe(false)
|
||||||
|
expect(pageCtx.getSetting('mode')).toBe('full')
|
||||||
|
|
||||||
pageCtx.setLang('en')
|
pageCtx.setLang('en')
|
||||||
expect(pageCtx.getLang()).toBe('en')
|
expect(pageCtx.getLang()).toBe('en')
|
||||||
pageCtx.setLang('zh')
|
pageCtx.setLang('zh')
|
||||||
expect(pageCtx.getLang()).toBe('zh')
|
expect(pageCtx.getLang()).toBe('zh')
|
||||||
pageCtx.destroy()
|
pageCtx.destroy()
|
||||||
|
expect(storageListeners.size).toBe(0)
|
||||||
|
} finally {
|
||||||
|
;(globalThis as unknown as { window: unknown }).window = prevWindow
|
||||||
|
;(globalThis as unknown as { localStorage: unknown }).localStorage = prevLocalStorage
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
it('isModalDomOverlayOpen returns false in non-DOM environments', () => {
|
it('isModalDomOverlayOpen returns false in non-DOM environments', () => {
|
||||||
expect(isModalDomOverlayOpen(null)).toBe(false)
|
expect(isModalDomOverlayOpen(null)).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,511 @@
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import type { D2WebPlugin } from '../../../src/client/plugin-host/api.ts'
|
||||||
|
import {
|
||||||
|
PLUGIN_CONFIG_STORAGE_KEY,
|
||||||
|
PluginConfigStore,
|
||||||
|
} from '../../../src/client/plugin-host/plugin-config-store.ts'
|
||||||
|
import {
|
||||||
|
PluginHost,
|
||||||
|
type PluginHostToolbarPort,
|
||||||
|
} from '../../../src/client/plugin-host/plugin-host.ts'
|
||||||
|
import { PluginMenu } from '../../../src/client/plugin-host/plugin-menu.ts'
|
||||||
|
import { OnlineSession } from '../../../src/client/session/online-session.ts'
|
||||||
|
import { isModalDomOverlayOpen } from '../../../src/client/ui/dom-overlay.ts'
|
||||||
|
|
||||||
|
class MemoryStorage implements Pick<Storage, 'getItem' | 'setItem' | 'removeItem'> {
|
||||||
|
readonly data = new Map<string, string>()
|
||||||
|
getItem(key: string): string | null {
|
||||||
|
return this.data.get(key) ?? null
|
||||||
|
}
|
||||||
|
setItem(key: string, value: string): void {
|
||||||
|
this.data.set(key, value)
|
||||||
|
}
|
||||||
|
removeItem(key: string): void {
|
||||||
|
this.data.delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type FakeListener = (event: FakeEvent) => void
|
||||||
|
|
||||||
|
interface FakeEvent {
|
||||||
|
type: string
|
||||||
|
key?: string
|
||||||
|
target?: FakeElement | null
|
||||||
|
defaultPrevented: boolean
|
||||||
|
immediatePropagationStopped: boolean
|
||||||
|
preventDefault(): void
|
||||||
|
stopImmediatePropagation(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeFakeEvent(type: string, init: { key?: string; target?: FakeElement | null } = {}): FakeEvent {
|
||||||
|
const ev: FakeEvent = {
|
||||||
|
type,
|
||||||
|
...(init.key !== undefined ? { key: init.key } : {}),
|
||||||
|
target: init.target ?? null,
|
||||||
|
defaultPrevented: false,
|
||||||
|
immediatePropagationStopped: false,
|
||||||
|
preventDefault() {
|
||||||
|
ev.defaultPrevented = true
|
||||||
|
},
|
||||||
|
stopImmediatePropagation() {
|
||||||
|
ev.immediatePropagationStopped = true
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return ev
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeElement {
|
||||||
|
readonly tagName: string
|
||||||
|
readonly ownerDocument: FakeDocument
|
||||||
|
readonly style: Record<string, string> = {}
|
||||||
|
readonly dataset: Record<string, string> = {}
|
||||||
|
id = ''
|
||||||
|
className = ''
|
||||||
|
type = ''
|
||||||
|
checked = false
|
||||||
|
value = ''
|
||||||
|
href = ''
|
||||||
|
target = ''
|
||||||
|
rel = ''
|
||||||
|
parentNode: FakeElement | null = null
|
||||||
|
readonly children: FakeElement[] = []
|
||||||
|
private readonly attrs = new Map<string, string>()
|
||||||
|
private readonly listeners = new Map<string, FakeListener[]>()
|
||||||
|
private text = ''
|
||||||
|
private isHidden = false
|
||||||
|
|
||||||
|
constructor(tagName: string, ownerDocument: FakeDocument) {
|
||||||
|
this.tagName = tagName.toLowerCase()
|
||||||
|
this.ownerDocument = ownerDocument
|
||||||
|
}
|
||||||
|
|
||||||
|
get hidden(): boolean {
|
||||||
|
return this.isHidden
|
||||||
|
}
|
||||||
|
|
||||||
|
set hidden(val: boolean) {
|
||||||
|
this.isHidden = Boolean(val)
|
||||||
|
if (this.isHidden) {
|
||||||
|
this.attrs.set('hidden', '')
|
||||||
|
} else {
|
||||||
|
this.attrs.delete('hidden')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get textContent(): string {
|
||||||
|
if (this.children.length === 0) return this.text
|
||||||
|
return this.text + this.children.map((c) => c.textContent).join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
set textContent(val: string | null) {
|
||||||
|
this.children.length = 0
|
||||||
|
this.text = val ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
get innerHTML(): string {
|
||||||
|
return this.textContent
|
||||||
|
}
|
||||||
|
|
||||||
|
set innerHTML(val: string) {
|
||||||
|
this.children.length = 0
|
||||||
|
this.text = val
|
||||||
|
}
|
||||||
|
|
||||||
|
setAttribute(name: string, value: string): void {
|
||||||
|
if (name === 'id') this.id = value
|
||||||
|
if (name === 'hidden') this.isHidden = true
|
||||||
|
this.attrs.set(name, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
getAttribute(name: string): string | null {
|
||||||
|
if (name === 'id' && this.id) return this.id
|
||||||
|
if (name === 'hidden') return this.isHidden ? '' : null
|
||||||
|
return this.attrs.get(name) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
appendChild(child: FakeElement): FakeElement {
|
||||||
|
if (child.parentNode) {
|
||||||
|
child.parentNode.removeChild(child)
|
||||||
|
}
|
||||||
|
child.parentNode = this
|
||||||
|
this.children.push(child)
|
||||||
|
return child
|
||||||
|
}
|
||||||
|
|
||||||
|
removeChild(child: FakeElement): FakeElement {
|
||||||
|
const idx = this.children.indexOf(child)
|
||||||
|
if (idx >= 0) {
|
||||||
|
this.children.splice(idx, 1)
|
||||||
|
child.parentNode = null
|
||||||
|
}
|
||||||
|
return child
|
||||||
|
}
|
||||||
|
|
||||||
|
addEventListener(type: string, listener: FakeListener): void {
|
||||||
|
const list = this.listeners.get(type) ?? []
|
||||||
|
list.push(listener)
|
||||||
|
this.listeners.set(type, list)
|
||||||
|
}
|
||||||
|
|
||||||
|
removeEventListener(type: string, listener: FakeListener): void {
|
||||||
|
const list = this.listeners.get(type)
|
||||||
|
if (!list) return
|
||||||
|
const idx = list.indexOf(listener)
|
||||||
|
if (idx >= 0) list.splice(idx, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatchEvent(event: FakeEvent): void {
|
||||||
|
if (!event.target) event.target = this
|
||||||
|
const list = [...(this.listeners.get(event.type) ?? [])]
|
||||||
|
for (const fn of list) {
|
||||||
|
fn(event)
|
||||||
|
if (event.immediatePropagationStopped) break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
click(): void {
|
||||||
|
this.dispatchEvent(makeFakeEvent('click', { target: this }))
|
||||||
|
}
|
||||||
|
|
||||||
|
focus(): void {
|
||||||
|
this.ownerDocument.activeElement = this
|
||||||
|
}
|
||||||
|
|
||||||
|
matchesSelector(selector: string): boolean {
|
||||||
|
if (selector === '[data-d2-modal-overlay]:not([hidden])') {
|
||||||
|
return this.attrs.has('data-d2-modal-overlay') && !this.isHidden
|
||||||
|
}
|
||||||
|
if (selector.startsWith('#')) {
|
||||||
|
return this.id === selector.slice(1)
|
||||||
|
}
|
||||||
|
const attrMatch = /^\[([a-z0-9-]+)="([^"]+)"\]$/i.exec(selector)
|
||||||
|
if (attrMatch) {
|
||||||
|
return this.getAttribute(attrMatch[1]!) === attrMatch[2]!
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
querySelector(selector: string): FakeElement | null {
|
||||||
|
for (const child of this.children) {
|
||||||
|
if (child.matchesSelector(selector)) return child
|
||||||
|
const found = child.querySelector(selector)
|
||||||
|
if (found) return found
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeDocument {
|
||||||
|
readonly head: FakeElement
|
||||||
|
readonly body: FakeElement
|
||||||
|
activeElement: FakeElement | null = null
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.head = new FakeElement('head', this)
|
||||||
|
this.body = new FakeElement('body', this)
|
||||||
|
}
|
||||||
|
|
||||||
|
createElement(tagName: string): FakeElement {
|
||||||
|
return new FakeElement(tagName, this)
|
||||||
|
}
|
||||||
|
|
||||||
|
getElementById(id: string): FakeElement | null {
|
||||||
|
return this.head.querySelector(`#${id}`) ?? this.body.querySelector(`#${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
querySelector(selector: string): FakeElement | null {
|
||||||
|
return this.head.querySelector(selector) ?? this.body.querySelector(selector)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeWindow {
|
||||||
|
private readonly captureListeners = new Map<string, FakeListener[]>()
|
||||||
|
private readonly bubbleListeners = new Map<string, FakeListener[]>()
|
||||||
|
|
||||||
|
addEventListener(type: string, listener: FakeListener, capture?: boolean): void {
|
||||||
|
const map = capture ? this.captureListeners : this.bubbleListeners
|
||||||
|
const list = map.get(type) ?? []
|
||||||
|
list.push(listener)
|
||||||
|
map.set(type, list)
|
||||||
|
}
|
||||||
|
|
||||||
|
removeEventListener(type: string, listener: FakeListener, capture?: boolean): void {
|
||||||
|
const map = capture ? this.captureListeners : this.bubbleListeners
|
||||||
|
const list = map.get(type)
|
||||||
|
if (!list) return
|
||||||
|
const idx = list.indexOf(listener)
|
||||||
|
if (idx >= 0) list.splice(idx, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatchKey(type: 'keydown' | 'keyup', key: string): FakeEvent {
|
||||||
|
const ev = makeFakeEvent(type, { key })
|
||||||
|
for (const fn of [...(this.captureListeners.get(type) ?? [])]) {
|
||||||
|
fn(ev)
|
||||||
|
if (ev.immediatePropagationStopped) return ev
|
||||||
|
}
|
||||||
|
for (const fn of [...(this.bubbleListeners.get(type) ?? [])]) {
|
||||||
|
fn(ev)
|
||||||
|
if (ev.immediatePropagationStopped) return ev
|
||||||
|
}
|
||||||
|
return ev
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeMenuPlugin(overrides: Partial<D2WebPlugin> & { onAction?: () => void } = {}): D2WebPlugin {
|
||||||
|
return {
|
||||||
|
id: overrides.id ?? 'kill-stats',
|
||||||
|
name: overrides.name ?? { zh: '击杀统计', en: 'Kill Stats' },
|
||||||
|
description: overrides.description ?? {
|
||||||
|
zh: '记录角色击杀数据',
|
||||||
|
en: 'Record character kill statistics',
|
||||||
|
},
|
||||||
|
defaultEnabled: overrides.defaultEnabled ?? true,
|
||||||
|
settings: overrides.settings ?? [
|
||||||
|
{
|
||||||
|
kind: 'boolean',
|
||||||
|
key: 'showToolbarShortcut',
|
||||||
|
label: { zh: '在工具栏显示快捷入口', en: 'Show shortcut in toolbar' },
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'select',
|
||||||
|
key: 'theme',
|
||||||
|
label: { zh: '主题', en: 'Theme' },
|
||||||
|
default: 'gold',
|
||||||
|
options: [
|
||||||
|
{ value: 'gold', label: { zh: '暗金', en: 'Gold' } },
|
||||||
|
{ value: 'iron', label: { zh: '玄铁', en: 'Iron' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
actions: overrides.actions ?? [
|
||||||
|
{
|
||||||
|
kind: 'link',
|
||||||
|
id: 'open-page',
|
||||||
|
label: { zh: '打开统计页', en: 'Open Stats Page' },
|
||||||
|
href: 'plugins/kill-stats/index.html',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'button',
|
||||||
|
id: 'custom-btn',
|
||||||
|
label: { zh: '执行动作', en: 'Run Action' },
|
||||||
|
run() {
|
||||||
|
overrides.onAction?.()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
activate:
|
||||||
|
overrides.activate ??
|
||||||
|
(() => ({
|
||||||
|
deactivate() {},
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('PluginMenu (DOM Modal Overlay, Keyboard Isolation & Interactive Controls)', () => {
|
||||||
|
let prevDocument: unknown
|
||||||
|
let prevWindow: unknown
|
||||||
|
let fakeDoc: FakeDocument
|
||||||
|
let fakeWin: FakeWindow
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
prevDocument = (globalThis as { document?: unknown }).document
|
||||||
|
prevWindow = (globalThis as { window?: unknown }).window
|
||||||
|
fakeDoc = new FakeDocument()
|
||||||
|
fakeWin = new FakeWindow()
|
||||||
|
;(globalThis as unknown as { document: unknown }).document = fakeDoc
|
||||||
|
;(globalThis as unknown as { window: unknown }).window = fakeWin
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
;(globalThis as unknown as { document: unknown }).document = prevDocument
|
||||||
|
;(globalThis as unknown as { window: unknown }).window = prevWindow
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('mounts hidden [data-d2-modal-overlay="plugins"], toggles isModalDomOverlayOpen, intercepts Escape in capture phase, and returns focus to #d2-toolbar-plugins-btn', async () => {
|
||||||
|
const toolbarBtn = fakeDoc.createElement('button')
|
||||||
|
toolbarBtn.id = 'd2-toolbar-plugins-btn'
|
||||||
|
fakeDoc.body.appendChild(toolbarBtn)
|
||||||
|
|
||||||
|
const storage = new MemoryStorage()
|
||||||
|
const plugin = makeMenuPlugin()
|
||||||
|
const configStore = new PluginConfigStore([plugin], { storage })
|
||||||
|
const buttonStates: { ready: boolean; hasWarning: boolean; open?: boolean | undefined }[] = []
|
||||||
|
const toolbarPort: PluginHostToolbarPort = {
|
||||||
|
setPluginsButtonState(state) {
|
||||||
|
buttonStates.push(state)
|
||||||
|
},
|
||||||
|
addContribution() {
|
||||||
|
return () => {}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = new PluginHost({
|
||||||
|
plugins: [plugin],
|
||||||
|
configStore,
|
||||||
|
session: new OnlineSession(),
|
||||||
|
toolbar: toolbarPort,
|
||||||
|
autoStart: false,
|
||||||
|
})
|
||||||
|
await host.start()
|
||||||
|
|
||||||
|
const menu = new PluginMenu({ host, toolbar: toolbarPort })
|
||||||
|
expect(menu.isOpen()).toBe(false)
|
||||||
|
expect(isModalDomOverlayOpen(fakeDoc as unknown as Document)).toBe(false)
|
||||||
|
|
||||||
|
// Register a bubble listener on window to verify capture-phase key interception when modal is open
|
||||||
|
const leakedKeys: string[] = []
|
||||||
|
fakeWin.addEventListener('keydown', (e) => leakedKeys.push(`down:${e.key}`), false)
|
||||||
|
fakeWin.addEventListener('keyup', (e) => leakedKeys.push(`up:${e.key}`), false)
|
||||||
|
|
||||||
|
// Open modal
|
||||||
|
menu.open()
|
||||||
|
expect(menu.isOpen()).toBe(true)
|
||||||
|
expect(isModalDomOverlayOpen(fakeDoc as unknown as Document)).toBe(true)
|
||||||
|
expect(fakeDoc.activeElement?.id).toBe('d2-plugin-menu-close')
|
||||||
|
expect(buttonStates.at(-1)?.open).toBe(true)
|
||||||
|
|
||||||
|
// Pressing a regular key while open is stopped in capture phase and never reaches bubble listeners
|
||||||
|
const spaceEv = fakeWin.dispatchKey('keydown', ' ')
|
||||||
|
const spaceUpEv = fakeWin.dispatchKey('keyup', ' ')
|
||||||
|
expect(spaceEv.immediatePropagationStopped).toBe(true)
|
||||||
|
expect(spaceUpEv.immediatePropagationStopped).toBe(true)
|
||||||
|
expect(leakedKeys).toEqual([])
|
||||||
|
|
||||||
|
// Pressing Escape closes the modal, prevents default, stops propagation, and restores focus to #d2-toolbar-plugins-btn
|
||||||
|
const escEv = fakeWin.dispatchKey('keydown', 'Escape')
|
||||||
|
expect(escEv.defaultPrevented).toBe(true)
|
||||||
|
expect(escEv.immediatePropagationStopped).toBe(true)
|
||||||
|
expect(menu.isOpen()).toBe(false)
|
||||||
|
expect(isModalDomOverlayOpen(fakeDoc as unknown as Document)).toBe(false)
|
||||||
|
expect(fakeDoc.activeElement).toBe(toolbarBtn)
|
||||||
|
expect(buttonStates.at(-1)?.open).toBe(false)
|
||||||
|
|
||||||
|
// When modal is closed, keys reach bubble listeners normally
|
||||||
|
fakeWin.dispatchKey('keydown', 'i')
|
||||||
|
expect(leakedKeys).toEqual(['down:i'])
|
||||||
|
|
||||||
|
menu.destroy()
|
||||||
|
await host.destroy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders plugin cards, boolean/select settings, link/button actions, and updates PluginHost on user interaction', async () => {
|
||||||
|
let actionCount = 0
|
||||||
|
const plugin = makeMenuPlugin({
|
||||||
|
onAction: () => {
|
||||||
|
actionCount++
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const storage = new MemoryStorage()
|
||||||
|
const configStore = new PluginConfigStore([plugin], { storage })
|
||||||
|
const host = new PluginHost({
|
||||||
|
plugins: [plugin],
|
||||||
|
configStore,
|
||||||
|
session: new OnlineSession(),
|
||||||
|
autoStart: false,
|
||||||
|
})
|
||||||
|
await host.start()
|
||||||
|
|
||||||
|
const menu = new PluginMenu({ host })
|
||||||
|
menu.open()
|
||||||
|
|
||||||
|
// Verify link action renders with target="_blank" and relative href
|
||||||
|
const linkEl = fakeDoc.querySelector('[data-plugin-action="kill-stats:open-page"]')
|
||||||
|
expect(linkEl).not.toBeNull()
|
||||||
|
expect(linkEl?.tagName).toBe('a')
|
||||||
|
expect(linkEl?.href).toBe('plugins/kill-stats/index.html')
|
||||||
|
expect(linkEl?.target).toBe('_blank')
|
||||||
|
|
||||||
|
// Verify button action invokes host.runAction
|
||||||
|
const actionBtn = fakeDoc.querySelector('[data-plugin-action="kill-stats:custom-btn"]')
|
||||||
|
expect(actionBtn).not.toBeNull()
|
||||||
|
actionBtn!.click()
|
||||||
|
expect(actionCount).toBe(1)
|
||||||
|
|
||||||
|
// Toggle boolean setting
|
||||||
|
const boolCheckbox = fakeDoc.querySelector(
|
||||||
|
'[data-plugin-setting="kill-stats:showToolbarShortcut"]',
|
||||||
|
)
|
||||||
|
expect(boolCheckbox).not.toBeNull()
|
||||||
|
expect(boolCheckbox?.checked).toBe(true)
|
||||||
|
boolCheckbox!.checked = false
|
||||||
|
boolCheckbox!.dispatchEvent(makeFakeEvent('change'))
|
||||||
|
expect(host.getSetting('kill-stats', 'showToolbarShortcut')).toBe(false)
|
||||||
|
|
||||||
|
// Change select setting
|
||||||
|
const selectEl = fakeDoc.querySelector('[data-plugin-setting="kill-stats:theme"]')
|
||||||
|
expect(selectEl).not.toBeNull()
|
||||||
|
expect(selectEl?.value).toBe('gold')
|
||||||
|
selectEl!.value = 'iron'
|
||||||
|
selectEl!.dispatchEvent(makeFakeEvent('change'))
|
||||||
|
expect(host.getSetting('kill-stats', 'theme')).toBe('iron')
|
||||||
|
|
||||||
|
// Disable plugin via toggle checkbox -> controls row is hidden when disabled
|
||||||
|
const toggleCheckbox = fakeDoc.querySelector('[data-plugin-toggle="kill-stats"]')
|
||||||
|
expect(toggleCheckbox).not.toBeNull()
|
||||||
|
toggleCheckbox!.checked = false
|
||||||
|
toggleCheckbox!.dispatchEvent(makeFakeEvent('change'))
|
||||||
|
await Promise.resolve()
|
||||||
|
await Promise.resolve()
|
||||||
|
|
||||||
|
expect(host.isEnabled('kill-stats')).toBe(false)
|
||||||
|
expect(
|
||||||
|
fakeDoc.querySelector('[data-plugin-setting="kill-stats:showToolbarShortcut"]'),
|
||||||
|
).toBeNull()
|
||||||
|
|
||||||
|
menu.destroy()
|
||||||
|
await host.destroy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('displays #d2-plugin-menu-alert on corrupt storage or plugin error and clears it when #d2-plugin-menu-reset is clicked', async () => {
|
||||||
|
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
const storage = new MemoryStorage()
|
||||||
|
storage.data.set(PLUGIN_CONFIG_STORAGE_KEY, '{corrupt-json')
|
||||||
|
|
||||||
|
let shouldFail = true
|
||||||
|
const plugin = makeMenuPlugin({
|
||||||
|
id: 'flaky-plugin',
|
||||||
|
activate() {
|
||||||
|
if (shouldFail) {
|
||||||
|
throw new Error('Activation failed once')
|
||||||
|
}
|
||||||
|
return { deactivate() {} }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const configStore = new PluginConfigStore([plugin], { storage })
|
||||||
|
const host = new PluginHost({
|
||||||
|
plugins: [plugin],
|
||||||
|
configStore,
|
||||||
|
session: new OnlineSession(),
|
||||||
|
autoStart: false,
|
||||||
|
})
|
||||||
|
await host.start()
|
||||||
|
|
||||||
|
const menu = new PluginMenu({ host })
|
||||||
|
menu.open()
|
||||||
|
|
||||||
|
const alertEl = fakeDoc.getElementById('d2-plugin-menu-alert')
|
||||||
|
expect(alertEl).not.toBeNull()
|
||||||
|
expect(alertEl?.hidden).toBe(false)
|
||||||
|
expect(alertEl?.getAttribute('role')).toBe('alert')
|
||||||
|
expect(alertEl?.textContent).toContain('Corrupt JSON')
|
||||||
|
expect(alertEl?.textContent).toContain('Activation failed once')
|
||||||
|
|
||||||
|
// Fix underlying error and click #d2-plugin-menu-reset
|
||||||
|
shouldFail = false
|
||||||
|
const resetBtn = fakeDoc.getElementById('d2-plugin-menu-reset')
|
||||||
|
expect(resetBtn).not.toBeNull()
|
||||||
|
resetBtn!.click()
|
||||||
|
await Promise.resolve()
|
||||||
|
await Promise.resolve()
|
||||||
|
|
||||||
|
expect(host.hasErrorOrCorruption()).toBe(false)
|
||||||
|
expect(alertEl?.hidden).toBe(true)
|
||||||
|
|
||||||
|
menu.destroy()
|
||||||
|
await host.destroy()
|
||||||
|
errSpy.mockRestore()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
import {
|
import {
|
||||||
computeCharKey,
|
computeCharKey,
|
||||||
computeTallyId,
|
computeTallyId,
|
||||||
|
|
@ -13,6 +13,14 @@ import {
|
||||||
resolveCharacterClassName,
|
resolveCharacterClassName,
|
||||||
resolveLocalizedMonsterName,
|
resolveLocalizedMonsterName,
|
||||||
} from '../../../plugins/kill-stats/kill-stats-view.ts'
|
} from '../../../plugins/kill-stats/kill-stats-view.ts'
|
||||||
|
import { bootStatsApp } from '../../../plugins/kill-stats/page-main.ts'
|
||||||
|
import { KillStatsPage } from '../../../plugins/kill-stats/stats-page.ts'
|
||||||
|
import type {
|
||||||
|
GameLang,
|
||||||
|
PluginPageContext,
|
||||||
|
} from '../../../src/client/plugin-host/api.ts'
|
||||||
|
import { MemoryKillStatsRepository } from './memory-repository.ts'
|
||||||
|
|
||||||
|
|
||||||
const REPLICA_A = '11111111-1111-4111-8111-111111111111'
|
const REPLICA_A = '11111111-1111-4111-8111-111111111111'
|
||||||
const REPLICA_B = '22222222-2222-4222-8222-222222222222'
|
const REPLICA_B = '22222222-2222-4222-8222-222222222222'
|
||||||
|
|
@ -414,3 +422,405 @@ describe('kill-stats-view (Pure View Model & 86-Row Boss/SU Board)', () => {
|
||||||
expect(searchId.monsterRows.map((r) => r.hcIdx)).toEqual([19])
|
expect(searchId.monsterRows.map((r) => r.hcIdx)).toEqual([19])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
class StatsFakeClassList {
|
||||||
|
private readonly classes = new Set<string>()
|
||||||
|
constructor(private readonly owner: StatsFakeElement) {}
|
||||||
|
syncFrom(className: string): void {
|
||||||
|
this.classes.clear()
|
||||||
|
for (const part of className.split(/\s+/)) {
|
||||||
|
if (part) this.classes.add(part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private flush(): void {
|
||||||
|
this.owner.syncClassName([...this.classes].join(' '))
|
||||||
|
}
|
||||||
|
add(...tokens: string[]): void {
|
||||||
|
for (const t of tokens) if (t) this.classes.add(t)
|
||||||
|
this.flush()
|
||||||
|
}
|
||||||
|
remove(...tokens: string[]): void {
|
||||||
|
for (const t of tokens) this.classes.delete(t)
|
||||||
|
this.flush()
|
||||||
|
}
|
||||||
|
toggle(token: string, force?: boolean): boolean {
|
||||||
|
const next = force !== undefined ? force : !this.classes.has(token)
|
||||||
|
if (next) this.classes.add(token)
|
||||||
|
else this.classes.delete(token)
|
||||||
|
this.flush()
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
contains(token: string): boolean {
|
||||||
|
return this.classes.has(token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class StatsFakeElement {
|
||||||
|
readonly tagName: string
|
||||||
|
readonly ownerDocument: StatsFakeDocument
|
||||||
|
readonly dataset: Record<string, string> = {}
|
||||||
|
readonly style: Record<string, string> = {}
|
||||||
|
readonly classList: StatsFakeClassList
|
||||||
|
readonly children: StatsFakeElement[] = []
|
||||||
|
parentNode: StatsFakeElement | null = null
|
||||||
|
id = ''
|
||||||
|
type = ''
|
||||||
|
checked = false
|
||||||
|
disabled = false
|
||||||
|
value = ''
|
||||||
|
placeholder = ''
|
||||||
|
href = ''
|
||||||
|
download = ''
|
||||||
|
accept = ''
|
||||||
|
colSpan = 1
|
||||||
|
lang = ''
|
||||||
|
hidden = false
|
||||||
|
private rawClassName = ''
|
||||||
|
private text = ''
|
||||||
|
private readonly attrs = new Map<string, string>()
|
||||||
|
private readonly listeners = new Map<string, ((ev: { type: string }) => void)[]>()
|
||||||
|
|
||||||
|
constructor(tagName: string, ownerDocument: StatsFakeDocument) {
|
||||||
|
this.tagName = tagName.toLowerCase()
|
||||||
|
this.ownerDocument = ownerDocument
|
||||||
|
this.classList = new StatsFakeClassList(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
get className(): string {
|
||||||
|
return this.rawClassName
|
||||||
|
}
|
||||||
|
|
||||||
|
set className(val: string) {
|
||||||
|
this.rawClassName = val
|
||||||
|
this.classList.syncFrom(val)
|
||||||
|
}
|
||||||
|
|
||||||
|
syncClassName(val: string): void {
|
||||||
|
this.rawClassName = val
|
||||||
|
}
|
||||||
|
|
||||||
|
get firstChild(): StatsFakeElement | null {
|
||||||
|
return this.children[0] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
get textContent(): string {
|
||||||
|
if (this.children.length === 0) return this.text
|
||||||
|
return this.text + this.children.map((c) => c.textContent).join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
set textContent(val: string | null) {
|
||||||
|
this.children.length = 0
|
||||||
|
this.text = val ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
setAttribute(name: string, val: string): void {
|
||||||
|
if (name === 'id') this.id = val
|
||||||
|
this.attrs.set(name, val)
|
||||||
|
}
|
||||||
|
|
||||||
|
getAttribute(name: string): string | null {
|
||||||
|
if (name === 'id' && this.id) return this.id
|
||||||
|
return this.attrs.get(name) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
appendChild(child: StatsFakeElement): StatsFakeElement {
|
||||||
|
if (child.parentNode) {
|
||||||
|
child.parentNode.removeChild(child)
|
||||||
|
}
|
||||||
|
child.parentNode = this
|
||||||
|
this.children.push(child)
|
||||||
|
return child
|
||||||
|
}
|
||||||
|
|
||||||
|
append(...nodes: StatsFakeElement[]): void {
|
||||||
|
for (const node of nodes) {
|
||||||
|
this.appendChild(node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
removeChild(child: StatsFakeElement): StatsFakeElement {
|
||||||
|
const idx = this.children.indexOf(child)
|
||||||
|
if (idx >= 0) {
|
||||||
|
this.children.splice(idx, 1)
|
||||||
|
child.parentNode = null
|
||||||
|
}
|
||||||
|
return child
|
||||||
|
}
|
||||||
|
|
||||||
|
addEventListener(type: string, cb: (ev: { type: string }) => void): void {
|
||||||
|
const list = this.listeners.get(type) ?? []
|
||||||
|
list.push(cb)
|
||||||
|
this.listeners.set(type, list)
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatchEvent(ev: { type: string }): void {
|
||||||
|
for (const cb of [...(this.listeners.get(ev.type) ?? [])]) {
|
||||||
|
cb(ev)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
click(): void {
|
||||||
|
this.dispatchEvent({ type: 'click' })
|
||||||
|
}
|
||||||
|
|
||||||
|
findById(id: string): StatsFakeElement | null {
|
||||||
|
if (this.id === id) return this
|
||||||
|
for (const child of this.children) {
|
||||||
|
const found = child.findById(id)
|
||||||
|
if (found) return found
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class StatsFakeDocument {
|
||||||
|
readonly documentElement: StatsFakeElement
|
||||||
|
readonly body: StatsFakeElement
|
||||||
|
title = ''
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.documentElement = new StatsFakeElement('html', this)
|
||||||
|
this.body = new StatsFakeElement('body', this)
|
||||||
|
this.documentElement.appendChild(this.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
createElement(tagName: string): StatsFakeElement {
|
||||||
|
return new StatsFakeElement(tagName, this)
|
||||||
|
}
|
||||||
|
|
||||||
|
getElementById(id: string): StatsFakeElement | null {
|
||||||
|
return this.documentElement.findById(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeFakePageContext(initialLang: GameLang = 'zh', initialEnabled = true): {
|
||||||
|
pageContext: PluginPageContext
|
||||||
|
setEnabledExternal(enabled: boolean): void
|
||||||
|
} {
|
||||||
|
let lang: GameLang = initialLang
|
||||||
|
let enabled = initialEnabled
|
||||||
|
const langSubs = new Set<(l: GameLang) => void>()
|
||||||
|
const enabledSubs = new Set<(e: boolean) => void>()
|
||||||
|
const settingsSubs = new Set<(key: string) => void>()
|
||||||
|
const settings: Record<string, boolean | string> = { showToolbarShortcut: true }
|
||||||
|
|
||||||
|
const pageContext: PluginPageContext = {
|
||||||
|
pluginId: 'kill-stats',
|
||||||
|
getLang: () => lang,
|
||||||
|
setLang(next) {
|
||||||
|
lang = next
|
||||||
|
for (const cb of langSubs) cb(lang)
|
||||||
|
},
|
||||||
|
subscribeLang(cb) {
|
||||||
|
langSubs.add(cb)
|
||||||
|
return () => langSubs.delete(cb)
|
||||||
|
},
|
||||||
|
isEnabled: () => enabled,
|
||||||
|
subscribeEnabled(cb) {
|
||||||
|
enabledSubs.add(cb)
|
||||||
|
return () => enabledSubs.delete(cb)
|
||||||
|
},
|
||||||
|
getSetting: <T extends boolean | string = boolean | string>(key: string): T =>
|
||||||
|
settings[key] as T,
|
||||||
|
setSetting(key, val) {
|
||||||
|
settings[key] = val
|
||||||
|
for (const cb of settingsSubs) cb(key)
|
||||||
|
},
|
||||||
|
subscribeSettings(cb) {
|
||||||
|
settingsSubs.add(cb)
|
||||||
|
return () => settingsSubs.delete(cb)
|
||||||
|
},
|
||||||
|
destroy() {
|
||||||
|
langSubs.clear()
|
||||||
|
enabledSubs.clear()
|
||||||
|
settingsSubs.clear()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
pageContext,
|
||||||
|
setEnabledExternal(next: boolean) {
|
||||||
|
enabled = next
|
||||||
|
for (const cb of enabledSubs) cb(enabled)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('KillStatsPage & bootStatsApp (DOM Controller, Live Banner & Failure Paths)', () => {
|
||||||
|
it('mounts DOM, renders KPI cards, toggles solo/language/disabled-banner, exports/imports JSON, and deletes/clears with confirmation', async () => {
|
||||||
|
const fakeDoc = new StatsFakeDocument()
|
||||||
|
const root = fakeDoc.createElement('div')
|
||||||
|
root.id = 'd2-stats-app'
|
||||||
|
fakeDoc.body.appendChild(root)
|
||||||
|
|
||||||
|
const repository = new MemoryKillStatsRepository({
|
||||||
|
initialReplicaId: REPLICA_A,
|
||||||
|
})
|
||||||
|
const char = makeChar({
|
||||||
|
host: 'www.laiseek.xyz',
|
||||||
|
realm: 'D2CS',
|
||||||
|
account: 'DeckardCain',
|
||||||
|
charName: 'SorcHero',
|
||||||
|
charClass: 1,
|
||||||
|
level: 88,
|
||||||
|
hardcore: true,
|
||||||
|
expansion: true,
|
||||||
|
ladder: true,
|
||||||
|
})
|
||||||
|
await repository.applyBatch(
|
||||||
|
char,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
charKey: char.charKey,
|
||||||
|
kind: 'monster',
|
||||||
|
difficulty: 2,
|
||||||
|
subject: 156,
|
||||||
|
count: 4,
|
||||||
|
soloCount: 3,
|
||||||
|
firstAt: 1000,
|
||||||
|
lastAt: 2000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
charKey: char.charKey,
|
||||||
|
kind: 'boss',
|
||||||
|
difficulty: 2,
|
||||||
|
subject: 156,
|
||||||
|
count: 4,
|
||||||
|
soloCount: 3,
|
||||||
|
firstAt: 1000,
|
||||||
|
lastAt: 2000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
2000,
|
||||||
|
)
|
||||||
|
|
||||||
|
const { pageContext, setEnabledExternal } = makeFakePageContext('zh', true)
|
||||||
|
let confirmResult = true
|
||||||
|
|
||||||
|
const page = new KillStatsPage({
|
||||||
|
root: root as unknown as HTMLElement,
|
||||||
|
repository,
|
||||||
|
pageContext,
|
||||||
|
confirmFn: () => confirmResult,
|
||||||
|
})
|
||||||
|
await page.init()
|
||||||
|
|
||||||
|
expect(fakeDoc.getElementById('d2-stats-val-hell')?.textContent).toBe('4')
|
||||||
|
expect(fakeDoc.getElementById('d2-stats-val-total')?.textContent).toBe('4')
|
||||||
|
expect(fakeDoc.getElementById('d2-stats-disabled-notice')?.hidden).toBe(true)
|
||||||
|
|
||||||
|
// Toggle solo-only filter -> Hell count updates from 4 to 3
|
||||||
|
const soloCheckbox = fakeDoc.getElementById('d2-stats-solo-only')!
|
||||||
|
soloCheckbox.checked = true
|
||||||
|
soloCheckbox.dispatchEvent({ type: 'change' })
|
||||||
|
expect(fakeDoc.getElementById('d2-stats-val-hell')?.textContent).toBe('3')
|
||||||
|
soloCheckbox.checked = false
|
||||||
|
soloCheckbox.dispatchEvent({ type: 'change' })
|
||||||
|
expect(fakeDoc.getElementById('d2-stats-val-hell')?.textContent).toBe('4')
|
||||||
|
|
||||||
|
// Live cross-tab plugin disable updates #d2-stats-disabled-notice immediately
|
||||||
|
setEnabledExternal(false)
|
||||||
|
expect(fakeDoc.getElementById('d2-stats-disabled-notice')?.hidden).toBe(false)
|
||||||
|
setEnabledExternal(true)
|
||||||
|
expect(fakeDoc.getElementById('d2-stats-disabled-notice')?.hidden).toBe(true)
|
||||||
|
|
||||||
|
// Toggle language zh -> en
|
||||||
|
fakeDoc.getElementById('d2-stats-lang-toggle')!.click()
|
||||||
|
expect(pageContext.getLang()).toBe('en')
|
||||||
|
expect(fakeDoc.documentElement.lang).toBe('en')
|
||||||
|
|
||||||
|
// Export JSON, clear all, then re-import JSON
|
||||||
|
const exported = await page.handleExportJson()
|
||||||
|
expect(page.getLastExportJson()).toBe(exported)
|
||||||
|
|
||||||
|
// Cancelled confirmation prevents clearAll
|
||||||
|
confirmResult = false
|
||||||
|
expect(await page.handleClearAll()).toBe(false)
|
||||||
|
expect(fakeDoc.getElementById('d2-stats-val-total')?.textContent).toBe('4')
|
||||||
|
|
||||||
|
// Confirmed clearAll wipes data and shows empty state
|
||||||
|
confirmResult = true
|
||||||
|
expect(await page.handleClearAll()).toBe(true)
|
||||||
|
expect(fakeDoc.getElementById('d2-stats-val-total')?.textContent).toBe('0')
|
||||||
|
expect(fakeDoc.getElementById('d2-stats-empty-state')?.hidden).toBe(false)
|
||||||
|
|
||||||
|
// Invalid import displays role="alert" error banner
|
||||||
|
const badSummary = await page.handleImportJsonText('{"version": 999}')
|
||||||
|
expect(badSummary).toBeNull()
|
||||||
|
const errBanner = fakeDoc.getElementById('d2-stats-error-banner')!
|
||||||
|
expect(errBanner.hidden).toBe(false)
|
||||||
|
expect(errBanner.getAttribute('role')).toBe('alert')
|
||||||
|
|
||||||
|
// Valid import restores all tallies and clears error banner
|
||||||
|
const goodSummary = await page.handleImportJsonText(exported)
|
||||||
|
expect(goodSummary?.addedTallies).toBe(2)
|
||||||
|
expect(errBanner.hidden).toBe(true)
|
||||||
|
expect(fakeDoc.getElementById('d2-stats-val-total')?.textContent).toBe('4')
|
||||||
|
|
||||||
|
// Delete single character
|
||||||
|
expect(await page.handleDeleteSelectedCharacter()).toBe(true)
|
||||||
|
expect(fakeDoc.getElementById('d2-stats-val-total')?.textContent).toBe('0')
|
||||||
|
|
||||||
|
page.destroy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('logs console.error, renders role="alert" error banner, rejects page.init() on loadAll() failure, and sets window.__d2StatsApp.ready = false in bootStatsApp()', async () => {
|
||||||
|
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
const fakeDoc = new StatsFakeDocument()
|
||||||
|
const root = fakeDoc.createElement('div')
|
||||||
|
root.id = 'd2-stats-app'
|
||||||
|
fakeDoc.body.appendChild(root)
|
||||||
|
|
||||||
|
const failingRepository = new MemoryKillStatsRepository()
|
||||||
|
vi.spyOn(failingRepository, 'loadAll').mockRejectedValue(
|
||||||
|
new Error('Simulated IndexedDB open failure'),
|
||||||
|
)
|
||||||
|
|
||||||
|
const { pageContext } = makeFakePageContext('en', true)
|
||||||
|
const page = new KillStatsPage({
|
||||||
|
root: root as unknown as HTMLElement,
|
||||||
|
repository: failingRepository,
|
||||||
|
pageContext,
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(page.init()).rejects.toThrow('Simulated IndexedDB open failure')
|
||||||
|
expect(errSpy).toHaveBeenCalledWith(
|
||||||
|
'[kill-stats] Failed to load kill stats snapshot:',
|
||||||
|
expect.any(Error),
|
||||||
|
)
|
||||||
|
|
||||||
|
const errBanner = fakeDoc.getElementById('d2-stats-error-banner')
|
||||||
|
expect(errBanner).not.toBeNull()
|
||||||
|
expect(errBanner?.hidden).toBe(false)
|
||||||
|
expect(errBanner?.getAttribute('role')).toBe('alert')
|
||||||
|
expect(errBanner?.textContent).toContain('Simulated IndexedDB open failure')
|
||||||
|
page.destroy()
|
||||||
|
|
||||||
|
// Also verify bootStatsApp() catches init() failure (when IndexedDB is unavailable in Node) and exposes window.__d2StatsApp.ready === false
|
||||||
|
const prevDoc = (globalThis as { document?: unknown }).document
|
||||||
|
const prevWin = (globalThis as { window?: unknown }).window
|
||||||
|
const bootDoc = new StatsFakeDocument()
|
||||||
|
const bootRoot = bootDoc.createElement('div')
|
||||||
|
bootRoot.id = 'd2-stats-app'
|
||||||
|
bootDoc.body.appendChild(bootRoot)
|
||||||
|
const bootWin: Record<string, unknown> = {}
|
||||||
|
|
||||||
|
;(globalThis as unknown as { document: unknown }).document = bootDoc
|
||||||
|
;(globalThis as unknown as { window: unknown }).window = bootWin
|
||||||
|
try {
|
||||||
|
await bootStatsApp()
|
||||||
|
const app = bootWin.__d2StatsApp as { ready: boolean; page: KillStatsPage } | undefined
|
||||||
|
expect(app).toBeDefined()
|
||||||
|
expect(app?.ready).toBe(false)
|
||||||
|
const bootBanner = bootDoc.getElementById('d2-stats-error-banner')
|
||||||
|
expect(bootBanner?.hidden).toBe(false)
|
||||||
|
expect(bootBanner?.getAttribute('role')).toBe('alert')
|
||||||
|
app?.page.destroy()
|
||||||
|
} finally {
|
||||||
|
;(globalThis as unknown as { document: unknown }).document = prevDoc
|
||||||
|
;(globalThis as unknown as { window: unknown }).window = prevWin
|
||||||
|
errSpy.mockRestore()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -220,12 +220,25 @@ describe('BUILTIN_PLUGINS Registry & killStatsPlugin Lifecycle', () => {
|
||||||
'monster:156=1',
|
'monster:156=1',
|
||||||
])
|
])
|
||||||
|
|
||||||
// Verify setting and action declarations
|
// Verify setting, action, and exact localized label declarations
|
||||||
const settingSpecs: readonly PluginSettingSpec[] = plugin.settings ?? []
|
const settingSpecs: readonly PluginSettingSpec[] = plugin.settings ?? []
|
||||||
const actionSpecs: readonly PluginActionSpec[] = plugin.actions ?? []
|
const actionSpecs: readonly PluginActionSpec[] = plugin.actions ?? []
|
||||||
|
expect(plugin.name).toEqual({
|
||||||
|
zh: '击杀统计',
|
||||||
|
en: 'Kill Stats',
|
||||||
|
})
|
||||||
expect(settingSpecs.map((s) => s.key)).toEqual(['showToolbarShortcut'])
|
expect(settingSpecs.map((s) => s.key)).toEqual(['showToolbarShortcut'])
|
||||||
|
expect(settingSpecs[0]?.label).toEqual({
|
||||||
|
zh: '在工具栏显示快捷入口',
|
||||||
|
en: 'Show shortcut in toolbar',
|
||||||
|
})
|
||||||
expect(actionSpecs.map((a) => a.id)).toEqual(['open-page'])
|
expect(actionSpecs.map((a) => a.id)).toEqual(['open-page'])
|
||||||
|
expect(actionSpecs[0]?.label).toEqual({
|
||||||
|
zh: '打开统计页',
|
||||||
|
en: 'Open Stats Page',
|
||||||
|
})
|
||||||
|
|
||||||
await host.destroy()
|
await host.destroy()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -378,7 +378,9 @@ async function run(): Promise<void> {
|
||||||
})()`)
|
})()`)
|
||||||
assert(afterShortcutOn, '#d2-toolbar-plugin-kill-stats-open must reappear when showToolbarShortcut is checked')
|
assert(afterShortcutOn, '#d2-toolbar-plugin-kill-stats-open must reappear when showToolbarShortcut is checked')
|
||||||
|
|
||||||
// 4. Attach fake game server, disable kill-stats, verify kills are NOT recorded while disabled, and verify #d2-stats-disabled-notice in stats page
|
// 4. Attach fake game server, record 1 pending kill (unflushed), disable kill-stats via DOM toggle ONLY,
|
||||||
|
// verify pending kill was flushed on deactivate (Total=1), verify kills while disabled are NOT recorded,
|
||||||
|
// and verify disabled state persists across play.html reload.
|
||||||
await playCdp.evaluate(`(async () => {
|
await playCdp.evaluate(`(async () => {
|
||||||
const app = window.__d2PlayApp;
|
const app = window.__d2PlayApp;
|
||||||
const session = app.session;
|
const session = app.session;
|
||||||
|
|
@ -414,19 +416,7 @@ async function run(): Promise<void> {
|
||||||
session.attachGameServer(fakeAdapter);
|
session.attachGameServer(fakeAdapter);
|
||||||
window.__d2EmitPluginTestEvent = (ev) => emitServerEvent && emitServerEvent(ev);
|
window.__d2EmitPluginTestEvent = (ev) => emitServerEvent && emitServerEvent(ev);
|
||||||
|
|
||||||
// Toggle kill-stats off via DOM checkbox
|
// Emit 1 monster kill BEFORE disabling (leave unflushed in memory so deactivate() must flush it)
|
||||||
const toggle = document.querySelector('[data-plugin-toggle="kill-stats"]');
|
|
||||||
toggle?.click();
|
|
||||||
await app.plugins.setEnabled('kill-stats', false);
|
|
||||||
})()`)
|
|
||||||
|
|
||||||
const disabledShortcutExists = await playCdp.evaluate<boolean>(
|
|
||||||
`Boolean(document.getElementById('d2-toolbar-plugin-kill-stats-open'))`,
|
|
||||||
)
|
|
||||||
assert(!disabledShortcutExists, '#d2-toolbar-plugin-kill-stats-open must be removed when kill-stats is disabled')
|
|
||||||
|
|
||||||
// Emit monster kill while kill-stats is disabled
|
|
||||||
await playCdp.evaluate(`(() => {
|
|
||||||
const emit = window.__d2EmitPluginTestEvent;
|
const emit = window.__d2EmitPluginTestEvent;
|
||||||
emit({ type: 'GameFlags', difficulty: 2, unknown1: 0, hardcore: true, expansion: true, ladder: true });
|
emit({ type: 'GameFlags', difficulty: 2, unknown1: 0, hardcore: true, expansion: true, ladder: true });
|
||||||
emit({
|
emit({
|
||||||
|
|
@ -434,6 +424,34 @@ async function run(): Promise<void> {
|
||||||
isSelf: true,
|
isSelf: true,
|
||||||
unit: { unitType: 'player', id: 1, classId: 1, name: 'PluginSorc', x: 5000, y: 5000, mode: 1, lifePct: 128 },
|
unit: { unitType: 'player', id: 1, classId: 1, name: 'PluginSorc', x: 5000, y: 5000, mode: 1, lifePct: 128 },
|
||||||
});
|
});
|
||||||
|
emit({
|
||||||
|
type: 'UnitAssign',
|
||||||
|
unit: { unitType: 'monster', id: 300, classId: 19, x: 5005, y: 5005, mode: 1, lifePct: 128 },
|
||||||
|
});
|
||||||
|
emit({ type: 'UnitMode', unitType: 'monster', id: 300, mode: 0, x: 5005, y: 5005, lifePct: 0 });
|
||||||
|
|
||||||
|
// Toggle kill-stats off via DOM checkbox ONLY (do NOT call app.plugins.setEnabled manually)
|
||||||
|
const toggle = document.querySelector('[data-plugin-toggle="kill-stats"]');
|
||||||
|
toggle?.click();
|
||||||
|
})()`)
|
||||||
|
|
||||||
|
// Wait for async deactivation & pending kill flush triggered by checkbox change event
|
||||||
|
let deactivatedByToggle = false
|
||||||
|
for (let i = 0; i < 40; i++) {
|
||||||
|
await sleep(100)
|
||||||
|
deactivatedByToggle = await playCdp.evaluate<boolean>(
|
||||||
|
`!window.__d2PlayApp.plugins.getInstance('kill-stats') && !document.getElementById('d2-toolbar-plugin-kill-stats-open')`,
|
||||||
|
)
|
||||||
|
if (deactivatedByToggle) break
|
||||||
|
}
|
||||||
|
assert(
|
||||||
|
deactivatedByToggle,
|
||||||
|
'Clicking [data-plugin-toggle="kill-stats"] must deactivate kill-stats and remove #d2-toolbar-plugin-kill-stats-open without manual setEnabled()',
|
||||||
|
)
|
||||||
|
|
||||||
|
// Emit monster kill while kill-stats is disabled (must NOT be recorded)
|
||||||
|
await playCdp.evaluate(`(() => {
|
||||||
|
const emit = window.__d2EmitPluginTestEvent;
|
||||||
emit({
|
emit({
|
||||||
type: 'UnitAssign',
|
type: 'UnitAssign',
|
||||||
unit: { unitType: 'monster', id: 301, classId: 156, x: 5010, y: 5010, mode: 1, lifePct: 128, isBoss: true },
|
unit: { unitType: 'monster', id: 301, classId: 156, x: 5010, y: 5010, mode: 1, lifePct: 128, isBoss: true },
|
||||||
|
|
@ -441,7 +459,7 @@ async function run(): Promise<void> {
|
||||||
emit({ type: 'UnitMode', unitType: 'monster', id: 301, mode: 0, x: 5010, y: 5010, lifePct: 0 });
|
emit({ type: 'UnitMode', unitType: 'monster', id: 301, mode: 0, x: 5010, y: 5010, lifePct: 0 });
|
||||||
})()`)
|
})()`)
|
||||||
|
|
||||||
// Open /plugins/kill-stats/index.html in second tab and verify #d2-stats-disabled-notice is visible & Total=0
|
// Open /plugins/kill-stats/index.html in second tab and verify #d2-stats-disabled-notice is visible & Total=1 (from pre-disable flush)
|
||||||
const statsTarget = await openNewPageTarget(cdpPort, statsUrl)
|
const statsTarget = await openNewPageTarget(cdpPort, statsUrl)
|
||||||
statsCdp = await CdpSession.connect(statsTarget.webSocketDebuggerUrl)
|
statsCdp = await CdpSession.connect(statsTarget.webSocketDebuggerUrl)
|
||||||
for (let i = 0; i < 100; i++) {
|
for (let i = 0; i < 100; i++) {
|
||||||
|
|
@ -463,19 +481,59 @@ async function run(): Promise<void> {
|
||||||
};
|
};
|
||||||
})()`)
|
})()`)
|
||||||
assert(statsDisabledState.noticeVisible, '#d2-stats-disabled-notice must be visible when kill-stats is disabled')
|
assert(statsDisabledState.noticeVisible, '#d2-stats-disabled-notice must be visible when kill-stats is disabled')
|
||||||
assert(statsDisabledState.valTotal === '0', `Expected Total=0 while kill-stats was disabled, got ${statsDisabledState.valTotal}`)
|
assert(
|
||||||
|
statsDisabledState.valTotal === '1',
|
||||||
|
`Expected Total=1 (flushed on disable, while post-disable kill 301 was ignored), got ${statsDisabledState.valTotal}`,
|
||||||
|
)
|
||||||
|
|
||||||
const shot4 = join(outDir, '04-stats-disabled-notice.png')
|
const shot4 = join(outDir, '04-stats-disabled-notice.png')
|
||||||
const shot4Bytes = await statsCdp.captureScreenshot(shot4)
|
const shot4Bytes = await statsCdp.captureScreenshot(shot4)
|
||||||
console.log(`[verify-plugin-menu] Captured ${shot4} (${shot4Bytes} bytes) — Disabled notice visible on stats page`)
|
console.log(`[verify-plugin-menu] Captured ${shot4} (${shot4Bytes} bytes) — Disabled notice visible on stats page`)
|
||||||
|
|
||||||
// 5. Re-enable kill-stats in play.html -> verify #d2-stats-disabled-notice hides live via storage event & kills record after GameFlags
|
// Reload play.html while kill-stats is disabled and verify disabled state persists across reload
|
||||||
await playCdp.evaluate(`(async () => {
|
await playCdp.send('Page.reload')
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
await sleep(150)
|
||||||
|
const ready = await playCdp.evaluate<boolean>(
|
||||||
|
'Boolean(window.__d2PlayApp && window.__d2PlayApp.ready && window.__d2PlayApp.plugins)',
|
||||||
|
)
|
||||||
|
if (ready) break
|
||||||
|
}
|
||||||
|
await playCdp.evaluate(`window.__d2PlayApp.plugins.whenStarted()`)
|
||||||
|
|
||||||
|
const persistedDisabledAfterReload = await playCdp.evaluate<{
|
||||||
|
isEnabled: boolean
|
||||||
|
hasInstance: boolean
|
||||||
|
hasShortcutBtn: boolean
|
||||||
|
}>(`(() => ({
|
||||||
|
isEnabled: window.__d2PlayApp.plugins.isEnabled('kill-stats'),
|
||||||
|
hasInstance: Boolean(window.__d2PlayApp.plugins.getInstance('kill-stats')),
|
||||||
|
hasShortcutBtn: Boolean(document.getElementById('d2-toolbar-plugin-kill-stats-open')),
|
||||||
|
}))()`)
|
||||||
|
assert(!persistedDisabledAfterReload.isEnabled, 'kill-stats must remain disabled after reloading play.html')
|
||||||
|
assert(!persistedDisabledAfterReload.hasInstance, 'kill-stats instance must remain null after reloading play.html')
|
||||||
|
assert(
|
||||||
|
!persistedDisabledAfterReload.hasShortcutBtn,
|
||||||
|
'#d2-toolbar-plugin-kill-stats-open must remain absent after reloading play.html while disabled',
|
||||||
|
)
|
||||||
|
|
||||||
|
// 5. Re-open plugin menu on reloaded play.html, re-enable kill-stats via DOM toggle ONLY -> verify #d2-stats-disabled-notice hides live & kills record
|
||||||
|
await playCdp.evaluate(`(() => {
|
||||||
|
document.getElementById('d2-toolbar-plugins-btn')?.click();
|
||||||
const toggle = document.querySelector('[data-plugin-toggle="kill-stats"]');
|
const toggle = document.querySelector('[data-plugin-toggle="kill-stats"]');
|
||||||
toggle?.click();
|
toggle?.click();
|
||||||
await window.__d2PlayApp.plugins.setEnabled('kill-stats', true);
|
|
||||||
})()`)
|
})()`)
|
||||||
|
|
||||||
|
let reenabledByToggle = false
|
||||||
|
for (let i = 0; i < 40; i++) {
|
||||||
|
await sleep(100)
|
||||||
|
reenabledByToggle = await playCdp.evaluate<boolean>(
|
||||||
|
`Boolean(window.__d2PlayApp.plugins.getInstance('kill-stats')) && Boolean(document.getElementById('d2-toolbar-plugin-kill-stats-open'))`,
|
||||||
|
)
|
||||||
|
if (reenabledByToggle) break
|
||||||
|
}
|
||||||
|
assert(reenabledByToggle, 'Clicking [data-plugin-toggle="kill-stats"] must re-enable kill-stats without manual setEnabled()')
|
||||||
|
|
||||||
let noticeHiddenAfterReenable = false
|
let noticeHiddenAfterReenable = false
|
||||||
for (let i = 0; i < 30; i++) {
|
for (let i = 0; i < 30; i++) {
|
||||||
await sleep(100)
|
await sleep(100)
|
||||||
|
|
@ -487,19 +545,49 @@ async function run(): Promise<void> {
|
||||||
assert(noticeHiddenAfterReenable, '#d2-stats-disabled-notice must hide automatically when kill-stats is re-enabled')
|
assert(noticeHiddenAfterReenable, '#d2-stats-disabled-notice must hide automatically when kill-stats is re-enabled')
|
||||||
|
|
||||||
await playCdp.evaluate(`(async () => {
|
await playCdp.evaluate(`(async () => {
|
||||||
const emit = window.__d2EmitPluginTestEvent;
|
const app = window.__d2PlayApp;
|
||||||
emit({ type: 'GameFlags', difficulty: 2, unknown1: 0, hardcore: true, expansion: true, ladder: true });
|
const session = app.session;
|
||||||
emit({
|
session.loginHostValue = 'www.laiseek.xyz';
|
||||||
|
session.usernameValue = 'pluginacct';
|
||||||
|
session.selectedRealmValue = { title: 'D2CS', description: 'Realm' };
|
||||||
|
session.selectedCharValue = 'PluginSorc';
|
||||||
|
session.charactersValue = [
|
||||||
|
{
|
||||||
|
name: 'PluginSorc',
|
||||||
|
charClass: 1,
|
||||||
|
level: 88,
|
||||||
|
hardcore: true,
|
||||||
|
expansion: true,
|
||||||
|
ladder: true,
|
||||||
|
rawFlags: 0,
|
||||||
|
act: 5,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let emitServerEvent = null;
|
||||||
|
const fakeAdapter = {
|
||||||
|
isConnected: true,
|
||||||
|
stats: { packetsIn: 0, packetsOut: 0, bytesIn: 0, bytesOut: 0, rttMs: 12, unknownPackets: 0 },
|
||||||
|
connect: async () => {},
|
||||||
|
disconnect: () => {},
|
||||||
|
send: () => {},
|
||||||
|
onEvent: (cb) => {
|
||||||
|
emitServerEvent = cb;
|
||||||
|
return () => { emitServerEvent = null; };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
session.attachGameServer(fakeAdapter);
|
||||||
|
emitServerEvent({ type: 'GameFlags', difficulty: 2, unknown1: 0, hardcore: true, expansion: true, ladder: true });
|
||||||
|
emitServerEvent({
|
||||||
type: 'UnitAssign',
|
type: 'UnitAssign',
|
||||||
isSelf: true,
|
isSelf: true,
|
||||||
unit: { unitType: 'player', id: 1, classId: 1, name: 'PluginSorc', x: 5000, y: 5000, mode: 1, lifePct: 128 },
|
unit: { unitType: 'player', id: 1, classId: 1, name: 'PluginSorc', x: 5000, y: 5000, mode: 1, lifePct: 128 },
|
||||||
});
|
});
|
||||||
emit({
|
emitServerEvent({
|
||||||
type: 'UnitAssign',
|
type: 'UnitAssign',
|
||||||
unit: { unitType: 'monster', id: 302, classId: 156, x: 5010, y: 5010, mode: 1, lifePct: 128, isBoss: true },
|
unit: { unitType: 'monster', id: 302, classId: 156, x: 5010, y: 5010, mode: 1, lifePct: 128, isBoss: true },
|
||||||
});
|
});
|
||||||
emit({ type: 'UnitMode', unitType: 'monster', id: 302, mode: 0, x: 5010, y: 5010, lifePct: 0 });
|
emitServerEvent({ type: 'UnitMode', unitType: 'monster', id: 302, mode: 0, x: 5010, y: 5010, lifePct: 0 });
|
||||||
await window.__d2PlayApp.plugins.getInstance('kill-stats').tracker.flushNow();
|
await app.plugins.getInstance('kill-stats').tracker.flushNow();
|
||||||
})()`)
|
})()`)
|
||||||
|
|
||||||
let totalAfterReenable = ''
|
let totalAfterReenable = ''
|
||||||
|
|
@ -508,9 +596,9 @@ async function run(): Promise<void> {
|
||||||
totalAfterReenable = await statsCdp.evaluate<string>(
|
totalAfterReenable = await statsCdp.evaluate<string>(
|
||||||
`document.getElementById('d2-stats-val-total')?.textContent?.trim() ?? ''`,
|
`document.getElementById('d2-stats-val-total')?.textContent?.trim() ?? ''`,
|
||||||
)
|
)
|
||||||
if (totalAfterReenable === '1') break
|
if (totalAfterReenable === '2') break
|
||||||
}
|
}
|
||||||
assert(totalAfterReenable === '1', `Expected Total=1 after re-enabling kill-stats, got ${totalAfterReenable}`)
|
assert(totalAfterReenable === '2', `Expected Total=2 after re-enabling kill-stats, got ${totalAfterReenable}`)
|
||||||
|
|
||||||
// Clean up recorded character in stats page
|
// Clean up recorded character in stats page
|
||||||
await statsCdp.evaluate(`(async () => {
|
await statsCdp.evaluate(`(async () => {
|
||||||
|
|
@ -518,29 +606,156 @@ async function run(): Promise<void> {
|
||||||
await window.__d2StatsApp.refresh();
|
await window.__d2StatsApp.refresh();
|
||||||
})()`)
|
})()`)
|
||||||
|
|
||||||
// 6. Verify Escape key closes the plugin modal WITHOUT triggering in-game Esc menu or HUD panels
|
// 6. Verify Escape key closes the plugin modal WITHOUT changing flowState.stage or opening in-game Esc menu / HUD panels
|
||||||
const escIsolation = await playCdp.evaluate<{
|
const escIsolation = await playCdp.evaluate<{
|
||||||
modalOpenBefore: boolean
|
modalOpenBefore: boolean
|
||||||
modalOpenAfter: boolean
|
modalOpenAfter: boolean
|
||||||
hudListenerTriggered: boolean
|
hudListenerTriggered: boolean
|
||||||
|
stageBefore: string
|
||||||
|
stageAfter: string
|
||||||
|
escMenuBefore: boolean
|
||||||
|
escMenuAfter: boolean
|
||||||
}>(`(() => {
|
}>(`(() => {
|
||||||
|
const app = window.__d2PlayApp;
|
||||||
const overlay = document.querySelector('[data-d2-modal-overlay="plugins"]');
|
const overlay = document.querySelector('[data-d2-modal-overlay="plugins"]');
|
||||||
const modalOpenBefore = Boolean(overlay && !overlay.hasAttribute('hidden'));
|
const modalOpenBefore = Boolean(overlay && !overlay.hasAttribute('hidden'));
|
||||||
|
const stageBefore = app.flowState.stage;
|
||||||
|
const escMenuBefore = Boolean(app.hudModel.getSnapshot().panels.escapeMenu || app.hudManager.escapeMenuOpen);
|
||||||
let hudBubbleKeySeen = false;
|
let hudBubbleKeySeen = false;
|
||||||
const onBubbleKey = () => { hudBubbleKeySeen = true; };
|
const onBubbleKey = () => { hudBubbleKeySeen = true; };
|
||||||
window.addEventListener('keydown', onBubbleKey, false);
|
window.addEventListener('keydown', onBubbleKey, false);
|
||||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', code: 'Escape', bubbles: true, cancelable: true }));
|
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', code: 'Escape', bubbles: true, cancelable: true }));
|
||||||
window.removeEventListener('keydown', onBubbleKey, false);
|
window.removeEventListener('keydown', onBubbleKey, false);
|
||||||
const modalOpenAfter = Boolean(overlay && !overlay.hasAttribute('hidden'));
|
const modalOpenAfter = Boolean(overlay && !overlay.hasAttribute('hidden'));
|
||||||
|
const stageAfter = app.flowState.stage;
|
||||||
|
const escMenuAfter = Boolean(app.hudModel.getSnapshot().panels.escapeMenu || app.hudManager.escapeMenuOpen);
|
||||||
return {
|
return {
|
||||||
modalOpenBefore,
|
modalOpenBefore,
|
||||||
modalOpenAfter,
|
modalOpenAfter,
|
||||||
hudListenerTriggered: hudBubbleKeySeen,
|
hudListenerTriggered: hudBubbleKeySeen,
|
||||||
|
stageBefore,
|
||||||
|
stageAfter,
|
||||||
|
escMenuBefore,
|
||||||
|
escMenuAfter,
|
||||||
};
|
};
|
||||||
})()`)
|
})()`)
|
||||||
assert(escIsolation.modalOpenBefore, 'Plugin modal must be open before Escape key')
|
assert(escIsolation.modalOpenBefore, 'Plugin modal must be open before Escape key')
|
||||||
assert(!escIsolation.modalOpenAfter, 'Plugin modal must close on Escape key')
|
assert(!escIsolation.modalOpenAfter, 'Plugin modal must close on Escape key')
|
||||||
assert(!escIsolation.hudListenerTriggered, 'Escape key while plugin modal is open must not reach bubble-phase HUD listeners')
|
assert(!escIsolation.hudListenerTriggered, 'Escape key while plugin modal is open must not reach bubble-phase HUD listeners')
|
||||||
|
assert(
|
||||||
|
escIsolation.stageAfter === escIsolation.stageBefore,
|
||||||
|
`Escape key while plugin modal is open must not change flowState.stage (${escIsolation.stageBefore} -> ${escIsolation.stageAfter})`,
|
||||||
|
)
|
||||||
|
assert(
|
||||||
|
!escIsolation.escMenuBefore && !escIsolation.escMenuAfter,
|
||||||
|
'Escape key while plugin modal is open must not open the in-game Escape menu',
|
||||||
|
)
|
||||||
|
|
||||||
|
// Ensure 0 console errors prior to intentional corrupt-JSON injection test
|
||||||
|
assert(
|
||||||
|
playCdp.consoleErrors.length === 0,
|
||||||
|
`play.html had console errors before corrupt-JSON test: ${JSON.stringify(playCdp.consoleErrors)}`,
|
||||||
|
)
|
||||||
|
assert(
|
||||||
|
statsCdp.consoleErrors.length === 0,
|
||||||
|
`stats page had console errors before corrupt-JSON test: ${JSON.stringify(statsCdp.consoleErrors)}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
// 7. Inject corrupt JSON into d2web.plugins.v1, reload play.html, verify ⚠ badge + #d2-plugin-menu-alert + preserved raw value,
|
||||||
|
// then click #d2-plugin-menu-reset and verify ⚠ clears, alert hides, and valid JSON is persisted.
|
||||||
|
await playCdp.evaluate(`localStorage.setItem('d2web.plugins.v1', '{corrupt-json')`)
|
||||||
|
await playCdp.send('Page.reload')
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
await sleep(150)
|
||||||
|
const ready = await playCdp.evaluate<boolean>(
|
||||||
|
'Boolean(window.__d2PlayApp && window.__d2PlayApp.ready && window.__d2PlayApp.plugins)',
|
||||||
|
)
|
||||||
|
if (ready) break
|
||||||
|
}
|
||||||
|
await playCdp.evaluate(`window.__d2PlayApp.plugins.whenStarted()`)
|
||||||
|
|
||||||
|
// Verify expected [PluginConfigStore] console.error was logged on corrupt JSON and filter it out on both tabs
|
||||||
|
const corruptLogs = playCdp.consoleErrors.filter((msg) =>
|
||||||
|
msg.includes('[PluginConfigStore] Corrupt JSON in d2web.plugins.v1'),
|
||||||
|
)
|
||||||
|
assert(corruptLogs.length >= 1, 'Expected [PluginConfigStore] Corrupt JSON console.error on corrupt d2web.plugins.v1')
|
||||||
|
const unexpectedPlayLogs = playCdp.consoleErrors.filter(
|
||||||
|
(msg) => !msg.includes('[PluginConfigStore] Corrupt JSON in d2web.plugins.v1'),
|
||||||
|
)
|
||||||
|
playCdp.consoleErrors.length = 0
|
||||||
|
playCdp.consoleErrors.push(...unexpectedPlayLogs)
|
||||||
|
|
||||||
|
const unexpectedStatsLogs = statsCdp.consoleErrors.filter(
|
||||||
|
(msg) => !msg.includes('[PluginConfigStore] Corrupt JSON in d2web.plugins.v1'),
|
||||||
|
)
|
||||||
|
statsCdp.consoleErrors.length = 0
|
||||||
|
statsCdp.consoleErrors.push(...unexpectedStatsLogs)
|
||||||
|
|
||||||
|
const corruptAlertState = await playCdp.evaluate<{
|
||||||
|
toolbarLabel: string
|
||||||
|
rawBeforeReset: string | null
|
||||||
|
alertVisible: boolean
|
||||||
|
alertRole: string | null
|
||||||
|
alertText: string
|
||||||
|
}>(`(() => {
|
||||||
|
const btn = document.getElementById('d2-toolbar-plugins-btn');
|
||||||
|
const toolbarLabel = btn?.textContent?.trim() ?? '';
|
||||||
|
btn?.click();
|
||||||
|
const alertEl = document.getElementById('d2-plugin-menu-alert');
|
||||||
|
return {
|
||||||
|
toolbarLabel,
|
||||||
|
rawBeforeReset: localStorage.getItem('d2web.plugins.v1'),
|
||||||
|
alertVisible: Boolean(alertEl && !alertEl.hidden),
|
||||||
|
alertRole: alertEl?.getAttribute('role') ?? null,
|
||||||
|
alertText: alertEl?.textContent?.trim() ?? '',
|
||||||
|
};
|
||||||
|
})()`)
|
||||||
|
assert(
|
||||||
|
corruptAlertState.toolbarLabel.includes('⚠'),
|
||||||
|
`Expected #d2-toolbar-plugins-btn to include ⚠ on corrupt JSON, got "${corruptAlertState.toolbarLabel}"`,
|
||||||
|
)
|
||||||
|
assert(
|
||||||
|
corruptAlertState.rawBeforeReset === '{corrupt-json',
|
||||||
|
`Raw corrupt localStorage value must NOT be overwritten before reset, got ${JSON.stringify(corruptAlertState.rawBeforeReset)}`,
|
||||||
|
)
|
||||||
|
assert(corruptAlertState.alertVisible, '#d2-plugin-menu-alert must be visible when storage is corrupt')
|
||||||
|
assert(corruptAlertState.alertRole === 'alert', 'Expected #d2-plugin-menu-alert to have role="alert"')
|
||||||
|
assert(corruptAlertState.alertText.length > 0, '#d2-plugin-menu-alert must contain a diagnostic message')
|
||||||
|
|
||||||
|
const shot5 = join(outDir, '05-plugin-menu-corrupt-alert.png')
|
||||||
|
const shot5Bytes = await playCdp.captureScreenshot(shot5)
|
||||||
|
console.log(`[verify-plugin-menu] Captured ${shot5} (${shot5Bytes} bytes) — Corrupt config alert banner`)
|
||||||
|
|
||||||
|
await playCdp.evaluate(`document.getElementById('d2-plugin-menu-reset')?.click()`)
|
||||||
|
await sleep(250)
|
||||||
|
|
||||||
|
const afterResetState = await playCdp.evaluate<{
|
||||||
|
toolbarLabel: string
|
||||||
|
alertHidden: boolean
|
||||||
|
rawAfterReset: string | null
|
||||||
|
}>(`(() => {
|
||||||
|
const btn = document.getElementById('d2-toolbar-plugins-btn');
|
||||||
|
const alertEl = document.getElementById('d2-plugin-menu-alert');
|
||||||
|
return {
|
||||||
|
toolbarLabel: btn?.textContent?.trim() ?? '',
|
||||||
|
alertHidden: Boolean(alertEl && alertEl.hidden),
|
||||||
|
rawAfterReset: localStorage.getItem('d2web.plugins.v1'),
|
||||||
|
};
|
||||||
|
})()`)
|
||||||
|
assert(
|
||||||
|
!afterResetState.toolbarLabel.includes('⚠'),
|
||||||
|
`Expected ⚠ to clear from #d2-toolbar-plugins-btn after reset, got "${afterResetState.toolbarLabel}"`,
|
||||||
|
)
|
||||||
|
assert(afterResetState.alertHidden, '#d2-plugin-menu-alert must be hidden after clicking #d2-plugin-menu-reset')
|
||||||
|
const parsedAfterReset = JSON.parse(afterResetState.rawAfterReset ?? '{}') as {
|
||||||
|
version?: number
|
||||||
|
plugins?: Record<string, { enabled?: boolean }>
|
||||||
|
}
|
||||||
|
assert(parsedAfterReset.version === 1, 'Expected reset d2web.plugins.v1 to have version: 1')
|
||||||
|
assert(
|
||||||
|
parsedAfterReset.plugins?.['kill-stats']?.enabled === true,
|
||||||
|
'Expected reset d2web.plugins.v1 to restore kill-stats enabled: true',
|
||||||
|
)
|
||||||
|
|
||||||
assert(
|
assert(
|
||||||
playCdp.consoleErrors.length === 0,
|
playCdp.consoleErrors.length === 0,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue