diff --git a/frontend-modern/src/pages/Alerts.tsx b/frontend-modern/src/pages/Alerts.tsx index 1a1a697..013d0bd 100644 --- a/frontend-modern/src/pages/Alerts.tsx +++ b/frontend-modern/src/pages/Alerts.tsx @@ -461,7 +461,11 @@ export function Alerts() { }); createEffect(() => { - if (!isAlertsActive() && activeTab() !== 'overview') { + const activation = alertsActivation.activationState(); + if (activation === null) { + return; + } + if (activation !== 'active' && activeTab() !== 'overview') { handleTabChange('overview'); } }); @@ -1588,8 +1592,16 @@ export function Alerts() { : 0; const groupingEnabled = groupingState.enabled && groupingWindowSeconds > 0; + const existingActivationState = alertsActivation.activationState(); + const existingActivationTime = alertsActivation.config()?.activationTime; + const existingObservationWindowHours = + alertsActivation.config()?.observationWindowHours; + const alertConfig = { enabled: true, + activationState: existingActivationState ?? undefined, + activationTime: existingActivationTime, + observationWindowHours: existingObservationWindowHours, // Global disable flags per resource type disableAllNodes: disableAllNodes(), disableAllGuests: disableAllGuests(), diff --git a/internal/alerts/alerts.go b/internal/alerts/alerts.go index 423c564..e623a74 100644 --- a/internal/alerts/alerts.go +++ b/internal/alerts/alerts.go @@ -960,6 +960,15 @@ func (m *Manager) UpdateConfig(config AlertConfig) { m.mu.Lock() defer m.mu.Unlock() + // Preserve activation state/time when clients update the config without including it. + // This avoids unintentionally resetting alerts to pending review when saving thresholds. + if config.ActivationState == "" && m.config.ActivationState != "" { + config.ActivationState = m.config.ActivationState + if config.ActivationTime == nil && m.config.ActivationTime != nil { + config.ActivationTime = m.config.ActivationTime + } + } + // Normalize all config sections normalizeStorageDefaults(&config) normalizeDockerDefaults(&config) diff --git a/tests/integration/QUICK_START.md b/tests/integration/QUICK_START.md index da8afa5..0e1e1d8 100644 --- a/tests/integration/QUICK_START.md +++ b/tests/integration/QUICK_START.md @@ -201,10 +201,10 @@ Example: ```typescript import { test, expect } from '@playwright/test'; -import { loginAsAdmin, navigateToSettings } from './helpers'; +import { ensureAuthenticated, navigateToSettings } from './helpers'; test('my new test', async ({ page }) => { - await loginAsAdmin(page); + await ensureAuthenticated(page); await navigateToSettings(page); // Your test logic here diff --git a/tests/integration/README.md b/tests/integration/README.md index 3fe33c5..76a35eb 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -37,7 +37,7 @@ The docker-compose stack seeds a deterministic bootstrap token for first-run set Credentials used by the E2E suite can be overridden: - `PULSE_E2E_USERNAME` (default `admin`) -- `PULSE_E2E_PASSWORD` (default `admin`) +- `PULSE_E2E_PASSWORD` (default `adminadminadmin`) - `PULSE_E2E_ALLOW_NODE_MUTATION=1` to enable the optional "Add Proxmox node" test (disabled by default for safety) ### Run Against An Existing Pulse Instance @@ -46,7 +46,7 @@ cd tests/integration PULSE_E2E_SKIP_DOCKER=1 \ PULSE_BASE_URL='http://your-pulse-host:7655' \ PULSE_E2E_USERNAME='admin' \ -PULSE_E2E_PASSWORD='admin' \ +PULSE_E2E_PASSWORD='adminadminadmin' \ npm test ``` diff --git a/tests/integration/docker-compose.test.yml b/tests/integration/docker-compose.test.yml index 6c7fa0c..4825ae5 100644 --- a/tests/integration/docker-compose.test.yml +++ b/tests/integration/docker-compose.test.yml @@ -9,7 +9,7 @@ services: volumes: - test-data:/data command: > - sh -c "set -e; umask 077; echo \"$PULSE_E2E_BOOTSTRAP_TOKEN\" > /data/.bootstrap_token" + sh -c "set -e; umask 077; echo \"$$PULSE_E2E_BOOTSTRAP_TOKEN\" > /data/.bootstrap_token" networks: - test-network diff --git a/tests/integration/playwright.config.ts b/tests/integration/playwright.config.ts index ce981d1..6c8977e 100644 --- a/tests/integration/playwright.config.ts +++ b/tests/integration/playwright.config.ts @@ -21,7 +21,7 @@ export default defineConfig({ /* Reporter to use */ reporter: [ - ['html', { outputFolder: 'playwright-report' }], + ['html', { outputFolder: 'playwright-report', open: 'never' }], ['list'], ['junit', { outputFile: 'test-results/junit.xml' }] ], diff --git a/tests/integration/scripts/pretest.mjs b/tests/integration/scripts/pretest.mjs index 01de70e..e6357c3 100644 --- a/tests/integration/scripts/pretest.mjs +++ b/tests/integration/scripts/pretest.mjs @@ -8,6 +8,11 @@ const truthy = (value) => { const shouldSkipDocker = truthy(process.env.PULSE_E2E_SKIP_DOCKER); const shouldSkipPlaywrightInstall = truthy(process.env.PULSE_E2E_SKIP_PLAYWRIGHT_INSTALL); +const DEFAULT_E2E_BOOTSTRAP_TOKEN = '0123456789abcdef0123456789abcdef0123456789abcdef'; +if (!process.env.PULSE_E2E_BOOTSTRAP_TOKEN) { + process.env.PULSE_E2E_BOOTSTRAP_TOKEN = DEFAULT_E2E_BOOTSTRAP_TOKEN; +} + const run = (command, args, options = {}) => new Promise((resolve, reject) => { const child = spawn(command, args, { stdio: 'inherit', ...options }); diff --git a/tests/integration/tests/00-diagnostic.spec.ts b/tests/integration/tests/00-diagnostic.spec.ts index 0f0d708..c004666 100644 --- a/tests/integration/tests/00-diagnostic.spec.ts +++ b/tests/integration/tests/00-diagnostic.spec.ts @@ -4,7 +4,17 @@ import { test, expect } from '@playwright/test'; +const truthy = (value: string | undefined) => { + if (!value) return false; + return ['1', 'true', 'yes', 'on'].includes(value.trim().toLowerCase()); +}; + test.describe('Login Diagnostic', () => { + test.skip( + !truthy(process.env.PULSE_E2E_DIAGNOSTIC), + 'Set PULSE_E2E_DIAGNOSTIC=1 to enable verbose login diagnostics', + ); + test('diagnose login page and API access', async ({ page }) => { // Capture all console messages including errors page.on('console', msg => { @@ -43,7 +53,7 @@ test.describe('Login Diagnostic', () => { }); console.log('\n=== Navigating to login page ==='); - await page.goto('http://localhost:7655/'); + await page.goto('/'); console.log('Page loaded'); // Wait a bit for any async operations diff --git a/tests/integration/tests/01-core-e2e.spec.ts b/tests/integration/tests/01-core-e2e.spec.ts index afd5647..9c79134 100644 --- a/tests/integration/tests/01-core-e2e.spec.ts +++ b/tests/integration/tests/01-core-e2e.spec.ts @@ -33,7 +33,20 @@ test.describe.serial('Core E2E flows', () => { test('Alerts page - create and delete threshold override', async ({ page }) => { await ensureAuthenticated(page); - await page.goto('/alerts/thresholds/proxmox'); + await page.goto('/alerts/overview'); + await expect(page.getByRole('heading', { name: 'Alerts Overview' })).toBeVisible(); + + const alertsToggleOnOverview = page.getByRole('checkbox', { name: /toggle alerts/i }); + await expect(alertsToggleOnOverview).toBeVisible(); + const alertsInitiallyEnabled = await alertsToggleOnOverview.isChecked(); + if (!alertsInitiallyEnabled) { + await alertsToggleOnOverview.setChecked(true, { force: true }); + await expect(page.getByText('Alerts enabled', { exact: true })).toBeVisible(); + } + + // Navigate to thresholds via in-app nav to avoid a full reload redirecting while activation is loading. + await page.getByRole('button', { name: 'Thresholds' }).click(); + await expect(page).toHaveURL(/\/alerts\/thresholds/); await expect(page.getByRole('heading', { name: 'Alert Thresholds' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Proxmox Nodes' })).toBeVisible(); @@ -41,36 +54,131 @@ test.describe.serial('Core E2E flows', () => { .getByRole('heading', { name: 'Proxmox Nodes' }) .locator('xpath=ancestor::*[.//table][1]'); - const firstRow = proxmoxNodesSection.locator('table tbody tr').first(); - await expect(firstRow).toBeVisible(); + const globalDefaultsRow = proxmoxNodesSection.locator('table tbody tr').filter({ + hasText: 'Global Defaults', + }); + await expect(globalDefaultsRow).toBeVisible(); + const cpuDefaultValueRaw = await globalDefaultsRow.locator('input[type="number"]').first().inputValue(); + const cpuDefault = Number(cpuDefaultValueRaw); + if (!Number.isFinite(cpuDefault)) { + test.skip(true, `Unable to read CPU default threshold (value="${cpuDefaultValueRaw}")`); + } - await firstRow.locator('button[title="Edit thresholds"]').click(); - const firstMetricInput = firstRow.locator('input[type="number"]').first(); - await expect(firstMetricInput).toBeVisible(); - await firstMetricInput.fill('77'); - await page.keyboard.press('Tab'); + const nodeRows = proxmoxNodesSection + .locator('table tbody tr') + .filter({ hasNotText: 'Global Defaults' }); + const rowCount = await nodeRows.count(); + let targetRowIndex = -1; + for (let i = 0; i < rowCount; i++) { + const row = nodeRows.nth(i); + const hasEdit = await row.locator('button[title="Edit thresholds"]').isVisible().catch(() => false); + if (!hasEdit) continue; + const hasCustom = await row.getByText('Custom', { exact: true }).isVisible().catch(() => false); + if (hasCustom) continue; + targetRowIndex = i; + break; + } + if (targetRowIndex < 0) { + test.skip(true, 'No Proxmox node row without an existing override was found'); + } + + const targetRow = nodeRows.nth(targetRowIndex); + await expect(targetRow).toBeVisible(); + + const resourceName = (await targetRow.locator('td').nth(1).locator('a, span').first().innerText()).trim(); + const cpuCellBefore = (await targetRow.locator('td').nth(2).innerText()).trim(); + + const overrideValue = cpuDefault === -1 ? 80 : Math.max(0, cpuDefault - 3); + if (overrideValue === cpuDefault) { + test.skip(true, `Computed overrideValue equals default (${cpuDefault})`); + } + + await targetRow.locator('button[title="Edit thresholds"]').click(); + const cancelButton = proxmoxNodesSection.locator('button[title="Cancel editing"]').first(); + await expect(cancelButton).toBeVisible(); + const editedRow = cancelButton.locator('xpath=ancestor::tr[1]'); + + const cpuInput = editedRow.locator('input[type="number"]').first(); + await expect(cpuInput).toBeVisible(); + await cpuInput.fill(String(overrideValue)); + await cpuInput.blur(); const unsaved = page.getByText('You have unsaved changes'); await expect(unsaved).toBeVisible(); await page.getByRole('button', { name: 'Save Changes' }).click(); await expect(unsaved).not.toBeVisible(); - await expect(firstRow.getByText('Custom')).toBeVisible(); - await expect(firstRow.locator('button[title="Remove override"]')).toBeVisible(); + const updatedRow = proxmoxNodesSection.locator('table tbody tr').filter({ hasText: resourceName }).first(); + await expect(updatedRow).toBeVisible(); + await expect(updatedRow.getByText('Custom')).toBeVisible(); + await expect(updatedRow.locator('button[title="Remove override"]')).toBeVisible(); - await firstRow.locator('button[title="Remove override"]').click(); + const stateRes = await page.request.get('/api/state'); + expect(stateRes.ok()).toBeTruthy(); + const state = (await stateRes.json()) as { nodes?: Array<{ id: string; name: string }> }; + const nodeId = state.nodes?.find((n) => n.name === resourceName)?.id; + expect(nodeId).toBeTruthy(); + + const configResAfterCreate = await page.request.get('/api/alerts/config'); + expect(configResAfterCreate.ok()).toBeTruthy(); + const configAfterCreate = (await configResAfterCreate.json()) as { overrides?: Record }; + expect(configAfterCreate.overrides && Object.prototype.hasOwnProperty.call(configAfterCreate.overrides, nodeId as string)).toBeTruthy(); + + await updatedRow.locator('button[title="Remove override"]').click(); await expect(unsaved).toBeVisible(); await page.getByRole('button', { name: 'Save Changes' }).click(); await expect(unsaved).not.toBeVisible(); - await expect(firstRow.getByText('Custom')).not.toBeVisible(); + const configResAfterDelete = await page.request.get('/api/alerts/config'); + expect(configResAfterDelete.ok()).toBeTruthy(); + const configAfterDelete = (await configResAfterDelete.json()) as { overrides?: Record }; + expect(configAfterDelete.overrides && Object.prototype.hasOwnProperty.call(configAfterDelete.overrides, nodeId as string)).toBeFalsy(); + + const parseNumericCell = (raw: string) => { + const cleaned = raw.replace(/[^\d.-]+/g, ''); + const num = Number(cleaned); + return Number.isFinite(num) ? num : null; + }; + + await page.reload(); + if (!/\/alerts\/thresholds/.test(page.url())) { + await page.goto('/alerts/overview'); + await expect(page.getByRole('heading', { name: 'Alerts Overview' })).toBeVisible(); + const toggle = page.getByRole('checkbox', { name: /toggle alerts/i }); + await expect(toggle).toBeVisible(); + await toggle.setChecked(true, { force: true }); + await page.getByRole('button', { name: 'Thresholds' }).click(); + await expect(page).toHaveURL(/\/alerts\/thresholds/); + } + + await expect(page.getByRole('heading', { name: 'Alert Thresholds' })).toBeVisible(); + const rowAfterReload = page + .getByRole('heading', { name: 'Proxmox Nodes' }) + .locator('xpath=ancestor::*[.//table][1]') + .locator('table tbody tr') + .filter({ hasText: resourceName }) + .first(); + await expect(rowAfterReload).toBeVisible(); + await expect(rowAfterReload.getByText('Custom')).not.toBeVisible(); + const cpuCellAfter = (await rowAfterReload.locator('td').nth(2).innerText()).trim(); + expect(parseNumericCell(cpuCellAfter)).toBe(parseNumericCell(cpuCellBefore)); + + // Restore prior activation state to keep the test safe against real instances. + if (!alertsInitiallyEnabled) { + await page.goto('/alerts/overview'); + await expect(page.getByRole('heading', { name: 'Alerts Overview' })).toBeVisible(); + const alertsToggleRestore = page.getByRole('checkbox', { name: /toggle alerts/i }); + await expect(alertsToggleRestore).toBeVisible(); + await alertsToggleRestore.setChecked(false, { force: true }); + await expect(page.getByText('Alerts disabled', { exact: true })).toBeVisible(); + } }); test('Settings persistence - toggle auto update checks', async ({ page }) => { await ensureAuthenticated(page); await page.goto('/settings/system-updates'); - await expect(page.getByRole('heading', { name: 'Updates' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Updates', level: 1 })).toBeVisible(); const toggle = page.getByTestId('updates-auto-check-toggle'); const initial = await toggle.isChecked(); @@ -81,9 +189,23 @@ test.describe.serial('Core E2E flows', () => { await page.getByRole('button', { name: 'Save Changes' }).click(); await expect(unsaved).not.toBeVisible(); + // Wait for the backend to reflect the saved value (Settings reload happens asynchronously). + await expect + .poll( + async () => { + const res = await page.request.get('/api/system/settings'); + if (!res.ok()) return null; + const json = (await res.json()) as { autoUpdateEnabled?: boolean }; + return Boolean(json.autoUpdateEnabled); + }, + { timeout: 10_000 }, + ) + .toBe(!initial); + + // The settings page fetches system settings asynchronously; wait until the toggle reflects persisted state. await page.reload(); - await expect(page.getByRole('heading', { name: 'Updates' })).toBeVisible(); - expect(await page.getByTestId('updates-auto-check-toggle').isChecked()).toBe(!initial); + await expect(page.getByRole('heading', { name: 'Updates', level: 1 })).toBeVisible(); + await expect(page.getByTestId('updates-auto-check-toggle')).toBeChecked({ checked: !initial }); // Restore previous state to keep the test safe against real instances await page.getByTestId('updates-auto-check-toggle').setChecked(initial, { force: true }); diff --git a/tests/integration/tests/helpers.ts b/tests/integration/tests/helpers.ts index 098b744..0c93f01 100644 --- a/tests/integration/tests/helpers.ts +++ b/tests/integration/tests/helpers.ts @@ -9,7 +9,8 @@ import { Page, expect } from '@playwright/test'; */ export const ADMIN_CREDENTIALS = { username: 'admin', - password: 'admin', + // Pulse enforces a minimum password length of 12 characters. + password: 'adminadminadmin', }; const DEFAULT_E2E_BOOTSTRAP_TOKEN = '0123456789abcdef0123456789abcdef0123456789abcdef'; @@ -91,8 +92,8 @@ export async function maybeCompleteSetupWizard(page: Page) { export async function loginAsAdmin(page: Page) { await page.goto('/'); await page.waitForSelector('input[name="username"]', { state: 'visible' }); - await page.fill('input[name="username"]', ADMIN_CREDENTIALS.username); - await page.fill('input[name="password"]', ADMIN_CREDENTIALS.password); + await page.fill('input[name="username"]', E2E_CREDENTIALS.username); + await page.fill('input[name="password"]', E2E_CREDENTIALS.password); await page.click('button[type="submit"]'); // Wait for redirect to dashboard @@ -101,11 +102,36 @@ export async function loginAsAdmin(page: Page) { export async function login(page: Page, credentials = E2E_CREDENTIALS) { await page.goto('/'); - await page.waitForSelector('input[name="username"]', { state: 'visible' }); + await page.waitForLoadState('domcontentloaded'); + + const authenticatedURL = /\/(proxmox|dashboard|nodes|hosts|docker)/; + const usernameInput = page.locator('input[name="username"]'); + + const state = await Promise.race([ + usernameInput + .waitFor({ state: 'visible', timeout: 10_000 }) + .then(() => 'login') + .catch(() => undefined), + page + .waitForURL(authenticatedURL, { timeout: 10_000 }) + .then(() => 'authenticated') + .catch(() => undefined), + ]); + + if (state === 'authenticated') { + return; + } + + if (state !== 'login') { + const url = page.url(); + const preview = ((await page.locator('body').textContent()) || '').replace(/\s+/g, ' ').slice(0, 200); + throw new Error(`Login did not render and did not redirect (url=${url}, body="${preview}")`); + } + await page.fill('input[name="username"]', credentials.username); await page.fill('input[name="password"]', credentials.password); await page.click('button[type="submit"]'); - await page.waitForURL(/\/(proxmox|dashboard|nodes|hosts|docker)/); + await page.waitForURL(authenticatedURL); } export async function ensureAuthenticated(page: Page) { @@ -119,6 +145,7 @@ export async function logout(page: Page) { const logoutButton = page.locator('button[aria-label="Logout"]').first(); await expect(logoutButton).toBeVisible(); await logoutButton.click(); + await page.waitForURL(/\/$/, { timeout: 15000 }); await expect(page.locator('input[name="username"]')).toBeVisible(); }