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:
troytt 2026-10-03 10:08:55 +00:00
parent ff76655d4d
commit 72d413fa93
14 changed files with 1877 additions and 133 deletions

View File

@ -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)*
``` ```
--- ---

View File

@ -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`。 |

View File

@ -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',
}, },

View File

@ -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> {
} }
} }
void bootStatsApp() if (typeof document !== 'undefined') {
void bootStatsApp()
}

View File

@ -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
}
} }
} }

View File

@ -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 清单的离线资产增量缓存

View File

@ -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)
} }
} }

View File

@ -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 ?? []) {

View File

@ -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()
})
})

View File

@ -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)
}) })
}) })

View File

@ -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()
})
})

View File

@ -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()
}
})
})

View File

@ -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()
}) })
}) })

View File

@ -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,