fix(plugins): remediate Iteration 2 plugin system docs, runtime edge-cases, E2E checks, and unit tests
- Align plugins/README.md, src/client/README.md, and README.md with actual plugin architecture, file inventories, and test paths - Rethrow initial repository read failures in StatsPage.init() and expose ready: false on window.__d2StatsApp in bootStatsApp() - Remove duplicate PLUGIN_CONFIG_STORAGE_KEY storage listener branch in src/client/plugin-host/api.ts - Serialize cross-tab reload reconciliation onto record.queue and unconditionally clear record.error in resetAllToDefaults() - Align localized strings in plugins/kill-stats/index.ts with plugin contract - Extend tools/verify-plugin-menu-browser.ts with Steps 4-7 (corrupt JSON alert banner, raw storage preservation, and reset recovery) - Add comprehensive unit tests in plugin-config-store.test.ts, plugin-menu.test.ts, plugin-host.test.ts, kill-stats-view.test.ts, and registry.test.ts TAG=agy CONV=2cf150d5-78df-48ac-be0c-42ef1694a004
This commit is contained in:
parent
ff76655d4d
commit
72d413fa93
|
|
@ -20,7 +20,7 @@
|
|||
| [`src/common`](./src/common) | `D2Common.dll` `Storm.dll` `D2Lang.dll` | 纯 TypeScript (`ES2022`),零 DOM / 零 Node.js 依赖 | MPQ 与原生二进制格式解析器 (DC6/DCC/DT1/DS1/COF/PL2/TBL)、`D2DataRegistry` 数据表中心、1.13c DRLG 随机地图生成算法、64 位 `D2Rng` 与烘焙校验契约 (`pack-contract`) | [src/common/README.md](./src/common/README.md) |
|
||||
| [`src/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)*
|
||||
```
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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`。 |
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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> {
|
|||
}
|
||||
}
|
||||
|
||||
void bootStatsApp()
|
||||
if (typeof document !== 'undefined') {
|
||||
void bootStatsApp()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 清单的离线资产增量缓存
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -129,14 +129,24 @@ export class PluginHost {
|
|||
this.notifyListeners()
|
||||
} else if (event.kind === 'reload') {
|
||||
for (const [pluginId, record] of this.records.entries()) {
|
||||
const desired = this.configStore.isEnabled(pluginId)
|
||||
if (desired !== record.active) {
|
||||
void this.enqueueTransition(record, desired)
|
||||
} else if (record.active) {
|
||||
for (const spec of record.plugin.settings ?? []) {
|
||||
this.notifySettingSubscribers(record, spec.key)
|
||||
const next = record.queue.then(async () => {
|
||||
if (this.destroyed) return
|
||||
const desired = this.configStore.isEnabled(pluginId)
|
||||
if (desired !== record.active) {
|
||||
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
|
||||
}
|
||||
record.error = null
|
||||
tasks.push(this.enqueueTransition(record, targetEnabled))
|
||||
if (targetEnabled && record.active) {
|
||||
for (const spec of record.plugin.settings ?? []) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,320 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { D2WebPlugin } from '../../../src/client/plugin-host/api.ts'
|
||||
import {
|
||||
PLUGIN_CONFIG_STORAGE_KEY,
|
||||
PluginConfigStore,
|
||||
type PluginConfigChangeEvent,
|
||||
} from '../../../src/client/plugin-host/plugin-config-store.ts'
|
||||
|
||||
class MemoryStorage implements Pick<Storage, 'getItem' | 'setItem' | 'removeItem'> {
|
||||
readonly data = new Map<string, string>()
|
||||
setCalls = 0
|
||||
removeCalls = 0
|
||||
throwOnGet = false
|
||||
throwOnSet = false
|
||||
|
||||
getItem(key: string): string | null {
|
||||
if (this.throwOnGet) {
|
||||
throw new Error('QuotaExceeded or SecurityError on getItem')
|
||||
}
|
||||
return this.data.get(key) ?? null
|
||||
}
|
||||
|
||||
setItem(key: string, value: string): void {
|
||||
if (this.throwOnSet) {
|
||||
throw new Error('QuotaExceeded or SecurityError on setItem')
|
||||
}
|
||||
this.setCalls++
|
||||
this.data.set(key, value)
|
||||
}
|
||||
|
||||
removeItem(key: string): void {
|
||||
this.removeCalls++
|
||||
this.data.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
function makeTestPlugin(overrides: Partial<D2WebPlugin> = {}): D2WebPlugin {
|
||||
return {
|
||||
id: overrides.id ?? 'kill-stats',
|
||||
name: overrides.name ?? { zh: '击杀统计', en: 'Kill Stats' },
|
||||
description: overrides.description ?? {
|
||||
zh: '记录并展示角色击杀统计',
|
||||
en: 'Record and display character kill statistics',
|
||||
},
|
||||
defaultEnabled: overrides.defaultEnabled ?? true,
|
||||
settings: overrides.settings ?? [
|
||||
{
|
||||
kind: 'boolean',
|
||||
key: 'showToolbarShortcut',
|
||||
label: { zh: '在工具栏显示快捷入口', en: 'Show shortcut in toolbar' },
|
||||
default: true,
|
||||
},
|
||||
{
|
||||
kind: 'select',
|
||||
key: 'density',
|
||||
label: { zh: '密度', en: 'Density' },
|
||||
default: 'normal',
|
||||
options: [
|
||||
{ value: 'compact', label: { zh: '紧凑', en: 'Compact' } },
|
||||
{ value: 'normal', label: { zh: '标准', en: 'Normal' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
actions: overrides.actions ?? [],
|
||||
activate:
|
||||
overrides.activate ??
|
||||
(() => ({
|
||||
deactivate() {},
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
describe('PluginConfigStore (d2web.plugins.v1 Persistence & Corruption Resilience)', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('uses manifest defaults when localStorage is empty without writing to storage', () => {
|
||||
const storage = new MemoryStorage()
|
||||
const pluginA = makeTestPlugin({ id: 'kill-stats', defaultEnabled: true })
|
||||
const pluginB = makeTestPlugin({
|
||||
id: 'map-overlay',
|
||||
defaultEnabled: false,
|
||||
settings: [],
|
||||
})
|
||||
|
||||
const store = new PluginConfigStore([pluginA, pluginB], { storage })
|
||||
|
||||
expect(store.hasCorruptionError()).toBe(false)
|
||||
expect(store.getCorruptionMessage()).toBeNull()
|
||||
expect(store.isEnabled('kill-stats')).toBe(true)
|
||||
expect(store.isEnabled('map-overlay')).toBe(false)
|
||||
expect(store.getSetting('kill-stats', 'showToolbarShortcut')).toBe(true)
|
||||
expect(store.getSetting('kill-stats', 'density')).toBe('normal')
|
||||
expect(store.getAllSettings('kill-stats')).toEqual({
|
||||
showToolbarShortcut: true,
|
||||
density: 'normal',
|
||||
})
|
||||
expect(storage.setCalls).toBe(0)
|
||||
store.detachStorageListener()
|
||||
})
|
||||
|
||||
it('persists and round-trips v1 schema across store instances and notifies subscribers', () => {
|
||||
const storage = new MemoryStorage()
|
||||
const plugin = makeTestPlugin()
|
||||
const store1 = new PluginConfigStore([plugin], { storage })
|
||||
const events: PluginConfigChangeEvent[] = []
|
||||
const unsub = store1.subscribe((e) => events.push(e))
|
||||
|
||||
store1.setEnabled('kill-stats', false)
|
||||
store1.setSetting('kill-stats', 'showToolbarShortcut', false)
|
||||
store1.setSetting('kill-stats', 'density', 'compact')
|
||||
|
||||
expect(events).toEqual([
|
||||
{ kind: 'enabled', pluginId: 'kill-stats' },
|
||||
{ kind: 'setting', pluginId: 'kill-stats', settingKey: 'showToolbarShortcut' },
|
||||
{ kind: 'setting', pluginId: 'kill-stats', settingKey: 'density' },
|
||||
])
|
||||
|
||||
const raw = storage.getItem(PLUGIN_CONFIG_STORAGE_KEY)
|
||||
expect(raw).not.toBeNull()
|
||||
const parsed = JSON.parse(raw!) as {
|
||||
version: number
|
||||
plugins: Record<string, { enabled: boolean; settings: Record<string, unknown> }>
|
||||
}
|
||||
expect(parsed).toEqual({
|
||||
version: 1,
|
||||
plugins: {
|
||||
'kill-stats': {
|
||||
enabled: false,
|
||||
settings: {
|
||||
showToolbarShortcut: false,
|
||||
density: 'compact',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const store2 = new PluginConfigStore([plugin], { storage })
|
||||
expect(store2.hasCorruptionError()).toBe(false)
|
||||
expect(store2.isEnabled('kill-stats')).toBe(false)
|
||||
expect(store2.getSetting('kill-stats', 'showToolbarShortcut')).toBe(false)
|
||||
expect(store2.getSetting('kill-stats', 'density')).toBe('compact')
|
||||
|
||||
unsub()
|
||||
store1.detachStorageListener()
|
||||
store2.detachStorageListener()
|
||||
})
|
||||
|
||||
it('flags corrupt JSON via console.error, falls back to defaults in memory, and preserves raw localStorage until explicit user write', () => {
|
||||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const storage = new MemoryStorage()
|
||||
const corruptRaw = '{not-valid-json'
|
||||
storage.data.set(PLUGIN_CONFIG_STORAGE_KEY, corruptRaw)
|
||||
|
||||
const plugin = makeTestPlugin()
|
||||
const store = new PluginConfigStore([plugin], { storage })
|
||||
|
||||
expect(errSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('[PluginConfigStore] Corrupt JSON in d2web.plugins.v1:'),
|
||||
)
|
||||
expect(store.hasCorruptionError()).toBe(true)
|
||||
expect(store.getCorruptionMessage()).toContain('Corrupt JSON in d2web.plugins.v1:')
|
||||
expect(store.isEnabled('kill-stats')).toBe(true)
|
||||
expect(store.getSetting('kill-stats', 'showToolbarShortcut')).toBe(true)
|
||||
// Raw corrupt entry MUST NOT be overwritten during initialization
|
||||
expect(storage.setCalls).toBe(0)
|
||||
expect(storage.getItem(PLUGIN_CONFIG_STORAGE_KEY)).toBe(corruptRaw)
|
||||
|
||||
// Explicit user action clears corruption state and writes valid v1 JSON
|
||||
store.setSetting('kill-stats', 'density', 'compact')
|
||||
expect(store.hasCorruptionError()).toBe(false)
|
||||
expect(store.getCorruptionMessage()).toBeNull()
|
||||
expect(storage.setCalls).toBe(1)
|
||||
expect(JSON.parse(storage.getItem(PLUGIN_CONFIG_STORAGE_KEY)!).version).toBe(1)
|
||||
|
||||
store.detachStorageListener()
|
||||
})
|
||||
|
||||
it('rejects unsupported schema version or missing plugins object without overwriting storage', () => {
|
||||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const storage = new MemoryStorage()
|
||||
const futureRaw = JSON.stringify({ version: 99, plugins: {} })
|
||||
storage.data.set(PLUGIN_CONFIG_STORAGE_KEY, futureRaw)
|
||||
|
||||
const store = new PluginConfigStore([makeTestPlugin()], { storage })
|
||||
expect(errSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('[PluginConfigStore] Invalid schema in d2web.plugins.v1'),
|
||||
)
|
||||
expect(store.hasCorruptionError()).toBe(true)
|
||||
expect(store.getCorruptionMessage()).toContain('Invalid schema in d2web.plugins.v1')
|
||||
expect(storage.setCalls).toBe(0)
|
||||
expect(storage.getItem(PLUGIN_CONFIG_STORAGE_KEY)).toBe(futureRaw)
|
||||
|
||||
store.detachStorageListener()
|
||||
})
|
||||
|
||||
it('validates per-plugin enabled and setting values, preserves unknown plugin entries, and supports resetAllToDefaults()', () => {
|
||||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const storage = new MemoryStorage()
|
||||
storage.data.set(
|
||||
PLUGIN_CONFIG_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
plugins: {
|
||||
'kill-stats': {
|
||||
enabled: 'not-a-boolean',
|
||||
settings: {
|
||||
showToolbarShortcut: false,
|
||||
density: 'invalid-option',
|
||||
},
|
||||
},
|
||||
'future-plugin': {
|
||||
enabled: false,
|
||||
settings: { customKey: 'customVal' },
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const store = new PluginConfigStore([makeTestPlugin()], { storage })
|
||||
expect(errSpy).toHaveBeenCalled()
|
||||
expect(store.hasCorruptionError()).toBe(true)
|
||||
// Invalid enabled & density fall back to defaults; valid showToolbarShortcut=false is retained
|
||||
expect(store.isEnabled('kill-stats')).toBe(true)
|
||||
expect(store.getSetting('kill-stats', 'showToolbarShortcut')).toBe(false)
|
||||
expect(store.getSetting('kill-stats', 'density')).toBe('normal')
|
||||
expect(storage.setCalls).toBe(0)
|
||||
|
||||
// resetAllToDefaults clears corruption error, restores manifest defaults, and preserves 'future-plugin'
|
||||
store.resetAllToDefaults()
|
||||
expect(store.hasCorruptionError()).toBe(false)
|
||||
expect(store.getCorruptionMessage()).toBeNull()
|
||||
expect(store.isEnabled('kill-stats')).toBe(true)
|
||||
expect(store.getSetting('kill-stats', 'showToolbarShortcut')).toBe(true)
|
||||
expect(store.getSetting('kill-stats', 'density')).toBe('normal')
|
||||
|
||||
const persisted = JSON.parse(storage.getItem(PLUGIN_CONFIG_STORAGE_KEY)!) as {
|
||||
version: number
|
||||
plugins: Record<string, unknown>
|
||||
}
|
||||
expect(persisted.plugins['future-plugin']).toEqual({
|
||||
enabled: false,
|
||||
settings: { customKey: 'customVal' },
|
||||
})
|
||||
|
||||
store.detachStorageListener()
|
||||
})
|
||||
|
||||
it('reloads configuration when window dispatches a storage event for d2web.plugins.v1', () => {
|
||||
const prevWindow = globalThis.window
|
||||
const storageListeners = new Set<(ev: StorageEvent) => void>()
|
||||
const fakeWindow = {
|
||||
addEventListener(type: string, listener: (ev: StorageEvent) => void) {
|
||||
if (type === 'storage') storageListeners.add(listener)
|
||||
},
|
||||
removeEventListener(type: string, listener: (ev: StorageEvent) => void) {
|
||||
if (type === 'storage') storageListeners.delete(listener)
|
||||
},
|
||||
}
|
||||
;(globalThis as unknown as { window: unknown }).window = fakeWindow
|
||||
|
||||
try {
|
||||
const storage = new MemoryStorage()
|
||||
const store = new PluginConfigStore([makeTestPlugin()], { storage })
|
||||
store.attachStorageListener()
|
||||
const events: PluginConfigChangeEvent[] = []
|
||||
store.subscribe((e) => events.push(e))
|
||||
|
||||
expect(store.isEnabled('kill-stats')).toBe(true)
|
||||
|
||||
storage.data.set(
|
||||
PLUGIN_CONFIG_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
plugins: {
|
||||
'kill-stats': {
|
||||
enabled: false,
|
||||
settings: { showToolbarShortcut: false, density: 'compact' },
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
for (const listener of storageListeners) {
|
||||
listener({ key: PLUGIN_CONFIG_STORAGE_KEY } as StorageEvent)
|
||||
}
|
||||
|
||||
expect(store.isEnabled('kill-stats')).toBe(false)
|
||||
expect(store.getSetting('kill-stats', 'showToolbarShortcut')).toBe(false)
|
||||
expect(store.getSetting('kill-stats', 'density')).toBe('compact')
|
||||
expect(events).toEqual([{ kind: 'reload', pluginId: null }])
|
||||
|
||||
store.detachStorageListener()
|
||||
expect(storageListeners.size).toBe(0)
|
||||
} finally {
|
||||
;(globalThis as unknown as { window: unknown }).window = prevWindow
|
||||
}
|
||||
})
|
||||
|
||||
it('handles storage getItem/setItem exceptions gracefully by logging console.error and flagging corruption', () => {
|
||||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const storage = new MemoryStorage()
|
||||
storage.throwOnGet = true
|
||||
|
||||
const store = new PluginConfigStore([makeTestPlugin()], { storage })
|
||||
expect(store.hasCorruptionError()).toBe(true)
|
||||
expect(store.getCorruptionMessage()).toContain('Failed to read d2web.plugins.v1:')
|
||||
|
||||
storage.throwOnGet = false
|
||||
storage.throwOnSet = true
|
||||
store.setEnabled('kill-stats', false)
|
||||
expect(errSpy).toHaveBeenCalledWith(
|
||||
'[PluginConfigStore] Failed to persist plugin configuration:',
|
||||
expect.any(Error),
|
||||
)
|
||||
|
||||
store.detachStorageListener()
|
||||
})
|
||||
})
|
||||
|
|
@ -366,21 +366,178 @@ describe('PluginHost (Lifecycle, Serialized Toggles, Resource Cleanup & Error Is
|
|||
errSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('bootPluginPage reads language and enabled status and cleans up on destroy', () => {
|
||||
const plugin = makeValidPlugin({ id: 'page-plugin', defaultEnabled: true })
|
||||
const pageCtx = bootPluginPage(plugin)
|
||||
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)
|
||||
},
|
||||
}
|
||||
|
||||
expect(pageCtx.pluginId).toBe('page-plugin')
|
||||
expect(pageCtx.isEnabled()).toBe(true)
|
||||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
try {
|
||||
const storage = new MemoryPluginStorage()
|
||||
let resolveInFlightActivate: (() => void) | null = null
|
||||
let blockNextActivate = true
|
||||
let deactivated = false
|
||||
|
||||
pageCtx.setLang('en')
|
||||
expect(pageCtx.getLang()).toBe('en')
|
||||
pageCtx.setLang('zh')
|
||||
expect(pageCtx.getLang()).toBe('zh')
|
||||
pageCtx.destroy()
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,511 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { D2WebPlugin } from '../../../src/client/plugin-host/api.ts'
|
||||
import {
|
||||
PLUGIN_CONFIG_STORAGE_KEY,
|
||||
PluginConfigStore,
|
||||
} from '../../../src/client/plugin-host/plugin-config-store.ts'
|
||||
import {
|
||||
PluginHost,
|
||||
type PluginHostToolbarPort,
|
||||
} from '../../../src/client/plugin-host/plugin-host.ts'
|
||||
import { PluginMenu } from '../../../src/client/plugin-host/plugin-menu.ts'
|
||||
import { OnlineSession } from '../../../src/client/session/online-session.ts'
|
||||
import { isModalDomOverlayOpen } from '../../../src/client/ui/dom-overlay.ts'
|
||||
|
||||
class MemoryStorage implements Pick<Storage, 'getItem' | 'setItem' | 'removeItem'> {
|
||||
readonly data = new Map<string, string>()
|
||||
getItem(key: string): string | null {
|
||||
return this.data.get(key) ?? null
|
||||
}
|
||||
setItem(key: string, value: string): void {
|
||||
this.data.set(key, value)
|
||||
}
|
||||
removeItem(key: string): void {
|
||||
this.data.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
type FakeListener = (event: FakeEvent) => void
|
||||
|
||||
interface FakeEvent {
|
||||
type: string
|
||||
key?: string
|
||||
target?: FakeElement | null
|
||||
defaultPrevented: boolean
|
||||
immediatePropagationStopped: boolean
|
||||
preventDefault(): void
|
||||
stopImmediatePropagation(): void
|
||||
}
|
||||
|
||||
function makeFakeEvent(type: string, init: { key?: string; target?: FakeElement | null } = {}): FakeEvent {
|
||||
const ev: FakeEvent = {
|
||||
type,
|
||||
...(init.key !== undefined ? { key: init.key } : {}),
|
||||
target: init.target ?? null,
|
||||
defaultPrevented: false,
|
||||
immediatePropagationStopped: false,
|
||||
preventDefault() {
|
||||
ev.defaultPrevented = true
|
||||
},
|
||||
stopImmediatePropagation() {
|
||||
ev.immediatePropagationStopped = true
|
||||
},
|
||||
}
|
||||
return ev
|
||||
}
|
||||
|
||||
class FakeElement {
|
||||
readonly tagName: string
|
||||
readonly ownerDocument: FakeDocument
|
||||
readonly style: Record<string, string> = {}
|
||||
readonly dataset: Record<string, string> = {}
|
||||
id = ''
|
||||
className = ''
|
||||
type = ''
|
||||
checked = false
|
||||
value = ''
|
||||
href = ''
|
||||
target = ''
|
||||
rel = ''
|
||||
parentNode: FakeElement | null = null
|
||||
readonly children: FakeElement[] = []
|
||||
private readonly attrs = new Map<string, string>()
|
||||
private readonly listeners = new Map<string, FakeListener[]>()
|
||||
private text = ''
|
||||
private isHidden = false
|
||||
|
||||
constructor(tagName: string, ownerDocument: FakeDocument) {
|
||||
this.tagName = tagName.toLowerCase()
|
||||
this.ownerDocument = ownerDocument
|
||||
}
|
||||
|
||||
get hidden(): boolean {
|
||||
return this.isHidden
|
||||
}
|
||||
|
||||
set hidden(val: boolean) {
|
||||
this.isHidden = Boolean(val)
|
||||
if (this.isHidden) {
|
||||
this.attrs.set('hidden', '')
|
||||
} else {
|
||||
this.attrs.delete('hidden')
|
||||
}
|
||||
}
|
||||
|
||||
get textContent(): string {
|
||||
if (this.children.length === 0) return this.text
|
||||
return this.text + this.children.map((c) => c.textContent).join('')
|
||||
}
|
||||
|
||||
set textContent(val: string | null) {
|
||||
this.children.length = 0
|
||||
this.text = val ?? ''
|
||||
}
|
||||
|
||||
get innerHTML(): string {
|
||||
return this.textContent
|
||||
}
|
||||
|
||||
set innerHTML(val: string) {
|
||||
this.children.length = 0
|
||||
this.text = val
|
||||
}
|
||||
|
||||
setAttribute(name: string, value: string): void {
|
||||
if (name === 'id') this.id = value
|
||||
if (name === 'hidden') this.isHidden = true
|
||||
this.attrs.set(name, value)
|
||||
}
|
||||
|
||||
getAttribute(name: string): string | null {
|
||||
if (name === 'id' && this.id) return this.id
|
||||
if (name === 'hidden') return this.isHidden ? '' : null
|
||||
return this.attrs.get(name) ?? null
|
||||
}
|
||||
|
||||
appendChild(child: FakeElement): FakeElement {
|
||||
if (child.parentNode) {
|
||||
child.parentNode.removeChild(child)
|
||||
}
|
||||
child.parentNode = this
|
||||
this.children.push(child)
|
||||
return child
|
||||
}
|
||||
|
||||
removeChild(child: FakeElement): FakeElement {
|
||||
const idx = this.children.indexOf(child)
|
||||
if (idx >= 0) {
|
||||
this.children.splice(idx, 1)
|
||||
child.parentNode = null
|
||||
}
|
||||
return child
|
||||
}
|
||||
|
||||
addEventListener(type: string, listener: FakeListener): void {
|
||||
const list = this.listeners.get(type) ?? []
|
||||
list.push(listener)
|
||||
this.listeners.set(type, list)
|
||||
}
|
||||
|
||||
removeEventListener(type: string, listener: FakeListener): void {
|
||||
const list = this.listeners.get(type)
|
||||
if (!list) return
|
||||
const idx = list.indexOf(listener)
|
||||
if (idx >= 0) list.splice(idx, 1)
|
||||
}
|
||||
|
||||
dispatchEvent(event: FakeEvent): void {
|
||||
if (!event.target) event.target = this
|
||||
const list = [...(this.listeners.get(event.type) ?? [])]
|
||||
for (const fn of list) {
|
||||
fn(event)
|
||||
if (event.immediatePropagationStopped) break
|
||||
}
|
||||
}
|
||||
|
||||
click(): void {
|
||||
this.dispatchEvent(makeFakeEvent('click', { target: this }))
|
||||
}
|
||||
|
||||
focus(): void {
|
||||
this.ownerDocument.activeElement = this
|
||||
}
|
||||
|
||||
matchesSelector(selector: string): boolean {
|
||||
if (selector === '[data-d2-modal-overlay]:not([hidden])') {
|
||||
return this.attrs.has('data-d2-modal-overlay') && !this.isHidden
|
||||
}
|
||||
if (selector.startsWith('#')) {
|
||||
return this.id === selector.slice(1)
|
||||
}
|
||||
const attrMatch = /^\[([a-z0-9-]+)="([^"]+)"\]$/i.exec(selector)
|
||||
if (attrMatch) {
|
||||
return this.getAttribute(attrMatch[1]!) === attrMatch[2]!
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
querySelector(selector: string): FakeElement | null {
|
||||
for (const child of this.children) {
|
||||
if (child.matchesSelector(selector)) return child
|
||||
const found = child.querySelector(selector)
|
||||
if (found) return found
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
class FakeDocument {
|
||||
readonly head: FakeElement
|
||||
readonly body: FakeElement
|
||||
activeElement: FakeElement | null = null
|
||||
|
||||
constructor() {
|
||||
this.head = new FakeElement('head', this)
|
||||
this.body = new FakeElement('body', this)
|
||||
}
|
||||
|
||||
createElement(tagName: string): FakeElement {
|
||||
return new FakeElement(tagName, this)
|
||||
}
|
||||
|
||||
getElementById(id: string): FakeElement | null {
|
||||
return this.head.querySelector(`#${id}`) ?? this.body.querySelector(`#${id}`)
|
||||
}
|
||||
|
||||
querySelector(selector: string): FakeElement | null {
|
||||
return this.head.querySelector(selector) ?? this.body.querySelector(selector)
|
||||
}
|
||||
}
|
||||
|
||||
class FakeWindow {
|
||||
private readonly captureListeners = new Map<string, FakeListener[]>()
|
||||
private readonly bubbleListeners = new Map<string, FakeListener[]>()
|
||||
|
||||
addEventListener(type: string, listener: FakeListener, capture?: boolean): void {
|
||||
const map = capture ? this.captureListeners : this.bubbleListeners
|
||||
const list = map.get(type) ?? []
|
||||
list.push(listener)
|
||||
map.set(type, list)
|
||||
}
|
||||
|
||||
removeEventListener(type: string, listener: FakeListener, capture?: boolean): void {
|
||||
const map = capture ? this.captureListeners : this.bubbleListeners
|
||||
const list = map.get(type)
|
||||
if (!list) return
|
||||
const idx = list.indexOf(listener)
|
||||
if (idx >= 0) list.splice(idx, 1)
|
||||
}
|
||||
|
||||
dispatchKey(type: 'keydown' | 'keyup', key: string): FakeEvent {
|
||||
const ev = makeFakeEvent(type, { key })
|
||||
for (const fn of [...(this.captureListeners.get(type) ?? [])]) {
|
||||
fn(ev)
|
||||
if (ev.immediatePropagationStopped) return ev
|
||||
}
|
||||
for (const fn of [...(this.bubbleListeners.get(type) ?? [])]) {
|
||||
fn(ev)
|
||||
if (ev.immediatePropagationStopped) return ev
|
||||
}
|
||||
return ev
|
||||
}
|
||||
}
|
||||
|
||||
function makeMenuPlugin(overrides: Partial<D2WebPlugin> & { onAction?: () => void } = {}): D2WebPlugin {
|
||||
return {
|
||||
id: overrides.id ?? 'kill-stats',
|
||||
name: overrides.name ?? { zh: '击杀统计', en: 'Kill Stats' },
|
||||
description: overrides.description ?? {
|
||||
zh: '记录角色击杀数据',
|
||||
en: 'Record character kill statistics',
|
||||
},
|
||||
defaultEnabled: overrides.defaultEnabled ?? true,
|
||||
settings: overrides.settings ?? [
|
||||
{
|
||||
kind: 'boolean',
|
||||
key: 'showToolbarShortcut',
|
||||
label: { zh: '在工具栏显示快捷入口', en: 'Show shortcut in toolbar' },
|
||||
default: true,
|
||||
},
|
||||
{
|
||||
kind: 'select',
|
||||
key: 'theme',
|
||||
label: { zh: '主题', en: 'Theme' },
|
||||
default: 'gold',
|
||||
options: [
|
||||
{ value: 'gold', label: { zh: '暗金', en: 'Gold' } },
|
||||
{ value: 'iron', label: { zh: '玄铁', en: 'Iron' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
actions: overrides.actions ?? [
|
||||
{
|
||||
kind: 'link',
|
||||
id: 'open-page',
|
||||
label: { zh: '打开统计页', en: 'Open Stats Page' },
|
||||
href: 'plugins/kill-stats/index.html',
|
||||
},
|
||||
{
|
||||
kind: 'button',
|
||||
id: 'custom-btn',
|
||||
label: { zh: '执行动作', en: 'Run Action' },
|
||||
run() {
|
||||
overrides.onAction?.()
|
||||
},
|
||||
},
|
||||
],
|
||||
activate:
|
||||
overrides.activate ??
|
||||
(() => ({
|
||||
deactivate() {},
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
describe('PluginMenu (DOM Modal Overlay, Keyboard Isolation & Interactive Controls)', () => {
|
||||
let prevDocument: unknown
|
||||
let prevWindow: unknown
|
||||
let fakeDoc: FakeDocument
|
||||
let fakeWin: FakeWindow
|
||||
|
||||
beforeEach(() => {
|
||||
prevDocument = (globalThis as { document?: unknown }).document
|
||||
prevWindow = (globalThis as { window?: unknown }).window
|
||||
fakeDoc = new FakeDocument()
|
||||
fakeWin = new FakeWindow()
|
||||
;(globalThis as unknown as { document: unknown }).document = fakeDoc
|
||||
;(globalThis as unknown as { window: unknown }).window = fakeWin
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
;(globalThis as unknown as { document: unknown }).document = prevDocument
|
||||
;(globalThis as unknown as { window: unknown }).window = prevWindow
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('mounts hidden [data-d2-modal-overlay="plugins"], toggles isModalDomOverlayOpen, intercepts Escape in capture phase, and returns focus to #d2-toolbar-plugins-btn', async () => {
|
||||
const toolbarBtn = fakeDoc.createElement('button')
|
||||
toolbarBtn.id = 'd2-toolbar-plugins-btn'
|
||||
fakeDoc.body.appendChild(toolbarBtn)
|
||||
|
||||
const storage = new MemoryStorage()
|
||||
const plugin = makeMenuPlugin()
|
||||
const configStore = new PluginConfigStore([plugin], { storage })
|
||||
const buttonStates: { ready: boolean; hasWarning: boolean; open?: boolean | undefined }[] = []
|
||||
const toolbarPort: PluginHostToolbarPort = {
|
||||
setPluginsButtonState(state) {
|
||||
buttonStates.push(state)
|
||||
},
|
||||
addContribution() {
|
||||
return () => {}
|
||||
},
|
||||
}
|
||||
|
||||
const host = new PluginHost({
|
||||
plugins: [plugin],
|
||||
configStore,
|
||||
session: new OnlineSession(),
|
||||
toolbar: toolbarPort,
|
||||
autoStart: false,
|
||||
})
|
||||
await host.start()
|
||||
|
||||
const menu = new PluginMenu({ host, toolbar: toolbarPort })
|
||||
expect(menu.isOpen()).toBe(false)
|
||||
expect(isModalDomOverlayOpen(fakeDoc as unknown as Document)).toBe(false)
|
||||
|
||||
// Register a bubble listener on window to verify capture-phase key interception when modal is open
|
||||
const leakedKeys: string[] = []
|
||||
fakeWin.addEventListener('keydown', (e) => leakedKeys.push(`down:${e.key}`), false)
|
||||
fakeWin.addEventListener('keyup', (e) => leakedKeys.push(`up:${e.key}`), false)
|
||||
|
||||
// Open modal
|
||||
menu.open()
|
||||
expect(menu.isOpen()).toBe(true)
|
||||
expect(isModalDomOverlayOpen(fakeDoc as unknown as Document)).toBe(true)
|
||||
expect(fakeDoc.activeElement?.id).toBe('d2-plugin-menu-close')
|
||||
expect(buttonStates.at(-1)?.open).toBe(true)
|
||||
|
||||
// Pressing a regular key while open is stopped in capture phase and never reaches bubble listeners
|
||||
const spaceEv = fakeWin.dispatchKey('keydown', ' ')
|
||||
const spaceUpEv = fakeWin.dispatchKey('keyup', ' ')
|
||||
expect(spaceEv.immediatePropagationStopped).toBe(true)
|
||||
expect(spaceUpEv.immediatePropagationStopped).toBe(true)
|
||||
expect(leakedKeys).toEqual([])
|
||||
|
||||
// Pressing Escape closes the modal, prevents default, stops propagation, and restores focus to #d2-toolbar-plugins-btn
|
||||
const escEv = fakeWin.dispatchKey('keydown', 'Escape')
|
||||
expect(escEv.defaultPrevented).toBe(true)
|
||||
expect(escEv.immediatePropagationStopped).toBe(true)
|
||||
expect(menu.isOpen()).toBe(false)
|
||||
expect(isModalDomOverlayOpen(fakeDoc as unknown as Document)).toBe(false)
|
||||
expect(fakeDoc.activeElement).toBe(toolbarBtn)
|
||||
expect(buttonStates.at(-1)?.open).toBe(false)
|
||||
|
||||
// When modal is closed, keys reach bubble listeners normally
|
||||
fakeWin.dispatchKey('keydown', 'i')
|
||||
expect(leakedKeys).toEqual(['down:i'])
|
||||
|
||||
menu.destroy()
|
||||
await host.destroy()
|
||||
})
|
||||
|
||||
it('renders plugin cards, boolean/select settings, link/button actions, and updates PluginHost on user interaction', async () => {
|
||||
let actionCount = 0
|
||||
const plugin = makeMenuPlugin({
|
||||
onAction: () => {
|
||||
actionCount++
|
||||
},
|
||||
})
|
||||
const storage = new MemoryStorage()
|
||||
const configStore = new PluginConfigStore([plugin], { storage })
|
||||
const host = new PluginHost({
|
||||
plugins: [plugin],
|
||||
configStore,
|
||||
session: new OnlineSession(),
|
||||
autoStart: false,
|
||||
})
|
||||
await host.start()
|
||||
|
||||
const menu = new PluginMenu({ host })
|
||||
menu.open()
|
||||
|
||||
// Verify link action renders with target="_blank" and relative href
|
||||
const linkEl = fakeDoc.querySelector('[data-plugin-action="kill-stats:open-page"]')
|
||||
expect(linkEl).not.toBeNull()
|
||||
expect(linkEl?.tagName).toBe('a')
|
||||
expect(linkEl?.href).toBe('plugins/kill-stats/index.html')
|
||||
expect(linkEl?.target).toBe('_blank')
|
||||
|
||||
// Verify button action invokes host.runAction
|
||||
const actionBtn = fakeDoc.querySelector('[data-plugin-action="kill-stats:custom-btn"]')
|
||||
expect(actionBtn).not.toBeNull()
|
||||
actionBtn!.click()
|
||||
expect(actionCount).toBe(1)
|
||||
|
||||
// Toggle boolean setting
|
||||
const boolCheckbox = fakeDoc.querySelector(
|
||||
'[data-plugin-setting="kill-stats:showToolbarShortcut"]',
|
||||
)
|
||||
expect(boolCheckbox).not.toBeNull()
|
||||
expect(boolCheckbox?.checked).toBe(true)
|
||||
boolCheckbox!.checked = false
|
||||
boolCheckbox!.dispatchEvent(makeFakeEvent('change'))
|
||||
expect(host.getSetting('kill-stats', 'showToolbarShortcut')).toBe(false)
|
||||
|
||||
// Change select setting
|
||||
const selectEl = fakeDoc.querySelector('[data-plugin-setting="kill-stats:theme"]')
|
||||
expect(selectEl).not.toBeNull()
|
||||
expect(selectEl?.value).toBe('gold')
|
||||
selectEl!.value = 'iron'
|
||||
selectEl!.dispatchEvent(makeFakeEvent('change'))
|
||||
expect(host.getSetting('kill-stats', 'theme')).toBe('iron')
|
||||
|
||||
// Disable plugin via toggle checkbox -> controls row is hidden when disabled
|
||||
const toggleCheckbox = fakeDoc.querySelector('[data-plugin-toggle="kill-stats"]')
|
||||
expect(toggleCheckbox).not.toBeNull()
|
||||
toggleCheckbox!.checked = false
|
||||
toggleCheckbox!.dispatchEvent(makeFakeEvent('change'))
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(host.isEnabled('kill-stats')).toBe(false)
|
||||
expect(
|
||||
fakeDoc.querySelector('[data-plugin-setting="kill-stats:showToolbarShortcut"]'),
|
||||
).toBeNull()
|
||||
|
||||
menu.destroy()
|
||||
await host.destroy()
|
||||
})
|
||||
|
||||
it('displays #d2-plugin-menu-alert on corrupt storage or plugin error and clears it when #d2-plugin-menu-reset is clicked', async () => {
|
||||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const storage = new MemoryStorage()
|
||||
storage.data.set(PLUGIN_CONFIG_STORAGE_KEY, '{corrupt-json')
|
||||
|
||||
let shouldFail = true
|
||||
const plugin = makeMenuPlugin({
|
||||
id: 'flaky-plugin',
|
||||
activate() {
|
||||
if (shouldFail) {
|
||||
throw new Error('Activation failed once')
|
||||
}
|
||||
return { deactivate() {} }
|
||||
},
|
||||
})
|
||||
|
||||
const configStore = new PluginConfigStore([plugin], { storage })
|
||||
const host = new PluginHost({
|
||||
plugins: [plugin],
|
||||
configStore,
|
||||
session: new OnlineSession(),
|
||||
autoStart: false,
|
||||
})
|
||||
await host.start()
|
||||
|
||||
const menu = new PluginMenu({ host })
|
||||
menu.open()
|
||||
|
||||
const alertEl = fakeDoc.getElementById('d2-plugin-menu-alert')
|
||||
expect(alertEl).not.toBeNull()
|
||||
expect(alertEl?.hidden).toBe(false)
|
||||
expect(alertEl?.getAttribute('role')).toBe('alert')
|
||||
expect(alertEl?.textContent).toContain('Corrupt JSON')
|
||||
expect(alertEl?.textContent).toContain('Activation failed once')
|
||||
|
||||
// Fix underlying error and click #d2-plugin-menu-reset
|
||||
shouldFail = false
|
||||
const resetBtn = fakeDoc.getElementById('d2-plugin-menu-reset')
|
||||
expect(resetBtn).not.toBeNull()
|
||||
resetBtn!.click()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(host.hasErrorOrCorruption()).toBe(false)
|
||||
expect(alertEl?.hidden).toBe(true)
|
||||
|
||||
menu.destroy()
|
||||
await host.destroy()
|
||||
errSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue