diff --git a/docs/qualification/recovery-feedback/settings-parent-result.json b/docs/qualification/recovery-feedback/settings-parent-result.json new file mode 100644 index 000000000..c011bd0ab --- /dev/null +++ b/docs/qualification/recovery-feedback/settings-parent-result.json @@ -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."} diff --git a/docs/qualification/recovery-feedback/settings-parent-source.sha256 b/docs/qualification/recovery-feedback/settings-parent-source.sha256 new file mode 100644 index 000000000..51548cbfa --- /dev/null +++ b/docs/qualification/recovery-feedback/settings-parent-source.sha256 @@ -0,0 +1 @@ +5cd23421409415dd8b25fa933bef261c8e3a11141ea225e0942e63fb9dcc6fa9 scripts/check-recovery-feedback.mjs diff --git a/docs/qualification/recovery-feedback/unsaved-edit-retention.md b/docs/qualification/recovery-feedback/unsaved-edit-retention.md index 91523592e..b1e0a6ce5 100644 --- a/docs/qualification/recovery-feedback/unsaved-edit-retention.md +++ b/docs/qualification/recovery-feedback/unsaved-edit-retention.md @@ -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. diff --git a/scripts/check-recovery-feedback.mjs b/scripts/check-recovery-feedback.mjs index d9b5f5817..b91179339 100644 --- a/scripts/check-recovery-feedback.mjs +++ b/scripts/check-recovery-feedback.mjs @@ -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 false} dismissQuickTip={noop} showAcknowledged={()=>true} setShowAcknowledged={noop} alertsDisabled={()=>false}/>;} function Destinations() { -const [pingUrl, setPingUrl] = createSignal(''); -window.unsaved = false; -window.editedPingUrl = pingUrl; -return ({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 'destinations'} +allResources={()=>[]} byType={()=>[]} children={()=>[]} activeAlerts={{}} +removeAlerts={noop} setOverviewOverrides={noop} +hasUnsavedChanges={unsaved} setHasUnsavedChanges={setUnsaved} +alertsActivationState={()=>'active'} alertsActivationConfig={()=>({enabled:true})}/>;} + function Fixture() {return
{location.pathname.endsWith('destinations')?:}
;} render(()=>,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 {