diff --git a/scripts/incident-browser-checks.md b/scripts/incident-browser-checks.md index 75e1ed531..37f53e53e 100644 --- a/scripts/incident-browser-checks.md +++ b/scripts/incident-browser-checks.md @@ -81,3 +81,27 @@ Chromium or Playwright changes. Ordinary runs use the unmodified base fixtures. For the ordinary-journey control, repeat the command above with `PULSE_E2E_INCIDENT_FOREGROUND` unset. Retain both exact-revision results; a prior success is not evidence for a later untested revision. + +## Saved alert intent (unmocked local backend) + +```sh +pulse-heavy-run -- env PULSE_E2E_USE_LOCAL_BACKEND=1 PULSE_MOCK_MODE=false \ + PULSE_E2E_ALERT_CONFIG_PERSISTENCE=1 PULSE_E2E_SKIP_PLAYWRIGHT_INSTALL=1 \ + npm --prefix tests/integration test -- tests/97-alert-config-persistence.spec.ts \ + --project=chromium --workers=1 --retries=0 +``` + +This opt-in test requires the managed, disposable backend. It activates that +instance through the real configuration API, changes Recovery notifications +through the production Schedule UI, verifies the staged value has not reached +the server, and clicks Save Changes. It checks the real PUT result, subsequent +GETs and rendered control after both page reload and managed backend restart +with preserved data. It does not intercept HTTP or WebSocket responses and +must not be run against a shared installation. The attachment contains only +expected non-secret settings and evidence boundaries, not full configuration. + +This proves saved recovery intent only when the test passes. API activation +setup is not UI activation coverage, and no resource or destination is seeded. +It does not establish alert-engine firing, retry exhaustion, delivered recovery, +recurrence or off-LAN receipt. Those require separate evidence; notification +package loopback tests are not a substitute for installed exact-pair acceptance. diff --git a/tests/integration/tests/97-alert-config-persistence.spec.ts b/tests/integration/tests/97-alert-config-persistence.spec.ts new file mode 100644 index 000000000..704bf12ef --- /dev/null +++ b/tests/integration/tests/97-alert-config-persistence.spec.ts @@ -0,0 +1,94 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { expect, test as base } from '@playwright/test'; + +import { restartManagedLocalBackend } from '../scripts/managed-local-backend.mjs'; +import { readRuntimeState } from '../scripts/runtime-state.mjs'; +import { + apiRequest, + createAuthenticatedStorageState, + ensureAuthenticated, +} from './helpers'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +type WorkerFixtures = { + authStorageStatePath: string; +}; + +const test = base.extend<{}, WorkerFixtures>({ + storageState: async ({ authStorageStatePath }, use) => + use(authStorageStatePath), + authStorageStatePath: [ + async ({ browser }, use, workerInfo) => { + const storageStatePath = path.resolve( + __dirname, + '..', + '..', + 'tmp', + 'playwright-auth', + `alert-config-persistence-${workerInfo.project.name}.json`, + ); + fs.mkdirSync(path.dirname(storageStatePath), { recursive: true }); + await createAuthenticatedStorageState(browser, storageStatePath); + try { + await use(storageStatePath); + } finally { + fs.rmSync(storageStatePath, { force: true }); + } + }, + { scope: 'worker' }, + ], +}); + +// No HTTP or WebSocket interception: this owns an isolated managed backend. +test('saves recovery intent through the UI and preserves it across reload and restart', async ({ page }, testInfo) => { + test.skip(process.env.PULSE_E2E_ALERT_CONFIG_PERSISTENCE !== '1', 'Requires an owned managed backend'); + test.skip(testInfo.project.name !== 'chromium', 'One worker owns backend restart'); + test.setTimeout(240_000); + const runtime = await readRuntimeState(); + expect(runtime?.managedLocalBackend).toBe(true); + expect(process.env.PULSE_MOCK_MODE).toBe('false'); + const readConfig = async () => { + const response = await apiRequest(page, '/api/alerts/config'); + expect(response.ok(), `config GET: ${response.status()}`).toBe(true); + return response.json(); + }; + await page.goto('/alerts/overview', { waitUntil: 'domcontentloaded' }); + const original = await readConfig(); + // Activate only the disposable instance; no destinations or resources are seeded. + const setup = await apiRequest(page, '/api/alerts/config', { + method: 'PUT', data: { ...original, enabled: true, activationState: 'active', + schedule: { ...original.schedule, notifyOnResolve: true } }, + }); + expect(setup.ok(), `config setup: ${setup.status()}`).toBe(true); + await page.goto('/alerts/schedule', { waitUntil: 'domcontentloaded' }); + const recovery = page.getByRole('button', { name: /^Recovery notifications/ }); + // Persist false, not the default true, so a reset cannot satisfy restart proof. + const initial = true; + await expect(recovery).toHaveAttribute('aria-pressed', String(initial)); + await recovery.click(); + // A staged edit must not already have altered the server. + expect((await readConfig()).schedule.notifyOnResolve).toBe(initial); + const saved = page.waitForResponse(response => + new URL(response.url()).pathname === '/api/alerts/config' && response.request().method() === 'PUT'); + await page.getByRole('button', { name: 'Save Changes', exact: true }).click(); + expect((await saved).ok()).toBe(true); + const expected = { enabled: true, activationState: 'active', schedule: { notifyOnResolve: !initial } }; + expect(await readConfig()).toMatchObject(expected); + await page.reload({ waitUntil: 'domcontentloaded' }); + await expect(recovery).toHaveAttribute('aria-pressed', String(!initial)); + expect(await readConfig()).toMatchObject(expected); + await restartManagedLocalBackend(); + await ensureAuthenticated(page); + await page.goto('/alerts/schedule', { waitUntil: 'domcontentloaded' }); + await expect(recovery).toHaveAttribute('aria-pressed', String(!initial)); + expect(await readConfig()).toMatchObject(expected); + await testInfo.attach('saved-intent-proof.json', { + body: Buffer.from(JSON.stringify({ expected, reload: true, backendRestart: true, + mockedAlertEndpoints: false, destinationReceiptProven: false })), + contentType: 'application/json', + }); +});