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:
parent
f907440f6b
commit
65a4534be9
10 changed files with 213 additions and 28 deletions
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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' }]
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue