# Changelog All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] ## [5.5.0] - 2026-10-03 ### Added - **性能测试套件**([#137](https://github.com/wontlost-ltd/vaadin-ckeditor/issues/137)): Playwright 性能套件(初始化、同页多实例、大文档、挂载 / 卸载泄漏、首屏)、 `perf/budgets.json` 集中预算、`perf.yml` CI 工作流,以及服务端开销测试 `ServerFootprintTest` / `SerializationTest`。详见 `e2e/README.md`「性能测试」。 ### Fixed - **`frontendHotdeploy=true` 下编辑器白屏转圈**([#120](https://github.com/wontlost-ltd/vaadin-ckeditor/issues/120))。 **更正 5.4.1 的结论**:该问题并非由 Vaadin 25.3.0 修复,也不是缺包。连接器以源 TS 分发, hotdeploy 下由 Vite 实时转译,rolldown 把 Lit 装饰器降级为从 `@oxc-project/runtime` 导入的 `_decorate` helper;该版本编码的路径在非根 servlet context path(如 `/App`)下走不通 `/VAADIN/@id/` 开发代理而 404。现在改用 Lit 原生的静态 `properties` + `customElements.define`,转译产物不再含装饰器。反应式字段以 `declare` 声明、默认值在构造器 中赋值,使其在消费者 tsconfig 的任一 `useDefineForClassFields` 取值下都保持反应性 (Vaadin 25.2 及更早不为 `jar-resources` 生成 tsconfig)。重复注册 `vaadin-ckeditor` 时改为告警而非抛错。已在 `-Photdeploy` + context path `/App` 下实机验证。 - **卸载时泄漏整个编辑器**:组件从 DOM 断开时此前跳过 `editor.destroy()`、寄望 GC 回收, 但 CKEditor 挂在 window / document 上的定时器与监听器使其永远无法回收——每次导航、 每次把编辑器移出布局都泄漏一个完整实例(约 88 个 DOM 节点、88 个监听器、800 KB 堆)。 现在所有卸载路径都真正销毁实例(带超时兜底),且超时计时器在销毁完成后即被清除。 - **大文档下发与回传重复传输**:`setValue()` 的内容此前经属性与 `executeJs` 各传一份, 客户端 `setData` 执行两次;客户端每次变更又把文档以「旧内容 + 新内容 + 新内容」三份回传。 现在下发与回传各只传一份(旧内容由服务端镜像补全;服务端改值或协作变更之后的第一次上报 仍显式携带旧内容,避免被 disabled / inert 丢弃的上报使镜像过期), 1 MB 文档 `setValue` 由约 5.5 s 降至约 1.4 s。 - **`setMinimapEnabled(true)` 不生效**:Minimap 插件被「需要特殊配置」规则自动剔除, 而其必需配置正是由连接器注入的。现在 decoupled 编辑器启用 minimap 时自动加载该插件。 - **组件无法随 Vaadin 会话序列化**:`CKEditorConfig`、内部管理器与处理器接口未实现 `Serializable`,会话持久化 / 集群复制时抛 `NotSerializableException`。现在可完整往返; 不可序列化的 autosave 回调会被丢弃并记录警告,而不再拖垮整个会话。 ### Changed - `ContentChangeEvent` 仅在注册了监听器时由客户端上报(此前无论有无监听器都发送整份文档)。 事件内容与触发顺序不变;客户端 ↔ 服务端的内部 RPC `fireContentChange` 由合并后的 `syncContent` 取代(同一 jar 内的前后端协议,无需迁移)。 - `HtmlSanitizer`、`ErrorHandler`、`UploadHandler` 现继承 `Serializable`(源码兼容; lambda 自动成为可序列化)。`HtmlSanitizer.withSafelist(...)` 因 jsoup `Safelist` 不可序列化,返回的净化器仍无法随会话序列化,已在 Javadoc 注明。 ## [5.4.1] - 2026-09-26 ### Changed - **显式 import `junit-bom`**(置于 `vaadin-bom` 之前),JUnit 版本改由新增的 `${junit.version}` 单点控制,依赖声明处不再写死版本。 这是**预防性加固,非缺陷修复**:已实测当前 `vaadin-bom` 25.3.0 不管理 JUnit, 单写依赖声明可正常工作,把版本提到 6.1.3 时六个构件也能齐平、563 项测试全通过。 加固的动机是将来一旦引入任何管理 JUnit 的 BOM(如 `spring-boot-dependencies`, 其 `junit-jupiter.version` 会覆盖依赖声明处的版本——同批次的 `vaadin-agui` 正因此把声明的 6.1.3 静默解析成 6.0.3),版本就会分裂成混合状态, surefire 随即因 JUnit jar 版本不一致而 `NoClassDefFoundError`、测试一个都跑不起来。 该类失败只在 `mvn verify` 下暴露,`mvn test` 不触发,排查成本高,故预先固化。 本次解析版本维持 6.0.3 不变,无行为变更。 ### Security - **`vitest`、`@vitest/coverage-v8`:4.1.9 → 4.1.11**(devDependency)。 修复 `@vitest/mocker` 的 Path Traversal / Arbitrary File Read 公告 (影响范围 2.1.0 – 4.1.10),`npm audit` 由 3 moderate 降为 **0**。 取 4.1.11 而非 Dependabot 提议的 5.0.1:前者是 4.x 线内的最小修复版本, 不跨 major,避免为一个仅影响开发期依赖的公告引入测试框架的破坏性变更。 该依赖不随 jar 分发,`npm audit --omit=dev` 此前即为 0 vulnerabilities, 故对消费端无影响。 ### Changed - **Vaadin Platform:25.2.6 → 25.3.0**(根 `pom.xml`、`ckeditor-vaadin-testbench/pom.xml`、 `examples/spring-boot-sample/pom.xml`)。 - **CKEditor 5(`ckeditor5`、`ckeditor5-premium-features`):48.5.0 → 48.5.2**。 patch 版本升级。同步更新全部版本锚点:前端 `package.json` 与 `package-lock.json`、 `@NpmPackage` 注解(`VaadinCKEditor`、`VaadinCKEditorPremium`)、 `VaadinCKEditorPremium.getVersion()` 返回值、示例应用 `package.json` (由 `vaadin:build-frontend` 重新生成)。 已复核 issue #122 的修复前提在 48.5.2 下依然成立:逐字节比对 `@ckeditor/ckeditor5-core` 48.5.0 与 48.5.2 的 `dist/index.js`,二者**完全相同** (两版本间仅 `package.json` 与 `LICENSE.md` 有差异),故 `Context#_removeEditor` 与 `Collection#add` 的行为未变,该修复继续必要且有效。 ### Fixed - **Vaadin 25.3.0 的 `vaadin-bom` 不再 import `tools.jackson:jackson-bom`**, 导致本项目 `tools.jackson.core:jackson-databind`(不写 version,依赖 BOM 管理) 失去版本来源、Maven 构建直接失败(`'dependencies.dependency.version' ... is missing`)。 修复方式为在 `dependencyManagement` 中显式 import `tools.jackson:jackson-bom`, 版本由新增的 `${jackson3.version}` 属性单点控制,取值 3.1.5 —— 与 `vaadin-core:25.3.0` 传递引入的 Jackson 版本一致,保持跟随 Vaadin 的安全基线, 同时延续“不在依赖声明处写死版本”的既有约定(避免把消费端拉回旧版)。 该问题由 Matti Tahvonen 在 PR #129 中独立指出("Vaadin 25.3 will stop declaring that"), 本处采用 BOM import 而非在依赖声明处写死版本,两者解析到的版本一致。 - **`frontendHotdeploy=true` 下 UI 无限转圈**(issue #120)——**由 Vaadin 25.3.0 修复,本库无需改动**。 > **更正(5.5.0)**:此结论有误,报告者在 25.3.0 上仍可复现。真正的根因与修复见 [5.5.0]。 根因是 rolldown 会把 TS 装饰器降级为 `@oxc-project/runtime` 的辅助模块 import, 但该包此前**无人声明**(rolldown 只依赖 `@oxc-project/types`),dev server 模式下 辅助模块 404。Vaadin 25.3.0 起已在生成的 `package.json` 中自行声明 `@oxc-project/runtime`,该包现已存在于依赖树中。 已用示例应用的 `-Photdeploy` 档实机验证(Vite 8.3.0 / rolldown 1.2.9): 连接器实际发出的 import 为 `.../@oxc-project+runtime@0.150.0/helpers/esm/decorate.js`, 返回 **200**;浏览器打开 `/classic` 后编辑器正常渲染并可输入, 无失败请求、无 console 错误。故先前考虑的 `@NpmPackage("@oxc-project/runtime")` 固定版本方案**不再需要**——那样反而会把版本钉死、与 Vaadin 自带声明冲突。 ## [5.4.0] - 2026-09-06 ### Changed - **CKEditor 5(`ckeditor5`、`ckeditor5-premium-features`):48.4.0 → 48.5.0**。 minor 版本升级,官方 changelog 的 48.5.0 段落**未列出任何 breaking change** (其余 MINOR BREAKING CHANGES 小节均属更早版本)。新增内容集中在 premium 侧: AI Chat 的 GHS 感知与 Context Library、Revision History 的 `showAISource`; 免费侧仅表格缺陷修复(列宽在有纵向滚动条时缩水、嵌套表最后一列 resize 撑满父表、 GHS 保留 `content-table`/`layout-table` 类)。 同步更新四处版本锚点:前端 `package.json`、`@NpmPackage` 注解(`VaadinCKEditor`、 `VaadinCKEditorPremium`)、`getPremiumVersion()` 返回值、示例应用 `package.json`。 已复核 issue #122 的修复前提在 48.5.0 下依然成立:`Context#_removeEditor` 实现逐字未变, `Collection#add` 仍会为无 `.id` 的实例自动生成 `uid()` 并写回(实测 `has()` 在覆盖 id 后 仍失配),故该修复继续必要且有效。`npm audit` 报告 0 vulnerabilities。 ### Fixed - **换父容器时 `destroy()` 无限递归卡死标签页**(前端,严重,issue #122)。 连接器在 `Editor.create()` 完成后覆盖了 editor 实例的 `.id`,但该实例在**构造期间** 就已注册进自己的私有 Context:`Context#_addEditor` → `Collection#add` → `_getItemIdBeforeAdding()` 发现新实例没有 `.id`,会自动生成 `uid()` **写回实例** 并以该值为键存入 `_itemMap`。事后覆盖 `.id` 使实例与其在 `_itemMap` 中的键脱钩: `destroy()` → `Context#_removeEditor()` → `editors.has(editor)` 失配 → `remove()` 从不执行 → 编辑器永远留在 `context.editors` → `_contextOwner === editor` 成立而 调用 `Context#destroy()` → 后者遍历仍含该编辑器的 `editors` 再次 `destroy()` → 无限递归。该递归跨 `await` 边界(`Context#destroy` 用 `Promise.all`), 因此表现为微任务饥饿、主线程被占满,而**不是** `RangeError` 栈溢出。 触发场景:编辑器在**保持连接**的前提下被换父(如 Vaadin 在同一次往返内做 `SplitLayout` 槽位重分配),此路径会真正调用 `editor.destroy()`。 修复方式是移除该赋值——`editor.id` 在整个代码库中从无读取方,属死代码; 组件侧 `editorId`(DOM id、只读锁 id、上传 id)契约完全不变,无迁移步骤。 已对照 CKEditor 48.4.0 源码逐行核实根因,并新增单测与 E2E 双重回归。 - **上传适配器猴补丁从不还原导致编辑器泄漏**(前端,严重)。 `setupCustomUploadAdapter()` 把自建工厂写到 CKEditor `FileRepository` 插件的 `createUploadAdapter` 上,该工厂闭包捕获了 `this`(Lit 元素),形成 「插件 → 闭包 → 组件 → 整棵 DOM 子树」的引用链,而全仓无任何还原代码。 由于 `destroyEditor()` 在组件已断开时会跳过 `editor.destroy()`(交给 GC), CKEditor 自身不拆卸插件,这条链会把整个编辑器钉住——每次路由往返泄漏一个实例。 修复:捕获原值并登记还原动作到 `listenerCleanups`,该数组在 `destroyEditor()` 的 `isDisconnected` 提前 return **之前**被无条件遍历执行。还原成 `undefined` 是安全的: CKEditor 的 `createLoader()` 有 `if (!this.createUploadAdapter)` 守卫,仅告警返回 null。 该缺陷与 issue #122 正交,由全仓扫描顺带查出。 ### Documentation - 修正 `MediaEmbedResize` 可用性的描述(`media-embed-resize.ts`、`CKEditorConfig#setMediaEmbedResizable`)。 原注释称该插件"由 umbrella `ckeditor5` 包导出,type 与 runtime 两层均可解析", 但实测 **48.4.0 与 48.5.0 的 umbrella 产物中均不含该符号**(`dist/index.d.ts` 与 `dist/ckeditor5.js` 两层皆无)。这意味着 `loadMediaEmbedResizePlugin()` 的 `?? null` 分支是常态而非异常路径——启用 `setMediaEmbedResizable(true)` 会静默降级为不可缩放。 这是既有描述失准,非本次升级引入;代码行为本身正确(优雅降级、不阻断编辑器创建),故仅修文档。 ### Added - DevTools 手工排查指引(`docs/USER_GUIDE.md`、`docs/QUICK_REFERENCE.md`): `$0.editorId` / `$0.editor` / `getData()` / 按 id 反查 / 列出页面全部编辑器。 并说明 `editor` 虽在 TypeScript 中声明为 `private`,但那只是编译期约束, 运行时(含生产构建)可正常访问;以及 `editor.id` 是 CKEditor 内部 id, 识别编辑器应使用 `$0.editorId`。全部片段已在生产构建上实机验证。 - 澄清 `window.VAADIN_CKEDITOR_DEBUG` 在模块加载时求值一次,需**先设置再刷新页面**。 ## [5.3.3] - 2026-08-26 ### Fixed - **builder 配置的 `ErrorHandler` 从不触发**(严重)。`setErrorHandlerInternal()` 只写字段, 未像公开 setter 那样把 handler 接到 `EventDispatcher` 上,导致 `.withErrorHandler(h).build()` 之后 `h` 永远不会被调用——消费端的错误处理静默失效。 修复方式与 `contentManager`、`uploadManager` 保持一致:在 `initializeManagers()` 中统一从字段装配。实测:修复前 builder 路径回调 0 次 / setter 路径 1 次,修复后均为 1 次。 - **`getToolbarStyle()` 丢失全部 per-button 样式**(严重)。重建时只读取 9 个标量字段而漏掉 `buttonStyles`,使 `setToolbarStyle(getToolbarStyle())` 这类「读-改-写」永久销毁按钮样式。 实测修复后 round-trip 完全保真。 - **三个内置 preset 在 STRICT 依赖模式下无法构建**(严重)。补齐 `AI_DOCUMENT`(缺 `CLOUD_SERVICES_CORE`)、`EMAIL`(缺 `CLIPBOARD`)、 `NOTION`(缺 `CLIPBOARD`/`WIDGET`/`WIDGET_TOOLBAR_REPOSITORY`)。 实测:修复前 9 个 preset 有 3 个在 STRICT 下抛 `IllegalStateException`,修复后全部可构建。 - `validateDependencies()` 改为按**传递闭包**报告缺失项。原实现只检查直接依赖, 与传递解析的 `resolve()` 在诊断范围上不一致:例如只声明 `SIMPLE_UPLOAD_ADAPTER` 时 原先仅报 `IMAGE_UPLOAD`,遗漏其再依赖的 `IMAGE`。 这是**诊断信息的完善,不是判定口径收紧**——已枚举全部插件验证: 「原先通过、现在被拒」的数量为 0(若直接依赖已满足,其依赖必然也在集合中)。 - **上传去重集合只增不减**。`notifiedUploadIds` 在正常完成路径从不清理, 长会话下持续泄漏。新增 `retireUpload()` 在终态释放登记信息。 实测:200 次上传后集合从 200 降为 0,且同一 ID 再次上传可正常回调。 注意 early-failure 路径**不**释放,以维持既有的「同一 uploadId 只通知一次」契约。 - **编辑器重挂载后永不重建**(前端,严重)。创建逻辑绑在 `firstUpdated()`(Lit 每实例只调一次), 而 `disconnectedCallback` 会销毁编辑器,导致「移出 DOM 再放回」后只剩空白容器 (Vaadin 中 `remove()/add()`、布局间移动、Tab 切换等场景常见)。改为在 `connectedCallback` 中按需重建。 - **异步创建期间断开会留下孤儿编辑器**(前端,严重)。`create()` 解析可能晚于 `disconnectedCallback`,而结果被无条件赋值给 `this.editor`,此时已无人负责销毁—— 每次路由往返泄漏一个完整编辑器实例。改为 await 之后重新检查连接状态并立即销毁孤儿。 - **标注侧栏 scroll 监听从未注销**(前端)。每次 `onEditorReady` 都会叠加监听器, 造成滚动卡顿与组件泄漏;相邻的 `MutationObserver` 已有防叠加处理,此处补齐。 - **属性路径写回 `editorData` 会绕过 `apiChangeDepth`**(前端)。直接调用 `setData` 会让 服务端推送的内容被判定为用户输入并回传服务端(issue #38 的问题经此路径复现), 且跳过源码视图刷新(issue #57)。改为统一走 `updateData()`。 - `CKEditorConfig.configs` 改为同步 Map。实测 8 线程并发写入会静默丢失约 3 万次写入且不抛异常。 - `toJson()` / `getConfigs()` 改为返回深拷贝。此前返回值与内部配置共享可变子节点, 调用方修改「快照」会反向污染真实配置。 - `firstUpdated` 中 `createEditor()` 的 unhandled rejection 兜底。 以下三项由交叉审查(Codex)发现,均为上述修复的边界遗漏,已一并解决: - **同一 tick 内 remove→add 时重建被跳过**。销毁被延迟到 microtask,而实测回调顺序是 `disconnectedCallback → connectedCallback → destroy microtask`,故 `connectedCallback` 执行时 `editor` 尚未清空、重建守卫直接返回,随后编辑器被销毁却无人重建。 改为在销毁完成后补判一次(`recreateEditorOnReconnect()` 由两处共用)。 - **「创建中断开、创建结束前又重连」漏唤醒**。孤儿销毁路径不经过 `destroyEditor()`, 拿不到上述 microtask 补偿;而 `connectedCallback` 早已在 `isCreating=true` 时返回。 改为在孤儿销毁后同样补触发一次重建判定。 - **同一 uploadId 重复登记时会互相删除**。`UploadManager` 的清理按裸 `uploadId` 进行, 一旦同一 ID 上先后存在两个 task(服务端不校验 ID 唯一性,且 `handleUpload` 会 无条件覆盖登记),先结束的一方会把另一方从 `activeTasks` 中移除, 导致后者无法取消、状态查询失效。所有 `activeTasks.remove` 改为两参数原子删除 (仅当映射仍指向本次 task 时才删);重复通知守卫的键也由裸 uploadId 改为 **`UploadTask` 实例本身**,按实例隔离——释放时机因此不再需要 「该 ID 是否还有活跃任务」这类非原子判断,窗口期问题随之消失。 说明:前端当前**不会**产生重复 ID——`UploadAdapterManager` 实例在组件重挂载后 被复用(`if (!this.uploadManager)`),其计数器不随 `cleanup()` 重置, 已实测确认 `cleanup()` 前后为 `upload-ed-1` → `upload-ed-2`。 因此这些修复属于服务端的**纵深防御**:`handleUpload` 是 `@ClientCallable`, ID 由客户端提供、服务端不应假定其唯一。 - **孤儿销毁后的补偿重建时机错误**。原先用 `queueMicrotask` 触发,实测该 microtask 会排在外层 `async` 函数的 `finally` **之前**,届时 `isCreating` 尚未复位、 守卫直接返回,补偿失效、组件仍永久空白。改为置标志位、由 `finally` 在释放 创建锁之后消费。 - **创建锁存在并发窗口**。`canCreateEditor()` 检查与 `isCreating = true` 之间隔着 `await waitForPreviousEditorCleanup()`,两个并发调用者可能都通过检查并各建一个 编辑器。改为先占锁再 await,并让守卫只依据 `isCreating`(`createPromise` 直到 await 之后才赋值,要求两者同时成立会在该窗口内放行第二个调用者)。 - **失败路径仍会强引用整个 `UploadTask`**。handler 返回 null 或同步抛异常时 `notifyError` 带 task 调用,而该路径不经过 `retireUpload`,守卫集合会一直持有 该 task。已补充释放。 - **有界等待的挂起被换了个入口**。第一次修复给孤儿销毁加了超时防挂起,但随后 让 `waitForPreviousEditorCleanup()` 无界 `await` 同一个 promise——detached 下 CKEditor 的 `destroy()` 可能永不 settle,于是 `isCreating` 被永久占用、重连后 依旧空白。改为等待也走 `Promise.race` 超时。 - **迟到的销毁会清空新编辑器**(DOM 所有权)。`Promise.race` 只停止等待、**不取消** 底层 `destroy()`;而 CKEditor 的 `ElementApiMixin.updateSourceElement()` 末尾执行 `setDataInElement(this.sourceElement, ...)`,其实现是 `el.innerHTML = data` (见 `@ckeditor/ckeditor5-utils`)——写回目标正是我们传给 `Editor.create()` 的容器, 而重连复用同一节点(`editorId` 是 `@property`)。因此超时后新增 `detachStaleEditorContainer()`:用同 id、同 class 的**新节点**替换容器, 陈旧实例持有的成为游离节点,其写回不再影响页面。 新容器的 class 取自常量 `EDITOR_CONTENT_CLASS`(同时用于 render() 的三处模板), 而非沿用 `stale.className`——后者可能已被 CKEditor 注入运行时 class。 - **换节点后 Lit 未重新绑定**。仅调用 `requestUpdate()` 不够:Lit 3 的模板实例在首次 克隆时就绑定了 `AttributePart`,不会重新扫描被外部 `replaceChild` 的节点。 实测此后把 `editorId` 改为新值,Lit 会写到**已游离的旧节点**上。该路径可达 (`setId()` 是公开 API)。改为先 `render(nothing, renderRoot)` 丢弃旧模板实例 再触发更新,实测动态更新可正确落到页面节点。 - **`destroyPromise` 的清空未按身份比对**。`destroyEditor()` 中无条件置 null, 会抹掉后登记的孤儿销毁,重新打开上述所有权漏洞。已改为按身份比对。 ### Added - `setSanitizeOnInput(boolean)` / `withSanitizeOnInput(boolean)`:可选的「客户端输入即净化」。 背景:本组件继承 `CustomField`,`Binder` 读取的是 `getValue()`, 而 `setHtmlSanitizer()` 仅作用于 `getSanitizedValue()`——即配置了净化器 + 使用 Binder 的用户实际得不到任何净化。开启本开关后,客户端内容在写入模型时即被净化, `getValue()`、Binder 与 `ValueChangeEvent` 均受保护;服务端自行 `setValue()` 的内容 视为可信来源,不受影响。**默认关闭以保持向后兼容**,并已在 `getValue()` 的 Javadoc 中 明确标注其返回未净化内容及对 Binder 的影响。 ### Changed - **CI 现在运行前端测试**:新增 `frontend` job 执行 `npm ci && npm run typecheck && npm test`。 此前 171 个 vitest 用例与 `tsc --noEmit` 从未在 CI 中运行过(Maven 只把 `.ts` 当资源复制), 类型错误会直到消费端 Vaadin 构建时才暴露。CI 同时开始编译 testbench 模块 (该模块不在根 reactor 中,此前完全无覆盖),并在发布 tag 上触发。 - **发布增加 CI 门禁**:`publish.yml` 与 `publish-testbench.yml` 新增 `verify-ci` 前置 job, 断言待发布 commit 在 `main` 上且其 CI 结论为 success。Maven Central 不可撤回, 此前 tag 一推即发布,且 `workflow_dispatch` 可从任意分支触发。 同时把 testbench 工作流中更严格的全串 SemVer 校验回填到主工作流。 - **发布产物不再包含测试资产**:`maven-jar-plugin` 补充排除 `*.test.ts`、`test-mocks/`、 `vitest.config.ts`(此前约占 jar 的 46%,且 import 消费端不会安装的 vitest); `maven-source-plugin` 补充独立的 excludes——它不继承 jar-plugin 配置,本地构建 (存在 node_modules)产出的 sources jar 达 49MB/约 2 万个文件。 实测 sources jar 由 49.6MB 降至 187KB。 ### Tests - 新增 `VersionSyncTest`:把 6 处版本常量的漂移变成构建失败。此前唯一约束是一句注释, 发布流程也只校验 tag↔pom 一对关系,实践中已发生过漏改。 - 净化器测试补充判别性断言与 XSS 向量覆盖。原断言「`