test(alerts): verify saved recovery intent across backend restart

Exercise the production Schedule control and real configuration API on an owned local backend without alert endpoint mocks. Keep persistence evidence distinct from installed notification delivery.

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-09-08 10:06:21 +01:00
parent c2575433ff
commit 6a66de4556
2 changed files with 118 additions and 0 deletions
+24
View File
@@ -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.
@@ -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',
});
});