- 可观测性
- 运维
- 后端
【免费下载链接】Pulse
Real-time monitoring dashboard for Proxmox VE, PBS, Docker, Kubernetes, TrueNAS and vSphere. Self-hosted, with smart alerts and AI patrols that catch silent failures.
导读
本文围绕 Pulse 仓库中 docs/qualification/recovery-feedback/unsaved-edit-retention.md 记录的一次设置页(Settings)父级作用域验证展开:它不再用合成的 reactive 父组件包裹子页签,而是直接挂载真实的AlertsConfigurationSurface,让配置快照、overrides、目标(Destinations)hooks 全部走真实状态机,只对 API 边界与外部资源/激活输入进行脚本化。读者可以从中获得一套「保留中的用户编辑不被投递恢复反馈打断」的真实浏览器回归测试方法论,并了解 Pulse 前端useAlertsConfigurationState状态机、dead-man ping URL 输入、dirty 信号与保存路径的完整行为。
适用范围与前提:本文描述的是仅测试层面的验证(isolated browser qualification),不包含安装后端、真实投递、接收回执、App 外壳导航或无障碍技术(screen reader)测试,也不构成 WCAG 合规性声明。运行环境需要仓库根目录下已安装 Vite、Solid、Playwright 等前端依赖。
一、验证目标:为什么「未保存编辑」必须由父组件来保留
Pulse 前端采用 SolidJS,AlertsConfigurationSurface.tsx 是告警配置页(含 Overview、Destinations、Schedule、Thresholds 等页签)的真实父组件。它通过 useAlertsConfigurationState.ts 统一管理:
useAlertsConfigurationSnapshotState:全局配置快照(thresholds/overrides 等);useAlertDestinationsState:Email、Apprise、Webhook、External watchdog(dead-man ping URL)等目标配置;useAlertOverridesState:资源级 overrides;guardedSetHasUnsavedChanges:带抑制机制的 dirty 信号(suppressDirtyFlag生效期间拒绝把状态置脏)。
在投递恢复反馈(AlertQueueActionFeedback.tsx,role="region"且aria-label="Notification 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},autocomplete="off",spellcheck={false}; - 输入即置脏:
onInput同时调用setPingUrl(...)与setHasUnsavedChanges(true); input[id^="alert-deadman-url-"]前缀:输入框 id 由createUniqueId()生成,前缀固定为alert-deadman-url-。
这段历史对测试有直接影响:文档明确记录「早期的尝试因为测试按type=url寻找输入框而超时,因为既有字段是掩码的(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.action='reject')→ 出现「Unable to retry retained notification deliveries.」→page.clock.fastForward(12000)加速 12 秒让错误 toast 真实过期并跑完退出动画(350ms/400ms); - 被取消的关闭(dismiss):
accept=false,点击 Dismiss 触发原生确认框并dialog.dismiss(),断言window.mutations不变、错误消息仍在; - 接受的重试 + 不可用的健康刷新:
accept=true且window.health='error',此时重试成功但健康读取失败 → 出现「Refresh delivery status」按钮,恢复反馈按钮清零,错误消息被替换; - 随后的恢复、失败的关闭、健康卡片移除与消息清除:
window.health='degraded'恢复快照 → 点击 Refresh → Dismiss 再次失败 → 在 destinations 表面上将window.health='healthy'后再次 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.png(1440×2600)、390-destinations-light.png(390×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 的(role="status" aria-live="polite" aria-atomic="true"挂在role="region"内),clear、更新的确认动作或离开视图都会移除消息;队列被接受 ≠ 投递回执。测试正是利用这一点验证「反馈生命周期与编辑生命周期相互独立」。
七、边界与不主张事项
文档明确划定了本次验证的边界,文章照实引用:
- 本验证未挂载整个 Alerts 应用外壳,未覆盖导航、组织切换(org switching,真实代码中由
eventBus.on('org_switched', ...)触发重载)、并发配置加载、后端持久化、安装态恢复、历史保留、接收者回执、无障碍技术公告; - 不主张任何 release qualification;
- 历史截图与早期 receipts 予以保留,不被刷新或作为本测试变更的视觉验收;
- 失败尝试不折算为通过:早期一次因隔离工作区缺 Vite 依赖而无法启动(lockfile install 解决),一次因按
type=url查找输入而超时(既有字段是掩码的,改选既有 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 中记录的全部早期失败与证据边界。
- 可观测性
- 运维
- 后端
【免费下载链接】Pulse
Real-time monitoring dashboard for Proxmox VE, PBS, Docker, Kubernetes, TrueNAS and vSphere. Self-hosted, with smart alerts and AI patrols that catch silent failures.
相关推荐
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大数据后端
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考