资讯动态

Pulse 前端未保存编辑保留(Unsaved Edit Retention)验证指南:基于真实设置父组件与脚本化 API 的浏览器级测试实践

发布时间:2026/10/9 1:22:35 来源:尧图企业网站定制
可观测性运维后端【免费下载链接】PulseReal-time monitoring dashboard for Proxmox VE, PBS, Docker, Kubernetes, TrueNAS and vSphere. Self-hosted, with smart alerts and AI patrols that catch silent failures.项目地址https://gitcode.com/gh_mirrors/pulse27/Pulse点击查看免费下载导读本文围绕 Pulse 仓库中 docs/qualification/recovery-feedback/unsaved-edit-retention.md 记录的一次设置页Settings父级作用域验证展开它不再用合成的 reactive 父组件包裹子页签而是直接挂载真实的AlertsConfigurationSurface让配置快照、overrides、目标Destinationshooks 全部走真实状态机只对 API 边界与外部资源/激活输入进行脚本化。读者可以从中获得一套「保留中的用户编辑不被投递恢复反馈打断」的真实浏览器回归测试方法论并了解 Pulse 前端useAlertsConfigurationState状态机、dead-man ping URL 输入、dirty 信号与保存路径的完整行为。适用范围与前提本文描述的是仅测试层面的验证isolated browser qualification不包含安装后端、真实投递、接收回执、App 外壳导航或无障碍技术screen reader测试也不构成 WCAG 合规性声明。运行环境需要仓库根目录下已安装 Vite、Solid、Playwright 等前端依赖。一、验证目标为什么「未保存编辑」必须由父组件来保留Pulse 前端采用 SolidJSAlertsConfigurationSurface.tsx 是告警配置页含 Overview、Destinations、Schedule、Thresholds 等页签的真实父组件。它通过 useAlertsConfigurationState.ts 统一管理useAlertsConfigurationSnapshotState全局配置快照thresholds/overrides 等useAlertDestinationsStateEmail、Apprise、Webhook、External watchdogdead-man ping URL等目标配置useAlertOverridesState资源级 overridesguardedSetHasUnsavedChanges带抑制机制的 dirty 信号suppressDirtyFlag生效期间拒绝把状态置脏。在投递恢复反馈AlertQueueActionFeedback.tsxroleregion且aria-labelNotification recovery feedback与设置编辑并存时真正的风险是用户正在填写 Healthchecks 兼容成功 ping URL此时恢复反馈反复出现「Retry retained deliveries / Dismiss retained failures / Clear recovery message」如果这些交互触发了配置重新加载或保存用户的未保存输入就会丢失。因此验证必须回答dirty 信号、输入框值与 unsaved-changes 横幅在整套反馈交互中是否原样保留「未保存编辑保留」正是 WCAG 2.2 redundant-entry 指南 所鼓励的输入保留理念——它降低用户回忆并重新输入信息的成本和出错概率。该文档仅以此作为测试设计的独立理由rationale不主张新增产品需求也不作 WCAG 合规性声明。二、测试结构真实设置表面 脚本化 API 边界验证脚本为 scripts/check-recovery-feedback.mjs。它的核心思路是把「哪些部分必须真实、哪些部分可以脚本化」划分得非常明确层处理方式OverviewTab/AlertsConfigurationSurface真实挂载含配置加载、状态与 dirty 信号配置快照、overrides、目标 hooks不 mock走useAlertsConfigurationState真实状态机API 边界与外部资源/激活输入脚本化AlertsAPI.getConfig/updateConfig/getDeadManConfig/updateDeadManConfig、NotificationsAPI.getEmailConfig/getAppriseConfig/...等外部External watchdog状态、投递健康通过window.health、window.action两个哨兵变量驱动2.1 挂载真实父组件的 Fixture脚本在内存中构造一个 SolidJS 应用ViteconfigureServer中间件拦截/qualification路径返回一个/recovery-fixture.tsx模块关键片段function Destinations() { const [unsaved, setUnsaved] createSignal(false); window.unsaved unsaved; return AlertsConfigurationSurface activeTab{() destinations} allResources{() []} byType{() []} children{() []} activeAlerts{{}} removeAlerts{noop} setOverviewOverrides{noop} hasUnsavedChanges{unsaved} setHasUnsavedChanges{setUnsaved} alertsActivationState{() active} alertsActivationConfig{() ({ enabled: true })} /; }注意这里hasUnsavedChanges这个 dirty 信号本身仍由 Fixture 提供createSignal(false)并暴露到window.unsaved而生成 dirty 信号的逻辑——即guardedSetHasUnsavedChanges以及输入框的setHasUnsavedChanges(true)调用——全部来自真实父组件与真实子组件。也就是说「脏状态如何产生」是真实的「脏状态存哪里」是 Fixture 提供的容器这正是文档所说的「enclosing fixture supplies the dirty signal as the application shell would」。2.2 脚本化的 API 边界AlertsAPI.getConfig async () { window.configReads; return { overrides: {} }; }; AlertsAPI.getDeadManConfig async () ({ pingUrl: https://example.invalid/original }); AlertsAPI.updateConfig async (value) { window.configWrites.push(value); return { success: true }; }; AlertsAPI.updateDeadManConfig async (value) { window.savedPingUrls.push(value); return {}; };window.configReads、window.configWrites、window.savedPingUrls、window.mutations四个计数器用于在每次 checkpoint 断言「配置只读了一次、从未被写、ping URL 从未被保存」——这是证明「未保存编辑未被副作用吞掉」的关键证据。三、输入对象被掩码的 Healthchecks ping URL验证的编辑对象是 Destinations 页「External watchdog」板块中的Healthchecks-compatible success ping URL输入框位于 AlertDeadManDestinationSection.tsx。该输入框的既有特性包括类型掩码type{showUrl() ? text : password}默认以密码框形式展示旁边有Show / Hide按钮切换aria-pressed状态已配置值时留空当存储值是***REDACTED***时输入框显示空值并显示 placeholder「Configured — enter a new URL to replace」否则 placeholder 为https://hc-ping.com/…长度限制maxLength{2048}autocompleteoffspellcheck{false}输入即置脏onInput同时调用setPingUrl(...)与setHasUnsavedChanges(true)input[id^alert-deadman-url-]前缀输入框 id 由createUniqueId()生成前缀固定为alert-deadman-url-。这段历史对测试有直接影响文档明确记录「早期的尝试因为测试按typeurl寻找输入框而超时因为既有字段是掩码的masked」最终通过选择既有 id 前缀解决 fixture 错误且未改动任何运行时源码。四、12 个用例的完整执行矩阵与步骤脚本按以下矩阵执行 12 个用例视口宽度: 1440 / 900 / 390 (像素, 高度固定 1000) 页面表面: overview / destinations 主题: light / dark其中destinations表面覆盖 6 个「设置父级编辑/保存」用例1440/900/390 × light/dark也就是unsavedEditCases: 6。4.1 Destinations 表面的编辑准备在进入反馈交互之前脚本先等待真实配置/目标加载完成await page.waitForFunction(() document.querySelector(input[id^alert-deadman-url-])?.value https://example.invalid/original, ); assert.equal(await page.evaluate(() window.unsaved()), false); await pingInput.fill(https://example.invalid/unsaved-recovery-check); await assertEditRetained();即先等待输入框从脚本化的getDeadManConfig加载出原始值确认 dirty 为false然后填入合成的新 URL此时 dirty 变为true。4.2 每个 checkpoint 的保留断言assertEditRetained是整套验证的基石const assertEditRetained async () { if (surface ! destinations) return; assert.equal(await pingInput.inputValue(), editedUrl); assert.equal(await page.evaluate(() window.unsaved()), true); assert.equal(await page.getByText(You have unsaved changes, { exact: true }).count(), 1); assert.equal(await page.evaluate(() window.configReads), 1); assert.deepEqual(await page.evaluate(() window.configWrites), []); assert.deepEqual(await page.evaluate(() window.savedPingUrls), []); };它在以下五个交互节点之后分别执行被拒绝的重试与 toast 到期键盘 Enter 触发 Retry → 脚本化 API 抛错window.actionreject→ 出现「Unable to retry retained notification deliveries.」→page.clock.fastForward(12000)加速 12 秒让错误 toast 真实过期并跑完退出动画350ms/400ms被取消的关闭dismissacceptfalse点击 Dismiss 触发原生确认框并dialog.dismiss()断言window.mutations不变、错误消息仍在接受的重试 不可用的健康刷新accepttrue且window.healtherror此时重试成功但健康读取失败 → 出现「Refresh delivery status」按钮恢复反馈按钮清零错误消息被替换随后的恢复、失败的关闭、健康卡片移除与消息清除window.healthdegraded恢复快照 → 点击 Refresh → Dismiss 再次失败 → 在 destinations 表面上将window.healthhealthy后再次 Refresh健康卡片 detach仅保留「Unable to dismiss...」消息最后键盘 Enter 点击「Clear recovery message」并断言焦点回到反馈区域反馈文本不溢出视口用Range.getClientRects()断言反馈文本与控件位于视口内left 0 right innerWidth。在全部 5 类交互之后assertEditRetained都证明配置只读过一次、全局配置从未写入、ping URL 从未被保存、dirty 仍为 true、输入框值不变、unsaved-changes 横幅仍在。4.3 最终保存证明「保留值」走真实父保存路径交互全部结束后脚本点击真实的Save Changes按钮注意仅 destinations 表面、仅此一次并断言await page.getByRole(button, { name: Save Changes, exact: true }).click(); await page.waitForFunction(() window.savedPingUrls.length 1 !window.unsaved(), ); assert.deepEqual(await page.evaluate(() window.savedPingUrls), [editedUrl]); assert.equal(await page.evaluate(() window.configWrites.length), 1); assert.equal(await pingInput.inputValue(), editedUrl); assert.equal(await page.getByText(You have unsaved changes, { exact: true }).count(), 0);保存路径对应真实代码 useAlertsConfigurationState.ts 的 saveAlertConfiguration先AlertsAPI.updateConfig(result.alertConfig)全局配置写一次再destinationsState.saveDestinations()其中将 dead-man ping URL 经AlertsAPI.updateDeadManConfig发送最后setHasUnsavedChanges(false)清除 dirty。这与断言完全吻合配置写了一次、URL 精确等于编辑值、dirty 归零。五、运行方式与产物在仓库根目录执行pulse-heavy-run -- node scripts/check-recovery-feedback.mjs最终运行通过全部 12 个用例、零页面错误errors数组为空并输出到 docs/qualification/recovery-feedback/settings-parent-result.json{result:passed,cases:12,unsavedEditCases:6,...}本次 settings-parent 验证的结果browser-result.json早期 12 用例OverviewTab DestinationsTab共享 toast/feedback的结果settings-parent-source.sha256本次脚本内容的摘要标识12 张截图{width}-{surface}-{theme}.png如1440-destinations-light.png1440×2600、390-destinations-light.png390×3091用于人工检查反馈文本可读性与控件可达性failed.png早期失败尝试的留档非通过结果。质量闸门还包括node --check语法与git diff --check空白/冲突标记通过。下方是 1440px 宽屏与 390px 窄屏下 Destinations 表面的代表性运行截图六、实现原理对照dirty 信号的守卫与保存时序把测试断言映射到真实实现可以看到两个关键机制1.guardedSetHasUnsavedChanges抑制机制useAlertsConfigurationState.ts 第 49-52 行const guardedSetHasUnsavedChanges (value: boolean) { if (value suppressDirtyFlag()) return; props.setHasUnsavedChanges(value); };loadAlertConfiguration在重载期间会setSuppressDirtyFlag(true)并先setHasUnsavedChanges(false)加载完成后用queueMicrotask恢复。这保证「加载/放弃配置」不会误触发 unsaved 状态也是测试中「配置只读一次、编辑值始终保留」能够成立的结构性前提。2. 保存时序saveAlertConfiguration先写全局配置、再写目标含 dead-man URL、最后清 dirty。测试断言configWrites.length 1且savedPingUrls [editedUrl]精确对应这一顺序——如果保存路径在 URL 写入前清掉 dirty或写入多次断言会立刻失败。3. 恢复反馈的 view-local 语义AlertQueueActionFeedback本身是 view-local 的rolestatus aria-livepolite aria-atomictrue挂在roleregion内clear、更新的确认动作或离开视图都会移除消息队列被接受 ≠ 投递回执。测试正是利用这一点验证「反馈生命周期与编辑生命周期相互独立」。七、边界与不主张事项文档明确划定了本次验证的边界文章照实引用本验证未挂载整个 Alerts 应用外壳未覆盖导航、组织切换org switching真实代码中由eventBus.on(org_switched, ...)触发重载、并发配置加载、后端持久化、安装态恢复、历史保留、接收者回执、无障碍技术公告不主张任何 release qualification历史截图与早期 receipts 予以保留不被刷新或作为本测试变更的视觉验收失败尝试不折算为通过早期一次因隔离工作区缺 Vite 依赖而无法启动lockfile install 解决一次因按typeurl查找输入而超时既有字段是掩码的改选既有 id 前缀解决两次均未改动任何运行时源码独立理由rationale引用 W3C redundant-entry 指南支持「输入保留」的测试方向而非新增产品需求或 WCAG 合规声明。八、可复用的验证方法论要点真实父组件 脚本化 API 边界只对「不可控的外部世界」打桩把配置加载、dirty 信号、保存路径全部交给真实代码测试才具有父级语义而非孤立输入语义哨兵变量驱动状态window.health/window.action让脚本化 API 能在线切换健康与拒绝行为从而在单次浏览器会话里模拟「失败→恢复→再失败→健康」的完整序列加速时钟验证真实到期page.clock.install()fastForward(12000)让错误 toast 走真实过期逻辑10 秒错误 toast 退出动画计时器不依赖人为 sleep计数器作为「未发生副作用」的证据configReads 1、configWrites.length 0、savedPingUrls.length 0、mutations不变用数值化的「没有发生」替代模糊断言交互矩阵覆盖响应式1440 / 900 / 390 三种宽度 × light/dark 主题 × overview/destinations 表面每用例都以零pageerror收尾保留失败证据失败的failed.png、source-sha256.json、settings-parent-source.sha256与通过结果并列存放使验证可被复现与审计。如需继续深入可直接阅读 scripts/check-recovery-feedback.mjs约 340 行的完整 harness与 AlertsConfigurationSurface.tsx、useAlertsConfigurationState.ts 两个核心实现文件以及 README.md 中记录的全部早期失败与证据边界。赞分享可观测性运维后端【免费下载链接】PulseReal-time monitoring dashboard for Proxmox VE, PBS, Docker, Kubernetes, TrueNAS and vSphere. Self-hosted, with smart alerts and AI patrols that catch silent failures.项目地址https://gitcode.com/gh_mirrors/pulse27/Pulse点击查看免费下载相关推荐WebdriverIO React 组件测试完整指南基于真实浏览器的组件测试实战WebdriverIO React 组件测试完整指南基于真实浏览器的组件测试实战 导读 本文基于 WebdriverIO 的 Browser Runner浏测试质量保障Phoenix LiveView 端到端测试实战基于 Playwright 的三引擎真实浏览器验证Phoenix LiveView 端到端测试实战基于 Playwright 的三引擎真实浏览器验证 本文以 Phoenix LiveView 仓库内置的端到端后端Web框架WebSocketApache Druid 数据留存规则实战基于 Retention Rules 配置数据的保留与丢弃Apache Druid 数据留存规则实战基于 Retention Rules 配置数据的保留与丢弃 本教程演示如何通过 Apache Druid 的留存规则数据库OLAP大数据后端上一篇【亲测免费】 **tiff.js在Web端轻松处理TIFF图像的开源库**下一篇Keymap Editor API参考手册所有接口详解与使用示例创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