fix(ai): improve AI settings UX with validation and smart fallbacks

Backend:
- Add smart provider fallback when selected model's provider isn't configured
- Automatically switch to a model from a configured provider instead of failing
- Log warning when fallback occurs for visibility

Frontend (AISettings.tsx):
- Add helper functions to check if model's provider is configured
- Group model dropdown: configured providers first, unconfigured marked with ⚠️
- Add inline warning when selecting model from unconfigured provider
- Validate on save that model's provider is configured (or being added)
- Warn before clearing last configured provider (would disable AI)
- Warn before clearing provider that current model uses
- Add patrol interval validation (must be 0 or >= 10 minutes)
- Show red border + inline error for invalid patrol intervals 1-9
- Update patrol interval hint: '(0=off, 10+ to enable)'

These changes prevent confusing '500 Internal Server Error' and
'AI is not enabled or configured' errors when model/provider mismatch.
This commit is contained in:
rcourtman
2025-12-17 18:30:19 +00:00
parent c4b893e257
commit 54fc259221
15 changed files with 766 additions and 138 deletions

78
.github/workflows/test-e2e.yml vendored Normal file
View File

@@ -0,0 +1,78 @@
name: Core E2E Tests
on:
pull_request:
branches:
- main
paths:
- 'frontend-modern/**'
- 'internal/**'
- 'tests/integration/**'
- 'Dockerfile'
- '.github/workflows/test-e2e.yml'
push:
branches:
- main
- master
paths:
- 'frontend-modern/**'
- 'internal/**'
- 'tests/integration/**'
- 'Dockerfile'
- '.github/workflows/test-e2e.yml'
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
e2e:
name: Playwright Core E2E
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: tests/integration/package-lock.json
- name: Install Playwright dependencies
working-directory: tests/integration
run: |
npm ci
npx playwright install --with-deps chromium
- name: Build Docker images for test environment
run: |
docker build -t pulse-mock-github:test ./tests/integration/mock-github-server
docker build -t pulse:test -f Dockerfile .
- name: Run E2E suite
working-directory: tests/integration
env:
PULSE_E2E_BOOTSTRAP_TOKEN: 0123456789abcdef0123456789abcdef0123456789abcdef
run: npm test
- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: tests/integration/playwright-report/
retention-days: 30
- name: Upload test videos and screenshots
if: failure()
uses: actions/upload-artifact@v4
with:
name: test-failures
path: tests/integration/test-results/
retention-days: 7

View File

