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/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) |
| [`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/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) |
@ -45,7 +45,7 @@
│ plugins/ │
│ (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/` 目录承载 **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 — 核心客户端零插件耦合**:
- 除客户端组合根 [`src/client/main.ts`](../src/client/main.ts) 导入静态插件注册表 [`plugins/index.ts`](./index.ts) 外,**`src/client/**` 下的任何文件严禁导入 `plugins/**`**。
2. **Rule 8 — 服务端与烘焙管线零插件耦合**:
- `src/common/**`、`src/netproto/**`、`src/server/**` 与 `src/baker/**` 严禁导入 `plugins/**`。
3. **Rule 9 — 插件仅限访问公共只读宿主契约**:
- `plugins/**` 仅允许导入:
- `src/common/**`(只读数据表 `D2DataRegistry`、二进制解析器等)
- `src/netproto/**`(只读协议事件与常量定义)
- `src/client/plugin-host/api.ts`(唯一允许导入的 `src/client` 模块)
- 同一插件目录 `plugins/<self>/**` 内部模块
- **严禁直接导入 `src/client/world/**`、`src/client/session/**`、`src/client/ui/**`、`src/client/render/**` 等客户端内部实现**。
4. **Rule 10 — 插件间严格物理隔离**:
- 每个插件必须自包含在 `plugins/<plugin-id>/` 子目录中,**严禁跨插件导入(`plugins/<a>/**` 不得导入 `plugins/<b>/**`)**。
- 所有插件均运行在纯浏览器环境(`tsconfig.client.json`),严禁使用任何 Node.js 内置模块。
1. **组合根(Composition Root — `play.html`)**:
- [`play.html`](../play.html) 是唯一的客户端组合根,通过内联 `<script type="module">` 同时导入 `/src/client/main.ts` 的 `startPlayApp` 与 `/plugins/index.ts` 的 `BUILTIN_PLUGINS`,并调用 `startPlayApp({ plugins: BUILTIN_PLUGINS })`。
- **`src/**` 对具体插件 100% 无感知**:`src/**`(含 `src/client/main.ts`)包含 **0 个** 指向 `plugins/` 的导入语句,且包含 **0 个** `'plugins/'` 或具体插件 ID(如 `'kill-stats'`)的字符串字面量。
2. **Rule 7 — 根 HTML 入口与内联组合根校验**:
- 仓库根目录唯一的 HTML 入口文件为 `play.html`(原根目录 `stats.html` 已迁移至 `plugins/kill-stats/index.html`,5 个历史遗留 HTML 文件保持删除)。
- `play.html` 声明本地 `D2Exocet`(`/ui/fonts/D2Exocet.ttf`)与 `D2Formal436`(`/ui/fonts/D2Formal436.ttf`)字体,且其内联 `<script type="module">` 仅导入 `['/src/client/main.ts', '/plugins/index.ts']`。
3. **Rule 9 — `plugins/` 依赖白名单、物理隔离与核心零耦合**:
- `plugins/index.ts` 仅允许导入 `./<id>/index.ts` 与 `src/client/plugin-host/api.ts`。
- `plugins/<id>/**` 仅允许导入:
- 同一插件目录内部模块 `plugins/<id>/**`
- `src/common/**`(只读 1.13c 数据表与通用解析器)
- `src/netproto/index.ts`(协议栈唯一公共门面,严禁导入 `src/netproto/` 内部子路径)
- `src/client/plugin-host/api.ts`(唯一允许导入的 `src/client/` 模块)
- 严禁跨插件导入(`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
plugins/
├── README.md # 插件系统架构说明与开发指南
├── index.ts # BUILTIN_PLUGINS 静态注册表(唯一向 src/client/main.ts 暴露的入口)
└── kill-stats/ # [Built-in #714] 怪物击杀统计插件 (Kill Statistics)
├── index.ts # killStatsPlugin 描述符、生命周期挂载与工具栏动作
├── index.html # 独立统计看板页面入口 (/plugins/kill-stats/index.html)
├── page-main.ts # 独立页面启动引导 (基于 bootPluginPage 统一处理语言与启停状态)
├── stats-page.ts # 独立统计页控制器 (IndexedDB 实时监听、BroadcastChannel 同步、导入/导出)
├── kill-tracker.ts # 实时击杀追踪器 (监听 unitDeath S2C 事件并批量落盘 IndexedDB)
├── kill-stats-model.ts # G-counter CRDT 统计模型与 JSON 序列化/合并校验
├── kill-stats-store.ts # IndexedDB (d2_kill_stats_v1) 存储实现
├── kill-stats-view.ts # 独立统计页双语 DOM 渲染器
├── monster-kill-classes.ts# 基于 MonStats.txt (hcIdx / killable / align) 的可击杀怪物分类器
└── stats-i18n.ts # 插件专属中英双语词典
├── README.md # 插件系统架构说明、API 契约与后续迁移路线图
├── index.ts # BUILTIN_PLUGINS 静态注册表(由组合根 play.html 导入并注入 startPlayApp)
└── kill-stats/ # 内置插件:怪物击杀统计 (id: 'kill-stats')
├── index.ts # killStatsPlugin 定义、激活/卸载生命周期、状态汇报与工具栏快捷入口管理
├── index.html # 独立统计页面入口 (/plugins/kill-stats/index.html)
├── page-main.ts # 独立页面启动引导(调用 bootPluginPage(killStatsPlugin) 并捕获启动异常)
├── stats-page.ts # 独立统计页面 DOM 控制器(IndexedDB 读取、BroadcastChannel 同步、导入/导出/删除)
├── kill-tracker.ts # 实时击杀追踪器(监听 D2GS ServerEvent、严格校验角色身份并批量落盘)
├── kill-stats-model.ts # G-counter CRDT 模型、键值规范化与严格导入校验 (KillStatsImportError)
├── kill-stats-store.ts # IndexedDB ('d2web-kill-stats' v1) 存储仓库实现
├── kill-stats-view.ts # 86 行首领/超级精英看板与怪物分类表纯视图模型构建器
├── monster-kill-classes.ts# 基于 MonStats.txt (Align / killable / neverCount) 的可计数怪物分类表
└── stats-i18n.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
export interface D2WebPlugin<TInstance = unknown> {
readonly descriptor: PluginDescriptor;
create(ctx: PluginHostContext): PluginLifecycle<TInstance>;
export type { GameLang }
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`**:
- `id`: 全局唯一 kebab-case 插件标识(如 `'kill-stats'`),必须与目录名 `plugins/<id>/` 一致。
- `version`: 语义化版本号(如 `'1.0.0'`)。
- `name` / `description`: `{ zh: string; en: string }` 双语名称与简介(在工具栏 **Plugins** 模态框中展示)。
- `defaultEnabled`: 默认是否启用。
- `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`。
### 宿主生命周期与故障隔离保证(`PluginHost`)
- **启动期快速失败校验(`validatePluginRegistry`)**:校验每个插件的 kebab-case `id` 唯一性、非空双语 `name`/`description`、唯一设置项 `key`、`select` 默认值合法性以及相对路径 `link.href`(拒绝 `http://`、`https://`、`//`、`javascript:` 或 `/` 开头路径)。
- **串行化热插拔(`setEnabled(id, enabled)`)**:同一插件的启停切换按序串行收敛;`deactivate` 时等待 `await instance.deactivate()`(例如 `kill-stats` 会先执行 `await tracker.flushNow()` 落盘待写入击杀增量),随后宿主自动清理该插件注册的全部工具栏按钮/链接(`#d2-toolbar-plugin-slot`)及设置/语言订阅。
- **插件级异常隔离**:`activate`、`deactivate`、动作回调或订阅回调抛错/reject 时,宿主通过 `console.error` 记录堆栈、将该插件状态标记为 `level: 'error'` 并在工具栏 `#d2-toolbar-plugins-btn` 显示 `⚠`,绝不中断主游戏循环或其他插件。
- **独立页面引导(`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']` 中的插件启停/设置状态。
---
## 4. 如何开发与接入新插件
## 4. 存储键值与跨标签页通信规范
1. **创建插件目录**:在 `plugins/<plugin-id>/` 下创建 `index.ts`(若需要独立 HTML 页面,创建 `plugins/<plugin-id>/index.html` 与 `page-main.ts`)。
2. **遵循只读与快速失败原则(Fail-Fast)**:
- 严禁伪造或硬编码游戏规则数据;凡涉及怪物、物品、技能或关卡元数据,必须从 `ctx.getDataRegistry()` 的 1.13c 权威数据表中读取,缺失必需参数时立即抛出明确错误。
3. **注册到 `plugins/index.ts`**:
- 将插件实例追加到 `BUILTIN_PLUGINS` 只读数组中。
- `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 零违规。
| 存储介质 / 通道 | 键名 / 标识 | 所属模块 | 用途与容错规范 |
| :--- | :--- | :--- | :--- |
| `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 原样保留。 |
| `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` 键)。 |
| `BroadcastChannel` | `'d2web-kill-stats'` | `KillTracker` & `KillStatsPage` (`plugins/kill-stats/`) | 每次 `KillTracker` 批量落盘或 `KillStatsPage` 导入/删除/清空成功后广播 `{ type: 'updated', at }`,触发已打开的 `/plugins/kill-stats/index.html` 标签页自动防抖刷新。 |
---
## 5. 内置与规划插件路线图(Gitea Issues)
## 5. 如何新增插件(Authoring Checklist)
| 插件目录 / 标识 | 状态 | Gitea Issue | 功能简述 |
| :--- | :---: | :---: | :--- |
| [`plugins/kill-stats/`](./kill-stats) | ✅ 已内置 | `#714` | **怪物击杀统计 (Monster Kill Statistics)**:按战网网关/账号/角色隔离追踪全部可击杀怪物(普通/精英/暗金/首领),基于 IndexedDB G-counter CRDT 支持多标签页实时同步与 JSON 导入合并。 |
| `plugins/loot-filter/` | 📋 规划中 | `#715` | **掉落物品过滤与高价值掉落提醒 (Loot Filter & Drop Notifier)**:订阅地面物品掉落事件,支持自定义过滤规则、底材孔数/无形高亮与高号符文/暗金掉落提示音。 |
| `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 掉落参考。 |
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`)。
3. **遵循 1.13c Ground Truth 与 Fail-Fast 原则**:严禁添加宽松兜底默认值(如缺失角色职业/等级/难度标记时不得伪造默认值),所有依赖只允许指向 `./*`、`src/common/**`、`src/netproto/index.ts` 与 `src/client/plugin-host/api.ts`。
4. **编写测试**:在 `tests/plugins/<plugin-id>/` 下添加单元测试,并确保 `tests/plugins/registry.test.ts` 与 `tests/arch/boundaries.test.ts`(Rules 1–10)全部通过。
---
## 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 {
id: 'kill-stats',
name: {
zh: '怪物击杀统计',
en: 'Kill Statistics',
zh: '击杀统计',
en: 'Kill Stats',
},
description: {
zh: '记录本浏览器内各账号与角色在普通/噩梦/地狱难度下的怪物、首领与超级精英击杀数。',
@ -44,8 +44,8 @@ export function createKillStatsPlugin(
kind: 'boolean',
key: 'showToolbarShortcut',
label: {
zh: '在顶部工具栏显示「击杀统计」快捷入口',
en: 'Show "Kill Stats" shortcut in top toolbar',
zh: '在工具栏显示快捷入口',
en: 'Show shortcut in toolbar',
},
default: true,
},
@ -55,8 +55,8 @@ export function createKillStatsPlugin(
kind: 'link',
id: 'open-page',
label: {
zh: '打开统计页面',
en: 'Open Statistics Page',
zh: '打开统计页',
en: 'Open Stats Page',
},
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})`
if (page) {
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 {
const banner = document.createElement('div')
banner.id = 'd2-stats-error-banner'
@ -72,4 +82,6 @@ export async function bootStatsApp(): Promise<void> {
}
}
if (typeof document !== 'undefined') {
void bootStatsApp()
}

View File

@ -190,7 +190,7 @@ export class KillStatsPage {
this.unsubEnabled = this.pageContext.subscribeEnabled(() => {
this.render()
})
await this.refresh()
await this.refresh({ rethrow: true })
}
destroy(): void {
@ -255,14 +255,18 @@ export class KillStatsPage {
}, this.refreshDebounceMs)
}
async refresh(): Promise<void> {
async refresh(options?: { readonly rethrow?: boolean }): Promise<void> {
try {
this.snapshot = await this.repository.loadAll()
this.render()
} catch (err) {
console.error('[kill-stats] Failed to load kill stats snapshot:', err)
const detail = err instanceof Error ? err.message : String(err)
this.showError(`${tStats('stats.error.idbUnavailable', this.lang)} (${detail})`)
this.render()
if (options?.rethrow) {
throw err
}
}
}

View File

@ -7,9 +7,9 @@
## 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))。
- `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`)**:
- `lib: ["ES2022", "DOM", "DOM.Iterable"]`, `types: []`。
- **严禁依赖 `src/server` 或 `src/baker`**,严禁导入任何 Node.js 内置模块(`fs`, `path`, `net`, `Buffer` 等)。
@ -32,7 +32,7 @@ src/client/
├── ui-model/ # UI 纯视图状态模型 (HudModel, 属性加点/技能树/背包网格/商店/赫拉迪克方块交互逻辑)
├── frontend/ # 战网前置界面 (FrontendRenderer:Gateway 选择、账号登录/注册、8 角色选择、建房/加房大厅)
├── 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 地址与显示偏好)
├── inspector/ # 实时网络封包检查器 (PacketInspector) 与 .d2cap 录制/回放面板
├── sw/ # Service Worker (sw.ts) + CacheUpdater:基于 SHA-256 清单的离线资产增量缓存

View File

@ -16,7 +16,6 @@ import {
import type { OnlineSession } from '../session/online-session.ts'
import { getGlobalSettingsStore } from '../settings/settings-store.ts'
import {
PLUGIN_CONFIG_STORAGE_KEY,
PluginConfigStore,
} from './plugin-config-store.ts'
@ -388,8 +387,6 @@ export function bootPluginPage(plugin: D2WebPlugin): PluginPageContext {
if (getGameLang() !== 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()
} else if (event.kind === 'reload') {
for (const [pluginId, record] of this.records.entries()) {
const next = record.queue.then(async () => {
if (this.destroyed) return
const desired = this.configStore.isEnabled(pluginId)
if (desired !== record.active) {
void this.enqueueTransition(record, desired)
if (desired) {
await this.activateRecord(record)
} else {
await this.deactivateRecord(record)
}
} else if (record.active) {
for (const spec of record.plugin.settings ?? []) {
this.notifySettingSubscribers(record, spec.key)
}
}
})
record.queue = next.catch(() => {
// Errors are isolated inside activateRecord / deactivateRecord
})
}
this.syncToolbarState()
this.notifyListeners()
@ -341,9 +351,7 @@ export class PluginHost {
const tasks: Promise<void>[] = []
for (const [pluginId, record] of this.records.entries()) {
const targetEnabled = this.configStore.isEnabled(pluginId)
if (!targetEnabled) {
record.error = null
}
tasks.push(this.enqueueTransition(record, targetEnabled))
if (targetEnabled && record.active) {
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()
})
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 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.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')
expect(pageCtx.getLang()).toBe('en')
pageCtx.setLang('zh')
expect(pageCtx.getLang()).toBe('zh')
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', () => {
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 {
computeCharKey,
computeTallyId,
@ -13,6 +13,14 @@ import {
resolveCharacterClassName,
resolveLocalizedMonsterName,
} 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_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])
})
})
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',
])
// Verify setting and action declarations
// Verify setting, action, and exact localized label declarations
const settingSpecs: readonly PluginSettingSpec[] = plugin.settings ?? []
const actionSpecs: readonly PluginActionSpec[] = plugin.actions ?? []
expect(plugin.name).toEqual({
zh: '击杀统计',
en: 'Kill Stats',
})
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[0]?.label).toEqual({
zh: '打开统计页',
en: 'Open Stats Page',
})
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')
// 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 () => {
const app = window.__d2PlayApp;
const session = app.session;
@ -414,19 +416,7 @@ async function run(): Promise<void> {
session.attachGameServer(fakeAdapter);
window.__d2EmitPluginTestEvent = (ev) => emitServerEvent && emitServerEvent(ev);
// Toggle kill-stats off via DOM checkbox
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(`(() => {
// Emit 1 monster kill BEFORE disabling (leave unflushed in memory so deactivate() must flush it)
const emit = window.__d2EmitPluginTestEvent;
emit({ type: 'GameFlags', difficulty: 2, unknown1: 0, hardcore: true, expansion: true, ladder: true });
emit({
@ -434,6 +424,34 @@ async function run(): Promise<void> {
isSelf: true,
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({
type: 'UnitAssign',
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 });
})()`)
// 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)
statsCdp = await CdpSession.connect(statsTarget.webSocketDebuggerUrl)
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.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 shot4Bytes = await statsCdp.captureScreenshot(shot4)
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
await playCdp.evaluate(`(async () => {
// Reload play.html while kill-stats is disabled and verify disabled state persists across reload
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"]');
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
for (let i = 0; i < 30; i++) {
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')
await playCdp.evaluate(`(async () => {
const emit = window.__d2EmitPluginTestEvent;
emit({ type: 'GameFlags', difficulty: 2, unknown1: 0, hardcore: true, expansion: true, ladder: true });
emit({
const app = window.__d2PlayApp;
const session = app.session;
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',
isSelf: true,
unit: { unitType: 'player', id: 1, classId: 1, name: 'PluginSorc', x: 5000, y: 5000, mode: 1, lifePct: 128 },
});
emit({
emitServerEvent({
type: 'UnitAssign',
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 });
await window.__d2PlayApp.plugins.getInstance('kill-stats').tracker.flushNow();
emitServerEvent({ type: 'UnitMode', unitType: 'monster', id: 302, mode: 0, x: 5010, y: 5010, lifePct: 0 });
await app.plugins.getInstance('kill-stats').tracker.flushNow();
})()`)
let totalAfterReenable = ''
@ -508,9 +596,9 @@ async function run(): Promise<void> {
totalAfterReenable = await statsCdp.evaluate<string>(
`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
await statsCdp.evaluate(`(async () => {
@ -518,29 +606,156 @@ async function run(): Promise<void> {
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<{
modalOpenBefore: boolean
modalOpenAfter: 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 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;
const onBubbleKey = () => { hudBubbleKeySeen = true; };
window.addEventListener('keydown', onBubbleKey, false);
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', code: 'Escape', bubbles: true, cancelable: true }));
window.removeEventListener('keydown', onBubbleKey, false);
const modalOpenAfter = Boolean(overlay && !overlay.hasAttribute('hidden'));
const stageAfter = app.flowState.stage;
const escMenuAfter = Boolean(app.hudModel.getSnapshot().panels.escapeMenu || app.hudManager.escapeMenuOpen);
return {
modalOpenBefore,
modalOpenAfter,
hudListenerTriggered: hudBubbleKeySeen,
stageBefore,
stageAfter,
escMenuBefore,
escMenuAfter,
};
})()`)
assert(escIsolation.modalOpenBefore, 'Plugin modal must be open before 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.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(
playCdp.consoleErrors.length === 0,