From 7de119541685b621dac6839ec3b06f61309aa20c Mon Sep 17 00:00:00 2001
From: "pulse-triage[bot]"
<249995291+pulse-triage[bot]@users.noreply.github.com>
Date: Tue, 8 Sep 2026 23:27:00 +0100
Subject: [PATCH] fix(web): align webhook test custom fields with saved
configuration
Manually entered Pushover aliases were normalised on save but not on test, so the test could exercise a different payload. Apply the same normalisation and retain a failing-before parity regression; 56 focused webhook tests pass.
Change-source: pulse-maintainer
---
.../v6/internal/subsystems/alerts.md | 11 +++
frontend-modern/browser-verification.json | 24 +++---
.../components/Alerts/WebhookConfig.test.tsx | 28 ++++++
.../Alerts/useWebhookConfigState.ts | 2 +-
scripts/check-webhook-test-save-parity.mjs | 85 +++++++++++++++++++
5 files changed, 136 insertions(+), 14 deletions(-)
create mode 100644 scripts/check-webhook-test-save-parity.mjs
diff --git a/docs/release-control/v6/internal/subsystems/alerts.md b/docs/release-control/v6/internal/subsystems/alerts.md
index 5b12dddb7..4f3717b24 100644
--- a/docs/release-control/v6/internal/subsystems/alerts.md
+++ b/docs/release-control/v6/internal/subsystems/alerts.md
@@ -2828,3 +2828,14 @@ The presentation unit tests cover the label and terminal guidance.
cards in Chromium at desktop, tablet and phone widths, asserting the copy,
absence of page errors and horizontal overflow. Scripted props are component
evidence only, not installed notification delivery.
+
+### Webhook test/save custom-field parity
+
+The webhook form applies the same service-specific custom-field normalisation
+when testing unsaved data as when saving it. In particular, manually entered
+Pushover app_token/user_token aliases become token/user with the existing
+canonical-value precedence. Existing-field editing remains normalised on load.
+This changes neither delivery scheduling nor the meaning of a successful test.
+The registered WebhookConfig regression covers alias test/save payload equality;
+the browser fixture exercises the real form at desktop and phone widths with
+synthetic callbacks, not a hosted Pushover destination or installed delivery.
diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json
index 7d0d17560..da66077c5 100644
--- a/frontend-modern/browser-verification.json
+++ b/frontend-modern/browser-verification.json
@@ -1,16 +1,16 @@
{
"version": 1,
- "base_sha": "d242055f9877a47d31b08133e3e8588cbff65f40",
- "verified_at": "2026-09-08T20:59:34.058403Z",
+ "base_sha": "6f3547bc5fbb10ddf313a8cd2f184b6359173a59",
+ "verified_at": "2026-09-08T22:29:31.643744Z",
"result": "passed",
"changed_paths": [
- "frontend-modern/src/utils/proxmoxUpdateEvidence.ts"
+ "frontend-modern/src/components/Alerts/useWebhookConfigState.ts"
],
"content_sha256": {
- "frontend-modern/src/utils/proxmoxUpdateEvidence.ts": "b99d8b5411e0e894f294f106d970978c8573915ff7ffe0ed60c451128e22d34a"
+ "frontend-modern/src/components/Alerts/useWebhookConfigState.ts": "14f0dceb069fe52b9a47100c50998ae53a2c5c8a29a524e342f7458546cb4a19"
},
"routes": [
- "/qualification.html (production-built real NodeDrawerOverview with synthetic reactive props)"
+ "/qualification (real WebhookConfigForm and hook; synthetic callbacks)"
],
"viewports": [
{
@@ -27,15 +27,13 @@
}
],
"states": [
- "Unavailable permission denial",
- "Retained stale count with permission-denial title",
- "Checked zero",
- "Checked positive count",
- "Not-checked permission-denial reason"
+ "Unsaved manually entered Pushover aliases",
+ "Test callback payload",
+ "Save callback payload"
],
"interactions": [
- "At each width select unavailable, stale, zero, positive, not_checked, then unavailable again; assert text, stale title, no obsolete Sys.Audit prescription, no page errors or horizontal page overflow."
+ "Click Test then Add Webhook at each width; assert both canonical token/user payloads, no page errors or horizontal overflow."
],
- "notes": "Built isolated real component with Vite esnext target, served compiled assets to Chromium. Desktop stale and phone unavailable screenshots visually inspected. Phone uses existing ellipsis for long values; full reason retained in title. No installed backend or reporter credential reproduction. Initial fixture build attempts failed on default target and virtual HTML path; corrected fixture build and all checks passed. Screenshots /tmp/pulse-update-access-copy; log /tmp/web-205503/browser-built.log.",
- "command": "pulse-heavy-run -- node scripts/check-update-access-copy.mjs"
+ "notes": "Real component with Vite dev transform in Chromium, not production build or installed/provider delivery. Phone screenshot visually inspected; synthetic credentials only. Screenshots /tmp/pulse-webhook-parity.",
+ "command": "pulse-heavy-run -- node scripts/check-webhook-test-save-parity.mjs"
}
diff --git a/frontend-modern/src/components/Alerts/WebhookConfig.test.tsx b/frontend-modern/src/components/Alerts/WebhookConfig.test.tsx
index ddd23c2f5..af6b72dc9 100644
--- a/frontend-modern/src/components/Alerts/WebhookConfig.test.tsx
+++ b/frontend-modern/src/components/Alerts/WebhookConfig.test.tsx
@@ -1,3 +1,5 @@
+import { createRoot } from 'solid-js';
+import { useWebhookConfigState } from './useWebhookConfigState';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library';
import webhookConfigSource from '@/components/Alerts/WebhookConfig.tsx?raw';
@@ -1580,3 +1582,29 @@ describe('WebhookConfig', () => {
expect(payloadTextareaAfter.value).toBe('');
});
});
+
+describe('webhook test/save parity', () => {
+ it('normalises manually entered Pushover aliases identically for test and save', () => {
+ createRoot((dispose) => {
+ try {
+ const onTest = vi.fn();
+ const onAdd = vi.fn();
+ const state = useWebhookConfigState({
+ webhooks: [], onTest, onAdd, onUpdate: vi.fn(), onDelete: vi.fn(),
+ });
+ state.openAddForm();
+ state.setFormData((data) => ({ ...data, name: 'Example', url: 'https://example.invalid', service: 'pushover' }));
+ for (const [index, key] of ['app_token', 'user_token'].entries()) {
+ state.addCustomFieldInput();
+ state.updateCustomFieldInput(index, { key, value: `synthetic-${index}` });
+ }
+ state.testWebhookForm();
+ state.saveWebhook();
+ expect(onAdd.mock.calls[0][0].customFields).toEqual({ token: 'synthetic-0', user: 'synthetic-1' });
+ expect(onTest.mock.calls[0][1].customFields).toEqual(onAdd.mock.calls[0][0].customFields);
+ } finally {
+ dispose();
+ }
+ });
+ });
+});
diff --git a/frontend-modern/src/components/Alerts/useWebhookConfigState.ts b/frontend-modern/src/components/Alerts/useWebhookConfigState.ts
index 080e44d0e..e7086e25b 100644
--- a/frontend-modern/src/components/Alerts/useWebhookConfigState.ts
+++ b/frontend-modern/src/components/Alerts/useWebhookConfigState.ts
@@ -284,7 +284,7 @@ export function useWebhookConfigState(props: WebhookConfigProps): WebhookConfigS
const testPayload = {
...restFormData,
headers,
- customFields,
+ customFields: normalizeAlertWebhookCustomFields(data.service, customFields),
template: payloadTemplate ?? restFormData.template ?? '',
};
const tempId = editingId() || 'temp-new-webhook';
diff --git a/scripts/check-webhook-test-save-parity.mjs b/scripts/check-webhook-test-save-parity.mjs
new file mode 100644
index 000000000..9a746512e
--- /dev/null
+++ b/scripts/check-webhook-test-save-parity.mjs
@@ -0,0 +1,85 @@
+// Isolated real-browser component qualification; no installed backend or delivery claim.
+import { createServer } from "../frontend-modern/node_modules/vite/dist/node/index.js";
+import solid from "../frontend-modern/node_modules/vite-plugin-solid/dist/esm/index.mjs";
+import { chromium } from "@playwright/test";
+import { resolve } from "node:path";
+import { mkdirSync } from "node:fs";
+import assert from "node:assert/strict";
+const root = resolve("frontend-modern");
+process.chdir(root);
+const fixture = `
+import { render } from 'solid-js/web';
+import { useWebhookConfigState } from '/src/components/Alerts/useWebhookConfigState';
+import { WebhookConfigForm } from '/src/components/Alerts/WebhookConfigForm';
+import '/src/index.css';
+render(() => {
+ const state = useWebhookConfigState({webhooks:[], onAdd: data => window.saved = data, onUpdate:()=>{}, onDelete:()=>{}, onTest: (_id,data) => window.tested = data});
+ state.openAddForm();
+ state.setFormData(data => ({...data,name:'Synthetic destination',url:'https://example.invalid',service:'pushover'}));
+ for (const [index,key] of ['app_token','user_token'].entries()) {
+ state.addCustomFieldInput();
+ state.updateCustomFieldInput(index,{key,value:'synthetic-'+index});
+ }
+ return