@@ -38,6 +38,24 @@ function getProviderFromModelId(modelId: string): string {
return 'ollama';
}
// Check if a provider is configured based on settings
function isProviderConfigured(provider: string, settings: AISettingsType | null): boolean {
if (!settings) return false;
switch (provider) {
case 'anthropic': return settings.anthropic_configured;
case 'openai': return settings.openai_configured;
case 'deepseek': return settings.deepseek_configured;
case 'ollama': return settings.ollama_configured;
default: return false;
}
}
// Check if a model's provider is configured
function isModelProviderConfigured(modelId: string, settings: AISettingsType | null): boolean {
const provider = getProviderFromModelId(modelId);
return isProviderConfigured(provider, settings);
}
// Group models by provider for optgroup rendering
function groupModelsByProvider(models: { id: string; name: string; description?: string }[]): Map<string, { id: string; name: string; description?: string }[]> {
const grouped = new Map<string, { id: string; name: string; description?: string }[]>();
@@ -243,11 +261,39 @@ export const AISettings: Component = () => {
const handleSave = async (event?: Event) => {
event?.preventDefault();
// Frontend validation: warn if model's provider isn't configured
const selectedModel = form.model.trim();
if (selectedModel && form.enabled) {
const modelProvider = getProviderFromModelId(selectedModel);
if (!isProviderConfigured(modelProvider, settings())) {
// Check if any API key is being added in this save for this provider
const isAddingCredential =
(modelProvider === 'anthropic' && form.anthropicApiKey.trim()) ||
(modelProvider === 'openai' && form.openaiApiKey.trim()) ||
(modelProvider === 'deepseek' && form.deepseekApiKey.trim()) ||
(modelProvider === 'ollama' && form.ollamaBaseUrl.trim());
if (!isAddingCredential) {
notificationStore.error(
`Cannot save: Model "${selectedModel}" requires ${PROVIDER_DISPLAY_NAMES[modelProvider] || modelProvider} to be configured. ` +
`Please add an API key for ${PROVIDER_DISPLAY_NAMES[modelProvider] || modelProvider} or select a different model.`
);
return;
}
}
}
// Validate patrol interval (must be 0 or >= 10)
if (form.patrolIntervalMinutes > 0 && form.patrolIntervalMinutes < 10) {
notificationStore.error('Patrol interval must be at least 10 minutes (or 0 to disable)');
return;
}
setSaving(true);
try {
const payload: Record<string, unknown> = {
provider: form.provider,
model: form.model.trim(),
model: selectedModel,
};
// Only include base_url if it's set or if provider is ollama
@@ -384,7 +430,25 @@ export const AISettings: Component = () => {
};
const handleClearProvider = async (provider: string) => {
if (!confirm(`Clear ${provider} credentials? You'll need to re-enter them to use this provider.`)) {
// Check if this is the last configured provider
const s = settings();
const configuredCount = [s?.anthropic_configured, s?.openai_configured, s?.deepseek_configured, s?.ollama_configured].filter(Boolean).length;
const isLastProvider = configuredCount === 1 && isProviderConfigured(provider, s);
// Check if current model uses this provider
const currentModel = form.model.trim();
const modelUsesProvider = currentModel && getProviderFromModelId(currentModel) === provider;
let confirmMessage = `Clear ${PROVIDER_DISPLAY_NAMES[provider] || provider} credentials?`;
if (isLastProvider) {
confirmMessage = `⚠️ This is your only configured provider! Clearing it will disable AI until you configure another provider. Continue?`;
} else if (modelUsesProvider) {
confirmMessage = `Your current model uses ${PROVIDER_DISPLAY_NAMES[provider] || provider}. Clearing this will require selecting a different model. Continue?`;
} else {
confirmMessage += ` You'll need to re-enter credentials to use this provider.`;
}
if (!confirm(confirmMessage)) {
return;
}
@@ -545,7 +609,8 @@ export const AISettings: Component = () => {
<Show when={!form.model || !availableModels().some(m => m.id === form.model)}>
<option value={form.model}>{form.model || 'Select a model...'}</option>
</Show>
<For each={Array.from(groupModelsByProvider(availableModels()).entries())}>
{/* Show configured providers first */}
<For each={Array.from(groupModelsByProvider(availableModels()).entries()).filter(([p]) => isProviderConfigured(p, settings()))}>
{([provider, models]) => (
<optgroup label={PROVIDER_DISPLAY_NAMES[provider] || provider}>
<For each={models}>
@@ -558,8 +623,32 @@ export const AISettings: Component = () => {
</optgroup>
)}
</For>
{/* Show unconfigured providers in a separate section with warning */}
<For each={Array.from(groupModelsByProvider(availableModels()).entries()).filter(([p]) => !isProviderConfigured(p, settings()))}>
{([provider, models]) => (
<optgroup label={`⚠️ ${PROVIDER_DISPLAY_NAMES[provider] || provider} (not configured)`}>
<For each={models}>
{(model) => (
<option value={model.id} class="text-gray-400">
{model.name || model.id.split(':').pop()}
</option>
)}
</For>
</optgroup>
)}
</For>
</select>
</Show>
{/* Warning if selected model's provider is not configured */}
<Show when={form.model && !isModelProviderConfigured(form.model, settings())}>
<p class="text-xs text-amber-600 dark:text-amber-400 mt-1 flex items-center gap-1">
<svg class="w-3.5 h-3.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
This model requires {PROVIDER_DISPLAY_NAMES[getProviderFromModelId(form.model)] || getProviderFromModelId(form.model)} to be configured.
Add an API key below or select a different model.
</p>
</Show>
</div>
{/* Advanced Model Selection - Collapsible */}
@@ -1017,22 +1106,30 @@ export const AISettings: Component = () => {
<Show when={showPatrolSettings()}>
<div class="px-3 py-3 bg-white dark:bg-gray-800/50 border-t border-gray-200 dark:border-gray-700 space-y-3">
{/* Patrol Interval - Compact */}
<div class="flex items-center gap-3">
<label class="text-xs font-medium text-gray-600 dark:text-gray-400 w-32 flex-shrink-0">Patrol Interval</label>
<input
type="number"
class="w-20 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700"
value={form.patrolIntervalMinutes}
onInput={(e) => {
const value = parseInt(e.currentTarget.value, 10);
if (!isNaN(value)) setForm('patrolIntervalMinutes', Math.max(0, value));
}}
min={0}
max={10080}
step={15}
disabled={saving()}
/>
<span class="text-xs text-gray-500">min (0 = disabled)</span>
<div class="flex flex-col gap-1">
<div class="flex items-center gap-3">
<label class="text-xs font-medium text-gray-600 dark:text-gray-400 w-32 flex-shrink-0">Patrol Interval</label>
<input
type="number"
class={`w-20 px-2 py-1 text-sm border rounded bg-white dark:bg-gray-700 ${form.patrolIntervalMinutes > 0 && form.patrolIntervalMinutes < 10
? 'border-red-300 dark:border-red-600'
: 'border-gray-300 dark:border-gray-600'
}`}
value={form.patrolIntervalMinutes}
onInput={(e) => {
const value = parseInt(e.currentTarget.value, 10);
if (!isNaN(value)) setForm('patrolIntervalMinutes', Math.max(0, value));
}}
min={0}
max={10080}
step={15}
disabled={saving()}
/>
<span class="text-xs text-gray-500">min (0=off, 10+ to enable)</span>
</div>
<Show when={form.patrolIntervalMinutes > 0 && form.patrolIntervalMinutes < 10}>
<p class="text-xs text-red-500 ml-32 pl-3">Minimum interval is 10 minutes</p>
</Show>
</div>
{/* Alert Analysis Toggle - Compact */}

View File

@@ -591,6 +591,7 @@ sudo tar -xzf pulse-${props.updateInfo()?.latestVersion}-linux-amd64.tar.gz -C /
<label class="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
data-testid="updates-auto-check-toggle"
checked={props.autoUpdateEnabled()}
onChange={(e) => {
props.setAutoUpdateEnabled(e.currentTarget.checked);

View File

@@ -410,14 +410,26 @@ func (d *Detector) saveToDisk() error {
}
d.mu.RLock()
eventsSnapshot := make([]Event, len(d.events))
copy(eventsSnapshot, d.events)
correlationsSnapshot := make(map[string]*Correlation, len(d.correlations))
for k, v := range d.correlations {
if v == nil {
continue
}
c := *v
correlationsSnapshot[k] = &c
}
d.mu.RUnlock()
data := struct {
Events []Event `json:"events"`
Correlations map[string]*Correlation `json:"correlations"`
}{
Events: d.events,
Correlations: d.correlations,
Events: eventsSnapshot,
Correlations: correlationsSnapshot,
}
d.mu.RUnlock()
jsonData, err := json.MarshalIndent(data, "", " ")
if err != nil {

View File

@@ -675,14 +675,55 @@ func (s *Service) LoadConfig() error {
return nil
}
} else {
log.Warn().
Err(err).
Str("selected_model", selectedModel).
Str("selected_provider", selectedProvider).
Strs("configured_providers", cfg.GetConfiguredProviders()).
Msg("AI enabled but selected provider is not configured; check API keys or model selection")
s.provider = nil
return nil
// Smart fallback: if selected provider isn't configured but OTHER providers are,
// automatically switch to a model from a configured provider.
// This prevents confusing errors when the user has e.g. DeepSeek configured
// but the model is still set to an Anthropic model.
configuredProviders := cfg.GetConfiguredProviders()
if len(configuredProviders) > 0 {
fallbackProvider := configuredProviders[0]
var fallbackModel string
switch fallbackProvider {
case config.AIProviderAnthropic:
fallbackModel = config.AIProviderAnthropic + ":" + config.DefaultAIModelAnthropic
case config.AIProviderOpenAI:
fallbackModel = config.AIProviderOpenAI + ":" + config.DefaultAIModelOpenAI
case config.AIProviderDeepSeek:
fallbackModel = config.AIProviderDeepSeek + ":" + config.DefaultAIModelDeepSeek
case config.AIProviderOllama:
fallbackModel = config.AIProviderOllama + ":" + config.DefaultAIModelOllama
}
if fallbackModel != "" {
log.Warn().
Str("selected_model", selectedModel).
Str("selected_provider", selectedProvider).
Str("fallback_model", fallbackModel).
Str("fallback_provider", fallbackProvider).
Msg("Selected provider not configured - automatically falling back to configured provider")
providerClient, err = providers.NewForModel(cfg, fallbackModel)
if err == nil {
selectedModel = fallbackModel
selectedProvider = fallbackProvider
} else {
log.Error().Err(err).Str("fallback_model", fallbackModel).Msg("Failed to create fallback provider")
s.provider = nil
return nil
}
}
}
if providerClient == nil {
log.Warn().
Err(err).
Str("selected_model", selectedModel).
Str("selected_provider", selectedProvider).
Strs("configured_providers", cfg.GetConfiguredProviders()).
Msg("AI enabled but no providers configured")
s.provider = nil
return nil
}
}
}

View File

@@ -106,37 +106,15 @@ npm run test:report
## Test Scenarios
### 1. Happy Path (`01-happy-path.spec.ts`)
- Valid checksums, successful update flow
- Tests complete update from UI to backend
- Verifies modal appears exactly once
### 1. Diagnostic Smoke Test (`00-diagnostic.spec.ts`)
- Ensures the containerized stack boots and the UI renders.
### 2. Bad Checksums (`02-bad-checksums.spec.ts`)
- Server rejects update due to invalid checksums
- UI shows error **once** (not twice)
- Error messages are user-friendly
### 3. Rate Limiting (`03-rate-limiting.spec.ts`)
- Multiple rapid requests are throttled gracefully
- Proper rate limit headers returned
- Clear error messages when limited
### 4. Network Failure (`04-network-failure.spec.ts`)
- UI retries with exponential backoff
- Handles timeouts gracefully
- Shows appropriate loading states
### 5. Stale Release (`05-stale-release.spec.ts`)
- Backend refuses to install flagged releases
- Proper error messages about why release is rejected
- No backup created for rejected releases
### 6. Frontend Validation (`06-frontend-validation.spec.ts`)
- UpdateProgressModal appears exactly once
- Error messages are user-friendly (not raw API errors)
- Modal can be dismissed after error
- No duplicate modals on error
- Proper accessibility attributes
### 2. Core E2E Flows (`01-core-e2e.spec.ts`)
- First-run setup wizard (fresh instance)
- Login/logout + authenticated state
- Alerts thresholds create/delete
- Settings persistence across refresh
- Add/delete a Proxmox node (test-only)
## Troubleshooting

View File

@@ -1,6 +1,6 @@
# Update Integration Tests
# Integration Tests (Playwright)
End-to-end tests for the Pulse update flow, validating the entire path from UI to backend.
End-to-end Playwright tests that validate critical user flows against a running Pulse instance.
## Architecture
@@ -14,37 +14,52 @@ End-to-end tests for the Pulse update flow, validating the entire path from UI t
## Test Scenarios
> **Note:** The comprehensive Playwright update specs were removed on 20251112 after repeated
> release-blocking flakes. We now rely on:
>
> 1. `tests/00-diagnostic.spec.ts` — ensures the containerized stack boots and the login page renders.
> 2. `tests/integration/api/update_flow_test.go` — drives the `/api/updates/*` endpoints directly to
> verify the backend can discover, plan, apply, and complete an update.
>
> Reintroduce full UI coverage once we have deterministic fixtures and selectors for the update flow.
- `tests/00-diagnostic.spec.ts` — smoke test that the stack boots and the UI renders.
- `tests/01-core-e2e.spec.ts` — critical UI flows:
- Bootstrap setup wizard (fresh instance)
- Login + authenticated state
- Alerts thresholds create/delete
- Settings persistence across refresh
- Add/delete a Proxmox node (test-only)
## Running Tests
### Local Development
### Local Development (Docker compose stack)
```bash
# Start test environment
cd tests/integration
docker-compose up -d
./scripts/setup.sh # one-time (installs deps + builds docker images)
npm test
```
# Run diagnostic Playwright test
npx playwright test tests/00-diagnostic.spec.ts
The docker-compose stack seeds a deterministic bootstrap token for first-run setup:
- Override via `PULSE_E2E_BOOTSTRAP_TOKEN`
- Default token value is defined in `tests/integration/docker-compose.test.yml`
# Run API integration test from repo root
UPDATE_API_BASE_URL=http://localhost:7655 go test ./tests/integration/api -run TestUpdateFlowIntegration
Credentials used by the E2E suite can be overridden:
- `PULSE_E2E_USERNAME` (default `admin`)
- `PULSE_E2E_PASSWORD` (default `admin`)
- `PULSE_E2E_ALLOW_NODE_MUTATION=1` to enable the optional "Add Proxmox node" test (disabled by default for safety)
# Cleanup
docker-compose down -v
### Run Against An Existing Pulse Instance
```bash
cd tests/integration
PULSE_E2E_SKIP_DOCKER=1 \
PULSE_BASE_URL='http://your-pulse-host:7655' \
PULSE_E2E_USERNAME='admin' \
PULSE_E2E_PASSWORD='admin' \
npm test
```
If the instance is behind self-signed TLS:
```bash
PULSE_E2E_INSECURE_TLS=1 PULSE_E2E_SKIP_DOCKER=1 PULSE_BASE_URL='https://...' npm test
```
### CI Pipeline
Tests run automatically on every PR touching update code via `.github/workflows/test-updates.yml`
- Core E2E flows run via `.github/workflows/test-e2e.yml`
- Update flow coverage remains in `.github/workflows/test-updates.yml`
## Test Data
## Test Data (Update Flow Only)
The mock GitHub server (`mock-github-server/`) provides controllable responses:
- `/api/releases` - List all releases
@@ -60,7 +75,5 @@ Response behavior can be controlled via environment variables:
## Success Criteria
-Tests run in CI on every PR touching update code
-All scenarios pass reliably
- ✅ Tests catch checksum validation issues automatically
- ✅ Frontend UX regressions are blocked
-Core E2E flows pass reliably in CI
-Update flow remains covered via API integration test + smoke UI check

View File

@@ -1,12 +1,24 @@
version: '3.8'
services:
# Seed a deterministic bootstrap token for first-run setup E2E flows.
# This is only used by the test stack and is safe to keep deterministic.
seed-bootstrap-token:
image: alpine:3.20
container_name: pulse-test-seed-bootstrap-token
environment:
- PULSE_E2E_BOOTSTRAP_TOKEN=${PULSE_E2E_BOOTSTRAP_TOKEN:-0123456789abcdef0123456789abcdef0123456789abcdef}
volumes:
- test-data:/data
command: >
sh -c "set -e; umask 077; echo \"$PULSE_E2E_BOOTSTRAP_TOKEN\" > /data/.bootstrap_token"
networks:
- test-network
# Mock GitHub API server for controlled testing
mock-github:
image: pulse-mock-github:test
container_name: pulse-mock-github
ports:
- "8080:8080"
- "${PULSE_E2E_MOCK_GITHUB_PORT:-8080}:8080"
environment:
- PORT=8080
- MOCK_BASE_URL=http://mock-github:8080
@@ -29,7 +41,7 @@ services:
image: pulse:test
container_name: pulse-test-server
ports:
- "7655:7655"
- "${PULSE_E2E_PORT:-7655}:7655"
environment:
- TZ=UTC
# Point to mock GitHub server
@@ -42,12 +54,11 @@ services:
- PULSE_MOCK_MODE=true
- PULSE_ALLOW_DOCKER_UPDATES=true
- PULSE_UPDATE_STAGE_DELAY_MS=250
# Pre-configure authentication to bypass first-run setup
- PULSE_AUTH_USER=admin
- PULSE_AUTH_PASS=admin
volumes:
- test-data:/data
depends_on:
seed-bootstrap-token:
condition: service_completed_successfully
mock-github:
condition: service_healthy
healthcheck:

View File

@@ -1,7 +1,7 @@
{
"name": "pulse-integration-tests",
"version": "1.0.0",
"description": "Integration tests for Pulse update flow",
"description": "Integration tests for Pulse (Playwright E2E)",
"type": "module",
"scripts": {
"test": "playwright test",
@@ -13,8 +13,8 @@
"docker:down": "docker-compose -f docker-compose.test.yml down -v",
"docker:logs": "docker-compose -f docker-compose.test.yml logs -f",
"docker:rebuild": "docker-compose -f docker-compose.test.yml up -d --build",
"pretest": "npm run docker:up && sleep 10",
"posttest": "npm run docker:down"
"pretest": "node ./scripts/pretest.mjs",
"posttest": "node ./scripts/posttest.mjs"
},
"keywords": ["pulse", "integration", "e2e", "playwright"],
"author": "rcourtman",

View File

@@ -35,7 +35,12 @@ export default defineConfig({
/* Shared settings for all projects */
use: {
/* Base URL for all tests */
baseURL: 'http://localhost:7655',
baseURL: process.env.PULSE_BASE_URL || process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:7655',
/* Allow testing against self-signed TLS when explicitly enabled */
ignoreHTTPSErrors: ['1', 'true', 'yes', 'on'].includes(
String(process.env.PULSE_E2E_INSECURE_TLS || '').trim().toLowerCase(),
),
/* Collect trace when retrying the failed test */
trace: 'on-first-retry',
@@ -59,8 +64,6 @@ export default defineConfig({
name: 'chromium',
use: {
...devices['Desktop Chrome'],
// Use headless mode in CI
headless: !!process.env.CI,
},
},

View File

@@ -0,0 +1,44 @@
import { spawn } from 'node:child_process';
const truthy = (value) => {
if (!value) return false;
return ['1', 'true', 'yes', 'on'].includes(String(value).trim().toLowerCase());
};
if (truthy(process.env.PULSE_E2E_SKIP_DOCKER)) {
console.log('[integration] PULSE_E2E_SKIP_DOCKER enabled, skipping docker compose down');
process.exit(0);
}
const run = (command, args, options = {}) =>
new Promise((resolve, reject) => {
const child = spawn(command, args, { stdio: 'inherit', ...options });
child.on('error', reject);
child.on('close', (code) => {
if (code === 0) resolve();
else reject(new Error(`${command} ${args.join(' ')} exited with code ${code}`));
});
});
const canRun = async (command, args) => {
try {
await run(command, args, { stdio: 'ignore' });
return true;
} catch {
return false;
}
};
const useDockerCompose = !(await canRun('docker', ['compose', 'version']));
try {
if (useDockerCompose) {
await run('docker-compose', ['-f', 'docker-compose.test.yml', 'down', '-v']);
} else {
await run('docker', ['compose', '-f', 'docker-compose.test.yml', 'down', '-v']);
}
} catch (err) {
// Avoid masking test failures with cleanup errors
console.warn('[integration] docker compose down failed:', err?.message || err);
}

View File

@@ -0,0 +1,70 @@
import { spawn } from 'node:child_process';
const truthy = (value) => {
if (!value) return false;
return ['1', 'true', 'yes', 'on'].includes(String(value).trim().toLowerCase());
};
const shouldSkipDocker = truthy(process.env.PULSE_E2E_SKIP_DOCKER);
const shouldSkipPlaywrightInstall = truthy(process.env.PULSE_E2E_SKIP_PLAYWRIGHT_INSTALL);
const run = (command, args, options = {}) =>
new Promise((resolve, reject) => {
const child = spawn(command, args, { stdio: 'inherit', ...options });
child.on('error', reject);
child.on('close', (code) => {
if (code === 0) resolve();
else reject(new Error(`${command} ${args.join(' ')} exited with code ${code}`));
});
});
const npxCmd = process.platform === 'win32' ? 'npx.cmd' : 'npx';
const canRun = async (command, args) => {
try {
await run(command, args, { stdio: 'ignore' });
return true;
} catch {
return false;
}
};
const waitForHealth = async (healthURL, timeoutMs = 120_000) => {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
try {
const res = await fetch(healthURL, { method: 'GET' });
if (res.ok) return;
} catch {
// ignore and retry
}
await new Promise((r) => setTimeout(r, 1000));
}
throw new Error(`Timed out waiting for ${healthURL}`);
};
if (truthy(process.env.PULSE_E2E_INSECURE_TLS)) {
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
}
if (!shouldSkipPlaywrightInstall) {
await run(npxCmd, ['playwright', 'install', 'chromium']);
}
if (shouldSkipDocker) {
console.log('[integration] PULSE_E2E_SKIP_DOCKER enabled, skipping docker compose up');
process.exit(0);
}
const composeArgs = ['compose', '-f', 'docker-compose.test.yml', 'up', '-d'];
const legacyComposeArgs = ['-f', 'docker-compose.test.yml', 'up', '-d'];
const useDockerCompose = !(await canRun('docker', ['compose', 'version']));
if (useDockerCompose) {
await run('docker-compose', legacyComposeArgs);
} else {
await run('docker', composeArgs);
}
const baseURL = (process.env.PULSE_BASE_URL || 'http://localhost:7655').replace(/\/+$/, '');
await waitForHealth(`${baseURL}/api/health`);

View File

@@ -1,8 +1,8 @@
#!/bin/bash
#
# Run update integration tests with different configurations
# Usage: ./run-tests.sh [test-suite]
# test-suite: all, happy, checksums, rate-limit, network, stale, frontend
# Run Pulse integration tests with different suites
# Usage: ./run-tests.sh [suite]
# suite: all, core, diagnostic, updates-api
#
set -e
@@ -10,7 +10,7 @@ set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TEST_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
TEST_SUITE="${1:-all}"
SUITE="${1:-all}"
# Colors for output
RED='\033[0;31m'
@@ -25,10 +25,10 @@ echo ""
cd "$TEST_ROOT"
# Function to run test with specific config
run_test() {
# Function to run suite with specific mock config
run_suite() {
local name="$1"
local file="$2"
local suite="$2"
local checksum_error="${3:-false}"
local network_error="${4:-false}"
local rate_limit="${5:-false}"
@@ -50,7 +50,12 @@ run_test() {
# Wait for services
echo "Waiting for services to be ready..."
sleep 15
for i in {1..60}; do
if curl -fsS "http://localhost:7655/api/health" >/dev/null 2>&1; then
break
fi
sleep 1
done
# Check if services are healthy
if ! docker-compose -f docker-compose.test.yml ps | grep -q "Up"; then
@@ -62,12 +67,30 @@ run_test() {
# Run tests
echo "Running tests..."
if npx playwright test "$file" --reporter=list; then
set +e
case "$suite" in
diagnostic)
npx playwright test "tests/00-diagnostic.spec.ts" --reporter=list
;;
core)
npx playwright test "tests/01-core-e2e.spec.ts" --reporter=list
;;
updates-api)
UPDATE_API_BASE_URL=http://localhost:7655 go test ./api -run TestUpdateFlowIntegration -count=1
;;
*)
echo "Unknown suite: $suite"
set -e
return 1
;;
esac
TEST_RESULT=$?
set -e
if [ $TEST_RESULT -eq 0 ]; then
echo -e "${GREEN}$name passed${NC}"
TEST_RESULT=0
else
echo -e "${RED}$name failed${NC}"
TEST_RESULT=1
fi
# Cleanup
@@ -80,45 +103,29 @@ run_test() {
# Run specific test suite or all tests
FAILED_TESTS=()
case "$TEST_SUITE" in
case "$SUITE" in
all)
echo "Running all test suites..."
run_test "Happy Path" "tests/01-happy-path.spec.ts" || FAILED_TESTS+=("Happy Path")
run_test "Bad Checksums" "tests/02-bad-checksums.spec.ts" "true" || FAILED_TESTS+=("Bad Checksums")
run_test "Rate Limiting" "tests/03-rate-limiting.spec.ts" "false" "false" "true" || FAILED_TESTS+=("Rate Limiting")
run_test "Network Failures" "tests/04-network-failure.spec.ts" "false" "true" || FAILED_TESTS+=("Network Failures")
run_test "Stale Releases" "tests/05-stale-release.spec.ts" "false" "false" "false" "true" || FAILED_TESTS+=("Stale Releases")
run_test "Frontend Validation" "tests/06-frontend-validation.spec.ts" || FAILED_TESTS+=("Frontend Validation")
echo "Running all suites..."
run_suite "Diagnostic Smoke" "diagnostic" || FAILED_TESTS+=("Diagnostic Smoke")
run_suite "Core E2E" "core" || FAILED_TESTS+=("Core E2E")
run_suite "Update API Integration" "updates-api" || FAILED_TESTS+=("Update API Integration")
;;
happy)
run_test "Happy Path" "tests/01-happy-path.spec.ts" || FAILED_TESTS+=("Happy Path")
diagnostic)
run_suite "Diagnostic Smoke" "diagnostic" || FAILED_TESTS+=("Diagnostic Smoke")
;;
checksums)
run_test "Bad Checksums" "tests/02-bad-checksums.spec.ts" "true" || FAILED_TESTS+=("Bad Checksums")
core)
run_suite "Core E2E" "core" || FAILED_TESTS+=("Core E2E")
;;
rate-limit)
run_test "Rate Limiting" "tests/03-rate-limiting.spec.ts" "false" "false" "true" || FAILED_TESTS+=("Rate Limiting")
;;
network)
run_test "Network Failures" "tests/04-network-failure.spec.ts" "false" "true" || FAILED_TESTS+=("Network Failures")
;;
stale)
run_test "Stale Releases" "tests/05-stale-release.spec.ts" "false" "false" "false" "true" || FAILED_TESTS+=("Stale Releases")
;;
frontend)
run_test "Frontend Validation" "tests/06-frontend-validation.spec.ts" || FAILED_TESTS+=("Frontend Validation")
updates-api)
run_suite "Update API Integration" "updates-api" || FAILED_TESTS+=("Update API Integration")
;;
*)
echo "Unknown test suite: $TEST_SUITE"
echo "Available suites: all, happy, checksums, rate-limit, network, stale, frontend"
echo "Unknown suite: $SUITE"
echo "Available suites: all, diagnostic, core, updates-api"
exit 1
;;
esac

View File

@@ -0,0 +1,158 @@
import { test, expect } from '@playwright/test';
import {
E2E_CREDENTIALS,
ensureAuthenticated,
getMockMode,
login,
logout,
setMockMode,
} from './helpers';
const truthy = (value: string | undefined) => {
if (!value) return false;
return ['1', 'true', 'yes', 'on'].includes(value.trim().toLowerCase());
};
test.describe.serial('Core E2E flows', () => {
test('Bootstrap flow - setup wizard and dashboard', async ({ page }) => {
await ensureAuthenticated(page);
await expect(page).toHaveURL(/\/proxmox\/overview/);
await expect(page.locator('#root')).toBeVisible();
});
test('Login flow - logout and re-login', async ({ page }) => {
await ensureAuthenticated(page);
await logout(page);
await login(page, E2E_CREDENTIALS);
const stateRes = await page.request.get('/api/state');
expect(stateRes.ok()).toBeTruthy();
});
test('Alerts page - create and delete threshold override', async ({ page }) => {
await ensureAuthenticated(page);
await page.goto('/alerts/thresholds/proxmox');
await expect(page.getByRole('heading', { name: 'Alert Thresholds' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Proxmox Nodes' })).toBeVisible();
const proxmoxNodesSection = page
.getByRole('heading', { name: 'Proxmox Nodes' })
.locator('xpath=ancestor::*[.//table][1]');
const firstRow = proxmoxNodesSection.locator('table tbody tr').first();
await expect(firstRow).toBeVisible();
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 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();
await firstRow.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();
});
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();
const toggle = page.getByTestId('updates-auto-check-toggle');
const initial = await toggle.isChecked();
await toggle.setChecked(!initial, { force: true });
const unsaved = page.getByText('Unsaved changes');
await expect(unsaved).toBeVisible();
await page.getByRole('button', { name: 'Save Changes' }).click();
await expect(unsaved).not.toBeVisible();
await page.reload();
await expect(page.getByRole('heading', { name: 'Updates' })).toBeVisible();
expect(await page.getByTestId('updates-auto-check-toggle').isChecked()).toBe(!initial);
// Restore previous state to keep the test safe against real instances
await page.getByTestId('updates-auto-check-toggle').setChecked(initial, { force: true });
await expect(page.getByText('Unsaved changes')).toBeVisible();
await page.getByRole('button', { name: 'Save Changes' }).click();
await expect(page.getByText('Unsaved changes')).not.toBeVisible();
});
test('Add Proxmox node - appears in UI', async ({ page }) => {
test.skip(
!truthy(process.env.PULSE_E2E_ALLOW_NODE_MUTATION),
'Set PULSE_E2E_ALLOW_NODE_MUTATION=1 to enable node mutation E2E',
);
await ensureAuthenticated(page);
const initialMockMode = await getMockMode(page);
if (initialMockMode.enabled) {
await setMockMode(page, false);
}
const nodeName = `e2e-pve-${Date.now()}`;
await page.goto('/settings/pve');
await page.getByRole('button', { name: 'Add PVE Node' }).click();
const modalForm = page.locator('form').filter({ hasText: 'Basic information' }).first();
await expect(modalForm).toBeVisible();
await modalForm
.locator('label:has-text("Node Name")')
.locator('..')
.locator('input')
.fill(nodeName);
await modalForm
.locator('label:has-text("Host URL")')
.locator('..')
.locator('input')
.fill('https://192.168.77.10:8006');
await modalForm
.locator('label:has-text("Token ID")')
.locator('..')
.locator('input')
.fill('pulse-monitor@pam!pulse-e2e');
await modalForm
.locator('label:has-text("Token Value")')
.locator('..')
.locator('input')
.fill('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa');
await modalForm.locator('button[type="submit"]').click();
await expect(modalForm).not.toBeVisible();
await expect(page.getByText(nodeName)).toBeVisible();
// Cleanup by deleting the node we just created (best-effort).
const nodesRes = await page.request.get('/api/config/nodes');
expect(nodesRes.ok()).toBeTruthy();
const nodes = (await nodesRes.json()) as Array<{ id: string; name: string }>;
const created = nodes.find((n) => n.name === nodeName);
expect(created).toBeTruthy();
if (created?.id) {
const delRes = await page.request.delete(`/api/config/nodes/${created.id}`);
expect(delRes.ok()).toBeTruthy();
}
if (initialMockMode.enabled) {
await setMockMode(page, true);
}
});
});

View File

@@ -12,6 +12,79 @@ export const ADMIN_CREDENTIALS = {
password: 'admin',
};
const DEFAULT_E2E_BOOTSTRAP_TOKEN = '0123456789abcdef0123456789abcdef0123456789abcdef';
export const E2E_CREDENTIALS = {
bootstrapToken: process.env.PULSE_E2E_BOOTSTRAP_TOKEN || DEFAULT_E2E_BOOTSTRAP_TOKEN,
username: process.env.PULSE_E2E_USERNAME || ADMIN_CREDENTIALS.username,
password: process.env.PULSE_E2E_PASSWORD || ADMIN_CREDENTIALS.password,
};
export async function waitForPulseReady(page: Page, timeoutMs = 120_000) {
const startedAt = Date.now();
let lastError: unknown = null;
while (Date.now() - startedAt < timeoutMs) {
try {
const res = await page.request.get('/api/health');
if (res.ok()) {
return;
}
lastError = new Error(`Health check returned ${res.status()}`);
} catch (err) {
lastError = err;
}
await page.waitForTimeout(1000);
}
throw lastError ?? new Error('Timed out waiting for Pulse to become ready');
}
type SecurityStatus = {
hasAuthentication?: boolean;
};
export async function getSecurityStatus(page: Page): Promise<SecurityStatus> {
const res = await page.request.get('/api/security/status');
if (!res.ok()) {
throw new Error(`Failed to fetch security status: ${res.status()}`);
}
return (await res.json()) as SecurityStatus;
}
export async function maybeCompleteSetupWizard(page: Page) {
const security = await getSecurityStatus(page);
if (security.hasAuthentication !== false) {
return;
}
if (!E2E_CREDENTIALS.bootstrapToken) {
throw new Error(
'Pulse requires first-run setup but PULSE_E2E_BOOTSTRAP_TOKEN is not set (or is empty)',
);
}
await page.goto('/');
const wizard = page.getByRole('main', { name: 'Pulse Setup Wizard' });
await expect(wizard).toBeVisible();
await page.getByPlaceholder('Paste your bootstrap token').fill(E2E_CREDENTIALS.bootstrapToken);
await page.getByRole('button', { name: /continue/i }).click();
await expect(wizard.getByText('Secure Your Dashboard')).toBeVisible();
await wizard.getByRole('button', { name: /custom password/i }).click();
await wizard.locator('input[type="text"]').first().fill(E2E_CREDENTIALS.username);
await wizard.locator('input[type="password"]').nth(0).fill(E2E_CREDENTIALS.password);
await wizard.locator('input[type="password"]').nth(1).fill(E2E_CREDENTIALS.password);
await wizard.getByRole('button', { name: /create account/i }).click();
await expect(wizard.getByText(/security configured/i)).toBeVisible();
await wizard.getByRole('button', { name: /go to dashboard|skip for now/i }).click();
await page.waitForLoadState('domcontentloaded');
}
/**
* Login as admin user
*/
@@ -26,6 +99,48 @@ export async function loginAsAdmin(page: Page) {
await page.waitForURL(/\/(dashboard|nodes|proxmox)/);
}
export async function login(page: Page, credentials = E2E_CREDENTIALS) {
await page.goto('/');
await page.waitForSelector('input[name="username"]', { state: 'visible' });
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)/);
}
export async function ensureAuthenticated(page: Page) {
await waitForPulseReady(page);
await maybeCompleteSetupWizard(page);
await login(page);
await expect(page).toHaveURL(/\/(proxmox|dashboard|nodes|hosts|docker)/);
}
export async function logout(page: Page) {
const logoutButton = page.locator('button[aria-label="Logout"]').first();
await expect(logoutButton).toBeVisible();
await logoutButton.click();
await expect(page.locator('input[name="username"]')).toBeVisible();
}
export async function setMockMode(page: Page, enabled: boolean) {
const res = await page.request.post('/api/system/mock-mode', {
data: { enabled },
headers: { 'Content-Type': 'application/json' },
});
if (!res.ok()) {
throw new Error(`Failed to update mock mode: ${res.status()} ${await res.text()}`);
}
return (await res.json()) as { enabled: boolean };
}
export async function getMockMode(page: Page) {
const res = await page.request.get('/api/system/mock-mode');
if (!res.ok()) {
throw new Error(`Failed to read mock mode: ${res.status()} ${await res.text()}`);
}
return (await res.json()) as { enabled: boolean };
}
/**
* Navigate to settings page
*/