Stabilize core E2E tests

- Preserve alerts activation state when saving thresholds
- Use compliant default E2E password and deterministic bootstrap token seeding
- Harden Playwright selectors, waits, and diagnostics gating
This commit is contained in:
rcourtman 2025-12-17 19:36:48 +00:00
parent f907440f6b
commit 65a4534be9
10 changed files with 213 additions and 28 deletions

View file

@ -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(),

View file

@ -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)

View file

@ -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

View file

@ -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
```

View file

@ -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

View file

@ -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' }]
],

View file

@ -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 });

View file

@ -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

View file

@ -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<string, unknown> };
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<string, unknown> };
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 });

View file

@ -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();
}