test(web): verify recovery preserves real settings-parent edits

The previous recovery browser fixture owned the edited URL in a synthetic parent, leaving the production configuration-state integration untested. Mount the real Alerts settings surface and assert edits and dirty state survive recovery without reload or implicit save, then reach the explicit save boundary intact.

Two serialized Chromium runs passed 12 cases each, including six real settings-parent cases. Record the exact script hash and final result; retain earlier harness failures and distinguish mocked API evidence from persistence, installed recovery and recipient receipt.

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-09-07 16:39:11 +01:00
parent aab925d675
commit fc3ebefd86
4 changed files with 107 additions and 29 deletions
@@ -0,0 +1 @@
{"result":"passed","cases":12,"unsavedEditCases":6,"viewports":[1440,900,390],"scope":"Real OverviewTab and AlertsConfigurationSurface with configuration/destination state, shared toast and feedback in Chromium; scripted API only, not installed delivery or recipient receipt."}
@@ -0,0 +1 @@
5cd23421409415dd8b25fa933bef261c8e3a11141ea225e0942e63fb9dcc6fa9 scripts/check-recovery-feedback.mjs
@@ -1,25 +1,54 @@
# Unsaved edit retention — 7 September 2026
`scripts/check-recovery-feedback.mjs` now gives the Destinations fixture a
reactive ping URL and an observable unsaved flag instead of no-op setters.
Each Destinations case enters a synthetic `example.invalid` URL and checks
both the rendered input and parent signal, plus the unsaved flag, after:
## Settings-parent qualification
`scripts/check-recovery-feedback.mjs` now mounts the real
`AlertsConfigurationSurface` for Destinations. Its
`useAlertsConfigurationState`, configuration snapshot, overrides and
destination hooks are unmocked. Only the API boundary and external resource /
activation inputs are scripted; the enclosing fixture supplies the dirty
signal as the application shell would.
The test waits for initial configuration loading, enters a synthetic
`example.invalid` ping URL, and checks the rendered value, dirty signal and
real unsaved-changes banner survive:
- rejected retry and toast expiry;
- cancelled dismissal;
- accepted retry followed by unavailable health refresh;
- subsequent recovery, failed dismissal, healthy-card removal and message clearing.
Validation: `pulse-heavy-run -- node scripts/check-recovery-feedback.mjs` passed
12 Chromium cases: Overview and Destinations at 1440, 900 and 390 pixels in
light and dark themes. Six cases include the unsaved edit assertions.
At each checkpoint, configuration was read only once and neither the global
configuration nor ping URL was saved. Finally, clicking the real Save Changes
button sends the exact edited URL through the real destination save path to
the scripted API, writes global configuration once and clears dirty state.
This verifies parent-owned retention rather than just an isolated input.
This is component qualification with scripted APIs. It does not establish
full settings-parent integration, saved configuration, installed recovery,
recipient receipt or assistive-technology announcements. Existing screenshots
are not refreshed by this evidence record; no new visual acceptance is claimed.
Validation: two serialized Chromium runs passed all 12 cases (Overview and
Destinations at 1440, 900 and 390 pixels in light/dark), including six
settings-parent edit/save cases. The final formatted-script result is
`settings-parent-result.json`; `settings-parent-source.sha256` identifies the
script. `node --check` and `git diff --check` passed.
Independent rationale: W3C's [redundant-entry guidance](https://www.w3.org/WAI/WCAG22/Understanding/redundant-entry.html),
retrieved through search on 7 September (direct retrieval returned HTTP 429),
describes the burden and error risk of re-entering information. This supports
protecting input retention, not a claim of WCAG conformance or new product demand.
Earlier attempts are not passes: the first could not start because this
isolated workspace lacked Vite dependencies; lockfile installs resolved that.
The next timed out because the test looked for an input of type URL, whereas
the existing field is masked. Selecting its existing ID prefix resolved the
fixture error. No runtime code was changed.
## Boundaries
The previous fixture used a synthetic reactive parent directly around
DestinationsTab. This replaces that limitation with the real settings surface
and state, but does not mount the entire Alerts application shell or exercise
navigation, organisation switching, concurrent configuration loads, backend
persistence, installed recovery, history retention, recipient receipt or
assistive-technology announcements. No release qualification is claimed.
Historical screenshots and older receipts are preserved, not refreshed or
presented as visual acceptance for this test-only change.
Independent rationale: W3C's
[redundant-entry guidance](https://www.w3.org/WAI/WCAG22/Understanding/redundant-entry.html),
successfully retrieved directly on 7 September, explains the effort and error
risk of requiring people to recall and re-enter information. This supports
input-retention testing, not new product demand or a WCAG conformance claim.
+61 -14
View File
@@ -14,11 +14,20 @@ import { Router, Route } from '@solidjs/router';
import { AlertsAPI } from '/src/api/alerts';
import { NotificationsAPI } from '/src/api/notifications';
import { OverviewTab } from '/src/features/alerts/OverviewTab';
import { DestinationsTab } from '/src/features/alerts/tabs/DestinationsTab';
import { AlertsConfigurationSurface } from '/src/features/alerts/AlertsConfigurationSurface';
import { ToastContainer } from '/src/components/Toast/Toast';
import '/src/index.css';
window.health = 'degraded'; window.action = 'reject';
window.mutations = 0;
window.configReads = 0; window.configWrites = []; window.savedPingUrls = [];
AlertsAPI.getConfig = async () => {window.configReads++; return {overrides:{}};};
AlertsAPI.getDeadManConfig = async () => ({pingUrl:'https://example.invalid/original'});
NotificationsAPI.getEmailConfig = async () => ({enabled:false,to:[]});
NotificationsAPI.getAppriseConfig = async () => ({enabled:false,targets:[]});
AlertsAPI.updateConfig = async (value) => {window.configWrites.push(value); return {success:true};};
NotificationsAPI.updateEmailConfig = async () => ({});
NotificationsAPI.updateAppriseConfig = async (value) => value;
AlertsAPI.updateDeadManConfig = async (value) => {window.savedPingUrls.push(value); return {};};
NotificationsAPI.getHealth = async () => {
if(window.health === 'error') throw new Error('scripted offline');
return {queue:{status:window.health, attentionRequired:window.health==='healthy'?0:2, failed:2,deadLetter:0}};
@@ -37,15 +46,14 @@ function Overview() {return <OverviewTab overrides={[]} activeAlerts={{}} update
showQuickTip={()=>false} dismissQuickTip={noop} showAcknowledged={()=>true}
setShowAcknowledged={noop} alertsDisabled={()=>false}/>;}
function Destinations() {
const [pingUrl, setPingUrl] = createSignal('');
window.unsaved = false;
window.editedPingUrl = pingUrl;
return <DestinationsTab
emailConfig={()=>({enabled:false,provider:'smtp',from:'',server:'',username:'',password:'',port:587,to:[],tls:true,startTLS:true,replyTo:'',maxRetries:3,retryDelay:5,rateLimit:60})} setEmailConfig={noop}
appriseConfig={()=>({enabled:false,mode:'cli',targetsText:'',configKey:'',serverUrl:'',timeoutSeconds:30,apiKey:'',apiKeyHeader:'X-API-KEY',hasApiKey:false,skipTlsVerify:false,cliPath:'apprise'})} setAppriseConfig={noop}
configLoadError={()=>null} isRetrying={()=>false} isLoadingDestinations={()=>false} onRetryLoad={noop}
webhooks={()=>[]} setHasUnsavedChanges={(value)=>{window.unsaved=value;}} deadManPingUrl={pingUrl} setDeadManPingUrl={setPingUrl}
pushMinimumSeverity={()=>'all'} setPushMinimumSeverity={noop}/>;}
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})}/>;}
function Fixture() {return <main class="p-4"><ToastContainer/>{location.pathname.endsWith('destinations')?<Destinations/>:<Overview/>}</main>;}
render(()=><Router><Route path="/qualification/*" component={Fixture}/></Router>,document.getElementById('root'));
`;
@@ -141,7 +149,7 @@ try {
});
throw error;
});
// A synthetic unsaved value, never submitted to a backend.
// Synthetic value owned by the real settings parent; APIs remain scripted.
const editedUrl = "https://example.invalid/unsaved-recovery-check";
const pingInput = page.getByLabel(
"Healthchecks-compatible success ping URL",
@@ -150,10 +158,25 @@ try {
const assertEditRetained = async () => {
if (surface !== "destinations") return;
assert.equal(await pingInput.inputValue(), editedUrl);
assert.equal(await page.evaluate(() => window.editedPingUrl()), editedUrl);
assert.equal(await page.evaluate(() => window.unsaved), true);
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), []);
};
if (surface === "destinations") {
// Wait for the real configuration/destination load before editing.
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(editedUrl);
await assertEditRetained();
}
@@ -268,6 +291,30 @@ try {
true,
);
await assertEditRetained();
if (surface === "destinations") {
// Prove the retained value reaches the real parent's save path, only
// after an explicit Save. This is not backend persistence evidence.
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,
);
}
assert.deepEqual(errors, []);
await page.close();
cases++;
@@ -280,7 +327,7 @@ try {
unsavedEditCases,
viewports: [1440, 900, 390],
scope:
"Real OverviewTab and DestinationsTab, shared toast and feedback in Chromium; scripted API only, not installed delivery or recipient receipt.",
"Real OverviewTab and AlertsConfigurationSurface with configuration/destination state, shared toast and feedback in Chromium; scripted API only, not installed delivery or recipient receipt.",
}),
);
} finally {